Compare commits
No commits in common. "81085aed8b64e3381613ba4aae4a3a3c42d64420" and "6752601ed8eee794ef1a4b8cc1c9f5b8dd7c3b8c" have entirely different histories.
81085aed8b
...
6752601ed8
4
.gitignore
vendored
|
|
@ -93,7 +93,3 @@ 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/
|
|
||||||
|
|
|
||||||
|
|
@ -62,15 +62,7 @@
|
||||||
<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" />
|
||||||
|
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
#!/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 собрана."
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
# Касса МойСклад — 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`.
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
# Кассовая программа (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 + карточка скачивания в админке.
|
|
||||||
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 78 KiB |
89
docs/pos.md
|
|
@ -1,89 +0,0 @@
|
||||||
# Касса 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.
|
|
||||||
|
|
@ -48,22 +48,6 @@ 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,
|
||||||
|
|
@ -183,9 +167,7 @@ public ActionResult<PosVersionDto> Version([FromServices] IConfiguration cfg)
|
||||||
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.");
|
||||||
// Склад: если у продаж указана точка продаж — берём её склад; иначе storeId/главный.
|
var resolvedStore = await ResolveStoreAsync(storeId, ct);
|
||||||
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" });
|
||||||
|
|
||||||
|
|
@ -293,10 +275,8 @@ public ActionResult<PosVersionDto> Version([FromServices] IConfiguration cfg)
|
||||||
if (src.Lines.Count == 0)
|
if (src.Lines.Count == 0)
|
||||||
throw new PosSaleRejectedException("Пустой чек.", "lines");
|
throw new PosSaleRejectedException("Пустой чек.", "lines");
|
||||||
|
|
||||||
// Возврат покупателю добавляет остаток, поэтому pre-flight не нужен; для
|
// Pre-flight на остатки. Сделано до создания RetailSale чтобы зря не
|
||||||
// продажи проверяем остаток до создания 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();
|
||||||
|
|
@ -310,16 +290,6 @@ public ActionResult<PosVersionDto> Version([FromServices] IConfiguration cfg)
|
||||||
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);
|
||||||
|
|
@ -339,10 +309,6 @@ public ActionResult<PosVersionDto> Version([FromServices] IConfiguration cfg)
|
||||||
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),
|
||||||
|
|
@ -376,18 +342,15 @@ public ActionResult<PosVersionDto> Version([FromServices] IConfiguration cfg)
|
||||||
|
|
||||||
_db.RetailSales.Add(sale);
|
_db.RetailSales.Add(sale);
|
||||||
|
|
||||||
// Проведение: продажа списывает (-Quantity, RetailSale), возврат
|
// Проведение: -Quantity StockMovement 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: src.IsReturn ? l.Quantity : -l.Quantity,
|
Quantity: -l.Quantity,
|
||||||
Type: movementType,
|
Type: MovementType.RetailSale,
|
||||||
DocumentType: documentType,
|
DocumentType: "retail-sale",
|
||||||
DocumentId: sale.Id,
|
DocumentId: sale.Id,
|
||||||
DocumentNumber: sale.Number,
|
DocumentNumber: sale.Number,
|
||||||
UnitCost: l.UnitPrice,
|
UnitCost: l.UnitPrice,
|
||||||
|
|
@ -411,23 +374,13 @@ public ActionResult<PosVersionDto> Version([FromServices] IConfiguration cfg)
|
||||||
return (sale.Id, sale.Number);
|
return (sale.Id, sale.Number);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task<Guid?> ResolveStoreAsync(Guid? requested, CancellationToken ct)
|
private async 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);
|
||||||
|
|
|
||||||
|
|
@ -35,12 +35,5 @@
|
||||||
"https://admin.food-market.kz"
|
"https://admin.food-market.kz"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Pos": {
|
|
||||||
"LatestVersion": "0.0.0",
|
|
||||||
"DownloadWindows": "",
|
|
||||||
"DownloadLinux": "",
|
|
||||||
"ReleaseNotes": "",
|
|
||||||
"Mandatory": false
|
|
||||||
},
|
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
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) { } }
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,241 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
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 }
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
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);
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -10,7 +10,8 @@
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Data.Sqlite" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||||
|
<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" />
|
||||||
|
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
7
src/food-market.pos/App.xaml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<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>
|
||||||
5
src/food-market.pos/App.xaml.cs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
namespace foodmarket.Pos;
|
||||||
|
|
||||||
|
public partial class App : System.Windows.Application
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
11
src/food-market.pos/MainWindow.xaml
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<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>
|
||||||
9
src/food-market.pos/MainWindow.xaml.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
namespace foodmarket.Pos;
|
||||||
|
|
||||||
|
public partial class MainWindow : System.Windows.Window
|
||||||
|
{
|
||||||
|
public MainWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,129 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,508 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
using Avalonia.Controls;
|
|
||||||
|
|
||||||
namespace foodmarket.Pos.Views;
|
|
||||||
|
|
||||||
public partial class LoginView : UserControl
|
|
||||||
{
|
|
||||||
public LoginView() => InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
using Avalonia.Controls;
|
|
||||||
|
|
||||||
namespace foodmarket.Pos.Views;
|
|
||||||
|
|
||||||
public partial class PaymentView : UserControl
|
|
||||||
{
|
|
||||||
public PaymentView() => InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
using Avalonia.Controls;
|
|
||||||
|
|
||||||
namespace foodmarket.Pos.Views;
|
|
||||||
|
|
||||||
public partial class ReturnView : UserControl
|
|
||||||
{
|
|
||||||
public ReturnView() => InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
using Avalonia.Controls;
|
|
||||||
|
|
||||||
namespace foodmarket.Pos.Views;
|
|
||||||
|
|
||||||
public partial class SaleView : UserControl
|
|
||||||
{
|
|
||||||
public SaleView() => InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
using Avalonia.Controls;
|
|
||||||
|
|
||||||
namespace foodmarket.Pos.Views;
|
|
||||||
|
|
||||||
public partial class ShiftView : UserControl
|
|
||||||
{
|
|
||||||
public ShiftView() => InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>WinExe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0-windows</TargetFramework>
|
||||||
<RootNamespace>foodmarket.Pos</RootNamespace>
|
<RootNamespace>foodmarket.Pos</RootNamespace>
|
||||||
<AssemblyName>foodmarket.Pos</AssemblyName>
|
<AssemblyName>foodmarket.Pos</AssemblyName>
|
||||||
<Nullable>enable</Nullable>
|
<UseWPF>true</UseWPF>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
<Nullable>enable</Nullable>
|
||||||
<ApplicationIcon></ApplicationIcon>
|
<!-- Allow building on macOS/Linux for CI; running still requires Windows -->
|
||||||
|
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
@ -16,15 +17,9 @@
|
||||||
</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>
|
||||||
|
|
|
||||||
|
|
@ -106,18 +106,6 @@ 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
|
||||||
|
|
@ -147,16 +135,6 @@ 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; }
|
||||||
|
|
|
||||||
|
|
@ -16,43 +16,6 @@ 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
|
||||||
|
|
@ -110,7 +73,6 @@ 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}
|
||||||
|
|
|
||||||
|
|
@ -220,90 +220,6 @@ 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(),
|
||||||
|
|
|
||||||
|
|
@ -1,377 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
@ -16,7 +16,6 @@
|
||||||
<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>
|
||||||
|
|
|
||||||