Compare commits

..

5 commits

Author SHA1 Message Date
nns 81085aed8b build(pos): сборка Win/Linux + AppImage, документация, скриншоты
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
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
- deploy/pos-publish.sh: self-contained Windows x64 (zip) + Linux x64 (tar.gz) +
  AppImage (если есть appimagetool); VERSION.txt для автообновления.
- docs/pos.md — инструкция кассиру/администратору (установка, горячие клавиши,
  смена, продажа, возврат, весы, печать, офлайн, фискализация-позже, автообновление).
- docs/pos-moysklad-reference.md — сверка розничной модели с МойСклад.
- docs/pos-progress.md — честный статус 11/11 пунктов + замеры + известные огранич.
- docs/pos-screenshots/ — 20 кадров (1920×1080 + 1366×768) + index.html галерея.
- .gitignore: сборки кассы (deploy/pos-dist) и TestResults — не в git.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 04:59:06 +05:00
nns cbf92dca3b feat(web): карточка скачивания кассы на странице «Кассы»
Запрашивает GET /api/pos/v1/version и показывает версию + кнопки «Скачать для
Windows / для Linux» (ссылки из конфигурации сервера, секция Pos). Если ссылки
не настроены — подсказка администратору.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 04:58:54 +05:00
nns fcd4a69624 feat(pos-ui): WPF→Avalonia 11, живая касса кассира (Win/Linux)
- Кроссплатформенный Avalonia-UI (net8.0, не net8.0-windows): экраны вход/
  продажа/оплата/смена/возврат, тёмная тема, ViewLocator, компилируемые привязки.
- Живое связывание: логин→сессия→Refit-API, привязка к точке; продажа (скан/
  поиск/количество/весовой/скидки/удаление), модальная оплата → запись чека в
  офлайн-очередь + печать + попытка выгрузки; смена open/close/drawer на
  api/money/shifts; горячие клавиши F1/F2/F7/F9/F10/F12/Esc/Del (работа без мыши);
  индикатор связи/очереди + фоновая синхронизация; автопроверка версии.
- Оборудование: ESC/POS-билдер (CP866, USB/COM/сеть, 58/80мм) + текст/PDF
  запасной; весы CAS/Масса-К (COM) + ручной ввод; IFiscalProvider — заглушка.
- DpapiTokenStore (Windows, scope CurrentUser) для refresh-токена; 0600 на Linux.
- Инструменты CLI: --smoke (замеры/сценарий без UI), --screenshots (headless
  рендер экранов), --uitest (headless UI-автотест, 15 проверок, Avalonia.Headless).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 04:58:39 +05:00
nns 2b63bc9c66 feat(pos-core): логика кассы без UI + 37 unit-тестов
- Модели (товар/корзина/строка/смена), парсер весовых ШК (EAN-13 20–29),
  корзина (штучный инкремент, весовой, скидки строка/чек с клампом), математика
  оплаты и смены (должно/факт/разница).
- PosStore на raw Microsoft.Data.Sqlite (быстрый старт, AOT-дружелюбно): каталог
  + штрихкоды + очередь продаж + kv; индексы по штрихкоду/NameLower (кириллица
  не сворачивается через LIKE) и barcodes.ProductId (дельта-синк O(n), не O(n²)).
- SyncService: дельта-pull каталога + push очереди с детерминированным
  idempotency-ключом (MD5 отсортированных ClientSaleId → один набор = один ключ).
- Auth: password/refresh grant, ITokenStore (FileTokenStore 0600 на Unix,
  InMemory для тестов). Checkout.BuildSale: сборка серверного чека, скидка на чек
  распределяется по строкам (сервер понимает только построчную). UpdateCheck.
- Тесты: штрихкод/корзина/оплата/смена/стор/синк-ключ/checkout/офлайн(5 без
  дублей)/версия — 37 зелёных.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 04:58:22 +05:00
nns 606b81b4d9 feat(pos-api): привязка чеков кассы к смене/точке + возвраты + версия
- 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>
2026-10-08 04:58:05 +05:00
77 changed files with 3457 additions and 59 deletions

4
.gitignore vendored
View file

@ -93,3 +93,7 @@ postgres-data/
src/food-market.public/.astro/ src/food-market.public/.astro/
src/food-market.public/dist/ src/food-market.public/dist/
src/food-market.public/node_modules/ src/food-market.public/node_modules/
## POS build artifacts (self-contained сборки кассы — не в git)
deploy/pos-dist/
tests/**/TestResults/

View file

@ -62,7 +62,15 @@
<PackageVersion Include="Polly" Version="8.5.0" /> <PackageVersion Include="Polly" Version="8.5.0" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="8.0.1" /> <PackageVersion Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.0" /> <PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<!-- Avalonia 11 — кроссплатформенная касса (Windows/Linux), MIT. -->
<PackageVersion Include="Avalonia" Version="11.1.0" />
<PackageVersion Include="Avalonia.Desktop" Version="11.1.0" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="11.1.0" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="11.1.0" />
<PackageVersion Include="Avalonia.Headless" Version="11.1.0" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="8.0.11" />
<PackageVersion Include="System.IO.Ports" Version="8.0.0" /> <PackageVersion Include="System.IO.Ports" Version="8.0.0" />
<PackageVersion Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
<!-- Testing --> <!-- Testing -->
<PackageVersion Include="xunit" Version="2.9.3" /> <PackageVersion Include="xunit" Version="2.9.3" />

76
deploy/pos-publish.sh Executable file
View file

@ -0,0 +1,76 @@
#!/usr/bin/env bash
# Сборка кассы food-market.pos (Avalonia) под Windows x64 и Linux x64.
# Self-contained + ReadyToRun (быстрый старт, без установленного .NET на кассе).
# Trimming выключен по умолчанию (Avalonia/XAML использует рефлексию; AOT/trim —
# отдельная задача, см. docs/pos.md). Результат — zip/tar.gz в deploy/pos-dist/.
#
# Запуск: deploy/pos-publish.sh [win|linux|all] (по умолчанию all)
set -euo pipefail
cd "$(dirname "$0")/.."
PROJ=src/food-market.pos/food-market.pos.csproj
OUT=deploy/pos-dist
VER="$(date +%Y.%m.%d)"
mkdir -p "$OUT"
TARGET="${1:-all}"
publish() {
local rid="$1" label="$2"
echo "[pos-publish] $label ($rid) …"
local dir="$OUT/food-market-pos-$rid"
rm -rf "$dir"
dotnet publish "$PROJ" -c Release -r "$rid" --self-contained true \
-p:PublishReadyToRun=true -p:Version="$VER" -p:DebugType=none \
-o "$dir" >/tmp/pos-publish-$rid.log 2>&1 || { tail -25 /tmp/pos-publish-$rid.log; exit 1; }
# версия для автообновления кассой
echo "$VER" > "$dir/VERSION.txt"
local size; size=$(du -sh "$dir" | cut -f1)
echo "[pos-publish] $label готово: $dir ($size)"
}
# AppImage для Linux (переносимый single-file). Требует appimagetool в PATH;
# если его нет — пропускаем (tar.gz остаётся основным артефактом Linux).
build_appimage() {
local rid="$1" ver="$2"
local src="$OUT/food-market-pos-$rid"
if ! command -v appimagetool >/dev/null 2>&1; then
echo "[pos-publish] appimagetool не найден — AppImage пропущен (есть tar.gz)."
return 0
fi
local appdir="$OUT/FoodMarketPos.AppDir"
rm -rf "$appdir"; mkdir -p "$appdir/usr/bin"
cp -r "$src/." "$appdir/usr/bin/"
cat > "$appdir/foodmarket-pos.desktop" <<DESK
[Desktop Entry]
Name=Food Market Касса
Exec=foodmarket.Pos
Icon=foodmarket-pos
Type=Application
Categories=Office;
DESK
# Минимальная иконка-заглушка (1x1 PNG), если нет фирменной.
printf '\x89PNG\r\n\x1a\n' > "$appdir/foodmarket-pos.png"
cat > "$appdir/AppRun" <<'RUN'
#!/bin/sh
HERE="$(dirname "$(readlink -f "$0")")"
exec "$HERE/usr/bin/foodmarket.Pos" "$@"
RUN
chmod +x "$appdir/AppRun" "$appdir/usr/bin/foodmarket.Pos"
( cd "$OUT" && ARCH=x86_64 appimagetool "FoodMarketPos.AppDir" "food-market-pos-$ver-x86_64.AppImage" >/tmp/pos-appimage.log 2>&1 ) \
&& echo "[pos-publish] AppImage: $OUT/food-market-pos-$ver-x86_64.AppImage" \
|| { echo "[pos-publish] AppImage не собрался (см. /tmp/pos-appimage.log) — tar.gz остаётся."; }
rm -rf "$appdir"
}
if [[ "$TARGET" == "win" || "$TARGET" == "all" ]]; then
publish win-x64 "Windows x64"
( cd "$OUT" && zip -qr "food-market-pos-win-x64-$VER.zip" food-market-pos-win-x64 )
echo "[pos-publish] zip: $OUT/food-market-pos-win-x64-$VER.zip"
fi
if [[ "$TARGET" == "linux" || "$TARGET" == "all" ]]; then
publish linux-x64 "Linux x64"
( cd "$OUT" && tar czf "food-market-pos-linux-x64-$VER.tar.gz" food-market-pos-linux-x64 )
echo "[pos-publish] tar: $OUT/food-market-pos-linux-x64-$VER.tar.gz"
build_appimage linux-x64 "$VER"
fi
echo "[pos-publish] версия $VER собрана."

View file

@ -0,0 +1,42 @@
# Касса МойСклад — reference для POS food-market
Сверка перед проектированием кассы (задача владельца). Источник:
`https://dev.moysklad.ru/doc/api/remap/1.2/documents/` — розничные документы.
Скрейп через headless Playwright (tests/e2e, chromium): SPA МойСклада грузит
разделы по фрагменту нестабильно (часть якорей → «Файл не найден»), поэтому ниже
— сводка модели по известной семантике МойСклада + успешно считанные разделы
(внесение/выплата денег). Нам нужна не калька API, а поведение кассы.
## Сущности розницы (МойСклад)
- **Розничная смена** `retailshift` — открывается на точке продаж (ККМ/`retailStore`).
Поля: `open`/`close` (bool), `proceedsCash`/`proceedsNoCash` (выручка нал/безнал),
`balanceStart` (размен), `balanceEnd`, суммы продаж/возвратов. Пока смена открыта —
к ней привязываются продажи/возвраты/внесения/выплаты.
- **Розничная продажа** `retaildemand` — чек. Привязан к смене (`retailShift`) и точке.
Оплата: `cashSum` (нал) + безнал; позиции `positions` (товар, цена, кол-во, скидка).
- **Возврат розничной продажи** `retailsalesreturn` — возврат покупателю, со ссылкой
на исходную продажу (`demand`) или без неё, нал/безнал, частичный.
- **Внесение денег** `retaildrawercashin` — приход наличных в кассу ККМ внутри смены
(размен/инкассация внутрь). Увеличивает наличные в ящике.
- **Выплата денег** `retaildrawercashout` — изъятие наличных из кассы ККМ (инкассация
наружу/расход). Уменьшает наличные в ящике.
## Деньги и остатки
- Касса = `balanceStart` (размен) + Σ продаж нал − Σ возвратов нал + Σ внесений − Σ выплат.
- Безнал (карта) идёт на эквайринг/расчётный счёт отдельно.
- Закрытие смены: фиксируется факт наличных; расхождение (излишек/недостача) видно в отчёте.
- X-отчёт — промежуточный (без закрытия), Z-отчёт — при закрытии смены.
## Как ложится на food-market
- У нас уже есть серверный модуль «Деньги» с точно такой моделью (движения смены
материализуются при закрытии — см. `docs/money.md`). POS НЕ дублирует эту логику:
- смену открывает/закрывает через `POST api/money/shifts/open|{id}/close`;
- внесение/выплату — `POST api/money/shifts/{id}/drawer` (In/Out);
- продажи уходят батчем в `api/pos/v1/sales` (идемпотентно), сервер создаёт
`RetailSale` (Posted), привязанный к открытой смене точки; возвраты — так же
(IsReturn + ReferenceSaleId);
- остаток кассы/эквайринга и Z-отчёт считает сервер (`api/money/reports`,
`api/money/shifts/{id}/report`).
- Весовой товар: МойСклад — `weighed`; у нас `Packaging=2` (весовой). Весовой ШК с
префиксом 20–29 парсит POS локально (код товара + вес/цена).
- Фискализация (ОФД KZ) — отдельно; в POS только интерфейс-заглушка `IFiscalProvider`.

121
docs/pos-progress.md Normal file
View file

@ -0,0 +1,121 @@
# Кассовая программа (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),
нал/карта/смешанная + сдача, горячие клавиши 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 — только заглушка (по заданию).
- Отложенный чек и полный интерактивный выбор позиций возврата — экраны-заготовки; серверная и
доменная части (возврат, привязка к смене) реализованы и покрыты тестами.
## Коммиты
(добавляются при коммите — тематические, в main, attribution Co-Authored-By: Claude Opus)
## Журнал
- 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 + карточка скачивания в админке.

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

File diff suppressed because one or more lines are too long

89
docs/pos.md Normal file
View file

@ -0,0 +1,89 @@
# Касса food-market — инструкция (кассир и администратор)
Кроссплатформенная касса на Avalonia 11 / .NET 8. Работает на Windows 10/11 и
Linux x64. Быстрая, без лишнего; офлайн-режим; сервер — food-market (`api/pos/v1`).
## Установка
### Windows 10/11
1. Скачайте `food-market-pos-win-x64-<версия>.zip` (ссылка — в админке, раздел
«Точки продаж») и распакуйте в любую папку, напр. `C:\food-market-pos`.
2. Запустите `foodmarket.Pos.exe`. .NET устанавливать НЕ нужно (сборка self-contained).
3. Ярлык на рабочий стол — по желанию (правой кнопкой по exe → «Создать ярлык»).
### Linux x64 (Ubuntu и др.)
1. Распакуйте `food-market-pos-linux-x64-<версия>.tar.gz`.
2. `chmod +x foodmarket.Pos && ./foodmarket.Pos`.
3. Для весов/принтера по COM добавьте пользователя в группу `dialout`.
4. Либо запустите переносимый `food-market-pos-<версия>-x86_64.AppImage` (если собран).
### Скачать (администратор)
В админке на странице **«Кассы»** (точки продаж) — карточка «Кассовое приложение»
со ссылками «Скачать для Windows / для Linux». Ссылки и версия задаются на сервере
в конфигурации (секция `Pos`: `LatestVersion`, `DownloadWindows`, `DownloadLinux`).
### Автообновление
При старте касса сверяет свою версию (`VERSION.txt` в сборке) с `GET /api/pos/v1/version`
и при более новой версии показывает в статус-баре «Доступно обновление …» со ссылкой.
## Первый запуск (администратор точки)
1. **Адрес сервера** — URL вашего food-market (напр. `https://admin.food-market.kz`).
2. **Регистрация устройства** — касса привязывается к точке продаж (retail point).
Кассир видит только свои точки (к которым он привязан в админке).
3. **Вход кассира** — логин и PIN/пароль учётной записи кассира.
## Горячие клавиши (работа без мыши)
- **Сканер** — печатает штрихкод в поле ввода, **Enter** добавляет товар (keyboard-wedge, фокус не нужен).
- **F1** — помощь · **F2** — поиск товара по названию · **F7** — внесение/выплата.
- **F9** — возврат · **F10** — смена (открыть/закрыть, X/Z) · **F12** — оплата · **Esc** — отмена/очистить чек.
- **Del** — удалить выделенную строку · **+/−** — количество.
## Смена
- Открыть смену (ввести размен). Без открытой смены продавать нельзя.
- Внесение / выплата наличных (инкассация) — с причиной.
- X-отчёт — промежуточный (смена не закрывается); Z-отчёт — при закрытии (должно
быть / факт / разница). Деньги смены учитываются сервером в остатках счетов
(см. docs/money.md).
## Продажа
- Пробитие по сканеру или поиск по названию.
- Весовой товар: вес с весов по COM (CAS / Масса-К) или ручной ввод; весовой
штрихкод с префиксом 20–29 (код + вес/цена) распознаётся автоматически.
- Скидка на позицию и на чек (по праву).
- Оплата: наличные / карта / смешанная; показывается сдача.
- Отложенный чек — отложить и вернуться.
## Возврат
- По чеку (номер или скан QR) — частичный или полный.
- Без чека — по праву администратора.
## Весы (COM/serial)
- Протоколы: CAS, Масса-К (ASCII). Настройка: порт (COM1 / /dev/ttyUSB0), скорость.
- Нет весов/связи — ручной ввод веса.
## Печать чека
- ESC/POS на чековый принтер: USB (`/dev/usb/lp0` или `COMn`), сеть (`tcp:IP:9100`),
ширина 58 мм (32 симв.) или 80 мм (48 симв.).
- Запасной вариант — текстовый/обычный принтер/PDF.
- X/Z-отчёты печатаются так же.
## Офлайн
- Каталог (товары, цены, штрихкоды, весовые) хранится локально (SQLite) — первичная
загрузка + дельта-синхронизация.
- Без сети касса продолжает работать: чеки пишутся локально и уходят на сервер
очередью с идемпотентным ключом (повтор не создаёт дублей).
- В статус-баре: «на связи / нет связи» и число чеков в очереди.
## Фискализация (ОФД Казахстана)
Сейчас касса НЕ фискализирует чеки (интерфейс-заглушка `IFiscalProvider`). Для
реальной фискализации KZ потребуется:
- Договор с ОФД-оператором (Казахтелеком/Webkassa/др.) и регистрация онлайн-ККМ.
- Реализация `IFiscalProvider.RegisterSaleAsync` под API выбранного оператора
(регистрация чека, получение фискального номера и QR, печать на чеке).
- Хранение ключей/токенов ОФД защищённо (как refresh-токен кассира).
- Серверная синхронизация фискальных признаков (поля уже есть в RetailSale).
## Производительность (замеры)
- Поиск по штрихкоду — ~14 мс, по названию на 30 000 товаров — ~47 мс (локальный SQLite с индексами).
- Первичная загрузка 30 000 товаров — ~9 с (однократно при настройке).
- Сборка self-contained + ReadyToRun; замеры холодного старта — см. docs/pos-progress.md.

View file

