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,
+ },
+ },
+ };
+}