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:
parent
d5d185cba3
commit
e022db30aa
160
src/food-market.shared/Pos/V1/SyncDtos.cs
Normal file
160
src/food-market.shared/Pos/V1/SyncDtos.cs
Normal 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; }
|
||||||
|
}
|
||||||
82
tests/food-market.UnitTests/PosContractsTests.cs
Normal file
82
tests/food-market.UnitTests/PosContractsTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue