Compare commits

..

No commits in common. "4675f38a0f75ab9b6e9114004f426361477debdb" and "05c70f036867c17a4b06760c66ab71df0b4eb532" have entirely different histories.

241 changed files with 212 additions and 32096 deletions

View file

@ -1 +1 @@
{"sessionId":"fae8ce63-bd1d-4246-9a44-09731c66e311","pid":2166378,"procStart":"133122020","acquiredAt":1779943840096} {"sessionId":"791848bb-ad06-4ccc-9c5a-13da4ee36524","pid":353363,"acquiredAt":1776883641374}

View file

@ -27,8 +27,6 @@
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" /> <PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" />
<!-- App services --> <!-- App services -->
<PackageVersion Include="CsvHelper" Version="33.0.1" />
<PackageVersion Include="ClosedXML" Version="0.104.2" />
<PackageVersion Include="MailKit" Version="4.10.0" /> <PackageVersion Include="MailKit" Version="4.10.0" />
<PackageVersion Include="Microsoft.AspNetCore.DataProtection" Version="8.0.11" /> <PackageVersion Include="Microsoft.AspNetCore.DataProtection" Version="8.0.11" />
<PackageVersion Include="MediatR" Version="12.4.1" /> <PackageVersion Include="MediatR" Version="12.4.1" />
@ -44,9 +42,6 @@
<PackageVersion Include="Hangfire.AspNetCore" Version="1.8.17" /> <PackageVersion Include="Hangfire.AspNetCore" Version="1.8.17" />
<PackageVersion Include="Hangfire.PostgreSql" Version="1.20.10" /> <PackageVersion Include="Hangfire.PostgreSql" Version="1.20.10" />
<!-- Observability / Prometheus -->
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
<!-- POS: local storage + API client --> <!-- POS: local storage + API client -->
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
<PackageVersion Include="Refit" Version="7.2.22" /> <PackageVersion Include="Refit" Version="7.2.22" />
@ -64,7 +59,6 @@
<PackageVersion Include="NSubstitute" Version="5.3.0" /> <PackageVersion Include="NSubstitute" Version="5.3.0" />
<PackageVersion Include="coverlet.collector" Version="6.0.3" /> <PackageVersion Include="coverlet.collector" Version="6.0.3" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.11" /> <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.11" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.1.0" /> <PackageVersion Include="Testcontainers.PostgreSql" Version="4.1.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -1,43 +0,0 @@
# food-market — пример переменных окружения для деплоя.
#
# Скопировать в deploy/.env и заполнить значениями (.env в .gitignore — НЕ коммитить).
# cp deploy/.env.example deploy/.env && $EDITOR deploy/.env
#
# docker-compose читает deploy/.env автоматически. Описание секретов и ротация —
# docs/secrets.md. Ключи OpenIddict — docs/openiddict-keys.md.
# ─── Реестр образов и теги (docker-compose) ──────────────────────────────────
# Откуда тянуть образы. Локальный registry на stage — 127.0.0.1:5001 (см. CLAUDE/memory).
REGISTRY=127.0.0.1:5001
API_TAG=latest
WEB_TAG=latest
PUBLIC_TAG=latest
# ─── База данных (ОБЯЗАТЕЛЬНО) ───────────────────────────────────────────────
# Пароль пользователя food_market в Postgres-контейнере. Подставляется и в
# POSTGRES_PASSWORD контейнера БД, и в ConnectionStrings__Default API.
# Сгенерировать: openssl rand -base64 24
POSTGRES_PASSWORD=CHANGE_ME_strong_db_password
# ─── OpenIddict / выдача токенов ─────────────────────────────────────────────
# Публичный URL админки = issuer токенов (обязателен за nginx-прокси).
OPENIDDICT_ISSUER=https://admin.food-market.kz/
# Пароль PFX-сертификатов подписи/шифрования. Пусто = без пароля (self-signed
# генерируется автоматически в App_Data, если файлов нет). Подробности — docs/openiddict-keys.md.
OPENIDDICT_CERT_PASSWORD=
# ─── Бэкап (systemd food-market-backup.*) ────────────────────────────────────
# Переопределения для скрипта бэкапа. По умолчанию совпадают с compose — можно не задавать.
# FM_BACKUP_DIR=/opt/food-market-data/backups
# FM_UPLOADS_DIR=/opt/food-market-data/uploads
# FM_BACKUP_RETENTION_DAYS=30
# ─── Прочее (опционально, переопределяет appsettings.json) ───────────────────
# CORS-origins фронта (если отличается от зашитых в appsettings). Индексируется с 0:
# Cors__AllowedOrigins__0=https://admin.food-market.kz
# Антибрутфорс на /connect/token и /api/auth/signup (дефолты 5/мин, 20/час):
# RateLimiting__Enabled=true
# RateLimiting__PerMinute=5
# RateLimiting__PerHour=20
# Интеграция МойСклад (по умолчанию боевой api.moysklad.ru):
# MoySklad__BaseUrl=https://api.moysklad.ru/api/remap/1.2/

View file

@ -32,7 +32,7 @@ ENV DOTNET_NOLOGO=1
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
EXPOSE 8080 EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s \ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
CMD curl -fsS http://localhost:8080/health/ready || exit 1 CMD curl -fsS http://localhost:8080/health || exit 1
ENTRYPOINT ["dotnet", "foodmarket.Api.dll"] ENTRYPOINT ["dotnet", "foodmarket.Api.dll"]

View file

@ -29,24 +29,11 @@ services:
environment: environment:
ASPNETCORE_ENVIRONMENT: Production ASPNETCORE_ENVIRONMENT: Production
ConnectionStrings__Default: Host=postgres;Port=5432;Database=food_market;Username=food_market;Password=${POSTGRES_PASSWORD:-food_market_dev} ConnectionStrings__Default: Host=postgres;Port=5432;Database=food_market;Username=food_market;Password=${POSTGRES_PASSWORD:-food_market_dev}
# Публичный issuer токенов — обязателен за прокси, иначе берётся из запроса
# (или дефолт localhost из appsettings, что неверно для прод).
OpenIddict__Issuer: ${OPENIDDICT_ISSUER:-https://admin.food-market.kz/}
# Пароль PFX-сертификатов OpenIddict (пусто = сертификаты без пароля).
OpenIddict__CertPassword: ${OPENIDDICT_CERT_PASSWORD:-}
# Host port mapping: pick free ports on existing stage server (80/443 taken by # Host port mapping: pick free ports on existing stage server (80/443 taken by
# legacy nginx, 5000/5002/5005 taken by legacy .NET apps). # legacy nginx, 5000/5002/5005 taken by legacy .NET apps).
ports: ports:
- "8080:8080" # api - "8080:8080" # api
healthcheck:
# Готовность = БД отвечает + миграции применены (см. /health/ready).
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/health/ready || exit 1"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
volumes: volumes:
- api-data:/app/App_Data - api-data:/app/App_Data
- api-logs:/app/logs - api-logs:/app/logs
@ -57,8 +44,7 @@ services:
container_name: food-market-web container_name: food-market-web
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
api: - api
condition: service_healthy
ports: ports:
- "8081:80" # web SPA, not on 80 (legacy nginx holds it) - "8081:80" # web SPA, not on 80 (legacy nginx holds it)

View file

@ -1,15 +0,0 @@
[Unit]
Description=food-market: бэкап БД и загруженных файлов
Documentation=https://github.com/nurdotnet/food-market/blob/main/docs/backup-restore.md
Wants=docker.service
After=docker.service
[Service]
Type=oneshot
# Опциональные переопределения FM_* (см. шапку скрипта). Знак "-" — файл не
# обязателен. Путь скорректировать под фактический каталог деплоя.
EnvironmentFile=-/opt/food-market/deploy/.env
ExecStart=/opt/food-market/deploy/food-market-backup.sh
# Бэкап не должен мешать основной нагрузке.
Nice=10
IOSchedulingClass=idle

View file

@ -1,67 +0,0 @@
#!/usr/bin/env bash
#
# food-market: ежедневный бэкап БД + загруженных файлов с ротацией.
#
# Дампит Postgres (контейнер food-market-postgres) в custom-формат pg_dump
# (-Fc, пригоден для pg_restore с параллелизмом/выборочным восстановлением) и
# архивирует каталог uploads. Удаляет бэкапы старше RETENTION_DAYS дней.
#
# Запускается из systemd-таймера food-market-backup.timer (ежедневно), либо
# вручную. Конфигурируется переменными окружения (значения по умолчанию
# совпадают с deploy/docker-compose.yml):
#
# FM_PG_CONTAINER имя контейнера Postgres (food-market-postgres)
# FM_PG_DB имя БД (food_market)
# FM_PG_USER пользователь БД (food_market)
# FM_BACKUP_DIR куда складывать бэкапы (/opt/food-market-data/backups)
# FM_UPLOADS_DIR каталог изображений (/opt/food-market-data/uploads)
# FM_BACKUP_RETENTION_DAYS срок хранения, дней (30)
#
set -euo pipefail
CONTAINER="${FM_PG_CONTAINER:-food-market-postgres}"
DB="${FM_PG_DB:-food_market}"
DB_USER="${FM_PG_USER:-food_market}"
BACKUP_DIR="${FM_BACKUP_DIR:-/opt/food-market-data/backups}"
UPLOADS_DIR="${FM_UPLOADS_DIR:-/opt/food-market-data/uploads}"
RETENTION_DAYS="${FM_BACKUP_RETENTION_DAYS:-30}"
TS="$(date +%Y%m%d-%H%M%S)"
DB_FILE="$BACKUP_DIR/db-$TS.dump"
UPLOADS_FILE="$BACKUP_DIR/uploads-$TS.tgz"
log() { echo "[$(date -Is)] $*"; }
mkdir -p "$BACKUP_DIR"
if ! docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then
log "ОШИБКА: контейнер '$CONTAINER' не запущен — бэкап невозможен." >&2
exit 1
fi
log "Дамп БД '$DB' → $DB_FILE"
# Дамп пишем во временный файл и переименовываем по успеху — частичный/битый
# дамп при падении pg_dump не попадёт в ротацию как валидный.
if docker exec "$CONTAINER" pg_dump -U "$DB_USER" -d "$DB" -Fc > "$DB_FILE.tmp"; then
mv "$DB_FILE.tmp" "$DB_FILE"
log "Готово: $(du -h "$DB_FILE" | cut -f1)"
else
rm -f "$DB_FILE.tmp"
log "ОШИБКА: pg_dump завершился с ошибкой." >&2
exit 1
fi
if [ -d "$UPLOADS_DIR" ]; then
log "Архив uploads '$UPLOADS_DIR' → $UPLOADS_FILE"
tar czf "$UPLOADS_FILE.tmp" -C "$(dirname "$UPLOADS_DIR")" "$(basename "$UPLOADS_DIR")" \
&& mv "$UPLOADS_FILE.tmp" "$UPLOADS_FILE"
log "Готово: $(du -h "$UPLOADS_FILE" | cut -f1)"
else
log "Каталог uploads '$UPLOADS_DIR' отсутствует — пропуск."
fi
log "Ротация: удаляю бэкапы старше $RETENTION_DAYS дн."
find "$BACKUP_DIR" -maxdepth 1 -type f -name 'db-*.dump' -mtime +"$RETENTION_DAYS" -print -delete
find "$BACKUP_DIR" -maxdepth 1 -type f -name 'uploads-*.tgz' -mtime +"$RETENTION_DAYS" -print -delete
log "Бэкап завершён."

View file

@ -1,14 +0,0 @@
[Unit]
Description=food-market: ежедневный бэкап (03:00)
Documentation=https://github.com/nurdotnet/food-market/blob/main/docs/backup-restore.md
[Timer]
# Каждый день в 03:00 локального времени сервера.
OnCalendar=*-*-* 03:00:00
# Догнать пропущенный запуск, если сервер был выключен в момент срабатывания.
Persistent=true
# Небольшой разброс — на случай нескольких таймеров одновременно.
RandomizedDelaySec=300
[Install]
WantedBy=timers.target

View file

@ -1,221 +0,0 @@
# ТЗ на доработку Food Market
> Дата составления: 2026-05-22
> Автор анализа: Claude Opus 4.7
> Базируется на полном обходе кодовой базы `~/food-market` (backend + web + public + tests + deploy).
---
## 1. Текущее состояние системы
### 1.1. Сводная таблица готовности по модулям
| Модуль / слой | Статус | Готовность | Ключевой комментарий |
|---|---|---:|---|
| **food-market.domain** | ✅ готово | 95% | 26 сущностей, мультитенантность через `ITenantEntity`/`IOptionalTenantEntity`, чисто (нет TODO/HACK). |
| **food-market.infrastructure** | ✅ готово | 90% | EF Core 8, query filters, MailKit SMTP, StockService, MoySkladClient, 34 миграции. |
| **food-market.api (контроллеры)** | ✅ готово | 85% | 27 контроллеров, ~120 endpoint'ов, OpenIddict (password + refresh), CRUD полный. |
| **food-market.application** | 🟡 частично | 60% | Только DTO + интерфейсы, нет MediatR handlers — вся логика в контроллерах. |
| **food-market.web (админка)** | ✅ готово | 95% | 35 страниц, темная тема, адаптив, RU-локаль, onBlur-валидация. |
| **food-market.public (сайт)** | ✅ готово | 90% | Astro 4: landing, тарифы, блог, KB, legal; SignupForm → API. |
| **food-market.shared (POS контракты)** | ❌ нет | 0% | Только .csproj, ни одного CS-файла. |
| **food-market.pos.core + food-market.pos** | ❌ скелет | 5% | Пустой WPF-проект, только зависимости в .csproj. |
| **POS Sync API** | ❌ нет | 0% | Нет `/api/pos/sync`, нет `/api/pos/sales bulk`, нет WebSocket. |
| **Documents: Supply / RetailSale** | ✅ готово | 100% | Полный цикл (Draft → Post → Unpost), Stock + Movement, Cost (скользящее среднее). |
| **Documents: Inventory / Loss / Enter / Transfer** | ❌ нет | 0% | Нет контроллеров и страниц. Domain-сущности тоже не определены. |
| **Documents: Demand (оптовая отгрузка)** | ❌ нет | 0% | Только enum `MovementType.WholesaleSale`, контроллера/сущности нет. |
| **Reports** | ❌ нет | 5% | Есть `/api/sales/retail/stats` для дашборда, отдельных отчётов нет. |
| **MoySklad интеграция** | 🟡 частично | 50% | Импорт товаров и контрагентов ✅; нет Demand/Payment sync, нет webhook'ов. |
| **OpenIddict auth** | ✅ готово | 100% | Password + refresh_token; org_id, role, sub claims; persistent dev-ключи. |
| **Multi-tenancy** | ✅ готово | 95% | Query filters + `HttpContextTenantContext`; SuperAdmin override read-only/edit. |
| **Permission-based authz (RolePermissions)** | 🟡 частично | 30% | 30+ флагов в БД, но контроллеры проверяют только Roles (Admin/Cashier и т.д.). |
| **SuperAdmin Console** | ✅ готово | 95% | Organizations CRUD, audit log, archive/restore, platform settings (SMTP); биллинг-KPI заглушка. |
| **Hangfire** | 🟡 частично | 40% | `ReferencePriceRefreshJob` ✅; нет dashboard, нет scheduled cleanup, нет retry. |
| **Email (SMTP)** | ✅ готово | 100% | MailKit, DataProtection-шифрование пароля, forgot-password flow. |
| **Платёжные интеграции (Kaspi/Halyk/Jusan)** | ❌ нет | 0% | Упомянуты только в маркетинге; есть `PaymentMethod` enum, реальных шлюзов нет. |
| **ОФД (фискализация чеков РК)** | ❌ нет | 0% | Поля `FiscalSerial`/`FiscalRegNumber` есть в RetailPoint, отправки чеков нет. |
| **Маркетплейсы (Ozon, Wildberries, Kaspi Magazin)** | ❌ нет | 0% | Только маркетинговые баннеры. |
| **CI/CD (Forgejo Actions)** | ✅ готово | 90% | docker-api/web/public + smoke-тест /health после деплоя; self-hosted runner. |
| **Docker / docker-compose (stage)** | ✅ готово | 95% | postgres:16 + api + web + public + persistent volumes + local registry. |
| **E2E тесты** | 🟡 частично | 40% | Один сценарий `full-cycle` (12 шагов), отчёт в md; нет регрессии и параллелизма. |
| **Backend unit/integration тесты** | ❌ нет | 0% | Совсем. В CI стоит `\|\| echo "No tests yet"`. |
| **Logging / Serilog** | ✅ готово | 90% | Console + File с ротацией 14 дней; нет structured fields для бизнес-событий. |
| **Health checks (детальные)** | 🟡 частично | 20% | Только `/health` → {status:ok}; нет проверки БД, SMTP, диска. |
| **Метрики / observability** | ❌ нет | 0% | Нет Prometheus/AppInsights/OpenTelemetry. |
| **Rate limiting** | 🟡 частично | 15% | Только в `forgot-password` (3/час/IP, in-memory). |
| **Backup БД** | 🟡 частично | 60% | `deploy/backup.sh` есть, но не привязан к cron/timer, restore-скрипта нет. |
### 1.2. Что точно работает (готово к продакшен-использованию)
- **Регистрация → онбординг → ежедневная работа магазина** (товары, цены, приёмки, розничные продажи, остатки).
- **Управление пользователями и ролями**, soft-delete, передача владельца, восстановление пароля по email.
- **SuperAdmin-консоль платформы** (создание/архивирование организаций, SMTP, аудит).
- **Импорт каталога из МойСклад** (товары + контрагенты, асинхронный job с прогрессом).
- **Полный stage-стенд** на docker-compose с локальным registry и автодеплоем через Forgejo Actions.
### 1.3. Где точно не получится запуститься без доработки
- **Невозможно работать с физическим магазином без ККМ-фискализации** (РК требует чеки в ОФД).
- **Невозможно вести полноценный складской учёт** — нет инвентаризации, оприходования, списания, перемещения.
- **Нет аналитики/отчётов** — только сводка на дашборде, ABC-анализа, отчёта по поставщикам/прибыли нет.
- **Нет POS-приложения** — главная ценность проекта (offline-касса на Windows) — пустой проект.
- **Нет защиты от перебора паролей** в основных endpoint'ах (login/signup), только в forgot-password.
---
## 2. ТЗ на доработку по приоритетам
### Приоритет P0 — блокеры запуска в продакшен
| # | Задача | Что сделать | Зачем |
|---:|---|---|---|
| P0-1 | **Production-сертификаты OpenIddict** | Заменить `App_Data/openiddict-dev-key.xml` на реальные RSA/X.509 сертификаты, читать из KeyVault или secrets. | Сейчас токены подписываются dev-ключом без шифрования access-token. В проде это утечка claims. |
| P0-2 | **HTTPS на nginx** | Настроить TLS-termination на reverse-proxy (Let's Encrypt через certbot), форсировать HTTPS-only, добавить HSTS. | OAuth/refresh_token нельзя гонять по HTTP. |
| P0-3 | **Rate limiting на login/signup** | Добавить `Microsoft.AspNetCore.RateLimiting` (sliding window) на `/connect/token`, `/api/auth/signup`. 5 попыток/минута/IP, 20/час/IP. | Перебор паролей и DOS публичного signup. |
| P0-4 | **Health check БД** | Расширить `/health` на `/health/live` (alive) + `/health/ready` (DB ping, миграции применены). Использовать `Microsoft.Extensions.Diagnostics.HealthChecks`. | Сейчас docker-compose `healthcheck` возвращает 200 даже когда БД упала — стейдж не падает корректно. |
| P0-5 | **Permission-based authorization** | В `RolePermissions` (Domain) уже 30+ флагов. Реализовать `PermissionHandler` (IAuthorizationHandler) + атрибут `[RequiresPermission("ProductsEdit")]`, проверять в контроллерах вместо `[Authorize(Roles=...)]`. | Без этого все Admin'ы организации имеют полные права, кастомные роли (Менеджер/Кладовщик/Кассир) — фикция. |
| P0-6 | **Автоматический backup БД** | Создать systemd-timer (`food-market-backup.timer`) на ежедневный запуск `deploy/backup.sh`, добавить restore-инструкцию в `docs/`. Хранить 30 дней локально + копия в S3/MinIO. | Сейчас бэкап делается вручную, восстановления не отрепетировали. |
| P0-7 | **ОФД фискализация РК** | Интегрировать одного оператора (например, Webkassa или ОФД-Соло, КГД РК), отправлять `RetailSale.Post` чек, сохранять QR-код и фискальный номер в `RetailSale.FiscalQrCode`/`FiscalNumber`. | В РК продажа без чека ОФД — административное правонарушение. Без этого нельзя продавать. |
| P0-8 | **.env.example + документация secrets** | Описать все required env-переменные (`ConnectionStrings__DefaultConnection`, `Cors__AllowedOrigins`, `Smtp__*`, `OpenIddict__Issuer`, `OFD__Token`). Обновить `docs/stage-setup.md`. | Сейчас новый деплой не задокументирован. Передача знаний из головы — узкое место. |
| P0-9 | **Чек-листы перед релизом** | Документ `docs/release-checklist.md`: миграции применены, бэкап свежий, smoke-тесты прошли, E2E full-cycle зелёный, мониторинг здоров. | Снижает риск выкатки в проде сломанной версии. |
### Приоритет P1 — важные функциональные пробелы
| # | Задача | Что сделать | Зачем |
|---:|---|---|---|
| P1-1 | **Документ «Оприходование» (Enter)** | Domain-сущность `Enter` + `EnterLine` (как Supply, но без поставщика). Контроллер CRUD + Post/Unpost. UI-страницы `/inventory/enters`. Создаёт `StockMovement` с типом `Enter`. | Нужно вводить начальные остатки и излишки инвентаризации без поставщика. |
| P1-2 | **Документ «Списание» (Loss)** | Domain-сущность `Loss` + `LossLine` (причина: брак, истечение срока, бой, недостача). Контроллер + UI. `StockMovement` тип `WriteOff`. | Списание брака — обязательная функция магазина. |
| P1-3 | **Документ «Перемещение» (Transfer)** | Domain `Transfer` + `TransferLine` (FromStore → ToStore). Контроллер с атомарной транзакцией (списание + поступление). UI-форма. | В сети магазинов товар постоянно перемещается между складами. |
| P1-4 | **Документ «Инвентаризация» (Inventory)** | Domain `Inventory` + `InventoryLine` (book qty, actual qty, diff). Контроллер с импортом текущих остатков + Post создаёт корректирующее движение `InventoryAdjustment`. UI-форма с CSV-импортом фактического количества. | Регулярная сверка остатков — обязательно для розницы. |
| P1-5 | **Документ «Оптовая отгрузка» (Demand)** | Domain `Demand` + `DemandLine` (покупатель, способ оплаты — наличные/безнал, цена опт.). Контроллер. UI-страницы. `StockMovement` тип `WholesaleSale`. | Часть клиентов работает с юрлицами, отгрузка по накладной с НДС. |
| P1-6 | **Возврат от покупателя (CustomerReturn)** | Расширить `RetailSale` опцией «Возврат» (по чеку или без). Domain enum `MovementType.CustomerReturn` уже есть. UI: кнопка «Создать возврат» из посту-проведённой продажи. | Закон о защите прав потребителей в РК требует приёма возвратов. |
| P1-7 | **Возврат поставщику (SupplierReturn)** | По аналогии с CustomerReturn для Supply. UI: «Возврат поставщику» из проведённой приёмки. | Брак, неликвид, отказ от партии. |
| P1-8 | **Отчёт «Продажи»** | `/api/reports/sales` с группировкой по периодам (день/неделя/месяц), товарам, кассирам, кассам, способам оплаты. UI: страница `/reports/sales` с фильтром периода и экспортом в CSV/XLSX. | Без отчёта по продажам управлять бизнесом невозможно. |
| P1-9 | **Отчёт «Остатки на дату»** | `/api/reports/stock` с восстановлением остатков на любую дату через `StockMovement` журнал. UI с экспортом. | Налоговый учёт, инвентаризация. |
| P1-10 | **Отчёт «Прибыль»** | `/api/reports/profit` — выручка - себестоимость по периодам/группам/товарам. Используем `Cost` snapshot из `RetailSaleLine`. | Главный показатель магазина. |
| P1-11 | **Отчёт «ABC-анализ»** | Топ товаров по выручке/прибыли/маржинальности за период. Группа A/B/C по правилу Парето. | Управление ассортиментом. |
| P1-12 | **POS Sync API** | Endpoints: `GET /api/pos/sync?since={ts}` (товары, цены, остатки, контрагенты с изменениями после ts); `POST /api/pos/sales` (батч продаж с idempotency-key). Контракты в `food-market.shared`. | Без этого POS-приложение не может синхронизироваться с сервером. |
| P1-13 | **POS WPF MVP** | Минимальный UI: логин кассира (привязка к RetailPoint), список товаров/поиск по штрихкоду, корзина, оплата (нал/карта), печать чека (с ОФД), оффлайн-буфер на SQLite, фоновая синхронизация. | Главная фича проекта по позиционированию. |
| P1-14 | **MoySklad — Demand sync** | Импорт оптовых отгрузок (демандов) из МойСклад. Расширить `MoySkladImportService`. | Текущая интеграция только односторонняя для каталога; продажи не синхронизируются. |
| P1-15 | **MoySklad — webhook на изменения** | Получать webhook'и от МойСклад при изменении товаров, автоматически обновлять каталог (вместо ручного «Импортировать сейчас»). | Двусторонняя живая синхронизация. |
| P1-16 | **Hangfire dashboard** | Подключить `Hangfire.Dashboard` с авторизацией только для SuperAdmin. Добавить scheduled jobs: ежедневный cleanup `StockMovement` (старше 2 лет), audit-log (старше 90 дней), eтиничные jobs (e.g. рассылка email). | Сейчас jobs запускаются только вручную через AdminCleanupController; нет видимости. |
| P1-17 | **Метрики Prometheus** | Подключить `prometheus-net.AspNetCore` (`/metrics` endpoint). Базовый набор: http_requests_total, http_request_duration, db_query_duration, business: sales_count, supply_posted_count, errors_total. | Без observability нельзя гнать прод. |
| P1-18 | **Аудит мутаций tenant'а** | Расширить `SuperAdminAuditLog` на обычные org-мутации (`OrgAuditLog`): кто, когда, что изменил в Supply/Sale/Product/Counterparty. Хранить diff JSON. | Розница часто судится с сотрудниками по поводу пропавших товаров — нужны доказательства. |
| P1-19 | **OpenAPI спецификация** | Включить `Swashbuckle.AspNetCore`. Опубликовать `/swagger/v1/swagger.json` (только в Dev) и сгенерировать TypeScript-клиент для food-market.web. | Удалит ручной труд по типизации API в фронте и POS. |
| P1-20 | **Unit-тесты критичной логики** | Покрыть xUnit'ом: `StockService.ApplyMovement`, расчёт Cost при `SuppliesController.Post`, расчёт автонаценки по `ProductGroup.MarkupPercent`, валидация платежа `RetailSalesController.Post`, multi-tenant query filter. | Без этих тестов любое изменение логики Supply/Sale = потенциально баг с минусовыми остатками или потерями денег. |
| P1-21 | **Integration-тесты на тестовой БД** | `Testcontainers.PostgreSql` + `WebApplicationFactory`. Покрыть: signup-flow, supply post→unpost, retail sale post с overselling, tenant isolation (org A vs org B), permission проверки. | Регрессия на каждый коммит в CI. |
| P1-22 | **Email-нотификации** | Готовый MailKit-сервис расширить шаблонами: приглашение сотрудника (с временным паролем), еженедельный отчёт владельцу, low-stock alert. Хранить шаблоны в `Resources/EmailTemplates/*.html`. | Сейчас email отправляется только при forgot-password. |
### Приоритет P2 — желательные улучшения
| # | Задача | Что сделать | Зачем |
|---:|---|---|---|
| P2-1 | **Платёжный шлюз Kaspi Pay** | Интеграция Kaspi Pay QR (касса показывает QR, покупатель оплачивает с приложения, callback фиксирует оплату в RetailSale). | Самый популярный способ безнала в РК. |
| P2-2 | **Платёжные шлюзы банков** | Halyk Epay, Jusan Pay, Forte Pay (POS-терминал API или e-commerce). | Альтернативы Kaspi. |
| P2-3 | **Интеграция с маркетплейсами** | Ozon Seller API, Wildberries, Kaspi Magazin — синхронизация остатков и цен (исходящая), импорт заказов (входящая). | Расширение каналов продаж. |
| P2-4 | **2FA для админов** | TOTP (Google Authenticator) для роли Admin и SuperAdmin. Использовать `Identity.AddDefaultTokenProviders` + `AuthenticatorTokenProvider`. | Защита платёжного функционала. |
| P2-5 | **SSO (Google/Microsoft)** | Расширить OpenIddict внешними провайдерами для логина персонала. | UX для офисных сотрудников. |
| P2-6 | **Многоязычность (en/kz)** | Подключить `react-i18next` в web, выделить русские строки в `locales/ru.json`. Перевести интерфейс на казахский (государственное требование). | Государство РК требует госязык в публичных интерфейсах. |
| P2-7 | **WebSocket / SignalR для real-time** | Push-уведомления на дашборд (новая продажа), кассе (изменение цены), импортах (вместо polling). | UX + снижение нагрузки от polling. |
| P2-8 | **Аналитика на public-сайте** | Google Analytics или Yandex.Metrika, A/B тесты pricing'а, события signup-конверсии. | Маркетинг. |
| P2-9 | **Mobile-приложение (PWA или React Native)** | Просмотр остатков, продаж, KPI для владельца. | UX для владельцев. |
| P2-10 | **Распознавание чеков (OCR)** | Загрузка фото чека от поставщика → распознавание → автозаполнение Supply. | Уменьшение ручного ввода. |
| P2-11 | **Электронные счёт-фактуры (ЭСФ)** | Интеграция с ИС ЭСФ КГД РК (выпуск счетов-фактур для юрлиц). | Часть оптовых клиентов требует ЭСФ. |
| P2-12 | **Бонусные программы / скидочные карты** | Domain: `LoyaltyProgram`, `LoyaltyCard`. Списание/начисление в RetailSale. | Удержание клиентов. |
| P2-13 | **Промокоды / акции** | Domain: `Promotion`, правила (категория, период, % скидки). UI-настройка из админки. | Маркетинг для магазина. |
| P2-14 | **Telegram-бот для владельца** | Ежедневная сводка выручки, low-stock alerts. | UX для владельцев. |
| P2-15 | **Multi-storage для изображений** | Сейчас файлы лежат в `/app/uploads` (volume). Перевести на S3-совместимое хранилище (MinIO/Yandex.Cloud). | Масштабируемость, отказоустойчивость. |
---
## 3. Дорожная карта (рекомендованная последовательность)
### Спринт 1 — Стабилизация (2-3 недели)
Цель: безопасно выкатить текущий функционал в прод.
- P0-1 → P0-9 (все блокеры запуска)
- P1-20, P1-21 (юнит/интеграционные тесты на текущую логику)
- P1-18 (аудит мутаций tenant'а)
**Критерий готовности:** прод-стенд работает с HTTPS, rate-limit'ы установлены, бэкап автоматический, фискализация ОФД работает, права RolePermissions проверяются.
### Спринт 2 — Складской учёт (3-4 недели)
Цель: полноценное складское ядро ERP.
- P1-1 (Enter), P1-2 (Loss), P1-3 (Transfer), P1-4 (Inventory)
- P1-6 (CustomerReturn), P1-7 (SupplierReturn)
- P1-16 (Hangfire dashboard + scheduled cleanup)
**Критерий готовности:** магазин может вести полный складской учёт без обходных путей.
### Спринт 3 — Отчёты и аналитика (2 недели)
- P1-8 (Sales report), P1-9 (Stock on date), P1-10 (Profit), P1-11 (ABC)
- P1-19 (OpenAPI / Swagger)
**Критерий готовности:** владелец видит, как идёт бизнес, без выгрузки в Excel.
### Спринт 4 — POS (4-6 недель)
- P1-12 (POS Sync API), `food-market.shared` контракты
- P1-13 (POS WPF MVP)
- P1-17 (метрики Prometheus + Grafana dashboard)
**Критерий готовности:** касса работает оффлайн, синхронизируется с сервером, печатает фискальные чеки.
### Спринт 5 — Оптовые продажи + MoySklad full sync (2-3 недели)
- P1-5 (Demand)
- P1-14 (MoySklad Demand sync), P1-15 (webhook'и)
- P1-22 (email-шаблоны)
**Критерий готовности:** клиент, работающий с юрлицами через МойСклад, может полностью перейти на Food Market.
### Спринт 6+ — Интеграции и фичи (P2)
P2-1 Kaspi Pay → P2-3 маркетплейсы → P2-6 локализация → P2-11 ЭСФ → P2-12/13 лояльность/акции.
---
## 4. Технический долг (для рефакторинга)
Не блокирует функциональность, но затрудняет развитие.
| # | Что | Почему важно |
|---:|---|---|
| TD-1 | **CQRS через MediatR** — перенести бизнес-логику из контроллеров в Command/Query handlers. | Сейчас невозможно переиспользовать логику между API/POS/Hangfire. Контроллеры по 500 строк. |
| TD-2 | **FluentValidation** — заменить inline-валидацию в контроллерах на отдельные `Validator<T>`. | Сейчас валидация перемешана с бизнес-логикой, тестировать сложно. |
| TD-3 | **Mapster** — выделить mapping в отдельные `MapperConfig`. | Сейчас projection'ы инлайнятся в LINQ-запросы, переиспользования нет. |
| TD-4 | **Структурные log-fields в Serilog** — добавить `org_id`, `user_id`, `correlation_id` в log scope. | Сейчас в логах сложно найти конкретного пользователя/организацию. |
| TD-5 | **ImportJobRegistry в БД** — сейчас in-memory `ConcurrentDictionary`. При рестарте API теряется. Перевести на таблицу `ImportJobs`. | Жизненный цикл job'а >5 минут — рестарт обычное дело. |
| TD-6 | **Concurrency-токены на документах**`RowVersion` (xmin/timestamp) на Supply/RetailSale, чтобы исключить race condition при параллельной правке. | Сейчас два кассира могут испортить один чек. |
---
## 5. Сводка по оценке готовности
```
┌──────────────────────────────────┬──────────────┬─────────────┐
│ Категория │ Готовность │ Состояние │
├──────────────────────────────────┼──────────────┼─────────────┤
│ Авторизация и multi-tenancy │ 95% │ ✅ готово │
│ Каталог товаров │ 95% │ ✅ готово │
│ Документы (Supply, RetailSale) │ 100% │ ✅ готово │
│ Документы (Inventory/Loss/...) │ 0% │ ❌ нет │
│ Отчёты │ 5% │ ❌ нет │
│ POS │ 5% │ ❌ нет │
│ MoySklad │ 50% │ 🟡 частично │
│ Платежи и фискализация │ 0% │ ❌ нет │
│ Инфраструктура (CI/CD, Docker) │ 90% │ ✅ готово │
│ Безопасность (HTTPS, rate-limit) │ 30% │ 🟡 частично │
│ Observability (метрики, аудит) │ 20% │ 🟡 частично │
│ Тестирование │ 40% │ 🟡 частично │
└──────────────────────────────────┴──────────────┴─────────────┘
Общая готовность к продакшен-запуску: 60-65%
- Для MVP "магазин на одном POS-терминале": требуется ОФД + базовые складские документы.
- Для полноценного ERP: требуется выполнение P0+P1.
- Для конкуренции с МойСклад: требуется ещё и P2.
```

View file

@ -1,630 +0,0 @@
# ТЗ на тестирование Food Market
> Дата составления: 2026-05-22
> Автор: Claude Opus 4.7
> Документ парный к [TZ-доработка.md](./TZ-доработка.md). Описывает что и как проверять до и после релизов.
---
## 0. Принципы тестирования
### 0.1. Пирамида тестов
```
▲ E2E (Playwright, full-cycle) ~ 5%
▲▲▲ API integration (axios + Testcontainers) ~ 25%
▲▲▲▲▲ Unit-тесты бизнес-логики (xUnit) ~ 70%
```
Сейчас реальное соотношение **5% / 0% / 0%** — это нужно перевернуть.
### 0.2. Уровни тестирования
| Уровень | Когда запускается | Что проверяет |
|---|---|---|
| **Smoke** | Каждый push, ≤2 мин | Сервис стартует, /health отвечает 200, миграции применены, главные страницы открываются. |
| **Регрессия** | Каждый PR, ≤10 мин | Основные сценарии не сломаны: signup→login, создание продукта/приёмки/продажи, multi-tenant isolation. |
| **E2E full-cycle** | Каждый merge в main, ≤30 мин | Полный путь от создания организации до отчёта о продажах. |
| **Нагрузочные** | Перед мажорными релизами | 1000 одновременных пользователей, 10000 товаров, 50000 движений. |
| **Безопасность** | Перед релизом + регулярно | OWASP Top-10, рейт-лимит, multi-tenant утечки. |
### 0.3. Когда считать «работает корректно»
- **Бэкенд:** код возвращает ожидаемый HTTP-код, тело ответа валидно по схеме, побочные эффекты в БД соответствуют ожиданиям (StockMovement, Stock.Quantity).
- **Фронтенд:** интерфейс отображает корректные данные, формы валидируются (HTML5 + onBlur + submit), ошибки сервера показываются человекочитаемо.
- **Безопасность:** запрещённые операции возвращают 401/403/404 без утечки информации (не «такого пользователя нет», а «неверный логин/пароль»).
- **Мультитенант:** пользователь org A никогда не видит и не изменяет данные org B (ни через UI, ни через прямой API).
### 0.4. Что считать багом
- Любой 500 без log-entry с причиной.
- Отрицательный остаток после провёденной продажи (overselling без контроля).
- Несоответствие `Stock.Quantity` сумме `StockMovement.Quantity` по тому же (Product, Store).
- Возможность увидеть/изменить данные другой организации.
- Возможность залогиниться как заархивированный/удалённый сотрудник.
- Email-flow signup/reset не работает (письма не доходят).
---
## 1. Приоритезация по критичности модулей
| Приоритет | Модули | Что попадает |
|---|---|---|
| **P0 (smoke + регрессия в каждом PR)** | Auth, Supply, RetailSale, Stock, Multi-tenancy | Без этого не работает ничего. |
| **P1 (регрессия перед релизом)** | Catalog (Products, Groups, Counterparties), SuperAdmin Console, MoySklad import, Permissions | Без этого магазин не функционален. |
| **P2 (один раз перед мажорным релизом + после изменений)** | Reports, Email, Health checks, UI-валидация, локализация чисел/дат | Не блокирует, но влияет на UX. |
| **P3 (точечно по требованию)** | Public site (Astro), CI/CD-сценарии | Меняется реже. |
---
## 2. Сценарии тестирования по модулям
### 2.1. Аутентификация (P0)
#### 2.1.1. Регистрация новой организации (`POST /api/auth/signup`)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Happy: новая орга** | POST с уникальным email, паролем 8+, org name, валидным KZ-телефоном (+7 7XX...). | 201, в БД: новый Organization, User с ролью Admin, Employee, bootstrap-данные (Stores=1, Roles=4, Units, PriceTypes). |
| **Email уже занят** | Повторный POST с тем же email. | 400 «email уже занят» (без раскрытия деталей о существующей орге). |
| **Слабый пароль (<8 симв.)** | POST с password="abc". | 400, поле password в ошибках. |
| **Невалидный телефон** | POST с phone="+79161234567" (РФ). | 400 «введите корректный номер Казахстана». |
| **Без согласия с офертой** | UI: не отметить чекбокс. | Submit заблокирован, поле agree красное. |
| **Email с лишними пробелами** | " user@example.kz ". | Нормализация: trim, lowercase. 201. |
| **Bootstrap полнота** | После регистрации: GET `/api/catalog/stores` → 1 основной склад; GET `/api/organization/employee-roles` → 4 системных роли (Admin/Manager/Storekeeper/Cashier). | Соответствие. |
#### 2.1.2. Логин (`POST /connect/token`)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Happy: правильный пароль** | grant_type=password, valid creds. | 200, access_token (jwt), refresh_token, claims.org_id, claims.role. |
| **Неверный пароль** | Wrong password. | 400 OAuth error="invalid_grant", без раскрытия «такой email есть» vs «нет». |
| **Заблокированный пользователь** | User.IsActive=false. | 400, токен не выдан. |
| **Архивированная организация** | Organization.IsArchived=true. | 400, токен не выдан, claim org_id отсутствует или вход запрещён. |
| **SuperAdmin без org** | User с ролью SuperAdmin, OrganizationId=null. | 200, токен выдан, claim role="SuperAdmin". |
| **Refresh token flow** | grant_type=refresh_token с действующим refresh. | 200, новый access + refresh (sliding). |
| **Истёкший access токен** | Запрос с истёкшим токеном. | 401, фронт делает refresh автоматически. |
| **Истёкший refresh** | Подождать >30 дней. | 400 на refresh, форс-логаут на фронте. |
| **Rate limit (после P0-3)** | 6+ login-попыток за минуту с одного IP. | 429 Too Many Requests. |
#### 2.1.3. Forgot/Reset password
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Happy: запрос восстановления** | POST `/api/auth/forgot-password` с email существующего. | 200 (всегда), на email приходит ссылка с токеном (1 час). |
| **Несуществующий email** | POST с unknown@example. | 200 (anti-enumeration), без email. |
| **Сброс по токену** | POST `/api/auth/reset-password` с email+token+newPassword. | 200, все refresh_token'ы revoke'нуты. |
| **Просроченный токен (>1 ч)** | POST с истёкшим токеном. | 400 «ссылка устарела». |
| **Rate limit forgot** | 4+ запроса за час с одного IP. | 429 «слишком много попыток». |
---
### 2.2. Multi-tenancy и изоляция данных (P0, КРИТИЧНО)
#### 2.2.1. Изоляция через UI
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Видимость списков** | Залогиниться в org A, создать товар «Хлеб». Залогиниться в org B. | `/catalog/products` org B не содержит «Хлеб». |
| **Переключение организаций** | Один email в двух организациях (если возможно — сейчас нет). | (Не поддерживается — каждый User принадлежит одной org). |
#### 2.2.2. Изоляция через прямой API
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **GET по GUID чужой орги** | Залогиниться в org A, узнать `productId` org B (например, через БД). GET `/api/catalog/products/{productIdOrgB}`. | 404 (не 200). Query filter скрывает. |
| **PUT по GUID чужой орги** | PUT `/api/catalog/products/{productIdOrgB}` с теми же данными. | 404 (не 200, не 200 с записью в чужую). |
| **DELETE по GUID чужой орги** | DELETE того же. | 404. |
| **POST с FK на чужую сущность** | POST Supply в org A с supplierId, принадлежащим org B. | 400 «contact not found» (FK проверка через query filter). |
| **Подделка org_id в JWT** | Сгенерировать токен с org_id чужой орги (без подписи). | 401 (подпись не валидна). |
#### 2.2.3. SuperAdmin override
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **SuperAdmin без override** | GET `/api/super-admin/organizations`. | Видит все организации. |
| **SuperAdmin с override (read)** | GET `/api/catalog/products` с заголовком `X-Org-Override: <orgId>`. | Видит товары org X. |
| **SuperAdmin с override (write)** | PUT `/api/catalog/products/{id}` с `X-Org-Override: <orgId>` без `X-Org-Override-Reason`. | 403 «Read-only mode...». |
| **SuperAdmin с override + reason** | PUT с обоими заголовками (`X-Org-Override-Reason: "Customer support ticket #123, исправляем дубль штрихкода"`). | 200, запись в SuperAdminAuditLog. |
| **Reason слишком короткий** | reason="ok". | 403. |
| **Обычный Admin с X-Org-Override** | Admin org A пытается подделать заголовок и выйти в org B. | 403 «only SuperAdmin can override». |
#### 2.2.4. Глобальные справочники
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Country / Currency видны всем** | Login org A: GET /api/catalog/countries → видит. Login org B: GET → тот же список. | Идентичные списки. |
| **System ProductGroup видна всем** | SuperAdmin создаёт ProductGroup OrganizationId=null. Login любая org: видит. | Видна, но не редактируется обычным Admin. |
| **System UnitOfMeasure (ОКЕИ)** | SuperAdmin: GET /api/super-admin/units-of-measure → видит все global. Admin org A: GET /api/catalog/units-of-measure → видит только enabled через junction. | Корректная фильтрация. |
---
### 2.3. Каталог (P1)
#### 2.3.1. Товары (Products)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Создание минимального** | POST `/api/catalog/products` с name + unit. | 201, автогенерация артикула (числовой), штрихкода нет. |
| **Создание с штрихкодом EAN13** | POST с barcode value="4607034630092", type=Ean13. | 201, валидация контрольной цифры. |
| **EAN13 с невалидной контрольной цифрой** | barcode="4607034630099". | 400 «невалидный EAN13». |
| **Дубль штрихкода в одной орге** | POST два товара с одинаковым штрихкодом. | 409 на втором. |
| **Дубль штрихкода в разных оргах** | Org A: barcode X. Org B: тот же barcode. | 201 для обеих (per-tenant unique). |
| **Цена по типам** | POST с prices: розничная=1000 KZT, оптовая=800 KZT. | Записи в ProductPrice. |
| **Обязательная розничная цена** | POST без розничной. | 400 «требуется розничная цена» (если PriceType.IsRequired=true). |
| **Артикул вручную** | POST с article="ABC-001". | 201, без автогенерации. |
| **Дубль артикула** | Два товара с article="ABC-001". | 409. |
| **Поиск по barcode** | GET `/api/catalog/products/by-barcode/4607034630092`. | Возвращает товар. |
| **Quick search** | GET `/api/catalog/products/quick-search?q=хле`. | Ранжирование: exact barcode → article → name prefix → name contains. |
| **Фильтр по группе** | GET `?groupId=X`. | Только товары группы X. |
| **Фильтр по упаковке** | `?packaging=Weight`. | Только весовые. |
| **Удаление товара с приёмками** | DELETE товара, на который есть SupplyLine. | 409 «нельзя удалить, есть документы». |
| **Удаление без документов** | DELETE свежесозданного. | 204. |
| **Изображения товара** | POST `/api/catalog/products/{id}/images` с jpg <10MB. | 201, файл в /uploads, ImageUrl обновлён. |
| **Загрузка > 10MB** | POST с 15MB файлом. | 400. |
| **Установка main image** | POST `/images/{imageId}/main`. | Product.ImageUrl ← путь, IsMain переброшен. |
#### 2.3.2. Группы товаров (ProductGroups)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Создание корневой** | POST с name="Хлебобулочные", parentId=null. | 201, Path="Хлебобулочные". |
| **Создание дочерней** | POST с parentId="<root>", name="Хлеб". | 201, Path="Хлебобулочные/Хлеб". |
| **Циклическая ссылка** | PUT группы с parentId=своим id или потомка. | 400 «цикл». |
| **Удаление с подгруппами** | DELETE родителя у которого есть дети. | 409. |
| **Удаление с товарами** | DELETE группы с привязанными товарами. | 409. |
| **Системная группа SuperAdmin** | SuperAdmin POST с OrganizationId=null. Login org → видна, но Edit/Delete недоступны. | Корректное поведение. |
| **Markup percent** | Группа с MarkupPercent=20%. Cost=1000 → RecalcRetail = 1200. | Round up до целых (по AllowFractionalPrices). |
#### 2.3.3. PriceTypes
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Только один IsRetail** | Создать два PriceType с IsRetail=true. | 409 на втором. |
| **IsSystem нельзя удалить** | DELETE PriceType с IsSystem=true. | 409. |
| **Переименование системного** | PUT name. | 200 (можно). |
| **Toggle IsRequired системного** | PUT IsRequired=false для системного. | 409 (зафиксирован). |
#### 2.3.4. UnitsOfMeasure
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Enable global unit** | POST `/api/catalog/units-of-measure/{id}/enable`. | 204, junction record создан. |
| **Disable enabled unit с ссылками** | DELETE enable у unit'а, который используется товаром. | 409 «нельзя — товары используют». |
| **SuperAdmin: создать global unit** | POST `/api/super-admin/units-of-measure` с code="МЛ". | 201. |
| **SuperAdmin: удалить global unit с ссылками** | DELETE того же если ссылается товар. | 409 со списком орг. |
#### 2.3.5. Counterparties
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Юрлицо с БИН** | POST с type=LegalEntity, bin="123456789012". | 201. |
| **БИН не 12 цифр** | bin="1234". | 400. |
| **Физлицо с ИИН** | type=Individual, iin="850101300123". | 201. |
| **Невалидный KZ телефон** | phone="+79161234567". | 400. |
---
### 2.4. Документы: Приёмки (P0)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Создание Draft** | POST Supply с supplier, store, currency, 2 lines. | 201, status=Draft, Total=sum(qty*price), Stock не изменился. |
| **Posting** | POST `/posts/{id}/post`. | Stock.Quantity += sum(qty). StockMovement +N записей с type=Supply. Cost пересчитан (weighted avg). |
| **Cost weighted average** | До: Stock.Cost=100, qty=10. Приёмка: qty=10, price=120. После: Cost = (10*100+10*120)/20 = 110. | Соответствие. |
| **ReferencePrice при первой приёмке** | Product без приёмок. После Supply.Post: Product.ReferencePrice = UnitPrice. | Соответствие. |
| **Автонаценка розничной** | ProductGroup.MarkupPercent=30, товар без override розницы, Cost=100. После Post: ProductPrice (IsRetail) = 130. | Соответствие. |
| **Unpost** | POST `/{id}/unpost` на провёденной. | StockMovement удалены/инвертированы, Stock.Quantity вернулся, status=Draft. |
| **Edit проведённой** | PUT провёденной (status=Posted). | 409 «нельзя редактировать проведённую». |
| **Delete Draft** | DELETE Draft без посту. | 204. |
| **Delete проведённой** | DELETE Posted. | 409. |
| **Posting → отрицательный остаток после unpost** | Post Supply (+10), затем Sale (-15), затем Unpost Supply. | 409 «нельзя расковать, остаток уйдёт в минус» (по дизайну). |
| **FK на удалённого поставщика** | Supplier удалён (если бы это было возможно), Supply ссылается. | Корректная обработка (запрет удаления поставщика или nullable). |
| **Пустые lines** | POST с lines=[]. | 400. |
| **Quantity ≤ 0** | line.qty=0 или -5. | 400. |
| **Параллельный Post одного Supply** | Два запроса /post одновременно. | Один 200, второй 409 (concurrency-конфликт после P1 RowVersion). |
---
### 2.5. Документы: Розничные продажи (P0)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Создание Draft** | POST RetailSale с retailPoint, lines, payments. | 201, status=Draft. |
| **Posting с достаточным остатком** | Stock=10. POST с qty=5, /post. | Stock=5, StockMovement type=RetailSale qty=-5. |
| **Posting с overselling** | Stock=3. POST с qty=5, /post. | 409 «недостаточно товара» (по фиксу из git history). |
| **PaidCash + PaidCard ≠ Total** | Total=1000, paidCash=300, paidCard=500. | 400 «суммы не сходятся». |
| **Расчёт суммы со скидкой** | Line qty=2, price=500, discount=100. Subtotal=1000, DiscountTotal=100, Total=900. | Соответствие. |
| **VAT snapshot в строке** | На момент продажи Product.VatPercent=12, после Sale.Post страну поменяли → VAT=0. Старая RetailSaleLine.VatPercent = 12. | Снимок цены/НДС сохраняется. |
| **Cashier из другого RetailPoint** | Cashier привязан к RP1. POST RetailSale с retailPointId=RP2. | 403 (после реализации Permission). |
| **Unpost продажи** | POST /unpost. | Stock возвращается, StockMovement инвертирован. |
| **Stats эндпоинт** | GET /stats?days=30. | Daily series 30 дней, revenueToday, transactionsToday, avgTicketThisMonth корректны. |
| **Возврат по чеку (после P1-6)** | POST CustomerReturn ссылается на RetailSale. | Stock возвращается, новый StockMovement type=CustomerReturn. |
---
### 2.6. Остатки и движения (P0)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Целостность Stock vs Movement** | После N операций: для любых (ProductId, StoreId) `Stock.Quantity == SUM(StockMovement.Quantity)`. | Соответствие (инвариант). |
| **Reserved** | (Если реализуется в P1) Сейчас Reserved=0 везде. | После резервирования через Demand: Reserved += qty. |
| **Доступно (Available)** | Available = Quantity - Reserved. | Корректно. |
| **Фильтр includeZero=false** | Stock.Quantity=0. GET /stock?includeZero=false. | Не включается. |
| **Movements: пагинация** | 1000 движений. GET ?page=1&pageSize=50. | total=1000, items=50. |
| **Сортировка по occurredAt desc** | GET ?sort=-occurredAt. | Свежие сверху. |
| **Фильтр по storeId** | Два склада, по 10 movements в каждом. GET ?storeId=X. | Только 10 из X. |
---
### 2.7. Сотрудники и роли (P1)
#### 2.7.1. CRUD сотрудников
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Создание без учётки** | POST с createAccount=false. | 201, Employee.UserId=null. |
| **Создание с учёткой** | POST с createAccount=true, email. | 201, User создан с temp password, password возвращён в response (один раз). |
| **Email обязателен при createAccount=true** | POST с email="" и createAccount=true. | 400. |
| **Дубль email** | Два сотрудника, одинаковый email при createAccount. | 409. |
| **Soft-delete (увольнение)** | DELETE employeeId. | IsActive=false, FiredAt=now, IsDeleted=false. User блокируется. |
| **Полное удаление (после увольнения)** | DELETE дважды (после Fired). | IsDeleted=true, DeletedAt=now. |
| **Архивный сотрудник в документах** | После soft-delete: старые Supply показывают «Иванов И. (удалён)». | Подпись сохраняется. |
| **Защита OwnerUser** | DELETE главного администратора (Organization.AccountOwnerUserId == Employee.UserId). | 409 «только SuperAdmin». |
| **Защита самого себя** | Залогинен Иванов, пытается DELETE сам себя. | 409 «нельзя удалить себя». |
#### 2.7.2. Роли и Permission-based authz (после P0-5)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Системные роли созданы** | После signup: 4-6 системных ролей (Admin, Manager, Storekeeper, Cashier, ...). | IsSystem=true, не удаляются. |
| **Кастомная роль** | POST роль "Менеджер по продажам" с permissions {productsView:true, suppliesView:true, all_else:false}. | 201. |
| **Permission DENY** | Employee с ролью "Менеджер по продажам" → POST Product. | 403 «нет права ProductsEdit». |
| **Permission ALLOW** | Тот же → GET /api/catalog/products. | 200. |
| **Изменение прав роли** | PUT permissions роли. | Применяется ко всем сотрудникам этой роли (без revoke токенов). |
| **Удаление роли с сотрудниками** | DELETE используемой роли. | 409. |
| **Cashier → RetailPoint** | Cashier с EmployeeRetailPointAssignment (RP1). POST RetailSale (RP1). | 200. С RP2: 403. |
---
### 2.8. SuperAdmin Console (P1)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Создание организации** | POST /api/super-admin/organizations с org+admin данными. | 201, temp password возвращён, organization в БД. |
| **Аудит создания** | SELECT FROM super_admin_audit_log WHERE action_type='OrganizationCreated'. | Запись с reason, before/after JSON. |
| **Архивирование** | POST /{id}/archive с confirmationName=правильное имя org. | IsArchived=true, ArchivedAt=now. Логины этой org перестают работать. |
| **Архивирование с неверным confirmationName** | confirmationName="wrong". | 400. |
| **Восстановление** | POST /{id}/restore. | IsArchived=false. |
| **Hard delete после retention period** | Через SystemSettings.ArchiveRetentionDays=0, DELETE архивной. | 204, организация физически удалена (CASCADE). |
| **Hard delete до retention** | DELETE архивной до истечения. | 409 «до удаления ещё N дней». |
| **Смена владельца** | POST /change-owner с newOwnerUserId, reason. | Organization.AccountOwnerUserId обновлён, запись в audit log. |
| **Reason требуется** | POST /change-owner без reason. | 400. |
| **Reason < 10 символов** | POST с reason="ok". | 400. |
| **Audit log фильтр** | GET /audit-log?orgId=X&actionType=Y. | Корректная фильтрация. |
| **Audit log CSV экспорт** | Через UI кнопка «Экспорт». | CSV-файл скачивается. |
---
### 2.9. SuperAdmin Platform Settings — SMTP (P1)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Сохранение SMTP** | PUT с host, port, ssl, username, newSmtpPassword="pass123". | 200, hasSmtpPassword=true. Пароль в БД зашифрован. |
| **Получение настроек** | GET. | Все поля кроме password. password не возвращается. |
| **Очистка пароля** | PUT с newSmtpPassword="__clear__". | hasSmtpPassword=false. |
| **Test send** | POST /test-send. | Письмо приходит на адрес супер-админа. |
| **Test send без настроек** | POST /test-send когда SMTP не настроен. | 400 «SMTP не настроен». |
| **Forgot password после настройки** | Юзер делает forgot-password → письмо со ссылкой приходит. | Соответствие. |
---
### 2.10. MoySklad интеграция (P1)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Сохранение токена** | PUT /api/admin/moysklad/settings с token. | Token сохранён в Organization, masked при GET. |
| **Test connection** | POST /test с валидным токеном. | 200, возвращает {organization, inn}. |
| **Test с невалидным токеном** | POST /test с "abc". | 400 или 401 с понятным сообщением. |
| **Import counterparties** | POST /import/counterparties. | 202, jobId возвращён. |
| **Job progress polling** | GET /api/admin/jobs/{id} раз в 1.5 сек. | Status: InProgress → Succeeded. Stage обновляется. |
| **Импортированные контрагенты** | GET /api/catalog/counterparties после import. | N новых контрагентов с правильными BIN, телефонами. |
| **OverwriteExisting=true** | Повторный import с overwrite. | Обновляются по name (case-insensitive), не дубли. |
| **Импорт товаров** | POST /import/products. | Товары + группы + штрихкоды + остатки импортированы. |
| **Архивные товары** | МойСклад имеет archived товары. | Импортируются в Product.IsArchived=true. |
| **Прерывание job (после P1)** | Рестарт API во время импорта. | Job маркируется как Failed, можно перезапустить. |
---
### 2.11. Складские документы (P1, после реализации)
#### 2.11.1. Оприходование (Enter)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Создание/Post** | POST Enter с lines, /post. | Stock += qty, StockMovement type=Enter. |
| **Без поставщика** | POST без supplierId. | 201 (Enter не требует supplier). |
#### 2.11.2. Списание (Loss)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Списание брака** | POST Loss с reason="брак", lines с qty. | Stock -= qty, StockMovement type=WriteOff. |
| **Списание сверх остатка** | Stock=3, Loss qty=5. | 409 «недостаточно». |
#### 2.11.3. Перемещение (Transfer)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Между складами** | POST Transfer (FromStore=A, ToStore=B), lines. /post. | Stock[A] -= qty, Stock[B] += qty. Два StockMovement (TransferOut + TransferIn). |
| **Атомарность** | Если ToStore Stock записать не удалось (например, БД упала между). | Транзакция откатывается, Stock[A] не изменён. |
#### 2.11.4. Инвентаризация (Inventory)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Создание с импортом текущих** | POST Inventory storeId=X. | InventoryLine на каждый товар склада X с bookQty=Stock.Quantity, actualQty=null. |
| **Заполнение фактических** | PUT с actualQty по каждой строке. | Diff = actual - book. |
| **Post** | POST /post. | StockMovement type=InventoryAdjustment с qty=diff. Stock актуализирован. |
| **CSV-импорт фактических** | UI: загрузка CSV (sku, qty). | Заполнение строк. |
---
### 2.12. Отчёты (P1, после реализации)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Отчёт по продажам** | GET /api/reports/sales?from=...&to=...&groupBy=day. | Series выручки по дням. |
| **Отчёт по продажам с фильтром по кассиру** | ?cashierId=X. | Только продажи этого кассира. |
| **Отчёт «остатки на дату»** | GET /stock?date=2026-04-01. | Stock восстановлен через `SUM(Movement WHERE occurredAt <= date)`. |
| **Отчёт «прибыль»** | GET /profit?from=...&to=... | Выручка - себестоимость (использует Cost snapshot из RetailSaleLine). |
| **ABC-анализ** | GET /abc?metric=revenue&period=last_quarter. | Топ-20% товаров (группа A), следующие 30% (B), остаток (C). |
| **Экспорт CSV/XLSX** | UI: кнопка «Экспорт». | Скачивается файл с теми же данными. |
| **Большой объём** | 100k продаж в периоде. | <5 секунд ответ (с агрегацией на стороне БД). |
---
### 2.13. POS Sync API (P1, после реализации)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Initial sync** | POS первый раз: GET /api/pos/sync?since=0. | Все товары, цены, остатки, контрагенты. |
| **Incremental sync** | GET /api/pos/sync?since=<lastSyncTimestamp>. | Только изменения после timestamp. |
| **Batch upload sales** | POST /api/pos/sales [{idempotencyKey, ...}, ...]. | Все продажи провёдены, idempotency повторных вызовов. |
| **Idempotency** | POST с тем же idempotencyKey дважды. | Второй вызов возвращает оригинальный результат, без дубля. |
| **Offline → online** | POS работает offline 1 час, накопил 20 продаж. После online: upload. | Все 20 синхронизированы корректно. |
| **Conflict: товар удалён** | POS отправил продажу товара, который SuperAdmin удалил. | 409, POS откатывает локально или маркирует как ошибку. |
---
### 2.14. Web-админка UI/UX (P2)
| Сценарий | Что проверить |
|---|---|
| **Темная тема** | Все 35 страниц — переключение dark mode через кнопку. Без артефактов, контраст AAA. |
| **Адаптив** | Каждая страница на мобильном (375px), планшете (768px), десктопе (1280px). |
| **onBlur валидация форм** | После только что внедрённого ФЛК (см. git ff44afc): SignupForm, LoginPage, ResetPasswordPage, ForgotPasswordPage, EmployeesPage, CounterpartiesPage, StoresPage, PriceTypesPage, EmployeeRolesPage, RetailPointsPage, OrganizationSettingsPage, SuperAdminOrgCreatePage. Каждое поле показывает ошибку при потере фокуса. |
| **onChange сбрасывает ошибку** | После показа ошибки, начать вводить — ошибка должна убираться. |
| **Обработка 401 в axios** | Истёкший токен → автоматический refresh → повторный запрос. |
| **Обработка 403** | Понятная страница «нет прав». |
| **Обработка 500** | Не белый экран, а toast/alert. |
| **Loading states** | Спиннеры/skeleton'ы на всех таблицах при загрузке. |
| **Empty state** | Пустые списки показывают «Нет данных», а не пустую таблицу. |
| **Pagination** | Корректные счётчики, переходы. На последней странице нет «next». |
| **Sticky header** | Заголовок таблицы остаётся при скролле длинных списков. |
| **Локализация** | Все строки на русском. Числа в `toLocaleString('ru-KZ')` (1 234,56). Даты в `dd.MM.yyyy`. |
---
### 2.15. Public site (Astro) (P3)
| Сценарий | Что проверить |
|---|---|
| **Маршруты** | /, /pricing, /features, /pos, /import, /integrations, /about, /contacts, /signup, /blog/[slug], /kb/[slug], /legal/[slug] — все открываются. |
| **SignupForm на /signup** | Заполнить корректно → редирект на admin.food-market.kz с токенами. |
| **Tariff builder на /pricing** | Изменение количества касс/складов → перерасчёт стоимости. |
| **SEO** | Каждая страница имеет title, description, canonical, og-image. |
| **sitemap.xml** | GET /sitemap.xml → валидный XML со всеми статичными страницами. |
| **robots.txt** | GET /robots.txt → ожидаемое содержимое. |
| **Lighthouse** | Performance 90+, Accessibility 95+, Best Practices 95+, SEO 100. |
---
### 2.16. Infrastructure / DevOps (P2)
| Сценарий | Что проверить |
|---|---|
| **/health** | 200 OK, JSON {status, time}. |
| **/health/ready (после P0-4)** | 200 если БД и миграции OK. 503 если БД упала. |
| **CI: build на push** | Зелёный pipeline на каждый push в main. |
| **CI: deploy on push api** | После push в `src/food-market.api/*` → docker-api workflow → деплой на stage → smoke /health → 200. |
| **Backup script** | Запуск `deploy/backup.sh local`. Файл *.sql.gz создан, размер > 0. |
| **Backup restore** | Восстановить из бэкапа в чистую БД, поднять API, проверить логин. |
| **Postgres healthcheck в compose** | docker-compose ps → postgres healthy. |
| **Persistent volumes** | После `docker compose down && up` данные сохраняются. |
| **Persistent OpenIddict ключ** | После рестарта API: refresh_token продолжает работать. |
| **Логи Serilog** | tail -f /app/logs/food-market-*.log — структурированные строки. |
| **Ротация логов** | После 14 дней старые логи удаляются. |
| **Локальный docker registry** | curl 127.0.0.1:5001/v2/_catalog → список образов. |
---
### 2.17. Безопасность (P0+P1)
| Сценарий | Что проверить |
|---|---|
| **HTTPS-only (после P0-2)** | HTTP → 301 redirect на HTTPS. HSTS-header установлен. |
| **JWT signature tampering** | Изменить body токена, не подписать. | 401. |
| **JWT expired** | Использовать токен старше 1 часа. | 401, фронт делает refresh. |
| **SQL injection** | Поиск со значением `'; DROP TABLE products;--`. | Безопасно (EF параметризует). |
| **XSS в формах** | Создать товар с name=`<script>alert(1)</script>`. | На UI выводится как текст, не как HTML. |
| **CSRF на /connect/token** | Cookie-based auth не используется, CSRF неактуален. | OK. |
| **CORS** | Запрос из http://evil.com → блокирован. |
| **Path traversal в /uploads** | GET /uploads/../../etc/passwd. | 404. |
| **Unauthenticated endpoints** | Список AllowAnonymous: /health, /api/auth/signup, /api/auth/forgot-password, /connect/token, /uploads/*. | Других — нет. |
| **Rate limit login (после P0-3)** | 10 попыток за минуту → 429. |
| **Перебор email на signup** | 100 signup-запросов с разными email — должен 429 после 20. |
| **Утечка через 404 vs 403** | GET /api/catalog/products/<существующий-чужого-tenant> возвращает 404, не 403 (чтобы не подтверждать существование). |
---
### 2.18. Производительность (P2)
| Сценарий | Цель |
|---|---|
| **GET /api/catalog/products при 10k товаров** | < 500 мс с пагинацией pageSize=50. |
| **POST Supply.Post с 100 lines** | < 2 сек. |
| **GET /api/inventory/movements при 100k записей** | < 1 сек с пагинацией. |
| **Quick search в каталоге** | < 200 мс при 10k товаров. |
| **Dashboard `/stats`** | < 500 мс при 100k продаж. |
| **N+1 запросы** | EF Profiler / Serilog log: на GET /products нет N+1 для prices/barcodes. |
| **Concurrent signup** | 50 параллельных signup → все 201, БД консистентна. |
---
## 3. Регрессионный чек-лист (перед каждым релизом)
```
[ ] Все миграции применяются на чистую БД
[ ] Smoke: GET /health → 200
[ ] Smoke: signup → новая org → login → /api/me → roles[admin]
[ ] Регрессия: создать товар → создать приёмку → /post → Stock обновился
[ ] Регрессия: создать продажу → /post → Stock уменьшился, чек создан
[ ] Регрессия: запрос с другого org_id → 404
[ ] Регрессия: SuperAdmin без override видит все организации
[ ] Регрессия: SuperAdmin с override read-only — мутация 403
[ ] Регрессия: SuperAdmin с reason — мутация 200, audit log запись
[ ] MoySklad: test connection → 200
[ ] Email: forgot password → письмо приходит
[ ] UI: все 35 страниц открываются, onBlur валидация работает
[ ] CI: docker-api workflow зелёный, smoke на /health → 200
[ ] Backup за последние сутки существует, размер > предыдущего > 50%
[ ] Логи за последний час: нет 5xx без log-entry
[ ] Метрики (после P1-17): error_rate < 1%, p95 latency < 1s
```
---
## 4. Стратегия покрытия тестами
### 4.1. Unit-тесты (xUnit, цель 70% coverage критичной логики)
```
food-market.tests.unit/
├── Domain/
│ ├── ProductTests.cs — валидация конструктора, computed properties
│ ├── StockMovementTests.cs — invariants
│ └── RolePermissionsTests.cs — расчёт прав
├── Application/ — после миграции на MediatR
│ ├── SuppliesPostHandlerTests.cs — Cost weighted average, авто-наценка
│ ├── RetailSalePostHandlerTests.cs — Stock update, overselling check
│ └── ImportJobsTests.cs — состояния job'а
└── Infrastructure/
├── TenantFilterTests.cs — query filter включается/исключается правильно
├── StockServiceTests.cs — атомарность ApplyMovement
└── MoySkladClientTests.cs — pagination, error handling (с mock HttpMessageHandler)
```
### 4.2. Integration-тесты (Testcontainers.PostgreSql + WebApplicationFactory)
```
food-market.tests.integration/
├── Auth/
│ ├── SignupFlowTests.cs — POST /signup → bootstrap data
│ └── LoginFlowTests.cs — token + refresh + revoke
├── Catalog/
│ ├── ProductsCrudTests.cs
│ └── ProductGroupsHierarchyTests.cs
├── Documents/
│ ├── SupplyPostUnpostTests.cs — Stock consistency
│ └── RetailSalePostTests.cs — overselling 409
├── Tenancy/
│ ├── MultiTenantIsolationTests.cs — org A не видит org B
│ └── SuperAdminOverrideTests.cs — read-only / edit mode
└── Authorization/
└── PermissionBasedAuthzTests.cs — после P0-5
```
### 4.3. E2E (расширить существующий full-cycle)
```
tests/e2e/scenarios/
├── full-cycle.yml — текущий: signup → import → supply → sale → report
├── multi-tenant-isolation.yml — два юзера, два org, попытки кросс-доступа
├── superadmin-flow.yml — создание org, архив, реор, edit-mode
├── permission-checks.yml — кастомные роли, разрешения/отказы
└── moysklad-sync.yml — конец-в-конец импорт + проверки в БД
```
---
## 5. Инструменты тестирования
| Инструмент | Назначение | Готовность |
|---|---|---|
| **xUnit + FluentAssertions** | Backend unit-тесты | ❌ нет (нужно завести проект) |
| **Testcontainers.PostgreSql** | Integration-тесты с реальной БД в Docker | ❌ нет |
| **WebApplicationFactory<Program>** | In-memory test server | ❌ нет |
| **Playwright** | E2E браузерные тесты | ✅ есть (через `tests/e2e/run.sh full-cycle`) |
| **axios + pg (TS)** | E2E API + DB-проверки | ✅ есть (`tests/e2e/lib/`) |
| **Bombardier / k6** | Нагрузочное тестирование | ❌ нет |
| **OWASP ZAP** | Сканер уязвимостей | ❌ нет (рекомендуется использовать перед prod) |
| **Lighthouse CI** | Public site performance | ❌ нет |
| **Codecov / Coverlet** | Coverage report | ❌ нет |
---
## 6. Метрики качества тестирования
```
Цели после внедрения P1-20, P1-21:
Unit-тесты бизнес-логики: ≥ 70% (сейчас 0%)
Integration-тесты API: ≥ 60% (сейчас 0%)
E2E сценариев: ≥ 5 (сейчас 1)
Время прогона полного CI: ≤ 15 мин
Время smoke в PR: ≤ 2 мин
Регрессионный SLA после релиза:
Severity 1 (блокирующие): обнаружение → фикс ≤ 2 часа
Severity 2 (важные): обнаружение → фикс ≤ 1 рабочий день
Severity 3 (косметика): в плановый спринт
```
---
## 7. Финальный чек-лист «готовности к продакшен»
Прежде чем сказать «можно запускать прод»:
```
БЕЗОПАСНОСТЬ
[ ] HTTPS forced, HSTS установлен
[ ] OpenIddict prod-сертификаты
[ ] Rate limiting на login/signup
[ ] Permission-based authorization работает
[ ] Multi-tenant изоляция проверена (см. п. 2.2)
[ ] OWASP Top-10 сканер пройден
ФУНКЦИОНАЛ
[ ] Складские документы (Enter/Loss/Transfer/Inventory) реализованы
[ ] Отчёты (Sales/Stock/Profit/ABC) реализованы
[ ] ОФД фискализация чеков работает
[ ] Email-нотификации настроены и тестированы
ИНФРАСТРУКТУРА
[ ] Backup автоматический (cron/timer)
[ ] Restore-сценарий отрепетирован
[ ] Health checks детальные (ready/live)
[ ] Метрики Prometheus + Grafana dashboard
[ ] Алерты на error_rate, latency p95
ПРОЦЕССЫ
[ ] CI зелёный на main
[ ] Coverage > 70% unit, > 60% integration
[ ] E2E full-cycle зелёный
[ ] Regression checklist пройден
[ ] Release notes написаны
[ ] Документация .env.example актуальна
[ ] Rollback plan описан
```

View file

@ -1,101 +0,0 @@
# Бэкап и восстановление
Артефакты в репозитории (`deploy/`):
- `food-market-backup.sh` — скрипт бэкапа БД + uploads с ротацией.
- `food-market-backup.service` — systemd oneshot-юнит, запускающий скрипт.
- `food-market-backup.timer` — ежедневный таймер (03:00, с догоном пропущенных).
> Установку на prod-vm выполняет отдельный деплой-шаг (см. ниже) — здесь только
> подготовленные артефакты.
## Что бэкапится
| Что | Как | Файл |
|---|---|---|
| База данных | `pg_dump -Fc` из контейнера `food-market-postgres` | `db-<TS>.dump` (custom-format) |
| Загруженные файлы (картинки товаров) | `tar czf` каталога uploads | `uploads-<TS>.tgz` |
Папка назначения по умолчанию — `/opt/food-market-data/backups`. Хранение —
30 дней (`FM_BACKUP_RETENTION_DAYS`), старые удаляются ротацией. Конфиг —
переменными `FM_*` (см. шапку `food-market-backup.sh`).
## Установка таймера на сервере (деплой-шаг)
Предполагается, что репозиторий выложен в `/opt/food-market` (иначе скорректировать
`ExecStart`/`EnvironmentFile` в `.service` и пути ниже).
```bash
sudo install -m 0755 /opt/food-market/deploy/food-market-backup.sh /opt/food-market/deploy/food-market-backup.sh
sudo cp /opt/food-market/deploy/food-market-backup.service /etc/systemd/system/
sudo cp /opt/food-market/deploy/food-market-backup.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now food-market-backup.timer
# Проверить расписание и последний запуск
systemctl list-timers food-market-backup.timer
# Прогнать бэкап немедленно (разово)
sudo systemctl start food-market-backup.service
journalctl -u food-market-backup.service --no-pager | tail -20
```
## Ручной бэкап
```bash
sudo /opt/food-market/deploy/food-market-backup.sh
# или с переопределением каталога:
FM_BACKUP_DIR=/mnt/backups sudo -E /opt/food-market/deploy/food-market-backup.sh
```
## Восстановление БД
> ⚠️ Восстановление перезаписывает данные. Сначала остановить API, чтобы не было
> записи во время восстановления.
```bash
cd /opt/food-market/deploy
docker compose stop api web
DUMP=/opt/food-market-data/backups/db-YYYYMMDD-HHMMSS.dump
# Скопировать дамп внутрь контейнера БД
docker cp "$DUMP" food-market-postgres:/tmp/restore.dump
# Вариант A — восстановить в чистую БД (рекомендуется):
docker exec food-market-postgres psql -U food_market -d postgres -c \
"DROP DATABASE IF EXISTS food_market WITH (FORCE); CREATE DATABASE food_market OWNER food_market;"
docker exec food-market-postgres pg_restore -U food_market -d food_market --no-owner /tmp/restore.dump
# Вариант B — в существующую БД, заменив объекты (без пересоздания БД):
# docker exec food-market-postgres pg_restore -U food_market -d food_market --clean --if-exists --no-owner /tmp/restore.dump
docker exec food-market-postgres rm -f /tmp/restore.dump
docker compose start api web
```
После старта API применит миграции (`Migrate()` идемпотентен) и поднимется. Проверить:
```bash
curl -fsS http://localhost:8080/health/ready
```
## Восстановление uploads
```bash
TGZ=/opt/food-market-data/backups/uploads-YYYYMMDD-HHMMSS.tgz
# tar содержит каталог uploads/ — распаковать в родителя смонтированного пути
sudo tar xzf "$TGZ" -C /opt/food-market-data/
```
(Каталог `/opt/food-market-data/uploads` смонтирован в контейнер api как `/app/uploads`.)
## Проверка дампа без восстановления
```bash
docker cp <dump> food-market-postgres:/tmp/v.dump
docker exec food-market-postgres pg_restore --list /tmp/v.dump | head # TOC валидного архива
docker exec food-market-postgres rm -f /tmp/v.dump
```
Скрипт и формат проверены локально (2026-05-27): дамп `PGDMP`, custom-format,
248 TOC-записей, `pg_restore --list` читает.

View file

@ -1,79 +0,0 @@
# Логирование (Serilog)
Структурные логи через Serilog. На каждый HTTP-запрос автоматически
обогащаются метки `CorrelationId`, `OrgId`, `UserId` через
`LogEnrichmentMiddleware`. Любой `ILogger<…>.Log*` внутри пайплайна
наследует эти свойства — не нужно тащить их в каждый вызов руками.
## Где приземляются логи
Текущая конфигурация (см. `appsettings.json` / `Program.cs`):
- **Console** (Serilog.Sinks.Console) — в dev и docker (stdout читается
docker logs / journalctl);
- **File** (Serilog.Sinks.File) — ротация по дням.
Для прод-ELK/Loki в будущем добавляется `Serilog.Sinks.Elasticsearch`
или `Serilog.Sinks.Grafana.Loki`; формат вывода уже JSON-friendly,
кардинальность лейблов под Loki не вылезает (`OrgId` гранулярный, но
не на каждое движение, плюс ограничен текущим парком орг ≪10k).
## Корреляция между запросами
Заголовок `X-Correlation-ID`:
- если клиент прислал — middleware его уважает (для bridging с upstream'ом);
- если нет — генерируется `Guid.NewGuid("N")`.
Эхо в response-header чтобы клиент при ошибке отдал support'у конкретный id.
```bash
curl -i http://localhost:5081/api/me -H "Authorization: Bearer …"
# < X-Correlation-ID: 7f9b3c1a4e5d4f0a8b1c2d3e4f5a6b7c
```
## Структурные бизнес-логи
В коде используем именованные плейсхолдеры — Serilog кладёт каждое
поле как отдельное property в LogEvent. Это позволяет фильтровать
`OrgId = "..." AND SupplyNumber = "..."` без regex'ов.
Хорошо:
```csharp
_log.LogInformation(
"Supply posted: {SupplyNumber} supplier={SupplierId} store={StoreId} lines={LinesCount} total={Total}",
supply.Number, supply.SupplierId, supply.StoreId, supply.Lines.Count, supply.Total);
```
Плохо (теряем структуру, нельзя фильтровать):
```csharp
_log.LogInformation($"Supply posted: {supply.Number} ..."); // string interpolation
```
## Что уже логируется как business event
- `Supply posted` — после успешного `/api/purchases/supplies/{id}/post`.
- `RetailSale posted` — после успешного `/api/sales/retail/{id}/post`.
В развитии: Demand.Post, Transfer.Post, Inventory.Post, Loss.Post —
по тому же паттерну. Метки разные, имя события одинаковое для
аналитики «сколько проведений в час по типам».
## Запросы (Serilog request logging)
`app.UseSerilogRequestLogging()` пишет одну summary-строку на каждый
HTTP-запрос: метод, путь, статус, длительность. Дополнительно
обогащается `OrgId/UserId/CorrelationId` из LogContext.
Шаблон в логе:
```
HTTP POST /api/purchases/supplies/{id}/post responded 204 in 87.3ms
{ OrgId: "8b0f...", UserId: "57c3...", CorrelationId: "7f9b..." }
```
## Анти-паттерны
- **Не логировать токены/пароли/email-пароли** — даже структурно.
Identity events (SignIn / Reset Password) — нет, только статус и user-id.
- **Не логировать тело запроса целиком** — может содержать PII.
Только конкретные поля по необходимости.
- **Не использовать string interpolation в шаблоне** — теряется
структура (выше).

View file

@ -1,115 +0,0 @@
# Observability (Prometheus / Grafana)
`food-market.api` экспортирует метрики Prometheus на `/metrics` (text exposition
format, без авторизации). На prod закрываем nginx-уровнем (allow private
network, deny all) или basic-auth.
## Базовые метрики (от prometheus-net)
| Метрика | Тип | Лейблы | Что показывает |
|---|---|---|---|
| `http_requests_received_total` | counter | code, method, controller, action | Сколько HTTP-запросов прошло — split per controller+action+status. |
| `http_request_duration_seconds` | histogram | code, method, controller, action | Длительность HTTP, гистограмма для p50/p95/p99 SLO. |
| `process_cpu_seconds_total` | counter | — | CPU time. |
| `process_resident_memory_bytes` | gauge | — | RSS. |
| `dotnet_total_memory_bytes` | gauge | — | Managed heap. |
| `dotnet_collection_count_total` | counter | generation | GC count по поколениям. |
## Кастомные метрики
| Метрика | Тип | Лейблы | Семантика |
|---|---|---|---|
| `food_market_documents_posted_total` | counter | type | Проведено документов (retail-sale, supply, enter, loss, transfer, inventory, supplier-return, customer-return). |
| `food_market_sales_posted_total` | counter | — | Alias для `documents_posted{type="retail-sale"}` (явно перечислен в SLO). |
| `food_market_supplies_posted_total` | counter | — | Alias для `documents_posted{type="supply"}`. |
| `food_market_documents_error_total` | counter | type, reason | Ошибки проведения: reason `serialization` (40001), `insufficient_stock`, `number_conflict`, `validation`, `other`. |
| `food_market_db_query_duration_seconds` | histogram | kind | Длительность SQL-запросов EF Core. `kind=query` (SELECT), `kind=command` (INSERT/UPDATE/DELETE/SCALAR). |
Tenant-меток в кастомных метриках НЕТ сознательно: на multi-tenant хосте они
бы раздули cardinality. Per-org разрез — через `/api/reports/*` (там
authz-фильтр уже работает).
## Scrape-конфиг (prometheus.yml)
```yaml
scrape_configs:
- job_name: food-market-api
metrics_path: /metrics
scrape_interval: 15s
static_configs:
- targets: ['food-market-api:8080']
```
## Образец Grafana dashboard
Минимальный набор панелей:
### Health row
* **Request rate**`sum(rate(http_requests_received_total[5m])) by (code)`
→ стек по 2xx/3xx/4xx/5xx.
* **Error rate (5xx)**`sum(rate(http_requests_received_total{code=~"5.."}[5m]))`
с alert `> 0.1 req/s` (5 минут) → Telegram.
* **p95 latency**`histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))`.
### Business row
* **Sales/hour**`rate(food_market_sales_posted_total[1h]) * 3600`.
* **Supplies posted**`increase(food_market_supplies_posted_total[1d])`.
* **Document errors**`sum(rate(food_market_documents_error_total[5m])) by (type, reason)`.
Alert `serialization rate > 1 req/min`: указывает на лок-контеншн Postgres.
### Database row
* **EF query rate**`sum(rate(food_market_db_query_duration_seconds_count[5m])) by (kind)`.
* **EF query p95** — `histogram_quantile(0.95,
sum(rate(food_market_db_query_duration_seconds_bucket[5m])) by (le, kind))`.
### Runtime row
* **CPU**`rate(process_cpu_seconds_total[1m]) * 100`.
* **Memory**`process_resident_memory_bytes / 1024 / 1024`.
* **GC Gen2 collections**`rate(dotnet_collection_count_total{generation="2"}[5m])`.
## Alerts (prometheus rules) — пример
```yaml
groups:
- name: food-market
rules:
- alert: HighErrorRate
expr: sum(rate(http_requests_received_total{code=~"5.."}[5m])) > 0.1
for: 5m
labels: { severity: warning }
annotations:
summary: "food-market.api возвращает >0.1 5xx/s"
- alert: DbSerializationContention
expr: rate(food_market_documents_error_total{reason="serialization"}[5m]) > 0.016
for: 10m
labels: { severity: warning }
annotations:
summary: "Сериализационные конфликты EF >1/мин"
- alert: NoSalesIn30Min
expr: increase(food_market_sales_posted_total[30m]) == 0
for: 30m
labels: { severity: info }
annotations:
summary: "Нет продаж 30 минут — POS оффлайн или магазин закрыт"
```
## Локальная отладка
```bash
# Чтобы посмотреть метрики из локального API:
curl http://localhost:5081/metrics | head -50
# Конкретная метрика:
curl -s http://localhost:5081/metrics | grep food_market_sales_posted_total
```
## Поведение в тестовом окружении
В интеграционных тестах prometheus-метрики поднимаются как часть
WebApplicationFactory; счётчики живут per-process (статические `Metrics.Create...`).
Состояние accumulated между тестами в той же сборке — поэтому в
`MetricsEndpointTests` мы проверяем «значение увеличилось», а не точное число.

View file

@ -1,59 +0,0 @@
# OpenAPI / Swagger
API публикует OpenAPI-документ через `Swashbuckle.AspNetCore`. Описание
включает security-scheme `Bearer` (OpenIddict JWT), стабильные
`operationId = Controller_Action`, уникальные `schemaId` с префиксом из
неймспейса (одноимённые nested record'ы в разных контроллерах не схлопываются).
## Эндпоинты
| URL | Когда |
|---|---|
| `/swagger` | UI, только Development |
| `/swagger/v1/swagger.json` | JSON-документ, только Development |
На stage/prod swagger отключён — отдельный endpoint enumeration
не должен раскрываться неавторизованным клиентам. Если нужно — поднимать
локальный API из той же ветки.
## TypeScript-клиент
В `src/food-market.web` подключён `openapi-typescript` (devDependency).
Команда:
```bash
# Терминал 1: поднять API
ASPNETCORE_ENVIRONMENT=Development dotnet run --project src/food-market.api
# Терминал 2: сгенерировать types
cd src/food-market.web
pnpm run gen:api # читает http://localhost:5081/swagger/v1/swagger.json
# → src/lib/api.generated.ts
```
Альтернативно (без живого API) — через `Swashbuckle.AspNetCore.Cli` (версия
должна совпадать с `Swashbuckle.AspNetCore`, у нас 6.9.0):
```bash
dotnet tool install --global Swashbuckle.AspNetCore.Cli --version 6.9.0
dotnet build src/food-market.api
swagger tofile --output /tmp/swagger.json \
src/food-market.api/bin/Debug/net8.0/foodmarket.Api.dll v1
cd src/food-market.web
pnpm exec openapi-typescript /tmp/swagger.json -o src/lib/api.generated.ts
```
## Использование
Тонкая обёртка в `src/food-market.web/src/lib/apiClient.ts` экспортирует
типизированные хелперы для отчётов (Reports/Sales, Reports/ABC,
Reports/Profit) — образец постепенной миграции с ручных типов в
`types.ts`. В новом коде использовать обёртку и переэкспортированные
типы; старые страницы переписывать по мере правок.
## Версионирование
Document `v1` — единственный. Если будут breaking changes — поднимаем
`v2` рядом, не ломая `v1`. У `operationId` стабильное имя
`Controller_Action` — переименование контроллера ломает TS-клиент,
относиться как к public API.

View file

@ -1,65 +0,0 @@
# Ключи OpenIddict (подпись и шифрование токенов)
Токены доступа/обновления подписываются (и в проде шифруются) ключами OpenIddict.
Конфигурация ключей — в `OpenIddictKeyConfigurator` (`src/food-market.api/Infrastructure/Security/`),
вызывается из `Program.cs` внутри `AddServer(...)`.
## Development
- Persistent RSA-ключ в `src/food-market.api/App_Data/openiddict-dev-key.xml`
(один и тот же для подписи и шифрования).
- Переживает рестарты — выданные токены остаются валидными между перезапусками.
- **Шифрование access-token выключено** (`DisableAccessTokenEncryption`) — токен это
обычный 3-сегментный JWT, удобно дебажить (можно прочитать на jwt.io).
- Файл `App_Data/` в `.gitignore` — ключ не коммитится.
## Production / Stage
- Отдельные **X509-сертификаты** для подписи и шифрования. Access-token шифруется
(5-сегментный JWE).
- Путь к сертификатам — из конфигурации:
| Ключ конфига | Env-переменная | Назначение | Дефолт |
|---|---|---|---|
| `OpenIddict:SigningCertPath` | `OpenIddict__SigningCertPath` | сертификат подписи | `App_Data/openiddict-signing.pfx` |
| `OpenIddict:EncryptionCertPath` | `OpenIddict__EncryptionCertPath` | сертификат шифрования | `App_Data/openiddict-encryption.pfx` |
| `OpenIddict:CertPassword` | `OpenIddict__CertPassword` | пароль PFX (опц.) | — |
- **Если файла нет** — генерируется persistent self-signed сертификат (RSA 2048, срок 5 лет)
и сохраняется по пути. При следующем старте берётся тот же файл, поэтому ранее
выданные токены остаются валидными (нет dev-ephemeral поведения, при котором каждый
рестарт инвалидировал бы все токены).
- `App_Data` смонтирован как volume (`api-data:/app/App_Data` в `docker-compose.yml`),
поэтому сертификаты переживают пересоздание контейнера.
### Принести собственные сертификаты
Положить готовые `.pfx` (с приватным ключом) по путям из конфига и, при наличии пароля,
задать `OpenIddict__CertPassword`. Приложение их подхватит вместо генерации self-signed.
```bash
# пример: смонтировать каталог с сертификатами и указать пути
OpenIddict__SigningCertPath=/run/secrets/oidc-signing.pfx
OpenIddict__EncryptionCertPath=/run/secrets/oidc-encryption.pfx
OpenIddict__CertPassword=<пароль или пусто>
```
### Ротация
1. Заменить/удалить `.pfx` файлы (или указать новые пути).
2. Рестарт API: при отсутствии файла сгенерируется новый сертификат.
3. **Важно:** ротация ключа подписи/шифрования инвалидирует все ранее выданные
токены — пользователям потребуется перелогиниться. Планировать на окно обслуживания.
### Проверка (smoke)
```bash
# 5 сегментов = JWE (шифрование включено) — норма для прода
curl -s -X POST $API/connect/token -H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=password&username=...&password=...&client_id=food-market-web&scope=api" \
| python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'].count('.')+1,'сегментов')"
```
Проверено локально (2026-05-27): prod-режим генерирует оба сертификата в `App_Data`,
выдаёт 5-сегментный JWE, `/api/me` → 200; после рестарта сертификаты те же
(fingerprint совпадает), токен, выданный до рестарта, остаётся валиден.

View file

@ -1,56 +0,0 @@
# Чек-лист релиза food-market
Практический список перед/во время/после выкатки. Деплой автоматизирован
(push в `main` → GitHub Actions: CI → образы → deploy stage; см. [stage-setup.md](stage-setup.md)).
Прод — после подтверждения на stage.
## 0. Предусловия (один раз на окружение)
- [ ] `deploy/.env` заполнен из `deploy/.env.example`, права `600` (см. [secrets.md](secrets.md)).
- [ ] `POSTGRES_PASSWORD` — не дефолтный.
- [ ] `OPENIDDICT_ISSUER` = публичный URL админки (за прокси обязателен).
- [ ] OpenIddict-сертификаты на месте или генерируются self-signed (см. [openiddict-keys.md](openiddict-keys.md)).
- [ ] Таймер бэкапа установлен и активен: `systemctl list-timers food-market-backup.timer` (см. [backup-restore.md](backup-restore.md)).
- [ ] HTTPS на nginx-проксе настроен (вне этого репо).
## 1. Перед релизом (на ветке/в PR)
- [ ] `dotnet build` зелёный (api + зависимости; POS на Linux не собирается — это норма).
- [ ] Юнит-тесты зелёные: `dotnet test tests/food-market.UnitTests`.
- [ ] Интеграционные тесты зелёные: `dotnet test tests/food-market.IntegrationTests` (нужен Docker для Testcontainers).
- [ ] Релевантные e2e-сценарии зелёные (`tests/e2e/run.sh <name>`).
- [ ] Новые EF-миграции просмотрены: идемпотентны, без потери данных, при ручном написании — `[Migration("ID")]` + `[DbContext]` (иначе `Migrate()` не подхватит).
- [ ] Изменения секретов/конфигов отражены в `.env.example` и `secrets.md`.
- [ ] CHANGELOG/release notes обновлены (если ведутся).
## 2. Бэкап перед выкаткой
- [ ] Свежий бэкап БД: `sudo systemctl start food-market-backup.service` → проверить файл в `FM_BACKUP_DIR`.
- [ ] Проверить, что дамп валиден: `pg_restore --list` (см. [backup-restore.md](backup-restore.md)).
## 3. Релиз
- [ ] Смёрджить в `main` (или прогнать `deploy-stage.yml`). CI соберёт образы и задеплоит на stage.
- [ ] Дождаться Telegram-уведомления «Deploy stage OK».
- [ ] Миграции применяются автоматически на старте API (`Migrate()`), отдельный шаг не нужен.
## 4. После выкатки (smoke)
- [ ] `curl -fsS https://<host>/health/ready``200 Healthy` (БД + миграции).
- [ ] `curl https://<host>/health/live``200`.
- [ ] Логин: получить токен на `/connect/token`, `/api/me``200` с ожидаемыми claim'ами и `org_id`.
- [ ] Ключевые потоки: создать товар, провести приёмку, провести розничную продажу — без ошибок.
- [ ] Permission-гейт работает: пользователь без права получает `403` (не `500`/`200`).
- [ ] Антибрутфорс: >5 логинов/мин с одного IP → `429`.
- [ ] Логи без необработанных исключений: `docker logs food-market-api | tail`.
## 5. Откат (если что-то не так)
- [ ] Откатить теги образов: задать прежние `API_TAG`/`WEB_TAG` в `.env`, `docker compose up -d`.
- [ ] Если миграция повредила данные — восстановить БД из бэкапа п.2 (см. [backup-restore.md](backup-restore.md)), затем откатить образ.
- [ ] Сообщить в Telegram-канал статус.
## 6. Прод (после OK на stage)
- [ ] Повторить пп. 25 на прод-окружении.
- [ ] Мониторить первые ~30 мин: `/health/ready`, логи, диск (`df -h`).

View file

@ -1,52 +0,0 @@
# Секреты и переменные окружения
Все секреты задаются через `deploy/.env``.gitignore`, **не коммитится**).
Шаблон со всеми переменными — `deploy/.env.example`. docker-compose читает `.env`
автоматически из каталога запуска (`deploy/`).
```bash
cp deploy/.env.example deploy/.env
$EDITOR deploy/.env # заполнить значения
chmod 600 deploy/.env # ограничить доступ
```
## Перечень
| Переменная | Обяз. | Назначение | Где используется | Как получить |
|---|:---:|---|---|---|
| `POSTGRES_PASSWORD` | ✅ | пароль БД `food_market` | контейнер postgres + `ConnectionStrings__Default` API | `openssl rand -base64 24` |
| `REGISTRY` | ✅ | реестр образов | image-ссылки в compose | стейдж: `127.0.0.1:5001` |
| `API_TAG` / `WEB_TAG` / `PUBLIC_TAG` | ✅ | теги образов | image-ссылки | тег из CI / `latest` |
| `OPENIDDICT_ISSUER` | ✅(прод) | публичный issuer токенов | API `OpenIddict__Issuer` | публичный URL админки, напр. `https://admin.food-market.kz/` |
| `OPENIDDICT_CERT_PASSWORD` | — | пароль PFX-сертификатов | API `OpenIddict__CertPassword` | свой пароль или пусто (self-signed без пароля) |
| `FM_BACKUP_DIR` / `FM_UPLOADS_DIR` / `FM_BACKUP_RETENTION_DAYS` | — | параметры бэкапа | `food-market-backup.sh` | дефолты совпадают с compose |
| `Cors__AllowedOrigins__N` | — | CORS-origins | API | переопределяет `appsettings.json` |
| `RateLimiting__*` | — | антибрутфорс лимиты | API | дефолты 5/мин, 20/час |
| `MoySklad__BaseUrl` | — | база API МойСклад | API | дефолт боевой `api.moysklad.ru` |
> `__` (двойное подчёркивание) — разделитель секций конфигурации .NET
> (`OpenIddict__Issuer` ≡ `OpenIddict:Issuer`).
## Где ещё живут секреты
- **SMTP (отправка писем)**НЕ в env. Хранятся в БД (`platform_settings`),
правятся из SuperAdmin-консоли (раздел «Платформа → SMTP»). Перечитываются на
каждой отправке без рестарта (см. `MailKitEmailSender`).
- **Сертификаты OpenIddict** — PFX в volume `api-data` (`/app/App_Data`). Генерируются
self-signed при отсутствии. Можно принести свои — см. [openiddict-keys.md](openiddict-keys.md).
- **Учётки БД/Forgejo на сервере** — вне репозитория (см. приватные заметки оператора).
## Ротация
| Секрет | Как ротировать | Влияние |
|---|---|---|
| `POSTGRES_PASSWORD` | `ALTER USER food_market PASSWORD '…'`, обновить `.env`, `docker compose up -d` | рестарт API |
| OpenIddict-сертификаты | заменить/удалить PFX, рестарт API | все токены инвалидируются — повторный логин |
| SMTP-пароль | через SuperAdmin-консоль | без рестарта |
## Гигиена
- `deploy/.env` — права `600`, владелец — пользователь деплоя.
- Не логировать значения секретов. Serilog настроен без дампа окружения.
- При утечке — ротировать затронутый секрет (таблица выше) и пересоздать токены.
- Проверка, что секреты не утекли в git: `git ls-files | grep -E '\.env$'` должен быть пуст.

View file

@ -1,89 +0,0 @@
# Спринт 1 — стабилизация (P0 код/инфра)
Автономная работа. После каждого пункта: `dotnet build` (SDK 8.0.126), релевантные тесты,
коммит порцией, отметка `[x]` здесь, коммит прогресса.
> Сборка: POS-проект (`food-market.pos`, net8.0-windows) на Linux не собирается — это
> ожидаемо (нужен Windows SDK). Эталон сборки — `dotnet build src/food-market.api/food-market.api.csproj`
> + solution-сборка тестовых проектов.
## Чек-лист
1. [x] **P0-3 Rate-limit**`Microsoft.AspNetCore.RateLimiting` (sliding window) на
`/connect/token` и `/api/auth/signup`. 5/мин/IP, 20/час/IP. Тест: 6-я попытка за минуту → 429.
`AuthRateLimiterExtensions` (global limiter + chained окна, gate по пути), отдельные
бакеты на эндпоинт. Проверено curl на :5091 — token 6→429, signup 6→429, бакеты независимы.
2. [x] **P0-4 Health checks**`/health/live` (alive) + `/health/ready` (DB ping + миграции
применены). docker-compose healthcheck → `/health/ready`.
`DatabaseReadyHealthCheck` (CanConnect + GetPendingMigrations), JSON-writer, tag `ready`.
Проверено: live→200 (checks:[]), ready→200 (database Healthy). Dockerfile + compose api
healthcheck на `/health/ready`, web ждёт api `service_healthy`. `/health` оставлен для
совместимости. Прим.: startup `Migrate()` — fail-fast при DB-down на буте (вне scope, compose
гейтит api на `postgres: service_healthy`).
3. [x] **P0-5 Permission-based authz**`PermissionHandler` + `[RequiresPermission("...")]`
читающий флаги `RolePermissions`. Заменить `[Authorize(Roles=...)]` в каталоге/документах.
E2E: кастомная роль без `ProductsEdit` → 403 на PUT товара.
`PermissionAuthorizationHandler` (live из БД: Employee→EmployeeRole→Permissions) +
`RequiresPermissionAttribute` + динамический `PermissionAuthorizationPolicyProvider`
(policy `perm:*`). SuperAdmin/Identity-Admin — full-access шорткат (custom-роли не маппятся
на Admin). Заменены role-гейты в 8 catalog + 2 document контроллерах (Currencies/Countries
оставлены SuperAdmin — глобальный справочник). Закрывает «роли — фикция» из аудита.
Проверка: curl на :5091 (403/200/400) + e2e `roles` step08 — зелёный 8/8.
Rate-limit стал конфигурируемым (`RateLimiting:*`) — иначе повторные логины тестов → 429.
4. [x] **P0-1 OpenIddict prod-ключи** — signing+encryption сертификаты из пути в конфиге,
persistent self-signed если файла нет. Dev-поведение не ломать. Документировать.
`OpenIddictKeyConfigurator`: dev RSA-XML (без изменений), prod X509 из
`OpenIddict:SigningCertPath`/`EncryptionCertPath`/`CertPassword`, self-signed (5 лет) в
App_Data при отсутствии. Проверено: prod 5-сегм. JWE, persist через рестарт (тот же
fingerprint, pre-restart токен валиден); dev 3-сегм. JWT. `docs/openiddict-keys.md`.
5. [x] **P0-6 Авто-бэкап**`deploy/food-market-backup.service` + `.timer`, скрипт
backup+ротация 30 дней, `docs/backup-restore.md`. Только артефакты в репо.
`food-market-backup.sh` (pg_dump -Fc + tar uploads, ротация 30д, атомарная запись),
systemd timer ежедневно 03:00 (Persistent). Проверено: дамп PGDMP/248 TOC, pg_restore --list ок.
6. [x] **P0-8**`deploy/.env.example` + `docs/secrets.md`.
`.env.example` (все required+опц.), `secrets.md` (таблица/ротация/гигиена), проброс
`OpenIddict__Issuer`/`CertPassword` в compose. `compose config` валиден.
7. [x] **P0-9**`docs/release-checklist.md`.
✅ Пред/во время/после выкатки + откат + прод; ссылки на secrets/backup/openiddict/stage-setup.
8. [x] **P1-20 Unit-тесты**`tests/food-market.UnitTests`: `StockService.ApplyMovement`,
расчёт Cost в `SuppliesController.Post`, валидация платежа `RetailSalesController.Post`,
multi-tenant query filter.
✅ 23 теста зелёные. Чистая логика вынесена в Application (`MovingAverageCost`,
`RetailPaymentValidator`) и используется контроллерами. StockService + query-filter на
SQLite in-memory (EF8 поддерживает `ToJson`). `FakeTenantContext`, `SqliteDb` helper.
9. [x] **P1-21 Integration-тесты** — Testcontainers.PostgreSql + WebApplicationFactory:
signup-flow, supply post→unpost, retail overselling, tenant isolation A vs B, permission-проверки.
`tests/food-market.IntegrationTests` — 10 тестов зелёные на реальном postgres:16-alpine
(Ryuk off, RateLimiting off через env). `ApiFactory`+`ApiActor`. Все 5 сценариев покрыты.
## Итог
**Все 9 пунктов выполнены.** Спринт 1 (стабилизация P0/P1-инфра) завершён 2026-05-27.
Сводка:
- **P0-3** rate-limit (5/мин+20/час на IP, конфигурируем) — `AuthRateLimiterExtensions`.
- **P0-4** health `/health/live` + `/health/ready` (БД+миграции), compose/Dockerfile healthcheck.
- **P0-5** permission-based authz (`[RequiresPermission]` + handler по флагам роли), 10 контроллеров.
- **P0-1** OpenIddict prod X509-ключи из конфига, persistent self-signed.
- **P0-6** авто-бэкап (systemd timer + скрипт + ротация 30д) + `backup-restore.md`.
- **P0-8** `deploy/.env.example` + `secrets.md`.
- **P0-9** `release-checklist.md`.
- **P1-20** unit-тесты (23) — `MovingAverageCost`, `RetailPaymentValidator`, StockService, query-filter.
- **P1-21** integration-тесты (10) — Testcontainers + WebApplicationFactory.
Сборка зелёная (`dotnet build src/food-market.api`); тесты: **23 unit + 10 integration = 33 зелёных**.
POS (net8.0-windows) на Linux не собирается — ожидаемо, вне scope.
Пропущено намеренно (по инструкции): P0-7 ОФД (нужен внешний оператор), gateway nginx HTTPS,
`global.json` (локальный даунгрейд не коммитим). Установка backup-таймера/сертификатов на
prod-vm — отдельный деплой-шаг (артефакты готовы).
### Эффект на код вне P0/P1
- Чистая логика вынесена в Application (`MovingAverageCost`, `RetailPaymentValidator`) — контроллеры используют её.
- `Program` стал `public partial` для WebApplicationFactory.
- e2e `roles` step08: gap → реальная проверка permission-enforcement (8/8 зелёный).
## Лог
- Каждый пункт: build + тесты + коммит порцией + отметка [x] + коммит прогресса.
- Все правки на ветке `main` (origin Forgejo), без коммита `global.json`.

View file

@ -1,119 +0,0 @@
# Спринт 2 — складские документы (P1)
Автономная работа. После каждого пункта: `dotnet build` (SDK 8.0.126),
unit + integration + (где применимо) E2E тесты этого пункта, коммит порцией,
отметка `[x]` здесь, коммит прогресса.
Multi-tenant: все новые сущности — `TenantEntity` с `OrganizationId` +
query filter. Stock-инвариант: после каждого Post/Unpost
`Stock.Quantity ≡ Σ StockMovement` для (Product, Store).
## Чек-лист
1. [x] **P1-1 Оприходование (Enter)** — Domain `Enter`+`EnterLine`, EF, миграция,
контроллер CRUD + Post/Unpost (Stock + StockMovement тип `Enter`), Web
`/inventory/enters`. Без поставщика (источник — начальные остатки, излишек инвентаризации).
✅ Контроллер `api/inventory/enters`; миграция `Phase6a_Enters`; пункт «Оприходования»
в сайдбаре Admin/Storekeeper. Тесты: 4 интеграционных (post raise stock, unpost
откатывает, double post→409, tenant-изоляция, блокировка unpost при минусе).
2. [x] **P1-2 Списание (Loss)** — Domain `Loss`+`LossLine` + enum `LossReason`
(Defect/Expired/Damage/Shortage/Other). EF, миграция, контроллер, Web,
`StockMovement` тип `WriteOff`.
✅ Контроллер `api/inventory/losses` (CRUD + Post/Unpost) с проверкой
«не списать сверх остатка» (409). Миграция `Phase6b_Losses`. Web с
фильтром по причине и колонкой stockAtStore. Тесты: 3 интеграционных
(post снижает stock, over-write-off → 409, tenant-изоляция).
3. [x] **P1-3 Перемещение (Transfer)** — Domain `Transfer`+`TransferLine`
(FromStoreId → ToStoreId, обязательны и различны). Атомарная транзакция:
`TransferOut` из From + `TransferIn` в To. EF, миграция, контроллер + Post/Unpost,
Web. Кейс: post→unpost не оставляет orphan-движений.
✅ Пара движений (Out + In) в одной Serializable-транзакции; обратная пара
в Unpost. Проверка «not short» на FromStore при Post и ToStore при Unpost.
Permission `TransferEdit`. Тесты: 4 интеграционных, ключевой проверяет что
движений ровно 2 после Post и ровно 4 после Unpost (никаких orphan).
4. [x] **P1-4 Инвентаризация (Inventory)** — Domain `Inventory`+`InventoryLine`
(productId, bookQty, actualQty, diff). EF, миграция. Контроллер: создание
подгружает текущие остатки; Post создаёт `InventoryAdjustment` на diff. Web:
форма со списком товаров склада, импорт CSV факта.
✅ Доменная сущность `InventoryDoc` (имя чтобы не пересекаться с системным
неймспейсом). Create с пустыми lines подтягивает все товары склада;
Update пишет actualQty построчно. Post создаёт `InventoryAdjustment`
только по строкам с diff != 0 (400 если нет расхождений). Unpost блочит
при «излишек уже расходован». Web с CSV-импортом (productId|article;qty).
Тесты: 3 интеграционных.
5. [x] **P1-6 Возврат от покупателя (CustomerReturn)** — расширение `RetailSale`
опцией возврата (referenceSaleId или без). Контроллер: создание возврата из
проведённой продажи, `CustomerReturn` тип уже есть. Web: кнопка «Создать возврат».
✅ RetailSale.IsReturn + ReferenceSaleId; RetailSaleLine.QtyReturned
(агрегация для защиты от over-return). `POST /create-return` копирует
проведённый чек в Draft-возврат с qty = (Quantity - QtyReturned).
Post return через `CustomerReturn`-движение с +Quantity, инкрементит
QtyReturned на исходных строках. Запрещён unpost оригинала при активных
возвратах. Тесты: 3 интеграционных.
6. [x] **P1-7 Возврат поставщику (SupplierReturn)** — по аналогии для Supply.
Domain `SupplierReturn`+`Line` (referenceSupplyId). Контроллер. Web.
✅ Зеркалит Supply, но Post с -Quantity (тип `SupplierReturn`). Валидация
что reference указывает на проведённую приёмку того же поставщика. Защита
от ухода в минус. Permissions переиспользуют `SuppliesEdit/Post/Delete`.
Тесты: 4 интеграционных.
7. [x] **P1-16 Hangfire dashboard + cleanup**`Hangfire.Dashboard` с
авторизацией только для SuperAdmin. Scheduled: ежедневный cleanup
`StockMovement` старше 2 лет, audit-log старше 90 дней.
`Hangfire.PostgreSql` storage на ConnectionStrings:Default. Сервер
стартует только если `Hangfire:Enabled=true` (по умолчанию). Dashboard
`/hangfire` гейтит `SuperAdminHangfireFilter`. Recurring: `prune-stock-movements`
(03:30 UTC, 730 дней) и `prune-audit-log` (03:45 UTC, 90 дней) —
`HousekeepingJobs` с `IgnoreQueryFilters` (межтенантно). Тесты:
1 unit + 1 интеграционный.
## Итог
**Все 7 пунктов выполнены.** Спринт 2 (складские документы P1) завершён 2026-05-28.
Сводка:
- **P1-1 Enter** — оприходование без поставщика (`/api/inventory/enters`, миграция
`Phase6a_Enters`); 4 интеграционных теста.
- **P1-2 Loss** — списание с enum `LossReason` (`/api/inventory/losses`,
`Phase6b_Losses`); 3 интеграционных.
- **P1-3 Transfer** — атомарное перемещение пара TransferOut + TransferIn
(`/api/inventory/transfers`, `Phase6c_Transfers`); 4 интеграционных, включая
проверку «после Post ровно 2 движения, после Unpost ровно 4».
- **P1-4 Inventory** — пересчёт с auto-load остатков (`/api/inventory/inventories`,
`Phase6d_Inventories`); 3 интеграционных, импорт CSV в UI.
- **P1-6 CustomerReturn**`RetailSale.IsReturn` + `ReferenceSaleId` +
`RetailSaleLine.QtyReturned`, эндпоинт `POST /create-return`
(`Phase6e_RetailSaleReturns`); 3 интеграционных.
- **P1-7 SupplierReturn** — зеркало Supply (`/api/purchases/supplier-returns`,
`Phase6f_SupplierReturns`); 4 интеграционных, валидация совпадения поставщика
при ссылке на приёмку.
- **P1-16 Hangfire**`Hangfire.PostgreSql` storage, dashboard `/hangfire`
с `SuperAdminHangfireFilter`, recurring jobs `prune-stock-movements` (730 дней)
и `prune-audit-log` (90 дней); 1 unit + 1 интеграционный.
**Сборка:** зелёная (`dotnet build src/food-market.api`).
**Тесты:** 24 unit + 32 integration = **56 зелёных**.
**Web:** `pnpm build` зелёный (5 новых пар list+edit страниц + расширение RetailSale).
### Новые таблицы
`enters`, `enter_lines`, `losses`, `loss_lines`, `transfers`, `transfer_lines`,
`inventories`, `inventory_lines`, `supplier_returns`, `supplier_return_lines`
+ колонки `retail_sales.IsReturn/ReferenceSaleId`, `retail_sale_lines.QtyReturned`.
### Новые permissions
`TransferEdit` добавлен в `RolePermissions` (Enter/Loss/Inventory/Supplies* —
переиспользованы существующие). `All()` обновлён.
### Stock-инвариант
Каждый документ при Post создаёт явные `StockMovement` через `IStockService`
в Serializable-транзакции. Post→Unpost — обратные движения тем же документ-id
(reversal-маркер в `DocumentType`). Проверка «не уйти в минус» на:
- Enter Unpost (товар уже мог быть продан),
- Loss Post (нельзя списать сверх остатка),
- Transfer Post (FromStore) и Unpost (ToStore),
- Inventory Unpost (излишек мог уйти),
- SupplierReturn Post (нельзя вернуть сверх остатка).
## Лог
- Каждый пункт: build + тесты + коммит порцией + отметка [x] + коммит прогресса.
- Все правки на ветке `main` (origin Forgejo), без коммита `global.json`.

View file

@ -1,84 +0,0 @@
# Спринт 3 — отчёты и аналитика (P1)
Автономная работа. После каждого пункта: `dotnet build` (SDK 8.0.126),
unit + integration тесты этого пункта, коммит порцией, отметка `[x]` здесь,
коммит прогресса.
Multi-tenant: все запросы фильтруются по `OrganizationId` через query filter
`AppDbContext`. Каждый отчёт — отдельный e2e/integration на изоляцию orgA vs orgB.
## Чек-лист
1. [x] **P1-8 Отчёт «Продажи»**`/api/reports/sales` с группировкой по период
(день/неделя/месяц), товар, кассир, касса, способ оплаты; фильтры
(от/до, магазин, группа товаров). Web `/reports/sales`: фильтр периода,
табы по группировкам, экспорт CSV+XLSX.
✅ Реализация: проекция в плоский ряд на сервере + агрегация в C#
(EF8 не переводит «distinct count» в group-проекции с nullable-ключами).
`CsvHelper` + `ClosedXML`. Bonus: исправлен баг `RetailSalesController.Update`
(DbUpdateConcurrency на свеже-созданном возврате). 5 интеграционных тестов.
2. [x] **P1-9 Отчёт «Остатки на дату»**`/api/reports/stock` восстанавливает
остатки на произвольную дату через журнал `StockMovement` (Σ движений до
даты по продукту). Web `/reports/stock`: выбор даты, фильтр магазин/группа,
экспорт. Edge: дата в будущем, дата раньше первой операции.
✅ Реконструкция через Σ `StockMovement.Quantity` где `OccurredAt ≤ date`.
Стоимость — последний `UnitCost` движения до даты + fallback на `Product.Cost`.
Edge'ы покрыты тестами: 5 интеграционных (today=current, before-first→empty,
future=current, future-supply исключается на «сегодня», tenant-изоляция).
3. [x] **P1-10 Отчёт «Прибыль»**`/api/reports/profit` = выручка себестоимость
по периодам/группам/товарам. Cost-snapshot уже есть в `RetailSaleLine`
(через `UnitCost` movement'а). Защита от деления на ноль при нулевой выручке.
✅ Cost-snapshot — `Product.Cost` (скользящее среднее; точный FIFO потребует
партий и вынесен из scope). Margin = profit/revenue·100, при revenue=0
возвращаем 0. Возврат вычитает и выручку, и COGS симметрично. 3 интеграционных.
4. [x] **P1-11 Отчёт «ABC-анализ»** — топ товаров по выручке за период,
классы A/B/C по Парето (A=80%, B=15%, C=5% накопительной выручки).
Параметр метрики (выручка/прибыль/маржа). Web с визуализацией.
`GET /api/reports/abc?metric=revenue|profit|margin`. Граница A —
`cumulativeShare ≤ 80`, B — `≤ 95`, C — остальное. Товары с
неположительной метрикой исключаются. Web: цветные плашки класса +
полоса накопительной доли. 4 интеграционных теста.
5. [x] **P1-19 OpenAPI / Swagger**`Swashbuckle.AspNetCore`,
`/swagger/v1/swagger.json` в Development. Сгенерировать TS-клиент для
food-market.web (`openapi-typescript`/`nswag`) и подключить для пары
контроллеров как образец.
✅ SwaggerGen с Bearer security-scheme + стабильные operationId
(Controller_VerbAction — verb включён против коллизии WipeAll/WipeAllAsync)
+ уникальные schemaId с namespace-префиксом. UI только в Development.
`openapi-typescript` как devDependency + npm-script `gen:api`.
`src/lib/api.generated.ts` + `apiClient.ts` (тонкая обёртка) — образец
на Reports/Sales/ABC/Profit. `docs/openapi.md` — workflow генерации.
## Итог
**Все 5 пунктов выполнены.** Спринт 3 (отчёты и аналитика) завершён 2026-05-28.
Сводка:
- **P1-8 Sales**`/api/reports/sales` 7 группировок + CSV/XLSX, 5 интеграционных.
- **P1-9 Stock** — реконструкция через журнал движений, 5 интеграционных (вкл. edges).
- **P1-10 Profit** — netto с защитой от деления на ноль, 3 интеграционных.
- **P1-11 ABC** — Парето 80/15/5 + 3 метрики + цветная UI-визуализация, 4 интеграционных.
- **P1-19 OpenAPI** — Swagger + TS-клиент через openapi-typescript,
обёртка `apiClient.ts` для подсказки IDE.
**Сборка:** зелёная. **Тесты:** 24 unit + 49 integration (32 sprint1+2 + 17 sprint3) **зелёные**.
**Web:** `pnpm build` зелёный (4 новых report-страницы + boilerplate api.generated.ts).
### Архитектурное замечание
Все отчётные контроллеры идут паттерном «плоский pull + group в C#» — EF8 не
переводит `g.Select(...).Distinct().Count()` в SQL для group-проекций с
nullable join-ключами (cashier, retail-point). Объёмы отчётов (~десятки
тыс. строк/месяц) держатся в RAM спокойно; на крупных тенантах перейдём
на raw SQL/views (вне scope этого спринта).
### Bonus
Поймал и исправил баг `RetailSalesController.Update`: DbUpdateConcurrency
`expected 1 row, affected 0` воспроизводился на возвратах сразу после
`create-return`. Лечение — `ApplyLines` добавляет строки напрямую в DbSet
(а не через nav-collection), Update не делает `Include(Lines)`, старые
строки удаляются `ExecuteDelete`.
## Лог
- Каждый пункт: build + тесты + коммит порцией + отметка [x] + коммит прогресса.
- Все правки на `main` (origin Forgejo), без коммита `global.json`.

View file

@ -1,71 +0,0 @@
# Спринт 4 (частичный) — POS Sync API + observability
Автономная работа. WPF/POS-UI (`food-market.pos`, net8.0-windows) на Linux
не собирается — пропускаем. Здесь только API-сторона синхронизации
и метрики.
После каждого пункта: `dotnet build` (SDK 8.0.126), unit + integration
тесты, коммит порцией, отметка `[x]`, коммит прогресса.
## Чек-лист
1. [x] **P1-12a Контракты POS в food-market.shared** — DTO `ProductSyncDto`,
`PriceSyncDto`, `StockSyncDto`, `CounterpartySyncDto`, `PosSaleBatchDto`
(с idempotency-key). Версионирование через namespace v1.
`src/food-market.shared/Pos/V1/SyncDtos.cs` — все DTO как record'ы
с required-полями, конверты `PosSyncResponse` и `PosSaleBatchResponse`,
двойная идемпотентность (batch IdempotencyKey + per-sale ClientSaleId).
3 unit-теста на round-trip сериализации.
2. [x] **P1-12b POS Sync API**`GET /api/pos/sync?since={iso8601}` возвращает
изменения после ts (товары, цены, остатки, контрагенты). `POST /api/pos/sales`
принимает батч продаж с idempotency-key (повторный запрос возвращает прежний
результат без дублей). Multi-tenant: POS-токен ↔ один магазин.
`/api/pos/v1/sync` и `/api/pos/v1/sales` (URL-versioned). Двойная
идемпотентность: `PosBatchAck` (unique idx OrgId+Key, race ловится 23505)
+ ClientSaleId через маркер `pos:GUID32` в `RetailSale.Notes`. Pre-flight
на остатки — недостающие позиции попадают в `Failed`, остальные проводятся.
7 интеграционных тестов.
3. [x] **P1-17 Метрики Prometheus**`prometheus-net.AspNetCore`, `/metrics`.
`http_requests_total`, `http_request_duration_seconds`,
`db_query_duration_seconds` (через EF interceptor), бизнес:
`sales_posted_total`, `supplies_posted_total`, `documents_error_total`.
`docs/observability.md` с образцом Grafana dashboard.
`prometheus-net.AspNetCore@8.2.1` + `DbMetricsInterceptor` (singleton,
EF Core `DbCommandInterceptor`). Бизнес-счётчики в `AppMetrics`
инкрементятся в успешных Post'ах Sales/Supplies/POS. Endpoint
`/metrics` без auth. `docs/observability.md` с scrape-конфигом,
образцом Grafana и alert-rules (HighErrorRate / DbSerializationContention /
NoSalesIn30Min). 3 интеграционных теста.
## Итог
**Все 3 пункта выполнены.** Спринт 4 (POS Sync API + observability) завершён 2026-05-28.
Сводка:
- **P1-12a Контракты POS**`food-market.shared/Pos/V1/SyncDtos.cs`, версионирование
через namespace, двойная идемпотентность, 3 unit-теста.
- **P1-12b POS Sync API**`/api/pos/v1/sync` + `/api/pos/v1/sales` с batch-
идемпотентностью (`PosBatchAck`) + per-sale (`ClientSaleId` через `Notes`).
7 интеграционных тестов.
- **P1-17 Prometheus**`/metrics`, EF interceptor, бизнес-счётчики,
`docs/observability.md` с Grafana/alerts. 3 интеграционных теста.
**Сборка:** зелёная.
**Тесты:** 27 unit + 59 integration (49 sprint3 + 7 POS + 3 Metrics) **зелёные**.
**Web:** не трогали в этом спринте.
### POS-side notes
WPF/POS UI (`food-market.pos`, net8.0-windows) собирается только на Windows
с Windows SDK — оставлен как отдельный шаг на Windows-машине. Сервер
готов принимать кассу через `/api/pos/v1/*`.
### Что не сделано в спринте (требует внешних участников)
- ОФД-интеграция (нужен оператор данных — `Транском`/`Касса24`);
- MoySklad webhook-токены (production-grade приёмка нотификаций);
- Прод-деплой stage→prod (нужен пользователь для проверки stage сначала);
- WPF/POS UI (Windows-машина с SDK).
## Лог
- Каждый пункт: build + тесты + коммит порцией + отметка [x] + коммит прогресса.
- Все правки на `main` (origin Forgejo), без коммита `global.json`.

View file

@ -1,77 +0,0 @@
# Спринт 5 — оптовые продажи, email, аудит, надёжность import-job
Автономная работа. После каждого пункта: `dotnet build` (SDK 8.0.126),
unit + integration тесты, коммит порцией, отметка `[x]`, коммит прогресса.
Multi-tenant: все новые сущности — `TenantEntity` с `OrganizationId` +
query filter. E2E на изоляцию A vs B где применимо.
## Чек-лист
1. [x] **P1-5 Оптовая отгрузка (Demand)** — Domain `Demand`+`DemandLine`
(CounterpartyId юрлица, способ оплаты нал/безнал, цена опт., НДС). EF +
миграция. Контроллер CRUD + Post/Unpost. Web `/sales/demands`.
`StockMovement` тип `WholesaleSale`. Multi-tenant. Тесты.
✅ Зеркалит RetailSale без RetailPoint/Cashier; `DemandPayment.Credit`
(постоплата/дебиторка), `PaidAmount` для отслеживания. Permissions
переиспользуют существующие `DemandsEdit/Post`. Метрики
`documents_posted{type="demand"}`. 3 интеграционных теста.
2. [x] **P1-18 Аудит мутаций tenant'а** — Domain `OrgAuditLog` (как
`SuperAdminAuditLog`, но per-org). Hook через EF SaveChangesInterceptor
на Supply/Sale/Demand/Product/Counterparty. UI: `/audit-log` для админа.
Multi-tenant строго. Тесты.
`OrgAuditInterceptor` снимает diff на `SavingChanges` (до commit) —
атомарно с мутацией. ChangesJson: `{"field":{"before":X,"after":Y}}`.
Белый список типов: Supply/SupplierReturn/RetailSale/Demand/Product/
ProductPrice/ProductBarcode/Counterparty. Web `/audit-log` с фильтрами
и diff-viewer'ом. Tenant-isolation через query-filter. 3 интеграционных.
3. [x] **P1-22 Email-шаблоны** — расширить MailKit-сервис:
`Resources/EmailTemplates/*.html`. Шаблоны: приглашение сотрудника с
временным паролем (sendInvite=true), еженедельный summary владельцу
(Hangfire recurring), low-stock alert (Hangfire daily). Тесты рендеринга.
`IEmailSender.SendHtmlAsync` (multipart/alternative с plain fallback);
`EmailTemplateRenderer` (mustache-light: `{{key}}` escape, `{{{raw}}}`,
`{{#key}}…{{/key}}` условие); `EmailTemplates` загружает embedded
`Resources/EmailTemplates/*.html` (Subject: первой строкой). Шаблоны:
invite/weekly-summary/low-stock. Hangfire: weekly понедельник 07:00,
low-stock ежедневно 08:00. 8 unit-тестов.
4. [x] **TD-5 ImportJobRegistry в БД** — сейчас in-memory `ConcurrentDictionary`,
теряется при рестарте. Перевод на таблицу `ImportJobs` (Id, OrgId, Status,
Progress, Total, StartedAt, FinishedAt, Errors JSON). Миграция.
`MoySkladImportController` использует. Тесты.
`Domain.Integrations.ImportJob` (TenantEntity); миграция `Phase8c_ImportJobs`.
`ImportJobRegistry` теперь IServiceScopeFactory-backed: `Create` пишет
строку немедленно, `SaveAsync` обновляет, `Get/RecentlyFinished` читают
из БД. Контроллер `MoySkladImportController.RunInBackgroundAsync` дополнен
periodic flush через Timer (каждые 2 сек) + финальный flush в finally —
UI видит реальный прогресс, terminal-state сохраняется через рестарт.
3 интеграционных теста (survives across scope, RecentlyFinished, tenant-isolation).
## Итог
**Все 4 пункта выполнены.** Спринт 5 завершён 2026-05-28.
Сводка:
- **P1-5 Demand** — оптовая отгрузка контрагенту-юрлицу с `DemandPayment.Credit`
и `PaidAmount`-полем; 3 интеграционных.
- **P1-18 OrgAuditLog** — per-tenant журнал через `SaveChangesInterceptor`,
atomic с мутацией, diff в jsonb; 3 интеграционных.
- **P1-22 Email**`IEmailSender.SendHtmlAsync` (multipart) + mustache-light
renderer + 3 шаблона + 2 recurring Hangfire-джоба; 8 unit.
- **TD-5 ImportJob** — persistence в БД, переживает рестарт, periodic flush
для live-прогресса; 3 интеграционных.
**Сборка:** зелёная.
**Тесты:** 35 unit + 68 integration = **103 зелёных**.
### Что осталось вне scope
- ОФД-оператор (нужен внешний участник: `Транском`/`Касса24`);
- MoySklad webhook-токены прод;
- WPF/POS UI (Windows-SDK);
- Стейдж→прод-деплой;
- Real SMTP-сервер для прод (Mailgun/Sendgrid/etc).
## Лог
- Каждый пункт: build + тесты + коммит порцией + отметка [x] + коммит прогресса.
- Все правки на `main` (origin Forgejo), без коммита `global.json`.

View file

@ -1,77 +0,0 @@
# Спринт 6 — технический долг + 2FA
Автономная работа. После каждого пункта: `dotnet build` (SDK 8.0.126),
unit + integration тесты, коммит порцией, отметка `[x]`, коммит прогресса.
Не ломать auth. НЕ трогать global.json/POS/nginx/ОФД.
## Чек-лист
1. [x] **TD-6 Concurrency-токены на документах**`RowVersion` (PostgreSQL
`xmin` через `IsRowVersion`) на Supply/Demand/RetailSale/Transfer/Inventory.
Миграция. EF concurrency check. 409 при конфликте. Тест: два параллельных
PUT → один 200, другой 409.
✅ Postgres `xmin` (system column, без миграции) через
`UseXminAsConcurrencyToken` для 5 документов + `IVersionedEntity`-marker.
SuppliesController PUT принимает `Xmin`, сверяет, 409 при mismatch.
Bonus: Supply.Update перешёл на ExecuteDelete+AddRange (тот же fix что
в RetailSale). 2 интеграционных теста.
2. [x] **TD-2 FluentValidation**`FluentValidation.AspNetCore`, validator'ы
для SupplyInputDto/RetailSaleInputDto/ProductInputDto/CounterpartyInputDto/
EmployeeInputDto. Auto-register. Тесты на каждый.
Не используем deprecated `FluentValidation.AspNetCore` — текущая
рекомендация: `AddValidatorsFromAssemblyContaining<Program>()` + кастомный
`ValidationFilter` (IAsyncActionFilter). На неуспех → 400 ValidationProblemDetails.
5 validator'ов, 16 unit-тестов, 70 integration зелёных без регрессий.
3. [x] **TD-4 Структурные log-fields в Serilog**`LogContext.PushProperty`
в middleware: OrgId, UserId, CorrelationId. Бизнес-логи (Supply.Post,
Sale.Post) — структурно. `docs/logging.md`.
`LogEnrichmentMiddleware` после Authentication. CorrelationId из заголовка
`X-Correlation-ID` или генерируется. Business-логи на Supply.Post /
RetailSale.Post с именованными плейсхолдерами. `docs/logging.md`
с паттерном + анти-паттернами (string interpolation, PII в логах).
4. [x] **TD-1 CQRS partial (MediatR)**`CreateSupplyCommand`,
`PostRetailSaleCommand`, `GetSalesReportQuery`. Показать паттерн, не
полный рефакторинг. Тесты на handlers.
✅ MediatR подключён в `food-market.api` с авторегистрацией из
`food-market.application`. 3 handler-образца с абстракциями
(`ISupplyWriter`, `IRetailSalePoster`) — testable без EF/БД.
Контроллеры остались на прежнем flow (поэтапная миграция). 6 unit-тестов.
5. [x] **P2-4 2FA для админов (TOTP)**`AuthenticatorTokenProvider`,
endpoints `/api/me/2fa/enroll`, `/api/me/2fa/verify`, `/api/me/2fa/disable`.
Опционально для Admin+SuperAdmin. При логине с включённым 2FA — два шага.
✅ Identity `AuthenticatorTokenProvider` (RFC 6238). Endpoints:
/api/me/2fa/{status, enroll, verify, disable} с QR-URI. Password-grant
на `/connect/token` принимает custom param `otp_code`; при включённом
2FA без него — `invalid_grant` с `error_description=2fa_required`.
4 интеграционных теста (тест сам генерит TOTP через RFC 6238).
## Итог
**Все 5 пунктов выполнены.** Спринт 6 завершён 2026-05-28.
Сводка:
- **TD-6 RowVersion** — Postgres `xmin` через `UseXminAsConcurrencyToken`
на 5 документах, 409 при conflict. Bonus: исправил Supply.Update.
- **TD-2 FluentValidation**`ValidationFilter` + 5 validator'ов
(Supply, RetailSale, Product, Counterparty, Employee).
- **TD-4 Structured logging**`LogEnrichmentMiddleware` (OrgId/UserId/
CorrelationId в LogContext), business-логи на Post-операциях.
- **TD-1 CQRS partial** — MediatR + 3 handler-образца с testable-абстракциями.
- **P2-4 TOTP 2FA** — endpoints + интеграция в password-grant.
**Сборка:** зелёная.
**Тесты:** 57 unit + 74 integration = **131 зелёных**.
### Что осталось вне scope автономной работы
- ОФД-оператор (`Транском`/`Касса24`),
- MoySklad webhook-токены прод,
- WPF/POS UI на Windows,
- Стейдж→прод-деплой,
- Реальный SMTP-провайдер прод (Mailgun/Sendgrid),
- Backup-коды для 2FA recovery (отдельная задача).
## Лог
- Каждый пункт: build + тесты + коммит порцией + отметка [x] + коммит прогресса.
- Все правки на `main` (origin Forgejo), без коммита `global.json`.

View file

@ -19,12 +19,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "food-market.pos.core", "src
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "food-market.pos", "src\food-market.pos\food-market.pos.csproj", "{B178B74E-A739-4722-BFA8-D9AB694024BB}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "food-market.pos", "src\food-market.pos\food-market.pos.csproj", "{B178B74E-A739-4722-BFA8-D9AB694024BB}"
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{F29E3026-31A5-4277-A265-081E87C76A28}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "food-market.UnitTests", "tests\food-market.UnitTests\food-market.UnitTests.csproj", "{D8556BE1-70E3-49AD-84FD-209C80B17B57}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "food-market.IntegrationTests", "tests\food-market.IntegrationTests\food-market.IntegrationTests.csproj", "{9122ECF4-9111-40B3-BB59-ED7C112FF575}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@ -62,14 +56,6 @@ Global
{B178B74E-A739-4722-BFA8-D9AB694024BB}.Debug|Any CPU.Build.0 = Debug|Any CPU {B178B74E-A739-4722-BFA8-D9AB694024BB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B178B74E-A739-4722-BFA8-D9AB694024BB}.Release|Any CPU.ActiveCfg = Release|Any CPU {B178B74E-A739-4722-BFA8-D9AB694024BB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B178B74E-A739-4722-BFA8-D9AB694024BB}.Release|Any CPU.Build.0 = Release|Any CPU {B178B74E-A739-4722-BFA8-D9AB694024BB}.Release|Any CPU.Build.0 = Release|Any CPU
{D8556BE1-70E3-49AD-84FD-209C80B17B57}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D8556BE1-70E3-49AD-84FD-209C80B17B57}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D8556BE1-70E3-49AD-84FD-209C80B17B57}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D8556BE1-70E3-49AD-84FD-209C80B17B57}.Release|Any CPU.Build.0 = Release|Any CPU
{9122ECF4-9111-40B3-BB59-ED7C112FF575}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9122ECF4-9111-40B3-BB59-ED7C112FF575}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9122ECF4-9111-40B3-BB59-ED7C112FF575}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9122ECF4-9111-40B3-BB59-ED7C112FF575}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(NestedProjects) = preSolution GlobalSection(NestedProjects) = preSolution
{BB7142C2-94F3-423F-938C-A44FF79133C0} = {8EAE9F35-BE6B-4B77-A1F4-383EF17D9870} {BB7142C2-94F3-423F-938C-A44FF79133C0} = {8EAE9F35-BE6B-4B77-A1F4-383EF17D9870}
@ -79,7 +65,5 @@ Global
{9E075C56-081E-4ABB-8DB3-ED649FD696FA} = {8EAE9F35-BE6B-4B77-A1F4-383EF17D9870} {9E075C56-081E-4ABB-8DB3-ED649FD696FA} = {8EAE9F35-BE6B-4B77-A1F4-383EF17D9870}
{BF3FBFD2-F40D-4510-8067-37305FFE1D14} = {8EAE9F35-BE6B-4B77-A1F4-383EF17D9870} {BF3FBFD2-F40D-4510-8067-37305FFE1D14} = {8EAE9F35-BE6B-4B77-A1F4-383EF17D9870}
{B178B74E-A739-4722-BFA8-D9AB694024BB} = {8EAE9F35-BE6B-4B77-A1F4-383EF17D9870} {B178B74E-A739-4722-BFA8-D9AB694024BB} = {8EAE9F35-BE6B-4B77-A1F4-383EF17D9870}
{D8556BE1-70E3-49AD-84FD-209C80B17B57} = {F29E3026-31A5-4277-A265-081E87C76A28}
{9122ECF4-9111-40B3-BB59-ED7C112FF575} = {F29E3026-31A5-4277-A265-081E87C76A28}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal

View file

@ -1,176 +0,0 @@
using foodmarket.Api.Infrastructure.Email;
using foodmarket.Api.Infrastructure.Tenancy;
using foodmarket.Application.Common.Email;
using foodmarket.Domain.Sales;
using foodmarket.Infrastructure.Identity;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Background;
/// <summary>Hangfire-джобы рассылки писем: weekly-summary (понедельник 07:00)
/// и low-stock (ежедневно 08:00). Бегут per-org через IgnoreQueryFilters +
/// AsyncLocal-tenant override для каждой обрабатываемой организации
/// (см. HttpContextTenantContext.UseOverride).
///
/// Каждая ошибка отправки логируется, но не валит весь джоб — продолжаем по
/// следующим оргам. Идемпотентность по неделе/дню — не реализована: повтор
/// джоба тем же утром обогатит почту админам, что приемлемо для MVP.</summary>
public class EmailNotificationJobs
{
private readonly AppDbContext _db;
private readonly IEmailSender _email;
private readonly EmailTemplates _templates;
private readonly UserManager<User> _users;
private readonly IConfiguration _cfg;
private readonly ILogger<EmailNotificationJobs> _log;
public EmailNotificationJobs(AppDbContext db, IEmailSender email, EmailTemplates templates,
UserManager<User> users, IConfiguration cfg, ILogger<EmailNotificationJobs> log)
{
_db = db; _email = email; _templates = templates; _users = users; _cfg = cfg; _log = log;
}
/// <summary>Weekly summary: владельцу каждой орги — выручка / транзакции /
/// топ-5 товаров за последние 7 дней. Cron в HangfireJobsConfigurator —
/// "0 7 * * 1" (понедельник 07:00 UTC).</summary>
public async Task SendWeeklySummariesAsync(CancellationToken ct = default)
{
// `from` — это LINQ-ключевое слово, в query-синтаксе ломает парсер
// даже как имя переменной (CS1525); используем fromDate/toDate.
var toDate = DateTime.UtcNow;
var fromDate = toDate.AddDays(-7);
var orgs = await _db.Organizations.IgnoreQueryFilters()
.Where(o => !o.IsArchived)
.Select(o => new { o.Id, o.Name })
.ToListAsync(ct);
foreach (var org in orgs)
{
try
{
using var scope = HttpContextTenantContext.UseOverride(org.Id, isSuperAdmin: false);
var revenueRaw = await _db.RetailSales
.Where(s => s.Status == RetailSaleStatus.Posted && s.Date >= fromDate && s.Date <= toDate)
.GroupBy(_ => 1)
.Select(g => new { Sum = g.Sum(s => s.IsReturn ? -s.Total : s.Total), Count = g.Count() })
.FirstOrDefaultAsync(ct);
var revenue = revenueRaw?.Sum ?? 0m;
var tx = revenueRaw?.Count ?? 0;
var avgTicket = tx == 0 ? 0m : decimal.Round(revenue / tx, 2);
// Топ-5 по выручке.
var topRaw = await (from l in _db.RetailSaleLines.AsNoTracking()
join s in _db.RetailSales.AsNoTracking() on l.RetailSaleId equals s.Id
join p in _db.Products.AsNoTracking() on l.ProductId equals p.Id
where s.Status == RetailSaleStatus.Posted
&& s.Date >= fromDate && s.Date <= toDate
select new
{
p.Name,
Revenue = s.IsReturn ? -l.LineTotal : l.LineTotal,
Qty = s.IsReturn ? -l.Quantity : l.Quantity,
}).ToListAsync(ct);
var top = topRaw.GroupBy(x => x.Name)
.Select(g => new { Name = g.Key, Revenue = g.Sum(x => x.Revenue), Qty = g.Sum(x => x.Qty) })
.OrderByDescending(x => x.Revenue).Take(5).ToList();
var topHtml = top.Count == 0 ? "" :
"<table style=\"border-collapse:collapse;width:100%;\"><tr><th align=\"left\">Товар</th><th align=\"right\">Кол-во</th><th align=\"right\">Выручка</th></tr>"
+ string.Join("", top.Select(t =>
$"<tr><td>{System.Net.WebUtility.HtmlEncode(t.Name)}</td><td align=\"right\">{t.Qty:0.##}</td><td align=\"right\">{t.Revenue:0.##}</td></tr>"))
+ "</table>";
var recipients = await GetOwnerEmailsAsync(org.Id, ct);
if (recipients.Count == 0) continue;
var (subject, html, text) = _templates.Render("weekly-summary", new Dictionary<string, string?>
{
["organizationName"] = org.Name,
["periodFrom"] = fromDate.ToString("yyyy-MM-dd"),
["periodTo"] = toDate.ToString("yyyy-MM-dd"),
["revenue"] = revenue.ToString("0.##"),
["transactions"] = tx.ToString(),
["avgTicket"] = avgTicket.ToString("0.##"),
["topProductsHtml"] = topHtml,
["reportsUrl"] = $"{_cfg["App:PublicUrl"]}/reports/sales",
});
foreach (var to_ in recipients)
await _email.SendHtmlAsync(to_, subject, html, text, ct);
_log.LogInformation("Weekly summary отправлен для {Org} → {Count} получателей",
org.Name, recipients.Count);
}
catch (Exception ex)
{
_log.LogWarning(ex, "Weekly summary upset для org {OrgId}", org.Id);
}
}
}
/// <summary>Low-stock: владельцу каждой орги — список товаров,
/// у которых quantity &lt; MinStock. Cron — "0 8 * * *" (ежедневно 08:00 UTC).</summary>
public async Task SendLowStockAlertsAsync(CancellationToken ct = default)
{
var orgs = await _db.Organizations.IgnoreQueryFilters()
.Where(o => !o.IsArchived).Select(o => new { o.Id, o.Name }).ToListAsync(ct);
foreach (var org in orgs)
{
try
{
using var scope = HttpContextTenantContext.UseOverride(org.Id, isSuperAdmin: false);
// Считаем по сумме остатков на всех складах vs MinStock.
var lowItems = await (from p in _db.Products.AsNoTracking()
where p.MinStock != null
let stockSum = _db.Stocks.Where(s => s.ProductId == p.Id).Sum(s => (decimal?)s.Quantity) ?? 0m
where stockSum < p.MinStock
orderby (decimal)(p.MinStock! - stockSum) descending
select new { p.Name, p.Article, MinStock = p.MinStock!.Value, Stock = stockSum })
.Take(50).ToListAsync(ct);
if (lowItems.Count == 0) continue;
var recipients = await GetOwnerEmailsAsync(org.Id, ct);
if (recipients.Count == 0) continue;
var itemsHtml = "<table style=\"border-collapse:collapse;width:100%;\"><tr><th align=\"left\">Товар</th><th align=\"left\">Артикул</th><th align=\"right\">Остаток</th><th align=\"right\">Минимум</th></tr>"
+ string.Join("", lowItems.Select(i =>
$"<tr><td>{System.Net.WebUtility.HtmlEncode(i.Name)}</td><td>{System.Net.WebUtility.HtmlEncode(i.Article ?? "")}</td><td align=\"right\" style=\"color:#b91c1c;\">{i.Stock:0.##}</td><td align=\"right\">{i.MinStock:0.##}</td></tr>"))
+ "</table>";
var (subject, html, text) = _templates.Render("low-stock", new Dictionary<string, string?>
{
["organizationName"] = org.Name,
["productCount"] = lowItems.Count.ToString(),
["itemsHtml"] = itemsHtml,
["stockUrl"] = $"{_cfg["App:PublicUrl"]}/inventory/stock",
});
foreach (var to_ in recipients)
await _email.SendHtmlAsync(to_, subject, html, text, ct);
_log.LogInformation("Low-stock alert для {Org}: {Count} товаров → {Recipients} получателей",
org.Name, lowItems.Count, recipients.Count);
}
catch (Exception ex)
{
_log.LogWarning(ex, "Low-stock upset для org {OrgId}", org.Id);
}
}
}
private async Task<List<string>> GetOwnerEmailsAsync(Guid orgId, CancellationToken ct)
{
// Получатели — активные User'ы орги с ролью Admin (через Identity-маппинг).
// Не Employee, а User: у Employee email — контактный, у User — для входа.
var adminUserIds = await _db.UserRoles.AsNoTracking()
.Join(_db.Roles.AsNoTracking(), ur => ur.RoleId, r => r.Id, (ur, r) => new { ur.UserId, r.Name })
.Where(x => x.Name == "Admin")
.Select(x => x.UserId)
.ToListAsync(ct);
return await _db.Users.IgnoreQueryFilters()
.Where(u => adminUserIds.Contains(u.Id) && u.OrganizationId == orgId
&& u.IsActive && u.Email != null)
.Select(u => u.Email!)
.ToListAsync(ct);
}
}

View file

@ -1,66 +0,0 @@
using Hangfire;
namespace foodmarket.Api.Background;
/// <summary>Регистрирует recurring jobs Hangfire при старте приложения.
/// Идемпотентно: RecurringJob.AddOrUpdate перетирает существующее
/// определение, так что миграции job'ов проходят без ручной очистки таблицы
/// <c>hangfire.recurring_jobs</c>.
///
/// Cron-выражения берутся из конфига (<c>Hangfire:Cron:*</c>) с дефолтом
/// «каждый день в 03:30 UTC» для cleanup-джобов — за 30 минут до бэкапа,
/// чтобы бэкап не цеплял временные блокировки PruneStockMovements.</summary>
public class HangfireJobsConfigurator : IHostedService
{
private readonly IRecurringJobManager _jobs;
private readonly IConfiguration _cfg;
public HangfireJobsConfigurator(IRecurringJobManager jobs, IConfiguration cfg)
{
_jobs = jobs;
_cfg = cfg;
}
public Task StartAsync(CancellationToken ct)
{
// Cron в Hangfire — стандартный 5-полевой (UTC по умолчанию).
var cronStock = _cfg["Hangfire:Cron:PruneStockMovements"] ?? "30 3 * * *";
var cronAudit = _cfg["Hangfire:Cron:PruneAuditLog"] ?? "45 3 * * *";
_jobs.AddOrUpdate<HousekeepingJobs>(
recurringJobId: "prune-stock-movements",
methodCall: j => j.PruneStockMovementsAsync(CancellationToken.None),
cronExpression: cronStock,
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
_jobs.AddOrUpdate<HousekeepingJobs>(
recurringJobId: "prune-audit-log",
methodCall: j => j.PruneAuditLogAsync(CancellationToken.None),
cronExpression: cronAudit,
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
// Email-уведомления: weekly-summary в понедельник 07:00 UTC,
// low-stock каждый день в 08:00 UTC (после weekly чтобы не дублить
// если оба совпадают). Cron-ы конфигурируются — на тестовом стенде
// имеет смысл сдвинуть/отключить через "off" специальное значение
// (не реализовано, пока просто кастомные cron).
var cronWeekly = _cfg["Hangfire:Cron:WeeklySummary"] ?? "0 7 * * 1";
var cronLowStock = _cfg["Hangfire:Cron:LowStockAlert"] ?? "0 8 * * *";
_jobs.AddOrUpdate<EmailNotificationJobs>(
recurringJobId: "weekly-summary",
methodCall: j => j.SendWeeklySummariesAsync(CancellationToken.None),
cronExpression: cronWeekly,
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
_jobs.AddOrUpdate<EmailNotificationJobs>(
recurringJobId: "low-stock-alert",
methodCall: j => j.SendLowStockAlertsAsync(CancellationToken.None),
cronExpression: cronLowStock,
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}

View file

@ -1,64 +0,0 @@
using foodmarket.Domain.Inventory;
using foodmarket.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Background;
/// <summary>Hangfire-джобы для регулярной чистки исторических данных:
/// <c>StockMovement</c> старше 2 лет (компенсирует разрастание таблицы при
/// большом обороте продаж), <c>SuperAdminAuditLog</c> старше 90 дней. Период
/// можно поменять через конфиг <c>Hangfire:Retention:*</c>.
///
/// Реализованы как scoped-сервис, чтобы Hangfire разрешил <see cref="AppDbContext"/>
/// и фильтр tenant'а корректно (контекст НЕТ tenant'а — но cleanup идёт без
/// tenant-фильтра через IgnoreQueryFilters, потому что это межтенантная задача).</summary>
public class HousekeepingJobs
{
private readonly AppDbContext _db;
private readonly IConfiguration _cfg;
private readonly ILogger<HousekeepingJobs> _log;
public HousekeepingJobs(AppDbContext db, IConfiguration cfg, ILogger<HousekeepingJobs> log)
{
_db = db;
_cfg = cfg;
_log = log;
}
/// <summary>Удаляет <see cref="StockMovement"/> старше N дней (по умолчанию 730).
/// Stock-инвариант (Stock.Quantity ≡ Σ StockMovement) ломается на удалённых
/// записях, поэтому удаляем ТОЛЬКО действительно старые движения — там
/// невозможно сослаться через unpost (документы уже закрыты).
///
/// Возвращает количество удалённых строк (для логов/мониторинга).</summary>
public async Task<int> PruneStockMovementsAsync(CancellationToken ct = default)
{
var days = _cfg.GetValue("Hangfire:Retention:StockMovementDays", 730);
var threshold = DateTime.UtcNow.AddDays(-days);
var deleted = await _db.StockMovements
.IgnoreQueryFilters()
.Where(m => m.OccurredAt < threshold)
.ExecuteDeleteAsync(ct);
_log.LogInformation("Hangfire/PruneStockMovements: удалено {Count} движений старше {Threshold:O}",
deleted, threshold);
return deleted;
}
/// <summary>Удаляет <c>super_admin_audit_log</c> старше N дней (по умолчанию 90).
/// SuperAdmin'ские действия в каждой орге — отдельный аудит, для compliance
/// храним недолго (квартал) и зачищаем.</summary>
public async Task<int> PruneAuditLogAsync(CancellationToken ct = default)
{
var days = _cfg.GetValue("Hangfire:Retention:AuditLogDays", 90);
var threshold = DateTime.UtcNow.AddDays(-days);
var deleted = await _db.SuperAdminAuditLogs
.IgnoreQueryFilters()
.Where(a => a.CreatedAt < threshold)
.ExecuteDeleteAsync(ct);
_log.LogInformation("Hangfire/PruneAuditLog: удалено {Count} записей старше {Threshold:O}",
deleted, threshold);
return deleted;
}
}

View file

@ -1,22 +0,0 @@
using Hangfire.Dashboard;
namespace foodmarket.Api.Background;
/// <summary>Авторизационный фильтр Hangfire Dashboard. Пускает только
/// аутентифицированных пользователей с ролью SuperAdmin. ClaimsPrincipal
/// берётся из <c>HttpContext.User</c> — то есть стандартный OpenIddict-токен
/// (Bearer) валидируется до этой проверки middleware'ом аутентификации.
///
/// Hangfire по умолчанию пускает только loopback — мы хотим строже:
/// доступ к дашборду = доступ к фоновым джобам всех тенантов, что эквивалентно
/// SuperAdmin-консоли в Web-админке.</summary>
public class SuperAdminHangfireFilter : IDashboardAuthorizationFilter
{
public bool Authorize(DashboardContext context)
{
var http = context.GetHttpContext();
var user = http.User;
if (user?.Identity?.IsAuthenticated != true) return false;
return user.IsInRole("SuperAdmin");
}
}

View file

@ -138,13 +138,6 @@ public ActionResult<object> WipeAllAsync()
finally finally
{ {
job.FinishedAt = DateTime.UtcNow; job.FinishedAt = DateTime.UtcNow;
// Финальный flush в БД (persisted progress, TD-5).
try
{
using var s = HttpContextTenantContext.UseOverride(orgId);
await _jobs.SaveAsync(job);
}
catch { /* swallow */ }
} }
}); });
return Ok(new { jobId = job.Id }); return Ok(new { jobId = job.Id });

View file

@ -124,20 +124,6 @@ public async Task<ActionResult<object>> ImportCounterparties([FromBody] ImportRe
{ {
return Task.Run(async () => return Task.Run(async () =>
{ {
// Периодический flush snapshot'а в БД каждые 2 секунды — чтобы UI
// видел actual прогресс (Stage/Created/Total), а не Create-time
// запись до самого финиша. AsyncLocal-override активен в callback'е,
// потому что Timer вызывает наш delegate в этом же ExecutionContext'е.
using var flushTimer = new Timer(_ =>
{
try
{
using var s = HttpContextTenantContext.UseOverride(orgId);
_ = _jobs.SaveAsync(job);
}
catch { /* swallow — следующий тик попробует снова */ }
}, null, dueTime: TimeSpan.FromSeconds(2), period: TimeSpan.FromSeconds(2));
try try
{ {
using var tenantScope = HttpContextTenantContext.UseOverride(orgId); using var tenantScope = HttpContextTenantContext.UseOverride(orgId);
@ -155,14 +141,6 @@ public async Task<ActionResult<object>> ImportCounterparties([FromBody] ImportRe
finally finally
{ {
job.FinishedAt = DateTime.UtcNow; job.FinishedAt = DateTime.UtcNow;
// Финальный flush — обязательный, иначе после рестарта job
// останется со статусом Running навсегда.
try
{
using var tenantScope2 = HttpContextTenantContext.UseOverride(orgId);
await _jobs.SaveAsync(job);
}
catch { /* registry сам логирует */ }
} }
}); });
} }

View file

@ -1,61 +0,0 @@
using foodmarket.Application.Common;
using foodmarket.Infrastructure.Persistence;
using foodmarket.Api.Infrastructure.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Admin;
/// <summary>Чтение per-tenant журнала мутаций. Запись — автоматическая через
/// <c>OrgAuditInterceptor</c>; этот контроллер только показывает. Доступ —
/// admin'у организации (через permission OrgSettingsManage). SuperAdmin
/// видит всё (через query-filter bypass + IsSuperAdmin).</summary>
[ApiController]
[Authorize]
[Route("api/admin/audit-log")]
public class OrgAuditLogController : ControllerBase
{
private readonly AppDbContext _db;
public OrgAuditLogController(AppDbContext db) => _db = db;
public record AuditRow(
Guid Id, DateTime CreatedAt,
Guid? UserId, string? UserName,
string Action, string EntityType, Guid? EntityId,
string ChangesJson);
[HttpGet, RequiresPermission("OrgSettingsManage")]
public async Task<ActionResult<PagedResult<AuditRow>>> List(
[FromQuery] PagedRequest req,
[FromQuery] string? entityType,
[FromQuery] Guid? entityId,
[FromQuery] Guid? userId,
[FromQuery] string? action,
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
CancellationToken ct)
{
var q = _db.OrgAuditLogs.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(entityType)) q = q.Where(l => l.EntityType == entityType);
if (entityId is not null) q = q.Where(l => l.EntityId == entityId);
if (userId is not null) q = q.Where(l => l.UserId == userId);
if (!string.IsNullOrWhiteSpace(action)) q = q.Where(l => l.Action == action);
if (from is not null) q = q.Where(l => l.CreatedAt >= from);
if (to is not null) q = q.Where(l => l.CreatedAt <= to);
var total = await q.CountAsync(ct);
var items = await (from l in q.OrderByDescending(l => l.CreatedAt)
.Skip(req.Skip).Take(req.Take)
join u in _db.Users.AsNoTracking() on l.UserId equals u.Id into uj
from u in uj.DefaultIfEmpty()
select new AuditRow(
l.Id, l.CreatedAt,
l.UserId, u != null ? u.FullName : null,
l.Action, l.EntityType, l.EntityId,
l.ChangesJson))
.ToListAsync(ct);
return new PagedResult<AuditRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
}

View file

@ -48,26 +48,6 @@ public async Task<IActionResult> Exchange()
return BadRequestError(Errors.InvalidGrant, "Неверный логин или пароль."); return BadRequestError(Errors.InvalidGrant, "Неверный логин или пароль.");
} }
// 2FA: если у юзера включён TOTP, требуем otp_code в запросе
// (custom parameter, OpenIddict передаёт его через request['otp_code']).
// Это второй шаг password-flow без редиректа: клиент видит
// invalid_grant + error_description="2fa_required" и шлёт ту же
// password-форму ещё раз с otp_code.
if (await _userManager.GetTwoFactorEnabledAsync(user))
{
var otp = request["otp_code"]?.Value?.ToString();
if (string.IsNullOrWhiteSpace(otp))
{
return BadRequestError(Errors.InvalidGrant, "2fa_required");
}
var validProvider = _userManager.Options.Tokens.AuthenticatorTokenProvider;
var ok = await _userManager.VerifyTwoFactorTokenAsync(user, validProvider, otp);
if (!ok)
{
return BadRequestError(Errors.InvalidGrant, "2fa_invalid");
}
}
var rejection = await CheckUserStillBelongsToLiveOrgAsync(user); var rejection = await CheckUserStillBelongsToLiveOrgAsync(user);
if (rejection is not null) return rejection; if (rejection is not null) return rejection;
@ -91,20 +71,6 @@ public async Task<IActionResult> Exchange()
if (rejection is not null) return rejection; if (rejection is not null) return rejection;
var principal = await CreatePrincipal(user, request.GetScopes()); var principal = await CreatePrincipal(user, request.GetScopes());
// Прокидываем внутренние OpenIddict-claims погашаемого refresh-token
// в новый principal. Ключевой — TokenId: handler RedeemTokenEntry
// читает его из подписываемого principal, чтобы понять, какой именно
// refresh-token пометить Redeemed. Без него ротация не гасит старый
// токен — он остаётся valid и допускает повторное использование
// (одна утечка refresh = вечный доступ). AuthorizationId связывает
// новый токен с той же авторизацией, чтобы не плодить дубли.
var tokenId = info.Principal?.GetTokenId();
if (!string.IsNullOrEmpty(tokenId))
principal.SetTokenId(tokenId);
var authzId = info.Principal?.GetAuthorizationId();
if (!string.IsNullOrEmpty(authzId))
principal.SetAuthorizationId(authzId);
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
} }

View file

@ -2,7 +2,6 @@
using foodmarket.Application.Common; using foodmarket.Application.Common;
using foodmarket.Domain.Catalog; using foodmarket.Domain.Catalog;
using foodmarket.Infrastructure.Persistence; using foodmarket.Infrastructure.Persistence;
using foodmarket.Api.Infrastructure.Authorization;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -70,7 +69,7 @@ public async Task<ActionResult<CounterpartyDto>> Get(Guid id, CancellationToken
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes); c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes);
} }
[HttpPost, RequiresPermission("CounterpartiesEdit")] [HttpPost, Authorize(Roles = "Admin,Storekeeper")]
public async Task<ActionResult<CounterpartyDto>> Create([FromBody] CounterpartyInput input, CancellationToken ct) public async Task<ActionResult<CounterpartyDto>> Create([FromBody] CounterpartyInput input, CancellationToken ct)
{ {
if (NormalizePhoneOrError(input.Phone) is { } err) return BadRequest(new { error = err }); if (NormalizePhoneOrError(input.Phone) is { } err) return BadRequest(new { error = err });
@ -80,7 +79,7 @@ public async Task<ActionResult<CounterpartyDto>> Create([FromBody] CounterpartyI
return CreatedAtAction(nameof(Get), new { id = e.Id }, await ProjectAsync(e.Id, ct)); return CreatedAtAction(nameof(Get), new { id = e.Id }, await ProjectAsync(e.Id, ct));
} }
[HttpPut("{id:guid}"), RequiresPermission("CounterpartiesEdit")] [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")]
public async Task<IActionResult> Update(Guid id, [FromBody] CounterpartyInput input, CancellationToken ct) public async Task<IActionResult> Update(Guid id, [FromBody] CounterpartyInput input, CancellationToken ct)
{ {
if (NormalizePhoneOrError(input.Phone) is { } err) return BadRequest(new { error = err }); if (NormalizePhoneOrError(input.Phone) is { } err) return BadRequest(new { error = err });
@ -99,28 +98,11 @@ public async Task<IActionResult> Update(Guid id, [FromBody] CounterpartyInput in
: foodmarket.Application.Common.PhoneNormalization.ErrorMessage; : foodmarket.Application.Common.PhoneNormalization.ErrorMessage;
} }
[HttpDelete("{id:guid}"), RequiresPermission("CounterpartiesDelete")] [HttpDelete("{id:guid}"), Authorize(Roles = "Admin")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct) public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{ {
var e = await _db.Counterparties.FirstOrDefaultAsync(x => x.Id == id, ct); var e = await _db.Counterparties.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound(); if (e is null) return NotFound();
// FK-guard: counterparty используется как Supplier в supplies / customer
// в retail-sales / default supplier у products. Без явного чека EF
// отдаёт 500 «DbUpdateException 23503 violates foreign key constraint»
// вместо понятного 409 — пользователь не понимает что чинить.
var usedAsSupplier = await _db.Supplies.AnyAsync(s => s.SupplierId == id, ct);
var usedAsCustomer = await _db.RetailSales.AnyAsync(s => s.CustomerId == id, ct);
var usedAsDefault = await _db.Products.AnyAsync(p => p.DefaultSupplierId == id, ct);
if (usedAsSupplier || usedAsCustomer || usedAsDefault)
{
return Conflict(new
{
error = "Нельзя удалить контрагента: он используется в документах или товарах.",
usedAsSupplier, usedAsCustomer, usedAsDefault,
});
}
_db.Counterparties.Remove(e); _db.Counterparties.Remove(e);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return NoContent(); return NoContent();

View file

@ -2,7 +2,6 @@
using foodmarket.Application.Common; using foodmarket.Application.Common;
using foodmarket.Domain.Catalog; using foodmarket.Domain.Catalog;
using foodmarket.Infrastructure.Persistence; using foodmarket.Infrastructure.Persistence;
using foodmarket.Api.Infrastructure.Authorization;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -50,7 +49,7 @@ public async Task<ActionResult<PriceTypeDto>> Get(Guid id, CancellationToken ct)
return p is null ? NotFound() : new PriceTypeDto(p.Id, p.Name, p.IsRequired, p.IsSystem, p.IsRetail, p.SortOrder); return p is null ? NotFound() : new PriceTypeDto(p.Id, p.Name, p.IsRequired, p.IsSystem, p.IsRetail, p.SortOrder);
} }
[HttpPost, RequiresPermission("PriceTypesManage")] [HttpPost, Authorize(Roles = "Admin")]
public async Task<ActionResult<PriceTypeDto>> Create([FromBody] PriceTypeInput input, CancellationToken ct) public async Task<ActionResult<PriceTypeDto>> Create([FromBody] PriceTypeInput input, CancellationToken ct)
{ {
if (input.IsRetail) if (input.IsRetail)
@ -73,7 +72,7 @@ await _db.PriceTypes.Where(p => p.IsRetail)
new PriceTypeDto(e.Id, e.Name, e.IsRequired, e.IsSystem, e.IsRetail, e.SortOrder)); new PriceTypeDto(e.Id, e.Name, e.IsRequired, e.IsSystem, e.IsRetail, e.SortOrder));
} }
[HttpPut("{id:guid}"), RequiresPermission("PriceTypesManage")] [HttpPut("{id:guid}"), Authorize(Roles = "Admin")]
public async Task<IActionResult> Update(Guid id, [FromBody] PriceTypeInput input, CancellationToken ct) public async Task<IActionResult> Update(Guid id, [FromBody] PriceTypeInput input, CancellationToken ct)
{ {
var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, ct); var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, ct);
@ -93,7 +92,7 @@ await _db.PriceTypes.Where(p => p.IsRetail && p.Id != id)
return NoContent(); return NoContent();
} }
[HttpDelete("{id:guid}"), RequiresPermission("PriceTypesManage")] [HttpDelete("{id:guid}"), Authorize(Roles = "Admin")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct) public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{ {
var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, ct); var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, ct);

View file

@ -2,7 +2,6 @@
using foodmarket.Application.Common; using foodmarket.Application.Common;
using foodmarket.Domain.Catalog; using foodmarket.Domain.Catalog;
using foodmarket.Infrastructure.Persistence; using foodmarket.Infrastructure.Persistence;
using foodmarket.Api.Infrastructure.Authorization;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -57,7 +56,7 @@ public async Task<ActionResult<ProductGroupDto>> Get(Guid id, CancellationToken
return g is null ? NotFound() : new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.MarkupPercent, g.OrganizationId); return g is null ? NotFound() : new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.MarkupPercent, g.OrganizationId);
} }
[HttpPost, RequiresPermission("ProductGroupsManage")] [HttpPost, Authorize(Roles = "Admin")]
public async Task<ActionResult<ProductGroupDto>> Create([FromBody] ProductGroupInput input, CancellationToken ct) public async Task<ActionResult<ProductGroupDto>> Create([FromBody] ProductGroupInput input, CancellationToken ct)
{ {
var path = await BuildPathAsync(input.ParentId, input.Name, ct); var path = await BuildPathAsync(input.ParentId, input.Name, ct);
@ -73,7 +72,7 @@ public async Task<ActionResult<ProductGroupDto>> Create([FromBody] ProductGroupI
new ProductGroupDto(e.Id, e.Name, e.ParentId, e.Path, e.SortOrder, e.MarkupPercent, e.OrganizationId)); new ProductGroupDto(e.Id, e.Name, e.ParentId, e.Path, e.SortOrder, e.MarkupPercent, e.OrganizationId));
} }
[HttpPut("{id:guid}"), RequiresPermission("ProductGroupsManage")] [HttpPut("{id:guid}"), Authorize(Roles = "Admin,SuperAdmin")]
public async Task<IActionResult> Update(Guid id, [FromBody] ProductGroupInput input, CancellationToken ct) public async Task<IActionResult> Update(Guid id, [FromBody] ProductGroupInput input, CancellationToken ct)
{ {
var e = await _db.ProductGroups.FirstOrDefaultAsync(x => x.Id == id, ct); var e = await _db.ProductGroups.FirstOrDefaultAsync(x => x.Id == id, ct);
@ -92,7 +91,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] ProductGroupInput in
return NoContent(); return NoContent();
} }
[HttpDelete("{id:guid}"), RequiresPermission("ProductGroupsManage")] [HttpDelete("{id:guid}"), Authorize(Roles = "Admin,SuperAdmin")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct) public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{ {
var meta = await _db.ProductGroups var meta = await _db.ProductGroups

View file

@ -1,7 +1,6 @@
using foodmarket.Application.Common.Tenancy; using foodmarket.Application.Common.Tenancy;
using foodmarket.Domain.Catalog; using foodmarket.Domain.Catalog;
using foodmarket.Infrastructure.Persistence; using foodmarket.Infrastructure.Persistence;
using foodmarket.Api.Infrastructure.Authorization;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -50,7 +49,7 @@ public async Task<ActionResult<IReadOnlyList<ImageDto>>> List(Guid productId, Ca
return images; return images;
} }
[HttpPost, RequiresPermission("ProductsEdit")] [HttpPost, Authorize(Roles = "Admin,Storekeeper")]
[RequestSizeLimit(MaxBytes)] [RequestSizeLimit(MaxBytes)]
public async Task<ActionResult<ImageDto>> Upload(Guid productId, IFormFile file, CancellationToken ct) public async Task<ActionResult<ImageDto>> Upload(Guid productId, IFormFile file, CancellationToken ct)
{ {
@ -91,7 +90,7 @@ public async Task<ActionResult<ImageDto>> Upload(Guid productId, IFormFile file,
return new ImageDto(entity.Id, entity.Url, entity.IsMain, entity.SortOrder); return new ImageDto(entity.Id, entity.Url, entity.IsMain, entity.SortOrder);
} }
[HttpDelete("{imageId:guid}"), RequiresPermission("ProductsEdit")] [HttpDelete("{imageId:guid}"), Authorize(Roles = "Admin,Storekeeper")]
public async Task<IActionResult> Delete(Guid productId, Guid imageId, CancellationToken ct) public async Task<IActionResult> Delete(Guid productId, Guid imageId, CancellationToken ct)
{ {
var image = await _db.ProductImages.FirstOrDefaultAsync(i => i.Id == imageId && i.ProductId == productId, ct); var image = await _db.ProductImages.FirstOrDefaultAsync(i => i.Id == imageId && i.ProductId == productId, ct);
@ -129,7 +128,7 @@ public async Task<IActionResult> Delete(Guid productId, Guid imageId, Cancellati
return NoContent(); return NoContent();
} }
[HttpPost("{imageId:guid}/main"), RequiresPermission("ProductsEdit")] [HttpPost("{imageId:guid}/main"), Authorize(Roles = "Admin,Storekeeper")]
public async Task<IActionResult> SetMain(Guid productId, Guid imageId, CancellationToken ct) public async Task<IActionResult> SetMain(Guid productId, Guid imageId, CancellationToken ct)
{ {
var image = await _db.ProductImages.FirstOrDefaultAsync(i => i.Id == imageId && i.ProductId == productId, ct); var image = await _db.ProductImages.FirstOrDefaultAsync(i => i.Id == imageId && i.ProductId == productId, ct);

View file

@ -3,7 +3,6 @@
using foodmarket.Application.Common.Tenancy; using foodmarket.Application.Common.Tenancy;
using foodmarket.Domain.Catalog; using foodmarket.Domain.Catalog;
using foodmarket.Infrastructure.Persistence; using foodmarket.Infrastructure.Persistence;
using foodmarket.Api.Infrastructure.Authorization;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -196,11 +195,9 @@ public async Task<ActionResult<ProductDto>> Get(Guid id, CancellationToken ct)
return p is null ? NotFound() : p; return p is null ? NotFound() : p;
} }
[HttpPost, RequiresPermission("ProductsEdit")] [HttpPost, Authorize(Roles = "Admin,Storekeeper")]
public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input, CancellationToken ct) public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input, CancellationToken ct)
{ {
if (string.IsNullOrWhiteSpace(input.Name))
return BadRequest(new { error = "Название товара обязательно.", field = nameof(input.Name) });
if (RequiredGuid.FirstMissing( if (RequiredGuid.FirstMissing(
(nameof(input.UnitOfMeasureId), input.UnitOfMeasureId), (nameof(input.UnitOfMeasureId), input.UnitOfMeasureId),
(nameof(input.ProductGroupId), input.ProductGroupId)) is { } missingFk) (nameof(input.ProductGroupId), input.ProductGroupId)) is { } missingFk)
@ -239,7 +236,7 @@ public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input
return CreatedAtAction(nameof(Get), new { id = e.Id }, dto); return CreatedAtAction(nameof(Get), new { id = e.Id }, dto);
} }
[HttpPut("{id:guid}"), RequiresPermission("ProductsEdit")] [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")]
public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input, CancellationToken ct) public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input, CancellationToken ct)
{ {
if (RequiredGuid.FirstMissing( if (RequiredGuid.FirstMissing(
@ -323,7 +320,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input,
/// <summary>«Привести розничную к себестоимости»: ставит дефолтную /// <summary>«Привести розничную к себестоимости»: ставит дефолтную
/// розничную цену = ceil(Cost * (1 + Group.MarkupPercent/100)). Если у /// розничную цену = ceil(Cost * (1 + Group.MarkupPercent/100)). Если у
/// группы товара не задан MarkupPercent — 400 с подсказкой.</summary> /// группы товара не задан MarkupPercent — 400 с подсказкой.</summary>
[HttpPost("{id:guid}/recalc-retail"), RequiresPermission("ProductsEdit")] [HttpPost("{id:guid}/recalc-retail"), Authorize(Roles = "Admin,Storekeeper")]
public async Task<IActionResult> RecalcRetail(Guid id, CancellationToken ct) public async Task<IActionResult> RecalcRetail(Guid id, CancellationToken ct)
{ {
var p = await _db.Products var p = await _db.Products
@ -372,7 +369,7 @@ public async Task<IActionResult> RecalcRetail(Guid id, CancellationToken ct)
return Ok(new { retail = newRetail }); return Ok(new { retail = newRetail });
} }
[HttpDelete("{id:guid}"), RequiresPermission("ProductsDelete")] [HttpDelete("{id:guid}"), Authorize(Roles = "Admin")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct) public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{ {
var e = await _db.Products.FirstOrDefaultAsync(x => x.Id == id, ct); var e = await _db.Products.FirstOrDefaultAsync(x => x.Id == id, ct);
@ -389,7 +386,7 @@ public record DuplicateProductRef(Guid ProductId, string ProductName, string? Ar
/// организации. Уникальный индекс это запрещает в новых записях, но реальная /// организации. Уникальный индекс это запрещает в новых записях, но реальная
/// БД может содержать исторические дубли (например, после ручной правки). /// БД может содержать исторические дубли (например, после ручной правки).
/// Используется UI чистки (/admin/cleanup) и отчётом MoySklad-импорта.</summary> /// Используется UI чистки (/admin/cleanup) и отчётом MoySklad-импорта.</summary>
[HttpGet("barcode-duplicates"), RequiresPermission("ProductsView")] [HttpGet("barcode-duplicates"), Authorize(Roles = "Admin")]
public async Task<ActionResult<IReadOnlyList<BarcodeDuplicate>>> BarcodeDuplicates(CancellationToken ct) public async Task<ActionResult<IReadOnlyList<BarcodeDuplicate>>> BarcodeDuplicates(CancellationToken ct)
{ {
var rows = await _db.ProductBarcodes var rows = await _db.ProductBarcodes

View file

@ -2,7 +2,6 @@
using foodmarket.Application.Common; using foodmarket.Application.Common;
using foodmarket.Domain.Catalog; using foodmarket.Domain.Catalog;
using foodmarket.Infrastructure.Persistence; using foodmarket.Infrastructure.Persistence;
using foodmarket.Api.Infrastructure.Authorization;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -57,7 +56,7 @@ public async Task<ActionResult<RetailPointDto>> Get(Guid id, CancellationToken c
r.Address, r.Phone, r.FiscalSerial, r.FiscalRegNumber, r.IsActive); r.Address, r.Phone, r.FiscalSerial, r.FiscalRegNumber, r.IsActive);
} }
[HttpPost, RequiresPermission("RetailPointsManage")] [HttpPost, Authorize(Roles = "Admin")]
public async Task<ActionResult<RetailPointDto>> Create([FromBody] RetailPointInput input, CancellationToken ct) public async Task<ActionResult<RetailPointDto>> Create([FromBody] RetailPointInput input, CancellationToken ct)
{ {
var store = await _db.Stores.FirstOrDefaultAsync(s => s.Id == input.StoreId, ct); var store = await _db.Stores.FirstOrDefaultAsync(s => s.Id == input.StoreId, ct);
@ -77,7 +76,7 @@ public async Task<ActionResult<RetailPointDto>> Create([FromBody] RetailPointInp
e.Address, e.Phone, e.FiscalSerial, e.FiscalRegNumber, e.IsActive)); e.Address, e.Phone, e.FiscalSerial, e.FiscalRegNumber, e.IsActive));
} }
[HttpPut("{id:guid}"), RequiresPermission("RetailPointsManage")] [HttpPut("{id:guid}"), Authorize(Roles = "Admin")]
public async Task<IActionResult> Update(Guid id, [FromBody] RetailPointInput input, CancellationToken ct) public async Task<IActionResult> Update(Guid id, [FromBody] RetailPointInput input, CancellationToken ct)
{ {
var e = await _db.RetailPoints.FirstOrDefaultAsync(x => x.Id == id, ct); var e = await _db.RetailPoints.FirstOrDefaultAsync(x => x.Id == id, ct);
@ -94,7 +93,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] RetailPointInput inp
return NoContent(); return NoContent();
} }
[HttpDelete("{id:guid}"), RequiresPermission("RetailPointsManage")] [HttpDelete("{id:guid}"), Authorize(Roles = "Admin")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct) public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{ {
var e = await _db.RetailPoints.FirstOrDefaultAsync(x => x.Id == id, ct); var e = await _db.RetailPoints.FirstOrDefaultAsync(x => x.Id == id, ct);

View file

@ -2,7 +2,6 @@
using foodmarket.Application.Common; using foodmarket.Application.Common;
using foodmarket.Domain.Catalog; using foodmarket.Domain.Catalog;
using foodmarket.Infrastructure.Persistence; using foodmarket.Infrastructure.Persistence;
using foodmarket.Api.Infrastructure.Authorization;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -56,7 +55,7 @@ public async Task<ActionResult<StoreDto>> Get(Guid id, CancellationToken ct)
return x is null ? NotFound() : new StoreDto(x.Id, x.Name, x.Code, x.Address, x.Phone, x.ManagerName, x.IsMain, x.IsActive); return x is null ? NotFound() : new StoreDto(x.Id, x.Name, x.Code, x.Address, x.Phone, x.ManagerName, x.IsMain, x.IsActive);
} }
[HttpPost, RequiresPermission("StoresManage")] [HttpPost, Authorize(Roles = "Admin")]
public async Task<ActionResult<StoreDto>> Create([FromBody] StoreInput input, CancellationToken ct) public async Task<ActionResult<StoreDto>> Create([FromBody] StoreInput input, CancellationToken ct)
{ {
if (input.IsMain) if (input.IsMain)
@ -74,7 +73,7 @@ public async Task<ActionResult<StoreDto>> Create([FromBody] StoreInput input, Ca
new StoreDto(e.Id, e.Name, e.Code, e.Address, e.Phone, e.ManagerName, e.IsMain, e.IsActive)); new StoreDto(e.Id, e.Name, e.Code, e.Address, e.Phone, e.ManagerName, e.IsMain, e.IsActive));
} }
[HttpPut("{id:guid}"), RequiresPermission("StoresManage")] [HttpPut("{id:guid}"), Authorize(Roles = "Admin")]
public async Task<IActionResult> Update(Guid id, [FromBody] StoreInput input, CancellationToken ct) public async Task<IActionResult> Update(Guid id, [FromBody] StoreInput input, CancellationToken ct)
{ {
var e = await _db.Stores.FirstOrDefaultAsync(x => x.Id == id, ct); var e = await _db.Stores.FirstOrDefaultAsync(x => x.Id == id, ct);
@ -95,7 +94,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] StoreInput input, Ca
return NoContent(); return NoContent();
} }
[HttpDelete("{id:guid}"), RequiresPermission("StoresManage")] [HttpDelete("{id:guid}"), Authorize(Roles = "Admin")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct) public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{ {
var e = await _db.Stores.FirstOrDefaultAsync(x => x.Id == id, ct); var e = await _db.Stores.FirstOrDefaultAsync(x => x.Id == id, ct);

View file

@ -3,7 +3,6 @@
using foodmarket.Application.Common.Tenancy; using foodmarket.Application.Common.Tenancy;
using foodmarket.Domain.Catalog; using foodmarket.Domain.Catalog;
using foodmarket.Infrastructure.Persistence; using foodmarket.Infrastructure.Persistence;
using foodmarket.Api.Infrastructure.Authorization;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -93,7 +92,7 @@ public async Task<ActionResult<UnitOfMeasureDto>> Get(Guid id, CancellationToken
/// <summary>Включить global для текущей орги. Идемпотентно: повторный /// <summary>Включить global для текущей орги. Идемпотентно: повторный
/// вызов отдаёт 204 и не плодит дубликатов junction.</summary> /// вызов отдаёт 204 и не плодит дубликатов junction.</summary>
[HttpPost("{id:guid}/enable"), RequiresPermission("UnitsManage")] [HttpPost("{id:guid}/enable"), Authorize(Roles = "Admin,SuperAdmin")]
public async Task<IActionResult> Enable(Guid id, CancellationToken ct) public async Task<IActionResult> Enable(Guid id, CancellationToken ct)
{ {
var orgId = _tenant.OrganizationId; var orgId = _tenant.OrganizationId;
@ -117,7 +116,7 @@ public async Task<IActionResult> Enable(Guid id, CancellationToken ct)
/// <summary>Отключить global для текущей орги. Если на эту единицу /// <summary>Отключить global для текущей орги. Если на эту единицу
/// ссылаются продукты орги — 409 со списком названий, чтобы админ /// ссылаются продукты орги — 409 со списком названий, чтобы админ
/// перепривязал их сначала.</summary> /// перепривязал их сначала.</summary>
[HttpDelete("{id:guid}/enable"), RequiresPermission("UnitsManage")] [HttpDelete("{id:guid}/enable"), Authorize(Roles = "Admin,SuperAdmin")]
public async Task<IActionResult> Disable(Guid id, CancellationToken ct) public async Task<IActionResult> Disable(Guid id, CancellationToken ct)
{ {
var orgId = _tenant.OrganizationId; var orgId = _tenant.OrganizationId;

View file

@ -1,426 +0,0 @@
using System.ComponentModel.DataAnnotations;
using foodmarket.Application.Common;
using foodmarket.Application.Inventory;
using foodmarket.Domain.Inventory;
using foodmarket.Infrastructure.Persistence;
using foodmarket.Api.Infrastructure.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Inventory;
/// <summary>Инвентаризация (пересчёт). Создание подгружает текущие остатки
/// склада в <c>bookQty</c>; пользователь вносит фактические количества;
/// при Post создаются корректирующие движения <see cref="MovementType.InventoryAdjustment"/>
/// на <c>diff = actual - book</c> (положительный — приход, отрицательный
/// — списание).</summary>
[ApiController]
[Authorize]
[Route("api/inventory/inventories")]
public class InventoriesController : ControllerBase
{
private readonly AppDbContext _db;
private readonly IStockService _stock;
public InventoriesController(AppDbContext db, IStockService stock)
{
_db = db;
_stock = stock;
}
public record InventoryListRow(
Guid Id, string Number, DateTime Date, InventoryStatus Status,
Guid StoreId, string StoreName,
int LineCount,
decimal SurplusValue, decimal ShortageValue,
DateTime? PostedAt);
public record InventoryLineDto(
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle, string? UnitSymbol,
decimal BookQty, decimal ActualQty, decimal Diff, decimal UnitCost,
int SortOrder);
public record InventoryDto(
Guid Id, string Number, DateTime Date, InventoryStatus Status,
Guid StoreId, string StoreName,
string? Notes,
DateTime? PostedAt,
IReadOnlyList<InventoryLineDto> Lines);
public record InventoryLineInput(
Guid ProductId,
[Range(0, 1e10)] decimal ActualQty);
public record InventoryInput(
DateTime Date, Guid StoreId,
string? Notes,
/// <summary>Если null/пусто — контроллер сам заполнит строками всеми
/// товарами склада с их текущим Stock в качестве bookQty и actual=0.</summary>
IReadOnlyList<InventoryLineInput>? Lines);
[HttpGet]
public async Task<ActionResult<PagedResult<InventoryListRow>>> List(
[FromQuery] PagedRequest req,
[FromQuery] InventoryStatus? status,
[FromQuery] Guid? storeId,
CancellationToken ct)
{
var q = from i in _db.InventoryDocs.AsNoTracking()
join st in _db.Stores on i.StoreId equals st.Id
select new { i, st };
if (status is not null) q = q.Where(x => x.i.Status == status);
if (storeId is not null) q = q.Where(x => x.i.StoreId == storeId);
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.Trim().ToLower();
q = q.Where(x => x.i.Number.ToLower().Contains(s));
}
var total = await q.CountAsync(ct);
q = (req.Sort, req.Desc) switch
{
("number", false) => q.OrderBy(x => x.i.Number),
("number", true) => q.OrderByDescending(x => x.i.Number),
("status", false) => q.OrderBy(x => x.i.Status).ThenByDescending(x => x.i.Date),
("status", true) => q.OrderByDescending(x => x.i.Status).ThenByDescending(x => x.i.Date),
("date", false) => q.OrderBy(x => x.i.Date).ThenBy(x => x.i.Number),
_ => q.OrderByDescending(x => x.i.Date).ThenByDescending(x => x.i.Number),
};
var items = await q
.Skip(req.Skip).Take(req.Take)
.Select(x => new InventoryListRow(
x.i.Id, x.i.Number, x.i.Date, x.i.Status,
x.st.Id, x.st.Name,
x.i.Lines.Count,
x.i.Lines.Where(l => l.Diff > 0).Sum(l => l.Diff * l.UnitCost),
x.i.Lines.Where(l => l.Diff < 0).Sum(l => l.Diff * l.UnitCost),
x.i.PostedAt))
.ToListAsync(ct);
return new PagedResult<InventoryListRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<InventoryDto>> Get(Guid id, CancellationToken ct)
{
var dto = await GetInternal(id, ct);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost, RequiresPermission("InventoryEdit")]
public async Task<ActionResult<InventoryDto>> Create([FromBody] InventoryInput input, CancellationToken ct)
{
if (RequiredGuid.FirstMissing((nameof(input.StoreId), input.StoreId)) is { } missing)
return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing });
var number = await GenerateNumberAsync(input.Date, ct);
var doc = new InventoryDoc
{
Number = number,
Date = input.Date,
Status = InventoryStatus.Draft,
StoreId = input.StoreId,
Notes = input.Notes,
};
// Если строки не указаны — подтягиваем все товары с ненулевым stock на складе.
if (input.Lines is null || input.Lines.Count == 0)
{
var stocks = await (from s in _db.Stocks.AsNoTracking()
join p in _db.Products.AsNoTracking() on s.ProductId equals p.Id
where s.StoreId == input.StoreId
select new { s.ProductId, s.Quantity, p.Cost })
.ToListAsync(ct);
var order = 0;
foreach (var st in stocks.OrderBy(x => x.ProductId))
{
doc.Lines.Add(new InventoryLine
{
ProductId = st.ProductId,
BookQty = st.Quantity,
ActualQty = 0,
Diff = -st.Quantity,
UnitCost = st.Cost,
SortOrder = order++,
});
}
}
else
{
var productIds = input.Lines.Select(l => l.ProductId).Distinct().ToList();
var book = await _db.Stocks.Where(s => s.StoreId == input.StoreId && productIds.Contains(s.ProductId))
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
var costs = await _db.Products.Where(p => productIds.Contains(p.Id))
.ToDictionaryAsync(p => p.Id, p => p.Cost, ct);
var order = 0;
foreach (var l in input.Lines)
{
book.TryGetValue(l.ProductId, out var b);
costs.TryGetValue(l.ProductId, out var c);
doc.Lines.Add(new InventoryLine
{
ProductId = l.ProductId,
BookQty = b,
ActualQty = l.ActualQty,
Diff = l.ActualQty - b,
UnitCost = c,
SortOrder = order++,
});
}
}
_db.InventoryDocs.Add(doc);
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
var dto = await GetInternal(doc.Id, ct);
return CreatedAtAction(nameof(Get), new { id = doc.Id }, dto);
}
private async Task<ActionResult?> SaveOrFkErrorAsync(CancellationToken ct)
{
try
{
await _db.SaveChangesAsync(ct);
return null;
}
catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23503")
{
var name = pg.ConstraintName ?? "";
string field = name.Contains("Store") ? "storeId"
: name.Contains("Product") ? "productId"
: "(unknown)";
return BadRequest(new { error = $"Связанная запись не найдена: {field}.", field, constraint = name });
}
}
[HttpPut("{id:guid}"), RequiresPermission("InventoryEdit")]
public async Task<IActionResult> Update(Guid id, [FromBody] InventoryInput input, CancellationToken ct)
{
var doc = await _db.InventoryDocs.Include(d => d.Lines).FirstOrDefaultAsync(d => d.Id == id, ct);
if (doc is null) return NotFound();
if (doc.Status != InventoryStatus.Draft)
return Conflict(new { error = "Только черновик может быть изменён. Сначала отмени проведение." });
doc.Date = input.Date;
doc.Notes = input.Notes;
// StoreId на UPDATE не меняем — это пересчитало бы bookQty целиком.
if (input.Lines is not null && input.Lines.Count > 0)
{
// Обновление actualQty по существующим строкам.
var byProduct = doc.Lines.ToDictionary(l => l.ProductId);
foreach (var ln in input.Lines)
{
if (byProduct.TryGetValue(ln.ProductId, out var existing))
{
existing.ActualQty = ln.ActualQty;
existing.Diff = ln.ActualQty - existing.BookQty;
}
else
{
// Новая строка — подгружаем book на момент изменения.
var b = await _db.Stocks.Where(s => s.StoreId == doc.StoreId && s.ProductId == ln.ProductId)
.Select(s => (decimal?)s.Quantity).FirstOrDefaultAsync(ct) ?? 0m;
var c = await _db.Products.Where(p => p.Id == ln.ProductId).Select(p => p.Cost).FirstOrDefaultAsync(ct);
doc.Lines.Add(new InventoryLine
{
InventoryDocId = doc.Id,
ProductId = ln.ProductId,
BookQty = b,
ActualQty = ln.ActualQty,
Diff = ln.ActualQty - b,
UnitCost = c,
SortOrder = doc.Lines.Count,
});
}
}
}
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
return NoContent();
}
[HttpDelete("{id:guid}"), RequiresPermission("InventoryEdit")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var doc = await _db.InventoryDocs.FirstOrDefaultAsync(d => d.Id == id, ct);
if (doc is null) return NotFound();
if (doc.Status != InventoryStatus.Draft)
return Conflict(new { error = "Нельзя удалить проведённый документ. Сначала отмени проведение." });
_db.InventoryDocs.Remove(doc);
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpPost("{id:guid}/post"), RequiresPermission("InventoryEdit")]
public async Task<IActionResult> Post(Guid id, CancellationToken ct)
{
var doc = await _db.InventoryDocs.Include(d => d.Lines).FirstOrDefaultAsync(d => d.Id == id, ct);
if (doc is null) return NotFound();
if (doc.Status == InventoryStatus.Posted) return Conflict(new { error = "Документ уже проведён." });
if (doc.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." });
var withDiff = doc.Lines.Where(l => l.Diff != 0m).ToList();
if (withDiff.Count == 0)
return BadRequest(new { error = "Нет расхождений учётного и фактического количества — нечего проводить." });
await using var tx = await _db.Database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, ct);
var now = DateTime.UtcNow;
foreach (var line in withDiff)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: doc.StoreId,
Quantity: line.Diff, // positive: surplus; negative: shortage
Type: MovementType.InventoryAdjustment,
DocumentType: "inventory",
DocumentId: doc.Id,
DocumentNumber: doc.Number,
UnitCost: line.UnitCost,
OccurredAt: doc.Date,
Notes: line.Diff > 0 ? "surplus" : "shortage"), ct);
}
doc.Status = InventoryStatus.Posted;
doc.PostedAt = now;
try
{
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
}
catch (Exception ex) when (IsSerializationConflict(ex))
{
return Conflict(new { error = "Документ проводится параллельно другим запросом. Повторите попытку." });
}
return NoContent();
}
[HttpPost("{id:guid}/unpost"), RequiresPermission("InventoryEdit")]
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
{
var doc = await _db.InventoryDocs.Include(d => d.Lines).FirstOrDefaultAsync(d => d.Id == id, ct);
if (doc is null) return NotFound();
if (doc.Status != InventoryStatus.Posted) return Conflict(new { error = "Документ не проведён." });
// Reverse: для каждой строки c diff != 0 — обратное движение на -diff.
// Защита от ухода в минус: если diff был положительный (излишек), при unpost
// мы списываем (-diff = -surplus) — стоит проверить что эта величина в наличии.
var positive = doc.Lines.Where(l => l.Diff > 0)
.GroupBy(l => l.ProductId)
.Select(g => new { ProductId = g.Key, Quantity = g.Sum(x => x.Diff) }).ToList();
var productIds = positive.Select(x => x.ProductId).ToList();
var stocks = await _db.Stocks
.Where(s => s.StoreId == doc.StoreId && productIds.Contains(s.ProductId))
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
var conflicts = new List<object>();
foreach (var r in positive)
{
stocks.TryGetValue(r.ProductId, out var available);
if (available < r.Quantity)
{
var name = await _db.Products.Where(p => p.Id == r.ProductId)
.Select(p => p.Name).FirstOrDefaultAsync(ct);
conflicts.Add(new
{
productId = r.ProductId,
productName = name,
reverseQty = r.Quantity,
available,
});
}
}
if (conflicts.Count > 0)
{
return Conflict(new
{
error = "Нельзя отменить проведение: остаток уйдёт в минус (излишек уже расходован).",
lines = conflicts,
});
}
await using var tx = await _db.Database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, ct);
foreach (var line in doc.Lines.Where(l => l.Diff != 0m))
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: doc.StoreId,
Quantity: -line.Diff,
Type: MovementType.InventoryAdjustment,
DocumentType: "inventory-reversal",
DocumentId: doc.Id,
DocumentNumber: doc.Number,
UnitCost: line.UnitCost,
OccurredAt: DateTime.UtcNow,
Notes: $"Отмена проведения документа {doc.Number}"), ct);
}
doc.Status = InventoryStatus.Draft;
doc.PostedAt = null;
try
{
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
}
catch (Exception ex) when (IsSerializationConflict(ex))
{
return Conflict(new { error = "Документ обрабатывается параллельно. Повторите попытку." });
}
return NoContent();
}
private static bool IsSerializationConflict(Exception ex)
{
for (Exception? e = ex; e is not null; e = e.InnerException)
{
if (e is System.Data.Common.DbException { SqlState: "40001" or "40P01" })
return true;
}
return false;
}
private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken ct)
{
var year = date.Year;
var prefix = $"И-{year}-";
var lastNumber = await _db.InventoryDocs
.Where(i => i.Number.StartsWith(prefix))
.OrderByDescending(i => i.Number)
.Select(i => i.Number)
.FirstOrDefaultAsync(ct);
var seq = 1;
if (lastNumber is not null && int.TryParse(lastNumber[prefix.Length..], out var last))
seq = last + 1;
return $"{prefix}{seq:D6}";
}
private async Task<InventoryDto?> GetInternal(Guid id, CancellationToken ct)
{
var row = await (from i in _db.InventoryDocs.AsNoTracking()
join st in _db.Stores on i.StoreId equals st.Id
where i.Id == id
select new { i, st }).FirstOrDefaultAsync(ct);
if (row is null) return null;
var lines = await (from l in _db.InventoryLines.AsNoTracking()
join p in _db.Products on l.ProductId equals p.Id
join u in _db.UnitsOfMeasure on p.UnitOfMeasureId equals u.Id
where l.InventoryDocId == id
orderby l.SortOrder
select new InventoryLineDto(
l.Id, l.ProductId, p.Name, p.Article, u.Name,
l.BookQty, l.ActualQty, l.Diff, l.UnitCost, l.SortOrder))
.ToListAsync(ct);
return new InventoryDto(
row.i.Id, row.i.Number, row.i.Date, row.i.Status,
row.st.Id, row.st.Name,
row.i.Notes, row.i.PostedAt,
lines);
}
}

View file

@ -1,393 +0,0 @@
using System.ComponentModel.DataAnnotations;
using foodmarket.Application.Common;
using foodmarket.Application.Inventory;
using foodmarket.Domain.Inventory;
using foodmarket.Infrastructure.Persistence;
using foodmarket.Api.Infrastructure.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Inventory;
/// <summary>Списание (Loss): уменьшение склада с указанием причины
/// (брак/просрочка/повреждение/недостача/прочее). При проведении создаёт
/// <see cref="StockMovement"/> тип <see cref="MovementType.WriteOff"/> с
/// отрицательным Quantity. Unpost блокируется проверкой не нужно (мы
/// добавляем обратно — остаток всегда увеличится).</summary>
[ApiController]
[Authorize]
[Route("api/inventory/losses")]
public class LossesController : ControllerBase
{
private readonly AppDbContext _db;
private readonly IStockService _stock;
public LossesController(AppDbContext db, IStockService stock)
{
_db = db;
_stock = stock;
}
public record LossListRow(
Guid Id, string Number, DateTime Date, LossStatus Status, LossReason Reason,
Guid StoreId, string StoreName,
Guid CurrencyId, string CurrencyCode,
decimal Total, int LineCount,
DateTime? PostedAt);
public record LossLineDto(
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle,
string? UnitSymbol,
decimal Quantity, decimal UnitCost, decimal LineTotal, int SortOrder,
decimal? StockAtStore);
public record LossDto(
Guid Id, string Number, DateTime Date, LossStatus Status, LossReason Reason,
Guid StoreId, string StoreName,
Guid CurrencyId, string CurrencyCode,
string? Notes,
decimal Total, DateTime? PostedAt,
IReadOnlyList<LossLineDto> Lines);
public record LossLineInput(
Guid ProductId,
[Range(0, 1e10)] decimal Quantity,
[Range(0, 1e10)] decimal UnitCost);
public record LossInput(
DateTime Date, Guid StoreId, Guid CurrencyId, LossReason Reason,
string? Notes,
IReadOnlyList<LossLineInput> Lines);
[HttpGet]
public async Task<ActionResult<PagedResult<LossListRow>>> List(
[FromQuery] PagedRequest req,
[FromQuery] LossStatus? status,
[FromQuery] LossReason? reason,
[FromQuery] Guid? storeId,
CancellationToken ct)
{
var q = from l in _db.Losses.AsNoTracking()
join st in _db.Stores on l.StoreId equals st.Id
join cu in _db.Currencies on l.CurrencyId equals cu.Id
select new { l, st, cu };
if (status is not null) q = q.Where(x => x.l.Status == status);
if (reason is not null) q = q.Where(x => x.l.Reason == reason);
if (storeId is not null) q = q.Where(x => x.l.StoreId == storeId);
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.Trim().ToLower();
q = q.Where(x => x.l.Number.ToLower().Contains(s));
}
var total = await q.CountAsync(ct);
q = (req.Sort, req.Desc) switch
{
("number", false) => q.OrderBy(x => x.l.Number),
("number", true) => q.OrderByDescending(x => x.l.Number),
("store", false) => q.OrderBy(x => x.st.Name).ThenByDescending(x => x.l.Date),
("store", true) => q.OrderByDescending(x => x.st.Name).ThenByDescending(x => x.l.Date),
("status", false) => q.OrderBy(x => x.l.Status).ThenByDescending(x => x.l.Date),
("status", true) => q.OrderByDescending(x => x.l.Status).ThenByDescending(x => x.l.Date),
("reason", false) => q.OrderBy(x => x.l.Reason).ThenByDescending(x => x.l.Date),
("reason", true) => q.OrderByDescending(x => x.l.Reason).ThenByDescending(x => x.l.Date),
("total", false) => q.OrderBy(x => x.l.Total).ThenByDescending(x => x.l.Date),
("total", true) => q.OrderByDescending(x => x.l.Total).ThenByDescending(x => x.l.Date),
("date", false) => q.OrderBy(x => x.l.Date).ThenBy(x => x.l.Number),
_ => q.OrderByDescending(x => x.l.Date).ThenByDescending(x => x.l.Number),
};
var items = await q
.Skip(req.Skip).Take(req.Take)
.Select(x => new LossListRow(
x.l.Id, x.l.Number, x.l.Date, x.l.Status, x.l.Reason,
x.st.Id, x.st.Name,
x.cu.Id, x.cu.Code,
x.l.Total,
x.l.Lines.Count,
x.l.PostedAt))
.ToListAsync(ct);
return new PagedResult<LossListRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<LossDto>> Get(Guid id, CancellationToken ct)
{
var dto = await GetInternal(id, ct);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost, RequiresPermission("LossEdit")]
public async Task<ActionResult<LossDto>> Create([FromBody] LossInput input, CancellationToken ct)
{
if (RequiredGuid.FirstMissing(
(nameof(input.StoreId), input.StoreId),
(nameof(input.CurrencyId), input.CurrencyId)) is { } missing)
return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing });
if (input.Lines is null || input.Lines.Count == 0)
return BadRequest(new { error = "Списание должно содержать хотя бы одну позицию." });
var number = await GenerateNumberAsync(input.Date, ct);
var loss = new Loss
{
Number = number,
Date = input.Date,
Status = LossStatus.Draft,
StoreId = input.StoreId,
CurrencyId = input.CurrencyId,
Reason = input.Reason,
Notes = input.Notes,
};
var order = 0;
foreach (var l in input.Lines)
{
loss.Lines.Add(new LossLine
{
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitCost = l.UnitCost,
LineTotal = l.Quantity * l.UnitCost,
SortOrder = order++,
});
}
loss.Total = loss.Lines.Sum(x => x.LineTotal);
_db.Losses.Add(loss);
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
var dto = await GetInternal(loss.Id, ct);
return CreatedAtAction(nameof(Get), new { id = loss.Id }, dto);
}
private async Task<ActionResult?> SaveOrFkErrorAsync(CancellationToken ct)
{
try
{
await _db.SaveChangesAsync(ct);
return null;
}
catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23503")
{
var name = pg.ConstraintName ?? "";
string field = name.Contains("Store") ? "storeId"
: name.Contains("Currency") ? "currencyId"
: name.Contains("Product") ? "productId"
: "(unknown)";
return BadRequest(new { error = $"Связанная запись не найдена: {field}.", field, constraint = name });
}
}
[HttpPut("{id:guid}"), RequiresPermission("LossEdit")]
public async Task<IActionResult> Update(Guid id, [FromBody] LossInput input, CancellationToken ct)
{
if (RequiredGuid.FirstMissing(
(nameof(input.StoreId), input.StoreId),
(nameof(input.CurrencyId), input.CurrencyId)) is { } missing)
return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing });
if (input.Lines is null || input.Lines.Count == 0)
return BadRequest(new { error = "Списание должно содержать хотя бы одну позицию." });
var loss = await _db.Losses.Include(l => l.Lines).FirstOrDefaultAsync(l => l.Id == id, ct);
if (loss is null) return NotFound();
if (loss.Status != LossStatus.Draft)
return Conflict(new { error = "Только черновик может быть изменён. Сначала отмени проведение." });
loss.Date = input.Date;
loss.StoreId = input.StoreId;
loss.CurrencyId = input.CurrencyId;
loss.Reason = input.Reason;
loss.Notes = input.Notes;
_db.LossLines.RemoveRange(loss.Lines);
loss.Lines.Clear();
var order = 0;
foreach (var l in input.Lines)
{
loss.Lines.Add(new LossLine
{
LossId = loss.Id,
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitCost = l.UnitCost,
LineTotal = l.Quantity * l.UnitCost,
SortOrder = order++,
});
}
loss.Total = loss.Lines.Sum(x => x.LineTotal);
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
return NoContent();
}
[HttpDelete("{id:guid}"), RequiresPermission("LossEdit")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var loss = await _db.Losses.FirstOrDefaultAsync(l => l.Id == id, ct);
if (loss is null) return NotFound();
if (loss.Status != LossStatus.Draft)
return Conflict(new { error = "Нельзя удалить проведённый документ. Сначала отмени проведение." });
_db.Losses.Remove(loss);
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpPost("{id:guid}/post"), RequiresPermission("LossEdit")]
public async Task<IActionResult> Post(Guid id, CancellationToken ct)
{
var loss = await _db.Losses.Include(l => l.Lines).FirstOrDefaultAsync(l => l.Id == id, ct);
if (loss is null) return NotFound();
if (loss.Status == LossStatus.Posted) return Conflict(new { error = "Документ уже проведён." });
if (loss.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." });
// Защита от ухода stock в минус.
var byProduct = loss.Lines.GroupBy(l => l.ProductId)
.Select(g => new { ProductId = g.Key, Quantity = g.Sum(x => x.Quantity) }).ToList();
var productIds = byProduct.Select(x => x.ProductId).ToList();
var stocks = await _db.Stocks
.Where(s => s.StoreId == loss.StoreId && productIds.Contains(s.ProductId))
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
var conflicts = new List<object>();
foreach (var r in byProduct)
{
stocks.TryGetValue(r.ProductId, out var available);
if (available < r.Quantity)
{
var name = await _db.Products.Where(p => p.Id == r.ProductId)
.Select(p => p.Name).FirstOrDefaultAsync(ct);
conflicts.Add(new
{
productId = r.ProductId,
productName = name,
writeOffQty = r.Quantity,
available,
});
}
}
if (conflicts.Count > 0)
{
return Conflict(new
{
error = "Нельзя списать больше, чем есть в наличии.",
lines = conflicts,
});
}
await using var tx = await _db.Database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, ct);
var now = DateTime.UtcNow;
foreach (var line in loss.Lines)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: loss.StoreId,
Quantity: -line.Quantity,
Type: MovementType.WriteOff,
DocumentType: "loss",
DocumentId: loss.Id,
DocumentNumber: loss.Number,
UnitCost: line.UnitCost,
OccurredAt: loss.Date,
Notes: $"reason={loss.Reason}"), ct);
}
loss.Status = LossStatus.Posted;
loss.PostedAt = now;
try
{
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
}
catch (Exception ex) when (IsSerializationConflict(ex))
{
return Conflict(new { error = "Документ проводится параллельно другим запросом. Повторите попытку." });
}
return NoContent();
}
[HttpPost("{id:guid}/unpost"), RequiresPermission("LossEdit")]
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
{
var loss = await _db.Losses.Include(l => l.Lines).FirstOrDefaultAsync(l => l.Id == id, ct);
if (loss is null) return NotFound();
if (loss.Status != LossStatus.Posted) return Conflict(new { error = "Документ не проведён." });
// Reverse: возвращаем товар обратно — остаток только увеличится, проверки на минус не нужно.
foreach (var line in loss.Lines)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: loss.StoreId,
Quantity: line.Quantity,
Type: MovementType.WriteOff,
DocumentType: "loss-reversal",
DocumentId: loss.Id,
DocumentNumber: loss.Number,
UnitCost: line.UnitCost,
OccurredAt: DateTime.UtcNow,
Notes: $"Отмена проведения документа {loss.Number}"), ct);
}
loss.Status = LossStatus.Draft;
loss.PostedAt = null;
await _db.SaveChangesAsync(ct);
return NoContent();
}
private static bool IsSerializationConflict(Exception ex)
{
for (Exception? e = ex; e is not null; e = e.InnerException)
{
if (e is System.Data.Common.DbException { SqlState: "40001" or "40P01" })
return true;
}
return false;
}
private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken ct)
{
var year = date.Year;
var prefix = $"С-{year}-";
var lastNumber = await _db.Losses
.Where(l => l.Number.StartsWith(prefix))
.OrderByDescending(l => l.Number)
.Select(l => l.Number)
.FirstOrDefaultAsync(ct);
var seq = 1;
if (lastNumber is not null && int.TryParse(lastNumber[prefix.Length..], out var last))
seq = last + 1;
return $"{prefix}{seq:D6}";
}
private async Task<LossDto?> GetInternal(Guid id, CancellationToken ct)
{
var row = await (from l in _db.Losses.AsNoTracking()
join st in _db.Stores on l.StoreId equals st.Id
join cu in _db.Currencies on l.CurrencyId equals cu.Id
where l.Id == id
select new { l, st, cu }).FirstOrDefaultAsync(ct);
if (row is null) return null;
var lines = await (from l in _db.LossLines.AsNoTracking()
join p in _db.Products on l.ProductId equals p.Id
join u in _db.UnitsOfMeasure on p.UnitOfMeasureId equals u.Id
where l.LossId == id
orderby l.SortOrder
select new LossLineDto(
l.Id, l.ProductId, p.Name, p.Article,
u.Name,
l.Quantity, l.UnitCost, l.LineTotal, l.SortOrder,
_db.Stocks.Where(s => s.ProductId == l.ProductId && s.StoreId == row.l.StoreId)
.Select(s => (decimal?)s.Quantity).FirstOrDefault()))
.ToListAsync(ct);
return new LossDto(
row.l.Id, row.l.Number, row.l.Date, row.l.Status, row.l.Reason,
row.st.Id, row.st.Name,
row.cu.Id, row.cu.Code,
row.l.Notes,
row.l.Total, row.l.PostedAt,
lines);
}
}

View file

@ -1,459 +0,0 @@
using System.ComponentModel.DataAnnotations;
using foodmarket.Application.Common;
using foodmarket.Application.Inventory;
using foodmarket.Domain.Inventory;
using foodmarket.Infrastructure.Persistence;
using foodmarket.Api.Infrastructure.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Inventory;
/// <summary>Перемещение (Transfer): атомарное движение товара между складами.
/// Post создаёт ПАРУ движений в <see cref="StockMovement"/>: TransferOut с
/// отрицательным Quantity из FromStore + TransferIn с положительным в
/// ToStore. Серьёзный кейс: post→unpost не должен оставить orphan-движений
/// (только один из двух) — оба создаются и оба «отменяются» в одной
/// транзакции.</summary>
[ApiController]
[Authorize]
[Route("api/inventory/transfers")]
public class TransfersController : ControllerBase
{
private readonly AppDbContext _db;
private readonly IStockService _stock;
public TransfersController(AppDbContext db, IStockService stock)
{
_db = db;
_stock = stock;
}
public record TransferListRow(
Guid Id, string Number, DateTime Date, TransferStatus Status,
Guid FromStoreId, string FromStoreName,
Guid ToStoreId, string ToStoreName,
decimal Total, int LineCount,
DateTime? PostedAt);
public record TransferLineDto(
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle,
string? UnitSymbol,
decimal Quantity, decimal UnitCost, decimal LineTotal, int SortOrder,
decimal? StockAtFrom);
public record TransferDto(
Guid Id, string Number, DateTime Date, TransferStatus Status,
Guid FromStoreId, string FromStoreName,
Guid ToStoreId, string ToStoreName,
string? Notes,
decimal Total, DateTime? PostedAt,
IReadOnlyList<TransferLineDto> Lines);
public record TransferLineInput(
Guid ProductId,
[Range(0, 1e10)] decimal Quantity,
[Range(0, 1e10)] decimal UnitCost);
public record TransferInput(
DateTime Date, Guid FromStoreId, Guid ToStoreId,
string? Notes,
IReadOnlyList<TransferLineInput> Lines);
[HttpGet]
public async Task<ActionResult<PagedResult<TransferListRow>>> List(
[FromQuery] PagedRequest req,
[FromQuery] TransferStatus? status,
[FromQuery] Guid? fromStoreId,
[FromQuery] Guid? toStoreId,
CancellationToken ct)
{
var q = from t in _db.Transfers.AsNoTracking()
join fs in _db.Stores on t.FromStoreId equals fs.Id
join ts in _db.Stores on t.ToStoreId equals ts.Id
select new { t, fs, ts };
if (status is not null) q = q.Where(x => x.t.Status == status);
if (fromStoreId is not null) q = q.Where(x => x.t.FromStoreId == fromStoreId);
if (toStoreId is not null) q = q.Where(x => x.t.ToStoreId == toStoreId);
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.Trim().ToLower();
q = q.Where(x => x.t.Number.ToLower().Contains(s));
}
var total = await q.CountAsync(ct);
q = (req.Sort, req.Desc) switch
{
("number", false) => q.OrderBy(x => x.t.Number),
("number", true) => q.OrderByDescending(x => x.t.Number),
("status", false) => q.OrderBy(x => x.t.Status).ThenByDescending(x => x.t.Date),
("status", true) => q.OrderByDescending(x => x.t.Status).ThenByDescending(x => x.t.Date),
("total", false) => q.OrderBy(x => x.t.Total).ThenByDescending(x => x.t.Date),
("total", true) => q.OrderByDescending(x => x.t.Total).ThenByDescending(x => x.t.Date),
("date", false) => q.OrderBy(x => x.t.Date).ThenBy(x => x.t.Number),
_ => q.OrderByDescending(x => x.t.Date).ThenByDescending(x => x.t.Number),
};
var items = await q
.Skip(req.Skip).Take(req.Take)
.Select(x => new TransferListRow(
x.t.Id, x.t.Number, x.t.Date, x.t.Status,
x.fs.Id, x.fs.Name,
x.ts.Id, x.ts.Name,
x.t.Total,
x.t.Lines.Count,
x.t.PostedAt))
.ToListAsync(ct);
return new PagedResult<TransferListRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<TransferDto>> Get(Guid id, CancellationToken ct)
{
var dto = await GetInternal(id, ct);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost, RequiresPermission("TransferEdit")]
public async Task<ActionResult<TransferDto>> Create([FromBody] TransferInput input, CancellationToken ct)
{
if (RequiredGuid.FirstMissing(
(nameof(input.FromStoreId), input.FromStoreId),
(nameof(input.ToStoreId), input.ToStoreId)) is { } missing)
return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing });
if (input.FromStoreId == input.ToStoreId)
return BadRequest(new { error = "Склад-отправитель и склад-получатель должны различаться.", field = "toStoreId" });
if (input.Lines is null || input.Lines.Count == 0)
return BadRequest(new { error = "Перемещение должно содержать хотя бы одну позицию." });
var number = await GenerateNumberAsync(input.Date, ct);
var t = new Transfer
{
Number = number,
Date = input.Date,
Status = TransferStatus.Draft,
FromStoreId = input.FromStoreId,
ToStoreId = input.ToStoreId,
Notes = input.Notes,
};
var order = 0;
foreach (var l in input.Lines)
{
t.Lines.Add(new TransferLine
{
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitCost = l.UnitCost,
LineTotal = l.Quantity * l.UnitCost,
SortOrder = order++,
});
}
t.Total = t.Lines.Sum(x => x.LineTotal);
_db.Transfers.Add(t);
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
var dto = await GetInternal(t.Id, ct);
return CreatedAtAction(nameof(Get), new { id = t.Id }, dto);
}
private async Task<ActionResult?> SaveOrFkErrorAsync(CancellationToken ct)
{
try
{
await _db.SaveChangesAsync(ct);
return null;
}
catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23503")
{
var name = pg.ConstraintName ?? "";
string field = name.Contains("FromStore") ? "fromStoreId"
: name.Contains("ToStore") ? "toStoreId"
: name.Contains("Product") ? "productId"
: "(unknown)";
return BadRequest(new { error = $"Связанная запись не найдена: {field}.", field, constraint = name });
}
}
[HttpPut("{id:guid}"), RequiresPermission("TransferEdit")]
public async Task<IActionResult> Update(Guid id, [FromBody] TransferInput input, CancellationToken ct)
{
if (RequiredGuid.FirstMissing(
(nameof(input.FromStoreId), input.FromStoreId),
(nameof(input.ToStoreId), input.ToStoreId)) is { } missing)
return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing });
if (input.FromStoreId == input.ToStoreId)
return BadRequest(new { error = "Склад-отправитель и склад-получатель должны различаться.", field = "toStoreId" });
if (input.Lines is null || input.Lines.Count == 0)
return BadRequest(new { error = "Перемещение должно содержать хотя бы одну позицию." });
var t = await _db.Transfers.Include(x => x.Lines).FirstOrDefaultAsync(x => x.Id == id, ct);
if (t is null) return NotFound();
if (t.Status != TransferStatus.Draft)
return Conflict(new { error = "Только черновик может быть изменён. Сначала отмени проведение." });
t.Date = input.Date;
t.FromStoreId = input.FromStoreId;
t.ToStoreId = input.ToStoreId;
t.Notes = input.Notes;
_db.TransferLines.RemoveRange(t.Lines);
t.Lines.Clear();
var order = 0;
foreach (var l in input.Lines)
{
t.Lines.Add(new TransferLine
{
TransferId = t.Id,
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitCost = l.UnitCost,
LineTotal = l.Quantity * l.UnitCost,
SortOrder = order++,
});
}
t.Total = t.Lines.Sum(x => x.LineTotal);
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
return NoContent();
}
[HttpDelete("{id:guid}"), RequiresPermission("TransferEdit")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var t = await _db.Transfers.FirstOrDefaultAsync(x => x.Id == id, ct);
if (t is null) return NotFound();
if (t.Status != TransferStatus.Draft)
return Conflict(new { error = "Нельзя удалить проведённый документ. Сначала отмени проведение." });
_db.Transfers.Remove(t);
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpPost("{id:guid}/post"), RequiresPermission("TransferEdit")]
public async Task<IActionResult> Post(Guid id, CancellationToken ct)
{
var t = await _db.Transfers.Include(x => x.Lines).FirstOrDefaultAsync(x => x.Id == id, ct);
if (t is null) return NotFound();
if (t.Status == TransferStatus.Posted) return Conflict(new { error = "Документ уже проведён." });
if (t.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." });
// Проверка: на FromStore хватает товара (нельзя уйти в минус).
var byProduct = t.Lines.GroupBy(l => l.ProductId)
.Select(g => new { ProductId = g.Key, Quantity = g.Sum(x => x.Quantity) }).ToList();
var productIds = byProduct.Select(x => x.ProductId).ToList();
var stocks = await _db.Stocks
.Where(s => s.StoreId == t.FromStoreId && productIds.Contains(s.ProductId))
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
var conflicts = new List<object>();
foreach (var r in byProduct)
{
stocks.TryGetValue(r.ProductId, out var available);
if (available < r.Quantity)
{
var name = await _db.Products.Where(p => p.Id == r.ProductId)
.Select(p => p.Name).FirstOrDefaultAsync(ct);
conflicts.Add(new
{
productId = r.ProductId,
productName = name,
requested = r.Quantity,
available,
});
}
}
if (conflicts.Count > 0)
{
return Conflict(new
{
error = "На складе-отправителе недостаточно товара.",
lines = conflicts,
});
}
// Атомарная транзакция: пара движений (Out + In) — либо обе, либо ни одной.
await using var tx = await _db.Database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, ct);
var now = DateTime.UtcNow;
foreach (var line in t.Lines)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: t.FromStoreId,
Quantity: -line.Quantity,
Type: MovementType.TransferOut,
DocumentType: "transfer-out",
DocumentId: t.Id,
DocumentNumber: t.Number,
UnitCost: line.UnitCost,
OccurredAt: t.Date), ct);
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: t.ToStoreId,
Quantity: line.Quantity,
Type: MovementType.TransferIn,
DocumentType: "transfer-in",
DocumentId: t.Id,
DocumentNumber: t.Number,
UnitCost: line.UnitCost,
OccurredAt: t.Date), ct);
}
t.Status = TransferStatus.Posted;
t.PostedAt = now;
try
{
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
}
catch (Exception ex) when (IsSerializationConflict(ex))
{
return Conflict(new { error = "Документ проводится параллельно другим запросом. Повторите попытку." });
}
return NoContent();
}
[HttpPost("{id:guid}/unpost"), RequiresPermission("TransferEdit")]
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
{
var t = await _db.Transfers.Include(x => x.Lines).FirstOrDefaultAsync(x => x.Id == id, ct);
if (t is null) return NotFound();
if (t.Status != TransferStatus.Posted) return Conflict(new { error = "Документ не проведён." });
// Reverse: на ToStore должен хватать товар (могли уже использовать).
var byProduct = t.Lines.GroupBy(l => l.ProductId)
.Select(g => new { ProductId = g.Key, Quantity = g.Sum(x => x.Quantity) }).ToList();
var productIds = byProduct.Select(x => x.ProductId).ToList();
var stocks = await _db.Stocks
.Where(s => s.StoreId == t.ToStoreId && productIds.Contains(s.ProductId))
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
var conflicts = new List<object>();
foreach (var r in byProduct)
{
stocks.TryGetValue(r.ProductId, out var available);
if (available < r.Quantity)
{
var name = await _db.Products.Where(p => p.Id == r.ProductId)
.Select(p => p.Name).FirstOrDefaultAsync(ct);
conflicts.Add(new
{
productId = r.ProductId,
productName = name,
reverseQty = r.Quantity,
availableAtTo = available,
});
}
}
if (conflicts.Count > 0)
{
return Conflict(new
{
error = "Нельзя отменить перемещение: на складе-получателе недостаточно товара (часть уже расходована).",
lines = conflicts,
});
}
// Атомарная транзакция: пара обратных движений.
await using var tx = await _db.Database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, ct);
foreach (var line in t.Lines)
{
// Возвращаем на FromStore (+Quantity).
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: t.FromStoreId,
Quantity: line.Quantity,
Type: MovementType.TransferOut,
DocumentType: "transfer-out-reversal",
DocumentId: t.Id,
DocumentNumber: t.Number,
UnitCost: line.UnitCost,
OccurredAt: DateTime.UtcNow,
Notes: $"Отмена проведения документа {t.Number}"), ct);
// Снимаем с ToStore (-Quantity).
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: t.ToStoreId,
Quantity: -line.Quantity,
Type: MovementType.TransferIn,
DocumentType: "transfer-in-reversal",
DocumentId: t.Id,
DocumentNumber: t.Number,
UnitCost: line.UnitCost,
OccurredAt: DateTime.UtcNow,
Notes: $"Отмена проведения документа {t.Number}"), ct);
}
t.Status = TransferStatus.Draft;
t.PostedAt = null;
try
{
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
}
catch (Exception ex) when (IsSerializationConflict(ex))
{
return Conflict(new { error = "Документ обрабатывается параллельно. Повторите попытку." });
}
return NoContent();
}
private static bool IsSerializationConflict(Exception ex)
{
for (Exception? e = ex; e is not null; e = e.InnerException)
{
if (e is System.Data.Common.DbException { SqlState: "40001" or "40P01" })
return true;
}
return false;
}
private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken ct)
{
var year = date.Year;
var prefix = $"П-{year}-Т-";
var lastNumber = await _db.Transfers
.Where(t => t.Number.StartsWith(prefix))
.OrderByDescending(t => t.Number)
.Select(t => t.Number)
.FirstOrDefaultAsync(ct);
var seq = 1;
if (lastNumber is not null && int.TryParse(lastNumber[prefix.Length..], out var last))
seq = last + 1;
return $"{prefix}{seq:D6}";
}
private async Task<TransferDto?> GetInternal(Guid id, CancellationToken ct)
{
var row = await (from t in _db.Transfers.AsNoTracking()
join fs in _db.Stores on t.FromStoreId equals fs.Id
join ts in _db.Stores on t.ToStoreId equals ts.Id
where t.Id == id
select new { t, fs, ts }).FirstOrDefaultAsync(ct);
if (row is null) return null;
var lines = await (from l in _db.TransferLines.AsNoTracking()
join p in _db.Products on l.ProductId equals p.Id
join u in _db.UnitsOfMeasure on p.UnitOfMeasureId equals u.Id
where l.TransferId == id
orderby l.SortOrder
select new TransferLineDto(
l.Id, l.ProductId, p.Name, p.Article,
u.Name,
l.Quantity, l.UnitCost, l.LineTotal, l.SortOrder,
_db.Stocks.Where(s => s.ProductId == l.ProductId && s.StoreId == row.t.FromStoreId)
.Select(s => (decimal?)s.Quantity).FirstOrDefault()))
.ToListAsync(ct);
return new TransferDto(
row.t.Id, row.t.Number, row.t.Date, row.t.Status,
row.fs.Id, row.fs.Name,
row.ts.Id, row.ts.Name,
row.t.Notes,
row.t.Total, row.t.PostedAt,
lines);
}
}

View file

@ -20,17 +20,10 @@ public class EmployeesController : ControllerBase
private readonly AppDbContext _db; private readonly AppDbContext _db;
private readonly ITenantContext _tenant; private readonly ITenantContext _tenant;
private readonly UserManager<User> _userMgr; private readonly UserManager<User> _userMgr;
private readonly foodmarket.Application.Common.Email.IEmailSender _email;
private readonly foodmarket.Api.Infrastructure.Email.EmailTemplates _templates;
private readonly ILogger<EmployeesController> _log;
public EmployeesController(AppDbContext db, ITenantContext tenant, UserManager<User> userMgr, public EmployeesController(AppDbContext db, ITenantContext tenant, UserManager<User> userMgr)
foodmarket.Application.Common.Email.IEmailSender email,
foodmarket.Api.Infrastructure.Email.EmailTemplates templates,
ILogger<EmployeesController> log)
{ {
_db = db; _tenant = tenant; _userMgr = userMgr; _db = db; _tenant = tenant; _userMgr = userMgr;
_email = email; _templates = templates; _log = log;
} }
public record EmployeeDto( public record EmployeeDto(
@ -53,12 +46,7 @@ public record EmployeeInput(
IReadOnlyList<Guid>? RetailPointIds, IReadOnlyList<Guid>? RetailPointIds,
// CreateAccount=true → создаём User c email + temp password. // CreateAccount=true → создаём User c email + temp password.
// Возвращается в response один раз (showOnce). // Возвращается в response один раз (showOnce).
bool CreateAccount = false, bool CreateAccount = false);
// SendInvite=true (требует CreateAccount=true) → шлём email-приглашение
// с временным паролем по шаблону EmailTemplates.invite. SMTP-ошибка
// не блокирует создание сотрудника — пишем warning, возвращаем пароль
// в ответе как обычно (showOnce).
bool SendInvite = false);
public record EmployeeCreateResult(EmployeeDto Employee, string? GeneratedPassword); public record EmployeeCreateResult(EmployeeDto Employee, string? GeneratedPassword);
@ -182,37 +170,6 @@ public async Task<ActionResult<EmployeeCreateResult>> Create([FromBody] Employee
_db.Employees.Add(employee); _db.Employees.Add(employee);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
// Email-приглашение. SMTP-ошибка не блокирует создание — логируем
// warning, фронт всё равно получит tempPassword в ответе.
if (input.SendInvite && input.CreateAccount && tempPassword is not null
&& !string.IsNullOrWhiteSpace(input.Email))
{
try
{
var orgName = await _db.Organizations.Where(o => o.Id == orgId)
.Select(o => o.Name).FirstOrDefaultAsync(ct) ?? "Food Market";
var loginUrl = (Request.Scheme + "://" + Request.Host.Value + "/login");
var (subject, html, text) = _templates.Render("invite", new Dictionary<string, string?>
{
["organizationName"] = orgName,
["employeeName"] = $"{input.FirstName} {input.LastName}".Trim(),
["email"] = input.Email,
["temporaryPassword"] = tempPassword,
["roleName"] = role.Name,
["loginUrl"] = loginUrl,
});
await _email.SendHtmlAsync(input.Email!, subject, html, text, ct);
}
catch (foodmarket.Application.Common.Email.EmailNotConfiguredException ex)
{
_log.LogWarning("Не удалось отправить приглашение: SMTP не настроен ({Msg})", ex.Message);
}
catch (Exception ex)
{
_log.LogWarning(ex, "Не удалось отправить приглашение сотруднику {Email}", input.Email);
}
}
var dto = await ProjectAsync(employee.Id, ct); var dto = await ProjectAsync(employee.Id, ct);
return new EmployeeCreateResult(dto!, tempPassword); return new EmployeeCreateResult(dto!, tempPassword);
} }
@ -272,9 +229,6 @@ public async Task<IActionResult> Update(Guid id, [FromBody] EmployeeInput input,
var nowActive = input.IsActive; var nowActive = input.IsActive;
if (e.IsActive && !nowActive) e.FiredAt = DateTime.UtcNow; if (e.IsActive && !nowActive) e.FiredAt = DateTime.UtcNow;
if (!e.IsActive && nowActive) e.FiredAt = null; if (!e.IsActive && nowActive) e.FiredAt = null;
// Меняем активность сотрудника — синхронизируем логин связанного User
// (деактивация гасит сессии, реактивация возвращает доступ). См. DELETE.
if (e.IsActive != nowActive) await SetLinkedUserActiveAsync(e.UserId, nowActive, ct);
e.IsActive = nowActive; e.IsActive = nowActive;
// Replace assignments wholesale // Replace assignments wholesale
@ -340,35 +294,10 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
e.IsDeleted = true; e.IsDeleted = true;
e.DeletedAt = DateTime.UtcNow; e.DeletedAt = DateTime.UtcNow;
} }
// Увольнение и soft-delete обязаны гасить логин связанного User: иначе
// уволенный сотрудник продолжает входить и обновлять токены (ТЗ 0.4).
// На обоих шагах сотрудник перестаёт быть активным персоналом → гасим.
await DeactivateLinkedUserAsync(e.UserId, ct);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return NoContent(); return NoContent();
} }
/// <summary>Синхронизирует активность связанного AppUser с активностью
/// сотрудника. При деактивации дополнительно отзывает valid refresh/access
/// токены — без этого у уволенного остаётся рабочий refresh до 30 дней
/// (логин и refresh в AuthorizationController гейтятся на User.IsActive).</summary>
private async Task SetLinkedUserActiveAsync(Guid? userId, bool active, CancellationToken ct)
{
if (userId is null) return;
var user = await _db.Users.IgnoreQueryFilters().FirstOrDefaultAsync(u => u.Id == userId.Value, ct);
if (user is null || user.IsActive == active) return;
user.IsActive = active;
if (!active)
{
await _db.Database.ExecuteSqlRawAsync(
"UPDATE \"OpenIddictTokens\" SET \"Status\" = 'revoked' WHERE \"Subject\" = {0} AND \"Status\" = 'valid'",
user.Id.ToString());
}
}
private Task DeactivateLinkedUserAsync(Guid? userId, CancellationToken ct)
=> SetLinkedUserActiveAsync(userId, active: false, ct);
private static Guid? ParseUserId(string? raw) => Guid.TryParse(raw, out var g) ? g : null; private static Guid? ParseUserId(string? raw) => Guid.TryParse(raw, out var g) ? g : null;
private async Task<EmployeeDto?> ProjectAsync(Guid id, CancellationToken ct) private async Task<EmployeeDto?> ProjectAsync(Guid id, CancellationToken ct)

View file

@ -1,415 +0,0 @@
using System.Text.Json;
using foodmarket.Application.Common.Tenancy;
using foodmarket.Application.Inventory;
using foodmarket.Domain.Inventory;
using foodmarket.Domain.Sales;
using foodmarket.Infrastructure.Persistence;
using foodmarket.Shared.Pos.V1;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Pos;
/// <summary>API синхронизации с оффлайн-кассами (food-market.pos, WPF/Windows).
///
/// Эндпоинты:
/// • <c>GET /api/pos/v1/sync?since=ISO8601&amp;storeId=…</c> — выгрузка изменений
/// с указанной reference time. Возвращает товары, цены, остатки на момент
/// ответа, контрагентов и список архивированных товаров.
/// • <c>POST /api/pos/v1/sales</c> — приём батча продаж от кассы.
/// <see cref="PosSaleBatchDto.IdempotencyKey"/> + per-sale ClientSaleId —
/// двойная идемпотентность: повтор того же батча возвращает тот же
/// результат без дублей в БД.
///
/// Multi-tenant: запросы идут под обычным OpenIddict JWT с claim
/// <c>org_id</c>. Дополнительно POS привязан к одному магазину через
/// <c>storeId</c> query/body — контроллер валидирует, что store
/// принадлежит организации.
///
/// Версионирование: префикс v1 в URL зафиксирован, добавления новых
/// необязательных полей в DTO не ломают v1; для breaking changes —
/// рядом будет v2.</summary>
[ApiController]
[Authorize]
[Route("api/pos/v1")]
public class PosController : ControllerBase
{
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
private readonly IStockService _stock;
private readonly ILogger<PosController> _log;
public PosController(AppDbContext db, ITenantContext tenant, IStockService stock, ILogger<PosController> log)
{
_db = db;
_tenant = tenant;
_stock = stock;
_log = log;
}
[HttpGet("sync")]
public async Task<ActionResult<PosSyncResponse>> Sync(
[FromQuery] DateTime? since,
[FromQuery] Guid? storeId,
CancellationToken ct)
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var resolvedStore = await ResolveStoreAsync(storeId, ct);
if (resolvedStore is null)
return BadRequest(new { error = "Магазин не найден или не принадлежит организации.", field = "storeId" });
var threshold = since ?? DateTime.MinValue.ToUniversalTime();
var serverTime = DateTime.UtcNow;
// Товары: дельта по UpdatedAt/CreatedAt. У записи Entity.UpdatedAt
// может быть null если её ни разу не правили после CreatedAt; берём max.
var products = await (from p in _db.Products.AsNoTracking()
join u in _db.UnitsOfMeasure.AsNoTracking() on p.UnitOfMeasureId equals u.Id
let stamp = p.UpdatedAt ?? p.CreatedAt
where stamp > threshold
select new { p, u, stamp })
.ToListAsync(ct);
var productIds = products.Select(x => x.p.Id).ToList();
var barcodes = await _db.ProductBarcodes.AsNoTracking()
.Where(b => productIds.Contains(b.ProductId))
.OrderByDescending(b => b.IsPrimary)
.Select(b => new { b.ProductId, b.Code })
.ToListAsync(ct);
var barcodesByProduct = barcodes.GroupBy(b => b.ProductId)
.ToDictionary(g => g.Key, g => (IReadOnlyList<string>)g.Select(x => x.Code).ToList());
var productDtos = products.Select(x => new ProductSyncDto
{
Id = x.p.Id,
Name = x.p.Name,
Article = x.p.Article,
Barcodes = barcodesByProduct.TryGetValue(x.p.Id, out var b) ? b : Array.Empty<string>(),
UnitCode = x.u.Code,
Packaging = (int)x.p.Packaging,
VatPercent = x.p.Vat,
VatEnabled = x.p.VatEnabled,
IsMarked = x.p.IsMarked,
// Product не имеет IsArchived в текущей доменной модели; всегда false.
// Когда добавим архив — заполняется здесь, без правки контракта.
IsArchived = false,
UpdatedAt = x.stamp,
}).ToList();
// Цены: всё что изменилось после since. PriceType подтягиваем чтобы
// POS мог выбрать «системный».
var prices = await (from pp in _db.ProductPrices.AsNoTracking()
join pt in _db.PriceTypes.AsNoTracking() on pp.PriceTypeId equals pt.Id
join cu in _db.Currencies.AsNoTracking() on pp.CurrencyId equals cu.Id
let stamp = pp.UpdatedAt ?? pp.CreatedAt
where stamp > threshold
select new PriceSyncDto
{
ProductId = pp.ProductId,
PriceTypeId = pt.Id,
PriceTypeName = pt.Name,
IsSystem = pt.IsSystem,
Amount = pp.Amount,
CurrencyCode = cu.Code,
UpdatedAt = stamp,
}).ToListAsync(ct);
// Остатки: всегда полный снимок на момент ответа. since-фильтр не
// применяется к остаткам — кассе всегда нужен актуальный stock.
var stocks = await (from s in _db.Stocks.AsNoTracking()
where s.StoreId == resolvedStore.Value && s.Quantity != 0m
select new StockSyncDto
{
ProductId = s.ProductId,
StoreId = s.StoreId,
Quantity = s.Quantity,
AsOf = serverTime,
}).ToListAsync(ct);
// Контрагенты-покупатели. Поставщиков на POS не шлём.
var counterparties = await (from c in _db.Counterparties.AsNoTracking()
let stamp = c.UpdatedAt ?? c.CreatedAt
where stamp > threshold
select new CounterpartySyncDto
{
Id = c.Id,
Name = c.Name,
Phone = c.Phone,
Email = c.Email,
Bin = c.Bin,
Iin = c.Iin,
UpdatedAt = stamp,
}).ToListAsync(ct);
// DeletedProductIds: в текущей модели Product hard-delete'ятся; для
// soft-delete заведём отдельный feed; пока пустой массив.
return Ok(new PosSyncResponse
{
ServerTime = serverTime,
Products = productDtos,
Prices = prices,
Stocks = stocks,
Counterparties = counterparties,
DeletedProductIds = Array.Empty<Guid>(),
});
}
[HttpPost("sales")]
public async Task<ActionResult<PosSaleBatchResponse>> AcceptSales(
[FromBody] PosSaleBatchDto batch,
[FromQuery] Guid? storeId,
CancellationToken ct)
{
if (batch is null || batch.Sales is null)
return BadRequest(new { error = "Пустое тело батча.", field = "batch" });
if (batch.IdempotencyKey == Guid.Empty)
return BadRequest(new { error = "Идемпотентный ключ не указан.", field = "idempotencyKey" });
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var resolvedStore = await ResolveStoreAsync(storeId, ct);
if (resolvedStore is null)
return BadRequest(new { error = "Магазин не найден или не принадлежит организации.", field = "storeId" });
// 1) Идемпотентность на уровне батча. Сначала проверяем cache.
var existing = await _db.PosBatchAcks.AsNoTracking()
.FirstOrDefaultAsync(a => a.IdempotencyKey == batch.IdempotencyKey, ct);
if (existing is not null)
{
var prev = JsonSerializer.Deserialize<PosSaleBatchResponse>(existing.ResponseJson)!;
return Ok(prev with { ReplayedFromCache = true });
}
// 2) Идемпотентность на уровне отдельной продажи: ищем уже созданные
// RetailSale, у которых Notes начинается с маркера ClientSaleId.
// (Альтернативой было бы добавить отдельную колонку ClientSaleId; для
// минимизации миграций используем Notes-префикс — он уже nullable.)
var clientSaleIds = batch.Sales.Select(s => s.ClientSaleId).Distinct().ToList();
var markers = clientSaleIds.Select(g => $"pos:{g:N}").ToList();
var alreadyByMarker = await _db.RetailSales.AsNoTracking()
.Where(s => s.Notes != null && markers.Contains(s.Notes.Substring(0, Math.Min(36, s.Notes.Length))))
.Select(s => new { s.Id, s.Number, s.Notes })
.ToListAsync(ct);
// Возвращает строки где Notes начинается с маркера; восстанавливаем csid.
var alreadyByClient = alreadyByMarker
.Where(x => x.Notes!.StartsWith("pos:"))
.ToDictionary(
x => Guid.ParseExact(x.Notes!.Substring(4, 32), "N"),
x => (Id: x.Id, Number: x.Number));
var accepted = new List<PosSaleAcceptedDto>();
var failed = new List<PosSaleFailedDto>();
foreach (var sale in batch.Sales)
{
if (alreadyByClient.TryGetValue(sale.ClientSaleId, out var prior))
{
accepted.Add(new PosSaleAcceptedDto
{
ClientSaleId = sale.ClientSaleId,
ServerSaleId = prior.Id,
ServerSaleNumber = prior.Number,
});
continue;
}
try
{
var (serverSale, serverNumber) = await CreateAndPostSaleAsync(
sale, resolvedStore.Value, ct);
accepted.Add(new PosSaleAcceptedDto
{
ClientSaleId = sale.ClientSaleId,
ServerSaleId = serverSale,
ServerSaleNumber = serverNumber,
});
}
catch (PosSaleRejectedException ex)
{
failed.Add(new PosSaleFailedDto
{
ClientSaleId = sale.ClientSaleId,
Error = ex.Message,
Field = ex.Field,
});
}
}
// 3) Записываем ack под уникальным ключом. Если параллельный POS-запрос
// успел нас обогнать — словим 23505 и вернём его ack.
var response = new PosSaleBatchResponse
{
IdempotencyKey = batch.IdempotencyKey,
Accepted = accepted,
Failed = failed,
ReplayedFromCache = false,
};
var ack = new PosBatchAck
{
IdempotencyKey = batch.IdempotencyKey,
ResponseJson = JsonSerializer.Serialize(response),
};
_db.PosBatchAcks.Add(ack);
try
{
await _db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23505")
{
// Конфликт по уникальному индексу — параллельный запрос успел
// сохранить ack. Возвращаем тот ack как replay.
var other = await _db.PosBatchAcks.AsNoTracking()
.FirstAsync(a => a.IdempotencyKey == batch.IdempotencyKey, ct);
var prev = JsonSerializer.Deserialize<PosSaleBatchResponse>(other.ResponseJson)!;
return Ok(prev with { ReplayedFromCache = true });
}
return Ok(response);
}
/// <summary>Создаёт RetailSale + проводит, возвращая (id, number).
/// Pre-flight: проверка остатков. При неудаче кидает <see cref="PosSaleRejectedException"/>,
/// который ловится наверху и формирует Failed-запись батч-ответа.</summary>
private async Task<(Guid Id, string Number)> CreateAndPostSaleAsync(
PosSaleDto src, Guid storeId, CancellationToken ct)
{
if (src.Lines.Count == 0)
throw new PosSaleRejectedException("Пустой чек.", "lines");
// Pre-flight на остатки. Сделано до создания RetailSale чтобы зря не
// дёргать БД при пустой полке.
var byProduct = src.Lines.GroupBy(l => l.ProductId)
.Select(g => new { ProductId = g.Key, Qty = g.Sum(x => x.Quantity) }).ToList();
var productIds = byProduct.Select(x => x.ProductId).ToList();
var stocks = await _db.Stocks
.Where(s => s.StoreId == storeId && productIds.Contains(s.ProductId))
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
foreach (var x in byProduct)
{
stocks.TryGetValue(x.ProductId, out var avail);
if (avail < x.Qty)
throw new PosSaleRejectedException(
$"Недостаточный остаток для товара {x.ProductId}.", "productId");
}
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero);
var currencyId = await _db.Currencies.AsNoTracking()
.Where(c => c.Code == "KZT").Select(c => c.Id).FirstOrDefaultAsync(ct);
if (currencyId == Guid.Empty)
throw new PosSaleRejectedException("Не найдена валюта KZT.", "currency");
var number = await GenerateNumberAsync(src.OccurredAt, ct);
var sale = new RetailSale
{
Number = number,
Date = src.OccurredAt,
Status = RetailSaleStatus.Draft,
StoreId = storeId,
CurrencyId = currencyId,
CustomerId = src.CustomerId,
CashierUserId = src.CashierUserId,
Payment = (PaymentMethod)src.Payment,
PaidCash = R(src.PaidCash),
PaidCard = R(src.PaidCard),
// Маркер для per-sale идемпотентности (см. AcceptSales).
Notes = $"pos:{src.ClientSaleId:N}" + (string.IsNullOrEmpty(src.Notes) ? "" : "\n" + src.Notes),
};
var order = 0;
decimal subtotal = 0m, discountTotal = 0m;
foreach (var l in src.Lines)
{
var price = R(l.UnitPrice);
var disc = R(l.Discount);
var lineTotal = l.Quantity * price - disc;
_db.RetailSaleLines.Add(new RetailSaleLine
{
RetailSaleId = sale.Id,
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitPrice = price,
Discount = disc,
LineTotal = lineTotal,
VatPercent = l.VatPercent,
SortOrder = order++,
});
subtotal += l.Quantity * price;
discountTotal += disc;
}
sale.Subtotal = subtotal;
sale.DiscountTotal = discountTotal;
sale.Total = subtotal - discountTotal;
_db.RetailSales.Add(sale);
// Проведение: -Quantity StockMovement RetailSale.
foreach (var l in src.Lines)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: l.ProductId,
StoreId: storeId,
Quantity: -l.Quantity,
Type: MovementType.RetailSale,
DocumentType: "retail-sale",
DocumentId: sale.Id,
DocumentNumber: sale.Number,
UnitCost: l.UnitPrice,
OccurredAt: src.OccurredAt), ct);
}
sale.Status = RetailSaleStatus.Posted;
sale.PostedAt = DateTime.UtcNow;
try
{
await _db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23505")
{
// Уникальный конфликт по (OrgId, Number) — крайне редкий race на
// GenerateNumberAsync; пересоздавать номер сложно без транзакции,
// поэтому отказываем и POS попробует в следующем батче.
foodmarket.Api.Infrastructure.Observability.AppMetrics.IncrementError("retail-sale", "number_conflict");
throw new PosSaleRejectedException("Конфликт номера чека, повторите.", "number");
}
foodmarket.Api.Infrastructure.Observability.AppMetrics.IncrementPosted("retail-sale");
return (sale.Id, sale.Number);
}
private async Task<Guid?> ResolveStoreAsync(Guid? requested, CancellationToken ct)
{
if (requested is { } id)
{
var ok = await _db.Stores.AsNoTracking().AnyAsync(s => s.Id == id, ct);
return ok ? id : null;
}
// Дефолт — главный склад организации.
var main = await _db.Stores.AsNoTracking()
.Where(s => s.IsMain).Select(s => (Guid?)s.Id).FirstOrDefaultAsync(ct);
if (main is not null) return main;
// Иначе — любой активный.
return await _db.Stores.AsNoTracking()
.Where(s => s.IsActive).Select(s => (Guid?)s.Id).FirstOrDefaultAsync(ct);
}
private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken ct)
{
var prefix = $"ПР-{date.Year}-";
var lastNumber = await _db.RetailSales
.Where(s => s.Number.StartsWith(prefix))
.OrderByDescending(s => s.Number)
.Select(s => s.Number)
.FirstOrDefaultAsync(ct);
var seq = 1;
if (lastNumber is not null && int.TryParse(lastNumber[prefix.Length..], out var last))
seq = last + 1;
return $"{prefix}{seq:D6}";
}
}
/// <summary>Внутреннее исключение для отказа в продаже на уровне отдельного
/// чека: контроллер ловит и формирует строку Failed.</summary>
internal sealed class PosSaleRejectedException : Exception
{
public string? Field { get; }
public PosSaleRejectedException(string message, string? field) : base(message)
=> Field = field;
}

View file

@ -1,386 +0,0 @@
using System.ComponentModel.DataAnnotations;
using foodmarket.Application.Common;
using foodmarket.Application.Inventory;
using foodmarket.Domain.Inventory;
using foodmarket.Domain.Purchases;
using foodmarket.Infrastructure.Persistence;
using foodmarket.Api.Infrastructure.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Purchases;
/// <summary>Оприходование (Enter) — постановка товара на склад без поставщика.
/// Используется при запуске учёта (начальные остатки) и для излишков
/// инвентаризации. Зеркалит <see cref="SuppliesController"/>, но проще:
/// нет SupplierId, нет пересчёта <c>Product.Cost</c> (UnitCost — балансовая
/// цена для отчёта).</summary>
[ApiController]
[Authorize]
[Route("api/inventory/enters")]
public class EntersController : ControllerBase
{
private readonly AppDbContext _db;
private readonly IStockService _stock;
public EntersController(AppDbContext db, IStockService stock)
{
_db = db;
_stock = stock;
}
public record EnterListRow(
Guid Id, string Number, DateTime Date, EnterStatus Status,
Guid StoreId, string StoreName,
Guid CurrencyId, string CurrencyCode,
decimal Total, int LineCount,
DateTime? PostedAt);
public record EnterLineDto(
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle,
string? UnitSymbol,
decimal Quantity, decimal UnitCost, decimal LineTotal, int SortOrder);
public record EnterDto(
Guid Id, string Number, DateTime Date, EnterStatus Status,
Guid StoreId, string StoreName,
Guid CurrencyId, string CurrencyCode,
string? Notes,
decimal Total, DateTime? PostedAt,
IReadOnlyList<EnterLineDto> Lines);
public record EnterLineInput(
Guid ProductId,
[Range(0, 1e10)] decimal Quantity,
[Range(0, 1e10)] decimal UnitCost);
public record EnterInput(
DateTime Date, Guid StoreId, Guid CurrencyId,
string? Notes,
IReadOnlyList<EnterLineInput> Lines);
[HttpGet]
public async Task<ActionResult<PagedResult<EnterListRow>>> List(
[FromQuery] PagedRequest req,
[FromQuery] EnterStatus? status,
[FromQuery] Guid? storeId,
CancellationToken ct)
{
var q = from e in _db.Enters.AsNoTracking()
join st in _db.Stores on e.StoreId equals st.Id
join cu in _db.Currencies on e.CurrencyId equals cu.Id
select new { e, st, cu };
if (status is not null) q = q.Where(x => x.e.Status == status);
if (storeId is not null) q = q.Where(x => x.e.StoreId == storeId);
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.Trim().ToLower();
q = q.Where(x => x.e.Number.ToLower().Contains(s));
}
var total = await q.CountAsync(ct);
q = (req.Sort, req.Desc) switch
{
("number", false) => q.OrderBy(x => x.e.Number),
("number", true) => q.OrderByDescending(x => x.e.Number),
("store", false) => q.OrderBy(x => x.st.Name).ThenByDescending(x => x.e.Date),
("store", true) => q.OrderByDescending(x => x.st.Name).ThenByDescending(x => x.e.Date),
("status", false) => q.OrderBy(x => x.e.Status).ThenByDescending(x => x.e.Date),
("status", true) => q.OrderByDescending(x => x.e.Status).ThenByDescending(x => x.e.Date),
("total", false) => q.OrderBy(x => x.e.Total).ThenByDescending(x => x.e.Date),
("total", true) => q.OrderByDescending(x => x.e.Total).ThenByDescending(x => x.e.Date),
("date", false) => q.OrderBy(x => x.e.Date).ThenBy(x => x.e.Number),
_ => q.OrderByDescending(x => x.e.Date).ThenByDescending(x => x.e.Number),
};
var items = await q
.Skip(req.Skip).Take(req.Take)
.Select(x => new EnterListRow(
x.e.Id, x.e.Number, x.e.Date, x.e.Status,
x.st.Id, x.st.Name,
x.cu.Id, x.cu.Code,
x.e.Total,
x.e.Lines.Count,
x.e.PostedAt))
.ToListAsync(ct);
return new PagedResult<EnterListRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<EnterDto>> Get(Guid id, CancellationToken ct)
{
var dto = await GetInternal(id, ct);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost, RequiresPermission("EnterEdit")]
public async Task<ActionResult<EnterDto>> Create([FromBody] EnterInput input, CancellationToken ct)
{
if (RequiredGuid.FirstMissing(
(nameof(input.StoreId), input.StoreId),
(nameof(input.CurrencyId), input.CurrencyId)) is { } missing)
return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing });
if (input.Lines is null || input.Lines.Count == 0)
return BadRequest(new { error = "Оприходование должно содержать хотя бы одну позицию." });
var number = await GenerateNumberAsync(input.Date, ct);
var enter = new Enter
{
Number = number,
Date = input.Date,
Status = EnterStatus.Draft,
StoreId = input.StoreId,
CurrencyId = input.CurrencyId,
Notes = input.Notes,
};
var order = 0;
foreach (var l in input.Lines)
{
enter.Lines.Add(new EnterLine
{
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitCost = l.UnitCost,
LineTotal = l.Quantity * l.UnitCost,
SortOrder = order++,
});
}
enter.Total = enter.Lines.Sum(x => x.LineTotal);
_db.Enters.Add(enter);
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
var dto = await GetInternal(enter.Id, ct);
return CreatedAtAction(nameof(Get), new { id = enter.Id }, dto);
}
private async Task<ActionResult?> SaveOrFkErrorAsync(CancellationToken ct)
{
try
{
await _db.SaveChangesAsync(ct);
return null;
}
catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23503")
{
var name = pg.ConstraintName ?? "";
string field = name.Contains("Store") ? "storeId"
: name.Contains("Currency") ? "currencyId"
: name.Contains("Product") ? "productId"
: "(unknown)";
return BadRequest(new { error = $"Связанная запись не найдена: {field}.", field, constraint = name });
}
}
[HttpPut("{id:guid}"), RequiresPermission("EnterEdit")]
public async Task<IActionResult> Update(Guid id, [FromBody] EnterInput input, CancellationToken ct)
{
if (RequiredGuid.FirstMissing(
(nameof(input.StoreId), input.StoreId),
(nameof(input.CurrencyId), input.CurrencyId)) is { } missing)
return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing });
if (input.Lines is null || input.Lines.Count == 0)
return BadRequest(new { error = "Оприходование должно содержать хотя бы одну позицию." });
var enter = await _db.Enters.Include(e => e.Lines).FirstOrDefaultAsync(e => e.Id == id, ct);
if (enter is null) return NotFound();
if (enter.Status != EnterStatus.Draft)
return Conflict(new { error = "Только черновик может быть изменён. Сначала отмени проведение." });
enter.Date = input.Date;
enter.StoreId = input.StoreId;
enter.CurrencyId = input.CurrencyId;
enter.Notes = input.Notes;
_db.EnterLines.RemoveRange(enter.Lines);
enter.Lines.Clear();
var order = 0;
foreach (var l in input.Lines)
{
enter.Lines.Add(new EnterLine
{
EnterId = enter.Id,
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitCost = l.UnitCost,
LineTotal = l.Quantity * l.UnitCost,
SortOrder = order++,
});
}
enter.Total = enter.Lines.Sum(x => x.LineTotal);
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
return NoContent();
}
[HttpDelete("{id:guid}"), RequiresPermission("EnterEdit")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var enter = await _db.Enters.FirstOrDefaultAsync(e => e.Id == id, ct);
if (enter is null) return NotFound();
if (enter.Status != EnterStatus.Draft)
return Conflict(new { error = "Нельзя удалить проведённый документ. Сначала отмени проведение." });
_db.Enters.Remove(enter);
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpPost("{id:guid}/post"), RequiresPermission("EnterEdit")]
public async Task<IActionResult> Post(Guid id, CancellationToken ct)
{
var enter = await _db.Enters.Include(e => e.Lines).FirstOrDefaultAsync(e => e.Id == id, ct);
if (enter is null) return NotFound();
if (enter.Status == EnterStatus.Posted) return Conflict(new { error = "Документ уже проведён." });
if (enter.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." });
await using var tx = await _db.Database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, ct);
var now = DateTime.UtcNow;
foreach (var line in enter.Lines)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: enter.StoreId,
Quantity: line.Quantity,
Type: MovementType.Enter,
DocumentType: "enter",
DocumentId: enter.Id,
DocumentNumber: enter.Number,
UnitCost: line.UnitCost,
OccurredAt: enter.Date), ct);
}
enter.Status = EnterStatus.Posted;
enter.PostedAt = now;
try
{
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
}
catch (Exception ex) when (IsSerializationConflict(ex))
{
return Conflict(new { error = "Документ проводится параллельно другим запросом. Повторите попытку." });
}
return NoContent();
}
[HttpPost("{id:guid}/unpost"), RequiresPermission("EnterEdit")]
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
{
var enter = await _db.Enters.Include(e => e.Lines).FirstOrDefaultAsync(e => e.Id == id, ct);
if (enter is null) return NotFound();
if (enter.Status != EnterStatus.Posted) return Conflict(new { error = "Документ не проведён." });
// Reverse: проверяем что текущий остаток позволяет «снять» оприходование
// без ухода в минус (часть товара может быть уже продана).
var reverseByProduct = enter.Lines
.GroupBy(l => l.ProductId)
.Select(g => new { ProductId = g.Key, Quantity = g.Sum(x => x.Quantity) })
.ToList();
var productIds = reverseByProduct.Select(r => r.ProductId).ToList();
var stocks = await _db.Stocks
.Where(s => s.StoreId == enter.StoreId && productIds.Contains(s.ProductId))
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
var conflicts = new List<object>();
foreach (var r in reverseByProduct)
{
stocks.TryGetValue(r.ProductId, out var available);
if (available < r.Quantity)
{
var name = await _db.Products
.Where(p => p.Id == r.ProductId).Select(p => p.Name).FirstOrDefaultAsync(ct);
conflicts.Add(new
{
productId = r.ProductId,
productName = name,
reverseQty = r.Quantity,
available,
});
}
}
if (conflicts.Count > 0)
{
return Conflict(new
{
error = "Нельзя отменить проведение: остаток уйдёт в минус (часть товара уже расходована).",
lines = conflicts,
});
}
foreach (var line in enter.Lines)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: enter.StoreId,
Quantity: -line.Quantity,
Type: MovementType.Enter,
DocumentType: "enter-reversal",
DocumentId: enter.Id,
DocumentNumber: enter.Number,
UnitCost: line.UnitCost,
OccurredAt: DateTime.UtcNow,
Notes: $"Отмена проведения документа {enter.Number}"), ct);
}
enter.Status = EnterStatus.Draft;
enter.PostedAt = null;
await _db.SaveChangesAsync(ct);
return NoContent();
}
private static bool IsSerializationConflict(Exception ex)
{
for (Exception? e = ex; e is not null; e = e.InnerException)
{
if (e is System.Data.Common.DbException { SqlState: "40001" or "40P01" })
return true;
}
return false;
}
private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken ct)
{
var year = date.Year;
var prefix = $"О-{year}-";
var lastNumber = await _db.Enters
.Where(e => e.Number.StartsWith(prefix))
.OrderByDescending(e => e.Number)
.Select(e => e.Number)
.FirstOrDefaultAsync(ct);
var seq = 1;
if (lastNumber is not null && int.TryParse(lastNumber[prefix.Length..], out var last))
seq = last + 1;
return $"{prefix}{seq:D6}";
}
private async Task<EnterDto?> GetInternal(Guid id, CancellationToken ct)
{
var row = await (from e in _db.Enters.AsNoTracking()
join st in _db.Stores on e.StoreId equals st.Id
join cu in _db.Currencies on e.CurrencyId equals cu.Id
where e.Id == id
select new { e, st, cu }).FirstOrDefaultAsync(ct);
if (row is null) return null;
var lines = await (from l in _db.EnterLines.AsNoTracking()
join p in _db.Products on l.ProductId equals p.Id
join u in _db.UnitsOfMeasure on p.UnitOfMeasureId equals u.Id
where l.EnterId == id
orderby l.SortOrder
select new EnterLineDto(
l.Id, l.ProductId, p.Name, p.Article,
u.Name,
l.Quantity, l.UnitCost, l.LineTotal, l.SortOrder))
.ToListAsync(ct);
return new EnterDto(
row.e.Id, row.e.Number, row.e.Date, row.e.Status,
row.st.Id, row.st.Name,
row.cu.Id, row.cu.Code,
row.e.Notes,
row.e.Total, row.e.PostedAt,
lines);
}
}

View file

@ -1,412 +0,0 @@
using System.ComponentModel.DataAnnotations;
using foodmarket.Application.Common;
using foodmarket.Application.Inventory;
using foodmarket.Domain.Inventory;
using foodmarket.Domain.Purchases;
using foodmarket.Infrastructure.Persistence;
using foodmarket.Api.Infrastructure.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Purchases;
/// <summary>Возврат поставщику. Списывает товар со склада (как Supply, но
/// со знаком минус и movement type SupplierReturn). Опционально ссылается на
/// исходную приёмку через <c>referenceSupplyId</c>.</summary>
[ApiController]
[Authorize]
[Route("api/purchases/supplier-returns")]
public class SupplierReturnsController : ControllerBase
{
private readonly AppDbContext _db;
private readonly IStockService _stock;
public SupplierReturnsController(AppDbContext db, IStockService stock)
{
_db = db;
_stock = stock;
}
public record SupplierReturnListRow(
Guid Id, string Number, DateTime Date, SupplierReturnStatus Status,
Guid SupplierId, string SupplierName,
Guid StoreId, string StoreName,
Guid CurrencyId, string CurrencyCode,
decimal Total, int LineCount,
DateTime? PostedAt,
Guid? ReferenceSupplyId, string? ReferenceSupplyNumber);
public record SupplierReturnLineDto(
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle, string? UnitSymbol,
decimal Quantity, decimal UnitPrice, decimal LineTotal, int SortOrder,
decimal? StockAtStore);
public record SupplierReturnDto(
Guid Id, string Number, DateTime Date, SupplierReturnStatus Status,
Guid SupplierId, string SupplierName,
Guid StoreId, string StoreName,
Guid CurrencyId, string CurrencyCode,
Guid? ReferenceSupplyId, string? ReferenceSupplyNumber,
string? Notes,
decimal Total, DateTime? PostedAt,
IReadOnlyList<SupplierReturnLineDto> Lines);
public record SupplierReturnLineInput(
Guid ProductId,
[Range(0, 1e10)] decimal Quantity,
[Range(0, 1e10)] decimal UnitPrice);
public record SupplierReturnInput(
DateTime Date,
Guid SupplierId, Guid StoreId, Guid CurrencyId,
Guid? ReferenceSupplyId,
string? Notes,
IReadOnlyList<SupplierReturnLineInput> Lines);
[HttpGet]
public async Task<ActionResult<PagedResult<SupplierReturnListRow>>> List(
[FromQuery] PagedRequest req,
[FromQuery] SupplierReturnStatus? status,
[FromQuery] Guid? supplierId,
[FromQuery] Guid? storeId,
CancellationToken ct)
{
var q = from r in _db.SupplierReturns.AsNoTracking()
join cp in _db.Counterparties on r.SupplierId equals cp.Id
join st in _db.Stores on r.StoreId equals st.Id
join cu in _db.Currencies on r.CurrencyId equals cu.Id
select new { r, cp, st, cu };
if (status is not null) q = q.Where(x => x.r.Status == status);
if (supplierId is not null) q = q.Where(x => x.r.SupplierId == supplierId);
if (storeId is not null) q = q.Where(x => x.r.StoreId == storeId);
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.Trim().ToLower();
q = q.Where(x => x.r.Number.ToLower().Contains(s) || x.cp.Name.ToLower().Contains(s));
}
var total = await q.CountAsync(ct);
q = (req.Sort, req.Desc) switch
{
("number", false) => q.OrderBy(x => x.r.Number),
("number", true) => q.OrderByDescending(x => x.r.Number),
("supplier", false) => q.OrderBy(x => x.cp.Name).ThenByDescending(x => x.r.Date),
("supplier", true) => q.OrderByDescending(x => x.cp.Name).ThenByDescending(x => x.r.Date),
("status", false) => q.OrderBy(x => x.r.Status).ThenByDescending(x => x.r.Date),
("status", true) => q.OrderByDescending(x => x.r.Status).ThenByDescending(x => x.r.Date),
("total", false) => q.OrderBy(x => x.r.Total).ThenByDescending(x => x.r.Date),
("total", true) => q.OrderByDescending(x => x.r.Total).ThenByDescending(x => x.r.Date),
("date", false) => q.OrderBy(x => x.r.Date).ThenBy(x => x.r.Number),
_ => q.OrderByDescending(x => x.r.Date).ThenByDescending(x => x.r.Number),
};
var items = await q
.Skip(req.Skip).Take(req.Take)
.Select(x => new SupplierReturnListRow(
x.r.Id, x.r.Number, x.r.Date, x.r.Status,
x.cp.Id, x.cp.Name,
x.st.Id, x.st.Name,
x.cu.Id, x.cu.Code,
x.r.Total,
x.r.Lines.Count,
x.r.PostedAt,
x.r.ReferenceSupplyId,
x.r.ReferenceSupplyId == null ? null : _db.Supplies.Where(s => s.Id == x.r.ReferenceSupplyId).Select(s => s.Number).FirstOrDefault()))
.ToListAsync(ct);
return new PagedResult<SupplierReturnListRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<SupplierReturnDto>> Get(Guid id, CancellationToken ct)
{
var dto = await GetInternal(id, ct);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost, RequiresPermission("SuppliesEdit")]
public async Task<ActionResult<SupplierReturnDto>> Create([FromBody] SupplierReturnInput input, CancellationToken ct)
{
if (RequiredGuid.FirstMissing(
(nameof(input.SupplierId), input.SupplierId),
(nameof(input.StoreId), input.StoreId),
(nameof(input.CurrencyId), input.CurrencyId)) is { } missing)
return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing });
if (input.Lines is null || input.Lines.Count == 0)
return BadRequest(new { error = "Возврат должен содержать хотя бы одну позицию." });
if (input.ReferenceSupplyId is { } refId)
{
var refSupply = await _db.Supplies.AsNoTracking().FirstOrDefaultAsync(s => s.Id == refId, ct);
if (refSupply is null)
return BadRequest(new { error = "Исходная приёмка не найдена.", field = "referenceSupplyId" });
if (refSupply.Status != SupplyStatus.Posted)
return BadRequest(new { error = "Можно возвращать только из проведённой приёмки.", field = "referenceSupplyId" });
if (refSupply.SupplierId != input.SupplierId)
return BadRequest(new { error = "Поставщик возврата должен совпадать с поставщиком исходной приёмки.", field = "supplierId" });
}
var number = await GenerateNumberAsync(input.Date, ct);
var r = new SupplierReturn
{
Number = number,
Date = input.Date,
Status = SupplierReturnStatus.Draft,
SupplierId = input.SupplierId,
StoreId = input.StoreId,
CurrencyId = input.CurrencyId,
ReferenceSupplyId = input.ReferenceSupplyId,
Notes = input.Notes,
};
var order = 0;
foreach (var l in input.Lines)
{
r.Lines.Add(new SupplierReturnLine
{
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitPrice = l.UnitPrice,
LineTotal = l.Quantity * l.UnitPrice,
SortOrder = order++,
});
}
r.Total = r.Lines.Sum(x => x.LineTotal);
_db.SupplierReturns.Add(r);
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
var dto = await GetInternal(r.Id, ct);
return CreatedAtAction(nameof(Get), new { id = r.Id }, dto);
}
private async Task<ActionResult?> SaveOrFkErrorAsync(CancellationToken ct)
{
try
{
await _db.SaveChangesAsync(ct);
return null;
}
catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23503")
{
var name = pg.ConstraintName ?? "";
string field = name.Contains("Supplier") ? "supplierId"
: name.Contains("Store") ? "storeId"
: name.Contains("Currency") ? "currencyId"
: name.Contains("Product") ? "productId"
: name.Contains("Supply") ? "referenceSupplyId"
: "(unknown)";
return BadRequest(new { error = $"Связанная запись не найдена: {field}.", field, constraint = name });
}
}
[HttpPut("{id:guid}"), RequiresPermission("SuppliesEdit")]
public async Task<IActionResult> Update(Guid id, [FromBody] SupplierReturnInput input, CancellationToken ct)
{
if (RequiredGuid.FirstMissing(
(nameof(input.SupplierId), input.SupplierId),
(nameof(input.StoreId), input.StoreId),
(nameof(input.CurrencyId), input.CurrencyId)) is { } missing)
return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing });
if (input.Lines is null || input.Lines.Count == 0)
return BadRequest(new { error = "Возврат должен содержать хотя бы одну позицию." });
var r = await _db.SupplierReturns.Include(x => x.Lines).FirstOrDefaultAsync(x => x.Id == id, ct);
if (r is null) return NotFound();
if (r.Status != SupplierReturnStatus.Draft)
return Conflict(new { error = "Только черновик может быть изменён. Сначала отмени проведение." });
r.Date = input.Date;
r.SupplierId = input.SupplierId;
r.StoreId = input.StoreId;
r.CurrencyId = input.CurrencyId;
r.ReferenceSupplyId = input.ReferenceSupplyId;
r.Notes = input.Notes;
_db.SupplierReturnLines.RemoveRange(r.Lines);
r.Lines.Clear();
var order = 0;
foreach (var l in input.Lines)
{
r.Lines.Add(new SupplierReturnLine
{
SupplierReturnId = r.Id,
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitPrice = l.UnitPrice,
LineTotal = l.Quantity * l.UnitPrice,
SortOrder = order++,
});
}
r.Total = r.Lines.Sum(x => x.LineTotal);
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
return NoContent();
}
[HttpDelete("{id:guid}"), RequiresPermission("SuppliesDelete")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var r = await _db.SupplierReturns.FirstOrDefaultAsync(x => x.Id == id, ct);
if (r is null) return NotFound();
if (r.Status != SupplierReturnStatus.Draft)
return Conflict(new { error = "Нельзя удалить проведённый документ. Сначала отмени проведение." });
_db.SupplierReturns.Remove(r);
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpPost("{id:guid}/post"), RequiresPermission("SuppliesPost")]
public async Task<IActionResult> Post(Guid id, CancellationToken ct)
{
var r = await _db.SupplierReturns.Include(x => x.Lines).FirstOrDefaultAsync(x => x.Id == id, ct);
if (r is null) return NotFound();
if (r.Status == SupplierReturnStatus.Posted) return Conflict(new { error = "Документ уже проведён." });
if (r.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." });
// Защита от ухода в минус.
var byProduct = r.Lines.GroupBy(l => l.ProductId)
.Select(g => new { ProductId = g.Key, Quantity = g.Sum(x => x.Quantity) }).ToList();
var productIds = byProduct.Select(x => x.ProductId).ToList();
var stocks = await _db.Stocks
.Where(s => s.StoreId == r.StoreId && productIds.Contains(s.ProductId))
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
var conflicts = new List<object>();
foreach (var x in byProduct)
{
stocks.TryGetValue(x.ProductId, out var available);
if (available < x.Quantity)
{
var name = await _db.Products.Where(p => p.Id == x.ProductId).Select(p => p.Name).FirstOrDefaultAsync(ct);
conflicts.Add(new
{
productId = x.ProductId,
productName = name,
returnQty = x.Quantity,
available,
});
}
}
if (conflicts.Count > 0)
return Conflict(new { error = "Нельзя вернуть больше, чем есть на складе.", lines = conflicts });
await using var tx = await _db.Database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, ct);
foreach (var line in r.Lines)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: r.StoreId,
Quantity: -line.Quantity,
Type: MovementType.SupplierReturn,
DocumentType: "supplier-return",
DocumentId: r.Id,
DocumentNumber: r.Number,
UnitCost: line.UnitPrice,
OccurredAt: r.Date), ct);
}
r.Status = SupplierReturnStatus.Posted;
r.PostedAt = DateTime.UtcNow;
try
{
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
}
catch (Exception ex) when (IsSerializationConflict(ex))
{
return Conflict(new { error = "Документ проводится параллельно. Повторите попытку." });
}
return NoContent();
}
[HttpPost("{id:guid}/unpost"), RequiresPermission("SuppliesPost")]
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
{
var r = await _db.SupplierReturns.Include(x => x.Lines).FirstOrDefaultAsync(x => x.Id == id, ct);
if (r is null) return NotFound();
if (r.Status != SupplierReturnStatus.Posted) return Conflict(new { error = "Документ не проведён." });
// Reverse — добавляем товар обратно, проверка на минус не нужна (только растёт).
foreach (var line in r.Lines)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: r.StoreId,
Quantity: line.Quantity,
Type: MovementType.SupplierReturn,
DocumentType: "supplier-return-reversal",
DocumentId: r.Id,
DocumentNumber: r.Number,
UnitCost: line.UnitPrice,
OccurredAt: DateTime.UtcNow,
Notes: $"Отмена возврата {r.Number}"), ct);
}
r.Status = SupplierReturnStatus.Draft;
r.PostedAt = null;
await _db.SaveChangesAsync(ct);
return NoContent();
}
private static bool IsSerializationConflict(Exception ex)
{
for (Exception? e = ex; e is not null; e = e.InnerException)
{
if (e is System.Data.Common.DbException { SqlState: "40001" or "40P01" })
return true;
}
return false;
}
private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken ct)
{
var year = date.Year;
var prefix = $"ВП-{year}-";
var lastNumber = await _db.SupplierReturns
.Where(r => r.Number.StartsWith(prefix))
.OrderByDescending(r => r.Number)
.Select(r => r.Number)
.FirstOrDefaultAsync(ct);
var seq = 1;
if (lastNumber is not null && int.TryParse(lastNumber[prefix.Length..], out var last))
seq = last + 1;
return $"{prefix}{seq:D6}";
}
private async Task<SupplierReturnDto?> GetInternal(Guid id, CancellationToken ct)
{
var row = await (from r in _db.SupplierReturns.AsNoTracking()
join cp in _db.Counterparties on r.SupplierId equals cp.Id
join st in _db.Stores on r.StoreId equals st.Id
join cu in _db.Currencies on r.CurrencyId equals cu.Id
where r.Id == id
select new { r, cp, st, cu }).FirstOrDefaultAsync(ct);
if (row is null) return null;
string? refNumber = row.r.ReferenceSupplyId is null ? null
: await _db.Supplies.Where(s => s.Id == row.r.ReferenceSupplyId).Select(s => s.Number).FirstOrDefaultAsync(ct);
var lines = await (from l in _db.SupplierReturnLines.AsNoTracking()
join p in _db.Products on l.ProductId equals p.Id
join u in _db.UnitsOfMeasure on p.UnitOfMeasureId equals u.Id
where l.SupplierReturnId == id
orderby l.SortOrder
select new SupplierReturnLineDto(
l.Id, l.ProductId, p.Name, p.Article, u.Name,
l.Quantity, l.UnitPrice, l.LineTotal, l.SortOrder,
_db.Stocks.Where(s => s.ProductId == l.ProductId && s.StoreId == row.r.StoreId)
.Select(s => (decimal?)s.Quantity).FirstOrDefault()))
.ToListAsync(ct);
return new SupplierReturnDto(
row.r.Id, row.r.Number, row.r.Date, row.r.Status,
row.cp.Id, row.cp.Name,
row.st.Id, row.st.Name,
row.cu.Id, row.cu.Code,
row.r.ReferenceSupplyId, refNumber,
row.r.Notes,
row.r.Total, row.r.PostedAt,
lines);
}
}

View file

@ -4,7 +4,6 @@
using foodmarket.Domain.Inventory; using foodmarket.Domain.Inventory;
using foodmarket.Domain.Purchases; using foodmarket.Domain.Purchases;
using foodmarket.Infrastructure.Persistence; using foodmarket.Infrastructure.Persistence;
using foodmarket.Api.Infrastructure.Authorization;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -18,13 +17,11 @@ public class SuppliesController : ControllerBase
{ {
private readonly AppDbContext _db; private readonly AppDbContext _db;
private readonly IStockService _stock; private readonly IStockService _stock;
private readonly ILogger<SuppliesController> _log;
public SuppliesController(AppDbContext db, IStockService stock, ILogger<SuppliesController> log) public SuppliesController(AppDbContext db, IStockService stock)
{ {
_db = db; _db = db;
_stock = stock; _stock = stock;
_log = log;
} }
public record SupplyListRow( public record SupplyListRow(
@ -50,7 +47,6 @@ public record SupplyDto(
Guid CurrencyId, string CurrencyCode, Guid CurrencyId, string CurrencyCode,
string? Notes, string? Notes,
decimal Total, DateTime? PostedAt, decimal Total, DateTime? PostedAt,
uint Xmin,
IReadOnlyList<SupplyLineDto> Lines); IReadOnlyList<SupplyLineDto> Lines);
public record SupplyLineInput( public record SupplyLineInput(
@ -62,11 +58,7 @@ public record SupplyLineInput(
public record SupplyInput( public record SupplyInput(
DateTime Date, Guid SupplierId, Guid StoreId, Guid CurrencyId, DateTime Date, Guid SupplierId, Guid StoreId, Guid CurrencyId,
string? Notes, string? Notes,
IReadOnlyList<SupplyLineInput> Lines, IReadOnlyList<SupplyLineInput> Lines);
// Optimistic concurrency token. null/0 для нового черновика (POST),
// обязателен для PUT — иначе считаем что клиент не передал version
// и пускаем без сверки (legacy). При несовпадении контроллер возвращает 409.
uint? Xmin = null);
[HttpGet] [HttpGet]
public async Task<ActionResult<PagedResult<SupplyListRow>>> List( public async Task<ActionResult<PagedResult<SupplyListRow>>> List(
@ -129,7 +121,7 @@ public async Task<ActionResult<SupplyDto>> Get(Guid id, CancellationToken ct)
return dto is null ? NotFound() : Ok(dto); return dto is null ? NotFound() : Ok(dto);
} }
[HttpPost, RequiresPermission("SuppliesEdit")] [HttpPost, Authorize(Roles = "Admin,Storekeeper")]
public async Task<ActionResult<SupplyDto>> Create([FromBody] SupplyInput input, CancellationToken ct) public async Task<ActionResult<SupplyDto>> Create([FromBody] SupplyInput input, CancellationToken ct)
{ {
if (RequiredGuid.FirstMissing( if (RequiredGuid.FirstMissing(
@ -187,16 +179,6 @@ public async Task<ActionResult<SupplyDto>> Create([FromBody] SupplyInput input,
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return null; return null;
} }
catch (DbUpdateConcurrencyException)
{
// Optimistic concurrency: кто-то другой обновил документ между
// SELECT и UPDATE. Клиент должен перезагрузить и попробовать снова.
return Conflict(new
{
error = "Документ изменён другим пользователем. Обновите страницу и повторите.",
code = "concurrency_conflict",
});
}
catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23503") catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23503")
{ {
// pg.ConstraintName выглядит как FK_supplies_counterparties_SupplierId // pg.ConstraintName выглядит как FK_supplies_counterparties_SupplierId
@ -211,7 +193,7 @@ public async Task<ActionResult<SupplyDto>> Create([FromBody] SupplyInput input,
} }
} }
[HttpPut("{id:guid}"), RequiresPermission("SuppliesEdit")] [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")]
public async Task<IActionResult> Update(Guid id, [FromBody] SupplyInput input, CancellationToken ct) public async Task<IActionResult> Update(Guid id, [FromBody] SupplyInput input, CancellationToken ct)
{ {
if (RequiredGuid.FirstMissing( if (RequiredGuid.FirstMissing(
@ -226,44 +208,21 @@ public async Task<IActionResult> Update(Guid id, [FromBody] SupplyInput input, C
if (supply.Status != SupplyStatus.Draft) if (supply.Status != SupplyStatus.Draft)
return Conflict(new { error = "Только черновик может быть изменён. Сначала отмени проведение." }); return Conflict(new { error = "Только черновик может быть изменён. Сначала отмени проведение." });
// Optimistic concurrency: если клиент прислал Xmin, сверяем с тем,
// что EF только что прочитал через Include. Несовпадение → 409
// (кто-то изменил документ между нашими GET и PUT). Прозрачнее, чем
// полагаться на EF DbUpdateConcurrencyException — там сообщение
// зависит от других UPDATE'ов в одной SaveChanges. null/0 от клиента
// → пропускаем проверку (старые клиенты).
// Optimistic concurrency: если клиент прислал Xmin, сверяем со shadow
// property "xmin", которую EF только что загрузила. Несовпадение → 409.
if (input.Xmin is { } cliXmin && cliXmin != 0)
{
var serverXmin = (uint)_db.Entry(supply).Property("xmin").CurrentValue!;
if (serverXmin != cliXmin)
{
return Conflict(new
{
error = "Документ изменён другим пользователем. Обновите страницу и повторите.",
code = "concurrency_conflict",
});
}
}
supply.Date = input.Date; supply.Date = input.Date;
supply.SupplierId = input.SupplierId; supply.SupplierId = input.SupplierId;
supply.StoreId = input.StoreId; supply.StoreId = input.StoreId;
supply.CurrencyId = input.CurrencyId; supply.CurrencyId = input.CurrencyId;
supply.Notes = input.Notes; supply.Notes = input.Notes;
// Удаляем старые строки через ExecuteDelete (минует трекер), новые // Replace lines wholesale (simple, idempotent).
// добавляем напрямую в DbSet — иначе EF8 на nav-collection+client-side Id _db.SupplyLines.RemoveRange(supply.Lines);
// путается и UPDATE supplies с concurrency-token-WHERE падает 0 affected. supply.Lines.Clear();
// Тот же паттерн что в RetailSale/Demand.Update.
await _db.SupplyLines.Where(l => l.SupplyId == supply.Id).ExecuteDeleteAsync(ct);
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct); var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
var order = 0; var order = 0;
foreach (var l in input.Lines) foreach (var l in input.Lines)
{ {
var unitPrice = allowFractional ? l.UnitPrice : Math.Round(l.UnitPrice, 0, MidpointRounding.AwayFromZero); var unitPrice = allowFractional ? l.UnitPrice : Math.Round(l.UnitPrice, 0, MidpointRounding.AwayFromZero);
_db.SupplyLines.Add(new SupplyLine supply.Lines.Add(new SupplyLine
{ {
SupplyId = supply.Id, SupplyId = supply.Id,
ProductId = l.ProductId, ProductId = l.ProductId,
@ -277,16 +236,13 @@ public async Task<IActionResult> Update(Guid id, [FromBody] SupplyInput input, C
: null, : null,
}); });
} }
// Total считаем из input напрямую — supply.Lines navigation в этом supply.Total = supply.Lines.Sum(x => x.LineTotal);
// подходе пустая (мы добавляли через DbSet).
supply.Total = input.Lines.Sum(l =>
l.Quantity * (allowFractional ? l.UnitPrice : Math.Round(l.UnitPrice, 0, MidpointRounding.AwayFromZero)));
if (await SaveOrFkErrorAsync(ct) is { } err) return err; if (await SaveOrFkErrorAsync(ct) is { } err) return err;
return NoContent(); return NoContent();
} }
[HttpDelete("{id:guid}"), RequiresPermission("SuppliesDelete")] [HttpDelete("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct) public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{ {
var supply = await _db.Supplies.FirstOrDefaultAsync(s => s.Id == id, ct); var supply = await _db.Supplies.FirstOrDefaultAsync(s => s.Id == id, ct);
@ -298,7 +254,7 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
return NoContent(); return NoContent();
} }
[HttpPost("{id:guid}/post"), RequiresPermission("SuppliesPost")] [HttpPost("{id:guid}/post"), Authorize(Roles = "Admin,Storekeeper")]
public async Task<IActionResult> Post(Guid id, CancellationToken ct) public async Task<IActionResult> Post(Guid id, CancellationToken ct)
{ {
var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct); var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
@ -308,16 +264,7 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct); var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
// Serializable: ApplyMovementAsync делает read-modify-write по await using var tx = await _db.Database.BeginTransactionAsync(ct);
// Stock.Quantity без RowVersion. На дефолтной изоляции два
// одновременных проведения (в т.ч. двойное проведение ОДНОГО
// документа, проскочившее проверку статуса выше до коммита соседа)
// дают lost update: остаток отстаёт от Σ StockMovement, приёмка
// применяется дважды, скользящее среднее Cost считается от
// устаревшего currentQty. Serializable заставляет конкурирующую
// транзакцию откатиться (40001) — ловим ниже и отдаём 409.
await using var tx = await _db.Database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, ct);
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
foreach (var line in supply.Lines) foreach (var line in supply.Lines)
@ -332,9 +279,12 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
.Where(s => s.ProductId == line.ProductId) .Where(s => s.ProductId == line.ProductId)
.SumAsync(s => (decimal?)s.Quantity, ct) ?? 0m; .SumAsync(s => (decimal?)s.Quantity, ct) ?? 0m;
// 1. Cost — скользящее среднее (формула в MovingAverageCost, юнит-тест). // 1. Cost — скользящее среднее.
product.Cost = foodmarket.Application.Inventory.MovingAverageCost.Compute( var totalQty = currentQty + line.Quantity;
currentQty, product.Cost, line.Quantity, line.UnitPrice); var newCost = totalQty == 0m || product.Cost == 0m && currentQty == 0m
? line.UnitPrice
: (currentQty * product.Cost + line.Quantity * line.UnitPrice) / totalQty;
product.Cost = Math.Round(newCost, 4, MidpointRounding.AwayFromZero);
// 2. ReferencePrice — автозаполнение при первой приёмке. // 2. ReferencePrice — автозаполнение при первой приёмке.
if (product.ReferencePrice is null) if (product.ReferencePrice is null)
@ -372,42 +322,11 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
supply.Status = SupplyStatus.Posted; supply.Status = SupplyStatus.Posted;
supply.PostedAt = now; supply.PostedAt = now;
try await _db.SaveChangesAsync(ct);
{ await tx.CommitAsync(ct);
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
}
catch (Exception ex) when (IsSerializationConflict(ex))
{
foodmarket.Api.Infrastructure.Observability.AppMetrics.IncrementError("supply", "serialization");
return Conflict(new { error = "Документ проводится параллельно другим запросом. Повторите попытку." });
}
foodmarket.Api.Infrastructure.Observability.AppMetrics.IncrementPosted("supply");
// Structured business log: лейблы Number/SupplierId/StoreId/LinesCount/Total
// попадут в Serilog как отдельные свойства, не часть текста — удобно
// фильтровать в Loki/ELK без regex'ов. CorrelationId/OrgId/UserId
// подмешает LogEnrichmentMiddleware из LogContext.
_log.LogInformation(
"Supply posted: {SupplyNumber} supplier={SupplierId} store={StoreId} lines={LinesCount} total={Total}",
supply.Number, supply.SupplierId, supply.StoreId, supply.Lines.Count, supply.Total);
return NoContent(); return NoContent();
} }
/// <summary>True, если исключение (или любое вложенное) — конфликт
/// сериализации/дедлок Postgres (SQLSTATE 40001 / 40P01). Возникает, когда
/// Serializable-транзакция откатывается из-за конкурирующего проведения.
/// Используем System.Data.Common.DbException.SqlState (.NET 8), чтобы не
/// тянуть прямую зависимость на Npgsql в API-слой.</summary>
private static bool IsSerializationConflict(Exception ex)
{
for (Exception? e = ex; e is not null; e = e.InnerException)
{
if (e is System.Data.Common.DbException { SqlState: "40001" or "40P01" })
return true;
}
return false;
}
/// <summary>Записывает значение в дефолтный розничный PriceType. Если в списке /// <summary>Записывает значение в дефолтный розничный PriceType. Если в списке
/// цен у товара такой записи нет — создаёт её. Дефолтным считается PriceType /// цен у товара такой записи нет — создаёт её. Дефолтным считается PriceType
/// с IsSystem=true; если такого нет — первый IsRetail; иначе — первый /// с IsSystem=true; если такого нет — первый IsRetail; иначе — первый
@ -437,54 +356,13 @@ private void SetDefaultRetail(foodmarket.Domain.Catalog.Product p, decimal value
} }
} }
[HttpPost("{id:guid}/unpost"), RequiresPermission("SuppliesPost")] [HttpPost("{id:guid}/unpost"), Authorize(Roles = "Admin,Storekeeper")]
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct) public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
{ {
var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct); var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
if (supply is null) return NotFound(); if (supply is null) return NotFound();
if (supply.Status != SupplyStatus.Posted) return Conflict(new { error = "Документ не проведён." }); if (supply.Status != SupplyStatus.Posted) return Conflict(new { error = "Документ не проведён." });
// Защита от ухода stock в минус: суммируем то, что вернём (по строкам
// с тем же ProductId — могут быть дубли), сравниваем с текущим Stock.
// Если приёмку «расковать», stock уменьшится на line.Quantity. Если
// покупатели уже что-то купили из этой партии, остаток станет
// отрицательным — это нарушение инварианта учёта.
var reverseByProduct = supply.Lines
.GroupBy(l => l.ProductId)
.Select(g => new { ProductId = g.Key, Quantity = g.Sum(x => x.Quantity) })
.ToList();
var productIds = reverseByProduct.Select(r => r.ProductId).ToList();
var stocks = await _db.Stocks
.Where(s => s.StoreId == supply.StoreId && productIds.Contains(s.ProductId))
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
var conflicts = new List<object>();
foreach (var r in reverseByProduct)
{
stocks.TryGetValue(r.ProductId, out var available);
if (available < r.Quantity)
{
var name = await _db.Products
.Where(p => p.Id == r.ProductId)
.Select(p => p.Name)
.FirstOrDefaultAsync(ct);
conflicts.Add(new
{
productId = r.ProductId,
productName = name,
reverseQty = r.Quantity,
available,
});
}
}
if (conflicts.Count > 0)
{
return Conflict(new
{
error = "Нельзя отменить проведение: остаток уйдёт в минус (часть товара уже расходована).",
lines = conflicts,
});
}
// Reverse: negative movements with same document reference // Reverse: negative movements with same document reference
foreach (var line in supply.Lines) foreach (var line in supply.Lines)
{ {
@ -525,14 +403,12 @@ private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken
private async Task<SupplyDto?> GetInternal(Guid id, CancellationToken ct) private async Task<SupplyDto?> GetInternal(Guid id, CancellationToken ct)
{ {
// Shadow-property xmin читаем через EF.Property — она не на entity,
// а в model metadata (UseXminAsConcurrencyToken).
var row = await (from s in _db.Supplies.AsNoTracking() var row = await (from s in _db.Supplies.AsNoTracking()
join cp in _db.Counterparties on s.SupplierId equals cp.Id join cp in _db.Counterparties on s.SupplierId equals cp.Id
join st in _db.Stores on s.StoreId equals st.Id join st in _db.Stores on s.StoreId equals st.Id
join cu in _db.Currencies on s.CurrencyId equals cu.Id join cu in _db.Currencies on s.CurrencyId equals cu.Id
where s.Id == id where s.Id == id
select new { s, cp, st, cu, Xmin = EF.Property<uint>(s, "xmin") }).FirstOrDefaultAsync(ct); select new { s, cp, st, cu }).FirstOrDefaultAsync(ct);
if (row is null) return null; if (row is null) return null;
// CurrentRetailPrice — текущая розничная цена товара (дефолтный PriceType), // CurrentRetailPrice — текущая розничная цена товара (дефолтный PriceType),
@ -565,7 +441,6 @@ orderby l.SortOrder
row.cu.Id, row.cu.Code, row.cu.Id, row.cu.Code,
row.s.Notes, row.s.Notes,
row.s.Total, row.s.PostedAt, row.s.Total, row.s.PostedAt,
row.Xmin,
lines); lines);
} }
} }

View file

@ -1,164 +0,0 @@
using foodmarket.Domain.Sales;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Reports;
/// <summary>Отчёт ABC-анализ.
///
/// Распределяет товары по классам A/B/C по правилу Парето:
/// A — топ-товары, дающие 80% накопительной метрики;
/// B — следующие 15% (порог 95% накопительно);
/// C — оставшиеся 5%.
///
/// Параметр <c>metric</c>:
/// • <c>revenue</c> — выручка (по умолчанию);
/// • <c>profit</c> — прибыль (выручка себестоимость);
/// • <c>margin</c> — суммарная маржа в деньгах (то же что profit, но
/// отсортированы по убыванию margin per unit; для совместимости с
/// требованием отдельной кнопки в UI оставляем как алиас).
///
/// Возвраты учтены со знаком: net-метрика для периода.</summary>
[ApiController]
[Authorize]
[Route("api/reports/abc")]
public class AbcReportController : ControllerBase
{
private readonly AppDbContext _db;
public AbcReportController(AppDbContext db) => _db = db;
public record AbcRow(
Guid ProductId, string ProductName, string? ProductArticle,
decimal MetricValue,
decimal Share, // % от общей метрики (0..100)
decimal CumulativeShare, // накопительный % (0..100)
string AbcClass, // "A" | "B" | "C"
int Rank);
[HttpGet]
public async Task<ActionResult<IReadOnlyList<AbcRow>>> Get(
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] string metric = "revenue",
[FromQuery] Guid? storeId = null,
[FromQuery] Guid? productGroupId = null,
CancellationToken ct = default)
{
var range = ResolveRange(from, to);
return Ok(await BuildAsync(range, metric, storeId, productGroupId, ct));
}
[HttpGet("export")]
public async Task<IActionResult> Export(
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] string metric = "revenue",
[FromQuery] Guid? storeId = null,
[FromQuery] Guid? productGroupId = null,
[FromQuery] string format = "csv",
CancellationToken ct = default)
{
var range = ResolveRange(from, to);
var rows = await BuildAsync(range, metric, storeId, productGroupId, ct);
var name = $"abc-{metric}-{range.From:yyyyMMdd}-{range.To:yyyyMMdd}";
var headers = new[]
{
"ProductId", "Товар", "Артикул", "Метрика", "Доля,%", "Накоп.доля,%", "Класс", "Ранг",
};
return format.ToLower() switch
{
"xlsx" => ReportExport.Xlsx(rows, name, "ABC", headers),
_ => ReportExport.Csv(rows, name, headers),
};
}
private static DateRange ResolveRange(DateTime? from, DateTime? to)
{
var t = to ?? DateTime.UtcNow;
var f = from ?? t.AddDays(-30);
return new DateRange(f, t);
}
private async Task<List<AbcRow>> BuildAsync(
DateRange range, string metric, Guid? storeId, Guid? productGroupId, CancellationToken ct)
{
var q = from l in _db.RetailSaleLines.AsNoTracking()
join s in _db.RetailSales.AsNoTracking() on l.RetailSaleId equals s.Id
join p in _db.Products.AsNoTracking() on l.ProductId equals p.Id
where s.Status == RetailSaleStatus.Posted
&& s.Date >= range.From && s.Date <= range.To
select new { l, s, p };
if (storeId is not null) q = q.Where(x => x.s.StoreId == storeId);
if (productGroupId is not null) q = q.Where(x => x.p.ProductGroupId == productGroupId);
var raw = await q
.Select(x => new
{
x.l.ProductId,
x.p.Name,
x.p.Article,
x.p.Cost,
Revenue = x.s.IsReturn ? -x.l.LineTotal : x.l.LineTotal,
Quantity = x.s.IsReturn ? -x.l.Quantity : x.l.Quantity,
})
.ToListAsync(ct);
// Группируем по product, считаем метрику.
var grouped = raw
.GroupBy(r => new { r.ProductId, r.Name, r.Article, r.Cost })
.Select(g =>
{
var rev = g.Sum(x => x.Revenue);
var qty = g.Sum(x => x.Quantity);
var cost = g.Key.Cost * qty;
var prof = rev - cost;
var value = metric.ToLower() switch
{
"profit" or "margin" => prof,
_ => rev,
};
return new
{
g.Key.ProductId,
g.Key.Name,
g.Key.Article,
MetricValue = value,
};
})
// Только положительные значения: товары с net-убытком или нулевой
// выручкой не имеют смысла в ABC (никакого вклада в Парето). При
// желании можно отдельным флагом включить и отрицательные.
.Where(x => x.MetricValue > 0m)
.OrderByDescending(x => x.MetricValue)
.ToList();
var total = grouped.Sum(x => x.MetricValue);
if (total <= 0m) return new();
var rows = new List<AbcRow>(grouped.Count);
decimal cum = 0m;
var rank = 1;
foreach (var g in grouped)
{
cum += g.MetricValue;
var share = g.MetricValue / total * 100m;
var cumShare = cum / total * 100m;
// Граница A: накопительная ≤ 80%. Если первый товар уже > 80%,
// он всё равно A (единичная позиция, исчерпывающая Парето).
var cls = cumShare <= 80m + 0.000001m ? "A"
: cumShare <= 95m + 0.000001m ? "B"
: "C";
rows.Add(new AbcRow(
g.ProductId, g.Name, g.Article,
Math.Round(g.MetricValue, 4),
Math.Round(share, 2),
Math.Round(cumShare, 2),
cls,
rank++));
}
return rows;
}
}

View file

@ -1,173 +0,0 @@
using System.Globalization;
using foodmarket.Domain.Inventory;
using foodmarket.Domain.Sales;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Reports;
/// <summary>Отчёт «Прибыль».
///
/// Выручка себестоимость, маржа, рентабельность по периодам / товарам /
/// группам товаров. Cost-snapshot берётся из <see cref="StockMovement.UnitCost"/>
/// сопоставленного по (documentId, productId): движение тип `RetailSale`
/// или `CustomerReturn` несёт UnitCost = `RetailSaleLine.UnitPrice`… НО для
/// расчёта прибыли нам нужна СЕБЕСТОИМОСТЬ, а не цена продажи. Поэтому
/// фактически берём `Product.Cost` (текущее скользящее среднее) как
/// COGS-snapshot — это приближение; точный COGS требует партий или
/// fetch'а на момент продажи. Документируем как компромисс.
///
/// Multi-tenant: query-filter работает прозрачно.</summary>
[ApiController]
[Authorize]
[Route("api/reports/profit")]
public class ProfitReportController : ControllerBase
{
private readonly AppDbContext _db;
public ProfitReportController(AppDbContext db) => _db = db;
public record ProfitRow(
string Key, string Label,
decimal Revenue, decimal Cost, decimal Profit,
decimal MarginPercent, decimal Quantity);
private record FlatRow(
Guid SaleId, DateTime Date,
Guid ProductId, string ProductName, string? ProductArticle,
Guid? ProductGroupId, string? ProductGroupName,
decimal Revenue, decimal Cost, decimal Quantity);
[HttpGet]
public async Task<ActionResult<IReadOnlyList<ProfitRow>>> Get(
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] string groupBy = "period:day",
[FromQuery] Guid? storeId = null,
[FromQuery] Guid? productGroupId = null,
CancellationToken ct = default)
{
var range = ResolveRange(from, to);
var flat = await FetchAsync(range, storeId, productGroupId, ct);
return Ok(Group(flat, groupBy));
}
[HttpGet("export")]
public async Task<IActionResult> Export(
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] string groupBy = "period:day",
[FromQuery] Guid? storeId = null,
[FromQuery] Guid? productGroupId = null,
[FromQuery] string format = "csv",
CancellationToken ct = default)
{
var range = ResolveRange(from, to);
var flat = await FetchAsync(range, storeId, productGroupId, ct);
var rows = Group(flat, groupBy);
var name = $"profit-{groupBy.Replace(':', '-')}-{range.From:yyyyMMdd}-{range.To:yyyyMMdd}";
var headers = new[] { "Ключ", "Группа", "Выручка", "Себестоимость", "Прибыль", "Маржа,%", "Количество" };
return format.ToLower() switch
{
"xlsx" => ReportExport.Xlsx(rows, name, "Profit", headers),
_ => ReportExport.Csv(rows, name, headers),
};
}
private static DateRange ResolveRange(DateTime? from, DateTime? to)
{
var t = to ?? DateTime.UtcNow;
var f = from ?? t.AddDays(-30);
return new DateRange(f, t);
}
private async Task<List<FlatRow>> FetchAsync(
DateRange range, Guid? storeId, Guid? productGroupId, CancellationToken ct)
{
// Берём строки проведённых чеков с присоединённым Product (для Cost-snapshot
// и группы) и ProductGroup (для имени). Возврат вычитается из выручки и
// из COGS (продали по цене X с себестоимостью C → returned восстанавливает).
var q = from l in _db.RetailSaleLines.AsNoTracking()
join s in _db.RetailSales.AsNoTracking() on l.RetailSaleId equals s.Id
join p in _db.Products.AsNoTracking() on l.ProductId equals p.Id
where s.Status == RetailSaleStatus.Posted
&& s.Date >= range.From && s.Date <= range.To
select new { l, s, p };
if (storeId is not null) q = q.Where(x => x.s.StoreId == storeId);
if (productGroupId is not null) q = q.Where(x => x.p.ProductGroupId == productGroupId);
var flat = await q
.Select(x => new FlatRow(
x.s.Id, x.s.Date,
x.l.ProductId, x.p.Name, x.p.Article,
x.p.ProductGroupId,
x.p.ProductGroupId == null ? null
: _db.ProductGroups.Where(g => g.Id == x.p.ProductGroupId).Select(g => g.Name).FirstOrDefault(),
x.s.IsReturn ? -x.l.LineTotal : x.l.LineTotal,
// COGS-snapshot: Quantity × Product.Cost (текущее скользящее среднее).
// Документировано как приближение; точный COGS требует партий.
(x.s.IsReturn ? -x.l.Quantity : x.l.Quantity) * x.p.Cost,
x.s.IsReturn ? -x.l.Quantity : x.l.Quantity))
.ToListAsync(ct);
return flat;
}
private static List<ProfitRow> Group(List<FlatRow> flat, string groupBy)
{
IEnumerable<IGrouping<(string Key, string Label), FlatRow>> grouped;
switch (groupBy)
{
case "period:day":
grouped = flat.GroupBy(x => (
Key: x.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
Label: x.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)));
break;
case "period:week":
var cal = CultureInfo.InvariantCulture.Calendar;
grouped = flat.GroupBy(x =>
{
var w = cal.GetWeekOfYear(x.Date, CalendarWeekRule.FirstFourDayWeek, DayOfWeek.Monday);
return (Key: $"{x.Date.Year:0000}-W{w:00}", Label: $"{x.Date.Year:0000} нед. {w:00}");
});
break;
case "period:month":
grouped = flat.GroupBy(x => (
Key: $"{x.Date.Year:0000}-{x.Date.Month:00}",
Label: $"{x.Date.Year:0000}-{x.Date.Month:00}"));
break;
case "product":
grouped = flat.GroupBy(x => (
Key: x.ProductId.ToString(),
Label: x.ProductName + (x.ProductArticle is not null ? " · " + x.ProductArticle : "")));
break;
case "group":
grouped = flat.GroupBy(x => (
Key: (x.ProductGroupId ?? Guid.Empty).ToString(),
Label: string.IsNullOrEmpty(x.ProductGroupName) ? "Без группы" : x.ProductGroupName));
break;
default:
return new();
}
return grouped
.Select(g =>
{
var revenue = g.Sum(x => x.Revenue);
var cost = g.Sum(x => x.Cost);
var profit = revenue - cost;
// Защита от деления на ноль: при нулевой выручке маржа = 0
// (а не NaN/Infinity). Бывает при чисто возвратных периодах,
// когда продажи отсутствуют.
var margin = revenue == 0m ? 0m : Math.Round(profit / revenue * 100m, 2);
return new ProfitRow(
g.Key.Key, g.Key.Label,
revenue, cost, profit, margin,
g.Sum(x => x.Quantity));
})
.OrderByDescending(r => r.Profit)
.ToList();
}
}

View file

@ -1,111 +0,0 @@
using System.Globalization;
using System.Text;
using ClosedXML.Excel;
using CsvHelper;
using CsvHelper.Configuration;
using Microsoft.AspNetCore.Mvc;
namespace foodmarket.Api.Controllers.Reports;
/// <summary>Хелперы экспорта табличных данных отчётов в CSV и XLSX.
///
/// CSV: разделитель «;» (Excel-RU открывает корректно без import wizard),
/// BOM UTF-8 (тот же повод), `CultureInfo.InvariantCulture` для чисел и дат
/// (так в файл попадает «1234.56», а не «1 234,56» — Excel сам отформатирует
/// по локали при чтении). Заголовки берутся из имён свойств — это compromise,
/// но контроллеры могут передать <c>columnNames</c> для русификации.
///
/// XLSX: ClosedXML без шрифтов/стилей сверх минимума — отчёты предполагается
/// открывать в Excel и форматировать там. Числовые ячейки реально числа
/// (а не строки), даты — DateTime; auto-fit колонок включён.</summary>
public static class ReportExport
{
public static IActionResult Csv<T>(IEnumerable<T> rows, string fileName,
IReadOnlyList<string>? columnNames = null)
{
using var ms = new MemoryStream();
var preamble = Encoding.UTF8.GetPreamble();
ms.Write(preamble, 0, preamble.Length);
using (var writer = new StreamWriter(ms, new UTF8Encoding(false), leaveOpen: true))
using (var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture)
{
Delimiter = ";",
// Если контроллер передал русские заголовки — мы их пишем сами и просим
// CsvHelper не генерить заголовок повторно. Иначе — пусть пишет
// заголовок из имён свойств.
HasHeaderRecord = columnNames is null || columnNames.Count == 0,
}))
{
if (columnNames is not null && columnNames.Count > 0)
{
foreach (var h in columnNames) csv.WriteField(h);
csv.NextRecord();
}
csv.WriteRecords(rows);
}
return new FileContentResult(ms.ToArray(), "text/csv; charset=utf-8")
{
FileDownloadName = fileName.EndsWith(".csv") ? fileName : fileName + ".csv",
};
}
/// <summary>Простой экспорт «таблица в XLSX». В строке reflection берёт
/// публичные свойства rows[0], ставит заголовки и значения. Для пустого
/// источника возвращает пустой лист с заголовками из дженерик-типа.</summary>
public static IActionResult Xlsx<T>(IEnumerable<T> rows, string fileName,
string sheetName = "Report",
IReadOnlyList<string>? columnNames = null)
{
using var wb = new XLWorkbook();
var ws = wb.AddWorksheet(sheetName.Length > 31 ? sheetName[..31] : sheetName);
var props = typeof(T).GetProperties();
for (int i = 0; i < props.Length; i++)
{
ws.Cell(1, i + 1).Value = (columnNames is not null && i < columnNames.Count)
? columnNames[i]
: props[i].Name;
ws.Cell(1, i + 1).Style.Font.Bold = true;
}
var r = 2;
foreach (var row in rows)
{
for (int i = 0; i < props.Length; i++)
{
var v = props[i].GetValue(row);
var c = ws.Cell(r, i + 1);
switch (v)
{
case null: c.Value = string.Empty; break;
case DateTime dt: c.Value = dt; c.Style.NumberFormat.Format = "yyyy-mm-dd hh:mm"; break;
case decimal dec: c.Value = dec; break;
case double dbl: c.Value = dbl; break;
case int ii: c.Value = ii; break;
case long ll: c.Value = ll; break;
case bool bb: c.Value = bb; break;
case Guid g: c.Value = g.ToString(); break;
default: c.Value = v.ToString(); break;
}
}
r++;
}
ws.Columns().AdjustToContents();
using var ms = new MemoryStream();
wb.SaveAs(ms);
return new FileContentResult(ms.ToArray(),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
{
FileDownloadName = fileName.EndsWith(".xlsx") ? fileName : fileName + ".xlsx",
};
}
}
/// <summary>Универсальный диапазон дат отчёта. Обе границы — UTC.
/// Пустые значения подменяются дефолтами в контроллере (обычно last 30 days).</summary>
public sealed record DateRange(DateTime From, DateTime To)
{
public static DateRange LastDays(int days)
{
var to = DateTime.UtcNow;
return new DateRange(to.AddDays(-days), to);
}
}

View file

@ -1,211 +0,0 @@
using System.Globalization;
using foodmarket.Domain.Sales;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Reports;
/// <summary>Отчёт «Продажи».
///
/// Группировка через query-параметр <c>groupBy</c>:
/// • <c>period:day</c> / <c>period:week</c> / <c>period:month</c>
/// • <c>product</c> — по товару
/// • <c>cashier</c> — по кассиру (CashierUserId)
/// • <c>register</c> — по кассе (RetailPointId)
/// • <c>payment</c> — по способу оплаты
///
/// Учитываются только проведённые чеки (Status=Posted). Возвраты
/// (IsReturn=true) включаются с отрицательным вкладом в выручку
/// (соответствует фискальной отчётности «netto»). Фильтры: from/to,
/// storeId, productGroupId.
///
/// Реализация: проекция в плоский ряд (FlatRow) с фильтрами выполняется
/// на сервере БД (простая Join+Where, EF переводит). Группировка/агрегация —
/// в памяти. Это сознательный компромисс: EF8 не умеет переводить
/// «distinct count» внутри group-проекции с join'ами по nullable-ключам;
/// объёмы отчётов (десятки тысяч строк за месяц) спокойно держатся в RAM.
///
/// Multi-tenant: query-filter <see cref="AppDbContext"/> применяется автоматически.</summary>
[ApiController]
[Authorize]
[Route("api/reports/sales")]
public class SalesReportController : ControllerBase
{
private readonly AppDbContext _db;
public SalesReportController(AppDbContext db) => _db = db;
public record SalesRow(
string Key,
string Label,
decimal Revenue,
decimal Discount,
int Transactions,
decimal Quantity);
private record FlatRow(
Guid SaleId, DateTime Date,
Guid StoreId,
Guid? RetailPointId, string? RetailPointName,
Guid? CashierUserId, string? CashierName,
PaymentMethod Payment,
Guid ProductId, string ProductName, string? ProductArticle,
Guid? ProductGroupId,
decimal Revenue, decimal Discount, decimal Quantity);
[HttpGet]
public async Task<ActionResult<IReadOnlyList<SalesRow>>> Get(
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] string groupBy = "period:day",
[FromQuery] Guid? storeId = null,
[FromQuery] Guid? productGroupId = null,
CancellationToken ct = default)
{
var range = ResolveRange(from, to);
var flat = await FetchAsync(range, storeId, productGroupId, ct);
return Ok(Group(flat, groupBy));
}
[HttpGet("export")]
public async Task<IActionResult> Export(
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] string groupBy = "period:day",
[FromQuery] Guid? storeId = null,
[FromQuery] Guid? productGroupId = null,
[FromQuery] string format = "csv",
CancellationToken ct = default)
{
var range = ResolveRange(from, to);
var flat = await FetchAsync(range, storeId, productGroupId, ct);
var rows = Group(flat, groupBy);
var name = $"sales-{groupBy.Replace(':', '-')}-{range.From:yyyyMMdd}-{range.To:yyyyMMdd}";
var headers = new[] { "Ключ", "Группа", "Выручка", "Скидки", "Чеков", "Количество" };
return format.ToLower() switch
{
"xlsx" => ReportExport.Xlsx(rows, name, "Sales", headers),
_ => ReportExport.Csv(rows, name, headers),
};
}
private static DateRange ResolveRange(DateTime? from, DateTime? to)
{
var t = to ?? DateTime.UtcNow;
var f = from ?? t.AddDays(-30);
return new DateRange(f, t);
}
/// <summary>Тащит плоский набор строк с переведёнными в SQL фильтрами
/// и join'ами на каталог. Возвращает уже materialized list.</summary>
private async Task<List<FlatRow>> FetchAsync(
DateRange range, Guid? storeId, Guid? productGroupId, CancellationToken ct)
{
// Сначала список саleId с фильтрами по чеку (period/store/return-знак).
// Затем JOIN на линии и на каталог. У EF8 эта форма успешно переводится в SQL.
var q = from l in _db.RetailSaleLines.AsNoTracking()
join s in _db.RetailSales.AsNoTracking() on l.RetailSaleId equals s.Id
join p in _db.Products.AsNoTracking() on l.ProductId equals p.Id
where s.Status == RetailSaleStatus.Posted
&& s.Date >= range.From && s.Date <= range.To
select new { l, s, p };
if (storeId is not null) q = q.Where(x => x.s.StoreId == storeId);
if (productGroupId is not null) q = q.Where(x => x.p.ProductGroupId == productGroupId);
// Левые join'ы на RetailPoints и Users — для имён. Подтаскиваем имена
// прямо в проекции через .Where + .FirstOrDefault — Npgsql переведёт.
var flat = await q
.Select(x => new FlatRow(
x.s.Id, x.s.Date,
x.s.StoreId,
x.s.RetailPointId,
x.s.RetailPointId == null ? null
: _db.RetailPoints.Where(r => r.Id == x.s.RetailPointId).Select(r => r.Name).FirstOrDefault(),
x.s.CashierUserId,
x.s.CashierUserId == null ? null
: _db.Users.Where(u => u.Id == x.s.CashierUserId).Select(u => u.FullName).FirstOrDefault(),
x.s.Payment,
x.l.ProductId, x.p.Name, x.p.Article,
x.p.ProductGroupId,
x.s.IsReturn ? -x.l.LineTotal : x.l.LineTotal,
x.s.IsReturn ? -x.l.Discount : x.l.Discount,
x.s.IsReturn ? -x.l.Quantity : x.l.Quantity))
.ToListAsync(ct);
return flat;
}
/// <summary>Группировка/агрегация в C#. Возврат уже отсортирован по убыванию
/// выручки (полезно для топ-списков и для табличного вида).</summary>
private static List<SalesRow> Group(List<FlatRow> flat, string groupBy)
{
IEnumerable<IGrouping<(string Key, string Label), FlatRow>> grouped;
switch (groupBy)
{
case "period:day":
grouped = flat.GroupBy(x => (
Key: x.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
Label: x.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)));
break;
case "period:week":
var cal = CultureInfo.InvariantCulture.Calendar;
grouped = flat.GroupBy(x =>
{
var w = cal.GetWeekOfYear(x.Date, CalendarWeekRule.FirstFourDayWeek, DayOfWeek.Monday);
var key = $"{x.Date.Year:0000}-W{w:00}";
return (Key: key, Label: $"{x.Date.Year:0000} нед. {w:00}");
});
break;
case "period:month":
grouped = flat.GroupBy(x => (
Key: $"{x.Date.Year:0000}-{x.Date.Month:00}",
Label: $"{x.Date.Year:0000}-{x.Date.Month:00}"));
break;
case "product":
grouped = flat.GroupBy(x => (
Key: x.ProductId.ToString(),
Label: x.ProductName + (x.ProductArticle is not null ? " · " + x.ProductArticle : "")));
break;
case "cashier":
grouped = flat.GroupBy(x => (
Key: (x.CashierUserId ?? Guid.Empty).ToString(),
Label: string.IsNullOrEmpty(x.CashierName) ? "Без кассира" : x.CashierName));
break;
case "register":
grouped = flat.GroupBy(x => (
Key: (x.RetailPointId ?? Guid.Empty).ToString(),
Label: string.IsNullOrEmpty(x.RetailPointName) ? "Без кассы" : x.RetailPointName));
break;
case "payment":
grouped = flat.GroupBy(x => (
Key: ((int)x.Payment).ToString(),
Label: PaymentLabel(x.Payment)));
break;
default:
return new();
}
return grouped
.Select(g => new SalesRow(
Key: g.Key.Key,
Label: g.Key.Label,
Revenue: g.Sum(x => x.Revenue),
Discount: g.Sum(x => x.Discount),
Transactions: g.Select(x => x.SaleId).Distinct().Count(),
Quantity: g.Sum(x => x.Quantity)))
.OrderByDescending(r => r.Revenue)
.ToList();
}
private static string PaymentLabel(PaymentMethod p) => p switch
{
PaymentMethod.Cash => "Наличные",
PaymentMethod.Card => "Карта",
PaymentMethod.BankTransfer => "Безнал",
PaymentMethod.Bonus => "Бонусы",
PaymentMethod.Mixed => "Смешанная",
_ => p.ToString(),
};
}

View file

@ -1,146 +0,0 @@
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Reports;
/// <summary>Отчёт «Остатки на дату».
///
/// Восстанавливает остаток (Product, Store) на произвольный момент,
/// агрегируя журнал <see cref="Domain.Inventory.StockMovement"/> до этой даты.
/// Текущий <see cref="Domain.Inventory.Stock"/> является результатом
/// Σ движений (инвариант учёта), поэтому подсчёт на «сейчас» совпадает с
/// материализованным остатком; подсчёт на прошлую дату — реконструкция.
///
/// Edge-cases:
/// • Дата в будущем — возвращаем текущий остаток (Σ до сейчас): движений
/// из будущего нет, фильтр на это пропускает.
/// • Дата раньше первой операции — возвращаем нули (или вообще не отдаём
/// строку, потому что и таких пар (Product, Store) не было).
///
/// Стоимость остатка считаем по последнему известному `UnitCost` движения
/// до этой даты ИЛИ снимаем с `Product.Cost` если в движениях не было ни
/// одной партии (например, оприходование без UnitCost). Это компромисс
/// для приближённой оценки — точная FIFO/LIFO потребует партий, которые
/// в текущем учёте не ведутся.</summary>
[ApiController]
[Authorize]
[Route("api/reports/stock")]
public class StockReportController : ControllerBase
{
private readonly AppDbContext _db;
public StockReportController(AppDbContext db) => _db = db;
public record StockRow(
Guid ProductId, string ProductName, string? ProductArticle, string? UnitName,
Guid StoreId, string StoreName,
decimal Quantity, decimal Cost, decimal Value);
[HttpGet]
public async Task<ActionResult<IReadOnlyList<StockRow>>> Get(
[FromQuery] DateTime? date,
[FromQuery] Guid? storeId,
[FromQuery] Guid? productGroupId,
[FromQuery] bool includeZero = false,
CancellationToken ct = default)
{
var on = date ?? DateTime.UtcNow;
return Ok(await BuildAsync(on, storeId, productGroupId, includeZero, ct));
}
[HttpGet("export")]
public async Task<IActionResult> Export(
[FromQuery] DateTime? date,
[FromQuery] Guid? storeId,
[FromQuery] Guid? productGroupId,
[FromQuery] bool includeZero = false,
[FromQuery] string format = "csv",
CancellationToken ct = default)
{
var on = date ?? DateTime.UtcNow;
var rows = await BuildAsync(on, storeId, productGroupId, includeZero, ct);
var name = $"stock-{on:yyyyMMdd}";
var headers = new[] { "ProductId", "Товар", "Артикул", "Ед.", "StoreId", "Склад", "Кол-во", "Цена", "Стоимость" };
return format.ToLower() switch
{
"xlsx" => ReportExport.Xlsx(rows, name, "Stock", headers),
_ => ReportExport.Csv(rows, name, headers),
};
}
private async Task<List<StockRow>> BuildAsync(
DateTime on, Guid? storeId, Guid? productGroupId, bool includeZero, CancellationToken ct)
{
// Σ Quantity и последний UnitCost (через max OccurredAt) на (Product, Store)
// — два отдельных запроса; объединяем в памяти. Альтернатива через window
// functions требует sql-raw; на типовых объёмах (десятки тыс. движений)
// двойной round-trip с агрегатом дешевле и читабельнее.
var movQ = _db.StockMovements.AsNoTracking().Where(m => m.OccurredAt <= on);
if (storeId is not null) movQ = movQ.Where(m => m.StoreId == storeId);
var qtyByPair = await movQ
.GroupBy(m => new { m.ProductId, m.StoreId })
.Select(g => new { g.Key.ProductId, g.Key.StoreId, Qty = g.Sum(m => m.Quantity) })
.ToListAsync(ct);
// Последний UnitCost (max OccurredAt с не-null UnitCost) на пару.
var lastCostsRaw = await movQ
.Where(m => m.UnitCost != null)
.GroupBy(m => new { m.ProductId, m.StoreId })
.Select(g => new
{
g.Key.ProductId,
g.Key.StoreId,
LastCost = g.OrderByDescending(m => m.OccurredAt).Select(m => m.UnitCost).First(),
})
.ToListAsync(ct);
var lastCosts = lastCostsRaw
.ToDictionary(x => (x.ProductId, x.StoreId), x => x.LastCost ?? 0m);
if (productGroupId is not null)
{
// Фильтр по группе — отдельным запросом, чтобы получить ProductIds.
var allowed = await _db.Products.AsNoTracking()
.Where(p => p.ProductGroupId == productGroupId)
.Select(p => p.Id)
.ToListAsync(ct);
var set = allowed.ToHashSet();
qtyByPair = qtyByPair.Where(x => set.Contains(x.ProductId)).ToList();
}
if (!includeZero) qtyByPair = qtyByPair.Where(x => x.Qty != 0m).ToList();
// Подтаскиваем имена товара/склада.
var productIds = qtyByPair.Select(x => x.ProductId).Distinct().ToList();
var storeIds = qtyByPair.Select(x => x.StoreId).Distinct().ToList();
var products = await _db.Products.AsNoTracking()
.Where(p => productIds.Contains(p.Id))
.Join(_db.UnitsOfMeasure.AsNoTracking(), p => p.UnitOfMeasureId, u => u.Id,
(p, u) => new { p.Id, p.Name, p.Article, p.Cost, UnitName = u.Name })
.ToListAsync(ct);
var stores = await _db.Stores.AsNoTracking()
.Where(s => storeIds.Contains(s.Id))
.Select(s => new { s.Id, s.Name })
.ToListAsync(ct);
var pMap = products.ToDictionary(p => p.Id);
var sMap = stores.ToDictionary(s => s.Id);
return qtyByPair
.Select(x =>
{
pMap.TryGetValue(x.ProductId, out var p);
sMap.TryGetValue(x.StoreId, out var s);
lastCosts.TryGetValue((x.ProductId, x.StoreId), out var cost);
if (cost == 0m && p is not null) cost = p.Cost;
return new StockRow(
x.ProductId, p?.Name ?? "(unknown)", p?.Article, p?.UnitName,
x.StoreId, s?.Name ?? "(unknown)",
x.Qty, cost, x.Qty * cost);
})
.OrderBy(r => r.ProductName)
.ThenBy(r => r.StoreName)
.ToList();
}
}

View file

@ -1,402 +0,0 @@
using System.ComponentModel.DataAnnotations;
using foodmarket.Application.Common;
using foodmarket.Application.Inventory;
using foodmarket.Domain.Inventory;
using foodmarket.Domain.Sales;
using foodmarket.Infrastructure.Persistence;
using foodmarket.Api.Infrastructure.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Sales;
/// <summary>Оптовая отгрузка (Demand). Списывает товар со склада в адрес
/// юрлица-контрагента. Зеркалит <see cref="RetailSalesController"/>, но
/// без RetailPoint/Cashier и с другим способом оплаты (Credit вместо Mixed/Bonus).
/// Множ. возвратов нет в MVP — учётный шаг без подтверждения дебиторки.</summary>
[ApiController]
[Authorize]
[Route("api/sales/demands")]
public class DemandsController : ControllerBase
{
private readonly AppDbContext _db;
private readonly IStockService _stock;
public DemandsController(AppDbContext db, IStockService stock)
{
_db = db;
_stock = stock;
}
public record DemandListRow(
Guid Id, string Number, DateTime Date, DemandStatus Status,
Guid CustomerId, string CustomerName,
Guid StoreId, string StoreName,
Guid CurrencyId, string CurrencyCode,
decimal Total, decimal PaidAmount,
DemandPayment Payment, int LineCount,
DateTime? PostedAt);
public record DemandLineDto(
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle, string? UnitSymbol,
decimal Quantity, decimal UnitPrice, decimal Discount, decimal LineTotal,
decimal VatPercent, int SortOrder,
decimal? StockAtStore);
public record DemandDto(
Guid Id, string Number, DateTime Date, DemandStatus Status,
Guid CustomerId, string CustomerName,
Guid StoreId, string StoreName,
Guid CurrencyId, string CurrencyCode,
DemandPayment Payment, decimal Subtotal, decimal DiscountTotal,
decimal Total, decimal PaidAmount,
string? Notes, DateTime? PostedAt,
IReadOnlyList<DemandLineDto> Lines);
public record DemandLineInput(
Guid ProductId,
[Range(0, 1e10)] decimal Quantity,
[Range(0, 1e10)] decimal UnitPrice,
[Range(0, 1e10)] decimal Discount,
[Range(0, 100)] decimal VatPercent);
public record DemandInput(
DateTime Date,
Guid CustomerId, Guid StoreId, Guid CurrencyId,
DemandPayment Payment,
[Range(0, 1e10)] decimal PaidAmount,
string? Notes,
IReadOnlyList<DemandLineInput> Lines);
[HttpGet]
public async Task<ActionResult<PagedResult<DemandListRow>>> List(
[FromQuery] PagedRequest req,
[FromQuery] DemandStatus? status,
[FromQuery] Guid? customerId,
[FromQuery] Guid? storeId,
CancellationToken ct)
{
var q = from d in _db.Demands.AsNoTracking()
join cp in _db.Counterparties on d.CustomerId equals cp.Id
join st in _db.Stores on d.StoreId equals st.Id
join cu in _db.Currencies on d.CurrencyId equals cu.Id
select new { d, cp, st, cu };
if (status is not null) q = q.Where(x => x.d.Status == status);
if (customerId is not null) q = q.Where(x => x.d.CustomerId == customerId);
if (storeId is not null) q = q.Where(x => x.d.StoreId == storeId);
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.Trim().ToLower();
q = q.Where(x => x.d.Number.ToLower().Contains(s) || x.cp.Name.ToLower().Contains(s));
}
var total = await q.CountAsync(ct);
q = (req.Sort, req.Desc) switch
{
("number", false) => q.OrderBy(x => x.d.Number),
("number", true) => q.OrderByDescending(x => x.d.Number),
("customer", false) => q.OrderBy(x => x.cp.Name).ThenByDescending(x => x.d.Date),
("customer", true) => q.OrderByDescending(x => x.cp.Name).ThenByDescending(x => x.d.Date),
("status", false) => q.OrderBy(x => x.d.Status).ThenByDescending(x => x.d.Date),
("status", true) => q.OrderByDescending(x => x.d.Status).ThenByDescending(x => x.d.Date),
("total", false) => q.OrderBy(x => x.d.Total).ThenByDescending(x => x.d.Date),
("total", true) => q.OrderByDescending(x => x.d.Total).ThenByDescending(x => x.d.Date),
("date", false) => q.OrderBy(x => x.d.Date).ThenBy(x => x.d.Number),
_ => q.OrderByDescending(x => x.d.Date).ThenByDescending(x => x.d.Number),
};
var items = await q.Skip(req.Skip).Take(req.Take)
.Select(x => new DemandListRow(
x.d.Id, x.d.Number, x.d.Date, x.d.Status,
x.cp.Id, x.cp.Name,
x.st.Id, x.st.Name,
x.cu.Id, x.cu.Code,
x.d.Total, x.d.PaidAmount, x.d.Payment, x.d.Lines.Count,
x.d.PostedAt))
.ToListAsync(ct);
return new PagedResult<DemandListRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<DemandDto>> Get(Guid id, CancellationToken ct)
{
var dto = await GetInternal(id, ct);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost, RequiresPermission("DemandsEdit")]
public async Task<ActionResult<DemandDto>> Create([FromBody] DemandInput input, CancellationToken ct)
{
if (RequiredGuid.FirstMissing(
(nameof(input.CustomerId), input.CustomerId),
(nameof(input.StoreId), input.StoreId),
(nameof(input.CurrencyId), input.CurrencyId)) is { } missing)
return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing });
if (input.Lines is null || input.Lines.Count == 0)
return BadRequest(new { error = "Отгрузка должна содержать хотя бы одну позицию." });
var number = await GenerateNumberAsync(input.Date, ct);
var demand = new Demand
{
Number = number,
Date = input.Date,
Status = DemandStatus.Draft,
CustomerId = input.CustomerId,
StoreId = input.StoreId,
CurrencyId = input.CurrencyId,
Payment = input.Payment,
PaidAmount = input.PaidAmount,
Notes = input.Notes,
};
ApplyLines(demand, input.Lines);
_db.Demands.Add(demand);
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
var dto = await GetInternal(demand.Id, ct);
return CreatedAtAction(nameof(Get), new { id = demand.Id }, dto);
}
private async Task<ActionResult?> SaveOrFkErrorAsync(CancellationToken ct)
{
try
{
await _db.SaveChangesAsync(ct);
return null;
}
catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23503")
{
var name = pg.ConstraintName ?? "";
string field = name.Contains("Customer") ? "customerId"
: name.Contains("Store") ? "storeId"
: name.Contains("Currency") ? "currencyId"
: name.Contains("Product") ? "productId"
: "(unknown)";
return BadRequest(new { error = $"Связанная запись не найдена: {field}.", field, constraint = name });
}
}
[HttpPut("{id:guid}"), RequiresPermission("DemandsEdit")]
public async Task<IActionResult> Update(Guid id, [FromBody] DemandInput input, CancellationToken ct)
{
if (RequiredGuid.FirstMissing(
(nameof(input.CustomerId), input.CustomerId),
(nameof(input.StoreId), input.StoreId),
(nameof(input.CurrencyId), input.CurrencyId)) is { } missing)
return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing });
if (input.Lines is null || input.Lines.Count == 0)
return BadRequest(new { error = "Отгрузка должна содержать хотя бы одну позицию." });
var demand = await _db.Demands.FirstOrDefaultAsync(d => d.Id == id, ct);
if (demand is null) return NotFound();
if (demand.Status != DemandStatus.Draft)
return Conflict(new { error = "Только черновик может быть изменён. Сначала отмени проведение." });
demand.Date = input.Date;
demand.CustomerId = input.CustomerId;
demand.StoreId = input.StoreId;
demand.CurrencyId = input.CurrencyId;
demand.Payment = input.Payment;
demand.PaidAmount = input.PaidAmount;
demand.Notes = input.Notes;
// Удаляем старые строки через ExecuteDelete (минует трекер), добавляем новые
// напрямую в DbSet — тот же паттерн что в RetailSale.Update (см. P1-8 fix).
await _db.DemandLines.Where(l => l.DemandId == demand.Id).ExecuteDeleteAsync(ct);
ApplyLines(demand, input.Lines);
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
return NoContent();
}
[HttpDelete("{id:guid}"), RequiresPermission("DemandsEdit")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var demand = await _db.Demands.FirstOrDefaultAsync(d => d.Id == id, ct);
if (demand is null) return NotFound();
if (demand.Status != DemandStatus.Draft)
return Conflict(new { error = "Нельзя удалить проведённый документ. Сначала отмени проведение." });
_db.Demands.Remove(demand);
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpPost("{id:guid}/post"), RequiresPermission("DemandsPost")]
public async Task<IActionResult> Post(Guid id, CancellationToken ct)
{
var demand = await _db.Demands.Include(d => d.Lines).FirstOrDefaultAsync(d => d.Id == id, ct);
if (demand is null) return NotFound();
if (demand.Status == DemandStatus.Posted) return Conflict(new { error = "Документ уже проведён." });
if (demand.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." });
// Защита от ухода в минус.
var byProduct = demand.Lines.GroupBy(l => l.ProductId)
.Select(g => new { ProductId = g.Key, Qty = g.Sum(x => x.Quantity) }).ToList();
var productIds = byProduct.Select(x => x.ProductId).ToList();
var stocks = await _db.Stocks
.Where(s => s.StoreId == demand.StoreId && productIds.Contains(s.ProductId))
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
var conflicts = new List<object>();
foreach (var x in byProduct)
{
stocks.TryGetValue(x.ProductId, out var avail);
if (avail < x.Qty)
{
var name = await _db.Products.Where(p => p.Id == x.ProductId).Select(p => p.Name).FirstOrDefaultAsync(ct);
conflicts.Add(new
{
productId = x.ProductId, productName = name,
requested = x.Qty, available = avail,
});
}
}
if (conflicts.Count > 0)
return Conflict(new { error = "Недостаточно остатка для проведения отгрузки.", lines = conflicts });
await using var tx = await _db.Database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, ct);
foreach (var line in demand.Lines)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: demand.StoreId,
Quantity: -line.Quantity,
Type: MovementType.WholesaleSale,
DocumentType: "demand",
DocumentId: demand.Id,
DocumentNumber: demand.Number,
UnitCost: line.UnitPrice,
OccurredAt: demand.Date), ct);
}
demand.Status = DemandStatus.Posted;
demand.PostedAt = DateTime.UtcNow;
try
{
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
}
catch (Exception ex) when (IsSerializationConflict(ex))
{
foodmarket.Api.Infrastructure.Observability.AppMetrics.IncrementError("demand", "serialization");
return Conflict(new { error = "Документ проводится параллельно. Повторите попытку." });
}
foodmarket.Api.Infrastructure.Observability.AppMetrics.IncrementPosted("demand");
return NoContent();
}
[HttpPost("{id:guid}/unpost"), RequiresPermission("DemandsPost")]
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
{
var demand = await _db.Demands.Include(d => d.Lines).FirstOrDefaultAsync(d => d.Id == id, ct);
if (demand is null) return NotFound();
if (demand.Status != DemandStatus.Posted) return Conflict(new { error = "Документ не проведён." });
foreach (var line in demand.Lines)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: demand.StoreId,
Quantity: line.Quantity,
Type: MovementType.WholesaleSale,
DocumentType: "demand-reversal",
DocumentId: demand.Id,
DocumentNumber: demand.Number,
UnitCost: line.UnitPrice,
OccurredAt: DateTime.UtcNow,
Notes: $"Отмена отгрузки {demand.Number}"), ct);
}
demand.Status = DemandStatus.Draft;
demand.PostedAt = null;
await _db.SaveChangesAsync(ct);
return NoContent();
}
private static bool IsSerializationConflict(Exception ex)
{
for (Exception? e = ex; e is not null; e = e.InnerException)
{
if (e is System.Data.Common.DbException { SqlState: "40001" or "40P01" })
return true;
}
return false;
}
private void ApplyLines(Demand demand, IReadOnlyList<DemandLineInput> input)
{
var order = 0;
decimal subtotal = 0m, discountTotal = 0m;
foreach (var l in input)
{
var lineTotal = l.Quantity * l.UnitPrice - l.Discount;
// Прямой Add в DbSet — тот же паттерн что в RetailSale.ApplyLines:
// через nav-collection EF8 в комбинации с client-side Id путается.
_db.DemandLines.Add(new DemandLine
{
DemandId = demand.Id,
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitPrice = l.UnitPrice,
Discount = l.Discount,
LineTotal = lineTotal,
VatPercent = l.VatPercent,
SortOrder = order++,
});
subtotal += l.Quantity * l.UnitPrice;
discountTotal += l.Discount;
}
demand.Subtotal = subtotal;
demand.DiscountTotal = discountTotal;
demand.Total = subtotal - discountTotal;
}
private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken ct)
{
var prefix = $"ОТГ-{date.Year}-";
var lastNumber = await _db.Demands
.Where(d => d.Number.StartsWith(prefix))
.OrderByDescending(d => d.Number)
.Select(d => d.Number)
.FirstOrDefaultAsync(ct);
var seq = 1;
if (lastNumber is not null && int.TryParse(lastNumber[prefix.Length..], out var last))
seq = last + 1;
return $"{prefix}{seq:D6}";
}
private async Task<DemandDto?> GetInternal(Guid id, CancellationToken ct)
{
var row = await (from d in _db.Demands.AsNoTracking()
join cp in _db.Counterparties on d.CustomerId equals cp.Id
join st in _db.Stores on d.StoreId equals st.Id
join cu in _db.Currencies on d.CurrencyId equals cu.Id
where d.Id == id
select new { d, cp, st, cu }).FirstOrDefaultAsync(ct);
if (row is null) return null;
var lines = await (from l in _db.DemandLines.AsNoTracking()
join p in _db.Products on l.ProductId equals p.Id
join u in _db.UnitsOfMeasure on p.UnitOfMeasureId equals u.Id
where l.DemandId == id
orderby l.SortOrder
select new DemandLineDto(
l.Id, l.ProductId, p.Name, p.Article, u.Name,
l.Quantity, l.UnitPrice, l.Discount, l.LineTotal,
l.VatPercent, l.SortOrder,
_db.Stocks.Where(s => s.ProductId == l.ProductId && s.StoreId == row.d.StoreId)
.Select(s => (decimal?)s.Quantity).FirstOrDefault()))
.ToListAsync(ct);
return new DemandDto(
row.d.Id, row.d.Number, row.d.Date, row.d.Status,
row.cp.Id, row.cp.Name,
row.st.Id, row.st.Name,
row.cu.Id, row.cu.Code,
row.d.Payment, row.d.Subtotal, row.d.DiscountTotal,
row.d.Total, row.d.PaidAmount,
row.d.Notes, row.d.PostedAt,
lines);
}
}

View file

@ -4,7 +4,6 @@
using foodmarket.Domain.Inventory; using foodmarket.Domain.Inventory;
using foodmarket.Domain.Sales; using foodmarket.Domain.Sales;
using foodmarket.Infrastructure.Persistence; using foodmarket.Infrastructure.Persistence;
using foodmarket.Api.Infrastructure.Authorization;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -18,13 +17,11 @@ public class RetailSalesController : ControllerBase
{ {
private readonly AppDbContext _db; private readonly AppDbContext _db;
private readonly IStockService _stock; private readonly IStockService _stock;
private readonly ILogger<RetailSalesController> _log;
public RetailSalesController(AppDbContext db, IStockService stock, ILogger<RetailSalesController> log) public RetailSalesController(AppDbContext db, IStockService stock)
{ {
_db = db; _db = db;
_stock = stock; _stock = stock;
_log = log;
} }
public record RetailSaleListRow( public record RetailSaleListRow(
@ -34,13 +31,11 @@ public record RetailSaleListRow(
Guid? CustomerId, string? CustomerName, Guid? CustomerId, string? CustomerName,
Guid CurrencyId, string CurrencyCode, Guid CurrencyId, string CurrencyCode,
decimal Total, PaymentMethod Payment, int LineCount, decimal Total, PaymentMethod Payment, int LineCount,
DateTime? PostedAt, DateTime? PostedAt);
bool IsReturn, Guid? ReferenceSaleId, string? ReferenceSaleNumber);
public record RetailSaleLineDto( public record RetailSaleLineDto(
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle, string? UnitSymbol, Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle, string? UnitSymbol,
decimal Quantity, decimal UnitPrice, decimal Discount, decimal LineTotal, decimal VatPercent, int SortOrder, decimal Quantity, decimal UnitPrice, decimal Discount, decimal LineTotal, decimal VatPercent, int SortOrder);
decimal QtyReturned);
public record RetailSaleDto( public record RetailSaleDto(
Guid Id, string Number, DateTime Date, RetailSaleStatus Status, Guid Id, string Number, DateTime Date, RetailSaleStatus Status,
@ -51,7 +46,6 @@ public record RetailSaleDto(
decimal Subtotal, decimal DiscountTotal, decimal Total, decimal Subtotal, decimal DiscountTotal, decimal Total,
PaymentMethod Payment, decimal PaidCash, decimal PaidCard, PaymentMethod Payment, decimal PaidCash, decimal PaidCard,
string? Notes, DateTime? PostedAt, string? Notes, DateTime? PostedAt,
bool IsReturn, Guid? ReferenceSaleId, string? ReferenceSaleNumber,
IReadOnlyList<RetailSaleLineDto> Lines); IReadOnlyList<RetailSaleLineDto> Lines);
public record RetailSaleLineInput( public record RetailSaleLineInput(
@ -66,9 +60,7 @@ public record RetailSaleInput(
[Range(0, 1e10)] decimal PaidCash, [Range(0, 1e10)] decimal PaidCash,
[Range(0, 1e10)] decimal PaidCard, [Range(0, 1e10)] decimal PaidCard,
string? Notes, string? Notes,
IReadOnlyList<RetailSaleLineInput> Lines, IReadOnlyList<RetailSaleLineInput> Lines);
bool IsReturn = false,
Guid? ReferenceSaleId = null);
public record SalesStatsBucket(DateTime Bucket, decimal Revenue, int Transactions); public record SalesStatsBucket(DateTime Bucket, decimal Revenue, int Transactions);
@ -189,9 +181,7 @@ public record SalesStatsResponse(
x.cu.Id, x.cu.Code, x.cu.Id, x.cu.Code,
x.s.Total, x.s.Payment, x.s.Total, x.s.Payment,
x.s.Lines.Count, x.s.Lines.Count,
x.s.PostedAt, x.s.PostedAt))
x.s.IsReturn, x.s.ReferenceSaleId,
x.s.ReferenceSaleId == null ? null : _db.RetailSales.Where(rs => rs.Id == x.s.ReferenceSaleId).Select(rs => rs.Number).FirstOrDefault()))
.ToListAsync(ct); .ToListAsync(ct);
return new PagedResult<RetailSaleListRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; return new PagedResult<RetailSaleListRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
@ -204,7 +194,7 @@ public async Task<ActionResult<RetailSaleDto>> Get(Guid id, CancellationToken ct
return dto is null ? NotFound() : Ok(dto); return dto is null ? NotFound() : Ok(dto);
} }
[HttpPost, RequiresPermission("RetailSalesOperate")] [HttpPost, Authorize(Roles = "Admin,Cashier")]
public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput input, CancellationToken ct) public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput input, CancellationToken ct)
{ {
if (RequiredGuid.FirstMissing( if (RequiredGuid.FirstMissing(
@ -216,17 +206,6 @@ public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput
var number = await GenerateNumberAsync(input.Date, ct); var number = await GenerateNumberAsync(input.Date, ct);
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);
// Если возврат привязан к чеку — валидация что reference существует и проведён.
if (input.IsReturn && input.ReferenceSaleId is { } refId)
{
var refSale = await _db.RetailSales.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == refId && !s.IsReturn, ct);
if (refSale is null)
return BadRequest(new { error = "Исходный чек не найден или сам является возвратом.", field = "referenceSaleId" });
if (refSale.Status != RetailSaleStatus.Posted)
return BadRequest(new { error = "Можно возвращать только из проведённого чека.", field = "referenceSaleId" });
}
var sale = new RetailSale var sale = new RetailSale
{ {
Number = number, Number = number,
@ -240,8 +219,6 @@ public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput
PaidCash = R(input.PaidCash), PaidCash = R(input.PaidCash),
PaidCard = R(input.PaidCard), PaidCard = R(input.PaidCard),
Notes = input.Notes, Notes = input.Notes,
IsReturn = input.IsReturn,
ReferenceSaleId = input.IsReturn ? input.ReferenceSaleId : null,
}; };
ApplyLines(sale, input.Lines, allowFractional); ApplyLines(sale, input.Lines, allowFractional);
_db.RetailSales.Add(sale); _db.RetailSales.Add(sale);
@ -274,18 +251,14 @@ public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput
} }
} }
[HttpPut("{id:guid}"), RequiresPermission("RetailSalesOperate")] [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Cashier")]
public async Task<IActionResult> Update(Guid id, [FromBody] RetailSaleInput input, CancellationToken ct) public async Task<IActionResult> Update(Guid id, [FromBody] RetailSaleInput input, CancellationToken ct)
{ {
if (RequiredGuid.FirstMissing( if (RequiredGuid.FirstMissing(
(nameof(input.StoreId), input.StoreId), (nameof(input.StoreId), input.StoreId),
(nameof(input.CurrencyId), input.CurrencyId)) is { } missing) (nameof(input.CurrencyId), input.CurrencyId)) is { } missing)
return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing }); return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing });
// Загружаем sale БЕЗ Include(Lines): иначе вылавливается баг EF8, var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
// когда после ExecuteDelete+Add EF путается со state'ом строк и кидает
// DbUpdateConcurrency. Старые строки удаляем хирургически через
// ExecuteDelete (минует трекер), новые — через отдельный AddRange.
var sale = await _db.RetailSales.FirstOrDefaultAsync(s => s.Id == id, ct);
if (sale is null) return NotFound(); if (sale is null) return NotFound();
if (sale.Status != RetailSaleStatus.Draft) if (sale.Status != RetailSaleStatus.Draft)
return Conflict(new { error = "Только черновик может быть изменён." }); return Conflict(new { error = "Только черновик может быть изменён." });
@ -302,14 +275,15 @@ public async Task<IActionResult> Update(Guid id, [FromBody] RetailSaleInput inpu
sale.PaidCard = R(input.PaidCard); sale.PaidCard = R(input.PaidCard);
sale.Notes = input.Notes; sale.Notes = input.Notes;
await _db.RetailSaleLines.Where(l => l.RetailSaleId == sale.Id).ExecuteDeleteAsync(ct); _db.RetailSaleLines.RemoveRange(sale.Lines);
sale.Lines.Clear();
ApplyLines(sale, input.Lines, allowFractional); ApplyLines(sale, input.Lines, allowFractional);
if (await SaveOrFkErrorAsync(ct) is { } err) return err; if (await SaveOrFkErrorAsync(ct) is { } err) return err;
return NoContent(); return NoContent();
} }
[HttpDelete("{id:guid}"), RequiresPermission("RetailSalesRefund")] [HttpDelete("{id:guid}"), Authorize(Roles = "Admin")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct) public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{ {
var sale = await _db.RetailSales.FirstOrDefaultAsync(s => s.Id == id, ct); var sale = await _db.RetailSales.FirstOrDefaultAsync(s => s.Id == id, ct);
@ -321,7 +295,7 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
return NoContent(); return NoContent();
} }
[HttpPost("{id:guid}/post"), RequiresPermission("RetailSalesOperate")] [HttpPost("{id:guid}/post"), Authorize(Roles = "Admin,Cashier")]
public async Task<IActionResult> Post(Guid id, CancellationToken ct) public async Task<IActionResult> Post(Guid id, CancellationToken ct)
{ {
var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct); var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
@ -329,27 +303,6 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
if (sale.Status == RetailSaleStatus.Posted) return Conflict(new { error = "Чек уже проведён." }); if (sale.Status == RetailSaleStatus.Posted) return Conflict(new { error = "Чек уже проведён." });
if (sale.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести пустой чек." }); if (sale.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести пустой чек." });
// Для возврата — отдельная ветка обработки (см. PostReturnAsync ниже).
if (sale.IsReturn) return await PostReturnAsync(sale, ct);
// Валидация платежа: сумма наличных + по карте должна покрывать Total.
// Сдача — нормально (PaidCash > Total), недоплата — нет. Иначе касса
// может «провести» чек на 5000 ₸, не получив с покупателя ни тенге.
// Округление до 2 знаков защищает от floating-point дрейфа.
// Правило вынесено в RetailPaymentValidator (юнит-тест). paid/due также
// считаем локально для текста ошибки — округление совпадает с валидатором.
if (!foodmarket.Application.Sales.RetailPaymentValidator.IsSufficient(
sale.PaidCash, sale.PaidCard, sale.Total))
{
var paid = decimal.Round(sale.PaidCash + sale.PaidCard, 2);
var due = decimal.Round(sale.Total, 2);
return BadRequest(new
{
error = $"Сумма оплаты {paid} меньше итога {due}. Доплатите или измените позиции чека.",
field = "PaidCash",
});
}
// Транзакция Serializable: чтение остатков + запись stock_movements + // Транзакция Serializable: чтение остатков + запись stock_movements +
// апдейт stocks под одной блокировкой. Это защищает от race condition // апдейт stocks под одной блокировкой. Это защищает от race condition
// когда два кассира одновременно постят чеки на один и тот же товар: // когда два кассира одновременно постят чеки на один и тот же товар:
@ -418,33 +371,16 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
sale.PostedAt = DateTime.UtcNow; sale.PostedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct); await tx.CommitAsync(ct);
foodmarket.Api.Infrastructure.Observability.AppMetrics.IncrementPosted("retail-sale");
_log.LogInformation(
"RetailSale posted: {SaleNumber} store={StoreId} payment={Payment} lines={LinesCount} total={Total}",
sale.Number, sale.StoreId, sale.Payment, sale.Lines.Count, sale.Total);
return NoContent(); return NoContent();
} }
[HttpPost("{id:guid}/unpost"), RequiresPermission("RetailSalesRefund")] [HttpPost("{id:guid}/unpost"), Authorize(Roles = "Admin")]
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct) public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
{ {
var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct); var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
if (sale is null) return NotFound(); if (sale is null) return NotFound();
if (sale.Status != RetailSaleStatus.Posted) return Conflict(new { error = "Чек не проведён." }); if (sale.Status != RetailSaleStatus.Posted) return Conflict(new { error = "Чек не проведён." });
// Если у этого чека-продажи есть проведённые возвраты — нельзя отменить
// оригинал, пока возвраты не отменены/удалены (иначе QtyReturned оказался
// бы посчитан против несуществующего больше чека).
if (!sale.IsReturn)
{
var hasReturns = await _db.RetailSales.AnyAsync(
r => r.ReferenceSaleId == sale.Id && r.IsReturn && r.Status == RetailSaleStatus.Posted, ct);
if (hasReturns)
return Conflict(new { error = "У чека есть проведённые возвраты — сначала отмени их." });
}
if (sale.IsReturn) return await UnpostReturnAsync(sale, ct);
foreach (var line in sale.Lines) foreach (var line in sale.Lines)
{ {
await _stock.ApplyMovementAsync(new StockMovementDraft( await _stock.ApplyMovementAsync(new StockMovementDraft(
@ -466,240 +402,18 @@ public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
return NoContent(); return NoContent();
} }
/// <summary>POST /create-return — копирует строки проведённого чека в новый private static void ApplyLines(RetailSale sale, IReadOnlyList<RetailSaleLineInput> input, bool allowFractional)
/// Draft с IsReturn=true и ReferenceSaleId. Изначально quantity берётся как
/// (line.Quantity - line.QtyReturned) — оставшееся к возврату. Пользователь
/// потом может уменьшить/удалить позиции через обычный PUT.</summary>
[HttpPost("{id:guid}/create-return"), RequiresPermission("RetailSalesRefund")]
public async Task<ActionResult<RetailSaleDto>> CreateReturnFrom(Guid id, CancellationToken ct)
{
var src = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
if (src is null) return NotFound();
if (src.IsReturn) return BadRequest(new { error = "Нельзя создать возврат с возврата." });
if (src.Status != RetailSaleStatus.Posted) return BadRequest(new { error = "Сначала проведи исходный чек." });
var remainingByLine = src.Lines
.Select(l => new { l, remaining = l.Quantity - l.QtyReturned })
.Where(x => x.remaining > 0)
.ToList();
if (remainingByLine.Count == 0)
return BadRequest(new { error = "Этот чек уже полностью возвращён." });
var number = await GenerateNumberAsync(src.Date, ct);
var ret = new RetailSale
{
Number = number,
Date = DateTime.UtcNow,
Status = RetailSaleStatus.Draft,
StoreId = src.StoreId,
RetailPointId = src.RetailPointId,
CustomerId = src.CustomerId,
CurrencyId = src.CurrencyId,
Payment = src.Payment,
PaidCash = 0,
PaidCard = 0,
Notes = $"Возврат по чеку {src.Number}",
IsReturn = true,
ReferenceSaleId = src.Id,
};
var order = 0;
decimal subtotal = 0, discountTotal = 0;
foreach (var x in remainingByLine)
{
var qty = x.remaining;
var lineTotal = qty * x.l.UnitPrice - x.l.Discount;
ret.Lines.Add(new RetailSaleLine
{
ProductId = x.l.ProductId,
Quantity = qty,
UnitPrice = x.l.UnitPrice,
Discount = 0, // discount проще обнулить и считать «возвращаем по цене продажи»
LineTotal = qty * x.l.UnitPrice,
VatPercent = x.l.VatPercent,
SortOrder = order++,
});
subtotal += qty * x.l.UnitPrice;
}
ret.Subtotal = subtotal;
ret.DiscountTotal = discountTotal;
ret.Total = subtotal - discountTotal;
_db.RetailSales.Add(ret);
await _db.SaveChangesAsync(ct);
var dto = await GetInternal(ret.Id, ct);
return CreatedAtAction(nameof(Get), new { id = ret.Id }, dto);
}
/// <summary>Post return: ВОЗВРАЩАЕТ товар на склад (CustomerReturn +Quantity),
/// инкрементит QtyReturned на исходной строке (защита от over-return).</summary>
private async Task<IActionResult> PostReturnAsync(RetailSale sale, CancellationToken ct)
{
// Если есть reference — валидация over-return: для каждой строки возврата
// суммарное Quantity по продукту ≤ исходное (Quantity - QtyReturned).
if (sale.ReferenceSaleId is { } refId)
{
var refLines = await _db.RetailSaleLines
.Where(l => l.RetailSaleId == refId).ToListAsync(ct);
var refByProduct = refLines
.GroupBy(l => l.ProductId)
.ToDictionary(g => g.Key, g => new { Sold = g.Sum(x => x.Quantity), Returned = g.Sum(x => x.QtyReturned) });
var conflicts = new List<object>();
var returnByProduct = sale.Lines.GroupBy(l => l.ProductId)
.ToDictionary(g => g.Key, g => g.Sum(x => x.Quantity));
foreach (var (productId, requested) in returnByProduct)
{
if (!refByProduct.TryGetValue(productId, out var rb))
{
conflicts.Add(new { productId, error = "товар не из исходного чека" });
continue;
}
var remaining = rb.Sold - rb.Returned;
if (requested > remaining)
{
var name = await _db.Products.Where(p => p.Id == productId).Select(p => p.Name).FirstOrDefaultAsync(ct);
conflicts.Add(new { productId, productName = name, requested, remaining });
}
}
if (conflicts.Count > 0)
{
return Conflict(new
{
error = "Превышено количество доступное к возврату по этому чеку.",
lines = conflicts,
});
}
}
await using var tx = await _db.Database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, ct);
foreach (var line in sale.Lines)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: sale.StoreId,
Quantity: +line.Quantity, // товар возвращается на склад
Type: MovementType.CustomerReturn,
DocumentType: "customer-return",
DocumentId: sale.Id,
DocumentNumber: sale.Number,
UnitCost: line.UnitPrice,
OccurredAt: sale.Date), ct);
}
// Инкрементим QtyReturned на исходных строках (для защиты следующих возвратов).
if (sale.ReferenceSaleId is { } refId2)
{
var refLines = await _db.RetailSaleLines
.Where(l => l.RetailSaleId == refId2).ToListAsync(ct);
foreach (var rl in refLines)
{
var taken = sale.Lines.Where(x => x.ProductId == rl.ProductId).Sum(x => x.Quantity);
if (taken > 0)
{
// Если в исходном чеке у одного товара несколько строк — распределим
// увеличение QtyReturned последовательно: насыщаем по line.Quantity.
var available = rl.Quantity - rl.QtyReturned;
var take = Math.Min(taken, available);
rl.QtyReturned += take;
taken -= take;
}
}
}
sale.Status = RetailSaleStatus.Posted;
sale.PostedAt = DateTime.UtcNow;
try
{
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
}
catch (Exception ex) when (IsSerializationConflict(ex))
{
return Conflict(new { error = "Документ проводится параллельно. Повторите попытку." });
}
return NoContent();
}
private async Task<IActionResult> UnpostReturnAsync(RetailSale sale, CancellationToken ct)
{
await using var tx = await _db.Database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, ct);
foreach (var line in sale.Lines)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: sale.StoreId,
Quantity: -line.Quantity,
Type: MovementType.CustomerReturn,
DocumentType: "customer-return-reversal",
DocumentId: sale.Id,
DocumentNumber: sale.Number,
UnitCost: line.UnitPrice,
OccurredAt: DateTime.UtcNow,
Notes: $"Отмена возврата {sale.Number}"), ct);
}
if (sale.ReferenceSaleId is { } refId)
{
var refLines = await _db.RetailSaleLines
.Where(l => l.RetailSaleId == refId).ToListAsync(ct);
foreach (var rl in refLines)
{
var taken = sale.Lines.Where(x => x.ProductId == rl.ProductId).Sum(x => x.Quantity);
if (taken > 0)
{
var giveBack = Math.Min(taken, rl.QtyReturned);
rl.QtyReturned -= giveBack;
taken -= giveBack;
}
}
}
sale.Status = RetailSaleStatus.Draft;
sale.PostedAt = null;
try
{
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
}
catch (Exception ex) when (IsSerializationConflict(ex))
{
return Conflict(new { error = "Документ обрабатывается параллельно. Повторите попытку." });
}
return NoContent();
}
private static bool IsSerializationConflict(Exception ex)
{
for (Exception? e = ex; e is not null; e = e.InnerException)
{
if (e is System.Data.Common.DbException { SqlState: "40001" or "40P01" })
return true;
}
return false;
}
private void ApplyLines(RetailSale sale, IReadOnlyList<RetailSaleLineInput> input, bool allowFractional)
{ {
decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero); decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero);
var order = 0; var order = 0;
decimal subtotal = 0, discountTotal = 0; decimal subtotal = 0, discountTotal = 0;
// Добавляем строки напрямую в DbSet, а не через nav `sale.Lines.Add`.
// На пути через nav EF8 в некоторых случаях помечает новую сущность
// как Modified (а не Added), потому что Id уже задан клиентом
// (Guid.NewGuid в Entity ctor) и связь child→parent через
// collection-navigation запутывает change-detector. Прямой Add в DbSet
// снимает неоднозначность.
foreach (var l in input) foreach (var l in input)
{ {
var unitPrice = R(l.UnitPrice); var unitPrice = R(l.UnitPrice);
var discount = R(l.Discount); var discount = R(l.Discount);
var lineTotal = l.Quantity * unitPrice - discount; var lineTotal = l.Quantity * unitPrice - discount;
_db.RetailSaleLines.Add(new RetailSaleLine sale.Lines.Add(new RetailSaleLine
{ {
RetailSaleId = sale.Id,
ProductId = l.ProductId, ProductId = l.ProductId,
Quantity = l.Quantity, Quantity = l.Quantity,
UnitPrice = unitPrice, UnitPrice = unitPrice,
@ -751,14 +465,9 @@ private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken
orderby l.SortOrder orderby l.SortOrder
select new RetailSaleLineDto( select new RetailSaleLineDto(
l.Id, l.ProductId, p.Name, p.Article, u.Name, l.Id, l.ProductId, p.Name, p.Article, u.Name,
l.Quantity, l.UnitPrice, l.Discount, l.LineTotal, l.VatPercent, l.SortOrder, l.Quantity, l.UnitPrice, l.Discount, l.LineTotal, l.VatPercent, l.SortOrder))
l.QtyReturned))
.ToListAsync(ct); .ToListAsync(ct);
string? refNumber = row.s.ReferenceSaleId is null ? null
: await _db.RetailSales.Where(rs => rs.Id == row.s.ReferenceSaleId)
.Select(rs => rs.Number).FirstOrDefaultAsync(ct);
return new RetailSaleDto( return new RetailSaleDto(
row.s.Id, row.s.Number, row.s.Date, row.s.Status, row.s.Id, row.s.Number, row.s.Date, row.s.Status,
row.st.Id, row.st.Name, row.st.Id, row.st.Name,
@ -768,7 +477,6 @@ orderby l.SortOrder
row.s.Subtotal, row.s.DiscountTotal, row.s.Total, row.s.Subtotal, row.s.DiscountTotal, row.s.Total,
row.s.Payment, row.s.PaidCash, row.s.PaidCard, row.s.Payment, row.s.PaidCash, row.s.PaidCard,
row.s.Notes, row.s.PostedAt, row.s.Notes, row.s.PostedAt,
row.s.IsReturn, row.s.ReferenceSaleId, refNumber,
lines); lines);
} }
} }

View file

@ -234,8 +234,7 @@ public async Task<IActionResult> ChangeOwner(Guid id, [FromBody] ChangeOwnerRequ
{ {
var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct); var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct);
if (o is null) return NotFound(); if (o is null) return NotFound();
if (string.IsNullOrWhiteSpace(req.Reason) || req.Reason.Trim().Length < 10) if (string.IsNullOrWhiteSpace(req.Reason)) return BadRequest(new { error = "Reason required." });
return BadRequest(new { error = "Причина смены владельца обязательна (≥ 10 символов) — она пишется в журнал аудита." });
var user = await _userMgr.FindByIdAsync(req.NewOwnerUserId.ToString()); var user = await _userMgr.FindByIdAsync(req.NewOwnerUserId.ToString());
if (user is null || user.OrganizationId != o.Id) if (user is null || user.OrganizationId != o.Id)
return BadRequest(new { error = "Пользователь не найден или не принадлежит этой организации." }); return BadRequest(new { error = "Пользователь не найден или не принадлежит этой организации." });

View file

@ -1,117 +0,0 @@
using System.Web;
using foodmarket.Infrastructure.Identity;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers;
/// <summary>TOTP-2FA для текущего пользователя. Использует встроенный
/// <c>AuthenticatorTokenProvider</c> ASP.NET Identity — RFC 6238 (Google
/// Authenticator, Authy, 1Password OTP).
///
/// Flow:
/// 1. POST <c>/api/me/2fa/enroll</c> — генерирует секрет (если ещё нет) и
/// возвращает QR-URI (otpauth://) + сам ключ. Клиент рисует QR.
/// 2. POST <c>/api/me/2fa/verify</c> с code из приложения — проверяет и
/// включает <c>TwoFactorEnabled</c>. После этого password-flow требует
/// otp_code.
/// 3. POST <c>/api/me/2fa/disable</c> с code — выключает.
///
/// Дополнительной фичи (backup-коды) пока нет — добавим если попросят.</summary>
[ApiController]
[Authorize]
[Route("api/me/2fa")]
public class TwoFactorController : ControllerBase
{
private readonly UserManager<User> _users;
private readonly AppDbContext _db;
private readonly IConfiguration _cfg;
public TwoFactorController(UserManager<User> users, AppDbContext db, IConfiguration cfg)
{
_users = users;
_db = db;
_cfg = cfg;
}
public record EnrollResult(string SharedKey, string AuthenticatorUri, bool AlreadyEnabled);
public record CodeInput(string Code);
public record StatusDto(bool Enabled);
[HttpGet("status")]
public async Task<ActionResult<StatusDto>> Status()
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
return new StatusDto(await _users.GetTwoFactorEnabledAsync(user));
}
[HttpPost("enroll")]
public async Task<ActionResult<EnrollResult>> Enroll()
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var already = await _users.GetTwoFactorEnabledAsync(user);
// Если уже включено — не отдаём секрет; сначала disable.
if (already)
return Ok(new EnrollResult("", "", true));
var key = await _users.GetAuthenticatorKeyAsync(user);
if (string.IsNullOrEmpty(key))
{
await _users.ResetAuthenticatorKeyAsync(user);
key = await _users.GetAuthenticatorKeyAsync(user);
}
var issuer = _cfg["App:Name"] ?? "food-market";
var label = $"{issuer}:{user.Email ?? user.UserName}";
// otpauth-URI согласно RFC: otpauth://totp/{label}?secret={key}&issuer={issuer}.
var uri = $"otpauth://totp/{HttpUtility.UrlEncode(label)}?secret={key}&issuer={HttpUtility.UrlEncode(issuer)}";
return Ok(new EnrollResult(key!, uri, false));
}
[HttpPost("verify")]
public async Task<IActionResult> Verify([FromBody] CodeInput input)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
if (string.IsNullOrWhiteSpace(input.Code))
return BadRequest(new { error = "Не указан код подтверждения.", field = "code" });
var provider = _users.Options.Tokens.AuthenticatorTokenProvider;
var ok = await _users.VerifyTwoFactorTokenAsync(user, provider, input.Code.Trim());
if (!ok)
return BadRequest(new { error = "Неверный код. Попробуйте ещё раз.", field = "code" });
await _users.SetTwoFactorEnabledAsync(user, true);
return Ok(new { enabled = true });
}
[HttpPost("disable")]
public async Task<IActionResult> Disable([FromBody] CodeInput input)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
if (!await _users.GetTwoFactorEnabledAsync(user))
return Ok(new { enabled = false });
// Защита от внезапного отключения 2FA: требуем текущий code из приложения.
if (string.IsNullOrWhiteSpace(input.Code))
return BadRequest(new { error = "Подтверди отключение текущим кодом из приложения.", field = "code" });
var provider = _users.Options.Tokens.AuthenticatorTokenProvider;
var ok = await _users.VerifyTwoFactorTokenAsync(user, provider, input.Code.Trim());
if (!ok)
return BadRequest(new { error = "Неверный код.", field = "code" });
await _users.SetTwoFactorEnabledAsync(user, false);
// Заодно сбрасываем authenticator-key, чтобы при повторном enroll выдался новый.
await _users.ResetAuthenticatorKeyAsync(user);
return Ok(new { enabled = false });
}
}

View file

@ -1,78 +0,0 @@
using System.Collections.Concurrent;
using System.Reflection;
using foodmarket.Api.Infrastructure.Tenancy;
using foodmarket.Domain.Organizations;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore;
using static OpenIddict.Abstractions.OpenIddictConstants;
namespace foodmarket.Api.Infrastructure.Authorization;
/// <summary>Проверяет <see cref="PermissionRequirement"/> по флагам роли
/// организации (RolePermissions) текущего пользователя.
///
/// Логика:
/// <list type="number">
/// <item>SuperAdmin — полный платформенный доступ.</item>
/// <item>Identity-роль Admin — системная роль «Администратор» = RolePermissions.All().
/// Шорткат, чтобы не зависеть от наличия идеально засиженной Employee-записи
/// (custom-роли НЕ маппятся на Identity Admin — см. IdentityRoleMapper —
/// поэтому шорткат их не задевает).</item>
/// <item>Иначе — ищем активного Employee пользователя в его орге и читаем
/// флаг права из его роли. Нет Employee/нет флага → доступ запрещён (fail-closed).</item>
/// </list>
///
/// Регистрируется scoped: использует <see cref="AppDbContext"/>.</summary>
public sealed class PermissionAuthorizationHandler : AuthorizationHandler<PermissionRequirement>
{
private static readonly ConcurrentDictionary<string, PropertyInfo?> PropertyCache = new();
private readonly AppDbContext _db;
public PermissionAuthorizationHandler(AppDbContext db) => _db = db;
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context, PermissionRequirement requirement)
{
var user = context.User;
if (user.IsInRole(HttpContextTenantContext.SuperAdminRole) || user.IsInRole("Admin"))
{
context.Succeed(requirement);
return;
}
var subRaw = user.FindFirst(Claims.Subject)?.Value;
var orgRaw = user.FindFirst(HttpContextTenantContext.OrganizationClaim)?.Value;
if (!Guid.TryParse(subRaw, out var userId) || !Guid.TryParse(orgRaw, out var orgId))
{
return; // нет sub/org — не можем установить роль → запрет
}
// Решение об авторизации не должно зависеть от ambient tenant-фильтра:
// фильтруем оргой из claim явно.
var role = await _db.Employees.IgnoreQueryFilters()
.Where(e => e.UserId == userId && e.OrganizationId == orgId && e.IsActive && !e.IsDeleted)
.Select(e => e.Role)
.FirstOrDefaultAsync();
if (role is not null && HasPermission(role.Permissions, requirement.Permission))
{
context.Succeed(requirement);
}
}
/// <summary>Читает boolean-флаг права по имени через рефлексию (с кэшем
/// PropertyInfo). Неизвестное имя или не-bool → false (fail-closed).</summary>
private static bool HasPermission(RolePermissions permissions, string permissionName)
{
var prop = PropertyCache.GetOrAdd(permissionName, name =>
typeof(RolePermissions).GetProperty(name,
BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase));
return prop is not null
&& prop.PropertyType == typeof(bool)
&& prop.GetValue(permissions) is true;
}
}

View file

@ -1,35 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
namespace foodmarket.Api.Infrastructure.Authorization;
/// <summary>Динамически собирает policy для <c>perm:&lt;Permission&gt;</c>,
/// чтобы не регистрировать каждую из ~30 permission-policy вручную. Всё
/// остальное (именованные policy типа AdminAccess, default) делегируется
/// штатному провайдеру.</summary>
public sealed class PermissionAuthorizationPolicyProvider : IAuthorizationPolicyProvider
{
private readonly DefaultAuthorizationPolicyProvider _fallback;
public PermissionAuthorizationPolicyProvider(IOptions<AuthorizationOptions> options)
=> _fallback = new DefaultAuthorizationPolicyProvider(options);
public Task<AuthorizationPolicy> GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync();
public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync();
public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
{
if (policyName.StartsWith(RequiresPermissionAttribute.PolicyPrefix, StringComparison.OrdinalIgnoreCase))
{
var permission = policyName[RequiresPermissionAttribute.PolicyPrefix.Length..];
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.AddRequirements(new PermissionRequirement(permission))
.Build();
return Task.FromResult<AuthorizationPolicy?>(policy);
}
return _fallback.GetPolicyAsync(policyName);
}
}

View file

@ -1,13 +0,0 @@
using Microsoft.AspNetCore.Authorization;
namespace foodmarket.Api.Infrastructure.Authorization;
/// <summary>Требование «у текущего пользователя включён флаг права
/// <see cref="Permission"/> в его роли (RolePermissions)». Имя права = имя
/// boolean-свойства <c>RolePermissions</c> (например, "ProductsEdit").</summary>
public sealed class PermissionRequirement : IAuthorizationRequirement
{
public string Permission { get; }
public PermissionRequirement(string permission) => Permission = permission;
}

View file

@ -1,22 +0,0 @@
using Microsoft.AspNetCore.Authorization;
namespace foodmarket.Api.Infrastructure.Authorization;
/// <summary>Гейтит endpoint на конкретное право роли. Имя права = имя
/// boolean-свойства <c>RolePermissions</c>. Реализовано поверх policy-механизма:
/// атрибут выставляет policy <c>perm:&lt;Permission&gt;</c>, которую
/// разворачивает <see cref="PermissionAuthorizationPolicyProvider"/> в
/// <see cref="PermissionRequirement"/>, а проверяет
/// <see cref="PermissionAuthorizationHandler"/>.
///
/// Пример: <c>[RequiresPermission("ProductsEdit")]</c>. Заменяет грубое
/// <c>[Authorize(Roles="Admin,Storekeeper")]</c> — теперь доступ определяют
/// флаги роли организации, а не зашитый список Identity-ролей.</summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)]
public sealed class RequiresPermissionAttribute : AuthorizeAttribute
{
public const string PolicyPrefix = "perm:";
public RequiresPermissionAttribute(string permission)
=> Policy = $"{PolicyPrefix}{permission}";
}

View file

@ -1,35 +0,0 @@
using foodmarket.Application.Purchases.Commands;
using foodmarket.Application.Sales.Commands;
namespace foodmarket.Api.Infrastructure.Cqrs;
/// <summary>Заглушки для абстракций CQRS-handler'ов из food-market.application:
/// контроллеры в текущем спринте остаются на прямом доступе к DbContext
/// (поэтапная миграция к CQRS), эти классы нужны только чтобы DI-validation
/// на старте не падал при попытке активировать handler.
///
/// Когда контроллер начнёт реально слать команду через IMediator, заменяем
/// эти стабы на реальную имплементацию (через EF, IStockService и т.п.).
/// При случайной активации throw'аем — phantom-инициализация не должна молча
/// проглатывать команды.</summary>
public sealed class UnusedSupplyWriter : ISupplyWriter
{
public Task<string> NextNumberAsync(int year, CancellationToken ct)
=> throw new NotImplementedException(
"CreateSupplyHandler ещё не подключён к контроллерам. " +
"Контроллер использует прямой доступ к EF; этот writer существует только как образец CQRS-абстракции.");
public Task<Guid> CreateAsync(Guid supplierId, Guid storeId, Guid currencyId,
DateTime date, string? notes, string number,
IReadOnlyList<CreateSupplyLine> lines, decimal total, CancellationToken ct)
=> throw new NotImplementedException(
"CreateSupplyHandler ещё не подключён к контроллерам.");
}
public sealed class UnusedRetailSalePoster : IRetailSalePoster
{
public Task PostAsync(Guid saleId, IReadOnlyList<PostSaleLine> lines, CancellationToken ct)
=> throw new NotImplementedException(
"PostRetailSaleHandler ещё не подключён к контроллерам. " +
"Контроллер RetailSalesController.Post использует прямой stock-service.");
}

View file

@ -1,75 +0,0 @@
using System.Collections.Concurrent;
using System.Reflection;
using foodmarket.Application.Common.Email;
namespace foodmarket.Api.Infrastructure.Email;
/// <summary>Загружает HTML-шаблоны из embedded resources
/// (`Resources/EmailTemplates/*.html`) и рендерит их подстановкой словаря.
/// Шаблоны кешируются после первого чтения — files embed'нуты в сборку,
/// перечитывать незачем.
///
/// Зачем embedded, а не файлы на диске: на проде нет gauantee, что
/// `ContentRootPath/Resources/EmailTemplates/` доедет до контейнера —
/// docker может монтировать только то, что ему нужно. Embedded — один
/// .dll, нет I/O при рендере, нет misconfiguration на staging.</summary>
public class EmailTemplates
{
private static readonly ConcurrentDictionary<string, string> _cache = new();
private static readonly Assembly _asm = typeof(EmailTemplates).Assembly;
/// <summary>Базовое имя шаблона (без .html): "invite", "weekly-summary",
/// "low-stock". Кидает FileNotFoundException если ресурс отсутствует —
/// это явный bug в сборке, не runtime-условие.</summary>
public (string Subject, string HtmlBody, string TextBody) Render(
string name, IReadOnlyDictionary<string, string?> values)
{
var raw = LoadRaw(name);
// Первая строка файла — Subject: <тема>. Остальное — HTML body. Plain text
// получаем грубым strip-тагов из HTML — для типовых шаблонов выглядит ок.
var lines = raw.Split('\n', 2);
if (lines.Length < 2 || !lines[0].StartsWith("Subject:", StringComparison.Ordinal))
throw new InvalidOperationException(
$"Шаблон {name} должен начинаться с 'Subject: <тема>' первой строкой.");
var subjectTemplate = lines[0]["Subject:".Length..].Trim();
var htmlTemplate = lines[1];
var subject = EmailTemplateRenderer.Render(subjectTemplate, values);
var html = EmailTemplateRenderer.Render(htmlTemplate, values);
var text = StripHtml(html);
return (subject, html, text);
}
private static string LoadRaw(string name)
{
return _cache.GetOrAdd(name, key =>
{
// Имя ресурса формируется через AssemblyName.<folder>.<file> с подстановкой
// path separator → '.'. У нас embedded под "foodmarket.Api.Resources.EmailTemplates.{key}.html".
var resource = $"foodmarket.Api.Resources.EmailTemplates.{key}.html";
using var stream = _asm.GetManifestResourceStream(resource)
?? throw new FileNotFoundException(
$"Email-шаблон {key}.html не найден в embedded resources. " +
$"Доступно: {string.Join(", ", _asm.GetManifestResourceNames())}");
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
});
}
private static string StripHtml(string html)
{
// Грубый strip для plain-fallback'а. Заменяет <br>/</p> на \n, удаляет остальные теги,
// декодирует основные entities. Не для произвольного HTML, но для наших шаблонов хватает.
var s = System.Text.RegularExpressions.Regex.Replace(html, @"<\s*br\s*/?\s*>", "\n",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
s = System.Text.RegularExpressions.Regex.Replace(s, @"</\s*(p|h\d|div|li|tr)\s*>", "\n",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
s = System.Text.RegularExpressions.Regex.Replace(s, @"<[^>]+>", "");
s = s.Replace("&nbsp;", " ").Replace("&amp;", "&")
.Replace("&lt;", "<").Replace("&gt;", ">")
.Replace("&quot;", "\"").Replace("&#39;", "'");
// Свернуть множественные пустые строки.
s = System.Text.RegularExpressions.Regex.Replace(s, @"\n{3,}", "\n\n");
return s.Trim();
}
}

View file

@ -1,43 +0,0 @@
using foodmarket.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace foodmarket.Api.Infrastructure.Health;
/// <summary>Readiness-проба: приложение готово принимать трафик только когда
/// БД отвечает И все миграции применены. Если есть неприменённые миграции —
/// схема не соответствует коду (например, контейнер поднялся раньше, чем
/// отработал <c>Migrate()</c>, или откатили не ту версию) — отдаём Unhealthy,
/// чтобы оркестратор/прокси не слал на этот инстанс запросы.</summary>
public sealed class DatabaseReadyHealthCheck : IHealthCheck
{
private readonly AppDbContext _db;
public DatabaseReadyHealthCheck(AppDbContext db) => _db = db;
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context, CancellationToken cancellationToken = default)
{
try
{
if (!await _db.Database.CanConnectAsync(cancellationToken))
{
return HealthCheckResult.Unhealthy("Нет соединения с БД.");
}
var pending = (await _db.Database.GetPendingMigrationsAsync(cancellationToken)).ToList();
if (pending.Count > 0)
{
return HealthCheckResult.Unhealthy(
$"Неприменённые миграции: {pending.Count} (первая: {pending[0]}).",
data: new Dictionary<string, object> { ["pendingMigrations"] = pending });
}
return HealthCheckResult.Healthy("БД доступна, миграции применены.");
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy("Ошибка проверки готовности БД.", ex);
}
}
}

View file

@ -1,73 +0,0 @@
using Prometheus;
namespace foodmarket.Api.Infrastructure.Observability;
/// <summary>Бизнес-метрики поверх стандартных HTTP/DB-счётчиков, которые
/// добавляет <c>prometheus-net.AspNetCore</c> через <c>UseHttpMetrics()</c>
/// и EF-интерсептор. Сюда выносим то, что важно знать на дашборде вне
/// зависимости от HTTP-кода: количество проведённых документов, ошибок
/// проведения, длительность запросов к БД (агрегат поверх интерсептора).
///
/// Multi-tenant: лейблы — только тип документа и outcome (success/error).
/// Tenant-метку НЕ добавляем — на крупном multi-tenant хосте это раздуло бы
/// cardinality. Если потребуется per-org разрез — отдельный endpoint
/// `/api/reports/...` уже есть, метрики остаются глобальным health-сигналом.</summary>
public static class AppMetrics
{
/// <summary>Счётчик проведённых документов (Post). Метка <c>type</c>:
/// "retail-sale", "supply", "enter", "loss", "transfer", "inventory",
/// "supplier-return", "customer-return". Метрика инкрементится из
/// <see cref="IncrementPosted"/> в коде контроллеров на успешном Post.</summary>
public static readonly Counter DocumentsPosted = Metrics.CreateCounter(
"food_market_documents_posted_total",
"Количество успешно проведённых документов учёта.",
new CounterConfiguration { LabelNames = new[] { "type" } });
/// <summary>Счётчик ошибок при попытке проведения (BadRequest/Conflict
/// и серверные сбои). Метка <c>type</c> — тот же, что у DocumentsPosted;
/// <c>reason</c> — короткий код: "insufficient_stock", "serialization",
/// "validation", "other".</summary>
public static readonly Counter DocumentsError = Metrics.CreateCounter(
"food_market_documents_error_total",
"Количество ошибок при проведении документов.",
new CounterConfiguration { LabelNames = new[] { "type", "reason" } });
/// <summary>Алиас на DocumentsPosted с фиксированной меткой "retail-sale"
/// — Sprint-spec явно перечисляет sales_posted_total в чек-листе.</summary>
public static readonly Counter SalesPosted = Metrics.CreateCounter(
"food_market_sales_posted_total",
"Количество проведённых розничных чеков (alias-метрика).");
/// <summary>Аналогично для приёмок.</summary>
public static readonly Counter SuppliesPosted = Metrics.CreateCounter(
"food_market_supplies_posted_total",
"Количество проведённых приёмок (alias-метрика).");
/// <summary>Гистограмма длительности EF Core SQL-запросов в секундах.
/// Лейбл <c>kind</c> — "query" / "command" (SELECT vs CUD), помогает
/// разделить read-heavy и write-heavy нагрузку. Buckets подобраны под
/// типичные веб-операции 5мс…5с.</summary>
public static readonly Histogram DbQueryDuration = Metrics.CreateHistogram(
"food_market_db_query_duration_seconds",
"Длительность EF Core SQL-запросов.",
new HistogramConfiguration
{
LabelNames = new[] { "kind" },
Buckets = new[] { 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10 },
});
/// <summary>Сахар: успешное проведение документа. Инкрементит общий
/// counter + опциональный alias (для sales/supplies).</summary>
public static void IncrementPosted(string type)
{
DocumentsPosted.WithLabels(type).Inc();
switch (type)
{
case "retail-sale": SalesPosted.Inc(); break;
case "supply": SuppliesPosted.Inc(); break;
}
}
public static void IncrementError(string type, string reason)
=> DocumentsError.WithLabels(type, reason).Inc();
}

View file

@ -1,63 +0,0 @@
using System.Data.Common;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace foodmarket.Api.Infrastructure.Observability;
/// <summary>EF Core <c>DbCommandInterceptor</c>, который засекает время каждого
/// SQL-запроса и пишет в <see cref="AppMetrics.DbQueryDuration"/>. Использует
/// диагностический контекст <c>CommandEventData.StartTime</c> чтобы корректно
/// измерить именно execution-фазу (без подготовки и инициализации соединения).
///
/// Лейбл <c>kind</c>:
/// • "query" — SELECT (Reader/Scalar);
/// • "command" — INSERT/UPDATE/DELETE/EXECUTE (NonQuery).
///
/// Интерсептор регистрируется в DI как Singleton — он stateless. EF DbContext
/// подхватывает его через <c>AddInterceptors</c>.</summary>
public sealed class DbMetricsInterceptor : DbCommandInterceptor
{
public override ValueTask<DbDataReader> ReaderExecutedAsync(
DbCommand command, CommandExecutedEventData eventData, DbDataReader result,
CancellationToken cancellationToken = default)
{
AppMetrics.DbQueryDuration.WithLabels("query").Observe(eventData.Duration.TotalSeconds);
return base.ReaderExecutedAsync(command, eventData, result, cancellationToken);
}
public override DbDataReader ReaderExecuted(
DbCommand command, CommandExecutedEventData eventData, DbDataReader result)
{
AppMetrics.DbQueryDuration.WithLabels("query").Observe(eventData.Duration.TotalSeconds);
return base.ReaderExecuted(command, eventData, result);
}
public override ValueTask<int> NonQueryExecutedAsync(
DbCommand command, CommandExecutedEventData eventData, int result,
CancellationToken cancellationToken = default)
{
AppMetrics.DbQueryDuration.WithLabels("command").Observe(eventData.Duration.TotalSeconds);
return base.NonQueryExecutedAsync(command, eventData, result, cancellationToken);
}
public override int NonQueryExecuted(
DbCommand command, CommandExecutedEventData eventData, int result)
{
AppMetrics.DbQueryDuration.WithLabels("command").Observe(eventData.Duration.TotalSeconds);
return base.NonQueryExecuted(command, eventData, result);
}
public override ValueTask<object?> ScalarExecutedAsync(
DbCommand command, CommandExecutedEventData eventData, object? result,
CancellationToken cancellationToken = default)
{
AppMetrics.DbQueryDuration.WithLabels("query").Observe(eventData.Duration.TotalSeconds);
return base.ScalarExecutedAsync(command, eventData, result, cancellationToken);
}
public override object? ScalarExecuted(
DbCommand command, CommandExecutedEventData eventData, object? result)
{
AppMetrics.DbQueryDuration.WithLabels("query").Observe(eventData.Duration.TotalSeconds);
return base.ScalarExecuted(command, eventData, result);
}
}

View file

@ -1,38 +0,0 @@
using System.Security.Claims;
using foodmarket.Api.Infrastructure.Tenancy;
using Serilog.Context;
namespace foodmarket.Api.Infrastructure.Observability;
/// <summary>Обогащает каждый HTTP-запрос структурными лейблами в Serilog
/// LogContext: OrgId, UserId, CorrelationId. Любая ILogger.Log<…>
/// внутри пайплайна автоматически получит эти свойства — не надо
/// тащить их явно в каждый вызов.
///
/// CorrelationId: берётся из заголовка <c>X-Correlation-ID</c> если есть,
/// иначе генерируется Guid. Эхо в response-header чтобы клиент видел
/// id для последующего запроса в support.</summary>
public sealed class LogEnrichmentMiddleware
{
private const string HeaderName = "X-Correlation-ID";
private readonly RequestDelegate _next;
public LogEnrichmentMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext ctx)
{
var correlationId = ctx.Request.Headers.TryGetValue(HeaderName, out var hdr) && !string.IsNullOrWhiteSpace(hdr)
? hdr.ToString()
: Guid.NewGuid().ToString("N");
ctx.Response.Headers[HeaderName] = correlationId;
var orgId = ctx.User?.FindFirst(HttpContextTenantContext.OrganizationClaim)?.Value;
var userId = ctx.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? ctx.User?.FindFirst("sub")?.Value;
using var c = LogContext.PushProperty("CorrelationId", correlationId);
using var o = orgId is null ? null : LogContext.PushProperty("OrgId", orgId);
using var u = userId is null ? null : LogContext.PushProperty("UserId", userId);
await _next(ctx);
}
}

View file

@ -1,113 +0,0 @@
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;
namespace foodmarket.Api.Infrastructure.RateLimiting;
/// <summary>Anti-brute-force на чувствительных auth-эндпоинтах: подбор пароля
/// на <c>/connect/token</c> и спам-регистрация на <c>/api/auth/signup</c>.
///
/// Два sliding-window лимита на IP, оба должны пропустить запрос (chained):
/// 5/мин (резкий всплеск) и 20/час (медленный перебор). Применяется
/// глобально, но no-op для всех путей кроме двух auth-эндпоинтов — так
/// нет нужды цеплять policy на каждый minimal-API/контроллер по отдельности,
/// а двойное окно (которое per-endpoint policy выразить не может) работает
/// через <see cref="PartitionedRateLimiter.CreateChained{TResource}"/>.</summary>
public static class AuthRateLimiterExtensions
{
// Дефолты. Переопределяются конфигом RateLimiting:* (см. ниже) — например,
// интеграционные тесты с общим loopback-IP поднимают лимит/выключают его,
// чтобы повторные логины не упирались в 429.
public const int DefaultPerMinutePermitLimit = 5;
public const int DefaultPerHourPermitLimit = 20;
private const string NoLimitPartition = "__not-an-auth-endpoint";
public static IServiceCollection AddAuthRateLimiting(this IServiceCollection services, IConfiguration config)
{
var section = config.GetSection("RateLimiting");
var enabled = section.GetValue("Enabled", true);
var perMinute = section.GetValue("PerMinute", DefaultPerMinutePermitLimit);
var perHour = section.GetValue("PerHour", DefaultPerHourPermitLimit);
services.AddRateLimiter(options =>
{
// По умолчанию RateLimiter отдаёт 503 — нам нужен честный 429.
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.GlobalLimiter = enabled
? PartitionedRateLimiter.CreateChained(
BuildWindow(perMinute, TimeSpan.FromMinutes(1)),
BuildWindow(perHour, TimeSpan.FromHours(1)))
: PartitionedRateLimiter.Create<HttpContext, string>(
_ => RateLimitPartition.GetNoLimiter(NoLimitPartition));
options.OnRejected = async (context, token) =>
{
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
{
context.HttpContext.Response.Headers.RetryAfter =
((int)retryAfter.TotalSeconds).ToString();
}
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
context.HttpContext.Response.ContentType = "application/json";
await context.HttpContext.Response.WriteAsJsonAsync(new
{
error = "too_many_requests",
error_description = "Слишком много попыток. Повторите позже.",
}, token);
};
});
return services;
}
private static PartitionedRateLimiter<HttpContext> BuildWindow(int permitLimit, TimeSpan window) =>
PartitionedRateLimiter.Create<HttpContext, string>(ctx =>
{
var bucket = ResolveAuthBucket(ctx);
if (bucket is null)
{
return RateLimitPartition.GetNoLimiter(NoLimitPartition);
}
// Отдельный бакет на каждый эндпоинт: успешная регистрация не должна
// съедать лимит логинов (и наоборот). Ключ = "<эндпоинт>:<IP>".
return RateLimitPartition.GetSlidingWindowLimiter(
$"{bucket}:{ResolveClientKey(ctx)}",
_ => new SlidingWindowRateLimiterOptions
{
PermitLimit = permitLimit,
Window = window,
SegmentsPerWindow = 6,
QueueLimit = 0,
AutoReplenishment = true,
});
});
/// <summary>Возвращает имя бакета для лимитируемого auth-эндпоинта либо
/// null, если запрос не подлежит лимиту.</summary>
private static string? ResolveAuthBucket(HttpContext ctx)
{
if (!HttpMethods.IsPost(ctx.Request.Method)) return null;
var path = ctx.Request.Path;
if (path.StartsWithSegments("/connect/token", StringComparison.OrdinalIgnoreCase)) return "token";
if (path.StartsWithSegments("/api/auth/signup", StringComparison.OrdinalIgnoreCase)) return "signup";
return null;
}
/// <summary>Ключ партиции — IP клиента. За nginx-проксей реальный адрес
/// в X-Forwarded-For (берём левый — самый дальний клиент); напрямую —
/// RemoteIpAddress. Без идентификации IP лимит общий на всех (fallback
/// «unknown»), что безопаснее, чем не лимитировать вовсе.</summary>
private static string ResolveClientKey(HttpContext ctx)
{
var forwarded = ctx.Request.Headers["X-Forwarded-For"].ToString();
if (!string.IsNullOrWhiteSpace(forwarded))
{
var first = forwarded.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
if (first.Length > 0) return first[0];
}
return ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown";
}
}

View file

@ -1,102 +0,0 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Microsoft.IdentityModel.Tokens;
using OpenIddict.Server;
namespace foodmarket.Api.Infrastructure.Security;
/// <summary>Настройка ключей подписи/шифрования токенов OpenIddict.
///
/// <para><b>Development</b> — persistent RSA-ключ в App_Data/openiddict-dev-key.xml
/// (как было): переживает рестарты, шифрование access-token выключено для удобства
/// отладки. Поведение не меняется.</para>
///
/// <para><b>Production/Stage</b> — настоящие X509-сертификаты (отдельные для подписи
/// и шифрования). Путь берётся из конфигурации (<c>OpenIddict:SigningCertPath</c> /
/// <c>OpenIddict:EncryptionCertPath</c>, можно через env), иначе — App_Data/*.pfx
/// (монтируется volume'ом, см. docker-compose). Если файла нет — генерируется
/// <b>persistent</b> self-signed сертификат (5 лет) и сохраняется по пути. Это
/// заменяет dev-ephemeral поведение: при рестарте берётся тот же сертификат,
/// поэтому ранее выданные токены остаются валидными.</para>
///
/// <para>Опциональный пароль PFX — <c>OpenIddict:CertPassword</c>.</para>
/// </summary>
public static class OpenIddictKeyConfigurator
{
private enum CertPurpose { Signing, Encryption }
public static void ConfigureSigningAndEncryption(
OpenIddictServerBuilder options, IConfiguration config, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
var rsa = LoadOrCreateDevRsa(
Path.Combine(env.ContentRootPath, "App_Data", "openiddict-dev-key.xml"));
var devKey = new RsaSecurityKey(rsa) { KeyId = "food-market-dev" };
options.AddEncryptionKey(devKey);
options.AddSigningKey(devKey);
options.DisableAccessTokenEncryption();
return;
}
var dataDir = Path.Combine(env.ContentRootPath, "App_Data");
var password = config["OpenIddict:CertPassword"];
var signingPath = config["OpenIddict:SigningCertPath"];
if (string.IsNullOrWhiteSpace(signingPath))
signingPath = Path.Combine(dataDir, "openiddict-signing.pfx");
var encryptionPath = config["OpenIddict:EncryptionCertPath"];
if (string.IsNullOrWhiteSpace(encryptionPath))
encryptionPath = Path.Combine(dataDir, "openiddict-encryption.pfx");
options.AddSigningCertificate(LoadOrCreateCertificate(signingPath, password, CertPurpose.Signing));
options.AddEncryptionCertificate(LoadOrCreateCertificate(encryptionPath, password, CertPurpose.Encryption));
}
private static RSA LoadOrCreateDevRsa(string keyPath)
{
var rsa = RSA.Create(2048);
if (File.Exists(keyPath))
{
rsa.FromXmlString(File.ReadAllText(keyPath));
}
else
{
Directory.CreateDirectory(Path.GetDirectoryName(keyPath)!);
File.WriteAllText(keyPath, rsa.ToXmlString(includePrivateParameters: true));
}
return rsa;
}
private static X509Certificate2 LoadOrCreateCertificate(string path, string? password, CertPurpose purpose)
{
// EphemeralKeySet — приватный ключ держим в памяти процесса, не пишем
// в системный keystore: в Linux-контейнере это самый переносимый вариант.
const X509KeyStorageFlags flags = X509KeyStorageFlags.EphemeralKeySet | X509KeyStorageFlags.Exportable;
if (File.Exists(path))
{
return new X509Certificate2(File.ReadAllBytes(path), password, flags);
}
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
using var rsa = RSA.Create(2048);
var request = new CertificateRequest(
$"CN=food-market-{purpose.ToString().ToLowerInvariant()}",
rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
var usage = purpose == CertPurpose.Signing
? X509KeyUsageFlags.DigitalSignature
: X509KeyUsageFlags.KeyEncipherment;
request.CertificateExtensions.Add(new X509KeyUsageExtension(usage, critical: true));
using var generated = request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(5));
var pfx = generated.Export(X509ContentType.Pfx, password);
File.WriteAllBytes(path, pfx);
return new X509Certificate2(pfx, password, flags);
}
}

View file

@ -69,16 +69,6 @@ public bool IsSuperAdmin
} }
} }
public Guid? UserId
{
get
{
var sub = _accessor.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? _accessor.HttpContext?.User?.FindFirst("sub")?.Value;
return Guid.TryParse(sub, out var id) ? id : null;
}
}
public bool IsTenantOverride public bool IsTenantOverride
{ {
get get

View file

@ -1,58 +0,0 @@
using Microsoft.AspNetCore.Authentication;
using System.Security.Claims;
namespace foodmarket.Api.Infrastructure.Tenancy;
/// <summary>В режиме «открыть как…» (SuperAdmin + X-Org-Override) сам
/// SuperAdmin не имеет роли Admin/Storekeeper/Cashier, потому что эти
/// роли — атрибуты сотрудника тенанта. Без трансформации claim'ов все
/// мутации, защищённые <c>[Authorize(Roles="Admin,Storekeeper")]</c>,
/// отшиваются 403 даже когда <see cref="ReadonlyOverrideMiddleware"/>
/// разрешил мутацию (edit-mode с X-Org-Override-Reason ≥ 10 символов).
///
/// Здесь мы временно (на текущий request) добавляем SuperAdmin'у полный
/// набор tenant-ролей, чтобы он мог пройти атрибуты-гарды контроллеров.
/// Изоляция и аудит остаются: query filter всё равно скоупится на
/// X-Org-Override, а <see cref="SuperAdminEditAuditFilter"/> пишет запись
/// в super_admin_audit_log с reason и diff'ом.
///
/// Срабатывает только если:
/// - User имеет роль SuperAdmin,
/// - запрос содержит заголовок X-Org-Override.
/// На GET-запросах (read-only override) тоже работает — это безопасно,
/// потому что мутации остаются за middleware-гардом.</summary>
public class SuperAdminOverrideClaimsTransformer : IClaimsTransformation
{
private readonly IHttpContextAccessor _accessor;
public SuperAdminOverrideClaimsTransformer(IHttpContextAccessor accessor)
=> _accessor = accessor;
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
var ctx = _accessor.HttpContext;
if (ctx is null) return Task.FromResult(principal);
var isSuper = principal.IsInRole(HttpContextTenantContext.SuperAdminRole);
var hasOverride = ctx.Request.Headers.ContainsKey(HttpContextTenantContext.OrgOverrideHeader);
if (!isSuper || !hasOverride) return Task.FromResult(principal);
// Не клонируем — IClaimsTransformation вызывается на каждый запрос,
// принципал и так свежий. Мутируем первый identity.
var identity = principal.Identities.FirstOrDefault(i => i.IsAuthenticated);
if (identity is null) return Task.FromResult(principal);
foreach (var role in TenantRolesForOverride)
{
if (!principal.IsInRole(role))
identity.AddClaim(new Claim(identity.RoleClaimType, role));
}
return Task.FromResult(principal);
}
private static readonly string[] TenantRolesForOverride =
[
"Admin", "Storekeeper", "Cashier",
];
}

View file

@ -1,43 +0,0 @@
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace foodmarket.Api.Infrastructure.Validation;
/// <summary>Action filter, который автоматически прогоняет
/// <see cref="IValidator{T}"/> по каждому body-параметру, для которого
/// validator зарегистрирован в DI. На неуспех — возвращает 400 ValidationProblemDetails
/// (стандартный формат RFC 7807, фронт уже знает как парсить ModelState).
///
/// Зачем не <c>FluentValidation.AspNetCore</c>: пакет официально deprecated
/// (см. https://docs.fluentvalidation.net/en/latest/aspnet.html). Текущая
/// рекомендация — ручная интеграция через DI + minimal filter. У нас тонкий
/// слой, читается прозрачно.</summary>
public sealed class ValidationFilter : IAsyncActionFilter
{
private readonly IServiceProvider _sp;
public ValidationFilter(IServiceProvider sp) => _sp = sp;
public async Task OnActionExecutionAsync(ActionExecutingContext ctx, ActionExecutionDelegate next)
{
foreach (var (name, value) in ctx.ActionArguments)
{
if (value is null) continue;
// Ищем IValidator<TConcrete> для рантайм-типа значения.
var validatorType = typeof(IValidator<>).MakeGenericType(value.GetType());
var validator = _sp.GetService(validatorType) as IValidator;
if (validator is null) continue;
var ctxV = new ValidationContext<object>(value);
var result = await validator.ValidateAsync(ctxV, ctx.HttpContext.RequestAborted);
if (!result.IsValid)
{
foreach (var err in result.Errors)
ctx.ModelState.AddModelError(err.PropertyName, err.ErrorMessage);
ctx.Result = new BadRequestObjectResult(new ValidationProblemDetails(ctx.ModelState));
return;
}
}
await next();
}
}

View file

@ -1,135 +0,0 @@
using FluentValidation;
namespace foodmarket.Api.Infrastructure.Validation;
// ──────────────────────────────────────────────────────────────────────────────
// FluentValidation-валидаторы для основных input-DTO. Зарегистрированы
// автоматически через AddValidatorsFromAssemblyContaining<Program>().
//
// Зачем выносим из контроллеров: единый формат ошибок (ValidationProblemDetails),
// тестируемые юнит-тестами без HTTP, переиспользуемые правила (например, BIN
// длиной 12 цифр — одинаково для Counterparty.Create и Counterparty.Update),
// читаемые правила (RuleFor + цепочка).
//
// Стиль: лаконично, на одно поле — одна цепочка. Сообщения по-русски (UI ждёт RU).
// ──────────────────────────────────────────────────────────────────────────────
public sealed class SupplyInputValidator : AbstractValidator<foodmarket.Api.Controllers.Purchases.SuppliesController.SupplyInput>
{
public SupplyInputValidator()
{
RuleFor(x => x.SupplierId).NotEqual(Guid.Empty).WithMessage("Не указан поставщик.");
RuleFor(x => x.StoreId).NotEqual(Guid.Empty).WithMessage("Не указан склад.");
RuleFor(x => x.CurrencyId).NotEqual(Guid.Empty).WithMessage("Не указана валюта.");
RuleFor(x => x.Date).LessThan(DateTime.UtcNow.AddDays(1)).WithMessage("Дата не может быть в будущем.");
RuleFor(x => x.Notes).MaximumLength(1000);
RuleFor(x => x.Lines).NotEmpty().WithMessage("Приёмка должна содержать хотя бы одну позицию.");
RuleForEach(x => x.Lines).ChildRules(line =>
{
line.RuleFor(l => l.ProductId).NotEqual(Guid.Empty).WithMessage("Не указан товар в строке.");
line.RuleFor(l => l.Quantity).GreaterThan(0).WithMessage("Количество должно быть больше 0.");
line.RuleFor(l => l.UnitPrice).GreaterThanOrEqualTo(0).WithMessage("Цена не может быть отрицательной.");
});
}
}
public sealed class RetailSaleInputValidator : AbstractValidator<foodmarket.Api.Controllers.Sales.RetailSalesController.RetailSaleInput>
{
public RetailSaleInputValidator()
{
RuleFor(x => x.StoreId).NotEqual(Guid.Empty).WithMessage("Не указан склад.");
RuleFor(x => x.CurrencyId).NotEqual(Guid.Empty).WithMessage("Не указана валюта.");
RuleFor(x => x.Date).LessThan(DateTime.UtcNow.AddDays(1)).WithMessage("Дата не может быть в будущем.");
RuleFor(x => x.Notes).MaximumLength(1000);
RuleFor(x => x.PaidCash).GreaterThanOrEqualTo(0);
RuleFor(x => x.PaidCard).GreaterThanOrEqualTo(0);
// Возврат с reference: ReferenceSaleId должен быть валидный GUID
// (саму ссылку валидирует контроллер обращаясь в БД).
When(x => x.IsReturn, () =>
{
RuleFor(x => x.ReferenceSaleId).Must(id => id is null || id != Guid.Empty)
.WithMessage("Невалидный ReferenceSaleId.");
});
RuleFor(x => x.Lines).NotEmpty().WithMessage("Чек должен содержать хотя бы одну позицию.");
RuleForEach(x => x.Lines).ChildRules(line =>
{
line.RuleFor(l => l.ProductId).NotEqual(Guid.Empty);
line.RuleFor(l => l.Quantity).GreaterThan(0);
line.RuleFor(l => l.UnitPrice).GreaterThanOrEqualTo(0);
line.RuleFor(l => l.Discount).GreaterThanOrEqualTo(0);
line.RuleFor(l => l.VatPercent).InclusiveBetween(0, 100);
});
}
}
public sealed class ProductInputValidator : AbstractValidator<foodmarket.Application.Catalog.ProductInput>
{
public ProductInputValidator()
{
RuleFor(x => x.Name).NotEmpty().WithMessage("Название обязательно.")
.MaximumLength(200);
RuleFor(x => x.Article).MaximumLength(50);
RuleFor(x => x.UnitOfMeasureId).NotEqual(Guid.Empty)
.WithMessage("Не указана единица измерения.");
RuleFor(x => x.Vat).InclusiveBetween(0m, 100m).When(x => x.Vat.HasValue)
.WithMessage("Ставка НДС должна быть от 0 до 100%.");
RuleFor(x => x.ReferencePrice).GreaterThanOrEqualTo(0).When(x => x.ReferencePrice.HasValue);
RuleFor(x => x.MinStock).GreaterThanOrEqualTo(0).When(x => x.MinStock.HasValue);
RuleFor(x => x.MaxStock).GreaterThanOrEqualTo(0).When(x => x.MaxStock.HasValue);
// MinStock <= MaxStock когда оба заданы.
RuleFor(x => x).Must(x => !x.MinStock.HasValue || !x.MaxStock.HasValue || x.MinStock.Value <= x.MaxStock.Value)
.WithMessage("MinStock не может быть больше MaxStock.");
}
}
public sealed class CounterpartyInputValidator : AbstractValidator<foodmarket.Application.Catalog.CounterpartyInput>
{
public CounterpartyInputValidator()
{
RuleFor(x => x.Name).NotEmpty().WithMessage("Название обязательно.")
.MaximumLength(200);
// БИН (юрлицо) — 12 цифр, ИИН (физлицо) — 12 цифр. Один из двух
// обязателен только если тип LegalEntity (Type=1). Для Individual (2)
// достаточно ИИН или имени. На уровне БД эти ограничения не enforced —
// валидируем именно тут.
When(x => !string.IsNullOrEmpty(x.Bin), () =>
{
RuleFor(x => x.Bin!).Matches(@"^\d{12}$")
.WithMessage("БИН должен быть 12-значный.");
});
When(x => !string.IsNullOrEmpty(x.Iin), () =>
{
RuleFor(x => x.Iin!).Matches(@"^\d{12}$")
.WithMessage("ИИН должен быть 12-значный.");
});
// Простая validation формата e-mail (без RFC-зубодробильника):
// contains @ and a dot, with no spaces.
When(x => !string.IsNullOrEmpty(x.Email), () =>
{
RuleFor(x => x.Email!).EmailAddress().WithMessage("Невалидный e-mail.");
});
}
}
public sealed class EmployeeInputValidator : AbstractValidator<foodmarket.Api.Controllers.Organizations.EmployeesController.EmployeeInput>
{
public EmployeeInputValidator()
{
RuleFor(x => x.LastName).NotEmpty().WithMessage("Фамилия обязательна.").MaximumLength(100);
RuleFor(x => x.FirstName).NotEmpty().WithMessage("Имя обязательно.").MaximumLength(100);
RuleFor(x => x.RoleId).NotEqual(Guid.Empty).WithMessage("Не выбрана роль.");
// SendInvite только при CreateAccount=true и наличии Email.
When(x => x.SendInvite, () =>
{
RuleFor(x => x.CreateAccount).Equal(true)
.WithMessage("Для отправки приглашения нужно создавать учётную запись.");
RuleFor(x => x.Email).NotEmpty()
.WithMessage("Для приглашения нужен email сотрудника.");
});
When(x => x.CreateAccount, () =>
{
RuleFor(x => x.Email).NotEmpty().EmailAddress()
.WithMessage("Для создания учётки нужен валидный email.");
});
}
}

View file

@ -1,9 +1,4 @@
using System.Security.Claims; using System.Security.Claims;
using Hangfire;
using Hangfire.PostgreSql;
using Prometheus;
using FluentValidation;
using foodmarket.Api.Infrastructure.RateLimiting;
using foodmarket.Api.Infrastructure.Tenancy; using foodmarket.Api.Infrastructure.Tenancy;
using foodmarket.Api.Seed; using foodmarket.Api.Seed;
using foodmarket.Application.Common.Tenancy; using foodmarket.Application.Common.Tenancy;
@ -36,29 +31,12 @@
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITenantContext, HttpContextTenantContext>(); builder.Services.AddScoped<ITenantContext, HttpContextTenantContext>();
// ClaimsTransformation для SuperAdmin override: добавляет роли Admin/
// Storekeeper/Cashier при наличии X-Org-Override, чтобы [Authorize(Roles=...)]
// не отшивал edit-mode мутации SuperAdmin'а. См. SuperAdminOverrideClaimsTransformer.
builder.Services.AddScoped<Microsoft.AspNetCore.Authentication.IClaimsTransformation,
foodmarket.Api.Infrastructure.Tenancy.SuperAdminOverrideClaimsTransformer>();
// Prometheus metrics: singleton-интерсептор EF засекает длительность каждого builder.Services.AddDbContext<AppDbContext>(opts =>
// SQL-запроса (см. food_market_db_query_duration_seconds). HTTP-метрики
// включаются ниже через UseHttpMetrics(). /metrics endpoint доступен всем
// (стандартная практика для Prometheus scrape) — на prod закрываем nginx'ом.
builder.Services.AddSingleton<foodmarket.Api.Infrastructure.Observability.DbMetricsInterceptor>();
// OrgAuditInterceptor — scoped (зависит от ITenantContext). EF тащит его
// через AddInterceptors на каждое создание DbContext (DbContext тоже scoped).
builder.Services.AddScoped<foodmarket.Infrastructure.Persistence.OrgAuditInterceptor>();
builder.Services.AddDbContext<AppDbContext>((sp, opts) =>
{ {
opts.UseNpgsql(builder.Configuration.GetConnectionString("Default"), opts.UseNpgsql(builder.Configuration.GetConnectionString("Default"),
npg => npg.MigrationsAssembly(typeof(AppDbContext).Assembly.GetName().Name)); npg => npg.MigrationsAssembly(typeof(AppDbContext).Assembly.GetName().Name));
opts.UseOpenIddict(); opts.UseOpenIddict();
opts.AddInterceptors(sp.GetRequiredService<
foodmarket.Api.Infrastructure.Observability.DbMetricsInterceptor>());
opts.AddInterceptors(sp.GetRequiredService<
foodmarket.Infrastructure.Persistence.OrgAuditInterceptor>());
}); });
builder.Services.AddIdentity<User, Role>(opts => builder.Services.AddIdentity<User, Role>(opts =>
@ -88,12 +66,27 @@
opts.AcceptAnonymousClients(); opts.AcceptAnonymousClients();
opts.RegisterScopes(Scopes.OpenId, Scopes.Profile, Scopes.Email, Scopes.Roles, "api"); opts.RegisterScopes(Scopes.OpenId, Scopes.Profile, Scopes.Email, Scopes.Roles, "api");
// Ключи подписи/шифрования: dev — persistent RSA-ключ в App_Data; // Persistent dev keys: RSA key stored in src/food-market.api/App_Data/openiddict-dev-key.xml.
// prod/stage — X509-сертификаты из конфига (OpenIddict:SigningCertPath / // Survives API restarts so issued tokens remain valid across rebuilds.
// EncryptionCertPath), persistent self-signed если файла нет. // Never commit this file (it's in .gitignore via App_Data/). Production must use real certificates.
// Подробности и dev-инвариант — в OpenIddictKeyConfigurator. var keyPath = Path.Combine(builder.Environment.ContentRootPath, "App_Data", "openiddict-dev-key.xml");
foodmarket.Api.Infrastructure.Security.OpenIddictKeyConfigurator var rsa = System.Security.Cryptography.RSA.Create(2048);
.ConfigureSigningAndEncryption(opts, builder.Configuration, builder.Environment); if (File.Exists(keyPath))
{
rsa.FromXmlString(File.ReadAllText(keyPath));
}
else
{
Directory.CreateDirectory(Path.GetDirectoryName(keyPath)!);
File.WriteAllText(keyPath, rsa.ToXmlString(includePrivateParameters: true));
}
var devKey = new Microsoft.IdentityModel.Tokens.RsaSecurityKey(rsa) { KeyId = "food-market-dev" };
opts.AddEncryptionKey(devKey);
opts.AddSigningKey(devKey);
if (builder.Environment.IsDevelopment())
{
opts.DisableAccessTokenEncryption();
}
opts.UseAspNetCore() opts.UseAspNetCore()
.EnableTokenEndpointPassthrough() .EnableTokenEndpointPassthrough()
@ -103,13 +96,6 @@
builder.Configuration["OpenIddict:AccessTokenLifetime"] ?? "01:00:00")); builder.Configuration["OpenIddict:AccessTokenLifetime"] ?? "01:00:00"));
opts.SetRefreshTokenLifetime(TimeSpan.Parse( opts.SetRefreshTokenLifetime(TimeSpan.Parse(
builder.Configuration["OpenIddict:RefreshTokenLifetime"] ?? "30.00:00:00")); builder.Configuration["OpenIddict:RefreshTokenLifetime"] ?? "30.00:00:00"));
// Rolling refresh включён по умолчанию: каждый refresh гасит старый
// токен (Redeemed) и выдаёт новый. Но по умолчанию OpenIddict даёт
// 30-секундный reuse-leeway — окно, в котором погашенный refresh ещё
// принимается (для гонок/ретраев). Для розничной админки это дыра:
// утёкший refresh остаётся рабочим 30с после ротации. Обнуляем окно —
// ротация инвалидирует старый refresh немедленно.
opts.SetRefreshTokenReuseLeeway(TimeSpan.Zero);
// Явный Issuer = публичный URL админки. Без него OpenIddict // Явный Issuer = публичный URL админки. Без него OpenIddict
// вычисляет issuer из текущего HTTP-запроса, что ломается за // вычисляет issuer из текущего HTTP-запроса, что ломается за
// nginx-прокси если Host/X-Forwarded-Proto не доходят. Берём // nginx-прокси если Host/X-Forwarded-Proto не доходят. Берём
@ -140,124 +126,26 @@
opts.AddPolicy("AdminAccess", p => p.RequireAssertion(ctx => opts.AddPolicy("AdminAccess", p => p.RequireAssertion(ctx =>
ctx.User.HasClaim(c => c.Type == Claims.Role && (c.Value == "Admin" || c.Value == "SuperAdmin")))); ctx.User.HasClaim(c => c.Type == Claims.Role && (c.Value == "Admin" || c.Value == "SuperAdmin"))));
}); });
// Permission-based авторизация: [RequiresPermission("...")] → policy perm:* →
// PermissionRequirement → проверка флага RolePermissions роли сотрудника.
builder.Services.AddSingleton<Microsoft.AspNetCore.Authorization.IAuthorizationPolicyProvider,
foodmarket.Api.Infrastructure.Authorization.PermissionAuthorizationPolicyProvider>();
builder.Services.AddScoped<Microsoft.AspNetCore.Authorization.IAuthorizationHandler,
foodmarket.Api.Infrastructure.Authorization.PermissionAuthorizationHandler>();
builder.Services.AddScoped<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>(); builder.Services.AddScoped<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>();
// Anti-brute-force на /connect/token и /api/auth/signup (5/мин + 20/час на IP).
// Лимиты конфигурируемы через RateLimiting:* (PerMinute/PerHour/Enabled).
builder.Services.AddAuthRateLimiting(builder.Configuration);
// Health-пробы: liveness (процесс жив) и readiness (БД + миграции применены).
// Readiness-чек помечен тегом "ready", чтобы /health/live его не запускал.
builder.Services.AddHealthChecks()
.AddCheck<foodmarket.Api.Infrastructure.Health.DatabaseReadyHealthCheck>(
"database", tags: ["ready"]);
// Email-отправка через MailKit. Singleton — внутри открывает scope для // Email-отправка через MailKit. Singleton — внутри открывает scope для
// свежего DbContext'а, поэтому конфиг (PlatformSettings) перечитывается // свежего DbContext'а, поэтому конфиг (PlatformSettings) перечитывается
// на каждой отправке без рестарта приложения. // на каждой отправке без рестарта приложения.
builder.Services.AddSingleton<foodmarket.Application.Common.Email.IEmailSender, builder.Services.AddSingleton<foodmarket.Application.Common.Email.IEmailSender,
foodmarket.Infrastructure.Email.MailKitEmailSender>(); foodmarket.Infrastructure.Email.MailKitEmailSender>();
// EmailTemplates загружает embedded HTML и подставляет {{key}} —
// см. Resources/EmailTemplates/*.html. Singleton с in-memory cache.
builder.Services.AddSingleton<foodmarket.Api.Infrastructure.Email.EmailTemplates>();
builder.Services.AddDataProtection(); builder.Services.AddDataProtection();
// MediatR (TD-1 partial CQRS): сканирует food-market.application для
// IRequest/IRequestHandler. Образцы: CreateSupplyCommand,
// PostRetailSaleCommand, GetSalesReportQuery. Контроллеры пока не
// переписаны под mediator — это паттерн-показ, не полный рефакторинг.
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(
typeof(foodmarket.Application.Inventory.IStockService).Assembly));
// Заглушки для абстракций CQRS-handler'ов: handler'ы пока не подключены
// к контроллерам (поэтапная миграция). Реальная имплементация появится
// когда контроллер начнёт отправлять команду через IMediator. Сейчас
// нужны только чтобы DI-validation на старте не падала.
builder.Services.AddTransient<foodmarket.Application.Purchases.Commands.ISupplyWriter,
foodmarket.Api.Infrastructure.Cqrs.UnusedSupplyWriter>();
builder.Services.AddTransient<foodmarket.Application.Sales.Commands.IRetailSalePoster,
foodmarket.Api.Infrastructure.Cqrs.UnusedRetailSalePoster>();
// FluentValidation: автоматическая регистрация валидаторов из сборки api.
// ValidationFilter гоняет валидаторы на каждом контроллер-action перед
// вызовом — fail возвращает 400 ValidationProblemDetails (RFC 7807).
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
builder.Services.AddScoped<foodmarket.Api.Infrastructure.Validation.ValidationFilter>();
builder.Services.AddControllers(o => builder.Services.AddControllers(o =>
{ {
// Глобальный action filter — пишет audit-log при успешных мутациях // Глобальный action filter — пишет audit-log при успешных мутациях
// в режиме «SuperAdmin открыть как… + edit-mode» (Phase 3). // в режиме «SuperAdmin открыть как… + edit-mode» (Phase 3).
o.Filters.AddService<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>(); o.Filters.AddService<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>();
o.Filters.AddService<foodmarket.Api.Infrastructure.Validation.ValidationFilter>();
}); });
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(opts => builder.Services.AddSwaggerGen();
{
opts.SwaggerDoc("v1", new Microsoft.OpenApi.Models.OpenApiInfo
{
Title = "food-market API",
Version = "v1",
Description = "Multi-tenant POS/inventory backend. Все запросы " +
"ограничены организацией текущего JWT (claim `org_id`).",
});
// Bearer JWT через OpenIddict. Swagger UI «Authorize» подставит в Authorization.
var bearer = new Microsoft.OpenApi.Models.OpenApiSecurityScheme
{
Type = Microsoft.OpenApi.Models.SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
In = Microsoft.OpenApi.Models.ParameterLocation.Header,
Description = "Access token, полученный через POST /connect/token.",
};
opts.AddSecurityDefinition("Bearer", bearer);
opts.AddSecurityRequirement(new Microsoft.OpenApi.Models.OpenApiSecurityRequirement
{
[new Microsoft.OpenApi.Models.OpenApiSecurityScheme
{
Reference = new Microsoft.OpenApi.Models.OpenApiReference
{
Type = Microsoft.OpenApi.Models.ReferenceType.SecurityScheme,
Id = "Bearer",
},
}] = Array.Empty<string>(),
});
// Стабильные operationId для генерации TS-клиентов:
// <Controller>_<Verb><Action>. Verb включён чтобы избежать коллизии
// когда ASP.NET стрипает Async-суффикс и два метода (WipeAll, WipeAllAsync)
// получают одинаковое имя action → одинаковый operationId → duplicate.
opts.CustomOperationIds(api =>
{
var ctrl = api.ActionDescriptor.RouteValues["controller"];
var action = api.ActionDescriptor.RouteValues["action"];
var verb = api.HttpMethod is { Length: > 0 } m ? char.ToUpper(m[0]) + m[1..].ToLowerInvariant() : "";
return $"{ctrl}_{verb}{action}";
});
// У нас есть одноимённые nested record'ы в разных контроллерах
// (например, StockRow в StockController и StockReportController).
// Включаем имя контроллера в schemaId через FullName-suffix чтобы не
// словить duplicate schemaId — Swashbuckle падает на этом по умолчанию.
opts.CustomSchemaIds(t => t.FullName!
.Replace("foodmarket.Api.Controllers.", "")
.Replace("foodmarket.Application.", "")
.Replace("foodmarket.Domain.", "")
.Replace("+", "_")
.Replace(".", "_"));
});
// MoySklad import integration. Auto-decompress gzip responses from MoySklad's edge. // MoySklad import integration. Auto-decompress gzip responses from MoySklad's edge.
// BaseAddress берётся из конфигурации (MoySklad:BaseUrl) с дефолтом на боевой builder.Services.AddHttpClient<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladClient>()
// api.moysklad.ru — так интеграцию можно навести на mock-сервер в e2e/интеграционных
// тестах, не трогая прод. Трейлинг-слэш обязателен (RFC 3986 §5.3, см. MoySkladClient).
builder.Services.AddHttpClient<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladClient>(http =>
{
var baseUrl = builder.Configuration["MoySklad:BaseUrl"];
if (!string.IsNullOrWhiteSpace(baseUrl))
http.BaseAddress = new Uri(baseUrl);
})
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{ {
AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate, AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate,
@ -268,30 +156,6 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
// Inventory // Inventory
builder.Services.AddScoped<foodmarket.Application.Inventory.IStockService, foodmarket.Infrastructure.Inventory.StockService>(); builder.Services.AddScoped<foodmarket.Application.Inventory.IStockService, foodmarket.Infrastructure.Inventory.StockService>();
// Hangfire — фоновые джобы и UI-дашборд. Хранилище — наш Postgres
// (тот же ConnectionString:Default). Запускаем фактический сервер только
// когда приложение действительно работает (не в тестах): чтобы тестовые
// прогоны не плодили tables/jobs в одноразовом контейнере. Регистрация
// recurring jobs — после Build() в IHostedService HangfireJobsConfigurator.
var enableHangfireServer = builder.Configuration.GetValue("Hangfire:Enabled", true);
builder.Services.AddHangfire(cfg => cfg
.SetDataCompatibilityLevel(Hangfire.CompatibilityLevel.Version_180)
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings()
.UsePostgreSqlStorage(opts => opts.UseNpgsqlConnection(
builder.Configuration.GetConnectionString("Default"))));
if (enableHangfireServer)
{
builder.Services.AddHangfireServer(opts =>
{
opts.WorkerCount = 2;
opts.Queues = new[] { "default" };
});
builder.Services.AddHostedService<foodmarket.Api.Background.HangfireJobsConfigurator>();
}
builder.Services.AddScoped<foodmarket.Api.Background.HousekeepingJobs>();
builder.Services.AddScoped<foodmarket.Api.Background.EmailNotificationJobs>();
builder.Services.AddHostedService<OpenIddictClientSeeder>(); builder.Services.AddHostedService<OpenIddictClientSeeder>();
builder.Services.AddHostedService<SystemReferenceSeeder>(); builder.Services.AddHostedService<SystemReferenceSeeder>();
builder.Services.AddHostedService<DevDataSeeder>(); builder.Services.AddHostedService<DevDataSeeder>();
@ -305,21 +169,8 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
app.UseSerilogRequestLogging(); app.UseSerilogRequestLogging();
app.UseCors(CorsPolicy); app.UseCors(CorsPolicy);
// Prometheus HTTP-метрики (http_requests_received_total, http_request_duration_seconds).
// Включаем сразу после CORS и до аутентификации, чтобы видеть и 401/403
// (это сигнал об атаке/неверной конфигурации). prometheus-net уже зашивает
// reserved-лейблы code/method/controller/action из route template'а
// дополнительной кастомизации не нужно, и она запрещена (см. ValidateMappings).
app.UseHttpMetrics();
// До аутентификации: лимитируем перебор пароля ещё на входе, не доводя
// до проверки credential'ов в БД.
app.UseRateLimiter();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
// После аутентификации, до контроллеров: вытягиваем OrgId/UserId из ClaimsPrincipal
// и кладём в Serilog LogContext вместе с CorrelationId — каждая ILogger.Log
// в пайплайне автоматически получит эти лейблы.
app.UseMiddleware<foodmarket.Api.Infrastructure.Observability.LogEnrichmentMiddleware>();
// SuperAdmin «открыть как…» — тот же tenant как у выбранной орги, но // SuperAdmin «открыть как…» — тот же tenant как у выбранной орги, но
// только GET. Любая мутация → 403, кроме /api/super-admin/* и /connect/*. // только GET. Любая мутация → 403, кроме /api/super-admin/* и /connect/*.
app.UseMiddleware<foodmarket.Api.Infrastructure.Tenancy.ReadonlyOverrideMiddleware>(); app.UseMiddleware<foodmarket.Api.Infrastructure.Tenancy.ReadonlyOverrideMiddleware>();
@ -336,56 +187,12 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
// Swagger/OpenAPI: только в Development. /swagger/v1/swagger.json — JSON-документ,
// /swagger — UI. На prod не раскрываем (sensitive endpoint enumeration), на dev
// используется фронтом для генерации TS-клиента (`pnpm run gen:api`).
app.UseSwagger(); app.UseSwagger();
app.UseSwaggerUI(opts => app.UseSwaggerUI();
{
opts.DocumentTitle = "food-market API";
opts.RoutePrefix = "swagger";
});
} }
app.MapControllers(); app.MapControllers();
// /metrics — текстовый Prometheus exposition format. Скрейпится prometheus-сервером
// (rate=15s типично). Доступ без авторизации — стандартная практика;
// на prod ограничиваем nginx-уровнем (allow 10.0.0.0/8, deny all) или basic-auth.
app.MapMetrics("/metrics");
// Hangfire Dashboard на /hangfire — только SuperAdmin. Auth-фильтр
// проверяет System.Security.Claims.ClaimsPrincipal (стандартный
// OpenIddict-токен в Authorization-заголовке). Не вешаем UseHangfireServer —
// он уже стартует через AddHangfireServer выше.
if (enableHangfireServer)
{
app.UseHangfireDashboard("/hangfire", new Hangfire.DashboardOptions
{
Authorization = new[] { new foodmarket.Api.Background.SuperAdminHangfireFilter() },
// Не показываем команды Delete/Requeue по умолчанию из UI чтобы случайные клики
// не разрушили scheduled — все джобы декларативные, перерегистрация делает их
// идемпотентной.
IgnoreAntiforgeryToken = false,
});
}
// Liveness: процесс отвечает — без обращения к зависимостям (Predicate=false
// => ни один чек не запускается). Используется для рестарта зависшего контейнера.
app.MapHealthChecks("/health/live", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
{
Predicate = _ => false,
ResponseWriter = WriteHealthResponse,
});
// Readiness: запускает чеки с тегом "ready" (БД + миграции). 503 если не готово.
app.MapHealthChecks("/health/ready", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready"),
ResponseWriter = WriteHealthResponse,
});
// Backward-compat: /health = liveness (Dockerfile/nginx исторически бьют сюда).
app.MapGet("/health", () => Results.Ok(new { status = "ok", time = DateTime.UtcNow })); app.MapGet("/health", () => Results.Ok(new { status = "ok", time = DateTime.UtcNow }));
app.MapGet("/api/_debug/whoami", (HttpContext ctx) => app.MapGet("/api/_debug/whoami", (HttpContext ctx) =>
@ -456,26 +263,3 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
{ {
Log.CloseAndFlush(); Log.CloseAndFlush();
} }
// JSON-ответ health-проб: общий статус + по каждому чеку. text/plain по умолчанию
// неудобен для мониторинга; отдаём структурировано.
static Task WriteHealthResponse(HttpContext context,
Microsoft.Extensions.Diagnostics.HealthChecks.HealthReport report)
{
context.Response.ContentType = "application/json";
return context.Response.WriteAsJsonAsync(new
{
status = report.Status.ToString(),
totalDurationMs = report.TotalDuration.TotalMilliseconds,
checks = report.Entries.Select(e => new
{
name = e.Key,
status = e.Value.Status.ToString(),
description = e.Value.Description,
}),
});
}
// Делает сгенерированный из top-level statements класс Program видимым для
// интеграционных тестов (WebApplicationFactory&lt;Program&gt;).
public partial class Program;

View file

@ -1,24 +0,0 @@
Subject: Приглашение в {{organizationName}}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Приглашение в Food Market</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 24px; color: #1f2937;">
<h2 style="color: #0f766e;">Добро пожаловать в {{organizationName}}</h2>
<p>Здравствуйте, {{employeeName}}.</p>
<p>Для вас создан доступ к системе Food Market. Войдите по адресу:</p>
<p style="margin: 20px 0;">
<a href="{{loginUrl}}" style="background: #0f766e; color: white; padding: 10px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Открыть Food Market</a>
</p>
<table style="border-collapse: collapse; background: #f9fafb; padding: 16px; border-radius: 6px;">
<tr><td style="padding: 4px 12px;"><strong>Логин</strong></td><td style="padding: 4px 12px;"><code>{{email}}</code></td></tr>
<tr><td style="padding: 4px 12px;"><strong>Временный пароль</strong></td><td style="padding: 4px 12px;"><code>{{temporaryPassword}}</code></td></tr>
<tr><td style="padding: 4px 12px;"><strong>Роль</strong></td><td style="padding: 4px 12px;">{{roleName}}</td></tr>
</table>
<p style="margin-top: 16px; color: #6b7280; font-size: 13px;">
После первого входа смените пароль в личном кабинете. Если вы не ожидали это письмо — просто игнорируйте его, доступ без первого входа не активируется.
</p>
</body>
</html>

View file

@ -1,19 +0,0 @@
Subject: Низкие остатки · {{organizationName}}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Низкие остатки</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 640px; margin: 0 auto; padding: 24px; color: #1f2937;">
<h2 style="color: #b91c1c;">Низкие остатки</h2>
<p>{{productCount}} товаров опустились ниже MinStock. Время сделать заказ.</p>
{{{itemsHtml}}}
<p style="margin-top: 24px; color: #6b7280; font-size: 13px;">
Полный список — в разделе <a href="{{stockUrl}}">Остатки</a>.
Отключить уведомления можно в настройках организации.
</p>
</body>
</html>

View file

@ -1,36 +0,0 @@
Subject: Итоги недели · {{organizationName}}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Еженедельная сводка</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 640px; margin: 0 auto; padding: 24px; color: #1f2937;">
<h2 style="color: #0f766e;">Итоги недели</h2>
<p>{{periodFrom}} — {{periodTo}}</p>
<div style="display: flex; gap: 12px; margin: 20px 0;">
<div style="flex: 1; background: #ecfdf5; padding: 16px; border-radius: 8px;">
<div style="color: #047857; font-size: 13px;">Выручка</div>
<div style="font-size: 24px; font-weight: 600; color: #064e3b;">{{revenue}}</div>
</div>
<div style="flex: 1; background: #eff6ff; padding: 16px; border-radius: 8px;">
<div style="color: #1d4ed8; font-size: 13px;">Чеков</div>
<div style="font-size: 24px; font-weight: 600; color: #1e3a8a;">{{transactions}}</div>
</div>
<div style="flex: 1; background: #fef3c7; padding: 16px; border-radius: 8px;">
<div style="color: #b45309; font-size: 13px;">Средний чек</div>
<div style="font-size: 24px; font-weight: 600; color: #78350f;">{{avgTicket}}</div>
</div>
</div>
{{#topProductsHtml}}
<h3 style="margin-top: 24px;">Топ-товары</h3>
{{{topProductsHtml}}}
{{/topProductsHtml}}
<p style="margin-top: 32px; color: #6b7280; font-size: 13px;">
Подробная аналитика — в разделе <a href="{{reportsUrl}}">Отчёты</a>.
</p>
</body>
</html>

View file

@ -21,16 +21,6 @@
<PackageReference Include="Serilog.Sinks.Console" /> <PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="Serilog.Sinks.File" /> <PackageReference Include="Serilog.Sinks.File" />
<PackageReference Include="Hangfire.AspNetCore" /> <PackageReference Include="Hangfire.AspNetCore" />
<PackageReference Include="Hangfire.PostgreSql" />
<PackageReference Include="CsvHelper" />
<PackageReference Include="ClosedXML" />
<PackageReference Include="prometheus-net.AspNetCore" />
<PackageReference Include="MediatR" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<!-- Email-шаблоны embedded в сборку — см. EmailTemplates.LoadRaw. -->
<EmbeddedResource Include="Resources/EmailTemplates/*.html" />
</ItemGroup>
</Project> </Project>

View file

@ -85,14 +85,12 @@ public record CounterpartyInput(
public record ProductBarcodeInput(string Code, BarcodeType Type = BarcodeType.Ean13, bool IsPrimary = false); public record ProductBarcodeInput(string Code, BarcodeType Type = BarcodeType.Ean13, bool IsPrimary = false);
public record ProductPriceInput(Guid PriceTypeId, [Range(0, 1e10)] decimal Amount, Guid CurrencyId); public record ProductPriceInput(Guid PriceTypeId, [Range(0, 1e10)] decimal Amount, Guid CurrencyId);
public record ProductInput( public record ProductInput(
[Required, MinLength(1), StringLength(500)] string Name, string Name, string? Article, string? Description,
[StringLength(500)] string? Article,
string? Description,
Guid UnitOfMeasureId, [Range(0, 100)] decimal? Vat, bool VatEnabled, Guid UnitOfMeasureId, [Range(0, 100)] decimal? Vat, bool VatEnabled,
Guid ProductGroupId, Guid? DefaultSupplierId, Guid? CountryOfOriginId, Guid ProductGroupId, Guid? DefaultSupplierId, Guid? CountryOfOriginId,
bool IsService = false, Packaging Packaging = Packaging.Piece, bool IsMarked = false, bool IsService = false, Packaging Packaging = Packaging.Piece, bool IsMarked = false,
[Range(0, 1e10)] decimal? MinStock = null, [Range(0, 1e10)] decimal? MaxStock = null, [Range(0, 1e10)] decimal? MinStock = null, [Range(0, 1e10)] decimal? MaxStock = null,
[Range(0, 1e10)] decimal? ReferencePrice = null, Guid? PurchaseCurrencyId = null, [Range(0, 1e10)] decimal? ReferencePrice = null, Guid? PurchaseCurrencyId = null,
[StringLength(1000)] string? ImageUrl = null, string? ImageUrl = null,
IReadOnlyList<ProductPriceInput>? Prices = null, IReadOnlyList<ProductPriceInput>? Prices = null,
IReadOnlyList<ProductBarcodeInput>? Barcodes = null); IReadOnlyList<ProductBarcodeInput>? Barcodes = null);

View file

@ -1,61 +0,0 @@
using System.Text.RegularExpressions;
namespace foodmarket.Application.Common.Email;
/// <summary>Лёгкий «mustache-light» рендерер для email-шаблонов: подстановка
/// `{{key}}` из словаря и условные блоки `{{#key}}...{{/key}}` (показываются
/// только если key есть и значение truthy).
///
/// Не Razor/Liquid — намеренно минимальный набор, потому что эти шаблоны:
/// • короткие и редкие (3 типа писем),
/// • не требуют циклов/выражений (структурированные DTO в коде формирует
/// payload построчно перед рендером),
/// • не должны давать SSRF/RCE-поверхность (как у любого «полноценного»
/// шаблонизатора).
///
/// HTML-escape по умолчанию ВКЛЮЧЁН — `{{name}}` экранирует, `{{{name}}}`
/// (тройные фигурные) вставляет как есть (для уже-готового HTML вставок,
/// типа таблиц). Используйте с осторожностью.</summary>
public static class EmailTemplateRenderer
{
private static readonly Regex BlockRegex = new(@"\{\{#(\w+)\}\}(.*?)\{\{/\1\}\}", RegexOptions.Singleline | RegexOptions.Compiled);
private static readonly Regex RawRegex = new(@"\{\{\{(\w+)\}\}\}", RegexOptions.Compiled);
private static readonly Regex EscRegex = new(@"\{\{(\w+)\}\}", RegexOptions.Compiled);
public static string Render(string template, IReadOnlyDictionary<string, string?> values)
{
// 1. Условные блоки. Truthy = не-null, не пустая строка, не "0", не "false".
var result = BlockRegex.Replace(template, m =>
{
var key = m.Groups[1].Value;
values.TryGetValue(key, out var v);
return IsTruthy(v) ? m.Groups[2].Value : "";
});
// 2. Raw (без escape). После блоков чтобы тройные внутри условного блока тоже рендерились.
result = RawRegex.Replace(result, m =>
{
values.TryGetValue(m.Groups[1].Value, out var v);
return v ?? "";
});
// 3. Escaped (по умолчанию).
result = EscRegex.Replace(result, m =>
{
values.TryGetValue(m.Groups[1].Value, out var v);
return v is null ? "" : HtmlEscape(v);
});
return result;
}
private static bool IsTruthy(string? v) =>
!string.IsNullOrWhiteSpace(v) && v != "0" && !string.Equals(v, "false", StringComparison.OrdinalIgnoreCase);
private static string HtmlEscape(string s) => s
.Replace("&", "&amp;")
.Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("\"", "&quot;")
.Replace("'", "&#39;");
}

View file

@ -6,13 +6,6 @@ namespace foodmarket.Application.Common.Email;
public interface IEmailSender public interface IEmailSender
{ {
Task SendAsync(string toEmail, string subject, string body, CancellationToken ct = default); Task SendAsync(string toEmail, string subject, string body, CancellationToken ct = default);
/// <summary>HTML-версия с опциональным plain-text fallback. Используется
/// шаблонизированными письмами (приглашения, weekly-summary, low-stock).
/// Если <paramref name="textBody"/> null — клиент должен сам уметь
/// рендерить HTML; для большинства современных clients это не проблема.</summary>
Task SendHtmlAsync(string toEmail, string subject, string htmlBody, string? textBody = null,
CancellationToken ct = default);
} }
/// <summary>Бросается когда платформенный SMTP не настроен. Контроллеры должны /// <summary>Бросается когда платформенный SMTP не настроен. Контроллеры должны

View file

@ -9,9 +9,4 @@ public interface ITenantContext
/// В этом режиме query-filter ОБЯЗАН применяться (по выбранной orgId), /// В этом режиме query-filter ОБЯЗАН применяться (по выбранной orgId),
/// иначе SuperAdmin'у возвращаются записи всех орг — нарушение изоляции.</summary> /// иначе SuperAdmin'у возвращаются записи всех орг — нарушение изоляции.</summary>
bool IsTenantOverride { get; } bool IsTenantOverride { get; }
/// <summary>Id текущего пользователя (sub claim в JWT). null для системных
/// операций без аутентификации (seed, фоновые джобы). Используется
/// для аудит-лога — кто инициировал мутацию.</summary>
Guid? UserId { get; }
} }

View file

@ -1,25 +0,0 @@
namespace foodmarket.Application.Inventory;
/// <summary>Скользящее средневзвешенное себестоимости товара при приёмке.
///
/// Себестоимость пересчитывается так, чтобы отражать средневзвешенную цену
/// всех закупленных единиц: <c>(остаток·текущаяСебестоимость + приход·ценаЗакупки)
/// / (остаток + приход)</c>. На первой приёмке (нет остатка и себестоимости)
/// берётся цена закупки. Результат округляется до 4 знаков.
///
/// Вынесено из <c>SuppliesController.Post</c> для юнит-тестируемости —
/// контроллер вызывает этот метод.</summary>
public static class MovingAverageCost
{
public static decimal Compute(
decimal currentQty, decimal currentCost, decimal incomingQty, decimal incomingUnitPrice)
{
var totalQty = currentQty + incomingQty;
// Порядок важен: && связывает крепче ||, т.е.
// (totalQty==0) || ((currentCost==0) && (currentQty==0)).
var newCost = totalQty == 0m || currentCost == 0m && currentQty == 0m
? incomingUnitPrice
: (currentQty * currentCost + incomingQty * incomingUnitPrice) / totalQty;
return Math.Round(newCost, 4, MidpointRounding.AwayFromZero);
}
}

View file

@ -1,49 +0,0 @@
using MediatR;
namespace foodmarket.Application.Purchases.Commands;
/// <summary>CQRS-образец №2 (TD-1): команда создания приёмки (Draft).
/// Handler принимает input + контракт <see cref="ISupplyWriter"/> и делегирует
/// фактическое сохранение — handler не зависит от EF напрямую, что
/// упрощает unit-тестирование.
///
/// Покрывает только Draft-создание; Posting остаётся в контроллере (где
/// stock-логика и serializable-транзакция). Это сознательное «partial»:
/// показываем паттерн, не переписываем всё.</summary>
public record CreateSupplyCommand(
Guid SupplierId, Guid StoreId, Guid CurrencyId,
DateTime Date, string? Notes,
IReadOnlyList<CreateSupplyLine> Lines) : IRequest<CreateSupplyResult>;
public record CreateSupplyLine(Guid ProductId, decimal Quantity, decimal UnitPrice);
public record CreateSupplyResult(Guid Id, string Number, decimal Total);
/// <summary>Абстракция над персистентностью: позволяет тестировать handler
/// без EF/Postgres (in-memory implementation в тестах).</summary>
public interface ISupplyWriter
{
Task<string> NextNumberAsync(int year, CancellationToken ct);
Task<Guid> CreateAsync(Guid supplierId, Guid storeId, Guid currencyId,
DateTime date, string? notes, string number,
IReadOnlyList<CreateSupplyLine> lines, decimal total,
CancellationToken ct);
}
public sealed class CreateSupplyHandler : IRequestHandler<CreateSupplyCommand, CreateSupplyResult>
{
private readonly ISupplyWriter _writer;
public CreateSupplyHandler(ISupplyWriter writer) => _writer = writer;
public async Task<CreateSupplyResult> Handle(CreateSupplyCommand cmd, CancellationToken ct)
{
// Бизнес-правила: суммирование, генерация номера, делегирование
// персистентности абстракции.
var total = cmd.Lines.Sum(l => l.Quantity * l.UnitPrice);
var number = await _writer.NextNumberAsync(cmd.Date.Year, ct);
var id = await _writer.CreateAsync(
cmd.SupplierId, cmd.StoreId, cmd.CurrencyId,
cmd.Date, cmd.Notes, number, cmd.Lines, total, ct);
return new CreateSupplyResult(id, number, total);
}
}

View file

@ -1,48 +0,0 @@
using MediatR;
namespace foodmarket.Application.Sales.Commands;
/// <summary>CQRS-образец №3 (TD-1): команда проведения чека. Handler
/// инкапсулирует валидацию платежа (вынесенную в Application слое уже
/// раньше как RetailPaymentValidator) и делегирует stock-операцию
/// абстракции IRetailSalePoster.
///
/// Цель — показать, что Post можно тестировать чисто (без БД, без
/// контроллера), проверяя бизнес-логику payment-validation + invocation
/// stock writer'а. Сам RetailSalesController остаётся как образец до-CQRS
/// контроллера (рефакторинг — отдельный спринт).</summary>
public record PostRetailSaleCommand(
Guid SaleId,
decimal PaidCash,
decimal PaidCard,
decimal Total,
IReadOnlyList<PostSaleLine> Lines) : IRequest<PostRetailSaleResult>;
public record PostSaleLine(Guid ProductId, decimal Quantity, decimal UnitPrice);
public record PostRetailSaleResult(bool Posted, string? ErrorMessage, string? ErrorField);
/// <summary>Абстракция: списать стоки и пометить чек проведённым.</summary>
public interface IRetailSalePoster
{
Task PostAsync(Guid saleId, IReadOnlyList<PostSaleLine> lines, CancellationToken ct);
}
public sealed class PostRetailSaleHandler : IRequestHandler<PostRetailSaleCommand, PostRetailSaleResult>
{
private readonly IRetailSalePoster _poster;
public PostRetailSaleHandler(IRetailSalePoster poster) => _poster = poster;
public async Task<PostRetailSaleResult> Handle(PostRetailSaleCommand cmd, CancellationToken ct)
{
// Переиспользуем уже вынесенный валидатор платежа (Sprint 1).
if (!RetailPaymentValidator.IsSufficient(cmd.PaidCash, cmd.PaidCard, cmd.Total))
return new PostRetailSaleResult(false,
$"Сумма оплаты меньше итога {cmd.Total:0.##}.", "PaidCash");
if (cmd.Lines.Count == 0)
return new PostRetailSaleResult(false, "Пустой чек.", "lines");
await _poster.PostAsync(cmd.SaleId, cmd.Lines, ct);
return new PostRetailSaleResult(true, null, null);
}
}

View file

@ -1,62 +0,0 @@
using System.Globalization;
using MediatR;
namespace foodmarket.Application.Sales.Queries;
/// <summary>CQRS-образец №1 (TD-1): чистая логика агрегации продаж в C#
/// (без БД), пригодная для unit-тестирования без TestContainers. Сам
/// контроллер `/api/reports/sales` остаётся как было (pull-фласк +
/// группировка в C#) — этот handler — refactor-демонстрация на пути к
/// CQRS-разделению.
///
/// Принимает уже подготовленные «плоские строки» (FlatSale) и параметры
/// группировки/окна, возвращает агрегированный список. Идемпотентен,
/// safe для параллельного вызова.</summary>
public record GetSalesReportQuery(
IReadOnlyList<FlatSale> Lines,
string GroupBy = "period:day",
DateTime? From = null,
DateTime? To = null) : IRequest<IReadOnlyList<SalesReportRow>>;
public record FlatSale(
Guid SaleId, DateTime Date,
Guid ProductId, string ProductName,
decimal Revenue,
decimal Quantity);
public record SalesReportRow(
string Key, string Label,
decimal Revenue, int Transactions, decimal Quantity);
public sealed class GetSalesReportHandler : IRequestHandler<GetSalesReportQuery, IReadOnlyList<SalesReportRow>>
{
public Task<IReadOnlyList<SalesReportRow>> Handle(GetSalesReportQuery req, CancellationToken ct)
{
var filtered = req.Lines
.Where(x => req.From is null || x.Date >= req.From)
.Where(x => req.To is null || x.Date <= req.To)
.ToList();
IEnumerable<IGrouping<(string Key, string Label), FlatSale>> grouped = req.GroupBy switch
{
"period:day" => filtered.GroupBy(x => (
Key: x.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
Label: x.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture))),
"period:month" => filtered.GroupBy(x => (
Key: $"{x.Date.Year:0000}-{x.Date.Month:00}",
Label: $"{x.Date.Year:0000}-{x.Date.Month:00}")),
"product" => filtered.GroupBy(x => (Key: x.ProductId.ToString(), Label: x.ProductName)),
_ => Enumerable.Empty<IGrouping<(string, string), FlatSale>>(),
};
IReadOnlyList<SalesReportRow> rows = grouped
.Select(g => new SalesReportRow(
g.Key.Key, g.Key.Label,
g.Sum(x => x.Revenue),
g.Select(x => x.SaleId).Distinct().Count(),
g.Sum(x => x.Quantity)))
.OrderByDescending(r => r.Revenue)
.ToList();
return Task.FromResult(rows);
}
}

View file

@ -1,15 +0,0 @@
namespace foodmarket.Application.Sales;
/// <summary>Правило достаточности оплаты розничного чека при проведении.
///
/// Сумма наличных + по карте должна покрывать итог чека. Переплата (сдача)
/// допустима, недоплата — нет (иначе касса может «провести» чек, не получив
/// денег). Округление до 2 знаков защищает от floating-point дрейфа.
///
/// Вынесено из <c>RetailSalesController.Post</c> для юнит-тестируемости —
/// контроллер вызывает этот метод для решения о проведении.</summary>
public static class RetailPaymentValidator
{
public static bool IsSufficient(decimal paidCash, decimal paidCard, decimal total)
=> decimal.Round(paidCash + paidCard, 2) >= decimal.Round(total, 2);
}

View file

@ -1,16 +0,0 @@
namespace foodmarket.Domain.Common;
/// <summary>Маркер «сущность с optimistic concurrency token». Postgres-маппинг —
/// системная колонка <c>xmin</c> (тип <c>xid</c>), которая инкрементится
/// автоматически при UPDATE; EF читает текущее значение в SELECT и
/// сверяет в WHERE при следующем UPDATE — конкурирующий апдейт упадёт с
/// 0 affected rows и контроллер вернёт 409 Conflict.
///
/// Не требует миграции данных (xmin есть на каждой таблице), требует только
/// `e.UseXminAsConcurrencyToken()` в EF-конфигурации + объявление свойства
/// на entity. PUT-эндпоинты должны принимать <c>xmin</c> в body и
/// присваивать в trackedEntity до SaveChanges.</summary>
public interface IVersionedEntity
{
uint Xmin { get; set; }
}

View file

@ -1,49 +0,0 @@
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Integrations;
public enum ImportJobStatus
{
Running = 0,
Succeeded = 1,
Failed = 2,
Cancelled = 3,
}
/// <summary>Запись прогресса фонового импорта (MoySklad: products /
/// counterparties; cleanup: all). Пишется один раз при старте,
/// обновляется в процессе через <c>ImportJobRegistry.SaveAsync</c>,
/// финализируется в <c>finally</c>-блоке RunInBackgroundAsync.
///
/// Tenant-scoped — каждая орга видит только свои джобы. Errors хранятся
/// JSON-массивом строк (jsonb) — для UI достаточно, для разбора достаточно
/// `(SELECT * FROM import_jobs WHERE Id = ?)`.
///
/// Раньше состояние жило в <c>ConcurrentDictionary</c> внутри Singleton
/// сервиса (TD-5): при рестарте процесса вся история импортов терялась.
/// Теперь — БД, прогресс сохраняется через рестарт.</summary>
public class ImportJob : TenantEntity
{
/// <summary>"products" | "counterparties" | "cleanup-all" | …</summary>
public string Kind { get; set; } = "";
public DateTime StartedAt { get; set; } = DateTime.UtcNow;
public DateTime? FinishedAt { get; set; }
public ImportJobStatus Status { get; set; } = ImportJobStatus.Running;
/// <summary>Описание текущей фазы для UI («Загрузка страниц 3/10…»).</summary>
public string? Stage { get; set; }
public int Total { get; set; }
public int Created { get; set; }
public int Updated { get; set; }
public int Skipped { get; set; }
public int Deleted { get; set; }
public int GroupsCreated { get; set; }
/// <summary>Финальное сообщение / последняя ошибка для UI.</summary>
public string? Message { get; set; }
/// <summary>JSON-массив строк с накопленными ошибками (для разбора).</summary>
public string ErrorsJson { get; set; } = "[]";
}

View file

@ -1,63 +0,0 @@
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Inventory;
public enum InventoryStatus
{
Draft = 0,
Posted = 1,
}
/// <summary>Документ инвентаризации (пересчёта). При создании контроллер
/// заполняет <c>bookQty</c> по текущему <see cref="Stock"/> склада. После
/// внесения фактических количеств (<c>actualQty</c>) при Post создаются
/// корректирующие движения <see cref="MovementType.InventoryAdjustment"/>
/// на <c>diff = actual - book</c>: положительные приходят (излишек),
/// отрицательные списываются (недостача).
///
/// Назван <c>InventoryDoc</c> чтобы не конфликтовать с .NET-неймспейсом
/// <c>System.Collections.Specialized.Inventory</c> и не путаться с самой
/// сущностью «остатков». Таблица — <c>inventories</c>.</summary>
public class InventoryDoc : TenantEntity, IVersionedEntity
{
public uint Xmin { get; set; }
public string Number { get; set; } = "";
public DateTime Date { get; set; } = DateTime.UtcNow;
public InventoryStatus Status { get; set; } = InventoryStatus.Draft;
public Guid StoreId { get; set; }
public Store Store { get; set; } = null!;
public string? Notes { get; set; }
public DateTime? PostedAt { get; set; }
public Guid? PostedByUserId { get; set; }
public ICollection<InventoryLine> Lines { get; set; } = new List<InventoryLine>();
}
public class InventoryLine : TenantEntity
{
public Guid InventoryDocId { get; set; }
public InventoryDoc InventoryDoc { get; set; } = null!;
public Guid ProductId { get; set; }
public Product Product { get; set; } = null!;
/// <summary>Учётное количество (Stock.Quantity на момент создания/обновления документа).</summary>
public decimal BookQty { get; set; }
/// <summary>Фактическое количество (введено вручную или импортом CSV).</summary>
public decimal ActualQty { get; set; }
/// <summary>diff = ActualQty - BookQty (положительный — излишек, отрицательный — недостача).
/// Вычисляется и сохраняется при сохранении строки для отчётности.</summary>
public decimal Diff { get; set; }
/// <summary>Снимок Product.Cost для расчёта суммы излишка/недостачи (только для отчётов).</summary>
public decimal UnitCost { get; set; }
public int SortOrder { get; set; }
}

View file

@ -1,72 +0,0 @@
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Inventory;
public enum LossStatus
{
Draft = 0,
Posted = 1,
}
/// <summary>Причина списания. Из этого классификатора строятся ABC/XYZ-отчёты
/// и фискальная отчётность (порча/просрочка) в KZ. Не строй наценочную логику
/// поверх этих значений — используй ровно как business reason.</summary>
public enum LossReason
{
/// <summary>Брак (производственный/транспортный).</summary>
Defect = 0,
/// <summary>Просрочка / истёк срок годности.</summary>
Expired = 1,
/// <summary>Повреждение (механическое, при хранении/перевозке).</summary>
Damage = 2,
/// <summary>Недостача (выявлена инвентаризацией; для оформления свыше — отдельный документ инвентаризации).</summary>
Shortage = 3,
/// <summary>Прочая причина (в комментарии).</summary>
Other = 99,
}
/// <summary>Документ списания со склада: брак, просрочка, недостача, повреждение.
/// При проведении создаёт <see cref="StockMovement"/> с типом
/// <see cref="MovementType.WriteOff"/> и отрицательным Quantity.</summary>
public class Loss : TenantEntity
{
public string Number { get; set; } = "";
public DateTime Date { get; set; } = DateTime.UtcNow;
public LossStatus Status { get; set; } = LossStatus.Draft;
public Guid StoreId { get; set; }
public Store Store { get; set; } = null!;
public Guid CurrencyId { get; set; }
public Currency Currency { get; set; } = null!;
public LossReason Reason { get; set; } = LossReason.Other;
public string? Notes { get; set; }
/// <summary>Сумма по строкам = Σ Quantity·UnitCost. Балансовая стоимость списания.</summary>
public decimal Total { get; set; }
public DateTime? PostedAt { get; set; }
public Guid? PostedByUserId { get; set; }
public ICollection<LossLine> Lines { get; set; } = new List<LossLine>();
}
public class LossLine : TenantEntity
{
public Guid LossId { get; set; }
public Loss Loss { get; set; } = null!;
public Guid ProductId { get; set; }
public Product Product { get; set; } = null!;
public decimal Quantity { get; set; }
/// <summary>Балансовая цена единицы (снимок Product.Cost на момент создания строки;
/// можно переопределить). Используется в Total и в отчётности.</summary>
public decimal UnitCost { get; set; }
public decimal LineTotal { get; set; }
public int SortOrder { get; set; }
}

View file

@ -1,57 +0,0 @@
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Inventory;
public enum TransferStatus
{
Draft = 0,
Posted = 1,
}
/// <summary>Документ перемещения товара между складами одной организации.
/// При проведении создаёт ПАРУ движений: TransferOut из FromStore и
/// TransferIn в ToStore (атомарной транзакцией). Unpost тоже атомарен —
/// возвращает обратно ОБА движения. Никогда не должно остаться orphan-движений
/// (только один из двух).</summary>
public class Transfer : TenantEntity, IVersionedEntity
{
public uint Xmin { get; set; }
public string Number { get; set; } = "";
public DateTime Date { get; set; } = DateTime.UtcNow;
public TransferStatus Status { get; set; } = TransferStatus.Draft;
public Guid FromStoreId { get; set; }
public Store FromStore { get; set; } = null!;
public Guid ToStoreId { get; set; }
public Store ToStore { get; set; } = null!;
public string? Notes { get; set; }
/// <summary>Балансовая сумма перемещения = Σ Quantity·UnitCost (только для отчётов;
/// при проведении не влияет на Product.Cost).</summary>
public decimal Total { get; set; }
public DateTime? PostedAt { get; set; }
public Guid? PostedByUserId { get; set; }
public ICollection<TransferLine> Lines { get; set; } = new List<TransferLine>();
}
public class TransferLine : TenantEntity
{
public Guid TransferId { get; set; }
public Transfer Transfer { get; set; } = null!;
public Guid ProductId { get; set; }
public Product Product { get; set; } = null!;
public decimal Quantity { get; set; }
/// <summary>Балансовая цена единицы (снимок Product.Cost). На остатках не отражается.</summary>
public decimal UnitCost { get; set; }
public decimal LineTotal { get; set; }
public int SortOrder { get; set; }
}

View file

@ -1,24 +0,0 @@
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Organizations;
/// <summary>Журнал действий внутри организации: создание/правка/удаление
/// доменных сущностей (Supply, RetailSale, Demand, Product, Counterparty).
/// Tenant-scoped — каждая орга видит ТОЛЬКО свой журнал; SuperAdmin видит
/// всё (как и для прочих TenantEntity).
///
/// Пишется автоматически через <c>OrgAuditInterceptor</c> на SaveChanges:
/// для отслеживаемых типов снимается diff (old/new значения свойств),
/// сериализуется в JSON и сохраняется одной строкой.</summary>
public class OrgAuditLog : TenantEntity
{
public Guid? UserId { get; set; }
/// <summary>"create" | "update" | "delete".</summary>
public string Action { get; set; } = "";
/// <summary>Имя CLR-типа без неймспейса: "RetailSale", "Product"…</summary>
public string EntityType { get; set; } = "";
public Guid? EntityId { get; set; }
/// <summary>JSON-объект формата { "field": { "before": ..., "after": ... }, ... }.
/// Для create — поля только в "after"; для delete — только "before".</summary>
public string ChangesJson { get; set; } = "{}";
}

View file

@ -35,7 +35,6 @@ public class RolePermissions
public bool InventoryEdit { get; set; } public bool InventoryEdit { get; set; }
public bool LossEdit { get; set; } public bool LossEdit { get; set; }
public bool EnterEdit { get; set; } public bool EnterEdit { get; set; }
public bool TransferEdit { get; set; }
// Отчёты // Отчёты
public bool ReportsView { get; set; } public bool ReportsView { get; set; }
@ -60,7 +59,7 @@ public static RolePermissions All() => new()
DemandsView = true, DemandsEdit = true, DemandsPost = true, DemandsView = true, DemandsEdit = true, DemandsPost = true,
RetailSalesOperate = true, RetailSalesRefund = true, RetailSalesOperate = true, RetailSalesRefund = true,
CounterpartiesView = true, CounterpartiesEdit = true, CounterpartiesDelete = true, CounterpartiesView = true, CounterpartiesEdit = true, CounterpartiesDelete = true,
StocksView = true, InventoryEdit = true, LossEdit = true, EnterEdit = true, TransferEdit = true, StocksView = true, InventoryEdit = true, LossEdit = true, EnterEdit = true,
ReportsView = true, ReportsFinanceView = true, ReportsStockView = true, ReportsView = true, ReportsFinanceView = true, ReportsStockView = true,
OrgSettingsManage = true, EmployeesManage = true, RolesManage = true, OrgSettingsManage = true, EmployeesManage = true, RolesManage = true,
StoresManage = true, RetailPointsManage = true, StoresManage = true, RetailPointsManage = true,

View file

@ -1,62 +0,0 @@
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Purchases;
public enum EnterStatus
{
Draft = 0,
Posted = 1,
}
/// <summary>Оприходование: документ постановки товара на склад БЕЗ поставщика.
/// Используется для начальных остатков (при запуске учёта), излишка по
/// результату инвентаризации, возврата товара из подразделения и т.п.
///
/// Отличается от <see cref="Supply"/>: нет SupplierId; total — не сумма закупки,
/// а сумма по UnitCost (стоимость оприходованного товара по балансовой цене).
/// При Post создаёт <see cref="Inventory.StockMovement"/> с типом
/// <see cref="Inventory.MovementType.Enter"/>.</summary>
public class Enter : TenantEntity
{
/// <summary>Уникальный в рамках организации номер документа (например "О-2026-000001").</summary>
public string Number { get; set; } = "";
public DateTime Date { get; set; } = DateTime.UtcNow;
public EnterStatus Status { get; set; } = EnterStatus.Draft;
public Guid StoreId { get; set; }
public Store Store { get; set; } = null!;
public Guid CurrencyId { get; set; }
public Currency Currency { get; set; } = null!;
public string? Notes { get; set; }
/// <summary>Сумма по строкам = Σ Quantity·UnitCost.</summary>
public decimal Total { get; set; }
public DateTime? PostedAt { get; set; }
public Guid? PostedByUserId { get; set; }
public ICollection<EnterLine> Lines { get; set; } = new List<EnterLine>();
}
public class EnterLine : TenantEntity
{
public Guid EnterId { get; set; }
public Enter Enter { get; set; } = null!;
public Guid ProductId { get; set; }
public Product Product { get; set; } = null!;
public decimal Quantity { get; set; }
/// <summary>Балансовая цена единицы (по которой товар ставится на учёт).
/// Не пересчитывает <c>Product.Cost</c> — оприходование не образует
/// себестоимости (в отличие от приёмки).</summary>
public decimal UnitCost { get; set; }
public decimal LineTotal { get; set; }
public int SortOrder { get; set; }
}

View file

@ -1,58 +0,0 @@
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Purchases;
public enum SupplierReturnStatus
{
Draft = 0,
Posted = 1,
}
/// <summary>Возврат поставщику. Отгрузка товара обратно поставщику (брак, излишек,
/// неликвид). При проведении создаёт <see cref="Inventory.StockMovement"/> с типом
/// <see cref="Inventory.MovementType.SupplierReturn"/> и отрицательным Quantity.
/// Опционально ссылается на исходную приёмку через <see cref="ReferenceSupplyId"/>.</summary>
public class SupplierReturn : TenantEntity
{
public string Number { get; set; } = "";
public DateTime Date { get; set; } = DateTime.UtcNow;
public SupplierReturnStatus Status { get; set; } = SupplierReturnStatus.Draft;
public Guid SupplierId { get; set; }
public Counterparty Supplier { get; set; } = null!;
public Guid StoreId { get; set; }
public Store Store { get; set; } = null!;
public Guid CurrencyId { get; set; }
public Currency Currency { get; set; } = null!;
/// <summary>Опциональная ссылка на исходную приёмку.</summary>
public Guid? ReferenceSupplyId { get; set; }
public Supply? ReferenceSupply { get; set; }
public string? Notes { get; set; }
public decimal Total { get; set; }
public DateTime? PostedAt { get; set; }
public Guid? PostedByUserId { get; set; }
public ICollection<SupplierReturnLine> Lines { get; set; } = new List<SupplierReturnLine>();
}
public class SupplierReturnLine : TenantEntity
{
public Guid SupplierReturnId { get; set; }
public SupplierReturn SupplierReturn { get; set; } = null!;
public Guid ProductId { get; set; }
public Product Product { get; set; } = null!;
public decimal Quantity { get; set; }
public decimal UnitPrice { get; set; }
public decimal LineTotal { get; set; }
public int SortOrder { get; set; }
}

View file

@ -9,11 +9,8 @@ public enum SupplyStatus
Posted = 1, Posted = 1,
} }
public class Supply : TenantEntity, IVersionedEntity public class Supply : TenantEntity
{ {
/// <summary>Postgres `xmin` — optimistic concurrency token. См. IVersionedEntity.</summary>
public uint Xmin { get; set; }
/// <summary>Human-readable document number, unique per organization (e.g. "П-2026-000001").</summary> /// <summary>Human-readable document number, unique per organization (e.g. "П-2026-000001").</summary>
public string Number { get; set; } = ""; public string Number { get; set; } = "";

View file

@ -1,79 +0,0 @@
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Sales;
public enum DemandStatus
{
Draft = 0,
Posted = 1,
}
/// <summary>Оптовая отгрузка контрагенту-юрлицу. По MoySklad — «Отгрузка».
/// Отличие от <see cref="RetailSale"/>: всегда юрлицо (CounterpartyId
/// обязателен), способ оплаты — нал/безнал/в кредит, цена и НДС могут
/// отличаться от розничных (тип цен «Опт.»). При проведении создаёт
/// <see cref="Inventory.StockMovement"/> тип
/// <see cref="Inventory.MovementType.WholesaleSale"/> с -Quantity.</summary>
public class Demand : TenantEntity, IVersionedEntity
{
public uint Xmin { get; set; }
public string Number { get; set; } = "";
public DateTime Date { get; set; } = DateTime.UtcNow;
public DemandStatus Status { get; set; } = DemandStatus.Draft;
public Guid CustomerId { get; set; }
public Counterparty Customer { get; set; } = null!;
public Guid StoreId { get; set; }
public Store Store { get; set; } = null!;
public Guid CurrencyId { get; set; }
public Currency Currency { get; set; } = null!;
/// <summary>0=Cash, 1=Card, 2=BankTransfer, 3=Credit (постоплата), 99=Mixed.
/// Совпадает с <see cref="PaymentMethod"/> + добавлен Credit.</summary>
public DemandPayment Payment { get; set; } = DemandPayment.BankTransfer;
public decimal Subtotal { get; set; }
public decimal DiscountTotal { get; set; }
public decimal Total { get; set; }
/// <summary>Сумма оплаченного по этой отгрузке (для отслеживания дебиторки).
/// Может быть меньше Total — тогда остаток за контрагентом (Total PaidAmount).
/// На MVP не строим отчёт по задолженности — просто сохраняем.</summary>
public decimal PaidAmount { get; set; }
public string? Notes { get; set; }
public DateTime? PostedAt { get; set; }
public Guid? PostedByUserId { get; set; }
public ICollection<DemandLine> Lines { get; set; } = new List<DemandLine>();
}
public enum DemandPayment
{
Cash = 0,
Card = 1,
BankTransfer = 2,
Credit = 3, // постоплата, дебиторка
Mixed = 99,
}
public class DemandLine : TenantEntity
{
public Guid DemandId { get; set; }
public Demand Demand { get; set; } = null!;
public Guid ProductId { get; set; }
public Product Product { get; set; } = null!;
public decimal Quantity { get; set; }
public decimal UnitPrice { get; set; }
public decimal Discount { get; set; }
public decimal LineTotal { get; set; }
public decimal VatPercent { get; set; }
public int SortOrder { get; set; }
}

View file

@ -1,26 +0,0 @@
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Sales;
/// <summary>Кеш ответов на батчи продаж от POS-касс для идемпотентности.
///
/// POS присылает <c>POST /api/pos/sales</c> с <c>IdempotencyKey</c> в теле;
/// при сетевой ошибке POS повторяет тот же батч с тем же ключом, и сервер
/// должен вернуть прежний результат БЕЗ повторного создания продаж в БД.
/// Реализуется через эту таблицу: вставка строки `(OrgId, Key)` через
/// unique-index — выигрыш гонки делает запрос обработчиком, проигравшие
/// читают <c>ResponseJson</c>.
///
/// <c>OrganizationId</c> наследуется через <see cref="TenantEntity"/> и
/// query-filter ограничивает выборку — POS чужой орги не может прочитать
/// чужой ack даже с угаданным ключом. Дополнительно ack TTL'ятся фоновым
/// cleanup'ом (Hangfire) — 30 дней.</summary>
public class PosBatchAck : TenantEntity
{
/// <summary>Idempotency-ключ, присланный POS. Уникален в рамках организации.</summary>
public Guid IdempotencyKey { get; set; }
/// <summary>Сериализованный ответ (PosSaleBatchResponse без поля
/// ReplayedFromCache — это поле выставляется true при репроигрывании).</summary>
public string ResponseJson { get; set; } = "";
}

View file

@ -18,10 +18,8 @@ public enum PaymentMethod
Mixed = 99, Mixed = 99,
} }
public class RetailSale : TenantEntity, IVersionedEntity public class RetailSale : TenantEntity
{ {
public uint Xmin { get; set; }
public string Number { get; set; } = ""; public string Number { get; set; } = "";
public DateTime Date { get; set; } = DateTime.UtcNow; public DateTime Date { get; set; } = DateTime.UtcNow;
public RetailSaleStatus Status { get; set; } = RetailSaleStatus.Draft; public RetailSaleStatus Status { get; set; } = RetailSaleStatus.Draft;
@ -52,17 +50,6 @@ public class RetailSale : TenantEntity, IVersionedEntity
public DateTime? PostedAt { get; set; } public DateTime? PostedAt { get; set; }
public Guid? PostedByUserId { get; set; } public Guid? PostedByUserId { get; set; }
/// <summary>true — это документ возврата от покупателя (refund). Не уменьшает
/// остатки, а увеличивает их; деньги возвращаются покупателю (PaidCash/PaidCard
/// для return — сумма, отданная клиенту, для отчёта). Может быть привязан к
/// исходному чеку через <see cref="ReferenceSaleId"/>, может — стоять отдельно.</summary>
public bool IsReturn { get; set; }
/// <summary>Ссылка на исходный чек продажи, по которому формируется возврат.
/// Опционально (нулевая для возврата без чека).</summary>
public Guid? ReferenceSaleId { get; set; }
public RetailSale? ReferenceSale { get; set; }
public ICollection<RetailSaleLine> Lines { get; set; } = new List<RetailSaleLine>(); public ICollection<RetailSaleLine> Lines { get; set; } = new List<RetailSaleLine>();
} }
@ -80,10 +67,4 @@ public class RetailSaleLine : TenantEntity
public decimal LineTotal { get; set; } // = Quantity * UnitPrice - Discount public decimal LineTotal { get; set; } // = Quantity * UnitPrice - Discount
public decimal VatPercent { get; set; } // snapshot public decimal VatPercent { get; set; } // snapshot
public int SortOrder { get; set; } public int SortOrder { get; set; }
/// <summary>Сколько единиц по этой строке возвращено (через документы CustomerReturn,
/// ссылающиеся на этот чек). Кешированная агрегация: контроллер инкрементит
/// при проведении return-документа. Защищает от over-return (нельзя вернуть
/// больше, чем продано).</summary>
public decimal QtyReturned { get; set; }
} }

Some files were not shown because too many files have changed in this diff Show more