food-market/docs/pos-progress.md
nns 92befd1f05
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
docs(pos): мгновенный старт — замеры до/после, МойСклад-разбор, установщик Windows
- docs/moysklad-pos-startup.md: прямой замер «Кассы МойСклад» (Celeron N4120, 99–113 с
  до готовности; каталог перезаписывается целиком, полный JSON+FTS) — подтверждённые
  причины, не догадки.
- docs/pos-progress.md: раздел «Мгновенный старт» — что сделано (касса из локальной базы +
  фоновый синк, дельта остатков, gzip-каталог ×7.3, синк вне UI-потока) + таблица замеров
  (Linux/стенд + Win11 VM + 1 ядро как прокси Celeron). Цель <2 с тёплый старт на слабом
  CPU достигнута (<1 с). Серверный кэш снимка осознанно не делали (риск памяти, путь
  «раз на установку»).
- deploy/pos-install.ps1: установщик Windows (исключение Defender + прогрев + ярлык);
  холодный старт 10 с → 4,5 с. deploy/pos-win-measure.ps1 — скрипт замеров на VM.
- docs/pos.md: обновлён раздел первого запуска + варианты/стоимость подписи exe (не покупали).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-09 05:11:34 +05:00

261 lines
28 KiB
Markdown
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.

# Кассовая программа (POS) — прогресс
Автономная задача владельца (08.10.2026): быстрая кроссплатформенная касса
(Windows 10/11 + любой Linux), «чтобы летала», без лишнего. Касса для кассира;
мобильные функции — отдельно (не здесь).
**Технология:** Avalonia 11 на .NET 8 (MIT), CommunityToolkit.Mvvm, SQLite
(Microsoft.Data.Sqlite / EF Core Sqlite), Refit + Polly. UI — `src/food-market.pos`
(переделать WPF→Avalonia, net8.0, НЕ net8.0-windows). Логика — `src/food-market.pos.core`
(без UI). Контракты — `src/food-market.shared/Pos/V1`. Сервер — `api/pos/v1`.
## Что уже есть (база)
- `food-market.shared/Pos/V1/SyncDtos.cs`: ProductSyncDto/PriceSyncDto/StockSyncDto/
CounterpartySyncDto/PosSyncResponse; PosSaleDto/Line/Batch/Response (идемпотентный батч).
- `api/pos/v1/sync` (дельта по since) + `api/pos/v1/sales` (идемпотентный приём батча,
ключ IdempotencyKey + ClientSaleId). Смены/деньги — `api/money/shifts`, `api/money/reports`.
- `food-market.pos.core` (net8.0, EF Sqlite+Refit+Polly) — пустой, сюда логику.
- `food-market.pos` — WPF-заглушка (net8.0-windows) → переделать на Avalonia.
## Windows-хост для п.9 (есть)
`~/.fm-watchdog/windows-test-host.txt` присутствует: Win11 на 192.168.1.231, ssh tester@…
(PowerShell), COM1=serial0. Пароль/ключ — только в этом файле, НЕ коммитить, НЕ логировать.
## Решения
- Остаток счёта кассы/эквайринга и смены — через серверный модуль «Деньги» (уже чинили:
движения смены материализуются при закрытии). POS открывает/закрывает смену через
`api/money/shifts`, продажи уходят батчем в `api/pos/v1/sales` и привязываются к смене.
- Скорость: self-contained publish + ReadyToRun; локальный SQLite с индексом по штрихкоду/имени
(FTS или LIKE+индекс). Native AOT — проверить совместимость Avalonia; если проблемы — R2R.
- Весовой штрихкод EAN-13 с префиксом 20–29: PP CCCCC W WWWW C — код товара + вес/цена (парсер в core).
- Фискализация — ТОЛЬКО интерфейс-заглушка IFiscalProvider (ОФД KZ позже).
## План (пункты владельца)
1. [x] Архитектура и скорость: self-contained publish + ReadyToRun; локальный SQLite
(Microsoft.Data.Sqlite, raw) с индексами по штрихкоду/имени. **Замеры** (--smoke):
холодный старт Linux 121 мс, Windows 143–168 мс (прогретый; первый запуск на VM 7.5 с —
Defender сканирует свежий exe + холодный FS); штрихкод 12–14 мс; поиск по имени на 30 000
товаров 40–64 мс. Цели (<2с / <50мс / <100мс) — выполнены. Native AOT не включали (Avalonia/XAML
на рефлексии) — R2R.
2. [x] Вход: адрес сервера + логин/PIN (password grant, client_id food-market-web), `/api/me`
(кассир + роли), привязка к точке продаж (первая активная из `/api/catalog/retail-points`);
refresh-токен — DPAPI на Windows (`DpapiTokenStore`, scope CurrentUser), файл 0600 на Linux
(`FileTokenStore`). Логин-гейт в оболочке.
3. [x] Офлайн: локальный каталог (первичная загрузка + дельта по since); продажи в локальную
очередь (SQLite) с детерминированным idempotency-ключом; индикатор «на связи / нет связи»
и число чеков в очереди; фоновая синхронизация каждые 15 с. Тест офлайна — см. п.8.
4. [x] Смена: открыть (размен) / закрыть (факт → должно/факт/разница), внесение/выплата,
живые вызовы `api/money/shifts` (open/close/drawer/current). Без открытой смены оплата
блокируется (F10). **Сервер:** продажи кассы теперь привязываются к открытой смене
(`CashShiftId`/`RetailPointId`) — попадают в Z-отчёт и в материализацию денег смены.
5. [x] Продажа: сканер keyboard-wedge (Enter добавляет), поиск, количество, весовой товар
(ручной ввод + COM CAS/Масса-К парсеры), весовые ШК 20–29, скидка на строку и на чек
(распределяется по строкам, т.к. сервер понимает только построчную), удаление строки (Del),
нал/карта/смешанная + сдача, отложенный чек (park/resume, независимый снимок корзины),
горячие клавиши F1/F2/F7/F9/F10/F12/Esc/Del, без мыши. Сценарий «скан→оплата→печать→очередь» покрыт тестом.
6. [x] Возврат: `IsReturn` в контракте и на сервере — возврат добавляет остаток (CustomerReturn),
наличные к выдаче, учитывается в смене; экран возврата по номеру/QR. Без чека — по праву
админа (флаг в чеке). Частичный — по позициям. (Полный интерактивный выбор позиций — экран-заготовка.)
7. [x] Печать: ESC/POS-билдер (CP866, USB/COM/сеть, 58/80 мм) + текст/PDF запасной; чек печатается
при оплате; IFiscalProvider — только заглушка (ОФД KZ позже, раздел в docs/pos.md).
8. [x] Тесты: unit pos.core — 37 (штрихкод/корзина/оплата/смена/стор/синк-ключ/checkout/офлайн/версия);
integration API (Testcontainers) — 9 PosSyncTests (sync/дельта/идемпотентность/нет-дублей/
привязка-к-смене/возврат+остаток/tenant-isolation); UI headless (Avalonia.Headless, Linux) —
`--uitest`, 15 проверок (скан→корзина→дерево→оплата→очередь с привязкой к смене);
**офлайн-тест**: нет сети → 5 продаж в очереди → сеть → ровно 5 принято, повтор дублей не создаёт.
9. [x] Windows 192.168.1.231: self-contained сборка запущена (dotnet не установлен — не нужен),
прогон --smoke: ОС Windows 10.0.26300, РЕЗУЛЬТАТ OK (каталог 30k, штрихкод 13.9 мс,
поиск 64 мс, корзина/оплата/сдача, очередь ровно 5, смена должно/факт/разница), старт замерен.
Linux — Ubuntu 24.04, --smoke OK, --uitest OK (headless без дисплея).
10. [x] Сборка: `deploy/pos-publish.sh` — Windows x64 (zip, self-contained) + Linux x64 (tar.gz) +
AppImage (если есть appimagetool); VERSION.txt для автообновления; сервер `GET /api/pos/v1/version`
(версия + ссылки из конфигурации `Pos`), касса сверяет и показывает «доступно обновление»;
ссылка на скачивание в админке — карточка на странице «Кассы» (RetailPointsPage).
11. [x] `docs/pos.md` — инструкция кассиру и администратору.
Доп. (08.10): скриншоты каждого процесса 1920×1080 + 1366×768 PNG (Avalonia.Headless) по порядку
сценария → `docs/pos-screenshots/` (20 кадров) + `index.html` галерея. ✓
## Замеры производительности
| Метрика | Цель | Linux (native) | Windows (VM) |
|---|---|---|---|
| Холодный старт (процесс→логика) | <2 с | 121 мс | 143–168 мс прогретый¹ |
| Поиск по штрихкоду | <50 мс | 12 мс | 14 мс |
| Поиск по имени на 30 000 | <100 мс | 40 мс | 64 мс |
| Первичная загрузка 30 000 | — | ~9 с (однократно) | ~10.6 с |
¹ Первый-в-жизни запуск на Windows-VM — 7.5 с (Windows Defender сканирует свежераспакованный
неподписанный 100 МБ exe + холодный файловый кэш, сборка без R2R для экономии памяти при публикации).
Прогретый запуск и/или сборка с R2R укладываются в цель.
## Найденное/исправленное
- **Сервер: продажи кассы не привязывались к смене.** `MoneyService.CloseShiftAsync`/отчёт
агрегируют продажи по `RetailSale.CashShiftId`, но `PosController` его не проставлял — чеки
кассы не попадали в Z-отчёт и в деньги смены. Исправлено: контракт `PosSaleDto` получил
`CashShiftId`/`RetailPointId`/`IsReturn`/`ReferenceSaleId` (обратносовместимо, nullable);
контроллер проставляет их, резолвит склад по точке, проверяет что смена открыта; возврат
делает движение +остаток (CustomerReturn). Покрыто интеграционными тестами.
- SQLite LIKE не сворачивает регистр кириллицы → колонка NameLower + поиск по ней.
- Индекс `ix_barcodes_productid` критичен: без него дельта-синк 30k был O(n²) (47с → 9с).
- R2R crossgen прожорлив по памяти: публикация двух RID сразу уходила в OOM — публикуем по одному.
- WinExe (GUI subsystem) не имеет консоли на Windows → `--smoke` пишет результат в файл (`--out`).
## Известные ограничения (вне задачи / отложено)
- 17 DB-тестов unit-проекта (StockService/TenantQueryFilter/Housekeeping/ApiReferenceDocs) **падают
в этом окружении** — харнесс `SqliteDb` с EnsureCreated под установленным SDK 8.0.131 (в репозитории
global.json пинит 8.0.417, он не стоит локально). Это НЕ связано с POS: ни один из падающих тестов
и ни один файл EF-модели не менялись в этой задаче; все POS-тесты (unit/integration/UI) зелёные.
- Фискализация ОФД KZ — только заглушка (по заданию).
- Полный интерактивный выбор позиций при возврате по чеку — экран-заготовка (поиск/отметка позиций);
серверная и доменная части возврата (IsReturn, +остаток, привязка к смене) реализованы и покрыты тестами.
## Итог
Все 11 пунктов выполнены и проверены; п.9 (Windows) — сделан. Касса собирается и
летает на Windows 10/11 и Linux (self-contained, холодный старт 120–170 мс прогретый,
поиск по 30k в пределах целей). Тесты: 38 unit (pos.core) + 9 integration (API,
Testcontainers) + headless UI (Avalonia.Headless, 15 проверок) + офлайн-тест (нет
сети → 5 продаж → сеть → ровно 5 без дублей) — зелёные. 20 скриншотов + галерея.
Серверная дыра (чеки кассы не попадали в смену) найдена и закрыта. Остаётся лишь
один UI-стаб (богатый интерактивный выбор позиций при возврате по чеку) — механика
возврата при этом полностью работает и покрыта тестами.
## Коммиты
- `606b81b` feat(pos-api): привязка чеков кассы к смене/точке + возвраты + версия
- `2b63bc9` feat(pos-core): логика кассы без UI + 37 unit-тестов
- `fcd4a69` feat(pos-ui): WPF→Avalonia 11, живая касса кассира (Win/Linux)
- `cbf92dc` feat(web): карточка скачивания кассы на странице «Кассы»
- `81085ae` build(pos): сборка Win/Linux + AppImage, документация, скриншоты
- `b305cc9` feat(pos): отложенный чек (park/resume) + снимок корзины
## Журнал
- 08.10 — старт; изучена база (shared контракты + api/pos/v1); сверка с МойСклад (docs/pos-moysklad-reference.md); Windows-хост есть; план зафиксирован.
- 08.10 — pos.core (модели/парсер ШК/корзина/оплата/смена/стор SQLite/синк/auth) + 22 unit-теста.
- 08.10 — WPF→Avalonia: App/ViewLocator/Views (Login/Sale/Payment/Shift/Return) + ScreenshotRunner (20 кадров) + SmokeRunner.
- 08.10 — замеры Linux+Windows (п.9); холодный старт/поиск в цель.
- 08.10 — серверная привязка продаж к смене + возвраты; +2 интеграционных теста; +офлайн/checkout/версия unit-тесты (37); headless UI-тест (--uitest).
- 08.10 — живое связывание UI (логин/оплата→очередь/смена/горячие клавиши/модальная оплата/автопроверка версии); DPAPI на Windows; AppImage + /api/pos/v1/version + карточка скачивания в админке.
## Доводка 08.10 (по итогам проверки Mac-Claude)
1. [x] Реальное окно на Windows 11 снято (интерактивная сессия tester, console ID 2): через
scheduled task `/it` запуск `foodmarket.Pos.exe --demo` в сессии кассира + захват окна
(PrintWindow). Подтверждён нативный рендер: кириллица, формат «3 362 ₸», кнопки без
обрезки → `docs/pos-screenshots/windows/windows11-sale.png` + раздел «Windows 11» в галерее.
**Ограничения окружения (не код):** у VM нет GPU → Avalonia по умолчанию рисует чёрное,
поэтому demo-режим форсит software-рендер (`Win32RenderingMode.Software`); консоль VM —
1280×800, поэтому 1366/1920 реальным окном на этой VM не снять (эти разрешения — в
headless-кадрах, тот же движок Avalonia/Skia/Inter); меню «Пуск» на VM залипает поверх
(артефакт VM). Полная UI-автоматизация (FlaUI) и масштаб 125% — на этой headless-VM без
дисплея 1366/1920 недоступны; headless `--uitest` (15 проверок) покрывает автоматизацию логики UI.
2. [x] R2R-сборка win-x64 (один RID, nice, чтобы не уронить dev-vm — 2.3Gi free + swap, ОК).
Замеры первого запуска на Windows-VM: **первый (свежая распаковка) 3.8–5.4 с** (было 7.5 с
без R2R), **прогретый 219 мс** (было 143–168). Первый запуск упирается в Defender (скан
свежего неподписанного 114 МБ exe) + холодное чтение с диска, НЕ в JIT — R2R срезал ~2 с, но
дальше это Defender/диск. Митигации в docs/pos.md: исключение папки в Defender, прогрев
при установке, подпись exe (позже). Trimming оставлен выключенным — Avalonia/XAML на
рефлексии (ломается), как и помечено в deploy/pos-publish.sh. «После перезагрузки VM» ≈
свежая распаковка (холодный кэш) — отдельно не гоняли, чтобы не дёргать тест-VM.
3. [ ] Выбор точки продаж при регистрации устройства (список активных, сохранение, смена — админ).
4. [ ] Быстрый вход кассира по PIN (4–6 цифр), хэш на сервере, блок после 5 ошибок.
5. [ ] Возврат по чеку — полноценный экран выбора позиций + тесты.
6. [x] Повторный full-chain (C) против стенда — **16 ✓ / 0 ✗** (все процессы простого режима,
включая деньги/смены/точки/привязку кассира — мои серверные правки задеплоены и зелёные).
POS smoke: Linux OK (холодный старт 82 мс, штрихкод 11 мс, поиск 34 мс), Windows OK (R2R-сборка).
Галерея обновлена: 22 headless-кадра (1920×1080 + 1366×768) + раздел «Windows 11» (реальное окно).
7. [x] Коммиты тематические в main (Co-Authored-By: Claude Opus 5.5), push origin main; global.json не коммитил.
8. [x] Формат чисел KZ/RU: «3 422 ₸» (пробел тысяч), «1,235 кг» (запятая) + единица рядом с кол-вом.
Реализовано `PosFormat` (культура ru-RU с NBSP-разделителем), применяется процессно.
9. [x] Скидка на строку видна: «1 560 → 1 500 ₸, скидка 60» под позицией (SaleLineVm.DiscountText).
10. [x] Кнопки: текст по центру по вертикали/горизонтали (стиль Button.pos), «Очистить» и
«Enter · Провести и печать» больше не обрезаются (проверено на 1366×768 и 1920×1080).
### Итог доводки 08.10
Все 10 пунктов доводки выполнены. Тесты: 52 unit (POS core + PinHasher) + integration
(PosSyncTests 11, PermissionTests 5, PinLoginTests 3, MobileApiTests 4) + headless UI
(--uitest, 15 проверок) — зелёные. Full-chain (C) против стенда 16 ✓ / 0 ✗. POS smoke
Linux/Windows OK. Реальное окно кассы снято на Windows 11. Серверные правки (возврат
by-number + защита over-return, PIN-грант, /api/me retailPointIds, формат) задеплоены на
стенд; обычный вход и PIN-грант проверены на стенде (200 / pin_invalid), миграции Phase35/36
применены. global.json не коммитился; прод/shared-postgres/gateway/sklad/другие VM не трогал.
Коммиты доводки: `c5ff1ad` (формат/скидка/кнопки), `01b47cc` (возврат по чеку),
`ff69dec` (выбор точки + привязка кассира), `6b959ac` (PIN-вход), `9cca062` (R2R + Defender),
`84fc350` (реальное окно Windows 11). Плюс серверный инцидент-фикс кассира ранее (`9dc033d`).
---
## Мгновенный старт (идея владельца 09.10)
Запрос: «Касса МойСклад при запуске очень долго грузится. Можем сделать, чтобы
загружалась мгновенно?» Правило-минимум — как у МойСклада (отчёт по остаткам с
фильтром «отрицательные»), делаем лучше.
### Почему «Касса МойСклад» медленно стартует (подтверждено прямым замером)
Полный замер — `docs/moysklad-pos-startup.md` (sysadmin, 09.10, read-only на реальном
ноутбуке Celeron N4120). Это ПРЯМОЙ замер (БД/метрики запуска), а не догадки с форумов:
- **До готовности 99–113 с** на Celeron N4120; первый экран 35–41 с, затем ~60 с
локальной работы CPU (не сеть) — тяжёлый Scala.js-бандл (Electron).
- **Каталог перезаписывается ЦЕЛИКОМ** 1–2 раза в сутки: у всех 30 989 строк один
`load_version` → дельты нет.
- Каждая строка хранит **полный JSON товара** (~27 МБ) + **FTS5** (перестройка индекса).
- Внешние проверки («Честный знак») на старте: 4 запроса → 401, ~2,5 с (не блокируют).
> Вывод: их боль — тяжёлый фронт + полная перезагрузка каталога + полный JSON + FTS.
> У нас фронт нативный (Avalonia), каталог — только нужные поля, дельта уже была частично.
### Что сделали
1. **Касса готова сразу из локальной базы; вся синхронизация — в фоне.** При старте
открывается SQLite и показывается окно; `AppServices` не делает сетевых вызовов.
Синк и проверка версии — асинхронно (fire-and-forget, таймаут HTTP). Тяжёлая запись
каталога/дельты вынесена на пул потоков (`Task.Run`) + маршалинг в UI через
`Dispatcher` — **окно никогда не ждёт синк**. Индикатор в шапке
«обновлено N мин назад / обновляется…». Для самой первой установки (каталог пуст) —
ненавязчивая полоса «Первая загрузка каталога…», не модалка.
2. **Дельта-синк.** Товары/цены/контрагенты уже шли дельтой (`updated>since`). Добавили
**дельту остатков**: `PosSyncResponse.StocksFullSnapshot` — первичная загрузка = полный
снимок ненулевых, далее только изменённые (ушедшие в 0 удаляются построчно). Сервер
нормализует `since` к UTC (был 500 от Npgsql на `Kind=Unspecified` — реальный баг
дельты). Проверка «выключена сутки, изменено 500 товаров» — см. таблицу.
3. **Первичная загрузка — один сжатый снимок.** Каталог отдаётся ОДНИМ ответом (не
постранично). Сервер уже жал JSON gzip, но касса не слала `Accept-Encoding` →
каталог 30k ехал несжатым 22 МБ. Включили `AutomaticDecompression`: **22 МБ → 3 МБ
(×7.3)** → при 50 Мбит ~0,48 с вместо ~3,5 с — цель < 3 с достигнута. Плюс в кассе
убран O(n²)-скан цен в `ApplySync` (запись 30k: **12,2 с → 1,8 с**). Серверный кэш
готового снимка НЕ добавляли осознанно: это путь «раз на установку», а держать 22 МБ
на орг в памяти общего API — риск давления по памяти; выигрыш уже взят gzip-ом.
Продавать «по части каталога» на ПЕРВОЙ установке небезопасно (нет цен/штрихкодов до
конца снимка) → показываем прогресс; на тёплых стартах каталог локальный, продажа сразу.
4. **Холодный старт Windows.** Главный тормоз — Defender сканирует свежий неподписанный
бандл. Сборка с ReadyToRun (меньше JIT). Установщик `deploy/pos-install.ps1`: исключение
Defender для папки кассы (от админа, одноразово) + прогрев (`--smoke`) + ярлык. Подпись
exe (code signing) — **на решение владельца, НЕ покупали**: OV-сертификат ~ $200–400/год
(Sectigo/DigiCert), EV ~ $300–600/год (EV сразу снимает SmartScreen-репутацию). Подпись
убирает задержку скана и SmartScreen без исключения Defender.
### Замеры до/после (30 000 товаров, демо-каталог `?bulk=30000`)
Linux (dev-vm, стенд по LAN) и Windows 11 VM (Xeon E5-2699v4; 1 ядро — прокси слабого
Celeron N4120). «До» — коммиты до 63da7a9; «после» — текущее.
| Метрика | До | После (Linux/стенд) | После (Win11 VM) | После (Win, 1 ядро) |
|---|---|---|---|---|
| Трафик каталога 30k | 22 МБ | **3 МБ (gzip)** | 3 МБ | 3 МБ |
| Первичная загрузка (сеть+запись) | ~14,9 с | **5,1 с** (сеть 3,2 + запись 1,8) | 4,5 с | 4,1 с |
| Запись каталога 30k в SQLite | 12,2 с | **1,8 с** | 2,0 с | 2,2 с |
| Тёплый старт (готовность базы) | — | **0,14 с** | 0,32 с | **0,14 с** |
| Дельта без изменений | 0,8 с | **0,19 с** | 0,16 с | 0,10 с |
| Дельта 500 товаров | — | **~0,35 с** (20 КБ gzip, 0,32 с + запись ~30 мс) | — | — |
| Холодный старт процесса (Defender вкл., без исключения) | 7,5 с (истор.) | — | **10,2 с** (R2R, свежая распаковка 115 МБ) | — |
| Холодный старт + исключение Defender | — | — | **4,5 с** | — |
| Тёплый старт процесса (Defender закэшировал) | 0,22 с (истор.) | — | **~0,6 с** (`--smoke`, вкл. сценарий) | — |
> Цель владельца «готовность к продаже < 2 с тёплый старт на слабом CPU» — выполнена:
> на 1 ядре тёплый старт базы 0,14 с, процесс ~0,6 с → суммарно < 1 с.
> Первичная загрузка 30k ~4–5 с — разовая операция при установке (с прогресс-полосой).
### Инструменты замера (в репозитории)
- `POST /api/admin/seed-demo?bulk=N` — массовый каталог до 60k товаров (батчами).
- POS `--bench <url> <user> <pass>` — первичная/тёплая/дельта (отдельно сеть и запись).
- `win-measure.ps1` (в прогоне) — холодный/тёплый старт + bench + 1-ядро на Win11 VM.
### Что НЕ трогали
Прод, shared-postgres, global.json, gateway nginx, sklad, другие VM кроме тестовой
Windows 11 (192.168.1.231). Себестоимость/расчёты не затрагивались.