food-market/src/food-market.shared/Pos/V1/SyncDtos.cs
nns 01b47cc0c8 feat(pos): возврат по чеку — экран выбора позиций + защита от over-return
- Сервер: 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>
2026-10-08 09:16:48 +05:00

208 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}