From 640c8d9c22dedb7a584a5c95d458b41649fc576e Mon Sep 17 00:00:00 2001 From: nns Date: Thu, 28 May 2026 12:10:17 +0500 Subject: [PATCH] =?UTF-8?q?feat(pos-api):=20GET=20/sync=20=D0=B8=20POST=20?= =?UTF-8?q?/sales=20=D1=81=20=D0=B4=D0=B2=D0=BE=D0=B9=D0=BD=D0=BE=D0=B9=20?= =?UTF-8?q?=D0=B8=D0=B4=D0=B5=D0=BC=D0=BF=D0=BE=D1=82=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=BD=D0=BE=D1=81=D1=82=D1=8C=D1=8E=20(P1-12b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Endpoints: - GET /api/pos/v1/sync?since=ISO&storeId=Guid - выгрузка изменений (Products / Prices / Stocks / Counterparties) после reference time; Stocks - всегда полный снимок на момент ответа (POS нужен актуальный остаток на полке). - POST /api/pos/v1/sales - батч продаж с idempotency. Двойная идемпотентность: 1. Batch-level: PosBatchAck (новая таблица, unique idx по OrgId+Key) - повтор того же батча возвращает кешированный ответ. При параллельном race ловим 23505 на уникальном индексе и тоже возвращаем кеш. 2. Per-sale: ClientSaleId записывается в RetailSale.Notes как prefix "pos:GUID32". Перед созданием продажи проверяем что такой маркер ещё не встречался - если есть, возвращаем существующую продажу. Это спасает и при разных batch-ключах с пересекающимися ClientSaleId. Pre-flight: проверка остатка ДО создания черновика - sale, которая не влезает в полку, попадает в Failed, остальные в батче проводятся. Domain: PosBatchAck (TenantEntity), миграция Phase7a_PosBatchAcks (jsonb для ResponseJson, unique idx). Контракты v1 из food-market.shared. Тесты: 7 интеграционных - полная sync, дельта по since, POST батч списывает stock, replay того же батча no-duplicates, ClientSaleId через разные batch-keys тоже no-duplicates, недостача попадает в Failed, tenant-изоляция. Co-Authored-By: Claude Opus 4.7 --- .../Controllers/Pos/PosController.cs | 413 ++++++++++++++++++ src/food-market.domain/Sales/PosBatchAck.cs | 26 ++ .../Persistence/AppDbContext.cs | 1 + .../Configurations/SalesConfigurations.cs | 12 + .../20260528100000_Phase7a_PosBatchAcks.cs | 43 ++ .../PosSyncTests.cs | 237 ++++++++++ 6 files changed, 732 insertions(+) create mode 100644 src/food-market.api/Controllers/Pos/PosController.cs create mode 100644 src/food-market.domain/Sales/PosBatchAck.cs create mode 100644 src/food-market.infrastructure/Persistence/Migrations/20260528100000_Phase7a_PosBatchAcks.cs create mode 100644 tests/food-market.IntegrationTests/PosSyncTests.cs diff --git a/src/food-market.api/Controllers/Pos/PosController.cs b/src/food-market.api/Controllers/Pos/PosController.cs new file mode 100644 index 0000000..a5acf9c --- /dev/null +++ b/src/food-market.api/Controllers/Pos/PosController.cs @@ -0,0 +1,413 @@ +using System.Text.Json; +using foodmarket.Application.Common.Tenancy; +using foodmarket.Application.Inventory; +using foodmarket.Domain.Inventory; +using foodmarket.Domain.Sales; +using foodmarket.Infrastructure.Persistence; +using foodmarket.Shared.Pos.V1; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.Pos; + +/// API синхронизации с оффлайн-кассами (food-market.pos, WPF/Windows). +/// +/// Эндпоинты: +/// • GET /api/pos/v1/sync?since=ISO8601&storeId=… — выгрузка изменений +/// с указанной reference time. Возвращает товары, цены, остатки на момент +/// ответа, контрагентов и список архивированных товаров. +/// • POST /api/pos/v1/sales — приём батча продаж от кассы. +/// + per-sale ClientSaleId — +/// двойная идемпотентность: повтор того же батча возвращает тот же +/// результат без дублей в БД. +/// +/// Multi-tenant: запросы идут под обычным OpenIddict JWT с claim +/// org_id. Дополнительно POS привязан к одному магазину через +/// storeId query/body — контроллер валидирует, что store +/// принадлежит организации. +/// +/// Версионирование: префикс v1 в URL зафиксирован, добавления новых +/// необязательных полей в DTO не ломают v1; для breaking changes — +/// рядом будет v2. +[ApiController] +[Authorize] +[Route("api/pos/v1")] +public class PosController : ControllerBase +{ + private readonly AppDbContext _db; + private readonly ITenantContext _tenant; + private readonly IStockService _stock; + private readonly ILogger _log; + + public PosController(AppDbContext db, ITenantContext tenant, IStockService stock, ILogger log) + { + _db = db; + _tenant = tenant; + _stock = stock; + _log = log; + } + + [HttpGet("sync")] + public async Task> Sync( + [FromQuery] DateTime? since, + [FromQuery] Guid? storeId, + CancellationToken ct) + { + var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant."); + var resolvedStore = await ResolveStoreAsync(storeId, ct); + if (resolvedStore is null) + return BadRequest(new { error = "Магазин не найден или не принадлежит организации.", field = "storeId" }); + + var threshold = since ?? DateTime.MinValue.ToUniversalTime(); + var serverTime = DateTime.UtcNow; + + // Товары: дельта по UpdatedAt/CreatedAt. У записи Entity.UpdatedAt + // может быть null если её ни разу не правили после CreatedAt; берём max. + var products = await (from p in _db.Products.AsNoTracking() + join u in _db.UnitsOfMeasure.AsNoTracking() on p.UnitOfMeasureId equals u.Id + let stamp = p.UpdatedAt ?? p.CreatedAt + where stamp > threshold + select new { p, u, stamp }) + .ToListAsync(ct); + + var productIds = products.Select(x => x.p.Id).ToList(); + var barcodes = await _db.ProductBarcodes.AsNoTracking() + .Where(b => productIds.Contains(b.ProductId)) + .OrderByDescending(b => b.IsPrimary) + .Select(b => new { b.ProductId, b.Code }) + .ToListAsync(ct); + var barcodesByProduct = barcodes.GroupBy(b => b.ProductId) + .ToDictionary(g => g.Key, g => (IReadOnlyList)g.Select(x => x.Code).ToList()); + + var productDtos = products.Select(x => new ProductSyncDto + { + Id = x.p.Id, + Name = x.p.Name, + Article = x.p.Article, + Barcodes = barcodesByProduct.TryGetValue(x.p.Id, out var b) ? b : Array.Empty(), + UnitCode = x.u.Code, + Packaging = (int)x.p.Packaging, + VatPercent = x.p.Vat, + VatEnabled = x.p.VatEnabled, + IsMarked = x.p.IsMarked, + // Product не имеет IsArchived в текущей доменной модели; всегда false. + // Когда добавим архив — заполняется здесь, без правки контракта. + IsArchived = false, + UpdatedAt = x.stamp, + }).ToList(); + + // Цены: всё что изменилось после since. PriceType подтягиваем чтобы + // POS мог выбрать «системный». + var prices = await (from pp in _db.ProductPrices.AsNoTracking() + join pt in _db.PriceTypes.AsNoTracking() on pp.PriceTypeId equals pt.Id + join cu in _db.Currencies.AsNoTracking() on pp.CurrencyId equals cu.Id + let stamp = pp.UpdatedAt ?? pp.CreatedAt + where stamp > threshold + select new PriceSyncDto + { + ProductId = pp.ProductId, + PriceTypeId = pt.Id, + PriceTypeName = pt.Name, + IsSystem = pt.IsSystem, + Amount = pp.Amount, + CurrencyCode = cu.Code, + UpdatedAt = stamp, + }).ToListAsync(ct); + + // Остатки: всегда полный снимок на момент ответа. since-фильтр не + // применяется к остаткам — кассе всегда нужен актуальный stock. + var stocks = await (from s in _db.Stocks.AsNoTracking() + where s.StoreId == resolvedStore.Value && s.Quantity != 0m + select new StockSyncDto + { + ProductId = s.ProductId, + StoreId = s.StoreId, + Quantity = s.Quantity, + AsOf = serverTime, + }).ToListAsync(ct); + + // Контрагенты-покупатели. Поставщиков на POS не шлём. + var counterparties = await (from c in _db.Counterparties.AsNoTracking() + let stamp = c.UpdatedAt ?? c.CreatedAt + where stamp > threshold + select new CounterpartySyncDto + { + Id = c.Id, + Name = c.Name, + Phone = c.Phone, + Email = c.Email, + Bin = c.Bin, + Iin = c.Iin, + UpdatedAt = stamp, + }).ToListAsync(ct); + + // DeletedProductIds: в текущей модели Product hard-delete'ятся; для + // soft-delete заведём отдельный feed; пока пустой массив. + return Ok(new PosSyncResponse + { + ServerTime = serverTime, + Products = productDtos, + Prices = prices, + Stocks = stocks, + Counterparties = counterparties, + DeletedProductIds = Array.Empty(), + }); + } + + [HttpPost("sales")] + public async Task> AcceptSales( + [FromBody] PosSaleBatchDto batch, + [FromQuery] Guid? storeId, + CancellationToken ct) + { + if (batch is null || batch.Sales is null) + return BadRequest(new { error = "Пустое тело батча.", field = "batch" }); + if (batch.IdempotencyKey == Guid.Empty) + return BadRequest(new { error = "Идемпотентный ключ не указан.", field = "idempotencyKey" }); + + var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant."); + var resolvedStore = await ResolveStoreAsync(storeId, ct); + if (resolvedStore is null) + return BadRequest(new { error = "Магазин не найден или не принадлежит организации.", field = "storeId" }); + + // 1) Идемпотентность на уровне батча. Сначала проверяем cache. + var existing = await _db.PosBatchAcks.AsNoTracking() + .FirstOrDefaultAsync(a => a.IdempotencyKey == batch.IdempotencyKey, ct); + if (existing is not null) + { + var prev = JsonSerializer.Deserialize(existing.ResponseJson)!; + return Ok(prev with { ReplayedFromCache = true }); + } + + // 2) Идемпотентность на уровне отдельной продажи: ищем уже созданные + // RetailSale, у которых Notes начинается с маркера ClientSaleId. + // (Альтернативой было бы добавить отдельную колонку ClientSaleId; для + // минимизации миграций используем Notes-префикс — он уже nullable.) + var clientSaleIds = batch.Sales.Select(s => s.ClientSaleId).Distinct().ToList(); + var markers = clientSaleIds.Select(g => $"pos:{g:N}").ToList(); + var alreadyByMarker = await _db.RetailSales.AsNoTracking() + .Where(s => s.Notes != null && markers.Contains(s.Notes.Substring(0, Math.Min(36, s.Notes.Length)))) + .Select(s => new { s.Id, s.Number, s.Notes }) + .ToListAsync(ct); + // Возвращает строки где Notes начинается с маркера; восстанавливаем csid. + var alreadyByClient = alreadyByMarker + .Where(x => x.Notes!.StartsWith("pos:")) + .ToDictionary( + x => Guid.ParseExact(x.Notes!.Substring(4, 32), "N"), + x => (Id: x.Id, Number: x.Number)); + + var accepted = new List(); + var failed = new List(); + + foreach (var sale in batch.Sales) + { + if (alreadyByClient.TryGetValue(sale.ClientSaleId, out var prior)) + { + accepted.Add(new PosSaleAcceptedDto + { + ClientSaleId = sale.ClientSaleId, + ServerSaleId = prior.Id, + ServerSaleNumber = prior.Number, + }); + continue; + } + + try + { + var (serverSale, serverNumber) = await CreateAndPostSaleAsync( + sale, resolvedStore.Value, ct); + accepted.Add(new PosSaleAcceptedDto + { + ClientSaleId = sale.ClientSaleId, + ServerSaleId = serverSale, + ServerSaleNumber = serverNumber, + }); + } + catch (PosSaleRejectedException ex) + { + failed.Add(new PosSaleFailedDto + { + ClientSaleId = sale.ClientSaleId, + Error = ex.Message, + Field = ex.Field, + }); + } + } + + // 3) Записываем ack под уникальным ключом. Если параллельный POS-запрос + // успел нас обогнать — словим 23505 и вернём его ack. + var response = new PosSaleBatchResponse + { + IdempotencyKey = batch.IdempotencyKey, + Accepted = accepted, + Failed = failed, + ReplayedFromCache = false, + }; + var ack = new PosBatchAck + { + IdempotencyKey = batch.IdempotencyKey, + ResponseJson = JsonSerializer.Serialize(response), + }; + _db.PosBatchAcks.Add(ack); + try + { + await _db.SaveChangesAsync(ct); + } + catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23505") + { + // Конфликт по уникальному индексу — параллельный запрос успел + // сохранить ack. Возвращаем тот ack как replay. + var other = await _db.PosBatchAcks.AsNoTracking() + .FirstAsync(a => a.IdempotencyKey == batch.IdempotencyKey, ct); + var prev = JsonSerializer.Deserialize(other.ResponseJson)!; + return Ok(prev with { ReplayedFromCache = true }); + } + return Ok(response); + } + + /// Создаёт RetailSale + проводит, возвращая (id, number). + /// Pre-flight: проверка остатков. При неудаче кидает , + /// который ловится наверху и формирует Failed-запись батч-ответа. + private async Task<(Guid Id, string Number)> CreateAndPostSaleAsync( + PosSaleDto src, Guid storeId, CancellationToken ct) + { + 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) + { + stocks.TryGetValue(x.ProductId, out var avail); + if (avail < x.Qty) + throw new PosSaleRejectedException( + $"Недостаточный остаток для товара {x.ProductId}.", "productId"); + } + + var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct); + decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero); + + var currencyId = await _db.Currencies.AsNoTracking() + .Where(c => c.Code == "KZT").Select(c => c.Id).FirstOrDefaultAsync(ct); + if (currencyId == Guid.Empty) + throw new PosSaleRejectedException("Не найдена валюта KZT.", "currency"); + + var number = await GenerateNumberAsync(src.OccurredAt, ct); + var sale = new RetailSale + { + Number = number, + Date = src.OccurredAt, + Status = RetailSaleStatus.Draft, + StoreId = storeId, + CurrencyId = currencyId, + CustomerId = src.CustomerId, + CashierUserId = src.CashierUserId, + Payment = (PaymentMethod)src.Payment, + PaidCash = R(src.PaidCash), + PaidCard = R(src.PaidCard), + // Маркер для per-sale идемпотентности (см. AcceptSales). + Notes = $"pos:{src.ClientSaleId:N}" + (string.IsNullOrEmpty(src.Notes) ? "" : "\n" + src.Notes), + }; + var order = 0; + decimal subtotal = 0m, discountTotal = 0m; + foreach (var l in src.Lines) + { + var price = R(l.UnitPrice); + var disc = R(l.Discount); + var lineTotal = l.Quantity * price - disc; + _db.RetailSaleLines.Add(new RetailSaleLine + { + RetailSaleId = sale.Id, + ProductId = l.ProductId, + Quantity = l.Quantity, + UnitPrice = price, + Discount = disc, + LineTotal = lineTotal, + VatPercent = l.VatPercent, + SortOrder = order++, + }); + subtotal += l.Quantity * price; + discountTotal += disc; + } + sale.Subtotal = subtotal; + sale.DiscountTotal = discountTotal; + sale.Total = subtotal - discountTotal; + + _db.RetailSales.Add(sale); + + // Проведение: -Quantity StockMovement RetailSale. + 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", + DocumentId: sale.Id, + DocumentNumber: sale.Number, + UnitCost: l.UnitPrice, + OccurredAt: src.OccurredAt), ct); + } + sale.Status = RetailSaleStatus.Posted; + sale.PostedAt = DateTime.UtcNow; + try + { + await _db.SaveChangesAsync(ct); + } + catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23505") + { + // Уникальный конфликт по (OrgId, Number) — крайне редкий race на + // GenerateNumberAsync; пересоздавать номер сложно без транзакции, + // поэтому отказываем и POS попробует в следующем батче. + throw new PosSaleRejectedException("Конфликт номера чека, повторите.", "number"); + } + return (sale.Id, sale.Number); + } + + private async Task ResolveStoreAsync(Guid? requested, CancellationToken ct) + { + if (requested is { } id) + { + var ok = await _db.Stores.AsNoTracking().AnyAsync(s => s.Id == id, ct); + return ok ? id : null; + } + // Дефолт — главный склад организации. + var main = await _db.Stores.AsNoTracking() + .Where(s => s.IsMain).Select(s => (Guid?)s.Id).FirstOrDefaultAsync(ct); + if (main is not null) return main; + // Иначе — любой активный. + return await _db.Stores.AsNoTracking() + .Where(s => s.IsActive).Select(s => (Guid?)s.Id).FirstOrDefaultAsync(ct); + } + + private async Task GenerateNumberAsync(DateTime date, CancellationToken ct) + { + var prefix = $"ПР-{date.Year}-"; + var lastNumber = await _db.RetailSales + .Where(s => s.Number.StartsWith(prefix)) + .OrderByDescending(s => s.Number) + .Select(s => s.Number) + .FirstOrDefaultAsync(ct); + var seq = 1; + if (lastNumber is not null && int.TryParse(lastNumber[prefix.Length..], out var last)) + seq = last + 1; + return $"{prefix}{seq:D6}"; + } +} + +/// Внутреннее исключение для отказа в продаже на уровне отдельного +/// чека: контроллер ловит и формирует строку Failed. +internal sealed class PosSaleRejectedException : Exception +{ + public string? Field { get; } + public PosSaleRejectedException(string message, string? field) : base(message) + => Field = field; +} diff --git a/src/food-market.domain/Sales/PosBatchAck.cs b/src/food-market.domain/Sales/PosBatchAck.cs new file mode 100644 index 0000000..bbbee27 --- /dev/null +++ b/src/food-market.domain/Sales/PosBatchAck.cs @@ -0,0 +1,26 @@ +using foodmarket.Domain.Common; + +namespace foodmarket.Domain.Sales; + +/// Кеш ответов на батчи продаж от POS-касс для идемпотентности. +/// +/// POS присылает POST /api/pos/sales с IdempotencyKey в теле; +/// при сетевой ошибке POS повторяет тот же батч с тем же ключом, и сервер +/// должен вернуть прежний результат БЕЗ повторного создания продаж в БД. +/// Реализуется через эту таблицу: вставка строки `(OrgId, Key)` через +/// unique-index — выигрыш гонки делает запрос обработчиком, проигравшие +/// читают ResponseJson. +/// +/// OrganizationId наследуется через и +/// query-filter ограничивает выборку — POS чужой орги не может прочитать +/// чужой ack даже с угаданным ключом. Дополнительно ack TTL'ятся фоновым +/// cleanup'ом (Hangfire) — 30 дней. +public class PosBatchAck : TenantEntity +{ + /// Idempotency-ключ, присланный POS. Уникален в рамках организации. + public Guid IdempotencyKey { get; set; } + + /// Сериализованный ответ (PosSaleBatchResponse без поля + /// ReplayedFromCache — это поле выставляется true при репроигрывании). + public string ResponseJson { get; set; } = ""; +} diff --git a/src/food-market.infrastructure/Persistence/AppDbContext.cs b/src/food-market.infrastructure/Persistence/AppDbContext.cs index f31695c..cd20504 100644 --- a/src/food-market.infrastructure/Persistence/AppDbContext.cs +++ b/src/food-market.infrastructure/Persistence/AppDbContext.cs @@ -61,6 +61,7 @@ public AppDbContext(DbContextOptions options, ITenantContext tenan public DbSet RetailSales => Set(); public DbSet RetailSaleLines => Set(); + public DbSet PosBatchAcks => Set(); public DbSet EmployeeRoles => Set(); public DbSet Employees => Set(); diff --git a/src/food-market.infrastructure/Persistence/Configurations/SalesConfigurations.cs b/src/food-market.infrastructure/Persistence/Configurations/SalesConfigurations.cs index 594a504..8f916f0 100644 --- a/src/food-market.infrastructure/Persistence/Configurations/SalesConfigurations.cs +++ b/src/food-market.infrastructure/Persistence/Configurations/SalesConfigurations.cs @@ -47,5 +47,17 @@ public static void ConfigureSales(this ModelBuilder b) e.HasOne(x => x.Product).WithMany().HasForeignKey(x => x.ProductId).OnDelete(DeleteBehavior.Restrict); e.HasIndex(x => new { x.OrganizationId, x.ProductId }); }); + + b.Entity(e => + { + e.ToTable("pos_batch_acks"); + e.Property(x => x.IdempotencyKey).IsRequired(); + e.Property(x => x.ResponseJson).HasColumnType("jsonb").IsRequired(); + // Уникальный индекс — гарантия идемпотентности на уровне БД: + // вторая параллельная попытка вставить тот же ключ упадёт 23505, + // контроллер прочитает уже сохранённый ResponseJson и вернёт его. + e.HasIndex(x => new { x.OrganizationId, x.IdempotencyKey }).IsUnique(); + e.HasIndex(x => x.CreatedAt); // для фонового cleanup'а старых acks + }); } } diff --git a/src/food-market.infrastructure/Persistence/Migrations/20260528100000_Phase7a_PosBatchAcks.cs b/src/food-market.infrastructure/Persistence/Migrations/20260528100000_Phase7a_PosBatchAcks.cs new file mode 100644 index 0000000..f138019 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20260528100000_Phase7a_PosBatchAcks.cs @@ -0,0 +1,43 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Phase7a — pos_batch_acks для идемпотентности /api/pos/sales. + /// + /// Хранит сериализованный ответ по (OrgId, IdempotencyKey). Уникальный + /// индекс — guarantee против двойного создания продаж при ретрае POS'ом. + /// JSONB-колонка ResponseJson: ответ воссоздаётся атомарно из БД, а не + /// перестраивается заново. + [DbContext(typeof(AppDbContext))] + [Migration("20260528100000_Phase7a_PosBatchAcks")] + public partial class Phase7a_PosBatchAcks : Migration + { + protected override void Up(MigrationBuilder b) + { + b.Sql(@" + CREATE TABLE IF NOT EXISTS public.pos_batch_acks ( + ""Id"" uuid PRIMARY KEY, + ""OrganizationId"" uuid NOT NULL, + ""IdempotencyKey"" uuid NOT NULL, + ""ResponseJson"" jsonb NOT NULL, + ""CreatedAt"" timestamp with time zone NOT NULL, + ""UpdatedAt"" timestamp with time zone + ); + + CREATE UNIQUE INDEX IF NOT EXISTS ""IX_pos_batch_acks_OrganizationId_IdempotencyKey"" + ON public.pos_batch_acks (""OrganizationId"", ""IdempotencyKey""); + CREATE INDEX IF NOT EXISTS ""IX_pos_batch_acks_CreatedAt"" + ON public.pos_batch_acks (""CreatedAt""); + "); + } + + protected override void Down(MigrationBuilder b) + { + b.Sql(@"DROP TABLE IF EXISTS public.pos_batch_acks;"); + } + } +} diff --git a/tests/food-market.IntegrationTests/PosSyncTests.cs b/tests/food-market.IntegrationTests/PosSyncTests.cs new file mode 100644 index 0000000..0941d1d --- /dev/null +++ b/tests/food-market.IntegrationTests/PosSyncTests.cs @@ -0,0 +1,237 @@ +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using foodmarket.IntegrationTests.Support; +using foodmarket.Shared.Pos.V1; +using Xunit; + +namespace foodmarket.IntegrationTests; + +[Collection(ApiCollection.Name)] +public class PosSyncTests +{ + private readonly ApiFactory _factory; + public PosSyncTests(ApiFactory factory) => _factory = factory; + private static string RandomBarcode() + => string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10))); + + [Fact] + public async Task Sync_returns_products_prices_stocks() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"pos-sync-{Guid.NewGuid():N}"); + var refs = await api.LoadRefsAsync(); + var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 250m, RandomBarcode()); + + // Подкормим остаток через Enter, чтобы было что синхронизировать. + 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 = p1, quantity = 5m, unitCost = 100m } }, + }); + enter.EnsureSuccessStatusCode(); + var eid = (await enter.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + (await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode(); + + // POS sync без since → полная выгрузка. + using var resp = await api.Http.GetAsync("/api/pos/v1/sync"); + resp.EnsureSuccessStatusCode(); + var sync = await resp.Content.ReadFromJsonAsync(); + sync!.Products.Should().Contain(p => p.Id.ToString() == p1); + sync.Prices.Should().Contain(pr => pr.ProductId.ToString() == p1 && pr.Amount == 250m); + sync.Stocks.Should().Contain(s => s.ProductId.ToString() == p1 && s.Quantity == 5m); + sync.ServerTime.Should().BeBefore(DateTime.UtcNow.AddSeconds(5)); + } + + [Fact] + public async Task Sync_with_since_returns_only_delta() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"pos-delta-{Guid.NewGuid():N}"); + var refs = await api.LoadRefsAsync(); + var pOld = await api.CreateProductAsync(refs, $"OLD-{Guid.NewGuid():N}", 100m, RandomBarcode()); + + // Маркер времени между двумя продуктами. + await Task.Delay(1100); + var marker = DateTime.UtcNow; + await Task.Delay(1100); + + var pNew = await api.CreateProductAsync(refs, $"NEW-{Guid.NewGuid():N}", 200m, RandomBarcode()); + + using var resp = await api.Http.GetAsync($"/api/pos/v1/sync?since={Uri.EscapeDataString(marker.ToString("o"))}"); + resp.EnsureSuccessStatusCode(); + var sync = await resp.Content.ReadFromJsonAsync(); + sync!.Products.Should().Contain(p => p.Id.ToString() == pNew); + sync.Products.Should().NotContain(p => p.Id.ToString() == pOld); + } + + [Fact] + public async Task Post_sales_batch_creates_retail_sales_and_decrements_stock() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"pos-sale-{Guid.NewGuid():N}"); + var refs = await api.LoadRefsAsync(); + var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode()); + + 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 = p1, quantity = 20m, unitCost = 50m } }, + }); + enter.EnsureSuccessStatusCode(); + var eid = (await enter.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + (await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode(); + (await api.StockOfAsync(refs.StoreId, p1)).Should().Be(20m); + + var batch = new PosSaleBatchDto + { + IdempotencyKey = Guid.NewGuid(), + Sales = new[] + { + MakeSale(p1, qty: 2m, price: 100m), + MakeSale(p1, qty: 3m, price: 100m), + MakeSale(p1, qty: 1m, price: 100m), + MakeSale(p1, qty: 4m, price: 100m), + MakeSale(p1, qty: 1m, price: 100m), + }, + }; + using var post = await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch); + post.IsSuccessStatusCode.Should().BeTrue($"POST вернул {(int)post.StatusCode}: {await post.Content.ReadAsStringAsync()}"); + var resp = await post.Content.ReadFromJsonAsync(); + resp!.Accepted.Should().HaveCount(5); + resp.Failed.Should().BeEmpty(); + resp.ReplayedFromCache.Should().BeFalse(); + + // 20 − (2+3+1+4+1) = 9 на складе. + (await api.StockOfAsync(refs.StoreId, p1)).Should().Be(9m); + } + + /// Ключевой тест задачи: повтор того же батча → no duplicates, + /// тот же ответ, остаток не уменьшается дважды. + [Fact] + public async Task Replay_same_batch_returns_cached_response_no_duplicates() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"pos-idem-{Guid.NewGuid():N}"); + var refs = await api.LoadRefsAsync(); + var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode()); + 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 = p1, quantity = 10m, unitCost = 50m } }, + }); + enter.EnsureSuccessStatusCode(); + var eid = (await enter.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + (await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode(); + + var batch = new PosSaleBatchDto + { + IdempotencyKey = Guid.NewGuid(), + Sales = new[] + { + MakeSale(p1, qty: 3m, price: 100m), + MakeSale(p1, qty: 2m, price: 100m), + }, + }; + + using var first = await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch); + first.EnsureSuccessStatusCode(); + var r1 = await first.Content.ReadFromJsonAsync(); + r1!.Accepted.Should().HaveCount(2); + r1.ReplayedFromCache.Should().BeFalse(); + (await api.StockOfAsync(refs.StoreId, p1)).Should().Be(5m); + + using var second = await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch); + second.EnsureSuccessStatusCode(); + var r2 = await second.Content.ReadFromJsonAsync(); + r2!.Accepted.Should().HaveCount(2); + r2.ReplayedFromCache.Should().BeTrue(); + // ServerSaleId должны совпасть — это тот же чек, что и в первом ответе. + r2.Accepted.Select(a => a.ServerSaleId).Should().BeEquivalentTo(r1.Accepted.Select(a => a.ServerSaleId)); + + // Stock не списался дважды. + (await api.StockOfAsync(refs.StoreId, p1)).Should().Be(5m); + } + + /// Per-sale ClientSaleId идемпотентность: если ту же продажу + /// прислали в РАЗНЫХ батчах (с разными idempotency-key'ями), сервер всё + /// равно вернёт ссылку на ранее созданный чек, не создавая дубль. + [Fact] + public async Task ClientSaleId_idempotency_across_batches() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"pos-csid-{Guid.NewGuid():N}"); + var refs = await api.LoadRefsAsync(); + var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode()); + 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 = p1, quantity = 10m, unitCost = 50m } }, + }); + enter.EnsureSuccessStatusCode(); + var eid = (await enter.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + (await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode(); + + var sharedSale = MakeSale(p1, qty: 4m, price: 100m); + var batch1 = new PosSaleBatchDto { IdempotencyKey = Guid.NewGuid(), Sales = new[] { sharedSale } }; + var batch2 = new PosSaleBatchDto { IdempotencyKey = Guid.NewGuid(), Sales = new[] { sharedSale } }; + + var r1 = await (await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch1)).Content.ReadFromJsonAsync(); + var r2 = await (await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch2)).Content.ReadFromJsonAsync(); + r1!.Accepted.Should().HaveCount(1); + r2!.Accepted.Should().HaveCount(1); + r2.Accepted[0].ServerSaleId.Should().Be(r1.Accepted[0].ServerSaleId); + (await api.StockOfAsync(refs.StoreId, p1)).Should().Be(6m, "товар списался 1 раз"); + } + + [Fact] + public async Task Insufficient_stock_goes_to_failed_not_accepted() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"pos-short-{Guid.NewGuid():N}"); + var refs = await api.LoadRefsAsync(); + var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode()); + // Не подкармливаем — остаток 0. + + var batch = new PosSaleBatchDto + { + IdempotencyKey = Guid.NewGuid(), + Sales = new[] { MakeSale(p1, qty: 3m, price: 100m) }, + }; + using var post = await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch); + post.EnsureSuccessStatusCode(); + var resp = await post.Content.ReadFromJsonAsync(); + resp!.Accepted.Should().BeEmpty(); + resp.Failed.Should().HaveCount(1); + resp.Failed[0].Error.Should().Contain("Недостаточный остаток"); + } + + [Fact] + public async Task Tenant_isolation_pos_sync() + { + var a = new ApiActor(_factory.CreateClient()); + var b = new ApiActor(_factory.CreateClient()); + await a.SignupAndLoginAsync($"pos-iso-a-{Guid.NewGuid():N}"); + await b.SignupAndLoginAsync($"pos-iso-b-{Guid.NewGuid():N}"); + var refsA = await a.LoadRefsAsync(); + var pA = await a.CreateProductAsync(refsA, $"PA-{Guid.NewGuid():N}", 100m, RandomBarcode()); + + var syncB = await (await b.Http.GetAsync("/api/pos/v1/sync")).Content.ReadFromJsonAsync(); + syncB!.Products.Should().NotContain(p => p.Id.ToString() == pA); + } + + private static PosSaleDto MakeSale(string productId, decimal qty, decimal price) => new() + { + ClientSaleId = Guid.NewGuid(), + OccurredAt = DateTime.UtcNow, + Payment = 0, PaidCash = qty * price, PaidCard = 0m, + Lines = new[] + { + new PosSaleLineDto + { + ProductId = Guid.Parse(productId), Quantity = qty, + UnitPrice = price, Discount = 0m, VatPercent = 12m, + }, + }, + }; +}