diff --git a/src/food-market.shared/Pos/V1/SyncDtos.cs b/src/food-market.shared/Pos/V1/SyncDtos.cs new file mode 100644 index 0000000..9fd9811 --- /dev/null +++ b/src/food-market.shared/Pos/V1/SyncDtos.cs @@ -0,0 +1,160 @@ +namespace foodmarket.Shared.Pos.V1; + +// ────────────────────────────────────────────────────────────────────────────── +// Контракты синхронизации сервер ↔ POS (Windows-касса food-market.pos). +// Namespace `Pos.V1` — версионирование на уровне неймспейса: v2 рядом, без +// breaking changes для v1. POS-клиент закрепляет за собой конкретную версию +// через URL `/api/pos/v1/...` (плюс заголовок `X-Pos-Api-Version: 1` для дабл-чека). +// +// Все DTO — record'ы с required-полями: компилятор обяжет POS-разработчика +// заполнить новые поля, если v1.X добавит обязательные. Опциональные поля — +// nullable; default-значения избегаем чтобы baseline на проде совпадал с +// баг-репортами «у меня поля null». +// ────────────────────────────────────────────────────────────────────────────── + +/// Полная карточка товара для оффлайн-кассы. Включает только то, что +/// нужно при пробивке: ID для FK, идентификаторы (имя/артикул/штрихкоды), +/// единица измерения, упаковка (штучная/весовая/разливная), НДС и текущая +/// розничная цена. Изображения и история — не передаём (вес sync-payload'а +/// важен на медленных каналах KZ-периферии). +public record ProductSyncDto +{ + public required Guid Id { get; init; } + public required string Name { get; init; } + public string? Article { get; init; } + /// Все штрихкоды товара (для поиска по сканеру). Первый — primary. + public required IReadOnlyList Barcodes { get; init; } + /// Код единицы измерения (шт., кг, л, м…), не FK — раскрывается на UI. + public required string UnitCode { get; init; } + /// Тип упаковки: 1=штучный, 2=весовой, 3=разливной (см. Domain.Packaging). + public required int Packaging { get; init; } + public required decimal VatPercent { get; init; } + public required bool VatEnabled { get; init; } + public required bool IsMarked { get; init; } + /// true — товар архивный, на кассе не продаётся, оставлен для отчётов. + public required bool IsArchived { get; init; } + public DateTime UpdatedAt { get; init; } +} + +/// Розничная цена товара. Один товар может иметь несколько цен +/// (разные типы цен — обычная, для VIP, со скидкой); POS обычно использует +/// «системный/главный» PriceType, его помечает IsSystem=true. +public record PriceSyncDto +{ + public required Guid ProductId { get; init; } + public required Guid PriceTypeId { get; init; } + public required string PriceTypeName { get; init; } + public required bool IsSystem { get; init; } + public required decimal Amount { get; init; } + public required string CurrencyCode { get; init; } + public DateTime UpdatedAt { get; init; } +} + +/// Текущий остаток на момент запроса. Не агрегируется на POS, просто +/// показывается кассиру для подсказки/предупреждения «осталось 2 шт». Никаких +/// reserve/available — это серверный учёт; на кассе только on-hand. +public record StockSyncDto +{ + public required Guid ProductId { get; init; } + public required Guid StoreId { get; init; } + public required decimal Quantity { get; init; } + public DateTime AsOf { get; init; } +} + +/// Контрагент (только покупатели — поставщики POS не нужны). Используется +/// для лояльности/привязки чека к клиенту: ввёл телефон → нашёлся контакт. +public record CounterpartySyncDto +{ + public required Guid Id { get; init; } + public required string Name { get; init; } + public string? Phone { get; init; } + public string? Email { get; init; } + public string? Bin { get; init; } + public string? Iin { get; init; } + public DateTime UpdatedAt { get; init; } +} + +/// Ответ GET /api/pos/sync: все изменения после since. +/// Клиент кеширует и шлёт её обратно в следующем +/// since — серверная reference time, чтобы клок-дрейф кассы не пропустил +/// записи. — id товаров, которые с last sync +/// были архивированы (на POS нужно их пометить, не удалять историю продаж). +public record PosSyncResponse +{ + public required DateTime ServerTime { get; init; } + public required IReadOnlyList Products { get; init; } + public required IReadOnlyList Prices { get; init; } + public required IReadOnlyList Stocks { get; init; } + public required IReadOnlyList Counterparties { get; init; } + public required IReadOnlyList DeletedProductIds { get; init; } +} + +/// Одна продажа из оффлайн-кассы. POS пробил чек локально, теперь +/// шлёт на сервер. ClientSaleId — Guid, который POS присвоил при +/// пробитии; служит частью idempotency-ключа (если батч придёт повторно из-за +/// сетевой ошибки, сервер вернёт прежний результат без двойного списания). +/// Сумма к оплате уже посчитана POS'ом — сервер пересчитывает по своим +/// ценам и сверяет; mismatch → строка попадает в `Failed` ответа. +public record PosSaleDto +{ + public required Guid ClientSaleId { get; init; } + public required DateTime OccurredAt { get; init; } + public Guid? CustomerId { get; init; } + public Guid? CashierUserId { get; init; } + public required int Payment { get; init; } // PaymentMethod enum int (0=Cash,1=Card,2=BankTransfer,3=Bonus,99=Mixed) + public required decimal PaidCash { get; init; } + public required decimal PaidCard { get; init; } + public required IReadOnlyList Lines { get; init; } + public string? Notes { get; init; } +} + +public record PosSaleLineDto +{ + public required Guid ProductId { get; init; } + public required decimal Quantity { get; init; } + public required decimal UnitPrice { get; init; } + public required decimal Discount { get; init; } + public decimal VatPercent { get; init; } +} + +/// Батч продаж POS → сервер. — Guid, +/// POS должен использовать СТАБИЛЬНЫЙ ключ на тот же набор продаж: повтор +/// батча с тем же ключом гарантирует один и тот же результат без дублей +/// в БД. (Внутри батча уникальность отдельных продаж обеспечивается +/// PosSaleDto.ClientSaleId — он тоже идемпотентен.) +public record PosSaleBatchDto +{ + public required Guid IdempotencyKey { get; init; } + public required IReadOnlyList Sales { get; init; } +} + +/// Ответ на загрузку батча. +/// — продажи, которые сервер реально провёл (Status=Posted) +/// или нашёл уже проведённый по ClientSaleId (идемпотентность). С каждой — +/// идентификатор серверной RetailSale. +/// — продажи, которые отклонены (mismatch цен, отсутствие +/// товара, нехватка остатка для проведения). POS должен показать кассиру и +/// решить (перепробить с новой ценой, списать как акт расхождения). +public record PosSaleBatchResponse +{ + public required Guid IdempotencyKey { get; init; } + public required IReadOnlyList Accepted { get; init; } + public required IReadOnlyList Failed { get; init; } + /// true — этот ответ построен по кешу прежнего батча с тем же + /// ключом, реальной обработки не было. + public required bool ReplayedFromCache { get; init; } +} + +public record PosSaleAcceptedDto +{ + public required Guid ClientSaleId { get; init; } + public required Guid ServerSaleId { get; init; } + public required string ServerSaleNumber { get; init; } +} + +public record PosSaleFailedDto +{ + public required Guid ClientSaleId { get; init; } + public required string Error { get; init; } + public string? Field { get; init; } +} diff --git a/tests/food-market.UnitTests/PosContractsTests.cs b/tests/food-market.UnitTests/PosContractsTests.cs new file mode 100644 index 0000000..316692d --- /dev/null +++ b/tests/food-market.UnitTests/PosContractsTests.cs @@ -0,0 +1,82 @@ +using System.Text.Json; +using FluentAssertions; +using foodmarket.Shared.Pos.V1; +using Xunit; + +namespace foodmarket.UnitTests; + +/// Контракт POS — public API: ломая его, мы ломаем все Windows-кассы +/// в поле. Тестируем сериализацию round-trip + наличие required-полей. +/// При попытке убрать поле или поменять тип компиляция этих тестов упадёт. +public class PosContractsTests +{ + [Fact] + public void Product_sync_dto_round_trips() + { + var dto = new ProductSyncDto + { + Id = Guid.NewGuid(), + Name = "Молоко 1л", + Article = "M-001", + Barcodes = new[] { "1234567890123", "2345678901234" }, + UnitCode = "л", + Packaging = 1, + VatPercent = 12m, + VatEnabled = true, + IsMarked = false, + IsArchived = false, + UpdatedAt = DateTime.UtcNow, + }; + var json = JsonSerializer.Serialize(dto); + var back = JsonSerializer.Deserialize(json)!; + back.Should().BeEquivalentTo(dto); + } + + [Fact] + public void Batch_dto_carries_idempotency_key() + { + var batch = new PosSaleBatchDto + { + IdempotencyKey = Guid.NewGuid(), + Sales = new[] + { + new PosSaleDto + { + ClientSaleId = Guid.NewGuid(), + OccurredAt = DateTime.UtcNow, + Payment = 0, PaidCash = 1000m, PaidCard = 0m, + Lines = new[] + { + new PosSaleLineDto + { + ProductId = Guid.NewGuid(), Quantity = 1m, + UnitPrice = 1000m, Discount = 0m, VatPercent = 12m, + }, + }, + }, + }, + }; + var json = JsonSerializer.Serialize(batch); + var back = JsonSerializer.Deserialize(json)!; + back.IdempotencyKey.Should().Be(batch.IdempotencyKey); + back.Sales.Should().HaveCount(1); + back.Sales[0].Lines.Should().HaveCount(1); + } + + [Fact] + public void Sync_response_collects_all_groups() + { + var resp = new PosSyncResponse + { + ServerTime = DateTime.UtcNow, + Products = Array.Empty(), + Prices = Array.Empty(), + Stocks = Array.Empty(), + Counterparties = Array.Empty(), + DeletedProductIds = Array.Empty(), + }; + var json = JsonSerializer.Serialize(resp); + var back = JsonSerializer.Deserialize(json)!; + back.Should().BeEquivalentTo(resp); + } +} diff --git a/tests/food-market.UnitTests/food-market.UnitTests.csproj b/tests/food-market.UnitTests/food-market.UnitTests.csproj index 0152f53..bb3e72b 100644 --- a/tests/food-market.UnitTests/food-market.UnitTests.csproj +++ b/tests/food-market.UnitTests/food-market.UnitTests.csproj @@ -15,6 +15,7 @@ +