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