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».
// ──────────────────────────────────────────────────────────────────────────────
/// Полная карточка товара для оффлайн-кассы. Включает только то, что
/// нужно при пробивке: ID для FK, идентификаторы (имя/артикул/штрихкоды),
/// единица измерения, упаковка (штучная/весовая/разливная), НДС и текущая
/// розничная цена. Изображения и история — не передаём (вес sync-payload'а
/// важен на медленных каналах KZ-периферии).
public record ProductSyncDto
{
public required Guid Id { get; init; }
public required string Name { get; init; }
public string? Article { get; init; }
/// Все штрихкоды товара (для поиска по сканеру). Первый — primary.
public required IReadOnlyList Barcodes { get; init; }
/// Код единицы измерения ОКЕИ ("796"=шт, "166"=кг, "112"=л…), не FK.
public required string UnitCode { get; init; }
/// Короткое имя единицы для отображения на кассе ("шт", "кг", "л").
/// Выводится из UnitCode/названия; чтобы касса не держала свою таблицу ОКЕИ.
public string UnitShort { get; init; } = "шт";
/// Тип упаковки: 1=штучный, 2=весовой, 3=разливной (см. Domain.Packaging).
public required int Packaging { get; init; }
/// Явный признак весового товара (Packaging==2) — для мобильного/кассы,
/// чтобы не завязываться на числовой код упаковки.
public bool IsWeighed { get; init; }
public required decimal VatPercent { get; init; }
public required bool VatEnabled { get; init; }
public required bool IsMarked { get; init; }
/// true — товар архивный, на кассе не продаётся, оставлен для отчётов.
public required bool IsArchived { get; init; }
/// Товар в «Избранном» кассы (плитка на экране продажи).
public bool IsFavorite { get; init; }
/// Порядок плитки в «Избранном».
public int FavoriteOrder { get; init; }
public DateTime UpdatedAt { get; init; }
}
/// Розничная цена товара. Один товар может иметь несколько цен
/// (разные типы цен — обычная, для VIP, со скидкой); POS обычно использует
/// «системный/главный» PriceType, его помечает IsSystem=true.
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; }
}
/// Текущий остаток на момент запроса. Не агрегируется на POS, просто
/// показывается кассиру для подсказки/предупреждения «осталось 2 шт». Никаких
/// reserve/available — это серверный учёт; на кассе только on-hand.
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; }
}
/// Контрагент (только покупатели — поставщики POS не нужны). Используется
/// для лояльности/привязки чека к клиенту: ввёл телефон → нашёлся контакт.
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; }
}
/// Ответ GET /api/pos/sync: все изменения после since.
/// Клиент кеширует и шлёт её обратно в следующем
/// since — серверная reference time, чтобы клок-дрейф кассы не пропустил
/// записи. — id товаров, которые с last sync
/// были архивированы (на POS нужно их пометить, не удалять историю продаж).
public record PosSyncResponse
{
public required DateTime ServerTime { get; init; }
public required IReadOnlyList Products { get; init; }
public required IReadOnlyList Prices { get; init; }
public required IReadOnlyList Stocks { get; init; }
public required IReadOnlyList Counterparties { get; init; }
public required IReadOnlyList DeletedProductIds { get; init; }
/// true — это ПОЛНЫЙ снимок ненулевых остатков
/// (первичная загрузка, since отсутствует): касса заменяет весь локальный снимок.
/// false — ДЕЛЬТА остатков с прошлого синка (изменённые с момента since, включая
/// ушедшие в 0): касса применяет построчно — ненулевые upsert, нулевые удаляет.
/// Так рекуррентный тик не переписывает все 30 000 строк каждый раз.
public bool StocksFullSnapshot { get; init; }
}
/// Одна продажа из оффлайн-кассы. POS пробил чек локально, теперь
/// шлёт на сервер. ClientSaleId — Guid, который POS присвоил при
/// пробитии; служит частью idempotency-ключа (если батч придёт повторно из-за
/// сетевой ошибки, сервер вернёт прежний результат без двойного списания).
/// Сумма к оплате уже посчитана POS'ом — сервер пересчитывает по своим
/// ценам и сверяет; mismatch → строка попадает в `Failed` ответа.
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 Lines { get; init; }
public string? Notes { get; init; }
// --- Привязка к смене/точке (добавлено, обратносовместимо с v1: nullable).
/// Точка продаж, к которой привязана касса. Если задана — сервер
/// проставит её в чек и возьмёт склад точки по умолчанию.
public Guid? RetailPointId { get; init; }
/// Открытая смена кассира. Нужна, чтобы чек попал в Z-отчёт и в
/// материализацию денег смены (MoneyService агрегирует продажи по CashShiftId).
public Guid? CashShiftId { get; init; }
/// true — это возврат покупателю (движение +остаток, наличные к выдаче).
public bool IsReturn { get; init; }
/// Чек-основание для возврата по чеку (если возврат по номеру/QR).
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; }
}
/// Батч продаж POS → сервер. — Guid,
/// POS должен использовать СТАБИЛЬНЫЙ ключ на тот же набор продаж: повтор
/// батча с тем же ключом гарантирует один и тот же результат без дублей
/// в БД. (Внутри батча уникальность отдельных продаж обеспечивается
/// PosSaleDto.ClientSaleId — он тоже идемпотентен.)
public record PosSaleBatchDto
{
public required Guid IdempotencyKey { get; init; }
public required IReadOnlyList Sales { get; init; }
}
/// Ответ на загрузку батча.
/// — продажи, которые сервер реально провёл (Status=Posted)
/// или нашёл уже проведённый по ClientSaleId (идемпотентность). С каждой —
/// идентификатор серверной RetailSale.
/// — продажи, которые отклонены (mismatch цен, отсутствие
/// товара, нехватка остатка для проведения). POS должен показать кассиру и
/// решить (перепробить с новой ценой, списать как акт расхождения).
/// Информация о последней версии кассы (автообновление + ссылка в админке).
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 Accepted { get; init; }
public required IReadOnlyList Failed { get; init; }
/// true — этот ответ построен по кешу прежнего батча с тем же
/// ключом, реальной обработки не было.
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; }
}
/// Чек-основание для возврата по номеру/QR: шапка + позиции с
/// количеством, доступным к возврату (продано − уже возвращено).
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 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
}