From 606b81b4d97ad272c905ad97d872001ddbbc10cc Mon Sep 17 00:00:00 2001 From: nns Date: Thu, 8 Oct 2026 04:58:05 +0500 Subject: [PATCH] =?UTF-8?q?feat(pos-api):=20=D0=BF=D1=80=D0=B8=D0=B2=D1=8F?= =?UTF-8?q?=D0=B7=D0=BA=D0=B0=20=D1=87=D0=B5=D0=BA=D0=BE=D0=B2=20=D0=BA?= =?UTF-8?q?=D0=B0=D1=81=D1=81=D1=8B=20=D0=BA=20=D1=81=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=B5/=D1=82=D0=BE=D1=87=D0=BA=D0=B5=20+=20=D0=B2=D0=BE=D0=B7?= =?UTF-8?q?=D0=B2=D1=80=D0=B0=D1=82=D1=8B=20+=20=D0=B2=D0=B5=D1=80=D1=81?= =?UTF-8?q?=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PosSaleDto: CashShiftId/RetailPointId/IsReturn/ReferenceSaleId (nullable, обратносовместимо с v1). PosController проставляет их в RetailSale, резолвит склад по точке продаж, проверяет что смена открыта. Без этого чеки кассы не попадали в Z-отчёт/материализацию денег смены (MoneyService агрегирует продажи по CashShiftId). - Возврат покупателю (IsReturn): движение +остаток (CustomerReturn), без pre-flight на остаток; наличные к выдаче, учитывается в смене. - GET /api/pos/v1/version (anon): версия + ссылки на сборки из конфигурации Pos (автообновление кассы + ссылка в админке). - Интеграционные тесты: продажа в смене попадает в expected cash при закрытии; возврат возвращает остаток (9/9 PosSyncTests зелёные, Testcontainers). Co-Authored-By: Claude Opus 5.5 --- .../Controllers/Pos/PosController.cs | 85 ++++++++++++++----- src/food-market.api/appsettings.json | 7 ++ src/food-market.shared/Pos/V1/SyncDtos.cs | 22 +++++ .../PosSyncTests.cs | 84 ++++++++++++++++++ 4 files changed, 179 insertions(+), 19 deletions(-) diff --git a/src/food-market.api/Controllers/Pos/PosController.cs b/src/food-market.api/Controllers/Pos/PosController.cs index 744843e..e8f4d56 100644 --- a/src/food-market.api/Controllers/Pos/PosController.cs +++ b/src/food-market.api/Controllers/Pos/PosController.cs @@ -48,6 +48,22 @@ public PosController(AppDbContext db, ITenantContext tenant, IStockService stock _log = log; } + /// Информация о последней версии кассы для автообновления и для + /// ссылки на скачивание в админке. Значения берутся из конфигурации + /// (секция Pos); по умолчанию — пустые/плейсхолдеры. Анонимно — + /// проверка версии не требует входа. + [HttpGet("version")] + [AllowAnonymous] + public ActionResult Version([FromServices] IConfiguration cfg) + => Ok(new PosVersionDto + { + Version = cfg["Pos:LatestVersion"] ?? "0.0.0", + WindowsUrl = cfg["Pos:DownloadWindows"], + LinuxUrl = cfg["Pos:DownloadLinux"], + Notes = cfg["Pos:ReleaseNotes"], + Mandatory = bool.TryParse(cfg["Pos:Mandatory"], out var m) && m, + }); + [HttpGet("sync")] public async Task> Sync( [FromQuery] DateTime? since, @@ -167,7 +183,9 @@ public PosController(AppDbContext db, ITenantContext tenant, IStockService stock return BadRequest(new { error = "Идемпотентный ключ не указан.", field = "idempotencyKey" }); var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant."); - var resolvedStore = await ResolveStoreAsync(storeId, ct); + // Склад: если у продаж указана точка продаж — берём её склад; иначе storeId/главный. + var pointFromSales = batch.Sales.Select(s => s.RetailPointId).FirstOrDefault(p => p is not null); + var resolvedStore = await ResolveStoreAsync(storeId, pointFromSales, ct); if (resolvedStore is null) return BadRequest(new { error = "Магазин не найден или не принадлежит организации.", field = "storeId" }); @@ -275,20 +293,32 @@ public PosController(AppDbContext db, ITenantContext tenant, IStockService stock if (src.Lines.Count == 0) throw new PosSaleRejectedException("Пустой чек.", "lines"); - // Pre-flight на остатки. Сделано до создания RetailSale чтобы зря не - // дёргать БД при пустой полке. - var byProduct = src.Lines.GroupBy(l => l.ProductId) - .Select(g => new { ProductId = g.Key, Qty = g.Sum(x => x.Quantity) }).ToList(); - var productIds = byProduct.Select(x => x.ProductId).ToList(); - var stocks = await _db.Stocks - .Where(s => s.StoreId == storeId && productIds.Contains(s.ProductId)) - .ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct); - foreach (var x in byProduct) + // Возврат покупателю добавляет остаток, поэтому pre-flight не нужен; для + // продажи проверяем остаток до создания RetailSale, чтобы зря не дёргать БД. + if (!src.IsReturn) { - stocks.TryGetValue(x.ProductId, out var avail); - if (avail < x.Qty) - throw new PosSaleRejectedException( - $"Недостаточный остаток для товара {x.ProductId}.", "productId"); + var byProduct = src.Lines.GroupBy(l => l.ProductId) + .Select(g => new { ProductId = g.Key, Qty = g.Sum(x => x.Quantity) }).ToList(); + var productIds = byProduct.Select(x => x.ProductId).ToList(); + var stocks = await _db.Stocks + .Where(s => s.StoreId == storeId && productIds.Contains(s.ProductId)) + .ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct); + foreach (var x in byProduct) + { + stocks.TryGetValue(x.ProductId, out var avail); + if (avail < x.Qty) + throw new PosSaleRejectedException( + $"Недостаточный остаток для товара {x.ProductId}.", "productId"); + } + } + + // Привязка к смене: если указана — проверяем, что она открыта и наша. + if (src.CashShiftId is Guid shiftId) + { + var shiftOk = await _db.CashShifts.AsNoTracking() + .AnyAsync(s => s.Id == shiftId && s.Status == Domain.Money.CashShiftStatus.Open, ct); + if (!shiftOk) + throw new PosSaleRejectedException("Смена не найдена или закрыта.", "cashShiftId"); } var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct); @@ -309,6 +339,10 @@ public PosController(AppDbContext db, ITenantContext tenant, IStockService stock CurrencyId = currencyId, CustomerId = src.CustomerId, CashierUserId = src.CashierUserId, + RetailPointId = src.RetailPointId, + CashShiftId = src.CashShiftId, + IsReturn = src.IsReturn, + ReferenceSaleId = src.ReferenceSaleId, Payment = (PaymentMethod)src.Payment, PaidCash = R(src.PaidCash), PaidCard = R(src.PaidCard), @@ -342,15 +376,18 @@ public PosController(AppDbContext db, ITenantContext tenant, IStockService stock _db.RetailSales.Add(sale); - // Проведение: -Quantity StockMovement RetailSale. + // Проведение: продажа списывает (-Quantity, RetailSale), возврат + // покупателя возвращает товар на склад (+Quantity, CustomerReturn). + var movementType = src.IsReturn ? MovementType.CustomerReturn : MovementType.RetailSale; + var documentType = src.IsReturn ? "retail-return" : "retail-sale"; foreach (var l in src.Lines) { await _stock.ApplyMovementAsync(new StockMovementDraft( ProductId: l.ProductId, StoreId: storeId, - Quantity: -l.Quantity, - Type: MovementType.RetailSale, - DocumentType: "retail-sale", + Quantity: src.IsReturn ? l.Quantity : -l.Quantity, + Type: movementType, + DocumentType: documentType, DocumentId: sale.Id, DocumentNumber: sale.Number, UnitCost: l.UnitPrice, @@ -374,13 +411,23 @@ public PosController(AppDbContext db, ITenantContext tenant, IStockService stock return (sale.Id, sale.Number); } - private async Task ResolveStoreAsync(Guid? requested, CancellationToken ct) + private Task ResolveStoreAsync(Guid? requested, CancellationToken ct) + => ResolveStoreAsync(requested, null, ct); + + private async Task ResolveStoreAsync(Guid? requested, Guid? retailPointId, CancellationToken ct) { if (requested is { } id) { var ok = await _db.Stores.AsNoTracking().AnyAsync(s => s.Id == id, ct); return ok ? id : null; } + // Если касса привязана к точке продаж — берём её склад. + if (retailPointId is { } rp) + { + var store = await _db.RetailPoints.AsNoTracking() + .Where(r => r.Id == rp).Select(r => (Guid?)r.StoreId).FirstOrDefaultAsync(ct); + if (store is not null) return store; + } // Дефолт — главный склад организации. var main = await _db.Stores.AsNoTracking() .Where(s => s.IsMain).Select(s => (Guid?)s.Id).FirstOrDefaultAsync(ct); diff --git a/src/food-market.api/appsettings.json b/src/food-market.api/appsettings.json index cf24a07..cc442ee 100644 --- a/src/food-market.api/appsettings.json +++ b/src/food-market.api/appsettings.json @@ -35,5 +35,12 @@ "https://admin.food-market.kz" ] }, + "Pos": { + "LatestVersion": "0.0.0", + "DownloadWindows": "", + "DownloadLinux": "", + "ReleaseNotes": "", + "Mandatory": false + }, "AllowedHosts": "*" } diff --git a/src/food-market.shared/Pos/V1/SyncDtos.cs b/src/food-market.shared/Pos/V1/SyncDtos.cs index 9fd9811..5c9c4c2 100644 --- a/src/food-market.shared/Pos/V1/SyncDtos.cs +++ b/src/food-market.shared/Pos/V1/SyncDtos.cs @@ -106,6 +106,18 @@ public record PosSaleDto public required decimal PaidCard { get; init; } public required IReadOnlyList Lines { get; init; } public string? Notes { get; init; } + + // --- Привязка к смене/точке (добавлено, обратносовместимо с v1: nullable). + /// Точка продаж, к которой привязана касса. Если задана — сервер + /// проставит её в чек и возьмёт склад точки по умолчанию. + public Guid? RetailPointId { get; init; } + /// Открытая смена кассира. Нужна, чтобы чек попал в Z-отчёт и в + /// материализацию денег смены (MoneyService агрегирует продажи по CashShiftId). + public Guid? CashShiftId { get; init; } + /// true — это возврат покупателю (движение +остаток, наличные к выдаче). + public bool IsReturn { get; init; } + /// Чек-основание для возврата по чеку (если возврат по номеру/QR). + public Guid? ReferenceSaleId { get; init; } } public record PosSaleLineDto @@ -135,6 +147,16 @@ public record PosSaleBatchDto /// — продажи, которые отклонены (mismatch цен, отсутствие /// товара, нехватка остатка для проведения). POS должен показать кассиру и /// решить (перепробить с новой ценой, списать как акт расхождения). +/// Информация о последней версии кассы (автообновление + ссылка в админке). +public record PosVersionDto +{ + public required string Version { get; init; } + public string? WindowsUrl { get; init; } + public string? LinuxUrl { get; init; } + public string? Notes { get; init; } + public bool Mandatory { get; init; } +} + public record PosSaleBatchResponse { public required Guid IdempotencyKey { get; init; } diff --git a/tests/food-market.IntegrationTests/PosSyncTests.cs b/tests/food-market.IntegrationTests/PosSyncTests.cs index 0941d1d..20dc519 100644 --- a/tests/food-market.IntegrationTests/PosSyncTests.cs +++ b/tests/food-market.IntegrationTests/PosSyncTests.cs @@ -220,6 +220,90 @@ public async Task Tenant_isolation_pos_sync() syncB!.Products.Should().NotContain(p => p.Id.ToString() == pA); } + /// Чек с кассы, пробитый в открытую смену, должен попасть в Z-отчёт + /// смены (MoneyService агрегирует продажи по CashShiftId) — именно эту + /// привязку касса теперь шлёт в RetailPointId/CashShiftId. + [Fact] + public async Task Pos_sale_in_shift_counts_in_close_expected_cash() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"pos-shift-{Guid.NewGuid():N}"); + var refs = await api.LoadRefsAsync(); + var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 500m, RandomBarcode()); + await SeedStockAsync(api, refs, p1, 10m); + + var retailPoint = await api.CreateRetailPointAsync(refs.StoreId, $"Точка-{Guid.NewGuid():N}"); + var cash = await api.CreateMoneyAccountAsync("Касса точки", 1, refs.CurrencyId, refs.StoreId, 0m); + var shiftId = await OpenShiftAsync(api, retailPoint, cash, openingFloat: 5_000m); + + // Касса пробивает чек на 2×500 наличными, привязывая к смене и точке. + var batch = new PosSaleBatchDto + { + IdempotencyKey = Guid.NewGuid(), + Sales = new[] { MakeSale(p1, qty: 2m, price: 500m) with + { + RetailPointId = Guid.Parse(retailPoint), CashShiftId = Guid.Parse(shiftId), + } }, + }; + using var post = await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch); + post.IsSuccessStatusCode.Should().BeTrue($"{(int)post.StatusCode}: {await post.Content.ReadAsStringAsync()}"); + (await post.Content.ReadFromJsonAsync())!.Accepted.Should().HaveCount(1); + + // Закрываем смену: expected = 5 000 (размен) + 1 000 (продажа нал) = 6 000. + using var close = await api.Http.PostAsJsonAsync($"/api/money/shifts/{shiftId}/close", new { actualCash = 6_000m, notes = (string?)null }); + close.EnsureSuccessStatusCode(); + var body = await close.Content.ReadFromJsonAsync(); + body.GetProperty("expectedCash").GetDecimal().Should().Be(6_000m, "продажа кассы учтена в смене"); + body.GetProperty("difference").GetDecimal().Should().Be(0m); + } + + /// Возврат покупателю (IsReturn) возвращает товар на склад (+остаток), + /// а не списывает его. + [Fact] + public async Task Pos_return_adds_stock_back() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"pos-ret-{Guid.NewGuid():N}"); + var refs = await api.LoadRefsAsync(); + var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 300m, RandomBarcode()); + await SeedStockAsync(api, refs, p1, 10m); + + var batch = new PosSaleBatchDto + { + IdempotencyKey = Guid.NewGuid(), + Sales = new[] { MakeSale(p1, qty: 3m, price: 300m) with { IsReturn = true } }, + }; + using var post = await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch); + post.IsSuccessStatusCode.Should().BeTrue($"{(int)post.StatusCode}: {await post.Content.ReadAsStringAsync()}"); + (await post.Content.ReadFromJsonAsync())!.Accepted.Should().HaveCount(1); + + // Был 10, возврат 3 → 13 (возврат добавил остаток). + (await api.StockOfAsync(refs.StoreId, p1)).Should().Be(13m); + } + + private static async Task SeedStockAsync(ApiActor api, ApiActor.Refs refs, string productId, decimal qty) + { + var enter = await api.Http.PostAsJsonAsync("/api/inventory/enters", new + { + date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId, + notes = "seed", lines = new[] { new { productId, quantity = qty, unitCost = 50m } }, + }); + enter.EnsureSuccessStatusCode(); + var eid = (await enter.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + (await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode(); + } + + private static async Task OpenShiftAsync(ApiActor api, string retailPointId, string cashAccountId, decimal openingFloat) + { + var resp = await api.Http.PostAsJsonAsync("/api/money/shifts/open", new + { + retailPointId, cashAccountId, acquiringAccountId = (string?)null, openingFloat, + }); + var body = await resp.Content.ReadAsStringAsync(); + if (!resp.IsSuccessStatusCode) throw new InvalidOperationException($"OpenShift failed {(int)resp.StatusCode}: {body}"); + return JsonDocument.Parse(body).RootElement.GetProperty("id").GetString()!; + } + private static PosSaleDto MakeSale(string productId, decimal qty, decimal price) => new() { ClientSaleId = Guid.NewGuid(),