@ -48,6 +48,22 @@ public PosController(AppDbContext db, ITenantContext tenant, IStockService stock
_log = log; _log = log;
} }
/// <summary>Информация о последней версии кассы для автообновления и для
/// ссылки на скачивание в админке. Значения берутся из конфигурации
/// (секция <c>Pos</c>); по умолчанию — пустые/плейсхолдеры. Анонимно —
/// проверка версии не требует входа.</summary>
[HttpGet("version")]
[AllowAnonymous]
public ActionResult<PosVersionDto> Version([FromServices] IConfiguration cfg)
=> Ok(new PosVersionDto
{
Version = cfg["Pos:LatestVersion"] ?? "0.0.0",
WindowsUrl = cfg["Pos:DownloadWindows"],
LinuxUrl = cfg["Pos:DownloadLinux"],
Notes = cfg["Pos:ReleaseNotes"],
Mandatory = bool.TryParse(cfg["Pos:Mandatory"], out var m) && m,
});
[HttpGet("sync")] [HttpGet("sync")]
public async Task<ActionResult<PosSyncResponse>> Sync( public async Task<ActionResult<PosSyncResponse>> Sync(
[FromQuery] DateTime? since, [FromQuery] DateTime? since,
@ -167,7 +183,9 @@ public PosController(AppDbContext db, ITenantContext tenant, IStockService stock
return BadRequest(new { error = "Идемпотентный ключ не указан.", field = "idempotencyKey" }); return BadRequest(new { error = "Идемпотентный ключ не указан.", field = "idempotencyKey" });
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant."); var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var resolvedStore = await ResolveStoreAsync(storeId, ct); // Склад: если у продаж указана точка продаж — берём её склад; иначе storeId/главный.
var pointFromSales = batch.Sales.Select(s => s.RetailPointId).FirstOrDefault(p => p is not null);
var resolvedStore = await ResolveStoreAsync(storeId, pointFromSales, ct);
if (resolvedStore is null) if (resolvedStore is null)
return BadRequest(new { error = "Магазин не найден или не принадлежит организации.", field = "storeId" }); return BadRequest(new { error = "Магазин не найден или не принадлежит организации.", field = "storeId" });
@ -275,8 +293,10 @@ public PosController(AppDbContext db, ITenantContext tenant, IStockService stock
if (src.Lines.Count == 0) if (src.Lines.Count == 0)
throw new PosSaleRejectedException("Пустой чек.", "lines"); throw new PosSaleRejectedException("Пустой чек.", "lines");
// Pre-flight на остатки. Сделано до создания RetailSale чтобы зря не // Возврат покупателю добавляет остаток, поэтому pre-flight не нужен; для
// дёргать БД при пустой полке. // продажи проверяем остаток до создания RetailSale, чтобы зря не дёргать БД.
if (!src.IsReturn)
{
var byProduct = src.Lines.GroupBy(l => l.ProductId) var byProduct = src.Lines.GroupBy(l => l.ProductId)
.Select(g => new { ProductId = g.Key, Qty = g.Sum(x => x.Quantity) }).ToList(); .Select(g => new { ProductId = g.Key, Qty = g.Sum(x => x.Quantity) }).ToList();
var productIds = byProduct.Select(x => x.ProductId).ToList(); var productIds = byProduct.Select(x => x.ProductId).ToList();
@ -290,6 +310,16 @@ public PosController(AppDbContext db, ITenantContext tenant, IStockService stock
throw new PosSaleRejectedException( throw new PosSaleRejectedException(
$"Недостаточный остаток для товара {x.ProductId}.", "productId"); $"Недостаточный остаток для товара {x.ProductId}.", "productId");
} }
}
// Привязка к смене: если указана — проверяем, что она открыта и наша.
if (src.CashShiftId is Guid shiftId)
{
var shiftOk = await _db.CashShifts.AsNoTracking()
.AnyAsync(s => s.Id == shiftId && s.Status == Domain.Money.CashShiftStatus.Open, ct);
if (!shiftOk)
throw new PosSaleRejectedException("Смена не найдена или закрыта.", "cashShiftId");
}
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct); var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero); decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero);
@ -309,6 +339,10 @@ public PosController(AppDbContext db, ITenantContext tenant, IStockService stock
CurrencyId = currencyId, CurrencyId = currencyId,
CustomerId = src.CustomerId, CustomerId = src.CustomerId,
CashierUserId = src.CashierUserId, CashierUserId = src.CashierUserId,
RetailPointId = src.RetailPointId,
CashShiftId = src.CashShiftId,
IsReturn = src.IsReturn,
ReferenceSaleId = src.ReferenceSaleId,
Payment = (PaymentMethod)src.Payment, Payment = (PaymentMethod)src.Payment,
PaidCash = R(src.PaidCash), PaidCash = R(src.PaidCash),
PaidCard = R(src.PaidCard), PaidCard = R(src.PaidCard),
@ -342,15 +376,18 @@ public PosController(AppDbContext db, ITenantContext tenant, IStockService stock
_db.RetailSales.Add(sale); _db.RetailSales.Add(sale);
// Проведение: -Quantity StockMovement RetailSale. // Проведение: продажа списывает (-Quantity, RetailSale), возврат
// покупателя возвращает товар на склад (+Quantity, CustomerReturn).
var movementType = src.IsReturn ? MovementType.CustomerReturn : MovementType.RetailSale;
var documentType = src.IsReturn ? "retail-return" : "retail-sale";
foreach (var l in src.Lines) foreach (var l in src.Lines)
{ {
await _stock.ApplyMovementAsync(new StockMovementDraft( await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: l.ProductId, ProductId: l.ProductId,
StoreId: storeId, StoreId: storeId,
Quantity: -l.Quantity, Quantity: src.IsReturn ? l.Quantity : -l.Quantity,
Type: MovementType.RetailSale, Type: movementType,
DocumentType: "retail-sale", DocumentType: documentType,
DocumentId: sale.Id, DocumentId: sale.Id,
DocumentNumber: sale.Number, DocumentNumber: sale.Number,
UnitCost: l.UnitPrice, UnitCost: l.UnitPrice,
@ -374,13 +411,23 @@ public PosController(AppDbContext db, ITenantContext tenant, IStockService stock
return (sale.Id, sale.Number); return (sale.Id, sale.Number);
} }
private async Task<Guid?> ResolveStoreAsync(Guid? requested, CancellationToken ct) private Task<Guid?> ResolveStoreAsync(Guid? requested, CancellationToken ct)
=> ResolveStoreAsync(requested, null, ct);
private async Task<Guid?> ResolveStoreAsync(Guid? requested, Guid? retailPointId, CancellationToken ct)
{ {
if (requested is { } id) if (requested is { } id)
{ {
var ok = await _db.Stores.AsNoTracking().AnyAsync(s => s.Id == id, ct); var ok = await _db.Stores.AsNoTracking().AnyAsync(s => s.Id == id, ct);
return ok ? id : null; return ok ? id : null;
} }
// Если касса привязана к точке продаж — берём её склад.
if (retailPointId is { } rp)
{
var store = await _db.RetailPoints.AsNoTracking()
.Where(r => r.Id == rp).Select(r => (Guid?)r.StoreId).FirstOrDefaultAsync(ct);
if (store is not null) return store;
}
// Дефолт — главный склад организации. // Дефолт — главный склад организации.
var main = await _db.Stores.AsNoTracking() var main = await _db.Stores.AsNoTracking()
.Where(s => s.IsMain).Select(s => (Guid?)s.Id).FirstOrDefaultAsync(ct); .Where(s => s.IsMain).Select(s => (Guid?)s.Id).FirstOrDefaultAsync(ct);

View file

@ -35,5 +35,12 @@
"https://admin.food-market.kz" "https://admin.food-market.kz"
] ]
}, },
"Pos": {
"LatestVersion": "0.0.0",
"DownloadWindows": "",
"DownloadLinux": "",
"ReleaseNotes": "",
"Mandatory": false
},
"AllowedHosts": "*" "AllowedHosts": "*"
} }

View file

@ -0,0 +1,65 @@
using System.Net.Http.Json;
using System.Text.Json;
namespace foodmarket.Pos.Core.Auth;
public record AuthTokens(string AccessToken, string RefreshToken, int ExpiresIn);
/// <summary>Вход кассира (password grant) и обновление токена (refresh) против
/// OpenIddict-сервера food-market. Касса использует тот же client_id,
/// что и веб. PIN поддерживается как пароль кассира (PIN = пароль учётки).</summary>
public sealed class AuthService
{
private readonly HttpClient _http;
public AuthService(HttpClient http) { _http = http; }
public async Task<AuthTokens> LoginAsync(string serverUrl, string username, string password, CancellationToken ct = default)
{
var form = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "password",
["username"] = username,
["password"] = password,
["client_id"] = "food-market-web",
["scope"] = "openid profile email roles api offline_access",
});
using var resp = await _http.PostAsync(serverUrl.TrimEnd('/') + "/connect/token", form, ct);
var body = await resp.Content.ReadAsStringAsync(ct);
if (!resp.IsSuccessStatusCode)
throw new AuthException(DescribeError(body) ?? $"Вход не удался ({(int)resp.StatusCode})");
return Parse(body);
}
public async Task<AuthTokens> RefreshAsync(string serverUrl, string refreshToken, CancellationToken ct = default)
{
var form = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "refresh_token",
["refresh_token"] = refreshToken,
["client_id"] = "food-market-web",
});
using var resp = await _http.PostAsync(serverUrl.TrimEnd('/') + "/connect/token", form, ct);
var body = await resp.Content.ReadAsStringAsync(ct);
if (!resp.IsSuccessStatusCode)
throw new AuthException("Сессия истекла — войдите снова.");
return Parse(body);
}
private static AuthTokens Parse(string body)
{
using var doc = JsonDocument.Parse(body);
var r = doc.RootElement;
return new AuthTokens(
r.GetProperty("access_token").GetString()!,
r.TryGetProperty("refresh_token", out var rt) ? rt.GetString()! : "",
r.TryGetProperty("expires_in", out var e) ? e.GetInt32() : 3600);
}
private static string? DescribeError(string body)
{
try { using var d = JsonDocument.Parse(body); return d.RootElement.TryGetProperty("error_description", out var e) ? e.GetString() : null; }
catch { return null; }
}
}
public sealed class AuthException : Exception { public AuthException(string m) : base(m) { } }

View file

@ -0,0 +1,76 @@
using System.Runtime.InteropServices;
using System.Text.Json;
namespace foodmarket.Pos.Core.Auth;
public sealed class PosSession
{
public required string ServerUrl { get; set; }
public required string AccessToken { get; set; }
public required string RefreshToken { get; set; }
public string? CashierName { get; set; }
public Guid? CashierUserId { get; set; }
public string[]? Roles { get; set; }
public Guid? RetailPointId { get; set; }
public Guid? CashAccountId { get; set; }
public Guid? AcquiringAccountId { get; set; }
public DateTime SavedAt { get; set; } = DateTime.UtcNow;
}
public interface ITokenStore
{
void Save(PosSession session);
PosSession? Load();
void Clear();
}
/// <summary>Кроссплатформенное хранение сессии в файле. На Unix выставляем права
/// 0600 (только владелец). На Windows полагаемся на ACL пользовательского профиля;
/// усиленный вариант (DPAPI) — в приложении под Windows (DpapiTokenStore).
/// Содержит refresh-токен — файл не логируем и не коммитим.</summary>
public class FileTokenStore : ITokenStore
{
private readonly string _path;
private static readonly JsonSerializerOptions J = new(JsonSerializerDefaults.Web);
public FileTokenStore(string path) { _path = path; }
public static string DefaultPath()
{
var dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "food-market-pos");
Directory.CreateDirectory(dir);
return Path.Combine(dir, "session.json");
}
public void Save(PosSession session)
{
var json = JsonSerializer.Serialize(session, J);
File.WriteAllText(_path, Protect(json));
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
try { File.SetUnixFileMode(_path, UnixFileMode.UserRead | UnixFileMode.UserWrite); } catch { /* best effort */ }
}
}
public PosSession? Load()
{
if (!File.Exists(_path)) return null;
try { return JsonSerializer.Deserialize<PosSession>(Unprotect(File.ReadAllText(_path)), J); }
catch { return null; }
}
public void Clear() { try { if (File.Exists(_path)) File.Delete(_path); } catch { /* ignore */ } }
// Точки расширения для DPAPI в приложении под Windows; базовый стор хранит как есть.
protected virtual string Protect(string plain) => plain;
protected virtual string Unprotect(string stored) => stored;
}
/// <summary>Токен-стор в памяти — для тестов/демо (без диска).</summary>
public sealed class InMemoryTokenStore : ITokenStore
{
private PosSession? _session;
public void Save(PosSession session) => _session = session;
public PosSession? Load() => _session;
public void Clear() => _session = null;
}

View file

@ -0,0 +1,241 @@
using System.Text.Json;
using foodmarket.Pos.Core.Models;
using foodmarket.Shared.Pos.V1;
using Microsoft.Data.Sqlite;
namespace foodmarket.Pos.Core.Data;
/// <summary>Локальное хранилище кассы на SQLite (Microsoft.Data.Sqlite напрямую —
/// без EF: быстрый холодный старт и дружелюбность к Native AOT). Хранит каталог
/// (товары+штрихкоды с индексом), очередь продаж и key-value (курсор синка, смена).
/// Потокобезопасность: один connection, операции короткие; UI-поток дёргает через await Task.Run.</summary>
public sealed class PosStore : IDisposable
{
private readonly SqliteConnection _cn;
private static readonly JsonSerializerOptions J = new(JsonSerializerDefaults.Web);
public PosStore(string dbPath)
{
_cn = new SqliteConnection($"Data Source={dbPath};Cache=Shared");
_cn.Open();
Exec("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA temp_store=MEMORY;");
InitSchema();
}
public static PosStore InMemory()
{
var s = new PosStore(":memory:");
return s;
}
private void InitSchema()
{
Exec(@"
CREATE TABLE IF NOT EXISTS products(
Id TEXT PRIMARY KEY, Name TEXT NOT NULL, NameLower TEXT NOT NULL DEFAULT '', Article TEXT, UnitCode TEXT NOT NULL,
Packaging INTEGER NOT NULL, VatPercent TEXT NOT NULL, VatEnabled INTEGER NOT NULL,
Price TEXT NOT NULL DEFAULT '0', IsArchived INTEGER NOT NULL DEFAULT 0, UpdatedAt TEXT);
CREATE TABLE IF NOT EXISTS barcodes(Code TEXT NOT NULL, ProductId TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS ix_barcodes_code ON barcodes(Code);
CREATE INDEX IF NOT EXISTS ix_barcodes_productid ON barcodes(ProductId);
CREATE INDEX IF NOT EXISTS ix_products_namelower ON products(NameLower);
CREATE TABLE IF NOT EXISTS sale_queue(
ClientSaleId TEXT PRIMARY KEY, Json TEXT NOT NULL, Status INTEGER NOT NULL DEFAULT 0,
CreatedAt TEXT NOT NULL, Error TEXT);
CREATE TABLE IF NOT EXISTS kv(Key TEXT PRIMARY KEY, Value TEXT);");
}
// ── каталог ──────────────────────────────────────────────────────────────
public void ApplySync(PosSyncResponse sync)
{
var priceByProduct = sync.Prices
.Where(p => p.IsSystem)
.GroupBy(p => p.ProductId)
.ToDictionary(g => g.Key, g => g.OrderByDescending(x => x.UpdatedAt).First().Amount);
using var tx = _cn.BeginTransaction();
// Подготовленные команды с переиспользуемыми параметрами — массовая
// вставка 30k товаров за ~1с вместо десятков секунд (команда на строку).
var up = _cn.CreateCommand(); up.Transaction = tx;
up.CommandText = @"INSERT INTO products(Id,Name,NameLower,Article,UnitCode,Packaging,VatPercent,VatEnabled,Price,IsArchived,UpdatedAt)
VALUES($id,$n,$nl,$a,$u,$pk,$vp,$ve,$pr,$ar,$up)
ON CONFLICT(Id) DO UPDATE SET Name=$n,NameLower=$nl,Article=$a,UnitCode=$u,Packaging=$pk,VatPercent=$vp,VatEnabled=$ve,
Price=CASE WHEN $hasPrice=1 THEN $pr ELSE products.Price END, IsArchived=$ar, UpdatedAt=$up;";
var upP = AddParams(up, "$id", "$n", "$nl", "$a", "$u", "$pk", "$vp", "$ve", "$pr", "$hasPrice", "$ar", "$up");
up.Prepare();
var delBc = _cn.CreateCommand(); delBc.Transaction = tx; delBc.CommandText = "DELETE FROM barcodes WHERE ProductId=$id";
var delP = AddParams(delBc, "$id"); delBc.Prepare();
var insBc = _cn.CreateCommand(); insBc.Transaction = tx; insBc.CommandText = "INSERT INTO barcodes(Code,ProductId) VALUES($c,$id)";
var insP = AddParams(insBc, "$c", "$id"); insBc.Prepare();
foreach (var p in sync.Products)
{
priceByProduct.TryGetValue(p.Id, out var price);
var hasPrice = priceByProduct.ContainsKey(p.Id);
var idStr = p.Id.ToString();
upP["$id"].Value = idStr; upP["$n"].Value = p.Name; upP["$nl"].Value = p.Name.ToLowerInvariant();
upP["$a"].Value = (object?)p.Article ?? DBNull.Value; upP["$u"].Value = p.UnitCode; upP["$pk"].Value = p.Packaging;
upP["$vp"].Value = p.VatPercent.ToString(System.Globalization.CultureInfo.InvariantCulture);
upP["$ve"].Value = p.VatEnabled ? 1 : 0;
upP["$pr"].Value = price.ToString(System.Globalization.CultureInfo.InvariantCulture);
upP["$hasPrice"].Value = hasPrice ? 1 : 0; upP["$ar"].Value = p.IsArchived ? 1 : 0; upP["$up"].Value = p.UpdatedAt.ToString("O");
up.ExecuteNonQuery();
delP["$id"].Value = idStr; delBc.ExecuteNonQuery();
foreach (var bc in p.Barcodes)
{
insP["$c"].Value = OnlyDigitsOrRaw(bc); insP["$id"].Value = idStr;
insBc.ExecuteNonQuery();
}
}
up.Dispose(); delBc.Dispose(); insBc.Dispose();
// цены для товаров, пришедших без карточки (только апдейт цены)
foreach (var kv in priceByProduct)
{
if (sync.Products.Any(x => x.Id == kv.Key)) continue;
using var cmd = _cn.CreateCommand(); cmd.Transaction = tx;
cmd.CommandText = "UPDATE products SET Price=$pr WHERE Id=$id";
Bind(cmd, "$pr", kv.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)); Bind(cmd, "$id", kv.Key.ToString());
cmd.ExecuteNonQuery();
}
foreach (var did in sync.DeletedProductIds)
{
using var cmd = _cn.CreateCommand(); cmd.Transaction = tx;
cmd.CommandText = "UPDATE products SET IsArchived=1 WHERE Id=$id"; Bind(cmd, "$id", did.ToString()); cmd.ExecuteNonQuery();
}
tx.Commit();
SetKv("last_sync", sync.ServerTime.ToString("O"));
}
public int ProductCount()
{
using var cmd = _cn.CreateCommand(); cmd.CommandText = "SELECT COUNT(*) FROM products WHERE IsArchived=0";
return Convert.ToInt32(cmd.ExecuteScalar());
}
public PosProduct? FindByBarcode(string code)
{
var lookup = OnlyDigitsOrRaw(code);
using var cmd = _cn.CreateCommand();
cmd.CommandText = @"SELECT p.* FROM products p JOIN barcodes b ON b.ProductId=p.Id
WHERE b.Code=$c AND p.IsArchived=0 LIMIT 1";
Bind(cmd, "$c", lookup);
using var r = cmd.ExecuteReader();
return r.Read() ? Map(r) : null;
}
public IReadOnlyList<PosProduct> SearchByName(string term, int limit = 50)
{
term = (term ?? string.Empty).Trim();
if (term.Length == 0) return Array.Empty<PosProduct>();
using var cmd = _cn.CreateCommand();
// сначала «начинается с», потом «содержит» — релевантнее; индекс ix_products_name помогает префиксу.
// LIKE в SQLite не сворачивает регистр у кириллицы → ищем по NameLower
// (строчное из C#, Unicode-aware); сначала «начинается с», потом «содержит».
var t = term.ToLowerInvariant();
cmd.CommandText = @"SELECT * FROM products WHERE IsArchived=0 AND NameLower LIKE $pfx
UNION
SELECT * FROM products WHERE IsArchived=0 AND NameLower LIKE $sub AND NameLower NOT LIKE $pfx
LIMIT $lim";
Bind(cmd, "$pfx", t + "%"); Bind(cmd, "$sub", "%" + t + "%"); Bind(cmd, "$lim", limit);
using var r = cmd.ExecuteReader();
var list = new List<PosProduct>();
while (r.Read()) list.Add(Map(r));
return list;
}
public PosProduct? GetProduct(Guid id)
{
using var cmd = _cn.CreateCommand(); cmd.CommandText = "SELECT * FROM products WHERE Id=$id";
Bind(cmd, "$id", id.ToString());
using var r = cmd.ExecuteReader(); return r.Read() ? Map(r) : null;
}
// ── очередь продаж ─────────────────────────────────────────────────────────
public void EnqueueSale(PosSaleDto sale)
{
using var cmd = _cn.CreateCommand();
cmd.CommandText = @"INSERT INTO sale_queue(ClientSaleId,Json,Status,CreatedAt) VALUES($id,$j,0,$t)
ON CONFLICT(ClientSaleId) DO NOTHING";
Bind(cmd, "$id", sale.ClientSaleId.ToString()); Bind(cmd, "$j", JsonSerializer.Serialize(sale, J)); Bind(cmd, "$t", DateTime.UtcNow.ToString("O"));
cmd.ExecuteNonQuery();
}
public IReadOnlyList<PosSaleDto> PendingSales(int limit = 200)
{
using var cmd = _cn.CreateCommand();
cmd.CommandText = "SELECT Json FROM sale_queue WHERE Status=0 ORDER BY CreatedAt LIMIT $lim";
Bind(cmd, "$lim", limit);
using var r = cmd.ExecuteReader();
var list = new List<PosSaleDto>();
while (r.Read()) list.Add(JsonSerializer.Deserialize<PosSaleDto>(r.GetString(0), J)!);
return list;
}
public int PendingCount()
{
using var cmd = _cn.CreateCommand(); cmd.CommandText = "SELECT COUNT(*) FROM sale_queue WHERE Status=0";
return Convert.ToInt32(cmd.ExecuteScalar());
}
public void MarkSent(Guid clientSaleId)
{
using var cmd = _cn.CreateCommand(); cmd.CommandText = "UPDATE sale_queue SET Status=1 WHERE ClientSaleId=$id";
Bind(cmd, "$id", clientSaleId.ToString()); cmd.ExecuteNonQuery();
}
public void MarkFailed(Guid clientSaleId, string error)
{
using var cmd = _cn.CreateCommand(); cmd.CommandText = "UPDATE sale_queue SET Status=2, Error=$e WHERE ClientSaleId=$id";
Bind(cmd, "$id", clientSaleId.ToString()); Bind(cmd, "$e", error); cmd.ExecuteNonQuery();
}
// ── key-value ──────────────────────────────────────────────────────────────
public string? GetKv(string key)
{
using var cmd = _cn.CreateCommand(); cmd.CommandText = "SELECT Value FROM kv WHERE Key=$k"; Bind(cmd, "$k", key);
return cmd.ExecuteScalar() as string;
}
public void SetKv(string key, string value)
{
using var cmd = _cn.CreateCommand();
cmd.CommandText = "INSERT INTO kv(Key,Value) VALUES($k,$v) ON CONFLICT(Key) DO UPDATE SET Value=$v";
Bind(cmd, "$k", key); Bind(cmd, "$v", value); cmd.ExecuteNonQuery();
}
public DateTime? LastSync() => DateTime.TryParse(GetKv("last_sync"), null, System.Globalization.DateTimeStyles.RoundtripKind, out var d) ? d : null;
// ── helpers ────────────────────────────────────────────────────────────────
private static PosProduct Map(SqliteDataReader r)
{
var id = Guid.Parse(r.GetString(r.GetOrdinal("Id")));
return new PosProduct
{
Id = id,
Name = r.GetString(r.GetOrdinal("Name")),
Article = r.IsDBNull(r.GetOrdinal("Article")) ? null : r.GetString(r.GetOrdinal("Article")),
UnitCode = r.GetString(r.GetOrdinal("UnitCode")),
Packaging = (PosPackaging)r.GetInt32(r.GetOrdinal("Packaging")),
VatPercent = decimal.Parse(r.GetString(r.GetOrdinal("VatPercent")), System.Globalization.CultureInfo.InvariantCulture),
VatEnabled = r.GetInt32(r.GetOrdinal("VatEnabled")) == 1,
Price = decimal.Parse(r.GetString(r.GetOrdinal("Price")), System.Globalization.CultureInfo.InvariantCulture),
IsArchived = r.GetInt32(r.GetOrdinal("IsArchived")) == 1,
Barcodes = Array.Empty<string>(),
};
}
private static string OnlyDigitsOrRaw(string s)
{
var d = new string((s ?? string.Empty).Where(char.IsDigit).ToArray());
return d.Length > 0 ? d : (s ?? string.Empty);
}
private void Exec(string sql) { using var cmd = _cn.CreateCommand(); cmd.CommandText = sql; cmd.ExecuteNonQuery(); }
private static void Bind(SqliteCommand cmd, string name, object value) { var p = cmd.CreateParameter(); p.ParameterName = name; p.Value = value; cmd.Parameters.Add(p); }
private static Dictionary<string, SqliteParameter> AddParams(SqliteCommand cmd, params string[] names)
{
var d = new Dictionary<string, SqliteParameter>(names.Length);
foreach (var n in names) { var p = cmd.CreateParameter(); p.ParameterName = n; cmd.Parameters.Add(p); d[n] = p; }
return d;
}
public void Dispose() => _cn.Dispose();
}

View file

@ -0,0 +1,53 @@
namespace foodmarket.Pos.Core.Logic;
/// <summary>Результат разбора штрихкода.</summary>
public readonly record struct ScannedBarcode(
string Raw,
string Lookup, // код для поиска товара (для весовых — встроенный код товара)
bool IsWeighted,
decimal? EmbeddedWeight, // кг (для EAN-13 с весом)
decimal? EmbeddedPrice); // ₸ (для EAN-13 с ценой)
/// <summary>Разбор весовых EAN-13 с префиксом 20–29 (внутримагазинные коды).
///
/// Формат (РФ/РК, распространённый): <c>PP CCCCC Q QQQQ K</c> —
/// PP — префикс 20..29,
/// CCCCC — 5-значный код товара (PLU),
/// Q — встроенная величина: вес в кг (5 цифр, 3 знака после запятой) ИЛИ
/// цена (5 цифр, 2 знака), в зависимости от префикса/настройки,
/// K — контрольная цифра EAN-13.
///
/// По умолчанию: префиксы 20–24 → ВЕС (кг, /1000), 25–29 → ЦЕНА (₸, /100).
/// Это настраивается, но даём рабочий дефолт. Для поиска товара возвращаем
/// Lookup = "2" + PP[1] + CCCCC? Нет — ищем товар по его обычному штрихкоду,
/// которым в весовом коде является PLU. Поэтому Lookup = PLU (5 цифр, без лидирующих нулей
/// не режем — каталог сам сматчит по хранимому коду).</summary>
public static class BarcodeParser
{
public static ScannedBarcode Parse(string raw)
{
var code = new string((raw ?? string.Empty).Where(char.IsDigit).ToArray());
if (code.Length == 13 && code[0] == '2')
{
var prefix = int.Parse(code.Substring(0, 2));
if (prefix is >= 20 and <= 29)
{
var plu = code.Substring(2, 5); // код товара
var valueDigits = code.Substring(7, 5); // вес или цена
var value = int.Parse(valueDigits);
if (prefix <= 24)
{
// вес в кг, 3 знака после запятой (12345 → 12.345 кг)
var weight = decimal.Round(value / 1000m, 3);
return new ScannedBarcode(code, plu, true, weight, null);
}
// цена в ₸, 2 знака (12345 → 123.45)
var price = decimal.Round(value / 100m, 2);
return new ScannedBarcode(code, plu, true, null, price);
}
}
return new ScannedBarcode(code.Length > 0 ? code : (raw ?? string.Empty), code.Length > 0 ? code : (raw ?? string.Empty), false, null, null);
}
}

View file

@ -0,0 +1,78 @@
using foodmarket.Pos.Core.Models;
namespace foodmarket.Pos.Core.Logic;
/// <summary>Итоги чека.</summary>
public readonly record struct CartTotals(decimal Subtotal, decimal LineDiscounts, decimal CartDiscount, decimal Total, decimal Vat, int LineCount, decimal Quantity);
/// <summary>Корзина (чек) — чистая логика, без UI. Штучный товар инкрементится,
/// весовой — задаётся количеством (вес). Скидки: на строку (абсолютная) и на
/// весь чек (абсолютная, поверх строчных). Итог не уходит ниже нуля.</summary>
public sealed class Cart
{
private readonly List<CartLine> _lines = new();
public IReadOnlyList<CartLine> Lines => _lines;
public decimal CartDiscount { get; private set; }
/// <summary>Добавить товар. Для весового qty = вес (обязателен),
/// для штучного — прибавка к количеству (по умолчанию 1).</summary>
public CartLine AddProduct(PosProduct p, decimal qty = 1m, decimal? priceOverride = null)
{
if (p.IsWeighed)
{
if (qty <= 0) throw new ArgumentException("Для весового товара нужен вес > 0", nameof(qty));
var wl = new CartLine
{
ProductId = p.Id, Name = p.Name, UnitCode = p.UnitCode, IsWeighed = true,
UnitPrice = priceOverride ?? p.Price, Quantity = qty,
VatPercent = p.VatEnabled ? p.VatPercent : 0m,
};
_lines.Add(wl);
return wl;
}
var existing = _lines.FirstOrDefault(l => l.ProductId == p.Id && !l.IsWeighed && l.Discount == 0m);
if (existing is not null)
{
existing.Quantity += qty;
return existing;
}
var line = new CartLine
{
ProductId = p.Id, Name = p.Name, UnitCode = p.UnitCode, IsWeighed = false,
UnitPrice = priceOverride ?? p.Price, Quantity = qty,
VatPercent = p.VatEnabled ? p.VatPercent : 0m,
};
_lines.Add(line);
return line;
}
public void SetQuantity(CartLine line, decimal qty)
{
if (qty <= 0) { _lines.Remove(line); return; }
line.Quantity = qty;
}
public void SetLineDiscount(CartLine line, decimal discount)
=> line.Discount = Math.Clamp(discount, 0m, decimal.Round(line.UnitPrice * line.Quantity, 2));
public void SetCartDiscount(decimal discount)
=> CartDiscount = Math.Clamp(discount, 0m, SumLineTotals());
public void Remove(CartLine line) => _lines.Remove(line);
public void Clear() { _lines.Clear(); CartDiscount = 0m; }
private decimal SumLineTotals() => _lines.Sum(l => l.LineTotal);
public CartTotals Totals()
{
var subtotal = decimal.Round(_lines.Sum(l => l.UnitPrice * l.Quantity), 2);
var lineDisc = _lines.Sum(l => l.Discount);
var cartDisc = Math.Min(CartDiscount, SumLineTotals());
var total = Math.Max(0m, decimal.Round(SumLineTotals() - cartDisc, 2));
var vat = decimal.Round(_lines.Sum(l => l.VatPercent <= 0 ? 0m : l.LineTotal * l.VatPercent / (100m + l.VatPercent)), 2);
return new CartTotals(subtotal, lineDisc, cartDisc, total, vat, _lines.Count, _lines.Sum(l => l.Quantity));
}
public bool IsEmpty => _lines.Count == 0;
}

View file

@ -0,0 +1,82 @@
using foodmarket.Pos.Core.Models;
using foodmarket.Shared.Pos.V1;
namespace foodmarket.Pos.Core.Logic;
/// <summary>Контекст чека: кто пробивает, на какой точке и в какой смене.
/// Проставляется из сессии кассира и открытой смены.</summary>
public sealed record CheckoutContext(Guid? CashierUserId, Guid? RetailPointId, Guid? CashShiftId);
/// <summary>Сборка серверного чека (PosSaleDto) из корзины — чистая логика.
/// Ключевой момент: сервер учитывает скидку только построчно, поэтому скидку
/// на чек распределяем по строкам пропорционально их сумме (остаток округления
/// падает на последнюю строку), чтобы итог сервера совпал с итогом кассы.</summary>
public static class Checkout
{
public static PosSaleDto BuildSale(
Cart cart, decimal paidCash, decimal paidCard, CheckoutContext ctx,
Guid? clientSaleId = null, DateTime? occurredAt = null,
bool isReturn = false, Guid? referenceSaleId = null, Guid? customerId = null, string? notes = null)
{
if (cart.IsEmpty) throw new InvalidOperationException("Пустой чек — нечего пробивать.");
var lines = cart.Lines.ToList();
var distributed = DistributeCartDiscount(lines, cart.CartDiscount);
var method = (paidCard > 0m && paidCash > 0m) ? PosPaymentMethod.Mixed
: paidCard > 0m ? PosPaymentMethod.Card
: PosPaymentMethod.Cash;
return new PosSaleDto
{
ClientSaleId = clientSaleId ?? Guid.NewGuid(),
OccurredAt = occurredAt ?? DateTime.UtcNow,
CustomerId = customerId,
CashierUserId = ctx.CashierUserId,
RetailPointId = ctx.RetailPointId,
CashShiftId = ctx.CashShiftId,
IsReturn = isReturn,
ReferenceSaleId = referenceSaleId,
Payment = (int)method,
PaidCash = paidCash,
PaidCard = paidCard,
Notes = notes,
Lines = lines.Select((l, i) => new PosSaleLineDto
{
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitPrice = l.UnitPrice,
Discount = distributed[i],
VatPercent = l.VatPercent,
}).ToList(),
};
}
/// <summary>Итоговая скидка по каждой строке = строчная скидка + доля скидки
/// на чек (пропорционально сумме строки). Сумма долей точно равна скидке на
/// чек: остаток от округления добавляется к последней непустой строке.</summary>
private static decimal[] DistributeCartDiscount(IReadOnlyList<CartLine> lines, decimal cartDiscount)
{
var result = lines.Select(l => l.Discount).ToArray();
if (cartDiscount <= 0m || lines.Count == 0) return result;
var totals = lines.Select(l => l.LineTotal).ToArray();
var sum = totals.Sum();
if (sum <= 0m) return result;
decimal allocated = 0m;
int lastIdx = -1;
for (int i = 0; i < lines.Count; i++)
{
if (totals[i] <= 0m) continue;
var share = decimal.Round(cartDiscount * totals[i] / sum, 2);
result[i] += share;
allocated += share;
lastIdx = i;
}
// Остаток округления — на последнюю участвовавшую строку.
if (lastIdx >= 0 && allocated != cartDiscount)
result[lastIdx] += cartDiscount - allocated;
return result;
}
}

View file

@ -0,0 +1,31 @@
namespace foodmarket.Pos.Core.Logic;
public readonly record struct PaymentResult(bool Sufficient, decimal Paid, decimal Due, decimal Change, decimal Shortfall);
/// <summary>Расчёт оплаты и сдачи. Нал + карта должны покрыть итог; переплата —
/// сдача (всегда наличными). Недоплата — чек провести нельзя.</summary>
public static class PaymentMath
{
private const decimal Eps = 0.005m;
public static PaymentResult Compute(decimal total, decimal paidCash, decimal paidCard)
{
var due = decimal.Round(total, 2);
var paid = decimal.Round(paidCash + paidCard, 2);
var sufficient = paid + Eps >= due;
var change = sufficient ? Math.Max(0m, decimal.Round(paid - due, 2)) : 0m;
var shortfall = sufficient ? 0m : decimal.Round(due - paid, 2);
return new PaymentResult(sufficient, paid, due, change, shortfall);
}
}
/// <summary>Расчёт смены (зеркало серверного MoneyMath — чтобы касса показывала
/// «должно быть/факт/разница» офлайн, до подтверждения сервером).</summary>
public static class ShiftMath
{
public static decimal ExpectedCash(decimal openingFloat, decimal salesCash, decimal returnsCash, decimal drawerIn, decimal drawerOut)
=> decimal.Round(openingFloat + salesCash - returnsCash + drawerIn - drawerOut, 2);
public static decimal CashDifference(decimal actualCash, decimal expectedCash)
=> decimal.Round(actualCash - expectedCash, 2);
}

View file

@ -0,0 +1,35 @@
namespace foodmarket.Pos.Core.Logic;
/// <summary>Сравнение версий кассы для автообновления. Версия — набор чисел
/// через точку (напр. 2026.10.8 или 1.2.3); сравниваются покомпонентно.
/// Чистая логика — тестируется.</summary>
public static class UpdateCheck
{
/// <summary>true — <paramref name="remote"/> новее <paramref name="local"/>.</summary>
public static bool IsNewer(string? local, string? remote)
{
var a = Parse(local);
var b = Parse(remote);
int n = Math.Max(a.Length, b.Length);
for (int i = 0; i < n; i++)
{
int ai = i < a.Length ? a[i] : 0;
int bi = i < b.Length ? b[i] : 0;
if (bi != ai) return bi > ai;
}
return false;
}
private static int[] Parse(string? v)
{
if (string.IsNullOrWhiteSpace(v)) return Array.Empty<int>();
var parts = v.Trim().Split('.', '-', '+');
var nums = new List<int>();
foreach (var p in parts)
{
if (int.TryParse(p, out var x)) nums.Add(x);
else break; // на первом нечисловом сегменте останавливаемся (напр. «-beta»)
}
return nums.ToArray();
}
}

View file

@ -0,0 +1,54 @@
namespace foodmarket.Pos.Core.Models;
/// <summary>Тип упаковки (зеркало Domain.Packaging): штучный/весовой/разливной.</summary>
public enum PosPackaging { Piece = 1, Weight = 2, Liquid = 3 }
public enum PosPaymentMethod { Cash = 0, Card = 1, BankTransfer = 2, Bonus = 3, Mixed = 99 }
/// <summary>Товар в локальном каталоге кассы (из ProductSyncDto + системная цена).</summary>
public sealed class PosProduct
{
public required Guid Id { get; init; }
public required string Name { get; init; }
public string? Article { get; init; }
public required IReadOnlyList<string> Barcodes { get; init; }
public required string UnitCode { get; init; }
public required PosPackaging Packaging { get; init; }
public required decimal VatPercent { get; init; }
public required bool VatEnabled { get; init; }
public required decimal Price { get; init; }
public bool IsArchived { get; init; }
public bool IsWeighed => Packaging == PosPackaging.Weight;
}
/// <summary>Строка чека. Количество дробное (весовой/разлив). Скидка — абсолютная
/// сумма на строку (как на сервере RetailSaleLineInput.Discount).</summary>
public sealed class CartLine
{
public required Guid ProductId { get; init; }
public required string Name { get; init; }
public required string UnitCode { get; init; }
public required bool IsWeighed { get; init; }
public required decimal UnitPrice { get; set; }
public decimal Quantity { get; set; } = 1m;
public decimal Discount { get; set; }
public decimal VatPercent { get; set; }
/// <summary>Сумма строки = цена×кол-во − скидка (не меньше нуля).</summary>
public decimal LineTotal => Math.Max(0m, decimal.Round(UnitPrice * Quantity, 2) - Discount);
}
/// <summary>Состояние открытой смены (локальная проекция серверной CashShift).</summary>
public sealed class PosShift
{
public required Guid Id { get; init; }
public required string Number { get; init; }
public required Guid RetailPointId { get; init; }
public required Guid CashAccountId { get; init; }
public Guid? AcquiringAccountId { get; init; }
public required decimal OpeningFloat { get; init; }
public DateTime OpenedAt { get; init; } = DateTime.UtcNow;
}
/// <summary>Статус записи в очереди синхронизации продаж.</summary>
public enum QueuedSaleStatus { Pending = 0, Sent = 1, Failed = 2 }

View file

@ -0,0 +1,58 @@
using foodmarket.Shared.Pos.V1;
using Refit;
namespace foodmarket.Pos.Core.Sync;
/// <summary>Серверный API, который дёргает касса. Авторизация — через
/// DelegatingHandler (Bearer из токен-стора + refresh). Версия POS-API закреплена
/// заголовком. Смены/деньги — общий серверный модуль «Деньги».</summary>
[Headers("X-Pos-Api-Version: 1")]
public interface IServerApi
{
[Get("/api/pos/v1/sync")]
Task<PosSyncResponse> SyncAsync([Query] DateTime? since, CancellationToken ct = default);
[Post("/api/pos/v1/sales")]
Task<PosSaleBatchResponse> AcceptSalesAsync([Body] PosSaleBatchDto batch, CancellationToken ct = default);
[Get("/api/pos/v1/version")]
Task<PosVersionDto> VersionAsync(CancellationToken ct = default);
[Get("/api/me")]
Task<MeDto> MeAsync(CancellationToken ct = default);
[Get("/api/catalog/retail-points")]
Task<PagedDto<RetailPointDto>> RetailPointsAsync([Query] int pageSize = 200, CancellationToken ct = default);
[Get("/api/money/accounts")]
Task<PagedDto<MoneyAccountDto>> MoneyAccountsAsync([Query] int pageSize = 200, CancellationToken ct = default);
[Get("/api/money/shifts/current")]
Task<ApiResponse<CurrentShiftDto>> CurrentShiftAsync([Query] Guid retailPointId, CancellationToken ct = default);
[Post("/api/money/shifts/open")]
Task<OpenShiftResultDto> OpenShiftAsync([Body] OpenShiftBody body, CancellationToken ct = default);
[Post("/api/money/shifts/{id}/close")]
Task<CloseShiftResultDto> CloseShiftAsync(Guid id, [Body] CloseShiftBody body, CancellationToken ct = default);
[Post("/api/money/shifts/{id}/drawer")]
Task DrawerAsync(Guid id, [Body] DrawerBody body, CancellationToken ct = default);
[Get("/api/money/shifts/{id}/report")]
Task<ShiftReportDto> ShiftReportAsync(Guid id, CancellationToken ct = default);
}
public record MeDto(Guid? orgId, string[] roles, bool advancedMode, string? name, string? email, string sub);
public record PagedDto<T>(IReadOnlyList<T> items, int total);
public record RetailPointDto(Guid id, string name, string? code, Guid? storeId, bool isActive);
public record MoneyAccountDto(Guid id, string name, int kind, Guid? storeId, bool isActive);
public record CurrentShiftDto(bool open, ShiftDto? shift, bool? isMine);
public record ShiftDto(Guid id, string number, Guid retailPointId, Guid cashAccountId, Guid? acquiringAccountId, decimal openingFloat);
public record OpenShiftBody(Guid RetailPointId, Guid CashAccountId, Guid? AcquiringAccountId, decimal OpeningFloat);
public record OpenShiftResultDto(Guid id, string number);
public record CloseShiftBody(decimal ActualCash, string? Notes);
public record CloseShiftResultDto(decimal expectedCash, decimal difference);
public record DrawerBody(int Direction, decimal Amount, string Reason); // 1=In, 2=Out
public record ShiftReportDto(string number, decimal openingFloat, ShiftSales sales, ShiftSales returns, decimal expectedCash, decimal? actualCash, decimal cashDifference);
public record ShiftSales(decimal cash, decimal card);

View file

@ -0,0 +1,52 @@
using System.Security.Cryptography;
using System.Text;
using foodmarket.Pos.Core.Data;
using foodmarket.Shared.Pos.V1;
namespace foodmarket.Pos.Core.Sync;
public record PushResult(int Accepted, int Failed, bool ReplayedFromCache, IReadOnlyList<PosSaleFailedDto> Failures);
/// <summary>Синхронизация кассы с сервером: дельта-загрузка каталога (pull) и
/// выгрузка очереди продаж (push, идемпотентно). Чистая логика — тестируется
/// против моков / реального API.</summary>
public sealed class SyncService
{
private readonly PosStore _store;
private readonly IServerApi _api;
public SyncService(PosStore store, IServerApi api) { _store = store; _api = api; }
/// <summary>Загрузить изменения каталога с сервера (дельта по last_sync).</summary>
public async Task<int> PullCatalogAsync(CancellationToken ct = default)
{
var resp = await _api.SyncAsync(_store.LastSync(), ct);
_store.ApplySync(resp);
return _store.ProductCount();
}
/// <summary>Отправить накопленные продажи батчем. Идемпотентный ключ стабилен
/// для одного и того же набора client-id, поэтому повтор не создаёт дублей.</summary>
public async Task<PushResult> PushSalesAsync(CancellationToken ct = default)
{
var pending = _store.PendingSales();
if (pending.Count == 0) return new PushResult(0, 0, false, Array.Empty<PosSaleFailedDto>());
var key = StableBatchKey(pending.Select(s => s.ClientSaleId));
var batch = new PosSaleBatchDto { IdempotencyKey = key, Sales = pending };
var resp = await _api.AcceptSalesAsync(batch, ct);
foreach (var a in resp.Accepted) _store.MarkSent(a.ClientSaleId);
foreach (var f in resp.Failed) _store.MarkFailed(f.ClientSaleId, f.Error);
return new PushResult(resp.Accepted.Count, resp.Failed.Count, resp.ReplayedFromCache, resp.Failed);
}
/// <summary>Детерминированный Guid из набора client-id (отсортированных) —
/// один и тот же набор продаж → один и тот же ключ (идемпотентность при ретрае).</summary>
public static Guid StableBatchKey(IEnumerable<Guid> clientSaleIds)
{
var joined = string.Join(",", clientSaleIds.Select(g => g.ToString("N")).OrderBy(x => x, StringComparer.Ordinal));
var hash = MD5.HashData(Encoding.UTF8.GetBytes(joined));
return new Guid(hash);
}
}

View file

@ -10,8 +10,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" /> <PackageReference Include="Microsoft.Data.Sqlite" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Include="CommunityToolkit.Mvvm" /> <PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="Refit" /> <PackageReference Include="Refit" />
<PackageReference Include="Refit.HttpClientFactory" /> <PackageReference Include="Refit.HttpClientFactory" />

View file

@ -0,0 +1,35 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:foodmarket.Pos"
x:Class="foodmarket.Pos.App"
RequestedThemeVariant="Dark">
<Application.DataTemplates>
<local:ViewLocator />
</Application.DataTemplates>
<Application.Styles>
<FluentTheme />
<!-- Крупный кассовый стиль: большие кнопки, читаемые цифры, без анимаций. -->
<Style Selector="Button.pos">
<Setter Property="Height" Value="56" />
<Setter Property="FontSize" Value="18" />
<Setter Property="Padding" Value="18,0" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Background" Value="#2d3748" />
<Setter Property="Foreground" Value="#f7fafc" />
</Style>
<Style Selector="Button.primary">
<Setter Property="Background" Value="#16a34a" />
<Setter Property="Foreground" Value="White" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<Style Selector="Button.danger">
<Setter Property="Background" Value="#dc2626" />
<Setter Property="Foreground" Value="White" />
</Style>
<Style Selector="TextBox.pos">
<Setter Property="FontSize" Value="20" />
<Setter Property="Height" Value="52" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
</Application.Styles>
</Application>

View file

@ -0,0 +1,23 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using foodmarket.Pos.Services;
using foodmarket.Pos.ViewModels;
using foodmarket.Pos.Views;
namespace foodmarket.Pos;
public partial class App : Application
{
public override void Initialize() => AvaloniaXamlLoader.Load(this);
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var services = new AppServices();
desktop.MainWindow = new MainWindow { DataContext = new MainWindowViewModel(services) };
}
base.OnFrameworkInitializationCompleted();
}
}

