Compare commits
No commits in common. "4675f38a0f75ab9b6e9114004f426361477debdb" and "05c70f036867c17a4b06760c66ab71df0b4eb532" have entirely different histories.
4675f38a0f
...
05c70f0368
|
|
@ -1 +1 @@
|
|||
{"sessionId":"fae8ce63-bd1d-4246-9a44-09731c66e311","pid":2166378,"procStart":"133122020","acquiredAt":1779943840096}
|
||||
{"sessionId":"791848bb-ad06-4ccc-9c5a-13da4ee36524","pid":353363,"acquiredAt":1776883641374}
|
||||
|
|
@ -27,8 +27,6 @@
|
|||
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" />
|
||||
|
||||
<!-- 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="Microsoft.AspNetCore.DataProtection" Version="8.0.11" />
|
||||
<PackageVersion Include="MediatR" Version="12.4.1" />
|
||||
|
|
@ -44,9 +42,6 @@
|
|||
<PackageVersion Include="Hangfire.AspNetCore" Version="1.8.17" />
|
||||
<PackageVersion Include="Hangfire.PostgreSql" Version="1.20.10" />
|
||||
|
||||
<!-- Observability / Prometheus -->
|
||||
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
||||
|
||||
<!-- POS: local storage + API client -->
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
|
||||
<PackageVersion Include="Refit" Version="7.2.22" />
|
||||
|
|
@ -64,7 +59,6 @@
|
|||
<PackageVersion Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageVersion Include="coverlet.collector" Version="6.0.3" />
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
@ -32,7 +32,7 @@ ENV DOTNET_NOLOGO=1
|
|||
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
|
||||
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s \
|
||||
CMD curl -fsS http://localhost:8080/health/ready || exit 1
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
|
||||
CMD curl -fsS http://localhost:8080/health || exit 1
|
||||
|
||||
ENTRYPOINT ["dotnet", "foodmarket.Api.dll"]
|
||||
|
|
|
|||
|
|
@ -29,24 +29,11 @@ services:
|
|||
environment:
|
||||
ASPNETCORE_ENVIRONMENT: Production
|
||||
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
|
||||
# legacy nginx, 5000/5002/5005 taken by legacy .NET apps).
|
||||
ports:
|
||||
- "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:
|
||||
- api-data:/app/App_Data
|
||||
- api-logs:/app/logs
|
||||
|
|
@ -57,8 +44,7 @@ services:
|
|||
container_name: food-market-web
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
- api
|
||||
ports:
|
||||
- "8081:80" # web SPA, not on 80 (legacy nginx holds it)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 "Бэкап завершён."
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
```
|
||||
|
|
@ -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, архив, реcтор, 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 описан
|
||||
```
|
||||
|
|
@ -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` читает.
|
||||
|
|
@ -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 в шаблоне** — теряется
|
||||
структура (выше).
|
||||
|
|
@ -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` мы проверяем «значение увеличилось», а не точное число.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 совпадает), токен, выданный до рестарта, остаётся валиден.
|
||||
|
|
@ -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)
|
||||
|
||||
- [ ] Повторить пп. 2–5 на прод-окружении.
|
||||
- [ ] Мониторить первые ~30 мин: `/health/ready`, логи, диск (`df -h`).
|
||||
|
|
@ -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$'` должен быть пуст.
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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`.
|
||||
|
|
@ -19,12 +19,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "food-market.pos.core", "src
|
|||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "food-market.pos", "src\food-market.pos\food-market.pos.csproj", "{B178B74E-A739-4722-BFA8-D9AB694024BB}"
|
||||
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
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
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}.Release|Any CPU.ActiveCfg = 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
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{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}
|
||||
{BF3FBFD2-F40D-4510-8067-37305FFE1D14} = {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
|
||||
EndGlobal
|
||||
|
|
|
|||
|
|
@ -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 < 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -138,13 +138,6 @@ public ActionResult<object> WipeAllAsync()
|
|||
finally
|
||||
{
|
||||
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 });
|
||||
|
|
|
|||
|
|
@ -124,20 +124,6 @@ public async Task<ActionResult<object>> ImportCounterparties([FromBody] ImportRe
|
|||
{
|
||||
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
|
||||
{
|
||||
using var tenantScope = HttpContextTenantContext.UseOverride(orgId);
|
||||
|
|
@ -155,14 +141,6 @@ public async Task<ActionResult<object>> ImportCounterparties([FromBody] ImportRe
|
|||
finally
|
||||
{
|
||||
job.FinishedAt = DateTime.UtcNow;
|
||||
// Финальный flush — обязательный, иначе после рестарта job
|
||||
// останется со статусом Running навсегда.
|
||||
try
|
||||
{
|
||||
using var tenantScope2 = HttpContextTenantContext.UseOverride(orgId);
|
||||
await _jobs.SaveAsync(job);
|
||||
}
|
||||
catch { /* registry сам логирует */ }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -48,26 +48,6 @@ public async Task<IActionResult> Exchange()
|
|||
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);
|
||||
if (rejection is not null) return rejection;
|
||||
|
||||
|
|
@ -91,20 +71,6 @@ public async Task<IActionResult> Exchange()
|
|||
if (rejection is not null) return rejection;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
using foodmarket.Application.Common;
|
||||
using foodmarket.Domain.Catalog;
|
||||
using foodmarket.Infrastructure.Persistence;
|
||||
using foodmarket.Api.Infrastructure.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
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);
|
||||
}
|
||||
|
||||
[HttpPost, RequiresPermission("CounterpartiesEdit")]
|
||||
[HttpPost, Authorize(Roles = "Admin,Storekeeper")]
|
||||
public async Task<ActionResult<CounterpartyDto>> Create([FromBody] CounterpartyInput input, CancellationToken ct)
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}"), RequiresPermission("CounterpartiesEdit")]
|
||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] CounterpartyInput input, CancellationToken ct)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}"), RequiresPermission("CounterpartiesDelete")]
|
||||
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var e = await _db.Counterparties.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
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);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return NoContent();
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
using foodmarket.Application.Common;
|
||||
using foodmarket.Domain.Catalog;
|
||||
using foodmarket.Infrastructure.Persistence;
|
||||
using foodmarket.Api.Infrastructure.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
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);
|
||||
}
|
||||
|
||||
[HttpPost, RequiresPermission("PriceTypesManage")]
|
||||
[HttpPost, Authorize(Roles = "Admin")]
|
||||
public async Task<ActionResult<PriceTypeDto>> Create([FromBody] PriceTypeInput input, CancellationToken ct)
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}"), RequiresPermission("PriceTypesManage")]
|
||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] PriceTypeInput input, CancellationToken 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();
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}"), RequiresPermission("PriceTypesManage")]
|
||||
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
using foodmarket.Application.Common;
|
||||
using foodmarket.Domain.Catalog;
|
||||
using foodmarket.Infrastructure.Persistence;
|
||||
using foodmarket.Api.Infrastructure.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
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);
|
||||
}
|
||||
|
||||
[HttpPost, RequiresPermission("ProductGroupsManage")]
|
||||
[HttpPost, Authorize(Roles = "Admin")]
|
||||
public async Task<ActionResult<ProductGroupDto>> Create([FromBody] ProductGroupInput input, CancellationToken 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));
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}"), RequiresPermission("ProductGroupsManage")]
|
||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,SuperAdmin")]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] ProductGroupInput input, CancellationToken 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();
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}"), RequiresPermission("ProductGroupsManage")]
|
||||
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,SuperAdmin")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var meta = await _db.ProductGroups
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
using foodmarket.Application.Common.Tenancy;
|
||||
using foodmarket.Domain.Catalog;
|
||||
using foodmarket.Infrastructure.Persistence;
|
||||
using foodmarket.Api.Infrastructure.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
|
@ -50,7 +49,7 @@ public async Task<ActionResult<IReadOnlyList<ImageDto>>> List(Guid productId, Ca
|
|||
return images;
|
||||
}
|
||||
|
||||
[HttpPost, RequiresPermission("ProductsEdit")]
|
||||
[HttpPost, Authorize(Roles = "Admin,Storekeeper")]
|
||||
[RequestSizeLimit(MaxBytes)]
|
||||
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);
|
||||
}
|
||||
|
||||
[HttpDelete("{imageId:guid}"), RequiresPermission("ProductsEdit")]
|
||||
[HttpDelete("{imageId:guid}"), Authorize(Roles = "Admin,Storekeeper")]
|
||||
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);
|
||||
|
|
@ -129,7 +128,7 @@ public async Task<IActionResult> Delete(Guid productId, Guid imageId, Cancellati
|
|||
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)
|
||||
{
|
||||
var image = await _db.ProductImages.FirstOrDefaultAsync(i => i.Id == imageId && i.ProductId == productId, ct);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
using foodmarket.Application.Common.Tenancy;
|
||||
using foodmarket.Domain.Catalog;
|
||||
using foodmarket.Infrastructure.Persistence;
|
||||
using foodmarket.Api.Infrastructure.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
|
@ -196,11 +195,9 @@ public async Task<ActionResult<ProductDto>> Get(Guid id, CancellationToken ct)
|
|||
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)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.Name))
|
||||
return BadRequest(new { error = "Название товара обязательно.", field = nameof(input.Name) });
|
||||
if (RequiredGuid.FirstMissing(
|
||||
(nameof(input.UnitOfMeasureId), input.UnitOfMeasureId),
|
||||
(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);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}"), RequiresPermission("ProductsEdit")]
|
||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input, CancellationToken ct)
|
||||
{
|
||||
if (RequiredGuid.FirstMissing(
|
||||
|
|
@ -323,7 +320,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input,
|
|||
/// <summary>«Привести розничную к себестоимости»: ставит дефолтную
|
||||
/// розничную цену = ceil(Cost * (1 + Group.MarkupPercent/100)). Если у
|
||||
/// группы товара не задан 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)
|
||||
{
|
||||
var p = await _db.Products
|
||||
|
|
@ -372,7 +369,7 @@ public async Task<IActionResult> RecalcRetail(Guid id, CancellationToken ct)
|
|||
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)
|
||||
{
|
||||
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>
|
||||
[HttpGet("barcode-duplicates"), RequiresPermission("ProductsView")]
|
||||
[HttpGet("barcode-duplicates"), Authorize(Roles = "Admin")]
|
||||
public async Task<ActionResult<IReadOnlyList<BarcodeDuplicate>>> BarcodeDuplicates(CancellationToken ct)
|
||||
{
|
||||
var rows = await _db.ProductBarcodes
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
using foodmarket.Application.Common;
|
||||
using foodmarket.Domain.Catalog;
|
||||
using foodmarket.Infrastructure.Persistence;
|
||||
using foodmarket.Api.Infrastructure.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
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);
|
||||
}
|
||||
|
||||
[HttpPost, RequiresPermission("RetailPointsManage")]
|
||||
[HttpPost, Authorize(Roles = "Admin")]
|
||||
public async Task<ActionResult<RetailPointDto>> Create([FromBody] RetailPointInput input, CancellationToken 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));
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}"), RequiresPermission("RetailPointsManage")]
|
||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] RetailPointInput input, CancellationToken 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();
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}"), RequiresPermission("RetailPointsManage")]
|
||||
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var e = await _db.RetailPoints.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
using foodmarket.Application.Common;
|
||||
using foodmarket.Domain.Catalog;
|
||||
using foodmarket.Infrastructure.Persistence;
|
||||
using foodmarket.Api.Infrastructure.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
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);
|
||||
}
|
||||
|
||||
[HttpPost, RequiresPermission("StoresManage")]
|
||||
[HttpPost, Authorize(Roles = "Admin")]
|
||||
public async Task<ActionResult<StoreDto>> Create([FromBody] StoreInput input, CancellationToken ct)
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}"), RequiresPermission("StoresManage")]
|
||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] StoreInput input, CancellationToken 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();
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}"), RequiresPermission("StoresManage")]
|
||||
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var e = await _db.Stores.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
using foodmarket.Application.Common.Tenancy;
|
||||
using foodmarket.Domain.Catalog;
|
||||
using foodmarket.Infrastructure.Persistence;
|
||||
using foodmarket.Api.Infrastructure.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
|
@ -93,7 +92,7 @@ public async Task<ActionResult<UnitOfMeasureDto>> Get(Guid id, CancellationToken
|
|||
|
||||
/// <summary>Включить global для текущей орги. Идемпотентно: повторный
|
||||
/// вызов отдаёт 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)
|
||||
{
|
||||
var orgId = _tenant.OrganizationId;
|
||||
|
|
@ -117,7 +116,7 @@ public async Task<IActionResult> Enable(Guid id, CancellationToken ct)
|
|||
/// <summary>Отключить global для текущей орги. Если на эту единицу
|
||||
/// ссылаются продукты орги — 409 со списком названий, чтобы админ
|
||||
/// перепривязал их сначала.</summary>
|
||||
[HttpDelete("{id:guid}/enable"), RequiresPermission("UnitsManage")]
|
||||
[HttpDelete("{id:guid}/enable"), Authorize(Roles = "Admin,SuperAdmin")]
|
||||
public async Task<IActionResult> Disable(Guid id, CancellationToken ct)
|
||||
{
|
||||
var orgId = _tenant.OrganizationId;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -20,17 +20,10 @@ public class EmployeesController : ControllerBase
|
|||
private readonly AppDbContext _db;
|
||||
private readonly ITenantContext _tenant;
|
||||
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,
|
||||
foodmarket.Application.Common.Email.IEmailSender email,
|
||||
foodmarket.Api.Infrastructure.Email.EmailTemplates templates,
|
||||
ILogger<EmployeesController> log)
|
||||
public EmployeesController(AppDbContext db, ITenantContext tenant, UserManager<User> userMgr)
|
||||
{
|
||||
_db = db; _tenant = tenant; _userMgr = userMgr;
|
||||
_email = email; _templates = templates; _log = log;
|
||||
}
|
||||
|
||||
public record EmployeeDto(
|
||||
|
|
@ -53,12 +46,7 @@ public record EmployeeInput(
|
|||
IReadOnlyList<Guid>? RetailPointIds,
|
||||
// CreateAccount=true → создаём User c email + temp password.
|
||||
// Возвращается в response один раз (showOnce).
|
||||
bool CreateAccount = false,
|
||||
// SendInvite=true (требует CreateAccount=true) → шлём email-приглашение
|
||||
// с временным паролем по шаблону EmailTemplates.invite. SMTP-ошибка
|
||||
// не блокирует создание сотрудника — пишем warning, возвращаем пароль
|
||||
// в ответе как обычно (showOnce).
|
||||
bool SendInvite = false);
|
||||
bool CreateAccount = false);
|
||||
|
||||
public record EmployeeCreateResult(EmployeeDto Employee, string? GeneratedPassword);
|
||||
|
||||
|
|
@ -182,37 +170,6 @@ public async Task<ActionResult<EmployeeCreateResult>> Create([FromBody] Employee
|
|||
_db.Employees.Add(employee);
|
||||
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);
|
||||
return new EmployeeCreateResult(dto!, tempPassword);
|
||||
}
|
||||
|
|
@ -272,9 +229,6 @@ public async Task<IActionResult> Update(Guid id, [FromBody] EmployeeInput input,
|
|||
var nowActive = input.IsActive;
|
||||
if (e.IsActive && !nowActive) e.FiredAt = DateTime.UtcNow;
|
||||
if (!e.IsActive && nowActive) e.FiredAt = null;
|
||||
// Меняем активность сотрудника — синхронизируем логин связанного User
|
||||
// (деактивация гасит сессии, реактивация возвращает доступ). См. DELETE.
|
||||
if (e.IsActive != nowActive) await SetLinkedUserActiveAsync(e.UserId, nowActive, ct);
|
||||
e.IsActive = nowActive;
|
||||
|
||||
// Replace assignments wholesale
|
||||
|
|
@ -340,35 +294,10 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
|||
e.IsDeleted = true;
|
||||
e.DeletedAt = DateTime.UtcNow;
|
||||
}
|
||||
// Увольнение и soft-delete обязаны гасить логин связанного User: иначе
|
||||
// уволенный сотрудник продолжает входить и обновлять токены (ТЗ 0.4).
|
||||
// На обоих шагах сотрудник перестаёт быть активным персоналом → гасим.
|
||||
await DeactivateLinkedUserAsync(e.UserId, ct);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
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 async Task<EmployeeDto?> ProjectAsync(Guid id, CancellationToken ct)
|
||||
|
|
|
|||
|
|
@ -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&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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,6 @@
|
|||
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;
|
||||
|
|
@ -18,13 +17,11 @@ public class SuppliesController : ControllerBase
|
|||
{
|
||||
private readonly AppDbContext _db;
|
||||
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;
|
||||
_stock = stock;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public record SupplyListRow(
|
||||
|
|
@ -50,7 +47,6 @@ public record SupplyDto(
|
|||
Guid CurrencyId, string CurrencyCode,
|
||||
string? Notes,
|
||||
decimal Total, DateTime? PostedAt,
|
||||
uint Xmin,
|
||||
IReadOnlyList<SupplyLineDto> Lines);
|
||||
|
||||
public record SupplyLineInput(
|
||||
|
|
@ -62,11 +58,7 @@ public record SupplyLineInput(
|
|||
public record SupplyInput(
|
||||
DateTime Date, Guid SupplierId, Guid StoreId, Guid CurrencyId,
|
||||
string? Notes,
|
||||
IReadOnlyList<SupplyLineInput> Lines,
|
||||
// Optimistic concurrency token. null/0 для нового черновика (POST),
|
||||
// обязателен для PUT — иначе считаем что клиент не передал version
|
||||
// и пускаем без сверки (legacy). При несовпадении контроллер возвращает 409.
|
||||
uint? Xmin = null);
|
||||
IReadOnlyList<SupplyLineInput> Lines);
|
||||
|
||||
[HttpGet]
|
||||
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);
|
||||
}
|
||||
|
||||
[HttpPost, RequiresPermission("SuppliesEdit")]
|
||||
[HttpPost, Authorize(Roles = "Admin,Storekeeper")]
|
||||
public async Task<ActionResult<SupplyDto>> Create([FromBody] SupplyInput input, CancellationToken ct)
|
||||
{
|
||||
if (RequiredGuid.FirstMissing(
|
||||
|
|
@ -187,16 +179,6 @@ public async Task<ActionResult<SupplyDto>> Create([FromBody] SupplyInput input,
|
|||
await _db.SaveChangesAsync(ct);
|
||||
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")
|
||||
{
|
||||
// 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)
|
||||
{
|
||||
if (RequiredGuid.FirstMissing(
|
||||
|
|
@ -226,44 +208,21 @@ public async Task<IActionResult> Update(Guid id, [FromBody] SupplyInput input, C
|
|||
if (supply.Status != SupplyStatus.Draft)
|
||||
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.SupplierId = input.SupplierId;
|
||||
supply.StoreId = input.StoreId;
|
||||
supply.CurrencyId = input.CurrencyId;
|
||||
supply.Notes = input.Notes;
|
||||
|
||||
// Удаляем старые строки через ExecuteDelete (минует трекер), новые
|
||||
// добавляем напрямую в DbSet — иначе EF8 на nav-collection+client-side Id
|
||||
// путается и UPDATE supplies с concurrency-token-WHERE падает 0 affected.
|
||||
// Тот же паттерн что в RetailSale/Demand.Update.
|
||||
await _db.SupplyLines.Where(l => l.SupplyId == supply.Id).ExecuteDeleteAsync(ct);
|
||||
// Replace lines wholesale (simple, idempotent).
|
||||
_db.SupplyLines.RemoveRange(supply.Lines);
|
||||
supply.Lines.Clear();
|
||||
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
|
||||
var order = 0;
|
||||
foreach (var l in input.Lines)
|
||||
{
|
||||
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,
|
||||
ProductId = l.ProductId,
|
||||
|
|
@ -277,16 +236,13 @@ public async Task<IActionResult> Update(Guid id, [FromBody] SupplyInput input, C
|
|||
: null,
|
||||
});
|
||||
}
|
||||
// Total считаем из input напрямую — supply.Lines navigation в этом
|
||||
// подходе пустая (мы добавляли через DbSet).
|
||||
supply.Total = input.Lines.Sum(l =>
|
||||
l.Quantity * (allowFractional ? l.UnitPrice : Math.Round(l.UnitPrice, 0, MidpointRounding.AwayFromZero)));
|
||||
supply.Total = supply.Lines.Sum(x => x.LineTotal);
|
||||
|
||||
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}"), RequiresPermission("SuppliesDelete")]
|
||||
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken 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();
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/post"), RequiresPermission("SuppliesPost")]
|
||||
[HttpPost("{id:guid}/post"), Authorize(Roles = "Admin,Storekeeper")]
|
||||
public async Task<IActionResult> Post(Guid id, CancellationToken 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);
|
||||
|
||||
// Serializable: ApplyMovementAsync делает read-modify-write по
|
||||
// Stock.Quantity без RowVersion. На дефолтной изоляции два
|
||||
// одновременных проведения (в т.ч. двойное проведение ОДНОГО
|
||||
// документа, проскочившее проверку статуса выше до коммита соседа)
|
||||
// дают lost update: остаток отстаёт от Σ StockMovement, приёмка
|
||||
// применяется дважды, скользящее среднее Cost считается от
|
||||
// устаревшего currentQty. Serializable заставляет конкурирующую
|
||||
// транзакцию откатиться (40001) — ловим ниже и отдаём 409.
|
||||
await using var tx = await _db.Database.BeginTransactionAsync(
|
||||
System.Data.IsolationLevel.Serializable, ct);
|
||||
await using var tx = await _db.Database.BeginTransactionAsync(ct);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
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)
|
||||
.SumAsync(s => (decimal?)s.Quantity, ct) ?? 0m;
|
||||
|
||||
// 1. Cost — скользящее среднее (формула в MovingAverageCost, юнит-тест).
|
||||
product.Cost = foodmarket.Application.Inventory.MovingAverageCost.Compute(
|
||||
currentQty, product.Cost, line.Quantity, line.UnitPrice);
|
||||
// 1. Cost — скользящее среднее.
|
||||
var totalQty = currentQty + line.Quantity;
|
||||
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 — автозаполнение при первой приёмке.
|
||||
if (product.ReferencePrice is null)
|
||||
|
|
@ -372,42 +322,11 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
|
|||
|
||||
supply.Status = SupplyStatus.Posted;
|
||||
supply.PostedAt = now;
|
||||
try
|
||||
{
|
||||
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);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
await tx.CommitAsync(ct);
|
||||
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. Если в списке
|
||||
/// цен у товара такой записи нет — создаёт её. Дефолтным считается PriceType
|
||||
/// с 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)
|
||||
{
|
||||
var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||
if (supply is null) return NotFound();
|
||||
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
|
||||
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)
|
||||
{
|
||||
// Shadow-property xmin читаем через EF.Property — она не на entity,
|
||||
// а в model metadata (UseXminAsConcurrencyToken).
|
||||
var row = await (from s in _db.Supplies.AsNoTracking()
|
||||
join cp in _db.Counterparties on s.SupplierId equals cp.Id
|
||||
join st in _db.Stores on s.StoreId equals st.Id
|
||||
join cu in _db.Currencies on s.CurrencyId equals cu.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;
|
||||
|
||||
// CurrentRetailPrice — текущая розничная цена товара (дефолтный PriceType),
|
||||
|
|
@ -565,7 +441,6 @@ orderby l.SortOrder
|
|||
row.cu.Id, row.cu.Code,
|
||||
row.s.Notes,
|
||||
row.s.Total, row.s.PostedAt,
|
||||
row.Xmin,
|
||||
lines);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,6 @@
|
|||
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;
|
||||
|
|
@ -18,13 +17,11 @@ public class RetailSalesController : ControllerBase
|
|||
{
|
||||
private readonly AppDbContext _db;
|
||||
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;
|
||||
_stock = stock;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public record RetailSaleListRow(
|
||||
|
|
@ -34,13 +31,11 @@ public record RetailSaleListRow(
|
|||
Guid? CustomerId, string? CustomerName,
|
||||
Guid CurrencyId, string CurrencyCode,
|
||||
decimal Total, PaymentMethod Payment, int LineCount,
|
||||
DateTime? PostedAt,
|
||||
bool IsReturn, Guid? ReferenceSaleId, string? ReferenceSaleNumber);
|
||||
DateTime? PostedAt);
|
||||
|
||||
public record RetailSaleLineDto(
|
||||
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle, string? UnitSymbol,
|
||||
decimal Quantity, decimal UnitPrice, decimal Discount, decimal LineTotal, decimal VatPercent, int SortOrder,
|
||||
decimal QtyReturned);
|
||||
decimal Quantity, decimal UnitPrice, decimal Discount, decimal LineTotal, decimal VatPercent, int SortOrder);
|
||||
|
||||
public record RetailSaleDto(
|
||||
Guid Id, string Number, DateTime Date, RetailSaleStatus Status,
|
||||
|
|
@ -51,7 +46,6 @@ public record RetailSaleDto(
|
|||
decimal Subtotal, decimal DiscountTotal, decimal Total,
|
||||
PaymentMethod Payment, decimal PaidCash, decimal PaidCard,
|
||||
string? Notes, DateTime? PostedAt,
|
||||
bool IsReturn, Guid? ReferenceSaleId, string? ReferenceSaleNumber,
|
||||
IReadOnlyList<RetailSaleLineDto> Lines);
|
||||
|
||||
public record RetailSaleLineInput(
|
||||
|
|
@ -66,9 +60,7 @@ public record RetailSaleInput(
|
|||
[Range(0, 1e10)] decimal PaidCash,
|
||||
[Range(0, 1e10)] decimal PaidCard,
|
||||
string? Notes,
|
||||
IReadOnlyList<RetailSaleLineInput> Lines,
|
||||
bool IsReturn = false,
|
||||
Guid? ReferenceSaleId = null);
|
||||
IReadOnlyList<RetailSaleLineInput> Lines);
|
||||
|
||||
public record SalesStatsBucket(DateTime Bucket, decimal Revenue, int Transactions);
|
||||
|
||||
|
|
@ -189,9 +181,7 @@ public record SalesStatsResponse(
|
|||
x.cu.Id, x.cu.Code,
|
||||
x.s.Total, x.s.Payment,
|
||||
x.s.Lines.Count,
|
||||
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()))
|
||||
x.s.PostedAt))
|
||||
.ToListAsync(ct);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
[HttpPost, RequiresPermission("RetailSalesOperate")]
|
||||
[HttpPost, Authorize(Roles = "Admin,Cashier")]
|
||||
public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput input, CancellationToken ct)
|
||||
{
|
||||
if (RequiredGuid.FirstMissing(
|
||||
|
|
@ -216,17 +206,6 @@ public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput
|
|||
var number = await GenerateNumberAsync(input.Date, ct);
|
||||
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
|
||||
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
|
||||
{
|
||||
Number = number,
|
||||
|
|
@ -240,8 +219,6 @@ public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput
|
|||
PaidCash = R(input.PaidCash),
|
||||
PaidCard = R(input.PaidCard),
|
||||
Notes = input.Notes,
|
||||
IsReturn = input.IsReturn,
|
||||
ReferenceSaleId = input.IsReturn ? input.ReferenceSaleId : null,
|
||||
};
|
||||
ApplyLines(sale, input.Lines, allowFractional);
|
||||
_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)
|
||||
{
|
||||
if (RequiredGuid.FirstMissing(
|
||||
(nameof(input.StoreId), input.StoreId),
|
||||
(nameof(input.CurrencyId), input.CurrencyId)) is { } missing)
|
||||
return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing });
|
||||
// Загружаем sale БЕЗ Include(Lines): иначе вылавливается баг EF8,
|
||||
// когда после ExecuteDelete+Add EF путается со state'ом строк и кидает
|
||||
// DbUpdateConcurrency. Старые строки удаляем хирургически через
|
||||
// ExecuteDelete (минует трекер), новые — через отдельный AddRange.
|
||||
var sale = await _db.RetailSales.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.Status != RetailSaleStatus.Draft)
|
||||
return Conflict(new { error = "Только черновик может быть изменён." });
|
||||
|
|
@ -302,14 +275,15 @@ public async Task<IActionResult> Update(Guid id, [FromBody] RetailSaleInput inpu
|
|||
sale.PaidCard = R(input.PaidCard);
|
||||
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);
|
||||
|
||||
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}"), RequiresPermission("RetailSalesRefund")]
|
||||
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken 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();
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/post"), RequiresPermission("RetailSalesOperate")]
|
||||
[HttpPost("{id:guid}/post"), Authorize(Roles = "Admin,Cashier")]
|
||||
public async Task<IActionResult> Post(Guid id, CancellationToken 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.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 +
|
||||
// апдейт stocks под одной блокировкой. Это защищает от race condition
|
||||
// когда два кассира одновременно постят чеки на один и тот же товар:
|
||||
|
|
@ -418,33 +371,16 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
|
|||
sale.PostedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(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();
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/unpost"), RequiresPermission("RetailSalesRefund")]
|
||||
[HttpPost("{id:guid}/unpost"), Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
|
||||
{
|
||||
var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||
if (sale is null) return NotFound();
|
||||
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)
|
||||
{
|
||||
await _stock.ApplyMovementAsync(new StockMovementDraft(
|
||||
|
|
@ -466,240 +402,18 @@ public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
|
|||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>POST /create-return — копирует строки проведённого чека в новый
|
||||
/// 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)
|
||||
private static void ApplyLines(RetailSale sale, IReadOnlyList<RetailSaleLineInput> input, bool allowFractional)
|
||||
{
|
||||
decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero);
|
||||
var order = 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)
|
||||
{
|
||||
var unitPrice = R(l.UnitPrice);
|
||||
var discount = R(l.Discount);
|
||||
var lineTotal = l.Quantity * unitPrice - discount;
|
||||
_db.RetailSaleLines.Add(new RetailSaleLine
|
||||
sale.Lines.Add(new RetailSaleLine
|
||||
{
|
||||
RetailSaleId = sale.Id,
|
||||
ProductId = l.ProductId,
|
||||
Quantity = l.Quantity,
|
||||
UnitPrice = unitPrice,
|
||||
|
|
@ -751,14 +465,9 @@ private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken
|
|||
orderby l.SortOrder
|
||||
select new RetailSaleLineDto(
|
||||
l.Id, l.ProductId, p.Name, p.Article, u.Name,
|
||||
l.Quantity, l.UnitPrice, l.Discount, l.LineTotal, l.VatPercent, l.SortOrder,
|
||||
l.QtyReturned))
|
||||
l.Quantity, l.UnitPrice, l.Discount, l.LineTotal, l.VatPercent, l.SortOrder))
|
||||
.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(
|
||||
row.s.Id, row.s.Number, row.s.Date, row.s.Status,
|
||||
row.st.Id, row.st.Name,
|
||||
|
|
@ -768,7 +477,6 @@ orderby l.SortOrder
|
|||
row.s.Subtotal, row.s.DiscountTotal, row.s.Total,
|
||||
row.s.Payment, row.s.PaidCash, row.s.PaidCard,
|
||||
row.s.Notes, row.s.PostedAt,
|
||||
row.s.IsReturn, row.s.ReferenceSaleId, refNumber,
|
||||
lines);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
if (o is null) return NotFound();
|
||||
if (string.IsNullOrWhiteSpace(req.Reason) || req.Reason.Trim().Length < 10)
|
||||
return BadRequest(new { error = "Причина смены владельца обязательна (≥ 10 символов) — она пишется в журнал аудита." });
|
||||
if (string.IsNullOrWhiteSpace(req.Reason)) return BadRequest(new { error = "Reason required." });
|
||||
var user = await _userMgr.FindByIdAsync(req.NewOwnerUserId.ToString());
|
||||
if (user is null || user.OrganizationId != o.Id)
|
||||
return BadRequest(new { error = "Пользователь не найден или не принадлежит этой организации." });
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace foodmarket.Api.Infrastructure.Authorization;
|
||||
|
||||
/// <summary>Динамически собирает policy для <c>perm:<Permission></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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace foodmarket.Api.Infrastructure.Authorization;
|
||||
|
||||
/// <summary>Гейтит endpoint на конкретное право роли. Имя права = имя
|
||||
/// boolean-свойства <c>RolePermissions</c>. Реализовано поверх policy-механизма:
|
||||
/// атрибут выставляет policy <c>perm:<Permission></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}";
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
|
|
@ -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(" ", " ").Replace("&", "&")
|
||||
.Replace("<", "<").Replace(">", ">")
|
||||
.Replace(""", "\"").Replace("'", "'");
|
||||
// Свернуть множественные пустые строки.
|
||||
s = System.Text.RegularExpressions.Regex.Replace(s, @"\n{3,}", "\n\n");
|
||||
return s.Trim();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
get
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
];
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,4 @@
|
|||
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.Seed;
|
||||
using foodmarket.Application.Common.Tenancy;
|
||||
|
|
@ -36,29 +31,12 @@
|
|||
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
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 засекает длительность каждого
|
||||
// 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) =>
|
||||
builder.Services.AddDbContext<AppDbContext>(opts =>
|
||||
{
|
||||
opts.UseNpgsql(builder.Configuration.GetConnectionString("Default"),
|
||||
npg => npg.MigrationsAssembly(typeof(AppDbContext).Assembly.GetName().Name));
|
||||
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 =>
|
||||
|
|
@ -88,12 +66,27 @@
|
|||
opts.AcceptAnonymousClients();
|
||||
opts.RegisterScopes(Scopes.OpenId, Scopes.Profile, Scopes.Email, Scopes.Roles, "api");
|
||||
|
||||
// Ключи подписи/шифрования: dev — persistent RSA-ключ в App_Data;
|
||||
// prod/stage — X509-сертификаты из конфига (OpenIddict:SigningCertPath /
|
||||
// EncryptionCertPath), persistent self-signed если файла нет.
|
||||
// Подробности и dev-инвариант — в OpenIddictKeyConfigurator.
|
||||
foodmarket.Api.Infrastructure.Security.OpenIddictKeyConfigurator
|
||||
.ConfigureSigningAndEncryption(opts, builder.Configuration, builder.Environment);
|
||||
// Persistent dev keys: RSA key stored in src/food-market.api/App_Data/openiddict-dev-key.xml.
|
||||
// Survives API restarts so issued tokens remain valid across rebuilds.
|
||||
// Never commit this file (it's in .gitignore via App_Data/). Production must use real certificates.
|
||||
var keyPath = Path.Combine(builder.Environment.ContentRootPath, "App_Data", "openiddict-dev-key.xml");
|
||||
var rsa = System.Security.Cryptography.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));
|
||||
}
|
||||
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()
|
||||
.EnableTokenEndpointPassthrough()
|
||||
|
|
@ -103,13 +96,6 @@
|
|||
builder.Configuration["OpenIddict:AccessTokenLifetime"] ?? "01:00:00"));
|
||||
opts.SetRefreshTokenLifetime(TimeSpan.Parse(
|
||||
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 из текущего HTTP-запроса, что ломается за
|
||||
// nginx-прокси если Host/X-Forwarded-Proto не доходят. Берём
|
||||
|
|
@ -140,124 +126,26 @@
|
|||
opts.AddPolicy("AdminAccess", p => p.RequireAssertion(ctx =>
|
||||
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>();
|
||||
|
||||
// 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 для
|
||||
// свежего DbContext'а, поэтому конфиг (PlatformSettings) перечитывается
|
||||
// на каждой отправке без рестарта приложения.
|
||||
builder.Services.AddSingleton<foodmarket.Application.Common.Email.IEmailSender,
|
||||
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();
|
||||
// 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 =>
|
||||
{
|
||||
// Глобальный action filter — пишет audit-log при успешных мутациях
|
||||
// в режиме «SuperAdmin открыть как… + edit-mode» (Phase 3).
|
||||
o.Filters.AddService<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>();
|
||||
o.Filters.AddService<foodmarket.Api.Infrastructure.Validation.ValidationFilter>();
|
||||
});
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(opts =>
|
||||
{
|
||||
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(".", "_"));
|
||||
});
|
||||
builder.Services.AddSwaggerGen();
|
||||
|
||||
// MoySklad import integration. Auto-decompress gzip responses from MoySklad's edge.
|
||||
// BaseAddress берётся из конфигурации (MoySklad:BaseUrl) с дефолтом на боевой
|
||||
// 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);
|
||||
})
|
||||
builder.Services.AddHttpClient<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladClient>()
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
||||
{
|
||||
AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate,
|
||||
|
|
@ -268,30 +156,6 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
|||
// Inventory
|
||||
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<SystemReferenceSeeder>();
|
||||
builder.Services.AddHostedService<DevDataSeeder>();
|
||||
|
|
@ -305,21 +169,8 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
|||
|
||||
app.UseSerilogRequestLogging();
|
||||
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.UseAuthorization();
|
||||
// После аутентификации, до контроллеров: вытягиваем OrgId/UserId из ClaimsPrincipal
|
||||
// и кладём в Serilog LogContext вместе с CorrelationId — каждая ILogger.Log
|
||||
// в пайплайне автоматически получит эти лейблы.
|
||||
app.UseMiddleware<foodmarket.Api.Infrastructure.Observability.LogEnrichmentMiddleware>();
|
||||
// SuperAdmin «открыть как…» — тот же tenant как у выбранной орги, но
|
||||
// только GET. Любая мутация → 403, кроме /api/super-admin/* и /connect/*.
|
||||
app.UseMiddleware<foodmarket.Api.Infrastructure.Tenancy.ReadonlyOverrideMiddleware>();
|
||||
|
|
@ -336,56 +187,12 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
|||
|
||||
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.UseSwaggerUI(opts =>
|
||||
{
|
||||
opts.DocumentTitle = "food-market API";
|
||||
opts.RoutePrefix = "swagger";
|
||||
});
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
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("/api/_debug/whoami", (HttpContext ctx) =>
|
||||
|
|
@ -456,26 +263,3 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
|||
{
|
||||
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<Program>).
|
||||
public partial class Program;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -21,16 +21,6 @@
|
|||
<PackageReference Include="Serilog.Sinks.Console" />
|
||||
<PackageReference Include="Serilog.Sinks.File" />
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Email-шаблоны embedded в сборку — см. EmailTemplates.LoadRaw. -->
|
||||
<EmbeddedResource Include="Resources/EmailTemplates/*.html" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -85,14 +85,12 @@ public record CounterpartyInput(
|
|||
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 ProductInput(
|
||||
[Required, MinLength(1), StringLength(500)] string Name,
|
||||
[StringLength(500)] string? Article,
|
||||
string? Description,
|
||||
string Name, string? Article, string? Description,
|
||||
Guid UnitOfMeasureId, [Range(0, 100)] decimal? Vat, bool VatEnabled,
|
||||
Guid ProductGroupId, Guid? DefaultSupplierId, Guid? CountryOfOriginId,
|
||||
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? ReferencePrice = null, Guid? PurchaseCurrencyId = null,
|
||||
[StringLength(1000)] string? ImageUrl = null,
|
||||
string? ImageUrl = null,
|
||||
IReadOnlyList<ProductPriceInput>? Prices = null,
|
||||
IReadOnlyList<ProductBarcodeInput>? Barcodes = null);
|
||||
|
|
|
|||
|
|
@ -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("&", "&")
|
||||
.Replace("<", "<")
|
||||
.Replace(">", ">")
|
||||
.Replace("\"", """)
|
||||
.Replace("'", "'");
|
||||
}
|
||||
|
|
@ -6,13 +6,6 @@ namespace foodmarket.Application.Common.Email;
|
|||
public interface IEmailSender
|
||||
{
|
||||
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 не настроен. Контроллеры должны
|
||||
|
|
|
|||
|
|
@ -9,9 +9,4 @@ public interface ITenantContext
|
|||
/// В этом режиме query-filter ОБЯЗАН применяться (по выбранной orgId),
|
||||
/// иначе SuperAdmin'у возвращаются записи всех орг — нарушение изоляции.</summary>
|
||||
bool IsTenantOverride { get; }
|
||||
|
||||
/// <summary>Id текущего пользователя (sub claim в JWT). null для системных
|
||||
/// операций без аутентификации (seed, фоновые джобы). Используется
|
||||
/// для аудит-лога — кто инициировал мутацию.</summary>
|
||||
Guid? UserId { get; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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; } = "[]";
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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; } = "{}";
|
||||
}
|
||||
|
|
@ -35,7 +35,6 @@ public class RolePermissions
|
|||
public bool InventoryEdit { get; set; }
|
||||
public bool LossEdit { get; set; }
|
||||
public bool EnterEdit { get; set; }
|
||||
public bool TransferEdit { get; set; }
|
||||
|
||||
// Отчёты
|
||||
public bool ReportsView { get; set; }
|
||||
|
|
@ -60,7 +59,7 @@ public static RolePermissions All() => new()
|
|||
DemandsView = true, DemandsEdit = true, DemandsPost = true,
|
||||
RetailSalesOperate = true, RetailSalesRefund = 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,
|
||||
OrgSettingsManage = true, EmployeesManage = true, RolesManage = true,
|
||||
StoresManage = true, RetailPointsManage = true,
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -9,11 +9,8 @@ public enum SupplyStatus
|
|||
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>
|
||||
public string Number { get; set; } = "";
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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; } = "";
|
||||
}
|
||||
|
|
@ -18,10 +18,8 @@ public enum PaymentMethod
|
|||
Mixed = 99,
|
||||
}
|
||||
|
||||
public class RetailSale : TenantEntity, IVersionedEntity
|
||||
public class RetailSale : TenantEntity
|
||||
{
|
||||
public uint Xmin { get; set; }
|
||||
|
||||
public string Number { get; set; } = "";
|
||||
public DateTime Date { get; set; } = DateTime.UtcNow;
|
||||
public RetailSaleStatus Status { get; set; } = RetailSaleStatus.Draft;
|
||||
|
|
@ -52,17 +50,6 @@ public class RetailSale : TenantEntity, IVersionedEntity
|
|||
public DateTime? PostedAt { 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>();
|
||||
}
|
||||
|
||||
|
|
@ -80,10 +67,4 @@ public class RetailSaleLine : TenantEntity
|
|||
public decimal LineTotal { get; set; } // = Quantity * UnitPrice - Discount
|
||||
public decimal VatPercent { get; set; } // snapshot
|
||||
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
Loading…
Reference in a new issue