food-market/src/food-market.api/Controllers/Pos/PosController.cs
nns 606b81b4d9 feat(pos-api): привязка чеков кассы к смене/точке + возвраты + версия
- PosSaleDto: CashShiftId/RetailPointId/IsReturn/ReferenceSaleId (nullable,
  обратносовместимо с v1). PosController проставляет их в RetailSale, резолвит
  склад по точке продаж, проверяет что смена открыта. Без этого чеки кассы не
  попадали в Z-отчёт/материализацию денег смены (MoneyService агрегирует продажи
  по CashShiftId).
- Возврат покупателю (IsReturn): движение +остаток (CustomerReturn), без
  pre-flight на остаток; наличные к выдаче, учитывается в смене.
- GET /api/pos/v1/version (anon): версия + ссылки на сборки из конфигурации Pos
  (автообновление кассы + ссылка в админке).
- Интеграционные тесты: продажа в смене попадает в expected cash при закрытии;
  возврат возвращает остаток (9/9 PosSyncTests зелёные, Testcontainers).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 04:58:05 +05:00

463 lines
23 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/// <summary>API синхронизации с оффлайн-кассами (food-market.pos, WPF/Windows).
///
/// Эндпоинты:
/// • <c>GET /api/pos/v1/sync?since=ISO8601&amp;storeId=…</c> — выгрузка изменений
/// с указанной reference time. Возвращает товары, цены, остатки на момент
/// ответа, контрагентов и список архивированных товаров.
/// • <c>POST /api/pos/v1/sales</c> — приём батча продаж от кассы.
/// <see cref="PosSaleBatchDto.IdempotencyKey"/> + per-sale ClientSaleId —
/// двойная идемпотентность: повтор того же батча возвращает тот же
/// результат без дублей в БД.
///
/// Multi-tenant: запросы идут под обычным OpenIddict JWT с claim
/// <c>org_id</c>. Дополнительно POS привязан к одному магазину через
/// <c>storeId</c> query/body — контроллер валидирует, что store
/// принадлежит организации.
///
/// Версионирование: префикс v1 в URL зафиксирован, добавления новых
/// необязательных полей в DTO не ломают v1; для breaking changes —
/// рядом будет v2.</summary>
[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<PosController> _log;
public PosController(AppDbContext db, ITenantContext tenant, IStockService stock, ILogger<PosController> log)
{
_db = db;
_tenant = tenant;
_stock = stock;
_log = log;
}
/// <summary>Информация о последней версии кассы для автообновления и для
/// ссылки на скачивание в админке. Значения берутся из конфигурации
/// (секция <c>Pos</c>); по умолчанию — пустые/плейсхолдеры. Анонимно —
/// проверка версии не требует входа.</summary>
[HttpGet("version")]
[AllowAnonymous]
public ActionResult<PosVersionDto> 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,
});
[HttpGet("sync")]
public async Task<ActionResult<PosSyncResponse>> 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<string>)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<string>(),
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<Guid>(),
});
}
[HttpPost("sales")]
public async Task<ActionResult<PosSaleBatchResponse>> 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<PosSaleBatchResponse>(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<PosSaleAcceptedDto>();
var failed = new List<PosSaleFailedDto>();
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<PosSaleBatchResponse>(other.ResponseJson)!;
return Ok(prev with { ReplayedFromCache = true });
}
return Ok(response);
}
/// <summary>Создаёт RetailSale + проводит, возвращая (id, number).
/// Pre-flight: проверка остатков. При неудаче кидает <see cref="PosSaleRejectedException"/>,
/// который ловится наверху и формирует Failed-запись батч-ответа.</summary>
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, чтобы зря не дёргать БД.
if (!src.IsReturn)
{
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");
}
}
// Привязка к смене: если указана — проверяем, что она открыта и наша.
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);
}
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<Guid?> ResolveStoreAsync(Guid? requested, CancellationToken ct)
=> ResolveStoreAsync(requested, null, ct);
private async Task<Guid?> 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<string> 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}";
}
}
/// <summary>Внутреннее исключение для отказа в продаже на уровне отдельного
/// чека: контроллер ловит и формирует строку Failed.</summary>
internal sealed class PosSaleRejectedException : Exception
{
public string? Field { get; }
public PosSaleRejectedException(string message, string? field) : base(message)
=> Field = field;
}