View file

@ -1,7 +0,0 @@
<Application x:Class="foodmarket.Pos.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="MainWindow.xaml">
<Application.Resources>
</Application.Resources>
</Application>

View file

@ -1,5 +0,0 @@
namespace foodmarket.Pos;
public partial class App : System.Windows.Application
{
}

View file

@ -0,0 +1,20 @@
namespace foodmarket.Pos.Hardware;
public sealed record FiscalResult(bool Fiscalized, string? FiscalNumber, string? QrPayload, string? Error);
/// <summary>Фискализация чека у ОФД-оператора (онлайн-ККМ Казахстана). СЕЙЧАС —
/// только интерфейс и заглушка (<see cref="NullFiscalProvider"/>): фискализацию
/// не делаем, чек проходит без фискального номера. Что потребуется для реальной
/// фискализации KZ — см. docs/pos.md раздел «Фискализация».</summary>
public interface IFiscalProvider
{
bool Enabled { get; }
Task<FiscalResult> RegisterSaleAsync(object sale, CancellationToken ct = default);
}
public sealed class NullFiscalProvider : IFiscalProvider
{
public bool Enabled => false;
public Task<FiscalResult> RegisterSaleAsync(object sale, CancellationToken ct = default)
=> Task.FromResult(new FiscalResult(false, null, null, null));
}

View file

@ -0,0 +1,105 @@
using System.Text;
namespace foodmarket.Pos.Hardware;
public sealed record ReceiptLine(string Left, string? Right = null, bool Bold = false, bool Big = false, bool Center = false);
/// <summary>Чек/отчёт в абстрактном виде — рендерится либо в ESC/POS, либо в текст/PDF.</summary>
public sealed class ReceiptDoc
{
public string Title { get; init; } = "";
public List<ReceiptLine> Lines { get; } = new();
public int WidthChars { get; init; } = 48; // 80мм ≈ 48 симв; 58мм ≈ 32
public void Add(string left, string? right = null, bool bold = false, bool big = false, bool center = false)
=> Lines.Add(new ReceiptLine(left, right, bold, big, center));
public void Separator() => Lines.Add(new ReceiptLine(new string('-', WidthChars)));
}
public interface IReceiptPrinter
{
string Name { get; }
Task PrintAsync(ReceiptDoc doc, CancellationToken ct = default);
}
/// <summary>Генератор команд ESC/POS (чистая логика → байты; тестируется).</summary>
public static class EscPos
{
private static readonly Encoding Cp866 = CodePagesEncodingProvider.Instance.GetEncoding(866) ?? Encoding.UTF8;
public static byte[] Build(ReceiptDoc doc)
{
var buf = new List<byte>();
buf.AddRange(new byte[] { 0x1B, 0x40 }); // ESC @ — init
buf.AddRange(new byte[] { 0x1B, 0x74, 0x11 }); // codepage CP866 (RU)
void Mode(bool bold, bool big, bool center)
{
buf.AddRange(new byte[] { 0x1B, 0x61, (byte)(center ? 1 : 0) }); // align
buf.AddRange(new byte[] { 0x1B, 0x45, (byte)(bold ? 1 : 0) }); // bold
buf.AddRange(new byte[] { 0x1D, 0x21, (byte)(big ? 0x11 : 0x00) });// size 2x
}
if (!string.IsNullOrEmpty(doc.Title)) { Mode(true, true, true); Line(doc.Title); }
foreach (var l in doc.Lines)
{
Mode(l.Bold, l.Big, l.Center);
Line(l.Right is null ? l.Left : Pad(l.Left, l.Right, doc.WidthChars));
}
Mode(false, false, false);
buf.AddRange(new byte[] { 0x0A, 0x0A, 0x0A }); // feed
buf.AddRange(new byte[] { 0x1D, 0x56, 0x42, 0x00 }); // GS V — full cut
return buf.ToArray();
void Line(string s) { buf.AddRange(Cp866.GetBytes(s)); buf.Add(0x0A); }
}
public static string Pad(string left, string right, int width)
{
var space = Math.Max(1, width - left.Length - right.Length);
if (left.Length + right.Length + 1 > width) left = left.Substring(0, Math.Max(0, width - right.Length - 1));
return left + new string(' ', Math.Max(1, width - left.Length - right.Length)) + right;
}
}
/// <summary>ESC/POS-принтер: пишет байты в поток (COM/USB raw/сетевой сокет).
/// Транспорт выбирается строкой: "COM1", "/dev/usb/lp0", "tcp:192.168.1.50:9100".</summary>
public sealed class EscPosReceiptPrinter : IReceiptPrinter
{
private readonly string _target;
public EscPosReceiptPrinter(string target) { _target = target; }
public string Name => $"ESC/POS ({_target})";
public async Task PrintAsync(ReceiptDoc doc, CancellationToken ct = default)
{
var bytes = EscPos.Build(doc);
if (_target.StartsWith("tcp:", StringComparison.OrdinalIgnoreCase))
{
var parts = _target[4..].Split(':');
using var tcp = new System.Net.Sockets.TcpClient();
await tcp.ConnectAsync(parts[0], int.Parse(parts[1]), ct);
await tcp.GetStream().WriteAsync(bytes, ct);
}
else
{
await using var fs = new FileStream(_target, FileMode.Open, FileAccess.Write);
await fs.WriteAsync(bytes, ct);
}
}
}
/// <summary>Запасной «принтер»: сохраняет чек как текстовый файл (для обычного
/// принтера/PDF-экспорта и для headless-проверки). Полноценный PDF — через
/// QuestPDF (MIT) в будущем; сейчас текстовый слип в папке чеков.</summary>
public sealed class PdfReceiptPrinter : IReceiptPrinter
{
public string Name => "Текст/PDF (запасной)";
public async Task PrintAsync(ReceiptDoc doc, CancellationToken ct = default)
{
var dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "food-market-pos", "receipts");
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, $"receipt-{DateTime.Now:yyyyMMdd-HHmmss-fff}.txt");
var sb = new StringBuilder();
if (!string.IsNullOrEmpty(doc.Title)) sb.AppendLine(Center(doc.Title, doc.WidthChars));
foreach (var l in doc.Lines) sb.AppendLine(l.Right is null ? (l.Center ? Center(l.Left, doc.WidthChars) : l.Left) : EscPos.Pad(l.Left, l.Right, doc.WidthChars));
await File.WriteAllTextAsync(path, sb.ToString(), ct);
}
private static string Center(string s, int w) => s.Length >= w ? s : new string(' ', (w - s.Length) / 2) + s;
}

View file

@ -0,0 +1,82 @@
using System.Globalization;
using System.IO.Ports;
namespace foodmarket.Pos.Hardware;
public interface IScale
{
string Name { get; }
/// <summary>Считать текущий вес в кг. Для ручных весов — последнее введённое значение.</summary>
Task<decimal> ReadWeightKgAsync(CancellationToken ct = default);
}
/// <summary>Ручной ввод веса (кассир вбивает вес с ярлыка/памяти) — запасной путь,
/// когда весов нет или нет связи с ними.</summary>
public sealed class ManualScale : IScale
{
public decimal LastValue { get; set; }
public string Name => "Ручной ввод веса";
public Task<decimal> ReadWeightKgAsync(CancellationToken ct = default) => Task.FromResult(LastValue);
}
/// <summary>Разбор ответа весов по COM. Поддержаны простые ASCII-протоколы
/// CAS (строка с граммами/кг + завершитель) и Масса-К (ASCII «вес кг»). Парсер
/// выделен и тестируется отдельно; реальный COM-поток — в <see cref="ComScale"/>.</summary>
public static class ScaleProtocol
{
/// <summary>CAS: ASCII-строка вида "ST,GS, 1.234kg" или "+001.234 kg" — тянем
/// первое число, трактуем как кг (если явно граммы — делим).</summary>
public static decimal? ParseCas(string raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
var isGrams = raw.Contains('g', StringComparison.OrdinalIgnoreCase) && !raw.Contains("kg", StringComparison.OrdinalIgnoreCase);
var num = ExtractNumber(raw);
if (num is null) return null;
var kg = isGrams ? num.Value / 1000m : num.Value;
return decimal.Round(kg, 3);
}
/// <summary>Масса-К (ASCII-режим): строка с весом в кг, например "1.234".</summary>
public static decimal? ParseMassaK(string raw)
{
var num = ExtractNumber(raw);
return num is null ? null : decimal.Round(num.Value, 3);
}
private static decimal? ExtractNumber(string s)
{
var chars = new System.Text.StringBuilder();
bool seen = false;
foreach (var c in s)
{
if (char.IsDigit(c) || c == '.' || c == ',' || (!seen && (c == '+' || c == '-')))
{ chars.Append(c == ',' ? '.' : c); seen = char.IsDigit(c) || seen; }
else if (seen && chars.Length > 0 && (chars[^1] is >= '0' and <= '9')) break;
}
return decimal.TryParse(chars.ToString().Trim('+'), NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : null;
}
}
/// <summary>Весы по COM/serial. Протокол: Cas | MassaK. Открывает порт, шлёт
/// запрос веса (опц.), читает строку, парсит. Доступно на Windows и Linux
/// (System.IO.Ports).</summary>
public sealed class ComScale : IScale
{
public enum Proto { Cas, MassaK }
private readonly string _port;
private readonly int _baud;
private readonly Proto _proto;
public ComScale(string port, int baud, Proto proto) { _port = port; _baud = baud; _proto = proto; }
public string Name => $"Весы {_proto} ({_port})";
public async Task<decimal> ReadWeightKgAsync(CancellationToken ct = default)
{
using var sp = new SerialPort(_port, _baud) { ReadTimeout = 1500, NewLine = "\r" };
sp.Open();
if (_proto == Proto.Cas) sp.Write(new byte[] { (byte)'W' }, 0, 1); // запрос веса (ряд CAS)
await Task.Delay(120, ct);
var line = sp.ReadLine();
var kg = _proto == Proto.Cas ? ScaleProtocol.ParseCas(line) : ScaleProtocol.ParseMassaK(line);
return kg ?? 0m;
}
}

View file

@ -1,11 +0,0 @@
<Window x:Class="foodmarket.Pos.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="food-market POS" Height="600" Width="900"
WindowStartupLocation="CenterScreen">
<Grid>
<TextBlock Text="food-market POS (каркас)"
HorizontalAlignment="Center" VerticalAlignment="Center"
FontSize="24" />
</Grid>
</Window>

View file

@ -1,9 +0,0 @@
namespace foodmarket.Pos;
public partial class MainWindow : System.Windows.Window
{
public MainWindow()
{
InitializeComponent();
}
}

View file

@ -0,0 +1,28 @@
using Avalonia;
using foodmarket.Pos.Tooling;
namespace foodmarket.Pos;
internal static class Program
{
[STAThread]
public static int Main(string[] args)
{
// CLI-режимы (без интерактивного окна):
// --smoke : прогон сценария кассы через логику (без UI), печать результатов.
// --screenshots : headless-рендер экранов в docs/pos-screenshots (1920×1080 + 1366×768).
// --uitest : headless UI-автотест экрана продажи (Avalonia.Headless, для CI/xvfb).
if (args.Contains("--smoke")) return SmokeRunner.Run(args);
if (args.Contains("--screenshots")) return ScreenshotRunner.Run(args);
if (args.Contains("--uitest")) return UiTestRunner.Run(args);
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
return 0;
}
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}

View file

@ -0,0 +1,82 @@
using System.Net.Http;
using System.Net.Http.Headers;
using foodmarket.Pos.Core.Auth;
using foodmarket.Pos.Core.Data;
using foodmarket.Pos.Core.Sync;
using foodmarket.Pos.Hardware;
using Refit;
namespace foodmarket.Pos.Services;
/// <summary>Лёгкий контейнер сервисов кассы (без тяжёлого DI — быстрый старт).
/// Держит локальное хранилище, http-клиент, авторизацию, Refit-API (после входа),
/// сервис синхронизации и оборудование (печать/весы/фискализация-заглушка).</summary>
public sealed class AppServices
{
public PosStore Store { get; }
public HttpClient Http { get; }
public AuthService Auth { get; }
public ITokenStore Tokens { get; }
public PosSession? Session { get; set; }
public IServerApi? Api { get; private set; }
public SyncService? Sync { get; private set; }
public IReceiptPrinter Printer { get; set; } = new PdfReceiptPrinter(); // запасной по умолчанию; ESC/POS выбирается в настройках
public IScale Scale { get; set; } = new ManualScale();
public IFiscalProvider Fiscal { get; } = new NullFiscalProvider();
private CheckoutService? _checkout;
/// <summary>Оркестратор завершения чека (очередь → push → печать).</summary>
public CheckoutService Checkout => _checkout ??= new CheckoutService(this);
public AppServices()
{
var dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "food-market-pos");
Directory.CreateDirectory(dir);
Store = new PosStore(Path.Combine(dir, "catalog.db"));
Http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
Auth = new AuthService(Http);
// Windows — DPAPI (привязка к учётке), иначе файл с правами 0600.
Tokens = OperatingSystem.IsWindows()
? new DpapiTokenStore(FileTokenStore.DefaultPath())
: new FileTokenStore(FileTokenStore.DefaultPath());
Session = Tokens.Load();
if (Session is not null) ConfigureApi(Session);
}
/// <summary>Контейнер с готовым хранилищем — для headless UI-тестов/демо
/// (без файлов на диске и без токен-стора).</summary>
public AppServices(PosStore store)
{
Store = store;
Http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
Auth = new AuthService(Http);
Tokens = new InMemoryTokenStore();
}
/// <summary>Поднять Refit-клиент на базовый URL сервера с Bearer-токеном сессии.</summary>
public void ConfigureApi(PosSession session)
{
Session = session;
var client = new HttpClient(new AuthHeaderHandler(() => Session?.AccessToken, new HttpClientHandler()))
{
BaseAddress = new Uri(session.ServerUrl.TrimEnd('/')),
Timeout = TimeSpan.FromSeconds(30),
};
Api = RestService.For<IServerApi>(client);
Sync = new SyncService(Store, Api);
}
}
/// <summary>Подставляет Bearer-токен в каждый запрос.</summary>
internal sealed class AuthHeaderHandler : DelegatingHandler
{
private readonly Func<string?> _token;
public AuthHeaderHandler(Func<string?> token, HttpMessageHandler inner) : base(inner) { _token = token; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
var t = _token();
if (!string.IsNullOrEmpty(t)) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", t);
return base.SendAsync(request, ct);
}
}

View file

@ -0,0 +1,86 @@
using foodmarket.Pos.Core.Logic;
using foodmarket.Pos.Core.Models;
using foodmarket.Pos.Hardware;
using foodmarket.Shared.Pos.V1;
namespace foodmarket.Pos.Services;
/// <summary>Оркестрация завершения чека: собрать серверный чек из корзины
/// (с привязкой к смене/точке и кассиру), записать в локальную очередь
/// (офлайн-устойчиво), попытаться сразу выгрузить на сервер и напечатать чек.
/// Запись в очередь — источник правды: даже если сеть/печать упали, продажа
/// не теряется и уйдёт при следующей синхронизации.</summary>
public sealed class CheckoutService
{
private readonly AppServices _svc;
public CheckoutService(AppServices svc) => _svc = svc;
/// <summary>Открытая смена кассы (локальная проекция). Без неё продавать нельзя.</summary>
public PosShift? CurrentShift { get; set; }
public bool HasOpenShift => CurrentShift is not null;
public sealed record CommitResult(Guid ClientSaleId, bool PushedNow, int PendingAfter, bool Printed, string? PrintError);
/// <summary>Завершить продажу/возврат. Возвращает статус (ушло ли сразу,
/// сколько осталось в очереди, напечатался ли чек).</summary>
public async Task<CommitResult> CommitAsync(
Cart cart, decimal paidCash, decimal paidCard, decimal change,
bool isReturn = false, Guid? referenceSaleId = null, Guid? customerId = null,
CancellationToken ct = default)
{
var ctx = new CheckoutContext(
_svc.Session?.CashierUserId,
CurrentShift?.RetailPointId ?? _svc.Session?.RetailPointId,
CurrentShift?.Id);
var sale = Checkout.BuildSale(cart, paidCash, paidCard, ctx,
isReturn: isReturn, referenceSaleId: referenceSaleId, customerId: customerId);
// 1) Очередь — всегда (офлайн-устойчивость).
_svc.Store.EnqueueSale(sale);
// 2) Печать чека (не критично — ошибку печати не теряем продажу).
bool printed = false; string? printErr = null;
try { await _svc.Printer.PrintAsync(BuildReceipt(cart, paidCash, paidCard, change, isReturn), ct); printed = true; }
catch (Exception ex) { printErr = ex.Message; }
// 3) Пытаемся сразу выгрузить. Нет сети — останется в очереди.
bool pushedNow = false;
if (_svc.Sync is not null)
{
try { var r = await _svc.Sync.PushSalesAsync(ct); pushedNow = r.Accepted > 0; }
catch { /* офлайн — молча оставляем в очереди */ }
}
return new CommitResult(sale.ClientSaleId, pushedNow, _svc.Store.PendingCount(), printed, printErr);
}
private ReceiptDoc BuildReceipt(Cart cart, decimal paidCash, decimal paidCard, decimal change, bool isReturn)
{
var t = cart.Totals();
var doc = new ReceiptDoc { Title = isReturn ? "ВОЗВРАТ" : "ТОВАРНЫЙ ЧЕК" };
doc.Add("Food Market", center: true, bold: true);
if (CurrentShift?.Number is { } sn) doc.Add($"Смена {sn}", center: true);
if (_svc.Session?.CashierName is { } c) doc.Add($"Кассир: {c}");
doc.Add(DateTime.Now.ToString("dd.MM.yyyy HH:mm:ss"));
doc.Separator();
foreach (var l in cart.Lines)
{
doc.Add(l.Name);
var qty = l.IsWeighed ? l.Quantity.ToString("0.###") : l.Quantity.ToString("0");
doc.Add($" {qty} {l.UnitCode} x {l.UnitPrice:N0}", $"{l.LineTotal:N0}");
}
doc.Separator();
if (t.LineDiscounts + t.CartDiscount > 0m)
doc.Add("Скидка", $"-{t.LineDiscounts + t.CartDiscount:N0}");
doc.Add("ИТОГО", $"{t.Total:N0} ₸", bold: true, big: true);
if (paidCash > 0m) doc.Add("Наличные", $"{paidCash:N0}");
if (paidCard > 0m) doc.Add("Карта", $"{paidCard:N0}");
if (change > 0m) doc.Add("Сдача", $"{change:N0}");
if (t.Vat > 0m) doc.Add("в т.ч. НДС", $"{t.Vat:N0}");
doc.Separator();
doc.Add("Спасибо за покупку!", center: true);
return doc;
}
}

View file

@ -0,0 +1,31 @@
using System.Runtime.Versioning;
using System.Security.Cryptography;
using System.Text;
using foodmarket.Pos.Core.Auth;
namespace foodmarket.Pos.Services;
/// <summary>Хранение сессии кассира под Windows с шифрованием DPAPI (ключ
/// привязан к учётной записи пользователя Windows, scope = CurrentUser). Файл
/// содержит refresh-токен — DPAPI не даёт прочитать его другому пользователю
/// или на другой машине. На Unix используется базовый <see cref="FileTokenStore"/>
/// с правами 0600.</summary>
[SupportedOSPlatform("windows")]
public sealed class DpapiTokenStore : FileTokenStore
{
private static readonly byte[] Entropy = Encoding.UTF8.GetBytes("food-market-pos/v1");
public DpapiTokenStore(string path) : base(path) { }
protected override string Protect(string plain)
{
var bytes = ProtectedData.Protect(Encoding.UTF8.GetBytes(plain), Entropy, DataProtectionScope.CurrentUser);
return Convert.ToBase64String(bytes);
}
protected override string Unprotect(string stored)
{
var bytes = ProtectedData.Unprotect(Convert.FromBase64String(stored), Entropy, DataProtectionScope.CurrentUser);
return Encoding.UTF8.GetString(bytes);
}
}

View file

