feat(pos-shared): контракты POS v1 в food-market.shared (P1-12a)

Доступные DTO для оффлайн-кассы (food-market.pos):
• ProductSyncDto/PriceSyncDto/StockSyncDto/CounterpartySyncDto — выгрузка
  изменений для последующей пробивки;
• PosSyncResponse — конверт всего sync-ответа с ServerTime (reference
  time против клок-дрейфа кассы) и DeletedProductIds;
• PosSaleDto/PosSaleLineDto/PosSaleBatchDto — батч продаж от кассы.
  PosSaleBatchDto несёт IdempotencyKey + каждая продажа имеет ClientSaleId
  (двойная идемпотентность);
• PosSaleBatchResponse — Accepted/Failed + ReplayedFromCache флаг.

Версионирование на уровне namespace  — для v2 будет рядом без
breaking changes. Required-поля везде where applicable: компилятор обяжет
заполнить новые обязательные поля при появлении v1.X добавок.

Тесты: 3 unit на сериализационный round-trip (компиляция падёт при удалении
любого поля контракта — это и есть тест public API).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-28 12:03:04 +05:00
parent d5d185cba3
commit e022db30aa
3 changed files with 243 additions and 0 deletions

View file

@ -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».
// ──────────────────────────────────────────────────────────────────────────────
/// <summary>Полная карточка товара для оффлайн-кассы. Включает только то, что
/// нужно при пробивке: ID для FK, идентификаторы (имя/артикул/штрихкоды),
/// единица измерения, упаковка (штучная/весовая/разливная), НДС и текущая
/// розничная цена. Изображения и история — не передаём (вес sync-payload'а
/// важен на медленных каналах KZ-периферии).</summary>
public record ProductSyncDto
{
public required Guid Id { get; init; }
public required string Name { get; init; }
public string? Article { get; init; }
/// <summary>Все штрихкоды товара (для поиска по сканеру). Первый — primary.</summary>
public required IReadOnlyList<string> Barcodes { get; init; }
/// <summary>Код единицы измерения (шт., кг, л, м…), не FK — раскрывается на UI.</summary>
public required string UnitCode { get; init; }
/// <summary>Тип упаковки: 1=штучный, 2=весовой, 3=разливной (см. Domain.Packaging).</summary>
public required int Packaging { get; init; }
public required decimal VatPercent { get; init; }
public required bool VatEnabled { get; init; }
public required bool IsMarked { get; init; }
/// <summary>true — товар архивный, на кассе не продаётся, оставлен для отчётов.</summary>
public required bool IsArchived { get; init; }
public DateTime UpdatedAt { get; init; }
}
/// <summary>Розничная цена товара. Один товар может иметь несколько цен
/// (разные типы цен — обычная, для VIP, со скидкой); POS обычно использует
/// «системный/главный» PriceType, его помечает <c>IsSystem=true</c>.</summary>
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; }
}
/// <summary>Текущий остаток на момент запроса. Не агрегируется на POS, просто
/// показывается кассиру для подсказки/предупреждения «осталось 2 шт». Никаких
/// reserve/available — это серверный учёт; на кассе только on-hand.</summary>
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; }
}
/// <summary>Контрагент (только покупатели — поставщики POS не нужны). Используется
/// для лояльности/привязки чека к клиенту: ввёл телефон → нашёлся контакт.</summary>
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; }
}
/// <summary>Ответ <c>GET /api/pos/sync</c>: все изменения после <c>since</c>.
/// Клиент кеширует <see cref="ServerTime"/> и шлёт её обратно в следующем
/// since — серверная reference time, чтобы клок-дрейф кассы не пропустил
/// записи. <see cref="DeletedProductIds"/> — id товаров, которые с last sync
/// были архивированы (на POS нужно их пометить, не удалять историю продаж).</summary>
public record PosSyncResponse
{
public required DateTime ServerTime { get; init; }
public required IReadOnlyList<ProductSyncDto> Products { get; init; }
public required IReadOnlyList<PriceSyncDto> Prices { get; init; }
public required IReadOnlyList<StockSyncDto> Stocks { get; init; }
public required IReadOnlyList<CounterpartySyncDto> Counterparties { get; init; }
public required IReadOnlyList<Guid> DeletedProductIds { get; init; }
}
/// <summary>Одна продажа из оффлайн-кассы. POS пробил чек локально, теперь
/// шлёт на сервер. <c>ClientSaleId</c> — Guid, который POS присвоил при
/// пробитии; служит частью idempotency-ключа (если батч придёт повторно из-за
/// сетевой ошибки, сервер вернёт прежний результат без двойного списания).
/// Сумма к оплате уже посчитана POS'ом — сервер пересчитывает по своим
/// ценам и сверяет; mismatch → строка попадает в `Failed` ответа.</summary>
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<PosSaleLineDto> 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; }
}
/// <summary>Батч продаж POS → сервер. <see cref="IdempotencyKey"/> — Guid,
/// POS должен использовать СТАБИЛЬНЫЙ ключ на тот же набор продаж: повтор
/// батча с тем же ключом гарантирует один и тот же результат без дублей
/// в БД. (Внутри батча уникальность отдельных продаж обеспечивается
/// <c>PosSaleDto.ClientSaleId</c> — он тоже идемпотентен.)</summary>
public record PosSaleBatchDto
{
public required Guid IdempotencyKey { get; init; }
public required IReadOnlyList<PosSaleDto> Sales { get; init; }
}
/// <summary>Ответ на загрузку батча.
/// <see cref="Accepted"/> — продажи, которые сервер реально провёл (Status=Posted)
/// или нашёл уже проведённый по ClientSaleId (идемпотентность). С каждой —
/// идентификатор серверной <c>RetailSale</c>.
/// <see cref="Failed"/> — продажи, которые отклонены (mismatch цен, отсутствие
/// товара, нехватка остатка для проведения). POS должен показать кассиру и
/// решить (перепробить с новой ценой, списать как акт расхождения).</summary>
public record PosSaleBatchResponse
{
public required Guid IdempotencyKey { get; init; }
public required IReadOnlyList<PosSaleAcceptedDto> Accepted { get; init; }
public required IReadOnlyList<PosSaleFailedDto> Failed { get; init; }
/// <summary>true — этот ответ построен по кешу прежнего батча с тем же
/// ключом, реальной обработки не было.</summary>
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; }
}

