Реальный сценарий владельца: весов с печатью этикеток нет, штрихкоды весовых —
обычные коды «27xxxxx» в карточке. Исправлены два бага:
- поиск по ШК матчил весовой PLU по АРТИКУЛУ (вода арт.12345 → 1,5 кг) — убрано,
поиск по штрихкоду больше нигде не матчит по артикулу;
- разбор весовых этикеток был дефолт-ON (WeightedBarcodeSettings.Enabled,
Organization.WeightBarcodeEnabled) и ловил обычные коды — теперь дефолт OFF.
- ByBarcode: сначала точное совпадение штрихкода (вкл. короткие «27xxxxx»),
разбор этикетки — только если включён в орг И точного ШК нет.
- Phase40: WeightBarcodeEnabled DEFAULT false + выключение у существующих орг;
WeightedCodeFormat('short'|'ean13')/WeightedCodePrefix(27)/WeightedCodeDigits(5).
- Генератор ШК нового весового товара: короткий «<prefix>+N цифр» (27xxxxx, next-free)
или EAN-13 по настройке; уникальность.
- Настройки орг отдают/принимают формат кода весовых.
- Sync отдаёт unitShort («шт»/«кг»/«л») рядом с unitCode (ОКЕИ) — shared UnitShort.
- Тесты: MobileApi (exact-only по умолчанию; не матчит артикул; PLU как ШК при вкл) 9/9;
unit WeightedBarcode/UnitShort 16/16.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
223 lines
14 KiB
C#
223 lines
14 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>Код единицы измерения ОКЕИ ("796"=шт, "166"=кг, "112"=л…), не FK.</summary>
|
||
public required string UnitCode { get; init; }
|
||
/// <summary>Короткое имя единицы для отображения на кассе ("шт", "кг", "л").
|
||
/// Выводится из UnitCode/названия; чтобы касса не держала свою таблицу ОКЕИ.</summary>
|
||
public string UnitShort { 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; }
|
||
/// <summary>Товар в «Избранном» кассы (плитка на экране продажи).</summary>
|
||
public bool IsFavorite { get; init; }
|
||
/// <summary>Порядок плитки в «Избранном».</summary>
|
||
public int FavoriteOrder { 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>true — <see cref="Stocks"/> это ПОЛНЫЙ снимок ненулевых остатков
|
||
/// (первичная загрузка, since отсутствует): касса заменяет весь локальный снимок.
|
||
/// false — ДЕЛЬТА остатков с прошлого синка (изменённые с момента since, включая
|
||
/// ушедшие в 0): касса применяет построчно — ненулевые upsert, нулевые удаляет.
|
||
/// Так рекуррентный тик не переписывает все 30 000 строк каждый раз.</summary>
|
||
public bool StocksFullSnapshot { 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 string UnitShort { 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
|
||
}
|