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; } /// Информация о последней версии кассы для автообновления и для /// ссылки на скачивание в админке. Значения берутся из конфигурации /// (секция 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, }); /// Чек-основание для возврата: поиск проведённого (не-возвратного) /// чека по номеру/QR + позиции с количеством, доступным к возврату /// (продано − уже возвращено). Для экрана возврата на кассе. [HttpGet("sales/by-number/{number}")] public async Task> SaleByNumber(string number, CancellationToken ct) { var n = (number ?? "").Trim(); if (n.Length == 0) return NotFound(); var sale = await _db.RetailSales.AsNoTracking() .Where(s => s.Number == n && !s.IsReturn && s.Status == RetailSaleStatus.Posted) .Select(s => new { s.Id, s.Number, s.Date, s.Payment }) .FirstOrDefaultAsync(ct); if (sale is null) return NotFound(new { error = "Чек не найден или не проведён.", field = "number" }); var lines = await (from l in _db.RetailSaleLines.AsNoTracking() join p in _db.Products.AsNoTracking() on l.ProductId equals p.Id join u in _db.UnitsOfMeasure.AsNoTracking() on p.UnitOfMeasureId equals u.Id where l.RetailSaleId == sale.Id orderby l.SortOrder select new PosReturnLineDto { ProductId = l.ProductId, Name = p.Name, UnitCode = u.Code, UnitPrice = l.UnitPrice, SoldQty = l.Quantity, ReturnedQty = l.QtyReturned, ReturnableQty = l.Quantity - l.QtyReturned, }).ToListAsync(ct); return Ok(new PosReturnLookupDto { SaleId = sale.Id, Number = sale.Number, Date = sale.Date, Payment = (int)sale.Payment, Lines = lines, }); } [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, IsWeighed = x.p.Packaging == Domain.Catalog.Packaging.Weight, VatPercent = x.p.Vat, VatEnabled = x.p.VatEnabled, IsMarked = x.p.IsMarked, // Product не имеет IsArchived в текущей доменной модели; всегда false. // Когда добавим архив — заполняется здесь, без правки контракта. IsArchived = false, IsFavorite = x.p.IsFavorite, FavoriteOrder = x.p.FavoriteOrder, 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."); // Склад: если у продаж указана точка продаж — берём её склад; иначе 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" }); // 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"); // Продажа в минус РАЗРЕШЕНА (решение владельца 08.10) — pre-flight по остатку // убран; минус отражается предупреждениями (NegativeStockAlert). Для возврата // по чеку оставляем защиту от over-return (нельзя вернуть больше, чем продано). if (src.IsReturn && src.ReferenceSaleId is Guid refIdCheck) { // Возврат по чеку: нельзя вернуть больше, чем продано минус уже // возвращено (QtyReturned на исходных строках). Защита от over-return. var refLines = await _db.RetailSaleLines.AsNoTracking() .Where(l => l.RetailSaleId == refIdCheck).ToListAsync(ct); if (refLines.Count == 0) throw new PosSaleRejectedException("Исходный чек не найден.", "referenceSaleId"); var remainingByProduct = refLines.GroupBy(l => l.ProductId) .ToDictionary(g => g.Key, g => g.Sum(x => x.Quantity) - g.Sum(x => x.QtyReturned)); foreach (var g in src.Lines.GroupBy(l => l.ProductId)) { if (!remainingByProduct.TryGetValue(g.Key, out var remaining)) throw new PosSaleRejectedException($"Товар {g.Key} не из исходного чека.", "productId"); if (g.Sum(x => x.Quantity) > remaining) throw new PosSaleRejectedException( $"Нельзя вернуть больше, чем доступно (к возврату {remaining}).", "quantity"); } } // Привязка к смене: если указана — проверяем, что она открыта и наша. 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); 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, RetailPointId = src.RetailPointId, CashShiftId = src.CashShiftId, IsReturn = src.IsReturn, ReferenceSaleId = src.ReferenceSaleId, 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, 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: src.IsReturn ? l.Quantity : -l.Quantity, Type: movementType, DocumentType: documentType, DocumentId: sale.Id, DocumentNumber: sale.Number, UnitCost: l.UnitPrice, OccurredAt: src.OccurredAt), ct); } // Возврат по чеку: инкрементим QtyReturned на исходных строках (защита // от повторного возврата той же позиции). Насыщаем по строкам продукта. if (src.IsReturn && src.ReferenceSaleId is Guid refIdInc) { var refLines = await _db.RetailSaleLines .Where(l => l.RetailSaleId == refIdInc).ToListAsync(ct); var takenByProduct = src.Lines.GroupBy(x => x.ProductId) .ToDictionary(g => g.Key, g => g.Sum(x => x.Quantity)); foreach (var rl in refLines.OrderBy(l => l.SortOrder)) { if (!takenByProduct.TryGetValue(rl.ProductId, out var taken) || taken <= 0) continue; var take = Math.Min(taken, rl.Quantity - rl.QtyReturned); if (take <= 0) continue; rl.QtyReturned += take; takenByProduct[rl.ProductId] = taken - take; } } 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 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); 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; }