View file

@ -0,0 +1,82 @@
using System.Text.Json;
using FluentAssertions;
using foodmarket.Shared.Pos.V1;
using Xunit;
namespace foodmarket.UnitTests;
/// <summary>Контракт POS — public API: ломая его, мы ломаем все Windows-кассы
/// в поле. Тестируем сериализацию round-trip + наличие required-полей.
/// При попытке убрать поле или поменять тип компиляция этих тестов упадёт.</summary>
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<ProductSyncDto>(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<PosSaleBatchDto>(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<ProductSyncDto>(),
Prices = Array.Empty<PriceSyncDto>(),
Stocks = Array.Empty<StockSyncDto>(),
Counterparties = Array.Empty<CounterpartySyncDto>(),
DeletedProductIds = Array.Empty<Guid>(),
};
var json = JsonSerializer.Serialize(resp);
var back = JsonSerializer.Deserialize<PosSyncResponse>(json)!;
back.Should().BeEquivalentTo(resp);
}
}

View file

@ -15,6 +15,7 @@
<ProjectReference Include="..\..\src\food-market.application\food-market.application.csproj" /> <ProjectReference Include="..\..\src\food-market.application\food-market.application.csproj" />
<ProjectReference Include="..\..\src\food-market.infrastructure\food-market.infrastructure.csproj" /> <ProjectReference Include="..\..\src\food-market.infrastructure\food-market.infrastructure.csproj" />
<ProjectReference Include="..\..\src\food-market.api\food-market.api.csproj" /> <ProjectReference Include="..\..\src\food-market.api\food-market.api.csproj" />
<ProjectReference Include="..\..\src\food-market.shared\food-market.shared.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>