From ff4391d19f9f0d10f65eaa2d107e8453f9980593 Mon Sep 17 00:00:00 2001 From: nns Date: Fri, 9 Oct 2026 05:58:31 +0500 Subject: [PATCH] =?UTF-8?q?feat(money):=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D0=B5=20=D1=81=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=BF=D0=B5=D1=80=D0=B2=D0=BE=D0=B9=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B4=D0=B0=D0=B6=D0=B5=D0=B9=20+=20Z-=D0=BE=D1=82?= =?UTF-8?q?=D1=87=D1=91=D1=82=20=D0=BF=D0=BE=20=D1=82=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D1=80=D0=B0=D0=BC=20+=2024=D1=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit П.1: продажа на точке без открытой смены — смена автооткрывается (подбор Cash-счёта точки/орг., размен 0, можно внести позже), как МойСклад; ручное открытие остаётся. Длительность смены и отметка overLimit24h в /current и Z-отчёте (мягкое правило: предупреждение + отметка, не жёсткий лимит). П.2: Z-отчёт /shifts/{id}/report теперь с разбивкой byProduct (продано кол-во/сумма, возвращено кол-во/сумма). Тест переписан: продажа без смены → автооткрытие + проводится; /current open=true; Z-отчёт содержит byProduct с верными кол-вом/суммой. Co-Authored-By: Claude Opus 4.8 --- .../Controllers/Money/CashShiftsController.cs | 34 +++++++++++++- .../Sales/RetailSalesController.cs | 47 +++++++++++++++---- .../MoneyModuleTests.cs | 34 +++++++++----- 3 files changed, 94 insertions(+), 21 deletions(-) diff --git a/src/food-market.api/Controllers/Money/CashShiftsController.cs b/src/food-market.api/Controllers/Money/CashShiftsController.cs index f7091c1..7eb1e9b 100644 --- a/src/food-market.api/Controllers/Money/CashShiftsController.cs +++ b/src/food-market.api/Controllers/Money/CashShiftsController.cs @@ -154,7 +154,10 @@ public async Task> Current([FromQuery] Guid retailPointId, var userId = CurrentUserId(); var s = await _db.CashShifts.AsNoTracking() .FirstOrDefaultAsync(x => x.RetailPointId == retailPointId && x.Status == CashShiftStatus.Open, ct); - return s is null ? Ok(new { open = false }) : Ok(new { open = true, shift = s, isMine = s.CashierUserId == userId }); + if (s is null) return Ok(new { open = false }); + var hours = (DateTime.UtcNow - s.OpenedAt).TotalHours; + return Ok(new { open = true, shift = s, isMine = s.CashierUserId == userId, + durationHours = Math.Round(hours, 1), overLimit24h = hours > 24 }); } [HttpPost("open"), RequiresPermission("CashShiftOperate")] @@ -207,15 +210,44 @@ public async Task> Report(Guid id, CancellationToken ct) .Where(o => o.CashShiftId == id) .OrderBy(o => o.At) .ToListAsync(ct); + + // Разбивка по товарам (как Z-отчёт МойСклада): по каждому товару смены — + // продано (кол-во/выручка) и возвращено (кол-во/сумма). Берём проведённые + // чеки смены; продажи и возвраты разделяем по IsReturn. + var lines = await (from l in _db.RetailSaleLines.AsNoTracking() + join rs in _db.RetailSales.AsNoTracking() on l.RetailSaleId equals rs.Id + join p in _db.Products.AsNoTracking() on l.ProductId equals p.Id + where rs.CashShiftId == id && rs.Status == Domain.Sales.RetailSaleStatus.Posted + select new { p.Name, l.ProductId, l.Quantity, l.LineTotal, rs.IsReturn }) + .ToListAsync(ct); + var byProduct = lines + .GroupBy(x => new { x.ProductId, x.Name }) + .Select(g => new + { + productId = g.Key.ProductId, + name = g.Key.Name, + soldQty = g.Where(x => !x.IsReturn).Sum(x => x.Quantity), + soldAmount = g.Where(x => !x.IsReturn).Sum(x => x.LineTotal), + returnedQty = g.Where(x => x.IsReturn).Sum(x => x.Quantity), + returnedAmount = g.Where(x => x.IsReturn).Sum(x => x.LineTotal), + }) + .OrderByDescending(x => x.soldAmount) + .ToList(); + return Ok(new { number = s.Number, openedAt = s.OpenedAt, closedAt = s.ClosedAt, openingFloat = s.OpeningFloat, + // Длительность смены и отметка превышения 24ч (мягкое правило: у МойСклада + // жёсткий лимит, у нас — предупреждение + отметка в отчёте). + durationHours = Math.Round(((s.ClosedAt ?? DateTime.UtcNow) - s.OpenedAt).TotalHours, 1), + overLimit24h = ((s.ClosedAt ?? DateTime.UtcNow) - s.OpenedAt).TotalHours > 24, sales = new { cash = s.SalesCash, card = s.SalesCard }, returns = new { cash = s.ReturnsCash, card = s.ReturnsCard }, drawer = new { @in = s.DrawerIn, @out = s.DrawerOut, items = ops }, + byProduct, expectedCash = s.ExpectedCash, actualCash = s.ActualCash, cashDifference = s.CashDifference, diff --git a/src/food-market.api/Controllers/Sales/RetailSalesController.cs b/src/food-market.api/Controllers/Sales/RetailSalesController.cs index 6170444..e1cf5d2 100644 --- a/src/food-market.api/Controllers/Sales/RetailSalesController.cs +++ b/src/food-market.api/Controllers/Sales/RetailSalesController.cs @@ -22,15 +22,19 @@ public class RetailSalesController : ControllerBase private readonly foodmarket.Api.Realtime.INotificationsPublisher _notify; private readonly foodmarket.Application.Common.Fiscal.IFiscalProviderFactory _fiscal; + private readonly foodmarket.Application.Money.IMoneyService _money; + public RetailSalesController(AppDbContext db, IStockService stock, ILogger log, foodmarket.Api.Realtime.INotificationsPublisher notify, - foodmarket.Application.Common.Fiscal.IFiscalProviderFactory fiscal) + foodmarket.Application.Common.Fiscal.IFiscalProviderFactory fiscal, + foodmarket.Application.Money.IMoneyService money) { _db = db; _stock = stock; _log = log; _notify = notify; _fiscal = fiscal; + _money = money; } public record RetailSaleListRow( @@ -102,6 +106,13 @@ public record RetailSaleInput( /// основания; само изменение способа — только с правом администратора. string? RefundReason = null); + private Guid CurrentUserId() + { + var sub = User.FindFirst("sub")?.Value + ?? User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + return Guid.TryParse(sub, out var g) ? g : Guid.Empty; + } + private static string PaymentLabel(PaymentMethod m) => m switch { PaymentMethod.Cash => "наличные", @@ -626,14 +637,34 @@ private async Task PostCoreAsync(Guid id, CancellationToken ct) .FirstOrDefaultAsync(ct); if (openShift == Guid.Empty) { - return Conflict(new - { - error = "На этой точке продаж не открыта кассовая смена. Откройте смену и попробуйте снова.", - code = "cash-shift-required", - }); + // АВТООТКРЫТИЕ смены первой операцией (как МойСклад): подбираем + // денежный счёт кассы точки (Cash на складе точки, иначе первый + // Cash орг.) и открываем смену с нулевым разменом (размен можно + // внести позже внесением). Если счёта нет — просим открыть вручную. + var rp = await _db.RetailPoints.AsNoTracking() + .Where(x => x.Id == rpId).Select(x => new { x.StoreId }).FirstOrDefaultAsync(ct); + var cashAccountId = await _db.MoneyAccounts + .Where(a => a.Kind == foodmarket.Domain.Money.MoneyAccountKind.Cash && a.IsActive + && (a.StoreId == rp!.StoreId || a.StoreId == null)) + .OrderByDescending(a => a.StoreId == rp!.StoreId) + .Select(a => (Guid?)a.Id).FirstOrDefaultAsync(ct); + if (cashAccountId is not { } cashAcc) + return Conflict(new + { + error = "На этой точке продаж не открыта кассовая смена, и не найден денежный счёт для автооткрытия. Откройте смену вручную.", + code = "cash-shift-required", + }); + var opened = await _money.OpenShiftAsync(rpId, cashAcc, null, 0m, CurrentUserId(), ct); + if (opened.Status != foodmarket.Application.Money.MoneyServiceStatus.Ok || opened.ShiftId is not { } newShift) + return Conflict(new { error = opened.Message ?? "Не удалось открыть смену автоматически.", code = "cash-shift-required" }); + _log.LogInformation("Смена автооткрыта первой продажей: shift={ShiftId} point={PointId}", newShift, rpId); + sale.CashShiftId = newShift; + } + else + { + // Привязываем чек к открытой смене. + sale.CashShiftId = openShift; } - // Привязываем чек к открытой смене. - sale.CashShiftId = openShift; } // Для возврата — отдельная ветка обработки (см. PostReturnAsync ниже). diff --git a/tests/food-market.IntegrationTests/MoneyModuleTests.cs b/tests/food-market.IntegrationTests/MoneyModuleTests.cs index efe9269..89cc2de 100644 --- a/tests/food-market.IntegrationTests/MoneyModuleTests.cs +++ b/tests/food-market.IntegrationTests/MoneyModuleTests.cs @@ -156,8 +156,10 @@ public async Task Supplier_debt_report_reflects_supplies_minus_payments() } [Fact] - public async Task Sale_without_open_shift_is_rejected_then_allowed_after_open() + public async Task Sale_without_shift_auto_opens_shift_and_report_has_breakdown() { + // Паритет п.1: смена автооткрывается первой продажей (как МойСклад), если + // есть денежный счёт. Z-отчёт (п.2) содержит разбивку по товарам. var api = new ApiActor(_factory.CreateClient()); await api.SignupAndLoginAsync($"shift-{Guid.NewGuid():N}"); var refs = await api.LoadRefsAsync(); @@ -167,18 +169,26 @@ public async Task Sale_without_open_shift_is_rejected_then_allowed_after_open() var retailPoint = await api.CreateRetailPointAsync(refs.StoreId, "Точка 1"); var cash = await api.CreateMoneyAccountAsync("Касса точки", Cash, refs.CurrencyId, refs.StoreId, 0m); - // Продажа на точке БЕЗ смены → 409 cash-shift-required. - var saleId = await CreateCashSaleDraftAsync(api, refs, retailPoint, product, qty: 1m, price: 500m); - using (var noShift = await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/post", new { })) - { - ((int)noShift.StatusCode).Should().Be(409); - (await noShift.Content.ReadAsStringAsync()).Should().Contain("cash-shift-required"); - } + // Продажа на точке БЕЗ открытой смены → смена автооткрывается, чек проводится. + var saleId = await CreateCashSaleDraftAsync(api, refs, retailPoint, product, qty: 2m, price: 500m); + using (var auto = await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/post", new { })) + auto.IsSuccessStatusCode.Should().BeTrue($"смена должна автооткрыться: {(int)auto.StatusCode} {await auto.Content.ReadAsStringAsync()}"); - // Открываем смену и повторяем — проходит. - await OpenShiftAsync(api, retailPoint, cash, openingFloat: 0m); - using var ok = await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/post", new { }); - ok.IsSuccessStatusCode.Should().BeTrue($"после открытия смены продажа должна провестись: {(int)ok.StatusCode} {await ok.Content.ReadAsStringAsync()}"); + // На точке теперь есть открытая смена. + var current = await api.GetJsonAsync($"/api/money/shifts/current?retailPointId={retailPoint}"); + current.GetProperty("open").GetBoolean().Should().BeTrue(); + current.GetProperty("overLimit24h").GetBoolean().Should().BeFalse(); + var shiftId = current.GetProperty("shift").GetProperty("id").GetString(); + + // Z-отчёт содержит разбивку по товарам (п.2). + var report = await api.GetJsonAsync($"/api/money/shifts/{shiftId}/report"); + report.TryGetProperty("byProduct", out var bp).Should().BeTrue(); + var rows = bp.EnumerateArray().ToList(); + rows.Should().ContainSingle(r => r.GetProperty("productId").GetString() == product); + var row = rows.First(r => r.GetProperty("productId").GetString() == product); + row.GetProperty("soldQty").GetDecimal().Should().Be(2m); + row.GetProperty("soldAmount").GetDecimal().Should().Be(1000m); + report.GetProperty("overLimit24h").GetBoolean().Should().BeFalse(); } [Fact]