@ -0,0 +1,105 @@
using System.Text;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using foodmarket.Pos.ViewModels;
using foodmarket.Pos.Views;
namespace foodmarket.Pos.Tooling;
/// <summary>Headless-рендер экранов кассы в PNG (без дисплея, Skia). По порядку
/// сценария, в 1920×1080 и 1366×768 → docs/pos-screenshots/ + index.html.
/// Запуск: dotnet run -- --screenshots [outputDir]</summary>
public static class ScreenshotRunner
{
private sealed record Shot(string Name, string Caption, Func<Control> Build);
public static int Run(string[] args)
{
var outDir = args.SkipWhile(a => a != "--screenshots").Skip(1).FirstOrDefault(a => !a.StartsWith("--"))
?? Path.Combine(FindRepoRoot(), "docs", "pos-screenshots");
Directory.CreateDirectory(outDir);
AppBuilder.Configure<App>()
.UseSkia()
.UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false })
.WithInterFont()
.SetupWithoutStarting();
var shots = new List<Shot>
{
new("01-login", "Вход кассира (адрес сервера, логин, PIN)", () => new LoginView { DataContext = LoginViewModel.Sample() }),
new("02-shift-open", "Смена: Z-отчёт/состояние смены", () => new ShiftView { DataContext = ShiftViewModel.Sample() }),
new("03-sale-empty", "Продажа: пустой чек, ожидание сканирования", () => new SaleView { DataContext = new SaleViewModel() }),
new("04-sale-items", "Продажа: чек с товарами (вкл. весовой)", () => new SaleView { DataContext = SaleViewModel.Sample() }),
new("05-payment-cash", "Оплата наличными со сдачей", () => new PaymentView { DataContext = PaymentViewModel.SampleCash() }),
new("06-payment-mixed", "Оплата смешанная (нал+карта)", () => new PaymentView { DataContext = PaymentViewModel.SampleMixed() }),
new("07-return", "Возврат по чеку (выбор позиций)", () => new ReturnView { DataContext = ReturnViewModel.Sample() }),
new("08-shift-z", "Z-отчёт при закрытии (должно/факт/разница)", () => new ShiftView { DataContext = ShiftViewModel.SampleClosed() }),
new("09-shell-offline", "Статус-бар: нет связи, чеки в очереди", () => ShellOffline()),
new("10-shell-online", "Статус-бар: на связи, очередь пуста", () => ShellOnline()),
};
var sizes = new (string suffix, int w, int h)[] { ("1920x1080", 1920, 1080), ("1366x768", 1366, 768) };
var produced = new List<(string file, string caption)>();
foreach (var shot in shots)
foreach (var (suffix, w, h) in sizes)
{
var control = shot.Build();
// Если это уже окно (оболочка) — рендерим его напрямую; иначе кладём в окно.
var window = control as Window ?? new Window { Content = control, SystemDecorations = SystemDecorations.None };
window.Width = w; window.Height = h;
window.Show();
Dispatcher.UIThread.RunJobs();
// дать разметке/рендеру отработать
for (int i = 0; i < 3; i++) { Dispatcher.UIThread.RunJobs(); }
var frame = window.CaptureRenderedFrame();
var file = $"{shot.Name}-{suffix}.png";
frame?.Save(Path.Combine(outDir, file));
produced.Add((file, $"{shot.Caption} ({suffix})"));
window.Close();
Console.WriteLine($"[shot] {file}");
}
WriteGallery(outDir, produced);
Console.WriteLine($"[done] {produced.Count} кадров → {outDir}");
return 0;
}
private static Control ShellOffline()
{
var vm = new MainWindowViewModel { IsOnline = false, QueueCount = 3, CashierName = "Пётр Кассиров", ShiftNumber = "С-2026-000001" };
return new MainWindow { DataContext = vm };
}
private static Control ShellOnline()
{
var vm = new MainWindowViewModel { IsOnline = true, QueueCount = 0, CashierName = "Пётр Кассиров", ShiftNumber = "С-2026-000001" };
return new MainWindow { DataContext = vm };
}
private static void WriteGallery(string dir, List<(string file, string caption)> shots)
{
var sb = new StringBuilder();
sb.Append("<!doctype html><html lang=\"ru\"><head><meta charset=\"utf-8\"><title>Касса food-market — скриншоты</title>");
sb.Append("<style>body{font:14px system-ui,Segoe UI,Roboto;margin:0;background:#0f172a;color:#e2e8f0}" +
"header{padding:18px 24px;background:#1e293b}h1{margin:0;font-size:20px}" +
".grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(420px,1fr));gap:16px;padding:20px}" +
"figure{margin:0;background:#1e293b;border:1px solid #334155;border-radius:10px;overflow:hidden}" +
"img{width:100%;display:block;background:#000;cursor:zoom-in}figcaption{padding:8px 10px;color:#cbd5e1;font-size:13px}</style></head><body>");
sb.Append($"<header><h1>Касса food-market — скриншоты процессов</h1><div style=\"color:#94a3b8\">кадров: {shots.Count}</div></header><div class=\"grid\">");
foreach (var (file, caption) in shots)
sb.Append($"<figure><a href=\"{file}\" target=\"_blank\"><img src=\"{file}\" loading=\"lazy\"></a><figcaption>{System.Net.WebUtility.HtmlEncode(caption)}</figcaption></figure>");
sb.Append("</div></body></html>");
File.WriteAllText(Path.Combine(dir, "index.html"), sb.ToString());
}
private static string FindRepoRoot()
{
var d = new DirectoryInfo(AppContext.BaseDirectory);
while (d is not null && !File.Exists(Path.Combine(d.FullName, "food-market.sln"))) d = d.Parent;
return d?.FullName ?? Directory.GetCurrentDirectory();
}
}

View file

@ -0,0 +1,104 @@
using System.Diagnostics;
using foodmarket.Pos.Core.Data;
using foodmarket.Pos.Core.Logic;
using foodmarket.Pos.Core.Models;
using foodmarket.Pos.Core.Sync;
using foodmarket.Shared.Pos.V1;
namespace foodmarket.Pos.Tooling;
/// <summary>CLI-smoke кассы: сценарий «открыть смену → продажи → возврат → закрыть»
/// + офлайн-очередь, через логику pos.core (без UI/дисплея). Работает одинаково
/// на Windows и Linux — для проверки на реальной машине. Запуск: --smoke</summary>
public static class SmokeRunner
{
public static int Run(string[] args)
{
var lines = new List<string>();
void Log(string s) { Console.WriteLine(s); lines.Add(s); }
// Холодный старт: от запуска процесса до входа в логику (загрузка рантайма + JIT).
double coldStartMs = 0;
try { coldStartMs = (DateTime.Now - Process.GetCurrentProcess().StartTime).TotalMilliseconds; } catch { }
var sw = Stopwatch.StartNew();
using var store = PosStore.InMemory();
// Каталог: 30k товаров для замера поиска + несколько «реальных».
var ids = SeedCatalog(store, big: args.Contains("--big"));
var seedMs = sw.ElapsedMilliseconds;
// Замер: поиск по штрихкоду и по имени.
var swBc = Stopwatch.StartNew();
var byBc = store.FindByBarcode("4870000000005");
swBc.Stop();
var swName = Stopwatch.StartNew();
var found = store.SearchByName("товар 1", 20);
swName.Stop();
// Сценарий дня.
var cart = new Cart();
var p1 = store.GetProduct(ids[0])!; var p2 = store.GetProduct(ids[1])!;
cart.AddProduct(p1, 2); cart.AddProduct(p2, 1);
var totals = cart.Totals();
var pay = PaymentMath.Compute(totals.Total, totals.Total + 500m, 0m);
// Положить 5 продаж в очередь (офлайн).
for (int i = 0; i < 5; i++)
store.EnqueueSale(new PosSaleDto
{
ClientSaleId = Guid.NewGuid(), OccurredAt = DateTime.UtcNow, Payment = 0, PaidCash = totals.Total, PaidCard = 0,
Lines = new[] { new PosSaleLineDto { ProductId = ids[0], Quantity = 1m, UnitPrice = p1.Price, Discount = 0m } },
});
var pending = store.PendingCount();
var expected = ShiftMath.ExpectedCash(5000m, 3775m, 1500m, 10000m, 3000m);
var diff = ShiftMath.CashDifference(14775m, expected);
var batchKey = SyncService.StableBatchKey(store.PendingSales().Select(s => s.ClientSaleId));
Log("=== POS smoke ===");
Log($"ОС: {System.Runtime.InteropServices.RuntimeInformation.OSDescription}");
Log($"Холодный старт (процесс→логика): {coldStartMs:F0} мс");
Log($"Каталог засеян: {store.ProductCount()} товаров за {seedMs} мс");
Log($"Поиск по штрихкоду: {(byBc is null ? "НЕ найден" : byBc.Name)} за {swBc.Elapsed.TotalMilliseconds:F2} мс");
Log($"Поиск по имени: {found.Count} совпадений за {swName.Elapsed.TotalMilliseconds:F2} мс");
Log($"Чек: {totals.LineCount} поз., итог {totals.Total} ₸; оплата {pay.Paid} ₸ → сдача {pay.Change} ₸ (достаточно: {pay.Sufficient})");
Log($"Очередь офлайн: {pending} чеков (ожид. 5); idempotency-ключ батча: {batchKey}");
Log($"Смена: должно {expected} ₸, факт 14775 ₸, разница {diff} ₸");
Log($"Всего инициализация+сценарий: {sw.ElapsedMilliseconds} мс");
var ok = byBc is not null && pay.Sufficient && pending == 5 && expected == 14275m && diff == 500m;
Log(ok ? "РЕЗУЛЬТАТ: OK" : "РЕЗУЛЬТАТ: ОШИБКА");
// Результат в файл — на Windows exe собран как WinExe (нет консоли).
var outArg = args.SkipWhile(a => a != "--out").Skip(1).FirstOrDefault();
var outPath = outArg ?? Path.Combine(Path.GetTempPath(), "pos-smoke-result.txt");
try { File.WriteAllText(outPath, string.Join(Environment.NewLine, lines)); } catch { }
return ok ? 0 : 1;
}
private static List<Guid> SeedCatalog(PosStore store, bool big)
{
var n = big ? 30_000 : 50;
var products = new List<ProductSyncDto>(n);
var prices = new List<PriceSyncDto>(n);
var ids = new List<Guid>();
for (int i = 0; i < n; i++)
{
var id = Guid.NewGuid(); ids.Add(id);
var bc = (4_870_000_000_000 + i).ToString();
products.Add(new ProductSyncDto
{
Id = id, Name = $"Товар {i + 1}", Barcodes = new[] { bc }, UnitCode = i % 7 == 0 ? "кг" : "шт",
Packaging = i % 7 == 0 ? 2 : 1, VatPercent = 12m, VatEnabled = true, IsMarked = false, IsArchived = false, UpdatedAt = DateTime.UtcNow,
});
prices.Add(new PriceSyncDto { ProductId = id, PriceTypeId = Guid.Empty, PriceTypeName = "Розн", IsSystem = true, Amount = 100m + i % 900, CurrencyCode = "KZT", UpdatedAt = DateTime.UtcNow });
}
store.ApplySync(new PosSyncResponse
{
ServerTime = DateTime.UtcNow, Products = products, Prices = prices,
Stocks = Array.Empty<StockSyncDto>(), Counterparties = Array.Empty<CounterpartySyncDto>(), DeletedProductIds = Array.Empty<Guid>(),
});
return ids;
}
}

View file

@ -0,0 +1,129 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using Avalonia.VisualTree;
using foodmarket.Pos.Core.Data;
using foodmarket.Pos.Core.Models;
using foodmarket.Pos.Services;
using foodmarket.Pos.ViewModels;
using foodmarket.Pos.Views;
using foodmarket.Shared.Pos.V1;
namespace foodmarket.Pos.Tooling;
/// <summary>Headless UI-автотест (Avalonia.Headless + Skia, работает на Linux
/// без дисплея / под xvfb). Прогоняет реальный экран продажи сквозь привязки:
/// сид каталога → ввод штрихкода → команда Scan → в визуальном дереве должна
/// появиться строка с названием товара, а ИТОГО — отражать цену. Любое
/// расхождение → ненулевой код возврата (для CI). Запуск: --uitest</summary>
public static class UiTestRunner
{
public static int Run(string[] args)
{
int failures = 0;
void Check(bool ok, string label)
{
Console.WriteLine($"[uitest] {(ok ? "OK " : "FAIL")} {label}");
if (!ok) failures++;
}
AppBuilder.Configure<App>()
.UseSkia()
.UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false })
.WithInterFont()
.SetupWithoutStarting();
// 1) Каталог в памяти с одним товаром + штрихкодом.
using var store = PosStore.InMemory();
var pid = Guid.NewGuid();
store.ApplySync(new PosSyncResponse
{
ServerTime = DateTime.UtcNow,
Products = new[] { new ProductSyncDto
{
Id = pid, Name = "Кофе молотый 250г", Barcodes = new[] { "4870001112223" },
UnitCode = "шт", Packaging = 1, VatPercent = 12m, VatEnabled = true,
IsMarked = false, IsArchived = false, UpdatedAt = DateTime.UtcNow,
} },
Prices = new[] { new PriceSyncDto
{
ProductId = pid, PriceTypeId = Guid.Empty, PriceTypeName = "Розн",
IsSystem = true, Amount = 1290m, CurrencyCode = "KZT", UpdatedAt = DateTime.UtcNow,
} },
Stocks = Array.Empty<StockSyncDto>(), Counterparties = Array.Empty<CounterpartySyncDto>(),
DeletedProductIds = Array.Empty<Guid>(),
});
var svc = new AppServices(store);
var vm = new SaleViewModel(svc);
var view = new SaleView { DataContext = vm };
var window = new Window { Content = view, Width = 1366, Height = 768 };
window.Show();
Pump();
Check(vm.Lines.Count == 0, "стартовый чек пуст");
// 2) Эмуляция сканера: штрихкод «набран» в поле, команда Scan (Enter).
vm.ScanInput = "4870001112223";
vm.ScanCommand.Execute(null);
Pump();
Check(vm.Lines.Count == 1, "после скана 1 позиция в корзине");
Check(vm.Lines.Count == 1 && vm.Lines[0].Name == "Кофе молотый 250г", "название позиции верное");
Check(vm.Total == 1290m, $"ИТОГО = 1290, факт {vm.Total}");
Check(vm.ItemCount == 1, "счётчик позиций = 1");
// 3) Привязка дошла до визуального дерева: ищем TextBlock с названием.
var texts = view.GetVisualDescendants().OfType<TextBlock>().Select(t => t.Text).ToList();
Check(texts.Any(t => t == "Кофе молотый 250г"), "название отрисовано в дереве");
// Разделитель групп зависит от культуры (пробел/NBSP/запятая) — нормализуем.
static string Digits(string? s) => new string((s ?? "").Where(char.IsDigit).ToArray());
Check(texts.Any(t => t != null && t.Contains('₸') && Digits(t) == "1290"), "ИТОГО отрисовано (1290 ₸)");
// 4) Повторный скан того же товара → штучный инкремент (2 шт), не вторая строка.
vm.ScanInput = "4870001112223";
vm.ScanCommand.Execute(null);
Pump();
Check(vm.Lines.Count == 1 && vm.Lines[0].Quantity == 2m, "повторный скан штучного = +1 к количеству");
Check(vm.Total == 2580m, $"ИТОГО после 2 шт = 2580, факт {vm.Total}");
// 5) Рендер кадра не пустой (Skia headless реально отрисовал экран).
var frame = window.CaptureRenderedFrame();
Check(frame is Bitmap b && b.PixelSize.Width == 1366 && b.PixelSize.Height == 768, "кадр отрисован 1366×768");
// 6) Очистка чека (Esc).
vm.ClearCommand.Execute(null);
Pump();
Check(vm.Lines.Count == 0 && vm.Total == 0m, "очистка чека обнуляет корзину");
// 7) Оплата → запись чека в офлайн-очередь с привязкой к смене.
svc.Printer = new NullPrinter();
var shiftId = Guid.NewGuid(); var rpId = Guid.NewGuid();
svc.Checkout.CurrentShift = new PosShift { Id = shiftId, Number = "С-TEST-1", RetailPointId = rpId, CashAccountId = Guid.NewGuid(), OpeningFloat = 0m };
vm.ScanInput = "4870001112223";
vm.ScanCommand.Execute(null);
Pump();
vm.CommitPaymentAsync(2000m, 0m, 710m).GetAwaiter().GetResult();
Pump();
Check(store.PendingCount() == 1, "после оплаты 1 чек в офлайн-очереди");
var queued = store.PendingSales().FirstOrDefault();
Check(queued is not null && queued.CashShiftId == shiftId && queued.RetailPointId == rpId, "чек привязан к смене и точке");
Check(queued is not null && queued.PaidCash == 2000m, "сумма оплаты сохранена");
Check(vm.Lines.Count == 0, "корзина очищена после оплаты");
window.Close();
Console.WriteLine(failures == 0 ? "[uitest] РЕЗУЛЬТАТ: OK" : $"[uitest] РЕЗУЛЬТАТ: ПРОВАЛЕНО ({failures})");
return failures == 0 ? 0 : 1;
}
private static void Pump() { for (int i = 0; i < 4; i++) Dispatcher.UIThread.RunJobs(); }
/// <summary>Принтер-заглушка для теста (без записи файлов/портов).</summary>
private sealed class NullPrinter : foodmarket.Pos.Hardware.IReceiptPrinter
{
public string Name => "null";
public Task PrintAsync(foodmarket.Pos.Hardware.ReceiptDoc doc, CancellationToken ct = default) => Task.CompletedTask;
}
}

View file

@ -0,0 +1,20 @@
using System;
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using foodmarket.Pos.ViewModels;
namespace foodmarket.Pos;
/// <summary>Сопоставляет ViewModel → View по имени (…ViewModel → …View).</summary>
public sealed class ViewLocator : IDataTemplate
{
public Control Build(object? data)
{
if (data is null) return new TextBlock { Text = "null" };
var name = data.GetType().FullName!.Replace("ViewModels", "Views").Replace("ViewModel", "View");
var type = Type.GetType(name);
return type is not null ? (Control)Activator.CreateInstance(type)! : new TextBlock { Text = "Не найдено: " + name };
}
public bool Match(object? data) => data is ViewModelBase;
}

View file

