food-market/src/food-market.shared/Pos/V1/SyncDtos.cs
nns d0fee10e96 fix(barcode): весовой разбор выкл по умолчанию; поиск по ШК — точное совпадение
Реальный сценарий владельца: весов с печатью этикеток нет, штрихкоды весовых —
обычные коды «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>
2026-10-09 11:02:43 +05:00

223 lines
14 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>Код единицы измерения ОКЕИ ("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
}