POS: чек с точки без открытой смены — сервер автооткрывает смену (Cash-счёт точки,
размен 0) и принимает чек (как в вебе). Фоновый ShiftWatchJob (раз в час) уведомляет
кассира и админов о сменах, открытых дольше 24ч (персистентное уведомление + SignalR,
дедуп раз в сутки). Тест POS-автооткрытия.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Касса работает из локальной базы сразу; вся синхронизация — в фоне, UI не ждёт.
- HttpClient с AutomaticDecompression: сервер уже жмёт JSON (gzip), но касса не
слала Accept-Encoding → каталог 30k ехал 22 МБ; теперь 3 МБ (×7.3).
- ApplySync: убран O(n²) скан цен (HashSet product-id) — запись 30k 12.2с → 1.8с.
- Остатки дельтой: PosSyncResponse.StocksFullSnapshot — первичная загрузка = полный
снимок, далее только изменённые (ушедшие в 0 удаляются). Рекуррентный тик:
остатков 30000→0, 806мс→189мс.
- Синк вне UI-потока (Task.Run) + маршалинг обновлений через Dispatcher: тяжёлая
запись каталога/дельты никогда не фризит окно. Флаг анти-перекрытия тиков.
- Индикатор в шапке «обновлено N мин назад / обновляется…»; ненавязчивая полоса
первичной загрузки (только когда каталог пуст), не модалка.
- Сервер: since нормализуется к UTC (был 500 от Npgsql на Kind=Unspecified).
- Тест POS-дельты остатков (upsert/zero-remove); oversell-тест кассы приведён к
решению владельца (продажа в минус разрешена → Accepted + предупреждение).
Замеры (стенд, 30 000 товаров): первичная 14.9с→5.1с (сеть 3.2с + запись 1.8с),
тёплый старт 141мс, дельта без изменений 189мс, дельта 500 товаров ~0.35с.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Сервер: 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>
- PosSaleDto: CashShiftId/RetailPointId/IsReturn/ReferenceSaleId (nullable,
обратносовместимо с v1). PosController проставляет их в RetailSale, резолвит
склад по точке продаж, проверяет что смена открыта. Без этого чеки кассы не
попадали в Z-отчёт/материализацию денег смены (MoneyService агрегирует продажи
по CashShiftId).
- Возврат покупателю (IsReturn): движение +остаток (CustomerReturn), без
pre-flight на остаток; наличные к выдаче, учитывается в смене.
- GET /api/pos/v1/version (anon): версия + ссылки на сборки из конфигурации Pos
(автообновление кассы + ссылка в админке).
- Интеграционные тесты: продажа в смене попадает в expected cash при закрытии;
возврат возвращает остаток (9/9 PosSyncTests зелёные, Testcontainers).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Endpoints:
- GET /api/pos/v1/sync?since=ISO&storeId=Guid - выгрузка изменений
(Products / Prices / Stocks / Counterparties) после reference time;
Stocks - всегда полный снимок на момент ответа (POS нужен актуальный
остаток на полке).
- POST /api/pos/v1/sales - батч продаж с idempotency.
Двойная идемпотентность:
1. Batch-level: PosBatchAck (новая таблица, unique idx по OrgId+Key) -
повтор того же батча возвращает кешированный ответ. При параллельном
race ловим 23505 на уникальном индексе и тоже возвращаем кеш.
2. Per-sale: ClientSaleId записывается в RetailSale.Notes как prefix
"pos:GUID32". Перед созданием продажи проверяем что такой маркер ещё
не встречался - если есть, возвращаем существующую продажу. Это
спасает и при разных batch-ключах с пересекающимися ClientSaleId.
Pre-flight: проверка остатка ДО создания черновика - sale, которая не
влезает в полку, попадает в Failed, остальные в батче проводятся.
Domain: PosBatchAck (TenantEntity), миграция Phase7a_PosBatchAcks
(jsonb для ResponseJson, unique idx).
Контракты v1 из food-market.shared.
Тесты: 7 интеграционных - полная sync, дельта по since, POST батч
списывает stock, replay того же батча no-duplicates, ClientSaleId через
разные batch-keys тоже no-duplicates, недостача попадает в Failed,
tenant-изоляция.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>