@ -0,0 +1,508 @@
using System.Collections.ObjectModel;
using System.IO;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using foodmarket.Pos.Core.Auth;
using foodmarket.Pos.Core.Logic;
using foodmarket.Pos.Core.Models;
using foodmarket.Pos.Core.Sync;
using foodmarket.Pos.Services;
namespace foodmarket.Pos.ViewModels;
public abstract partial class ViewModelBase : ObservableObject { }
/// <summary>Оболочка: статус-бар (касса/смена/сеть/очередь), текущая страница,
/// модальная оплата, горячие клавиши и фоновая синхронизация. Логин-гейт:
/// без валидной сессии показывается экран входа.</summary>
public partial class MainWindowViewModel : ViewModelBase
{
private readonly AppServices? _svc;
private SaleViewModel? _sale;
private System.Timers.Timer? _syncTimer;
[ObservableProperty] private object? _currentPage;
[ObservableProperty] private string _cashierName = "—";
[ObservableProperty] private string _shiftNumber = "смена не открыта";
[ObservableProperty] private bool _isOnline = true;
[ObservableProperty] private int _queueCount;
[ObservableProperty] private PaymentViewModel? _payment;
[ObservableProperty] private bool _isPaymentOpen;
[ObservableProperty] private string? _updateMessage;
public string NetBadge => IsOnline ? "● на связи" : "○ нет связи";
public string QueueBadge => QueueCount > 0 ? $"{QueueCount} чек(ов) в очереди" : "очередь пуста";
partial void OnIsOnlineChanged(bool value) { OnPropertyChanged(nameof(NetBadge)); }
partial void OnQueueCountChanged(int value) { OnPropertyChanged(nameof(QueueBadge)); }
/// <summary>Конструктор для дизайнера/скриншотов (демо-данные).</summary>
public MainWindowViewModel() { CurrentPage = SaleViewModel.Sample(); CashierName = "Пётр Кассиров"; ShiftNumber = "С-2026-000001"; }
public MainWindowViewModel(AppServices svc) { _svc = svc; if (svc.Session is not null) StartSession(); else ShowLogin(); }
private void ShowLogin()
{
CashierName = "—"; ShiftNumber = "вход…";
CurrentPage = new LoginViewModel(_svc!) { OnLoggedIn = StartSession };
}
private void StartSession()
{
CashierName = _svc!.Session?.CashierName ?? "кассир";
QueueCount = _svc.Store.PendingCount();
_sale = new SaleViewModel(_svc) { PaymentRequested = OpenPayment, SaleCommitted = _ => RefreshStatus() };
CurrentPage = _sale;
StartSyncLoop();
_ = CheckForUpdateAsync();
}
private async Task CheckForUpdateAsync()
{
if (_svc?.Api is null) return;
try
{
var info = await _svc.Api.VersionAsync();
var local = LocalVersion();
if (UpdateCheck.IsNewer(local, info.Version))
{
var url = OperatingSystem.IsWindows() ? info.WindowsUrl : info.LinuxUrl;
UpdateMessage = $"Доступно обновление {info.Version}" + (string.IsNullOrEmpty(url) ? "" : $" — {url}");
}
}
catch { /* проверка версии не критична */ }
}
private static string LocalVersion()
{
try
{
var f = Path.Combine(AppContext.BaseDirectory, "VERSION.txt");
if (File.Exists(f)) return File.ReadAllText(f).Trim();
}
catch { }
return System.Reflection.Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "0.0.0";
}
private void RefreshStatus() { if (_svc is not null) QueueCount = _svc.Store.PendingCount(); }
[RelayCommand] private void ShowSale() { CurrentPage = _sale ??= (_svc is null ? SaleViewModel.Sample() : new SaleViewModel(_svc) { PaymentRequested = OpenPayment, SaleCommitted = _ => RefreshStatus() }); }
[RelayCommand] private void ShowShift() => CurrentPage = _svc is null ? ShiftViewModel.Sample() : new ShiftViewModel(_svc) { OnShiftChanged = s => { ShiftNumber = s ?? "смена не открыта"; } };
[RelayCommand] private void ShowReturn() => CurrentPage = _svc is null ? ReturnViewModel.Sample() : new ReturnViewModel(_svc);
private void OpenPayment(PaymentViewModel pvm)
{
pvm.OnCancel = ClosePayment;
var confirm = pvm.OnConfirm;
pvm.OnConfirm = async (cash, card, change) => { if (confirm is not null) await confirm(cash, card, change); ClosePayment(); };
Payment = pvm; IsPaymentOpen = true;
}
private void ClosePayment() { IsPaymentOpen = false; Payment = null; }
/// <summary>Горячие клавиши (работа без мыши). Вызывается из code-behind окна.</summary>
public void HandleHotkey(string key)
{
switch (key)
{
case "F2": if (_sale is not null) _sale.Message = "Введите название или штрихкод"; break;
case "F7": case "F10": ShowShiftCommand.Execute(null); break;
case "F9": ShowReturnCommand.Execute(null); break;
case "F12": if (IsPaymentOpen) Payment?.ConfirmCommand.Execute(null); else _sale?.PayCommand.Execute(null); break;
case "Delete": _sale?.RemoveSelectedCommand.Execute(null); break;
case "Escape": if (IsPaymentOpen) ClosePayment(); else _sale?.ClearCommand.Execute(null); break;
}
}
private void StartSyncLoop()
{
if (_svc?.Sync is null) return;
_syncTimer = new System.Timers.Timer(15_000) { AutoReset = true };
_syncTimer.Elapsed += async (_, _) => await SyncTickAsync();
_syncTimer.Start();
_ = SyncTickAsync();
}
private async Task SyncTickAsync()
{
if (_svc?.Sync is null) return;
try
{
await _svc.Sync.PushSalesAsync();
await _svc.Sync.PullCatalogAsync();
IsOnline = true;
}
catch { IsOnline = false; }
finally { QueueCount = _svc.Store.PendingCount(); }
}
}
/// <summary>Экран входа: адрес сервера, логин, PIN. Регистрирует кассу на точке
/// продаж (берёт точку из привязки кассира). Хранит refresh-токен защищённо.</summary>
public partial class LoginViewModel : ViewModelBase
{
private readonly AppServices? _svc;
[ObservableProperty] private string _serverUrl = "https://test.admin.food-market.kz";
[ObservableProperty] private string _username = "";
[ObservableProperty] private string _pin = "";
[ObservableProperty] private string? _error;
[ObservableProperty] private bool _busy;
public Action? OnLoggedIn;
public LoginViewModel() { }
public LoginViewModel(AppServices svc) { _svc = svc; }
[RelayCommand]
private async Task LoginAsync()
{
if (_svc is null) { OnLoggedIn?.Invoke(); return; }
Error = null; Busy = true;
try
{
var t = await _svc.Auth.LoginAsync(ServerUrl.Trim(), Username.Trim(), Pin);
var session = new PosSession { ServerUrl = ServerUrl.Trim(), AccessToken = t.AccessToken, RefreshToken = t.RefreshToken };
_svc.ConfigureApi(session);
// Кто вошёл + роли.
try
{
var me = await _svc.Api!.MeAsync();
session.CashierName = me.name ?? Username.Trim();
session.Roles = me.roles;
if (Guid.TryParse(me.sub, out var uid)) session.CashierUserId = uid;
}
catch { session.CashierName = Username.Trim(); }
// Привязка к точке продаж (первая активная, к которой есть доступ).
try
{
var points = await _svc.Api!.RetailPointsAsync();
var point = points.items.FirstOrDefault(p => p.isActive) ?? points.items.FirstOrDefault();
if (point is not null) session.RetailPointId = point.id;
}
catch { /* офлайн — точку подтянем позже */ }
_svc.Tokens.Save(session);
_svc.Session = session;
OnLoggedIn?.Invoke();
}
catch (AuthException ex) { Error = ex.Message; }
catch (Exception ex) { Error = "Нет связи с сервером: " + ex.Message; }
finally { Busy = false; }
}
public static LoginViewModel Sample() => new() { Username = "cashier@food-market.kz" };
}
public partial class SaleLineVm : ObservableObject
{
internal CartLine? Model;
public required Guid ProductId { get; init; }
[ObservableProperty] private string _name = "";
[ObservableProperty] private string _unit = "шт";
[ObservableProperty] private decimal _price;
[ObservableProperty] private decimal _quantity = 1m;
[ObservableProperty] private decimal _discount;
public decimal LineTotal => Math.Max(0m, decimal.Round(Price * Quantity, 2) - Discount);
public string QtyText => Quantity == decimal.Truncate(Quantity) && Unit == "шт" ? Quantity.ToString("0") : Quantity.ToString("0.###");
partial void OnQuantityChanged(decimal value) { OnPropertyChanged(nameof(LineTotal)); OnPropertyChanged(nameof(QtyText)); }
partial void OnDiscountChanged(decimal value) => OnPropertyChanged(nameof(LineTotal));
}
/// <summary>Экран продажи — сердце кассы. Пробитие по сканеру/поиску, корзина,
/// итоги, скидка, оплата. Горячие клавиши назначены в оболочке/View.</summary>
public partial class SaleViewModel : ViewModelBase
{
private readonly AppServices? _svc;
private readonly Cart _cart = new();
public ObservableCollection<SaleLineVm> Lines { get; } = new();
[ObservableProperty] private string _scanInput = "";
[ObservableProperty] private string _shiftNumber = "С-2026-000001";
[ObservableProperty] private string _message = "Отсканируйте товар или введите штрихкод";
[ObservableProperty] private decimal _subtotal;
[ObservableProperty] private decimal _discount;
[ObservableProperty] private decimal _total;
[ObservableProperty] private int _itemCount;
[ObservableProperty] private SaleLineVm? _selectedLine;
/// <summary>Поднять модальную оплату (обрабатывает оболочка).</summary>
public Action<PaymentViewModel>? PaymentRequested;
/// <summary>Чек проведён — оболочка обновляет индикатор очереди.</summary>
public Action<CheckoutService.CommitResult>? SaleCommitted;
public SaleViewModel() { }
public SaleViewModel(AppServices svc) { _svc = svc; }
private void Recalc()
{
var t = _cart.Totals();
Subtotal = t.Subtotal; Discount = t.LineDiscounts + t.CartDiscount; Total = t.Total; ItemCount = t.LineCount;
}
public void AddProduct(PosProduct p, decimal qty, decimal? priceOverride = null)
{
var line = _cart.AddProduct(p, qty, priceOverride);
var vm = Lines.FirstOrDefault(l => ReferenceEquals(l.Model, line));
if (vm is not null) { vm.Quantity = line.Quantity; }
else
{
vm = new SaleLineVm { ProductId = line.ProductId, Name = line.Name, Unit = line.UnitCode, Price = line.UnitPrice, Quantity = line.Quantity, Model = line };
Lines.Insert(0, vm);
}
SelectedLine = vm;
Recalc();
Message = $"{p.Name} · {(p.IsWeighed ? qty.ToString("0.###") + " " + p.UnitCode : "×" + line.Quantity.ToString("0"))}";
}
[RelayCommand]
private void Scan()
{
var raw = ScanInput.Trim();
ScanInput = "";
if (raw.Length == 0) return;
var parsed = BarcodeParser.Parse(raw);
var p = _svc?.Store.FindByBarcode(parsed.Lookup);
if (p is null) { Message = $"Штрихкод {parsed.Lookup} не найден"; return; }
var qty = parsed.IsWeighted && parsed.EmbeddedWeight is { } w ? w : 1m;
var price = parsed.IsWeighted && parsed.EmbeddedPrice is { } pr ? pr / Math.Max(0.001m, qty) : (decimal?)null;
AddProduct(p, qty, price);
}
[RelayCommand]
private void RemoveSelected()
{
if (SelectedLine is null) return;
if (SelectedLine.Model is { } m) _cart.Remove(m);
Lines.Remove(SelectedLine);
SelectedLine = Lines.FirstOrDefault();
Recalc();
Message = "Позиция удалена";
}
public void SetLineQuantity(SaleLineVm line, decimal qty)
{
if (line.Model is null) return;
_cart.SetQuantity(line.Model, qty);
if (qty <= 0) { Lines.Remove(line); }
else line.Quantity = line.Model.Quantity;
Recalc();
}
public void SetCartDiscount(decimal amount) { _cart.SetCartDiscount(amount); Recalc(); Message = $"Скидка на чек: {amount:N0} ₸"; }
[RelayCommand]
private void Pay()
{
if (_cart.IsEmpty) { Message = "Чек пуст — нечего оплачивать"; return; }
if (_svc is not null && !_svc.Checkout.HasOpenShift) { Message = "Смена не открыта — F10, откройте смену"; return; }
var pvm = new PaymentViewModel { Total = Total };
pvm.OnConfirm = (cash, card, change) => CommitPaymentAsync(cash, card, change);
if (PaymentRequested is not null) PaymentRequested(pvm);
else _ = CommitPaymentAsync(Total, 0m, 0m); // без оболочки (тест/демо) — оплата наличными «под расчёт»
}
/// <summary>Провести оплату: офлайн-устойчивая запись чека + печать + попытка
/// отправки. Очищает корзину.</summary>
public async Task CommitPaymentAsync(decimal cash, decimal card, decimal change)
{
if (_cart.IsEmpty) return;
if (_svc is null)
{
_cart.Clear(); Lines.Clear(); Recalc(); Message = "Оплата проведена (демо)"; return;
}
var res = await _svc.Checkout.CommitAsync(_cart, cash, card, change);
_cart.Clear(); Lines.Clear(); SelectedLine = null; Recalc();
Message = res.PushedNow ? "Чек проведён и отправлен на сервер" : $"Чек проведён (в очереди: {res.PendingAfter})";
if (!res.Printed && res.PrintError is not null) Message += " · печать не удалась";
SaleCommitted?.Invoke(res);
}
[RelayCommand] private void Clear() { _cart.Clear(); Lines.Clear(); SelectedLine = null; Recalc(); Message = "Чек очищен"; }
/// <summary>Пример заполненного чека для скриншотов/демо.</summary>
public static SaleViewModel Sample()
{
var vm = new SaleViewModel();
vm.Lines.Add(new SaleLineVm { ProductId = Guid.NewGuid(), Name = "Хлеб «Семейный» нарезной", Unit = "шт", Price = 280m, Quantity = 2 });
vm.Lines.Add(new SaleLineVm { ProductId = Guid.NewGuid(), Name = "Молоко 2.5% 1л", Unit = "шт", Price = 450m, Quantity = 1 });
vm.Lines.Add(new SaleLineVm { ProductId = Guid.NewGuid(), Name = "Яблоки «Апорт» (вес)", Unit = "кг", Price = 690m, Quantity = 1.235m });
vm.Lines.Add(new SaleLineVm { ProductId = Guid.NewGuid(), Name = "Сахар-песок 1кг", Unit = "шт", Price = 520m, Quantity = 3, Discount = 60m });
vm.Subtotal = vm.Lines.Sum(l => l.Price * l.Quantity);
vm.Discount = 60m;
vm.Total = decimal.Round(vm.Subtotal - vm.Discount, 2);
vm.ItemCount = vm.Lines.Count;
vm.Message = "Чек готов к оплате — F12 Оплата";
return vm;
}
}
public partial class PaymentViewModel : ViewModelBase
{
[ObservableProperty] private decimal _total;
[ObservableProperty] private decimal _paidCash;
[ObservableProperty] private decimal _paidCard;
[ObservableProperty] private string _method = "Наличные";
public decimal Change => PaymentMath.Compute(Total, PaidCash, PaidCard).Change;
public decimal Shortfall => PaymentMath.Compute(Total, PaidCash, PaidCard).Shortfall;
public bool Sufficient => PaymentMath.Compute(Total, PaidCash, PaidCard).Sufficient;
partial void OnPaidCashChanged(decimal value) => RaisePay();
partial void OnPaidCardChanged(decimal value) => RaisePay();
partial void OnTotalChanged(decimal value) => RaisePay();
private void RaisePay() { OnPropertyChanged(nameof(Change)); OnPropertyChanged(nameof(Shortfall)); OnPropertyChanged(nameof(Sufficient)); }
/// <summary>Вызывается при подтверждении оплаты (cash, card, change).</summary>
public Func<decimal, decimal, decimal, Task>? OnConfirm;
public Action? OnCancel;
[RelayCommand] private void ExactCash() { PaidCash = Total; PaidCard = 0m; Method = "Наличные"; }
[RelayCommand]
private async Task Confirm()
{
if (!Sufficient) return;
if (OnConfirm is not null) await OnConfirm(PaidCash, PaidCard, Change);
}
[RelayCommand] private void Cancel() => OnCancel?.Invoke();
public static PaymentViewModel SampleCash() => new() { Total = 3365m, PaidCash = 5000m, Method = "Наличные" };
public static PaymentViewModel SampleMixed() => new() { Total = 3365m, PaidCash = 1365m, PaidCard = 2000m, Method = "Смешанная" };
}
/// <summary>Смена: открыть (размен) / закрыть (факт → должно/факт/разница),
/// внесение/выплата, X/Z-отчёты. Живые вызовы идут на серверный модуль «Деньги».</summary>
public partial class ShiftViewModel : ViewModelBase
{
private readonly AppServices? _svc;
[ObservableProperty] private bool _isOpen = true;
[ObservableProperty] private string _number = "С-2026-000001";
[ObservableProperty] private decimal _openingFloat = 5000m;
[ObservableProperty] private decimal _salesCash = 3775m;
[ObservableProperty] private decimal _salesCard = 2325m;
[ObservableProperty] private decimal _returnsCash = 1500m;
[ObservableProperty] private decimal _drawerIn = 10000m;
[ObservableProperty] private decimal _drawerOut = 3000m;
[ObservableProperty] private decimal _actualCash = 14775m;
[ObservableProperty] private string? _message;
[ObservableProperty] private bool _busy;
public decimal ExpectedCash => ShiftMath.ExpectedCash(OpeningFloat, SalesCash, ReturnsCash, DrawerIn, DrawerOut);
public decimal Difference => ShiftMath.CashDifference(ActualCash, ExpectedCash);
partial void OnOpeningFloatChanged(decimal v) => RaiseCalc();
partial void OnActualCashChanged(decimal v) => RaiseCalc();
private void RaiseCalc() { OnPropertyChanged(nameof(ExpectedCash)); OnPropertyChanged(nameof(Difference)); }
/// <summary>Сообщить оболочке новый номер смены (или null — закрыта).</summary>
public Action<string?>? OnShiftChanged;
public ShiftViewModel() { }
public ShiftViewModel(AppServices svc)
{
_svc = svc; IsOpen = svc.Checkout.HasOpenShift;
if (svc.Checkout.CurrentShift is { } s) { Number = s.Number; OpeningFloat = s.OpeningFloat; }
_ = RefreshAsync();
}
private async Task RefreshAsync()
{
if (_svc?.Api is null || _svc.Session?.RetailPointId is not Guid rp) return;
try
{
var cur = await _svc.Api.CurrentShiftAsync(rp);
if (cur.Content is { open: true, shift: { } sh })
{
IsOpen = true; Number = sh.number; OpeningFloat = sh.openingFloat;
_svc.Checkout.CurrentShift = new PosShift { Id = sh.id, Number = sh.number, RetailPointId = sh.retailPointId, CashAccountId = sh.cashAccountId, AcquiringAccountId = sh.acquiringAccountId, OpeningFloat = sh.openingFloat };
OnShiftChanged?.Invoke(sh.number);
}
else { IsOpen = false; OnShiftChanged?.Invoke(null); }
}
catch (Exception ex) { Message = "Нет связи со сменой: " + ex.Message; }
}
[RelayCommand]
private async Task OpenShift()
{
if (_svc?.Api is null || _svc.Session?.RetailPointId is not Guid rp) { Message = "Нет привязки к точке/серверу"; return; }
Busy = true; Message = null;
try
{
// Денежный счёт точки (наличная касса) — первый наличный привязанный к складу точки.
var accounts = await _svc.Api.MoneyAccountsAsync();
var cash = accounts.items.FirstOrDefault(a => a.kind == 1 && a.isActive) ?? accounts.items.FirstOrDefault(a => a.kind == 1);
if (cash is null) { Message = "Не найдена наличная касса (создайте счёт в админке)"; return; }
var res = await _svc.Api.OpenShiftAsync(new OpenShiftBody(rp, cash.id, null, OpeningFloat));
IsOpen = true; Number = res.number;
_svc.Checkout.CurrentShift = new PosShift { Id = res.id, Number = res.number, RetailPointId = rp, CashAccountId = cash.id, OpeningFloat = OpeningFloat };
OnShiftChanged?.Invoke(res.number);
Message = $"Смена {res.number} открыта";
}
catch (Exception ex) { Message = "Открыть смену не удалось: " + ex.Message; }
finally { Busy = false; }
}
[RelayCommand]
private async Task CloseShift()
{
if (_svc?.Api is null || _svc.Checkout.CurrentShift is not { } s) { Message = "Смена не открыта"; return; }
Busy = true; Message = null;
try
{
var res = await _svc.Api.CloseShiftAsync(s.Id, new CloseShiftBody(ActualCash, null));
IsOpen = false;
_svc.Checkout.CurrentShift = null;
OnShiftChanged?.Invoke(null);
Message = $"Смена закрыта. Должно {res.expectedCash:N0} ₸, факт {ActualCash:N0} ₸, разница {res.difference:N0} ₸";
}
catch (Exception ex) { Message = "Закрыть смену не удалось: " + ex.Message; }
finally { Busy = false; }
}
[RelayCommand]
private async Task DepositCash() => await DrawerAsync(1, DrawerIn, "Внесение");
[RelayCommand]
private async Task WithdrawCash() => await DrawerAsync(2, DrawerOut, "Выплата/инкассация");
private async Task DrawerAsync(int direction, decimal amount, string reason)
{
if (_svc?.Api is null || _svc.Checkout.CurrentShift is not { } s) { Message = "Смена не открыта"; return; }
try { await _svc.Api.DrawerAsync(s.Id, new DrawerBody(direction, amount, reason)); Message = $"{reason}: {amount:N0} ₸"; }
catch (Exception ex) { Message = "Операция с кассой не удалась: " + ex.Message; }
}
public static ShiftViewModel Sample() => new();
public static ShiftViewModel SampleClosed() { var s = new ShiftViewModel { IsOpen = false }; return s; }
}
public partial class ReturnViewModel : ViewModelBase
{
private readonly AppServices? _svc;
[ObservableProperty] private string _receiptNumber = "";
[ObservableProperty] private string _message = "Отсканируйте QR чека или введите номер";
[ObservableProperty] private bool _busy;
public ObservableCollection<SaleLineVm> Lines { get; } = new();
public decimal RefundTotal => Lines.Sum(l => l.LineTotal);
public ReturnViewModel() { }
public ReturnViewModel(AppServices svc) { _svc = svc; }
[RelayCommand]
private Task Lookup()
{
// Возврат по чеку: поиск чека-основания по номеру/QR. Серверный разбор
// чека выполняется при проведении возврата; здесь — заготовка для
// интерактивного выбора позиций (полный UI выбора — вне MVP-задачи).
if (string.IsNullOrWhiteSpace(ReceiptNumber)) { Message = "Введите номер чека или отсканируйте QR"; return Task.CompletedTask; }
Message = $"Чек {ReceiptNumber}: отметьте возвращаемые позиции и подтвердите возврат";
return Task.CompletedTask;
}
public static ReturnViewModel Sample()
{
var vm = new ReturnViewModel { ReceiptNumber = "Ч-2026-000128", Message = "Чек найден — отметьте возвращаемые позиции" };
vm.Lines.Add(new SaleLineVm { ProductId = Guid.NewGuid(), Name = "Молоко 2.5% 1л", Unit = "шт", Price = 450m, Quantity = 1 });
vm.Lines.Add(new SaleLineVm { ProductId = Guid.NewGuid(), Name = "Хлеб «Семейный»", Unit = "шт", Price = 280m, Quantity = 1 });
return vm;
}
}

View file

@ -0,0 +1,28 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:foodmarket.Pos.ViewModels"
x:Class="foodmarket.Pos.Views.LoginView"
x:DataType="vm:LoginViewModel">
<Grid Background="#0f172a">
<Border Background="#1e293b" CornerRadius="12" Width="440" Padding="28" VerticalAlignment="Center" HorizontalAlignment="Center">
<StackPanel Spacing="14">
<TextBlock Text="FOOD MARKET" Foreground="#22c55e" FontWeight="Bold" FontSize="22" HorizontalAlignment="Center"/>
<TextBlock Text="Вход кассира" Foreground="#f8fafc" FontSize="18" HorizontalAlignment="Center"/>
<StackPanel Spacing="4">
<TextBlock Text="Адрес сервера" Foreground="#94a3b8"/>
<TextBox Classes="pos" Text="{Binding ServerUrl}" FontSize="16"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Логин кассира" Foreground="#94a3b8"/>
<TextBox Classes="pos" Text="{Binding Username}" FontSize="18"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="PIN / пароль" Foreground="#94a3b8"/>
<TextBox Classes="pos" Text="{Binding Pin}" PasswordChar="●" FontSize="18"/>
</StackPanel>
<TextBlock Text="{Binding Error}" Foreground="#fca5a5" IsVisible="{Binding Error, Converter={x:Static ObjectConverters.IsNotNull}}"/>
<Button Classes="pos primary" HorizontalAlignment="Stretch" Height="56" FontSize="18" Content="Войти" Command="{Binding LoginCommand}" IsDefault="True" IsEnabled="{Binding !Busy}"/>
</StackPanel>
</Border>
</Grid>
</UserControl>

View file

@ -0,0 +1,8 @@
using Avalonia.Controls;
namespace foodmarket.Pos.Views;
public partial class LoginView : UserControl
{
public LoginView() => InitializeComponent();
}

View file

