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 попробует в следующем батче. foodmarket.Api.Infrastructure.Observability.AppMetrics.IncrementError("retail-sale", "number_conflict"); throw new PosSaleRejectedException("Конфликт номера чека, повторите.", "number"); } foodmarket.Api.Infrastructure.Observability.AppMetrics.IncrementPosted("retail-sale"); 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; }