- Сервер: GET /api/pos/v1/sales/by-number/{number} — чек-основание с позициями
и доступным к возврату (продано − уже возвращено, по QtyReturned). POS-путь
возврата теперь валидирует over-return (нельзя вернуть больше остатка) и
инкрементит QtyReturned на исходных строках (как RetailSalesController).
- Контракт: PosReturnLookupDto/PosReturnLineDto; IServerApi.ReturnLookupAsync.
- Касса: экран возврата — найти чек по номеру/скану QR, степпер количества по
каждой позиции (0..доступно, кнопка «Всё»), сумма к выдаче, способ (нал/карта
как в исходном чеке); CheckoutService.CommitReturnAsync пишет возврат в офлайн-
очередь + печать + выгрузка.
- Тесты: integration — lookup by-number, возврат 2 из 3, over-return в Failed,
довозврат 1, QtyReturned/остатки корректны; 404 на неизвестный номер. 11/11 PosSyncTests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
208 lines
13 KiB
C#
208 lines
13 KiB
C#
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; }
|
||
/// <summary>Явный признак весового товара (Packaging==2) — для мобильного/кассы,
|
||
/// чтобы не завязываться на числовой код упаковки.</summary>
|
||
public bool IsWeighed { 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; }
|
||
|
||
// --- Привязка к смене/точке (добавлено, обратносовместимо с v1: nullable).
|
||
/// <summary>Точка продаж, к которой привязана касса. Если задана — сервер
|
||
/// проставит её в чек и возьмёт склад точки по умолчанию.</summary>
|
||
public Guid? RetailPointId { get; init; }
|
||
/// <summary>Открытая смена кассира. Нужна, чтобы чек попал в Z-отчёт и в
|
||
/// материализацию денег смены (MoneyService агрегирует продажи по CashShiftId).</summary>
|
||
public Guid? CashShiftId { get; init; }
|
||
/// <summary>true — это возврат покупателю (движение +остаток, наличные к выдаче).</summary>
|
||
public bool IsReturn { get; init; }
|
||
/// <summary>Чек-основание для возврата по чеку (если возврат по номеру/QR).</summary>
|
||
public Guid? ReferenceSaleId { 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>
|
||
/// <summary>Информация о последней версии кассы (автообновление + ссылка в админке).</summary>
|
||
public record PosVersionDto
|
||
{
|
||
public required string Version { get; init; }
|
||
public string? WindowsUrl { get; init; }
|
||
public string? LinuxUrl { get; init; }
|
||
public string? Notes { get; init; }
|
||
public bool Mandatory { get; init; }
|
||
}
|
||
|
||
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; }
|
||
}
|
||
|
||
/// <summary>Чек-основание для возврата по номеру/QR: шапка + позиции с
|
||
/// количеством, доступным к возврату (продано − уже возвращено).</summary>
|
||
public record PosReturnLookupDto
|
||
{
|
||
public required Guid SaleId { get; init; }
|
||
public required string Number { get; init; }
|
||
public required DateTime Date { get; init; }
|
||
public required int Payment { get; init; } // PaymentMethod исходного чека
|
||
public required IReadOnlyList<PosReturnLineDto> Lines { get; init; }
|
||
}
|
||
|
||
public record PosReturnLineDto
|
||
{
|
||
public required Guid ProductId { get; init; }
|
||
public required string Name { get; init; }
|
||
public required string UnitCode { get; init; }
|
||
public required decimal UnitPrice { get; init; }
|
||
public required decimal SoldQty { get; init; }
|
||
public required decimal ReturnedQty { get; init; }
|
||
public required decimal ReturnableQty { get; init; } // SoldQty − ReturnedQty
|
||
}
|