@ -0,0 +1,45 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:foodmarket.Pos.ViewModels"
x:Class="foodmarket.Pos.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Width="1280" Height="800" MinWidth="1024" MinHeight="680"
Background="#0f172a" Title="Касса food-market"
WindowStartupLocation="CenterScreen">
<Panel>
<Grid RowDefinitions="54,*,34">
<!-- Статус-бар -->
<Border Grid.Row="0" Background="#1e293b" Padding="16,0">
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
<TextBlock Text="FOOD MARKET · Касса" FontWeight="Bold" Foreground="#22c55e" FontSize="16" VerticalAlignment="Center"/>
</StackPanel>
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="18" VerticalAlignment="Center">
<TextBlock Text="{Binding CashierName}" Foreground="#cbd5e1" VerticalAlignment="Center"/>
<TextBlock Text="{Binding ShiftNumber}" Foreground="#93c5fd" VerticalAlignment="Center"/>
</StackPanel>
<TextBlock Grid.Column="1" Text="{Binding UpdateMessage}" Foreground="#fde68a" VerticalAlignment="Center" HorizontalAlignment="Center"
IsVisible="{Binding UpdateMessage, Converter={x:Static ObjectConverters.IsNotNull}}" FontSize="12"/>
<TextBlock Grid.Column="3" Margin="18,0" Text="{Binding NetBadge}" VerticalAlignment="Center" Foreground="#34d399"/>
<TextBlock Grid.Column="4" Text="{Binding QueueBadge}" Foreground="#fbbf24" VerticalAlignment="Center"/>
</Grid>
</Border>
<!-- Текущая страница -->
<ContentControl Grid.Row="1" Content="{Binding CurrentPage}" Margin="0"/>
<!-- Нижняя панель навигации -->
<Border Grid.Row="2" Background="#1e293b">
<StackPanel Orientation="Horizontal" Spacing="2" HorizontalAlignment="Center">
<TextBlock Text="F1 Помощь · F2 Поиск · F7 Внесение/Выплата · F9 Возврат · F10 Смена · F12 Оплата · Esc Отмена"
Foreground="#94a3b8" VerticalAlignment="Center" FontSize="12"/>
</StackPanel>
</Border>
</Grid>
<!-- Модальная оплата поверх экрана продажи -->
<Border Background="#cc0b1220" IsVisible="{Binding IsPaymentOpen}">
<ContentControl Content="{Binding Payment}"/>
</Border>
</Panel>
</Window>

View file

@ -0,0 +1,34 @@
using Avalonia.Controls;
using Avalonia.Input;
using foodmarket.Pos.ViewModels;
namespace foodmarket.Pos.Views;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
AddHandler(KeyDownEvent, OnKeyDown, Avalonia.Interactivity.RoutingStrategies.Tunnel);
}
/// <summary>Горячие клавиши кассы (работа без мыши). F-клавиши и Esc/Del
/// обрабатываются оболочкой; ввод в текстовые поля не перехватываем, кроме
/// функциональных клавиш.</summary>
private void OnKeyDown(object? sender, KeyEventArgs e)
{
if (DataContext is not MainWindowViewModel vm) return;
string? key = e.Key switch
{
Key.F1 => "F1", Key.F2 => "F2", Key.F7 => "F7", Key.F9 => "F9",
Key.F10 => "F10", Key.F12 => "F12",
Key.Escape => "Escape",
// Delete — только когда фокус не в поле ввода (иначе это правка текста).
Key.Delete when e.Source is not TextBox => "Delete",
_ => null,
};
if (key is null) return;
vm.HandleHotkey(key);
e.Handled = true;
}
}

View file

@ -0,0 +1,37 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:foodmarket.Pos.ViewModels"
x:Class="foodmarket.Pos.Views.PaymentView"
x:DataType="vm:PaymentViewModel">
<Grid Background="#0f172a">
<Border Background="#1e293b" CornerRadius="12" Width="560" Padding="28" VerticalAlignment="Center" HorizontalAlignment="Center">
<StackPanel Spacing="14">
<TextBlock Text="Оплата чека" FontSize="24" FontWeight="Bold" Foreground="#f8fafc" HorizontalAlignment="Center"/>
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="К оплате" Foreground="#94a3b8" FontSize="18" VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="{Binding Total, StringFormat='{}{0:N0} ₸'}" Foreground="#34d399" FontSize="30" FontWeight="Bold"/>
</Grid>
<Separator Background="#334155"/>
<Grid ColumnDefinitions="140,*">
<TextBlock Text="Наличные" Foreground="#cbd5e1" VerticalAlignment="Center"/>
<TextBox Grid.Column="1" Classes="pos" Text="{Binding PaidCash}" FontSize="22"/>
</Grid>
<Grid ColumnDefinitions="140,*">
<TextBlock Text="Картой" Foreground="#cbd5e1" VerticalAlignment="Center"/>
<TextBox Grid.Column="1" Classes="pos" Text="{Binding PaidCard}" FontSize="22"/>
</Grid>
<Border Background="#0b1220" CornerRadius="8" Padding="14">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="СДАЧА" Foreground="#f8fafc" FontSize="20" FontWeight="SemiBold" VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="{Binding Change, StringFormat='{}{0:N0} ₸'}" Foreground="#fbbf24" FontSize="30" FontWeight="Bold"/>
</Grid>
</Border>
<Grid ColumnDefinitions="*,2*">
<Button Classes="pos" Margin="0,0,6,0" HorizontalAlignment="Stretch" Height="64" FontSize="16" Content="Без сдачи" Command="{Binding ExactCashCommand}"/>
<Button Grid.Column="1" Classes="pos primary" HorizontalAlignment="Stretch" Height="64" FontSize="20" Content="Enter · Провести и напечатать чек" Command="{Binding ConfirmCommand}" IsDefault="True" IsEnabled="{Binding Sufficient}"/>
</Grid>
<Button Classes="pos" HorizontalAlignment="Stretch" Height="44" Content="Esc · Отмена" Command="{Binding CancelCommand}"/>
</StackPanel>
</Border>
</Grid>
</UserControl>

View file

@ -0,0 +1,8 @@
using Avalonia.Controls;
namespace foodmarket.Pos.Views;
public partial class PaymentView : UserControl
{
public PaymentView() => InitializeComponent();
}

View file

@ -0,0 +1,37 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:foodmarket.Pos.ViewModels"
x:Class="foodmarket.Pos.Views.ReturnView"
x:DataType="vm:ReturnViewModel">
<Grid Background="#0f172a" RowDefinitions="Auto,*,Auto" Margin="16">
<StackPanel Grid.Row="0" Spacing="8">
<TextBlock Text="Возврат по чеку" FontSize="22" FontWeight="Bold" Foreground="#f8fafc"/>
<Grid ColumnDefinitions="*,160">
<TextBox Classes="pos" Watermark="Номер чека или скан QR" Text="{Binding ReceiptNumber}"/>
<Button Grid.Column="1" Classes="pos" Margin="8,0,0,0" HorizontalAlignment="Stretch" Content="Найти" Command="{Binding LookupCommand}" IsDefault="True"/>
</Grid>
<TextBlock Text="{Binding Message}" Foreground="#60a5fa"/>
</StackPanel>
<Border Grid.Row="1" Background="#111827" CornerRadius="10" Margin="0,10">
<ListBox ItemsSource="{Binding Lines}" Background="Transparent" BorderThickness="0">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:SaleLineVm">
<Grid ColumnDefinitions="Auto,*,120,120" Height="46">
<CheckBox IsChecked="True" VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="{Binding Name}" Foreground="#f3f4f6" FontSize="16" VerticalAlignment="Center"/>
<TextBlock Grid.Column="2" Text="{Binding Quantity, StringFormat='{}{0:0.###}'}" Foreground="#e5e7eb" HorizontalAlignment="Right" VerticalAlignment="Center"/>
<TextBlock Grid.Column="3" Text="{Binding LineTotal, StringFormat='{}{0:N0} ₸'}" Foreground="#fff" FontWeight="SemiBold" HorizontalAlignment="Right" VerticalAlignment="Center" Margin="0,0,10,0"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
<Grid Grid.Row="2" ColumnDefinitions="*,Auto">
<TextBlock Text="К возврату" Foreground="#cbd5e1" FontSize="18" VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="16">
<TextBlock Text="{Binding RefundTotal, StringFormat='{}{0:N0} ₸'}" Foreground="#fca5a5" FontSize="24" FontWeight="Bold" VerticalAlignment="Center"/>
<Button Classes="pos danger" Height="56" Content="Оформить возврат"/>
</StackPanel>
</Grid>
</Grid>
</UserControl>

View file

@ -0,0 +1,8 @@
using Avalonia.Controls;
namespace foodmarket.Pos.Views;
public partial class ReturnView : UserControl
{
public ReturnView() => InitializeComponent();
}

View file

@ -0,0 +1,69 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:foodmarket.Pos.ViewModels"
x:Class="foodmarket.Pos.Views.SaleView"
x:DataType="vm:SaleViewModel">
<Grid ColumnDefinitions="*,380" Background="#0f172a">
<!-- Левая часть: чек -->
<Grid Grid.Column="0" RowDefinitions="Auto,*,Auto" Margin="16,12,8,12">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="0,0,0,8">
<TextBox Grid.Column="0" Classes="pos" Watermark="Штрихкод / поиск товара (Enter)"
Text="{Binding ScanInput}" FontSize="22">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding ScanCommand}"/>
</TextBox.KeyBindings>
</TextBox>
</Grid>
<Border Grid.Row="1" Background="#111827" CornerRadius="10" Padding="0">
<DockPanel>
<Grid DockPanel.Dock="Top" ColumnDefinitions="*,90,120,120" Background="#1f2937" Height="38">
<TextBlock Text=" Товар" Foreground="#9ca3af" VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="Кол-во" Foreground="#9ca3af" VerticalAlignment="Center" HorizontalAlignment="Right"/>
<TextBlock Grid.Column="2" Text="Цена" Foreground="#9ca3af" VerticalAlignment="Center" HorizontalAlignment="Right"/>
<TextBlock Grid.Column="3" Text="Сумма " Foreground="#9ca3af" VerticalAlignment="Center" HorizontalAlignment="Right"/>
</Grid>
<ListBox ItemsSource="{Binding Lines}" Background="Transparent" BorderThickness="0">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:SaleLineVm">
<Grid ColumnDefinitions="*,90,120,120" Height="46">
<StackPanel VerticalAlignment="Center">
<TextBlock Text="{Binding Name}" Foreground="#f3f4f6" FontSize="16"/>
</StackPanel>
<TextBlock Grid.Column="1" Text="{Binding QtyText}" Foreground="#e5e7eb" FontSize="16" HorizontalAlignment="Right" VerticalAlignment="Center"/>
<TextBlock Grid.Column="2" Text="{Binding Price, StringFormat='{}{0:N0} ₸'}" Foreground="#e5e7eb" FontSize="16" HorizontalAlignment="Right" VerticalAlignment="Center"/>
<TextBlock Grid.Column="3" Text="{Binding LineTotal, StringFormat='{}{0:N0} ₸'}" Foreground="#ffffff" FontSize="17" FontWeight="SemiBold" HorizontalAlignment="Right" VerticalAlignment="Center" Margin="0,0,10,0"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</Border>
<TextBlock Grid.Row="2" Text="{Binding Message}" Foreground="#60a5fa" Margin="4,8,0,0" FontSize="14"/>
</Grid>
<!-- Правая часть: итоги + оплата -->
<Border Grid.Column="1" Background="#1e293b" Margin="8,12,16,12" CornerRadius="10" Padding="18">
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto">
<StackPanel Grid.Row="0" Spacing="6">
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Позиций" Foreground="#94a3b8"/><TextBlock Grid.Column="1" Text="{Binding ItemCount}" Foreground="#e5e7eb"/></Grid>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Подытог" Foreground="#94a3b8"/><TextBlock Grid.Column="1" Text="{Binding Subtotal, StringFormat='{}{0:N0} ₸'}" Foreground="#e5e7eb"/></Grid>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Скидка" Foreground="#94a3b8"/><TextBlock Grid.Column="1" Text="{Binding Discount, StringFormat='-{0:N0} ₸'}" Foreground="#fca5a5"/></Grid>
</StackPanel>
<Separator Grid.Row="1" Margin="0,12" Background="#334155"/>
<Grid Grid.Row="2" ColumnDefinitions="*,Auto">
<TextBlock Text="ИТОГО" Foreground="#f8fafc" FontSize="22" FontWeight="Bold" VerticalAlignment="Bottom"/>
<TextBlock Grid.Column="1" Text="{Binding Total, StringFormat='{}{0:N0} ₸'}" Foreground="#34d399" FontSize="34" FontWeight="Bold" HorizontalAlignment="Right"/>
</Grid>
<StackPanel Grid.Row="4" Spacing="8">
<Button Classes="pos primary" HorizontalAlignment="Stretch" Height="68" FontSize="22" Content="F12 · ОПЛАТА"/>
<Grid ColumnDefinitions="*,*" >
<Button Classes="pos" Margin="0,0,4,0" HorizontalAlignment="Stretch" Content="Скидка"/>
<Button Grid.Column="1" Classes="pos danger" Margin="4,0,0,0" HorizontalAlignment="Stretch" Content="Esc · Очистить" Command="{Binding ClearCommand}"/>
</Grid>
</StackPanel>
</Grid>
</Border>
</Grid>
</UserControl>

View file

@ -0,0 +1,8 @@
using Avalonia.Controls;
namespace foodmarket.Pos.Views;
public partial class SaleView : UserControl
{
public SaleView() => InitializeComponent();
}

View file

@ -0,0 +1,36 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:foodmarket.Pos.ViewModels"
x:Class="foodmarket.Pos.Views.ShiftView"
x:DataType="vm:ShiftViewModel">
<Grid Background="#0f172a">
<Border Background="#1e293b" CornerRadius="12" Width="620" Padding="28" VerticalAlignment="Center" HorizontalAlignment="Center">
<StackPanel Spacing="12">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="Z-отчёт смены" FontSize="24" FontWeight="Bold" Foreground="#f8fafc"/>
<TextBlock Grid.Column="1" Text="{Binding Number}" Foreground="#93c5fd" FontSize="18" VerticalAlignment="Center"/>
</Grid>
<Separator Background="#334155"/>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Размен (на открытие)" Foreground="#94a3b8"/><TextBlock Grid.Column="1" Text="{Binding OpeningFloat, StringFormat='{}{0:N0} ₸'}" Foreground="#e5e7eb"/></Grid>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Продажи наличными" Foreground="#94a3b8"/><TextBlock Grid.Column="1" Text="{Binding SalesCash, StringFormat='{}{0:N0} ₸'}" Foreground="#e5e7eb"/></Grid>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Продажи картой" Foreground="#94a3b8"/><TextBlock Grid.Column="1" Text="{Binding SalesCard, StringFormat='{}{0:N0} ₸'}" Foreground="#e5e7eb"/></Grid>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Возвраты наличными" Foreground="#94a3b8"/><TextBlock Grid.Column="1" Text="{Binding ReturnsCash, StringFormat='-{0:N0} ₸'}" Foreground="#fca5a5"/></Grid>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Внесения" Foreground="#94a3b8"/><TextBlock Grid.Column="1" Text="{Binding DrawerIn, StringFormat='+{0:N0} ₸'}" Foreground="#86efac"/></Grid>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Выплаты" Foreground="#94a3b8"/><TextBlock Grid.Column="1" Text="{Binding DrawerOut, StringFormat='-{0:N0} ₸'}" Foreground="#fca5a5"/></Grid>
<Separator Background="#334155"/>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Должно быть в кассе" Foreground="#cbd5e1" FontSize="18"/><TextBlock Grid.Column="1" Text="{Binding ExpectedCash, StringFormat='{}{0:N0} ₸'}" Foreground="#f8fafc" FontSize="20" FontWeight="SemiBold"/></Grid>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Факт (пересчёт)" Foreground="#cbd5e1" FontSize="18"/><TextBlock Grid.Column="1" Text="{Binding ActualCash, StringFormat='{}{0:N0} ₸'}" Foreground="#f8fafc" FontSize="20" FontWeight="SemiBold"/></Grid>
<Border Background="#0b1220" CornerRadius="8" Padding="12">
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Разница" Foreground="#f8fafc" FontSize="18"/><TextBlock Grid.Column="1" Text="{Binding Difference, StringFormat='{}{0:+#,0;-#,0;0} ₸'}" Foreground="#fbbf24" FontSize="24" FontWeight="Bold"/></Grid>
</Border>
<Grid ColumnDefinitions="*,*,*">
<Button Classes="pos" Margin="0,0,4,0" HorizontalAlignment="Stretch" Content="Открыть смену" Command="{Binding OpenShiftCommand}" IsEnabled="{Binding !IsOpen}"/>
<Button Grid.Column="1" Classes="pos" Margin="4,0" HorizontalAlignment="Stretch" Content="Внести" Command="{Binding DepositCashCommand}" IsEnabled="{Binding IsOpen}"/>
<Button Grid.Column="2" Classes="pos" Margin="4,0,0,0" HorizontalAlignment="Stretch" Content="Выплата" Command="{Binding WithdrawCashCommand}" IsEnabled="{Binding IsOpen}"/>
</Grid>
<Button Classes="pos danger" HorizontalAlignment="Stretch" Height="52" Content="Закрыть смену (Z-отчёт)" Command="{Binding CloseShiftCommand}" IsEnabled="{Binding IsOpen}"/>
<TextBlock Text="{Binding Message}" Foreground="#60a5fa" IsVisible="{Binding Message, Converter={x:Static ObjectConverters.IsNotNull}}" TextWrapping="Wrap"/>
</StackPanel>
</Border>
</Grid>
</UserControl>

View file

@ -0,0 +1,8 @@
using Avalonia.Controls;
namespace foodmarket.Pos.Views;
public partial class ShiftView : UserControl
{
public ShiftView() => InitializeComponent();
}

View file

