- 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>
463 lines
23 KiB
C#
463 lines
23 KiB
C#
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&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;
|
||
}
|