@ -1,14 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<RootNamespace>foodmarket.Pos</RootNamespace> <RootNamespace>foodmarket.Pos</RootNamespace>
<AssemblyName>foodmarket.Pos</AssemblyName> <AssemblyName>foodmarket.Pos</AssemblyName>
<UseWPF>true</UseWPF>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<!-- Allow building on macOS/Linux for CI; running still requires Windows --> <ImplicitUsings>enable</ImplicitUsings>
<EnableWindowsTargeting>true</EnableWindowsTargeting> <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<ApplicationIcon></ApplicationIcon>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@ -17,9 +16,15 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.Desktop" />
<PackageReference Include="Avalonia.Themes.Fluent" />
<PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="Avalonia.Headless" />
<PackageReference Include="CommunityToolkit.Mvvm" /> <PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Refit.HttpClientFactory" /> <PackageReference Include="Refit.HttpClientFactory" />
<PackageReference Include="Polly" />
<PackageReference Include="System.IO.Ports" /> <PackageReference Include="System.IO.Ports" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -106,6 +106,18 @@ public record PosSaleDto
public required decimal PaidCard { get; init; } public required decimal PaidCard { get; init; }
public required IReadOnlyList<PosSaleLineDto> Lines { get; init; } public required IReadOnlyList<PosSaleLineDto> Lines { get; init; }
public string? Notes { 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 record PosSaleLineDto
@ -135,6 +147,16 @@ public record PosSaleBatchDto
/// <see cref="Failed"/> — продажи, которые отклонены (mismatch цен, отсутствие /// <see cref="Failed"/> — продажи, которые отклонены (mismatch цен, отсутствие
/// товара, нехватка остатка для проведения). POS должен показать кассиру и /// товара, нехватка остатка для проведения). POS должен показать кассиру и
/// решить (перепробить с новой ценой, списать как акт расхождения).</summary> /// решить (перепробить с новой ценой, списать как акт расхождения).</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 record PosSaleBatchResponse
{ {
public required Guid IdempotencyKey { get; init; } public required Guid IdempotencyKey { get; init; }

View file

@ -16,6 +16,43 @@ import type { PagedResult, RetailPoint, Store } from '@/lib/types'
const URL = '/api/catalog/retail-points' const URL = '/api/catalog/retail-points'
interface PosVersion { version: string; windowsUrl?: string; linuxUrl?: string; notes?: string; mandatory: boolean }
/** Карточка со ссылками на скачивание кассового приложения (Windows/Linux). */
function PosDownloadCard() {
const { data } = useQuery({
queryKey: ['pos-version'],
queryFn: async () => (await api.get<PosVersion>('/api/pos/v1/version')).data,
staleTime: 300_000,
})
const win = data?.windowsUrl
const lin = data?.linuxUrl
return (
<div className="mb-4 rounded-lg border border-slate-200 bg-slate-50 p-4 dark:border-slate-700 dark:bg-slate-800/50">
<div className="flex flex-wrap items-center gap-3">
<div className="flex-1 min-w-[220px]">
<div className="font-medium">Кассовое приложение (касса кассира)</div>
<div className="text-sm text-slate-500 dark:text-slate-400">
Кроссплатформенная касса для Windows 10/11 и Linux.
{data?.version && data.version !== '0.0.0' ? ` Последняя версия: ${data.version}.` : ''}
</div>
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" disabled={!win}
onClick={() => win && window.open(win, '_blank')}>Скачать для Windows</Button>
<Button variant="secondary" size="sm" disabled={!lin}
onClick={() => lin && window.open(lin, '_blank')}>Скачать для Linux</Button>
</div>
</div>
{!win && !lin && (
<div className="mt-2 text-xs text-amber-600 dark:text-amber-400">
Ссылки на сборки не настроены (раздел <code>Pos</code> в конфигурации сервера).
</div>
)}
</div>
)
}
interface Form { interface Form {
id?: string id?: string
name: string name: string
@ -73,6 +110,7 @@ export function RetailPointsPage() {
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} /> <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)} )}
> >
<PosDownloadCard />
<DataTable <DataTable
rows={data?.items ?? []} rows={data?.items ?? []}
isLoading={isLoading} isLoading={isLoading}

View file

@ -220,6 +220,90 @@ public async Task Tenant_isolation_pos_sync()
syncB!.Products.Should().NotContain(p => p.Id.ToString() == pA); syncB!.Products.Should().NotContain(p => p.Id.ToString() == pA);
} }
/// <summary>Чек с кассы, пробитый в открытую смену, должен попасть в Z-отчёт
/// смены (MoneyService агрегирует продажи по CashShiftId) — именно эту
/// привязку касса теперь шлёт в RetailPointId/CashShiftId.</summary>
[Fact]
public async Task Pos_sale_in_shift_counts_in_close_expected_cash()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"pos-shift-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 500m, RandomBarcode());
await SeedStockAsync(api, refs, p1, 10m);
var retailPoint = await api.CreateRetailPointAsync(refs.StoreId, $"Точка-{Guid.NewGuid():N}");
var cash = await api.CreateMoneyAccountAsync("Касса точки", 1, refs.CurrencyId, refs.StoreId, 0m);
var shiftId = await OpenShiftAsync(api, retailPoint, cash, openingFloat: 5_000m);
// Касса пробивает чек на 2×500 наличными, привязывая к смене и точке.
var batch = new PosSaleBatchDto
{
IdempotencyKey = Guid.NewGuid(),
Sales = new[] { MakeSale(p1, qty: 2m, price: 500m) with
{
RetailPointId = Guid.Parse(retailPoint), CashShiftId = Guid.Parse(shiftId),
} },
};
using var post = await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch);
post.IsSuccessStatusCode.Should().BeTrue($"{(int)post.StatusCode}: {await post.Content.ReadAsStringAsync()}");
(await post.Content.ReadFromJsonAsync<PosSaleBatchResponse>())!.Accepted.Should().HaveCount(1);
// Закрываем смену: expected = 5 000 (размен) + 1 000 (продажа нал) = 6 000.
using var close = await api.Http.PostAsJsonAsync($"/api/money/shifts/{shiftId}/close", new { actualCash = 6_000m, notes = (string?)null });
close.EnsureSuccessStatusCode();
var body = await close.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("expectedCash").GetDecimal().Should().Be(6_000m, "продажа кассы учтена в смене");
body.GetProperty("difference").GetDecimal().Should().Be(0m);
}
/// <summary>Возврат покупателю (IsReturn) возвращает товар на склад (+остаток),
/// а не списывает его.</summary>
[Fact]
public async Task Pos_return_adds_stock_back()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"pos-ret-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 300m, RandomBarcode());
await SeedStockAsync(api, refs, p1, 10m);
var batch = new PosSaleBatchDto
{
IdempotencyKey = Guid.NewGuid(),
Sales = new[] { MakeSale(p1, qty: 3m, price: 300m) with { IsReturn = true } },
};
using var post = await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch);
post.IsSuccessStatusCode.Should().BeTrue($"{(int)post.StatusCode}: {await post.Content.ReadAsStringAsync()}");
(await post.Content.ReadFromJsonAsync<PosSaleBatchResponse>())!.Accepted.Should().HaveCount(1);
// Был 10, возврат 3 → 13 (возврат добавил остаток).
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(13m);
}
private static async Task SeedStockAsync(ApiActor api, ApiActor.Refs refs, string productId, decimal qty)
{
var enter = await api.Http.PostAsJsonAsync("/api/inventory/enters", new
{
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
notes = "seed", lines = new[] { new { productId, quantity = qty, unitCost = 50m } },
});
enter.EnsureSuccessStatusCode();
var eid = (await enter.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode();
}
private static async Task<string> OpenShiftAsync(ApiActor api, string retailPointId, string cashAccountId, decimal openingFloat)
{
var resp = await api.Http.PostAsJsonAsync("/api/money/shifts/open", new
{
retailPointId, cashAccountId, acquiringAccountId = (string?)null, openingFloat,
});
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode) throw new InvalidOperationException($"OpenShift failed {(int)resp.StatusCode}: {body}");
return JsonDocument.Parse(body).RootElement.GetProperty("id").GetString()!;
}
private static PosSaleDto MakeSale(string productId, decimal qty, decimal price) => new() private static PosSaleDto MakeSale(string productId, decimal qty, decimal price) => new()
{ {
ClientSaleId = Guid.NewGuid(), ClientSaleId = Guid.NewGuid(),

View file

@ -0,0 +1,377 @@
using FluentAssertions;
using foodmarket.Pos.Core.Data;
using foodmarket.Pos.Core.Logic;
using foodmarket.Pos.Core.Models;
using foodmarket.Pos.Core.Sync;
using foodmarket.Shared.Pos.V1;
using Xunit;
namespace foodmarket.UnitTests;
public class PosBarcodeTests
{
[Fact]
public void Normal_barcode_is_not_weighted()
{
var r = BarcodeParser.Parse("4870204010101");
r.IsWeighted.Should().BeFalse();
r.Lookup.Should().Be("4870204010101");
}
[Fact]
public void Weight_prefix_20_extracts_plu_and_kg()
{
// 21 | 12345 | 01500 | K → PLU=12345, вес 1.500 кг
var r = BarcodeParser.Parse("2112345015007");
r.IsWeighted.Should().BeTrue();
r.Lookup.Should().Be("12345");
r.EmbeddedWeight.Should().Be(1.500m);
r.EmbeddedPrice.Should().BeNull();
}
[Fact]
public void Price_prefix_25_extracts_plu_and_price()
{
// 25 | 54321 | 12345 | K → PLU=54321, цена 123.45 ₸
var r = BarcodeParser.Parse("2554321123456");
r.IsWeighted.Should().BeTrue();
r.Lookup.Should().Be("54321");
r.EmbeddedPrice.Should().Be(123.45m);
r.EmbeddedWeight.Should().BeNull();
}
}
public class PosCartTests
{
private static PosProduct Piece(decimal price = 500m) => new()
{ Id = Guid.NewGuid(), Name = "Шт товар", Barcodes = new[] { "111" }, UnitCode = "шт", Packaging = PosPackaging.Piece, VatPercent = 12m, VatEnabled = true, Price = price };
private static PosProduct Weighed(decimal price = 1000m) => new()
{ Id = Guid.NewGuid(), Name = "Вес товар", Barcodes = new[] { "222" }, UnitCode = "кг", Packaging = PosPackaging.Weight, VatPercent = 12m, VatEnabled = true, Price = price };
[Fact]
public void Piece_scans_increment_quantity()
{
var c = new Cart(); var p = Piece();
c.AddProduct(p); c.AddProduct(p); c.AddProduct(p);
c.Lines.Should().HaveCount(1);
c.Lines[0].Quantity.Should().Be(3);
c.Totals().Total.Should().Be(1500m);
}
[Fact]
public void Weighed_requires_weight_and_is_separate_line()
{
var c = new Cart(); var w = Weighed(1000m);
c.AddProduct(w, 1.5m);
c.AddProduct(w, 0.25m);
c.Lines.Should().HaveCount(2);
c.Totals().Total.Should().Be(1500m + 250m);
}
[Fact]
public void Line_and_cart_discounts_apply_and_clamp()
{
var c = new Cart(); var p = Piece(500m);
var line = c.AddProduct(p, 4m); // 2000
c.SetLineDiscount(line, 300m); // 1700
c.SetCartDiscount(200m); // 1500
c.Totals().Total.Should().Be(1500m);
// перебор скидки обрезается
c.SetLineDiscount(line, 999999m);
c.Lines[0].Discount.Should().Be(2000m);
}
[Fact]
public void Remove_and_clear()
{
var c = new Cart(); var line = c.AddProduct(Piece());
c.Remove(line); c.IsEmpty.Should().BeTrue();
}
}
public class PosPaymentShiftTests
{
[Fact]
public void Cash_overpay_gives_change()
{
var r = PaymentMath.Compute(1500m, paidCash: 2000m, paidCard: 0m);
r.Sufficient.Should().BeTrue();
r.Change.Should().Be(500m);
}
[Fact]
public void Mixed_exact_no_change()
{
var r = PaymentMath.Compute(1500m, paidCash: 500m, paidCard: 1000m);
r.Sufficient.Should().BeTrue();
r.Change.Should().Be(0m);
}
[Fact]
public void Underpay_is_insufficient_with_shortfall()
{
var r = PaymentMath.Compute(1500m, paidCash: 1000m, paidCard: 0m);
r.Sufficient.Should().BeFalse();
r.Shortfall.Should().Be(500m);
}
[Fact]
public void Shift_expected_and_difference()
{
var exp = ShiftMath.ExpectedCash(5000m, 3775m, 1500m, 10000m, 3000m);
exp.Should().Be(14275m);
ShiftMath.CashDifference(14775m, exp).Should().Be(500m);
}
}
public class PosStoreTests
{
private static PosSyncResponse Sync(params (Guid id, string name, string bc, decimal price, int pack)[] items) => new()
{
ServerTime = DateTime.UtcNow,
Products = items.Select(i => new ProductSyncDto
{
Id = i.id, Name = i.name, Barcodes = new[] { i.bc }, UnitCode = i.pack == 2 ? "кг" : "шт",
Packaging = i.pack, VatPercent = 12m, VatEnabled = true, IsMarked = false, IsArchived = false, UpdatedAt = DateTime.UtcNow,
}).ToList(),
Prices = items.Select(i => new PriceSyncDto
{
ProductId = i.id, PriceTypeId = Guid.NewGuid(), PriceTypeName = "Розн", IsSystem = true,
Amount = i.price, CurrencyCode = "KZT", UpdatedAt = DateTime.UtcNow,
}).ToList(),
Stocks = Array.Empty<StockSyncDto>(), Counterparties = Array.Empty<CounterpartySyncDto>(), DeletedProductIds = Array.Empty<Guid>(),
};
[Fact]
public void Catalog_find_by_barcode_and_name()
{
using var s = PosStore.InMemory();
var id = Guid.NewGuid();
s.ApplySync(Sync((id, "Молоко 2.5%", "4870001112223", 450m, 1)));
s.ProductCount().Should().Be(1);
s.FindByBarcode("4870001112223")!.Name.Should().Be("Молоко 2.5%");
s.FindByBarcode("4870001112223")!.Price.Should().Be(450m);
s.SearchByName("молок").Should().ContainSingle();
}
[Fact]
public void Queue_enqueue_pending_marksent()
{
using var s = PosStore.InMemory();
var sale = new PosSaleDto
{
ClientSaleId = Guid.NewGuid(), OccurredAt = DateTime.UtcNow, Payment = 0, PaidCash = 100m, PaidCard = 0m,
Lines = new[] { new PosSaleLineDto { ProductId = Guid.NewGuid(), Quantity = 1m, UnitPrice = 100m, Discount = 0m } },
};
s.EnqueueSale(sale);
s.EnqueueSale(sale); // повтор не дублирует (PK ClientSaleId)
s.PendingCount().Should().Be(1);
s.MarkSent(sale.ClientSaleId);
s.PendingCount().Should().Be(0);
}
}
public class PosSyncKeyTests
{
[Fact]
public void Batch_key_is_stable_for_same_set_regardless_of_order()
{
var a = Guid.NewGuid(); var b = Guid.NewGuid();
SyncService.StableBatchKey(new[] { a, b }).Should().Be(SyncService.StableBatchKey(new[] { b, a }));
}
[Fact]
public void Batch_key_differs_for_different_sets()
{
SyncService.StableBatchKey(new[] { Guid.NewGuid() })
.Should().NotBe(SyncService.StableBatchKey(new[] { Guid.NewGuid() }));
}
}
/// <summary>Офлайн-сценарий задачи (п.8): нет сети → кассир пробивает 5 чеков →
/// появляется сеть → синхронизация → ровно 5 продаж на «сервере», без дублей
/// даже при повторном пуше. Прогоняется против фейкового IServerApi, который
/// повторяет серверную идемпотентность по ClientSaleId.</summary>
public class PosOfflineFlowTests
{
/// <summary>Фейковый сервер: хранит принятые продажи по ClientSaleId.
/// Повторный приём той же продажи не создаёт дубля (как реальный сервер).</summary>
private sealed class FakeServer : IServerApi
{
public readonly Dictionary<Guid, Guid> ServerByClient = new();
public int BatchCalls;
public bool Online;
public Task<PosSaleBatchResponse> AcceptSalesAsync(PosSaleBatchDto batch, CancellationToken ct = default)
{
if (!Online) throw new HttpRequestException("нет сети");
BatchCalls++;
bool anyNew = false;
var accepted = new List<PosSaleAcceptedDto>();
foreach (var s in batch.Sales)
{
if (!ServerByClient.TryGetValue(s.ClientSaleId, out var sid))
{ sid = Guid.NewGuid(); ServerByClient[s.ClientSaleId] = sid; anyNew = true; }
accepted.Add(new PosSaleAcceptedDto { ClientSaleId = s.ClientSaleId, ServerSaleId = sid, ServerSaleNumber = $"ПР-{sid:N}".Substring(0, 10) });
}
return Task.FromResult(new PosSaleBatchResponse
{
IdempotencyKey = batch.IdempotencyKey, Accepted = accepted,
Failed = Array.Empty<PosSaleFailedDto>(), ReplayedFromCache = !anyNew,
});
}
public Task<PosVersionDto> VersionAsync(CancellationToken ct = default) => throw new NotImplementedException();
public Task<PosSyncResponse> SyncAsync(DateTime? since, CancellationToken ct = default) => throw new NotImplementedException();
public Task<MeDto> MeAsync(CancellationToken ct = default) => throw new NotImplementedException();
public Task<PagedDto<RetailPointDto>> RetailPointsAsync(int pageSize = 200, CancellationToken ct = default) => throw new NotImplementedException();
public Task<PagedDto<MoneyAccountDto>> MoneyAccountsAsync(int pageSize = 200, CancellationToken ct = default) => throw new NotImplementedException();
public Task<Refit.ApiResponse<CurrentShiftDto>> CurrentShiftAsync(Guid retailPointId, CancellationToken ct = default) => throw new NotImplementedException();
public Task<OpenShiftResultDto> OpenShiftAsync(OpenShiftBody body, CancellationToken ct = default) => throw new NotImplementedException();
public Task<CloseShiftResultDto> CloseShiftAsync(Guid id, CloseShiftBody body, CancellationToken ct = default) => throw new NotImplementedException();
public Task DrawerAsync(Guid id, DrawerBody body, CancellationToken ct = default) => throw new NotImplementedException();
public Task<ShiftReportDto> ShiftReportAsync(Guid id, CancellationToken ct = default) => throw new NotImplementedException();
}
private static PosSaleDto Sale() => new()
{
ClientSaleId = Guid.NewGuid(), OccurredAt = DateTime.UtcNow,
Payment = 0, PaidCash = 500m, PaidCard = 0m,
Lines = new[] { new PosSaleLineDto { ProductId = Guid.NewGuid(), Quantity = 1m, UnitPrice = 500m, Discount = 0m } },
};
[Fact]
public async Task No_network_then_five_sales_then_sync_yields_exactly_five_no_duplicates()
{
using var store = PosStore.InMemory();
var server = new FakeServer { Online = false };
var sync = new SyncService(store, server);
// 1) Сети нет — попытка пуша кидает, очередь не теряется.
store.EnqueueSale(Sale());
await Assert.ThrowsAnyAsync<Exception>(() => sync.PushSalesAsync());
server.ServerByClient.Should().BeEmpty("офлайн — на сервер ничего не ушло");
// 2) Кассир пробивает ещё 4 чека офлайн — всего 5 в очереди.
for (int i = 0; i < 4; i++) store.EnqueueSale(Sale());
store.PendingCount().Should().Be(5);
// 3) Появилась сеть — синхронизация.
server.Online = true;
var push = await sync.PushSalesAsync();
push.Accepted.Should().Be(5);
push.Failed.Should().Be(0);
store.PendingCount().Should().Be(0, "все отправленные помечены sent");
server.ServerByClient.Should().HaveCount(5, "на сервере ровно 5 чеков");
// 4) Повторный пуш (например, двойной клик/ретрай) не плодит дублей.
var replay = await sync.PushSalesAsync();
replay.Accepted.Should().Be(0, "очередь уже пуста — слать нечего");
server.ServerByClient.Should().HaveCount(5, "по-прежнему ровно 5");
}
[Fact]
public async Task Resend_of_same_queue_is_idempotent_on_server()
{
using var store = PosStore.InMemory();
var server = new FakeServer { Online = true };
var sync = new SyncService(store, server);
for (int i = 0; i < 5; i++) store.EnqueueSale(Sale());
// Эмулируем: пуш прошёл на сервере, но ответ не дошёл (касса не пометила sent).
var pending = store.PendingSales();
await server.AcceptSalesAsync(new PosSaleBatchDto { IdempotencyKey = SyncService.StableBatchKey(pending.Select(s => s.ClientSaleId)), Sales = pending });
server.ServerByClient.Should().HaveCount(5);
// Касса повторяет весь набор — сервер распознаёт по ClientSaleId, дублей нет.
var push = await sync.PushSalesAsync();
push.Accepted.Should().Be(5);
server.ServerByClient.Should().HaveCount(5, "повтор того же набора не создал новых чеков");
store.PendingCount().Should().Be(0);
}
}
/// <summary>Сборка серверного чека из корзины: распределение скидки на чек по
/// строкам (сервер понимает только построчную скидку), способ оплаты, привязка
/// к смене/точке, возвраты.</summary>
public class PosCheckoutTests
{
private static PosProduct P(decimal price, bool weighed = false) => new()
{
Id = Guid.NewGuid(), Name = "Товар", Barcodes = new[] { "1" }, UnitCode = weighed ? "кг" : "шт",
Packaging = weighed ? PosPackaging.Weight : PosPackaging.Piece, VatPercent = 12m, VatEnabled = true, Price = price,
};
[Fact]
public void Cart_discount_is_distributed_across_lines_and_sums_exactly()
{
var cart = new Cart();
cart.AddProduct(P(100m), 1); // 100
cart.AddProduct(P(200m), 1); // 200
cart.AddProduct(P(700m), 1); // 700 → сумма 1000
cart.SetCartDiscount(100m); // 10% на чек
var sale = Checkout.BuildSale(cart, paidCash: 900m, paidCard: 0m,
new CheckoutContext(null, null, null));
// Сумма построчных скидок точно равна скидке на чек.
sale.Lines.Sum(l => l.Discount).Should().Be(100m);
// Итог сервера (Σ qty*price − Σ disc) совпадает с итогом кассы.
var serverTotal = sale.Lines.Sum(l => l.Quantity * l.UnitPrice - l.Discount);
serverTotal.Should().Be(cart.Totals().Total);
}
[Fact]
public void Payment_method_is_inferred_from_cash_and_card()
{
var cart = new Cart(); cart.AddProduct(P(500m), 1);
Checkout.BuildSale(cart, 500m, 0m, new(null, null, null)).Payment.Should().Be((int)PosPaymentMethod.Cash);
Checkout.BuildSale(cart, 0m, 500m, new(null, null, null)).Payment.Should().Be((int)PosPaymentMethod.Card);
Checkout.BuildSale(cart, 200m, 300m, new(null, null, null)).Payment.Should().Be((int)PosPaymentMethod.Mixed);
}
[Fact]
public void Shift_and_point_context_is_carried_into_sale()
{
var cart = new Cart(); cart.AddProduct(P(500m), 1);
var rp = Guid.NewGuid(); var shift = Guid.NewGuid(); var cashier = Guid.NewGuid();
var sale = Checkout.BuildSale(cart, 500m, 0m, new(cashier, rp, shift));
sale.RetailPointId.Should().Be(rp);
sale.CashShiftId.Should().Be(shift);
sale.CashierUserId.Should().Be(cashier);
sale.IsReturn.Should().BeFalse();
}
[Fact]
public void Return_sets_flag_and_reference()
{
var cart = new Cart(); cart.AddProduct(P(500m), 1);
var refSale = Guid.NewGuid();
var sale = Checkout.BuildSale(cart, 0m, 0m, new(null, null, null), isReturn: true, referenceSaleId: refSale);
sale.IsReturn.Should().BeTrue();
sale.ReferenceSaleId.Should().Be(refSale);
}
[Fact]
public void Empty_cart_throws()
{
var cart = new Cart();
var act = () => Checkout.BuildSale(cart, 0m, 0m, new(null, null, null));
act.Should().Throw<InvalidOperationException>();
}
}
public class PosUpdateCheckTests
{
[Theory]
[InlineData("2026.10.7", "2026.10.8", true)]
[InlineData("2026.10.8", "2026.10.8", false)]
[InlineData("2026.10.8", "2026.10.7", false)]
[InlineData("1.9.0", "1.10.0", true)]
[InlineData("2026.9.30", "2026.10.1", true)]
[InlineData(null, "2026.10.1", true)]
[InlineData("2026.10.1", null, false)]
[InlineData("1.2.3", "1.2.3-beta", false)]
public void Version_comparison(string? local, string? remote, bool expected)
=> UpdateCheck.IsNewer(local, remote).Should().Be(expected);
}

View file

@ -16,6 +16,7 @@
<ProjectReference Include="..\..\src\food-market.infrastructure\food-market.infrastructure.csproj" /> <ProjectReference Include="..\..\src\food-market.infrastructure\food-market.infrastructure.csproj" />
<ProjectReference Include="..\..\src\food-market.api\food-market.api.csproj" /> <ProjectReference Include="..\..\src\food-market.api\food-market.api.csproj" />
<ProjectReference Include="..\..\src\food-market.shared\food-market.shared.csproj" /> <ProjectReference Include="..\..\src\food-market.shared\food-market.shared.csproj" />
<ProjectReference Include="..\..\src\food-market.pos.core\food-market.pos.core.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>