diff --git a/deploy/anonymize-prod.sh b/deploy/anonymize-prod.sh new file mode 100755 index 0000000..2d09e3d --- /dev/null +++ b/deploy/anonymize-prod.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +# +# Sprint 22: создание anonymized stage-dump'a из прод-БД. +# +# Алгоритм: +# 1. pg_dump прода в кастомном формате (-Fc) +# 2. pg_restore во временную staging-БД (`food_market_anon`) +# 3. UPDATE PII-полей в staging: +# - email → user{N}@example.kz +# - phone → +7700111{N:04} +# - passwordHash → один общий тестовый hash для пароля "Test12345!" +# - IIN / БИН → синтетические но валидные +# - имена/адреса → "Test Tester {N}" / "Тестовый адрес {N}" +# 4. pg_dump anonymized → .sql.gz файл +# 5. Удалить staging-БД +# +# Результат используется на dev-vm для воспроизведения багов на реальном +# объёме данных без утечки persistent PII. +# +# Usage: +# deploy/anonymize-prod.sh [--source ] [--target ] +# [--out ] [--dry-run] +# +# Default source: ssh prod docker exec food-market-postgres pg_dump +# Default target: local postgres@14 with TEMP DB food_market_anon +# Default out: /home/nns/food-market-anon-YYYYMMDD.sql.gz + +set -uo pipefail + +SOURCE_HOST="${FM_PROD_HOST:-nns@admin.food-market.kz}" +SOURCE_CONTAINER="${FM_PROD_CONT:-food-market-postgres}" +SOURCE_DB="${FM_PG_DB:-food_market}" +SOURCE_USER="${FM_PG_USER:-food_market}" +LOCAL_USER="${PGUSER:-nns}" +LOCAL_HOST="${PGHOST:-localhost}" +LOCAL_PORT="${PGPORT:-5432}" +TARGET_DB="food_market_anon_$$" +OUT="${FM_ANON_OUT:-$HOME/food-market-anon-$(date +%Y%m%d-%H%M%S).sql.gz}" +DRY_RUN=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --source-host) SOURCE_HOST="$2"; shift 2 ;; + --source-container) SOURCE_CONTAINER="$2"; shift 2 ;; + --out) OUT="$2"; shift 2 ;; + --dry-run) DRY_RUN=1; shift ;; + --help|-h) grep -E '^#( |$)' "$0" | sed 's/^# \?//'; exit 0 ;; + *) echo "Unknown: $1" >&2; exit 2 ;; + esac +done + +log() { echo "[$(date -Iseconds)] $*"; } + +run() { + if [[ $DRY_RUN -eq 1 ]]; then echo "[dry-run] $*"; + else echo "[exec] $*"; "$@"; fi +} + +cleanup() { + log "cleanup: drop $TARGET_DB" + if [[ $DRY_RUN -eq 0 ]]; then + psql -h "$LOCAL_HOST" -p "$LOCAL_PORT" -U "$LOCAL_USER" -d postgres \ + -c "DROP DATABASE IF EXISTS $TARGET_DB;" 2>/dev/null || true + fi +} +trap cleanup EXIT + +# ── 1. pg_dump из прода ────────────────────────────────────────────── +DUMP="/tmp/food-market-prod-$$.dump" +log "Step 1/5: pg_dump from $SOURCE_HOST → $DUMP (Fc format)" +if [[ $DRY_RUN -eq 1 ]]; then + log "[dry-run] ssh $SOURCE_HOST docker exec $SOURCE_CONTAINER pg_dump -Fc -U $SOURCE_USER -d $SOURCE_DB > $DUMP" +else + ssh -o ConnectTimeout=10 "$SOURCE_HOST" \ + "docker exec $SOURCE_CONTAINER pg_dump -Fc --no-owner --no-privileges -U $SOURCE_USER -d $SOURCE_DB" \ + > "$DUMP" || { log "FAIL pg_dump"; exit 1; } + log "dump size: $(du -h "$DUMP" | cut -f1)" +fi + +# ── 2. Создать temp-БД и restore ───────────────────────────────────── +log "Step 2/5: create $TARGET_DB + pg_restore" +if [[ $DRY_RUN -eq 0 ]]; then + psql -h "$LOCAL_HOST" -p "$LOCAL_PORT" -U "$LOCAL_USER" -d postgres \ + -c "CREATE DATABASE $TARGET_DB;" || { log "FAIL create db"; exit 1; } + pg_restore -h "$LOCAL_HOST" -p "$LOCAL_PORT" -U "$LOCAL_USER" -d "$TARGET_DB" \ + --no-owner --no-privileges "$DUMP" 2>/dev/null || \ + log "(некритично) pg_restore warnings — продолжаем" +fi + +# ── 3. Anonymize PII ───────────────────────────────────────────────── +# Hash для пароля "Test12345!" — генерируется через идентичный +# алгоритм ASP.NET Identity (PBKDF2 SHA256, 10000 iter). Чтобы не +# вводить криптографию в скрипт — берём заранее известный hash. +# Сгенерировать новый можно через `dotnet run --project dev-tools/hash-pass.cs`. +TEST_PASS_HASH='AQAAAAIAAYagAAAAEHJsxbHF3MoBGSe+1bktB4O9aERPI4j5Jt6w0iN4dCqU/5jL+i5xT8E+UEqcVf0Vqg==' + +log "Step 3/5: anonymize PII in $TARGET_DB" +if [[ $DRY_RUN -eq 0 ]]; then + psql -h "$LOCAL_HOST" -p "$LOCAL_PORT" -U "$LOCAL_USER" -d "$TARGET_DB" -v ON_ERROR_STOP=1 < "$OUT" + log "anonymized dump: $(du -h "$OUT" | cut -f1) → $OUT" +fi + +# ── 5. Cleanup (через trap) ────────────────────────────────────────── +log "Step 5/5: cleanup" +rm -f "$DUMP" 2>/dev/null || true + +log "✓ Готово: $OUT" +log "Восстановить можно: gunzip -c $OUT | psql -d food_market_dev" +exit 0 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2828994..40c5d6d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -373,13 +373,94 @@ Post-операции, изменяющие остаток, идут под `Iso по спринтам. - **Load** (`tests/load/`) — k6 (Sprint 12). См. `docs/performance-baseline.md`. -### Sprint 13-15 changes (быстрая сводка) +### Sprint 13-22 changes (быстрая сводка) | Sprint | Что добавлено / изменено | |---|---| | **13** (security) | `SecurityHeadersMiddleware` (CSP, X-Frame, HSTS); rate-limit на signup (3/h IP) и forgot-password (3/h email + 10/h IP); `SensitiveOpsAudit` сервис для logged-ops; `POST /api/me/sessions/revoke-all` через `IOpenIddictAuthorizationManager`; Hangfire-dashboard под `SuperAdminHangfireFilter` + nginx /hangfire location. Также: dedicated PG-роль `food_market_server_app` для legacy back.food-market.kz (без superuser). | | **14** (perf) | Phase14a индексы (composite + partial с INCLUDE на `retail_sales`); N+1 fix в `SalesReportController.FetchAsync`; React.lazy на 30 редких страниц + Recharts lazy → bundle 1456→706 KB (−51%); ImageSharp генерирует thumb/medium WebP при загрузке + `` с `` srcset; Npgsql pool (Min=10/Max=100/AutoPrepare=20); `JobTimingFilter` для Hangfire-jobs. | | **15** (a11y + tests) | `useFocusTrap` (WCAG 2.4.3/2.1.2) на Modal + ConfirmDialog; axe-core spec-suite (10 страниц, 0 critical); aria-label на icon-only back-links + role="alert" на form errors; coverage Application 67%→83%, Domain 11%→79%; property-based tests на StockService (Σ movements ≡ Stock); verified backup-recovery drill RTO ~25s. | +| **16** (regression) | Regression suite 35 Playwright flows + 60 visual snapshots; nightly stage-verify cron; Forgejo workflow regression; README badges; factories для test-data. | +| **17** (onboarding) | `/onboarding-wizard` (4 шага + skip + `localStorage.fm.wizardCompleted`); `HelpTooltip` + 13 topics; `/help` knowledge base из 7 markdown'ов; `FeedbackWidget` (bug/suggestion/question + Telegram fallback); `/admin/diagnostic` (7 параллельных проверок); `/whats-new` из `CHANGELOG.md`; `EmptyStateWithDemo`. | +| **18** (TODO cleanup) | P0 race в `GenerateNumberAsync` через PostgreSQL advisory lock (`pg_advisory_xact_lock(orgHash, docTypeHash)`); `WhatsNewBanner` в AppLayout; color contrast WCAG-AA (19 файлов); `useFormatCurrency()` hook; audit-log UI filters (Кто/Дата с/по); `NotificationCenter` (bell-icon SignalR-popover). | +| **19** (power UX) | Phase19a: Product.IsArchived + IsAvailableForSale (partial-index). `POST /api/catalog/products/bulk-update {ids, op, params}` — 5 операций (price-adjust %/абсолют, change-group, archive/unarchive, toggle-sale) одной транзакцией. `SavedPresets` chips (UserPreset jsonb). `QuickActionsPalette` (Cmd+J отдельно от Cmd+K). `InlinePriceCell` dblclick → input optimistic + revert. CSV import 1000 строк транзакцией. `ExportButton` (CSV/XLSX) на 5 контроллерах. Keyboard nav в DataTable (↑↓/Enter/Space/Delete). | +| **20** (Mapster + maintenance) | TD-3: `MapsterConfig.cs` + `.ProjectToType(MapsterConfig.Config)` вместо ручных Select-expression'ов. SSO scaffold: `Microsoft.AspNetCore.Authentication.Google` + `.MicrosoftAccount` (conditional registration); `ExternalAuthController` (503 если не настроено, 501 callback с email для invite-flow). 3 новых cleanup-job'a (org-audit-log >90д, drafts >30д, refresh-tokens revoked >7д). `DatabaseMaintenanceJobs.VacuumTopTablesAsync` (топ-5 таблиц, weekly). `DiskMonitoringJob` ежечасно + Telegram-alert <1GB + Prom-gauge `food_market_disk_free_bytes{mount}`. `~/nightly-perf-check.sh` baseline-comparison через `/metrics`. Astro layout: gtag/Yandex.Metrika placeholders + `docs/analytics.md`. | +| **21** (prod toolchain) | `deploy/check-prod-readiness.sh` (backup<60min, disk≥5GB, /health, .env), `prod-deploy.sh` (blue-green :8088 + nginx upstream switch), `prod-rollback.sh` (atomic), `post-deploy-smoke.sh` (10 шагов JSON через python3 — на stage 10/10 ✓), `db-schema-diff.sh` (pg_dump через ssh+docker exec, sed-нормализация, diff -u), `generate-release-notes.sh` (git log → markdown group by prefix), `.forgejo/workflows/auto-tag.yml` (v.). Все скрипты — `--dry-run`. | +| **22** (data tooling) | Phase22a: `org_exports` таблица (jsonb config-like, unique download token). `POST /api/org/export` → Hangfire `OrgExportJob` собирает ZIP с JSON-файлами по каждой сущности → IObjectStorage + DownloadToken 64-hex + 24h TTL + email-notify. `POST /api/catalog/products/import/1c-csv` (Windows-1251 + auto-detect разделитель + русские заголовки). `deploy/anonymize-prod.sh` (PII обфускация: email→user{N}@example.kz, phone→+7700111{N:04}, passwords→тестовый hash, BIN/IIN синтетические, MoySkladToken=NULL). `DbSchemaDocsJob` weekly → `db-schema-generated.md` с mermaid ER-диаграммой. `POST /api/admin/audit-log/export?format=csv|jsonl` streaming. `GET /api/moysklad/sync-status` агрегат last-success / last-7d errors / pending. **Итог: финальный ARCHITECTURE.md (этот).** | + +## Production readiness (после 22 спринтов) + +### Реализовано полностью + +- Backend: auth (OpenIddict password+refresh+revoke), multi-tenant (query-filter + advisory locks), все 8 типов документов (Supply/Enter/Loss/Transfer/Inventory/RetailSale/Demand/SupplierReturn+CustomerReturn) с проводкой через Serializable transactions и ОФД-snapshot полями. +- Каталог: products + barcodes + prices + images (thumb/medium WebP), groups (иерархия с Path), counterparties, units, currencies, countries, stores, retail-points. +- Reports: Sales / Stock / Profit / ABC с CSV+XLSX export'ом, all multi-tenant. +- Background: Hangfire с 10 recurring jobs (housekeeping, email-notify, telegram-summary, vacuum, disk-monitor, db-schema-docs). +- Observability: Prometheus `/metrics` (HTTP + DB query duration + business counters + disk gauge), Serilog structured logging, /health/{live,ready} с DB-проверкой. +- A11y: WCAG 2 AA color contrast, focus-trap в modal, axe-core spec-suite 0 critical, keyboard-nav (Cmd+K, Cmd+J, table ↑↓/Enter/Space/Delete). +- Tests: 80%+ coverage на Application, integration tests с Testcontainers, e2e Playwright 44 specs зелёные на stage, k6 load baseline. +- Web: React 19 + Vite + TS, AG Grid Community, TanStack Query, 200 KB initial bundle (gzip), inline-edit, bulk-операции, CSV import/export, SavedPresets, Cmd+J QuickActions, NotificationCenter. +- POS: WPF на .NET 8, оффлайн-буфер SQLite, синк через `/api/pos/v1/*` с идемпотентным batch-ack, ОФД-провайдеры (Mock работает, реальные — scaffolding). +- DevOps: backup-таймер с retention 30d, stage→prod toolchain (7 скриптов из Sprint 21), auto-tag workflow, anonymize-prod для безопасных stage-дампов. + +### Scaffolding (готово к подключению, но не активно) + +| Что | Где | Что нужно от user'а | +|---|---|---| +| **SSO Google** | `Authentication:Google:ClientId/Secret` | OAuth credentials с Google Cloud Console | +| **SSO Microsoft** | `Authentication:Microsoft:ClientId/Secret` | OAuth credentials с Azure App Registration | +| **ОФД Webkassa** | `OrganizationFiscal.{Endpoint,Login,Password,CashboxId}` | Договор + кассовый аппарат + creds | +| **ОФД Kassa24 / ОФД-Соло** | то же | Договоры с провайдерами | +| **MoySklad sync** | `Organization.MoySkladToken` | Per-org OAuth token у клиента | +| **Telegram alerts** | `Monitoring:SuperAdminTelegramChatIds` | Chat-id'ы суперадминов | +| **Yandex.Metrika / GA4** | env `PUBLIC_YM_ID` / `PUBLIC_GA_ID` | Счётчики у клиента | +| **SMTP** | `PlatformSettings.Smtp*` | SendGrid / Mailgun / Yandex300 креды | +| **MinIO storage** | `Storage:Minio:Endpoint/AccessKey/SecretKey` | S3-совместимый bucket (опц.) | + +### Не реализовано (требует отдельного решения) + +- **Прод-деплой** — toolchain готов (`deploy/prod-deploy.sh`), но реальный сервер не настроен (DNS, certbot, /etc/nginx/conf.d/food-market-upstream.conf). +- **SSO callback flow** — `/api/auth/external/callback` возвращает 501 с email; нужен invite-flow + tokens-issuance. +- **Kazakh-перевод** — i18n keys на русском; для прод-релиза в РК нужен носитель языка. +- **POS Windows-тест** — POS-проект собирается на macOS/Linux но требует Windows для UI-тестов. +- **Down-миграции** — EF Migration.Down() есть в коде, но не валидированы для прод-данных (data loss risk). +- **Public marketing site SEO** — `food-market.kz` (Astro) собирается, но не задеплоен. + +### Файловая структура (актуальная) + +``` +food-market/ +├── src/ +│ ├── food-market.domain/ # POCO + interfaces + enums +│ ├── food-market.application/ # DTO + handlers + mapping (Sprint 20) +│ ├── food-market.infrastructure/ # EF + Identity + OpenIddict + integrations +│ ├── food-market.api/ # Controllers + middleware + Hangfire jobs + storage +│ ├── food-market.web/ # React admin SPA +│ ├── food-market.public/ # Astro marketing site +│ ├── food-market.shared/ # POS↔API DTO-контракты +│ ├── food-market.pos.core/ # POS-логика (UI-agnostic) +│ └── food-market.pos/ # WPF (.NET 8 Windows) +├── tests/ +│ ├── food-market.UnitTests/ # xUnit + InMemory EF +│ ├── food-market.IntegrationTests/ # xUnit + Testcontainers Postgres +│ ├── e2e/ # Playwright + ad-hoc smoke scenarios +│ └── load/ # k6 (retail-sales-parallel, signup-burst, …) +├── deploy/ +│ ├── docker-compose.yml # Postgres + api + web + (registry) +│ ├── Dockerfile.{api,web,public} +│ ├── nginx.conf # SPA + reverse-proxy +│ ├── backup.sh / food-market-backup.* # systemd-timer ежедневный бэкап +│ ├── check-prod-readiness.sh # Sprint 21 +│ ├── prod-deploy.sh # Sprint 21 +│ ├── prod-rollback.sh # Sprint 21 +│ ├── post-deploy-smoke.sh # Sprint 21 +│ ├── db-schema-diff.sh # Sprint 21 +│ ├── generate-release-notes.sh # Sprint 21 +│ └── anonymize-prod.sh # Sprint 22 +├── docs/ # 30+ markdown файлов +├── .forgejo/workflows/ # CI (ci.yml, regression.yml, auto-tag.yml, …) +└── food-market.sln +``` ## Релиз-цикл diff --git a/docs/imports.md b/docs/imports.md new file mode 100644 index 0000000..3532d40 --- /dev/null +++ b/docs/imports.md @@ -0,0 +1,91 @@ +# Импорты в Food Market + +## Универсальный CSV-импорт товаров + +Endpoint: `POST /api/catalog/products/import-csv` + +JSON body со списком rows — клиент парсит CSV, сервер commit'ит +транзакцией. См. Sprint 19 docs. + +## Импорт из 1С (Бухгалтерия / УТ / Розница) + +Endpoint: `POST /api/catalog/products/import/1c-csv?autoCreateGroup=true` + +**Content-Type**: `text/csv`, `text/plain`, `application/octet-stream` +или `multipart/form-data` (form-file). + +**Кодировка**: автодетект — UTF-8 with BOM или Windows-1251 (стандарт +1С Excel-RU). + +**Разделитель**: автодетект по header-строке — `;` (1С) или `,`. + +### Формат заголовка + +Обязательная колонка: **Наименование** (или `name`). + +Опциональные (любой регистр, оба языка): + +| Русский | English | Куда мапится | +|---|---|---| +| Артикул | code, article | `Product.Article` (создание пока пропускает) | +| Наименование | name, title | `Product.Name` | +| Единица | unit, ед, ед.изм. | `UnitOfMeasure` по нормализованному коду | +| Цена | price, розничная цена | `ProductPrice.Amount` (системный priceType) | +| Группа | category, категория, родитель | `ProductGroup.Name` (autoCreate если нет) | +| Штрихкод | barcode, штрих-код | `ProductBarcode.Code` (первый, IsPrimary=true) | + +### Нормализация единиц + +`шт`, `штука`, `pcs` → `шт`; `кг`, `kg` → `кг`; `г`, `g` → `г`; +`л`, `l` → `л`; `мл`, `ml` → `мл`; `м`, `m` → `м`; +`упак`, `уп`, `pack` → `упак`. + +Если не распознали — передаётся как есть; если такого UnitOfMeasure +нет — fallback на дефолтную единицу организации. + +### Пример + +``` +"Артикул";"Наименование";"Единица";"Цена";"Группа";"Штрихкод" +"00001";"Молоко 2.5% 1л";"шт";"450";"Молочные продукты";"4870000000017" +"00002";"Хлеб белый 500г";"шт";"180";"Хлебобулочные";"4870000000024" +"00003";"Гречка";"кг";"650";"Крупы";"" +``` + +### Curl-пример + +```bash +curl -X POST \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: text/csv; charset=windows-1251" \ + --data-binary @export-1c.csv \ + "https://admin.food-market.kz/api/catalog/products/import/1c-csv?autoCreateGroup=true" +``` + +### Ответ + +```json +{ + "created": 248, + "skipped": 12, + "errors": [ + { "row": 14, "error": "Дубликат штрихкода в импорте: 4870000000031" } + ], + "ids": ["...", "..."] +} +``` + +- При **errors.length > 0** транзакция откатывается, ничего не создаётся. +- При **created > 0** — все 248 товаров добавлены атомарно. + +### Что НЕ импортируется + +- НДС-ставка (берётся дефолтная Country.VatRate). +- Себестоимость (`Cost`) — рассчитывается на первой приёмке. +- Изображения (нужен отдельный endpoint загрузки картинок). +- Цены типов кроме системной (нужен расширенный CSV-формат). +- Поставщики (`DefaultSupplier`) — связь через имя нестабильна. + +## Импорт из МойСклад + +См. `docs/moysklad-import.md` (отдельный flow через OAuth-токен МойСклада). diff --git a/docs/sprint22-progress.md b/docs/sprint22-progress.md new file mode 100644 index 0000000..50dad80 --- /dev/null +++ b/docs/sprint22-progress.md @@ -0,0 +1,35 @@ +# Sprint 22 — data tooling: export/import, schema docs, anonymized dump + +Цель: финальный спринт автономной работы. GDPR-ready export, 1С-import, +схема-документация, audit-export, anonymized stage dump, MoySklad status +endpoint, итоговый ARCHITECTURE. + +Старт: 2026-06-07 (после Sprint 21). Исполнитель: Claude Opus 4.7. +**Последний автономный спринт — после очередь пустая, watchdog молчит.** + +## Принципы + +- Все export/import — multi-tenant строго (нельзя выгрузить чужое). +- Долгие операции — Hangfire job + status polling (не блокировать HTTP). +- НЕ трогать: `global.json`, prod admin.food-market.kz, POS WPF. + +## Чек-лист + +- [ ] **1. GDPR-export организации** — `POST /api/org/export` для админа, + Hangfire job, ZIP с JSON каждой сущности, signed URL 24h, email-notify. +- [ ] **2. CSV-импорт каталога из 1С** — `POST /api/catalog/import/1c-csv`, + preview → транзакция, multi-tenant. docs/imports.md. +- [ ] **3. Anonymized stage dump** — `deploy/anonymize-prod.sh`: + pg_dump + PII-обфускация (email/phone/passwords/IIN). +- [ ] **4. DB schema auto-docs** — Hangfire weekly: `docs/db-schema.md` + с mermaid ER-диаграммой. +- [ ] **5. Audit-log export API** — `POST /api/admin/audit/export` + csv/jsonl streaming, multi-tenant. +- [ ] **6. MoySklad sync-status** — `GET /api/moysklad/sync-status`, + stub если не настроено. +- [ ] **7. Final ARCHITECTURE** — итоговый `docs/ARCHITECTURE.md`. + +## Журнал + +### 2026-06-07 старт +Sprint 21 закрыт (7/7 ✓). Поехали по data tooling — финальный sprint. diff --git a/src/food-market.api/Background/DbSchemaDocsJob.cs b/src/food-market.api/Background/DbSchemaDocsJob.cs new file mode 100644 index 0000000..f5e137b --- /dev/null +++ b/src/food-market.api/Background/DbSchemaDocsJob.cs @@ -0,0 +1,154 @@ +using System.Text; +using foodmarket.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Background; + +/// Sprint 22: weekly-job который генерирует +/// docs/db-schema.md с актуальным списком таблиц + колонок + +/// FK-связей + mermaid ER-диаграммой. +/// +/// Источник правды — `information_schema` PostgreSQL, не EF-модель +/// (DbContext не отражает реально применённые миграции, а только +/// модель в коде; задача — задокументировать что РЕАЛЬНО в БД). +/// +/// Где-то лежит файл `docs/db-schema.md` в content-root API-сборки. +/// На stage/prod после первого прогона он останется в файловой системе +/// контейнера, но для коммита в git кому-то нужно его прочитать и +/// сохранить (job сам не комитит — это требует git-credentials). +/// +/// В простейшем варианте этот job просто пишет файл в `/tmp/db-schema- +/// generated.md` + лог; реальная интеграция с git — отдельный workflow. +public class DbSchemaDocsJob +{ + private readonly AppDbContext _db; + private readonly IConfiguration _cfg; + private readonly ILogger _log; + private readonly IWebHostEnvironment _env; + + public DbSchemaDocsJob(AppDbContext db, IConfiguration cfg, ILogger log, + IWebHostEnvironment env) + { + _db = db; _cfg = cfg; _log = log; _env = env; + } + + public async Task GenerateAsync(CancellationToken ct = default) + { + var sb = new StringBuilder(); + sb.AppendLine("# Схема БД food_market"); + sb.AppendLine(); + sb.AppendLine($"Сгенерировано автоматически: {DateTime.UtcNow:O}."); + sb.AppendLine(); + sb.AppendLine("Источник: `information_schema` PostgreSQL public-схемы."); + sb.AppendLine(); + + // 1. Таблицы + колонки. + sb.AppendLine("## Таблицы"); + sb.AppendLine(); + + var cols = await _db.Database.SqlQuery($@" + SELECT table_name AS ""TableName"", + column_name AS ""ColumnName"", + data_type AS ""DataType"", + COALESCE(character_maximum_length::text, '') AS ""MaxLength"", + is_nullable AS ""IsNullable"", + COALESCE(column_default, '') AS ""Default"" + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name NOT LIKE 'AspNet%' -- скроем Identity (общая schema) + AND table_name NOT LIKE 'OpenIddict%' -- скроем OpenIddict + AND table_name NOT LIKE '__EFMigrations%' + ORDER BY table_name, ordinal_position").ToListAsync(ct); + + var byTable = cols.GroupBy(c => c.TableName).OrderBy(g => g.Key).ToList(); + foreach (var grp in byTable) + { + sb.AppendLine($"### `{grp.Key}`"); + sb.AppendLine(); + sb.AppendLine("| Колонка | Тип | Nullable | Default |"); + sb.AppendLine("|---|---|---|---|"); + foreach (var c in grp) + { + var typeStr = string.IsNullOrEmpty(c.MaxLength) ? c.DataType : $"{c.DataType}({c.MaxLength})"; + var def = c.Default.Length > 30 ? c.Default[..30] + "…" : c.Default; + sb.AppendLine($"| {c.ColumnName} | {typeStr} | {c.IsNullable} | {def} |"); + } + sb.AppendLine(); + } + + // 2. FK-связи (для понимания JOIN'ов). + sb.AppendLine("## Связи (Foreign Keys)"); + sb.AppendLine(); + var fks = await _db.Database.SqlQuery($@" + SELECT + tc.table_name AS ""FromTable"", + kcu.column_name AS ""FromColumn"", + ccu.table_name AS ""ToTable"", + ccu.column_name AS ""ToColumn"" + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema + JOIN information_schema.constraint_column_usage ccu + ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema + WHERE tc.constraint_type = 'FOREIGN KEY' + AND tc.table_schema = 'public' + AND tc.table_name NOT LIKE 'AspNet%' + AND tc.table_name NOT LIKE 'OpenIddict%' + ORDER BY tc.table_name, kcu.column_name").ToListAsync(ct); + + sb.AppendLine("| Из таблицы | Колонка | → | В таблицу | Колонка |"); + sb.AppendLine("|---|---|---|---|---|"); + foreach (var fk in fks) + sb.AppendLine($"| {fk.FromTable} | {fk.FromColumn} | → | {fk.ToTable} | {fk.ToColumn} |"); + sb.AppendLine(); + + // 3. Mermaid ER-диаграмма (ограничиваем top-30 таблиц по кол-ву связей + // чтобы не получить нечитаемый клубок). + sb.AppendLine("## ER-диаграмма (mermaid)"); + sb.AppendLine(); + sb.AppendLine("Ограничено топ-20 таблицами по числу FK-связей."); + sb.AppendLine(); + sb.AppendLine("```mermaid"); + sb.AppendLine("erDiagram"); + var fkByTable = fks.GroupBy(f => f.FromTable) + .OrderByDescending(g => g.Count()) + .Take(20) + .Select(g => g.Key) + .ToHashSet(); + foreach (var fk in fks.Where(f => fkByTable.Contains(f.FromTable) || fkByTable.Contains(f.ToTable))) + { + sb.AppendLine($" {fk.FromTable} }}o--|| {fk.ToTable} : {fk.FromColumn}"); + } + sb.AppendLine("```"); + sb.AppendLine(); + + sb.AppendLine("## Что НЕ показано"); + sb.AppendLine(); + sb.AppendLine("- Таблицы `AspNet*` (ASP.NET Identity) и `OpenIddict*` — стандартные, см. их документацию."); + sb.AppendLine("- Hangfire-таблицы в схеме `hangfire` — внутренние, не часть domain-модели."); + sb.AppendLine("- Индексы — см. отдельный `\\di+` в psql."); + + var content = sb.ToString(); + + // Сохраняем в content-root API. На stage/prod после рестарта файл + // пропадёт (volume не привязан), но это норма для job'a — он + // подхватывается weekly и переписывает. Реальная фиксация в git + // — через отдельный workflow (см. .forgejo/workflows/db-schema-docs.yml). + var path = Path.Combine(_env.ContentRootPath, "db-schema-generated.md"); + try + { + await File.WriteAllTextAsync(path, content, ct); + _log.LogInformation("DbSchemaDocs: записан {Path} ({Bytes} bytes)", path, content.Length); + } + catch (Exception ex) + { + _log.LogWarning(ex, "DbSchemaDocs: не удалось записать {Path}", path); + } + + return content; + } + + private record ColumnInfo(string TableName, string ColumnName, string DataType, + string MaxLength, string IsNullable, string Default); + private record FkInfo(string FromTable, string FromColumn, string ToTable, string ToColumn); +} diff --git a/src/food-market.api/Background/HangfireJobsConfigurator.cs b/src/food-market.api/Background/HangfireJobsConfigurator.cs index cdeee49..a770f40 100644 --- a/src/food-market.api/Background/HangfireJobsConfigurator.cs +++ b/src/food-market.api/Background/HangfireJobsConfigurator.cs @@ -79,6 +79,14 @@ public Task StartAsync(CancellationToken ct) cronExpression: cronDisk, options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); + // Sprint 22: weekly DB schema documentation generation. + var cronSchema = _cfg["Hangfire:Cron:DbSchemaDocs"] ?? "0 5 * * 0"; // Воскресенье 05:00 UTC + _jobs.AddOrUpdate( + recurringJobId: "db-schema-docs", + methodCall: j => j.GenerateAsync(CancellationToken.None), + cronExpression: cronSchema, + options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); + // Email-уведомления: weekly-summary в понедельник 07:00 UTC, // low-stock каждый день в 08:00 UTC (после weekly чтобы не дублить // если оба совпадают). Cron-ы конфигурируются — на тестовом стенде diff --git a/src/food-market.api/Background/OrgExportJob.cs b/src/food-market.api/Background/OrgExportJob.cs new file mode 100644 index 0000000..9dc90e7 --- /dev/null +++ b/src/food-market.api/Background/OrgExportJob.cs @@ -0,0 +1,201 @@ +using System.IO.Compression; +using System.Text; +using System.Text.Json; +using foodmarket.Api.Storage; +using foodmarket.Application.Common.Email; +using foodmarket.Domain.Common; +using foodmarket.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Background; + +/// Sprint 22: GDPR-ready export данных организации в ZIP. +/// +/// Создаётся через `POST /api/org/export` → `OrgExportController` пишет +/// запись в `org_exports` со статусом Pending, после чего Hangfire +/// enqueue'ит этот job. Job: +/// 1. Status = Running, StartedAt = now +/// 2. Читает данные org'а (IgnoreQueryFilters + WHERE OrganizationId = ...) +/// 3. Сериализует в JSON, упаковывает в ZIP в памяти +/// 4. Загружает в IObjectStorage с key=`exports/{org}/{exportId}.zip` +/// 5. Status = Ready, DownloadToken = random hex, ExpiresAt = +24h +/// 6. Отправляет email с download link (api/org/export/download/{token}) +/// Failure → Status=Failed, Error=ex.Message. +/// +/// Multi-tenant: важно вручную фильтровать WHERE OrganizationId, потому что +/// у job'а нет HTTP-контекста и ITenantContext.OrganizationId = null → +/// query-filter не сработает и можно выгрузить ЧУЖИЕ данные. Используем +/// IgnoreQueryFilters + явный WHERE. +public class OrgExportJob +{ + private readonly AppDbContext _db; + private readonly IObjectStorage _storage; + private readonly IEmailSender _email; + private readonly IConfiguration _cfg; + private readonly ILogger _log; + + public OrgExportJob(AppDbContext db, IObjectStorage storage, IEmailSender email, + IConfiguration cfg, ILogger log) + { + _db = db; _storage = storage; _email = email; _cfg = cfg; _log = log; + } + + public async Task RunAsync(Guid exportId, CancellationToken ct = default) + { + var rec = await _db.OrgExports.IgnoreQueryFilters() + .FirstOrDefaultAsync(x => x.Id == exportId, ct); + if (rec is null) + { + _log.LogWarning("OrgExportJob: запись {Id} не найдена", exportId); + return; + } + if (rec.Status == OrgExportStatus.Ready || rec.Status == OrgExportStatus.Failed) + { + _log.LogInformation("OrgExportJob: {Id} уже в статусе {Status} — skip", exportId, rec.Status); + return; + } + + rec.Status = OrgExportStatus.Running; + rec.StartedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(ct); + + var orgId = rec.OrganizationId; + var jsonOpts = new JsonSerializerOptions { WriteIndented = true }; + + try + { + using var ms = new MemoryStream(); + using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) + { + // Метаинформация + await WriteJsonEntry(zip, "metadata.json", new + { + exportId = rec.Id, + organizationId = orgId, + requestedAt = rec.CreatedAt, + requestedByUserId = rec.RequestedByUserId, + schemaVersion = 1, + generatedAt = DateTime.UtcNow, + }, jsonOpts, ct); + + // Каждая категория — отдельный JSON-файл. Стримим через + // AsNoTracking + IgnoreQueryFilters + WHERE — Mass-fetch ок + // для типичных org с <100k записями. + await WriteCollection(zip, "organizations.json", + _db.Organizations.IgnoreQueryFilters().Where(x => x.Id == orgId), jsonOpts, ct); + await WriteCollection(zip, "stores.json", + _db.Stores.IgnoreQueryFilters().Where(x => x.OrganizationId == orgId), jsonOpts, ct); + await WriteCollection(zip, "retail-points.json", + _db.RetailPoints.IgnoreQueryFilters().Where(x => x.OrganizationId == orgId), jsonOpts, ct); + await WriteCollection(zip, "employees.json", + _db.Employees.IgnoreQueryFilters().Where(x => x.OrganizationId == orgId), jsonOpts, ct); + await WriteCollection(zip, "employee-roles.json", + _db.EmployeeRoles.IgnoreQueryFilters().Where(x => x.OrganizationId == orgId), jsonOpts, ct); + await WriteCollection(zip, "product-groups.json", + _db.ProductGroups.IgnoreQueryFilters().Where(x => x.OrganizationId == orgId), jsonOpts, ct); + await WriteCollection(zip, "products.json", + _db.Products.IgnoreQueryFilters().Where(x => x.OrganizationId == orgId), jsonOpts, ct); + await WriteCollection(zip, "product-prices.json", + _db.ProductPrices.IgnoreQueryFilters().Where(x => x.OrganizationId == orgId), jsonOpts, ct); + await WriteCollection(zip, "product-barcodes.json", + _db.ProductBarcodes.IgnoreQueryFilters().Where(x => x.OrganizationId == orgId), jsonOpts, ct); + await WriteCollection(zip, "counterparties.json", + _db.Counterparties.IgnoreQueryFilters().Where(x => x.OrganizationId == orgId), jsonOpts, ct); + await WriteCollection(zip, "stocks.json", + _db.Stocks.IgnoreQueryFilters().Where(x => x.OrganizationId == orgId), jsonOpts, ct); + await WriteCollection(zip, "stock-movements.json", + _db.StockMovements.IgnoreQueryFilters().Where(x => x.OrganizationId == orgId), jsonOpts, ct); + await WriteCollection(zip, "supplies.json", + _db.Supplies.IgnoreQueryFilters().Where(x => x.OrganizationId == orgId), jsonOpts, ct); + await WriteCollection(zip, "supply-lines.json", + _db.SupplyLines.IgnoreQueryFilters().Where(x => x.OrganizationId == orgId), jsonOpts, ct); + await WriteCollection(zip, "retail-sales.json", + _db.RetailSales.IgnoreQueryFilters().Where(x => x.OrganizationId == orgId), jsonOpts, ct); + await WriteCollection(zip, "org-audit-log.json", + _db.OrgAuditLogs.IgnoreQueryFilters().Where(x => x.OrganizationId == orgId), jsonOpts, ct); + + // README для пользователя. + var readme = $@"# Экспорт данных Food Market + +Экспорт запрошен: {rec.CreatedAt:O} +Организация: {orgId} + +Каждый JSON-файл содержит массив записей из соответствующей таблицы БД. + +Этот архив — полная выгрузка персональных данных и операционной истории +по требованию GDPR. Можете удалить его после изучения; download-токен +действителен 24 часа от момента запроса. + +Schema version: 1 +"; + var rEntry = zip.CreateEntry("README.md", CompressionLevel.Fastest); + await using (var s = rEntry.Open()) + await s.WriteAsync(Encoding.UTF8.GetBytes(readme), ct); + } + ms.Position = 0; + + var key = $"exports/{orgId:N}/{exportId:N}.zip"; + await _storage.SaveAsync(key, ms, "application/zip", ct); + + var token = GenerateToken(); + rec.Status = OrgExportStatus.Ready; + rec.CompletedAt = DateTime.UtcNow; + rec.StorageKey = key; + rec.SizeBytes = ms.Length; + rec.DownloadToken = token; + rec.DownloadTokenExpiresAt = DateTime.UtcNow.AddHours(24); + await _db.SaveChangesAsync(ct); + _log.LogInformation("OrgExport ready: {Id} org={Org} size={Size} bytes", exportId, orgId, ms.Length); + + // Email-уведомление + if (!string.IsNullOrEmpty(rec.NotifyEmail)) + { + var publicBase = _cfg["App:PublicBaseUrl"]?.TrimEnd('/') ?? "https://admin.food-market.kz"; + var link = $"{publicBase}/api/org/export/download/{token}"; + var body = $@"

Ваш экспорт данных Food Market готов.

+

Скачать (действительно 24 часа):

+

{link}

+

Размер архива: {ms.Length / 1024:N0} KB

+

Если вы не запрашивали экспорт — сообщите администратору организации.

"; + try + { + await _email.SendAsync(rec.NotifyEmail, "Ваш экспорт данных Food Market готов", body, ct); + } + catch (Exception ex) + { + _log.LogWarning(ex, "OrgExport email-notify не отправилось (export всё равно готов)"); + } + } + } + catch (Exception ex) + { + _log.LogError(ex, "OrgExport failed: {Id}", exportId); + rec.Status = OrgExportStatus.Failed; + rec.CompletedAt = DateTime.UtcNow; + rec.Error = ex.Message.Length > 2000 ? ex.Message[..2000] : ex.Message; + await _db.SaveChangesAsync(CancellationToken.None); + } + } + + private static async Task WriteJsonEntry(ZipArchive zip, string name, object payload, + JsonSerializerOptions opts, CancellationToken ct) + { + var entry = zip.CreateEntry(name, CompressionLevel.Optimal); + await using var s = entry.Open(); + await JsonSerializer.SerializeAsync(s, payload, opts, ct); + } + + private static async Task WriteCollection(ZipArchive zip, string name, + IQueryable q, JsonSerializerOptions opts, CancellationToken ct) + { + var items = await q.AsNoTracking().ToListAsync(ct); + await WriteJsonEntry(zip, name, items, opts, ct); + } + + private static string GenerateToken() + { + var bytes = new byte[32]; + System.Security.Cryptography.RandomNumberGenerator.Fill(bytes); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } +} diff --git a/src/food-market.api/Controllers/Admin/MoySkladSyncStatusController.cs b/src/food-market.api/Controllers/Admin/MoySkladSyncStatusController.cs new file mode 100644 index 0000000..4f9eb26 --- /dev/null +++ b/src/food-market.api/Controllers/Admin/MoySkladSyncStatusController.cs @@ -0,0 +1,101 @@ +using foodmarket.Api.Infrastructure.Authorization; +using foodmarket.Application.Common.Tenancy; +using foodmarket.Domain.Integrations; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.Admin; + +/// Sprint 22: MoySklad sync-status endpoint для будущего +/// dashboard-виджета. +/// +/// Возвращает агрегат по `import_jobs` где Kind in ('products', +/// 'counterparties'): последний успешный sync, кол-во ошибок за 7 дней, +/// очередь pending (Running). Multi-tenant — query-filter применяется. +/// +/// Stub-режим: если у org'а MoySkladToken не настроен — возвращаем +/// `{ configured: false }` с пустыми счётчиками. +[ApiController] +[Authorize] +[Route("api/moysklad")] +public class MoySkladSyncStatusController : ControllerBase +{ + private readonly AppDbContext _db; + private readonly ITenantContext _tenant; + + public MoySkladSyncStatusController(AppDbContext db, ITenantContext tenant) + { + _db = db; + _tenant = tenant; + } + + public record SyncStatusResponse( + bool Configured, + DateTime? LastSuccessAt, + int ErrorCountLast7Days, + int PendingCount, + IReadOnlyDictionary ByKind); + + public record KindStatus( + DateTime? LastRunAt, + DateTime? LastSuccessAt, + ImportJobStatus? LastStatus, + int Last7DaysRuns, + int Last7DaysSucceeded, + int Last7DaysFailed); + + [HttpGet("sync-status"), RequiresPermission("OrgSettingsManage")] + public async Task> Get(CancellationToken ct) + { + var orgId = _tenant.OrganizationId; + if (orgId is null) return Forbid(); + + var org = await _db.Organizations.AsNoTracking() + .Where(o => o.Id == orgId) + .Select(o => new { o.MoySkladToken }) + .FirstOrDefaultAsync(ct); + var configured = !string.IsNullOrWhiteSpace(org?.MoySkladToken); + + var weekAgo = DateTime.UtcNow.AddDays(-7); + var kinds = new[] { "products", "counterparties" }; + var byKind = new Dictionary(); + + foreach (var kind in kinds) + { + var jobs = await _db.ImportJobs.AsNoTracking() + .Where(j => j.Kind == kind && j.StartedAt >= weekAgo) + .OrderByDescending(j => j.StartedAt) + .Select(j => new { j.StartedAt, j.FinishedAt, j.Status }) + .ToListAsync(ct); + var lastRun = jobs.FirstOrDefault(); + var lastSuccess = jobs.FirstOrDefault(j => j.Status == ImportJobStatus.Succeeded); + byKind[kind] = new KindStatus( + LastRunAt: lastRun?.StartedAt, + LastSuccessAt: lastSuccess?.StartedAt, + LastStatus: lastRun?.Status, + Last7DaysRuns: jobs.Count, + Last7DaysSucceeded: jobs.Count(j => j.Status == ImportJobStatus.Succeeded), + Last7DaysFailed: jobs.Count(j => j.Status == ImportJobStatus.Failed)); + } + + var allLastSuccess = byKind.Values + .Where(k => k.LastSuccessAt is not null) + .Select(k => k.LastSuccessAt!.Value) + .DefaultIfEmpty() + .Max(); + var errorTotal = byKind.Values.Sum(k => k.Last7DaysFailed); + var pending = await _db.ImportJobs.AsNoTracking() + .Where(j => j.Kind == "products" || j.Kind == "counterparties") + .Where(j => j.Status == ImportJobStatus.Running) + .CountAsync(ct); + + return new SyncStatusResponse( + Configured: configured, + LastSuccessAt: allLastSuccess == default ? null : allLastSuccess, + ErrorCountLast7Days: errorTotal, + PendingCount: pending, + ByKind: byKind); + } +} diff --git a/src/food-market.api/Controllers/Admin/OrgAuditLogController.cs b/src/food-market.api/Controllers/Admin/OrgAuditLogController.cs index 2a60da4..92616ec 100644 --- a/src/food-market.api/Controllers/Admin/OrgAuditLogController.cs +++ b/src/food-market.api/Controllers/Admin/OrgAuditLogController.cs @@ -68,4 +68,77 @@ public record AuditRow( DateTimeKind.Local => d.Value.ToUniversalTime(), _ => DateTime.SpecifyKind(d.Value, DateTimeKind.Utc), }; + + /// Sprint 22: streaming-export audit-log для compliance / + /// расследований. Multi-tenant — query-filter применяется; SuperAdmin + /// видит всё через IgnoreQueryFilters (если передан orgId). + /// + /// Streams CSV или JSONL прямо в Response.Body — БД не материализуется + /// в память (важно для периодов с миллионами записей). + /// + /// Параметры: + /// format: "csv" | "jsonl" (default csv) + /// from/to: DateTime? — UTC границы + /// entityType: string? — фильтр по типу + /// userId: Guid? — фильтр по пользователю + [HttpPost("export"), RequiresPermission("OrgSettingsManage")] + public async Task ExportAsync( + [FromQuery] string? format, + [FromQuery] DateTime? from, + [FromQuery] DateTime? to, + [FromQuery] string? entityType, + [FromQuery] Guid? userId, + CancellationToken ct) + { + var fmt = (format ?? "csv").ToLowerInvariant(); + if (fmt != "csv" && fmt != "jsonl") + { + Response.StatusCode = 400; + await Response.WriteAsync($"{{\"error\":\"format должен быть csv или jsonl, не '{format}'\"}}", ct); + return; + } + + var q = _db.OrgAuditLogs.AsNoTracking().AsQueryable(); + if (!string.IsNullOrWhiteSpace(entityType)) q = q.Where(l => l.EntityType == entityType); + if (userId is not null) q = q.Where(l => l.UserId == userId); + if (AsUtc(from) is { } f) q = q.Where(l => l.CreatedAt >= f); + if (AsUtc(to) is { } t) q = q.Where(l => l.CreatedAt <= t); + + var fname = $"audit-log-{DateTime.UtcNow:yyyyMMdd-HHmmss}.{fmt}"; + Response.Headers["Content-Disposition"] = $"attachment; filename=\"{fname}\""; + + if (fmt == "csv") + { + Response.ContentType = "text/csv; charset=utf-8"; + // UTF-8 BOM чтобы Excel-RU не путался. + await Response.Body.WriteAsync(System.Text.Encoding.UTF8.GetPreamble(), ct); + await using var w = new StreamWriter(Response.Body, System.Text.Encoding.UTF8, leaveOpen: true); + await w.WriteLineAsync("CreatedAt;UserId;Action;EntityType;EntityId;ChangesJson"); + // Стримим стрейтом из EF без полной материализации. + await foreach (var l in q.OrderBy(l => l.CreatedAt).AsAsyncEnumerable().WithCancellation(ct)) + { + var changes = (l.ChangesJson ?? "").Replace("\"", "\"\"").Replace("\n", " "); + await w.WriteLineAsync( + $"{l.CreatedAt:O};{l.UserId?.ToString() ?? ""};{l.Action};{l.EntityType};{l.EntityId?.ToString() ?? ""};\"{changes}\""); + } + } + else // jsonl + { + Response.ContentType = "application/x-ndjson; charset=utf-8"; + await using var w = new StreamWriter(Response.Body, System.Text.Encoding.UTF8, leaveOpen: true); + await foreach (var l in q.OrderBy(l => l.CreatedAt).AsAsyncEnumerable().WithCancellation(ct)) + { + var line = System.Text.Json.JsonSerializer.Serialize(new + { + createdAt = l.CreatedAt, + userId = l.UserId, + action = l.Action, + entityType = l.EntityType, + entityId = l.EntityId, + changesJson = l.ChangesJson, + }); + await w.WriteLineAsync(line); + } + } + } } diff --git a/src/food-market.api/Controllers/Catalog/ProductsController.cs b/src/food-market.api/Controllers/Catalog/ProductsController.cs index c1cebab..d6faac4 100644 --- a/src/food-market.api/Controllers/Catalog/ProductsController.cs +++ b/src/food-market.api/Controllers/Catalog/ProductsController.cs @@ -521,6 +521,173 @@ public record CsvImportRequest(IReadOnlyList Rows, bool AutoCreat public record CsvImportRowError(int Row, string Error); public record CsvImportResponse(int Created, IReadOnlyList Errors, IReadOnlyList Ids); + /// Sprint 22: импорт каталога из 1С (Бухгалтерия / УТ / Розница). + /// + /// Принимает multipart CSV (выгрузка «Цены номенклатуры» или «Номенклатура») + /// или text/plain в body. Заголовок 1С: + /// "Артикул","Наименование","Единица","Цена","Группа","Штрихкод" + /// Допустимые альтернативы (case-insensitive): "Code", "Name", "Unit", + /// "Price", "Category", "Barcode". Разделитель CSV — `;` (1С Excel-RU + /// дефолт). Кодировка — Windows-1251 ИЛИ UTF-8 with BOM (детектим по + /// первым байтам). + /// + /// Endpoint конвертирует 1С-формат в CsvProductRow и делегирует на + /// существующий ImportCsv, чтобы не дублировать транзакционную логику. + public record OneCImportResponse(int Created, int Skipped, IReadOnlyList Errors, IReadOnlyList Ids); + + [HttpPost("import/1c-csv"), RequiresPermission("ProductsEdit")] + [Microsoft.AspNetCore.Mvc.Consumes("text/csv", "text/plain", "application/octet-stream", "multipart/form-data")] + public async Task> Import1cCsv( + [FromQuery] bool autoCreateGroup = true, CancellationToken ct = default) + { + // Body может быть text/csv напрямую либо multipart (form file). Поддерживаем оба. + string text; + if (Request.HasFormContentType && Request.Form.Files.Count > 0) + { + var file = Request.Form.Files[0]; + using var sr = new StreamReader(file.OpenReadStream(), DetectEncoding(file.OpenReadStream())); + text = await sr.ReadToEndAsync(ct); + } + else + { + // Прочитаем raw bytes, потом decoder. + using var ms = new MemoryStream(); + await Request.Body.CopyToAsync(ms, ct); + ms.Position = 0; + using var sr = new StreamReader(ms, DetectEncoding(ms)); + text = await sr.ReadToEndAsync(ct); + } + + if (string.IsNullOrWhiteSpace(text)) + return BadRequest(new { error = "Пустое тело запроса." }); + + var (rows, parseErrs) = Parse1cCsv(text); + if (parseErrs.Count > 0) + return new OneCImportResponse(0, 0, parseErrs, []); + + // Делегируем на универсальный ImportCsv (валидация + транзакция там). + var inner = new CsvImportRequest(rows, autoCreateGroup); + var result = await ImportCsv(inner, ct); + if (result.Result is BadRequestObjectResult br) return br; + if (result.Value is null) return Problem("import-csv вернул null"); + var v = result.Value; + return new OneCImportResponse(v.Created, rows.Count - v.Created, v.Errors, v.Ids); + } + + /// Детект кодировки: BOM UTF-8 → utf-8; иначе предполагаем + /// Windows-1251 (стандарт 1С). После чтения первых байтов перематываем + /// поток в начало. + private static System.Text.Encoding DetectEncoding(Stream s) + { + try + { + // Регистрируем codepage providers (Windows-1251 не в default-пуле .NET 8). + System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance); + } + catch { /* идемпотентно */ } + if (!s.CanSeek) + return new System.Text.UTF8Encoding(true); + var pos = s.Position; + var buf = new byte[3]; + var n = s.Read(buf, 0, 3); + s.Position = pos; + if (n >= 3 && buf[0] == 0xEF && buf[1] == 0xBB && buf[2] == 0xBF) + return System.Text.Encoding.UTF8; + try + { + return System.Text.Encoding.GetEncoding("windows-1251"); + } + catch + { + return System.Text.Encoding.UTF8; + } + } + + /// Парсер 1С-CSV → CsvProductRow. Возвращает (rows, errors). + /// Колонки матчатся по русскому имени case-insensitive или по + /// английскому (Code/Name/Unit/Price/Category/Barcode). + private static (List, List) Parse1cCsv(string text) + { + var errors = new List(); + var rows = new List(); + var lines = text.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None) + .Where(l => !string.IsNullOrWhiteSpace(l)).ToList(); + if (lines.Count == 0) return (rows, errors); + + // Разделитель: `;` если в header'е больше `;` чем `,`, иначе `,`. + var headerLine = lines[0]; + var delim = headerLine.Count(c => c == ';') >= headerLine.Count(c => c == ',') ? ';' : ','; + var headers = headerLine.Split(delim).Select(h => h.Trim().Trim('"').ToLowerInvariant()).ToArray(); + + // Маппинг русских → канонические имена. + int Idx(params string[] aliases) + { + foreach (var a in aliases) + { + var i = Array.IndexOf(headers, a.ToLowerInvariant()); + if (i >= 0) return i; + } + return -1; + } + var iArt = Idx("артикул", "code", "article"); + var iName = Idx("наименование", "name", "title"); + var iUnit = Idx("единица", "unit", "ед", "ед.изм."); + var iPrice = Idx("цена", "price", "розничная цена", "цена розничная"); + var iGroup = Idx("группа", "category", "категория", "родитель"); + var iBarcode = Idx("штрихкод", "barcode", "штрих-код"); + + if (iName < 0) + { + errors.Add(new CsvImportRowError(0, "Не найдена обязательная колонка 'Наименование' (или 'name').")); + return (rows, errors); + } + + for (var i = 1; i < lines.Count; i++) + { + var cells = lines[i].Split(delim).Select(c => c.Trim().Trim('"')).ToArray(); + string get(int idx) => idx < 0 || idx >= cells.Length ? "" : cells[idx]; + + var name = get(iName); + if (string.IsNullOrWhiteSpace(name)) continue; + + decimal? price = null; + var priceRaw = get(iPrice).Replace(",", ".").Replace(" ", ""); + if (!string.IsNullOrEmpty(priceRaw) && decimal.TryParse(priceRaw, + System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var p)) + price = p; + + rows.Add(new CsvProductRow( + Name: name, + Price: price, + UnitCode: NormalizeUnit(get(iUnit)), + GroupName: get(iGroup), + Barcode: get(iBarcode))); + } + + return (rows, errors); + } + + /// 1С пишет «шт»/«шт.»/«штука»/«PCS» — нормализуем к коду + /// в БД (см. UnitsOfMeasure.Code). Если не распознали — возвращаем + /// сырую строку; ImportCsv упадёт на UnitCode-lookup'е если не + /// найдёт, и распишет ошибки построчно. + private static string? NormalizeUnit(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) return null; + var t = raw.Trim().Trim('.').ToLowerInvariant(); + return t switch + { + "шт" or "штука" or "штук" or "pcs" or "ea" => "шт", + "кг" or "kg" or "килограмм" => "кг", + "г" or "g" or "грамм" => "г", + "л" or "l" or "литр" => "л", + "мл" or "ml" or "миллилитр" => "мл", + "м" or "m" or "метр" => "м", + "упак" or "уп" or "pack" => "упак", + _ => raw, + }; + } + [HttpPost("import-csv"), RequiresPermission("ProductsEdit")] public async Task> ImportCsv( [FromBody] CsvImportRequest req, CancellationToken ct) diff --git a/src/food-market.api/Controllers/Organizations/OrgExportController.cs b/src/food-market.api/Controllers/Organizations/OrgExportController.cs new file mode 100644 index 0000000..4a9c245 --- /dev/null +++ b/src/food-market.api/Controllers/Organizations/OrgExportController.cs @@ -0,0 +1,143 @@ +using System.Security.Claims; +using foodmarket.Api.Background; +using foodmarket.Api.Infrastructure.Authorization; +using foodmarket.Api.Storage; +using foodmarket.Application.Common.Tenancy; +using foodmarket.Domain.Common; +using foodmarket.Infrastructure.Persistence; +using Hangfire; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.Organizations; + +/// Sprint 22: GDPR-export данных организации в ZIP. +/// +/// Endpoints: +/// POST /api/org/export — создать новый экспорт (admin org'а) +/// GET /api/org/export — список своих экспортов +/// GET /api/org/export/{id} — статус конкретного +/// GET /api/org/export/download/{token} — скачать (анонимно по токену) +/// +/// Multi-tenant: создание + список ограничены org'ом юзера через +/// ITenantContext. Download — по токену (без авторизации), поэтому +/// безопасность обеспечивается длиной и случайностью токена (32 байта = +/// 256 бит → невозможно подобрать). +[ApiController] +[Route("api/org/export")] +public class OrgExportController : ControllerBase +{ + private readonly AppDbContext _db; + private readonly ITenantContext _tenant; + private readonly IObjectStorage _storage; + private readonly IBackgroundJobClient _jobs; + + public OrgExportController(AppDbContext db, ITenantContext tenant, + IObjectStorage storage, IBackgroundJobClient jobs) + { + _db = db; _tenant = tenant; _storage = storage; _jobs = jobs; + } + + public record ExportDto(Guid Id, OrgExportStatus Status, DateTime CreatedAt, + DateTime? StartedAt, DateTime? CompletedAt, long? SizeBytes, + string? DownloadUrl, DateTime? DownloadExpiresAt, string? Error); + + /// Создать новый экспорт. Возвращает 202 + Id; полезно + /// сразу polled'ить GET /api/org/export/{id} до Status=Ready. + [HttpPost, Authorize, RequiresPermission("OrgSettingsManage")] + public async Task> Create(CancellationToken ct) + { + var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("Нет tenant'a"); + var userId = ParseUserId(); + if (userId is null) return Forbid(); + + // Anti-spam: не больше 3 одновременных Running/Pending на org'у. + var inFlight = await _db.OrgExports + .Where(x => x.Status == OrgExportStatus.Pending || x.Status == OrgExportStatus.Running) + .CountAsync(ct); + if (inFlight >= 3) + return Conflict(new { error = "Уже в очереди 3+ экспорта. Подождите завершения." }); + + // Notify email — текущий пользователь. + var email = User.FindFirstValue(ClaimTypes.Email) ?? User.FindFirstValue("email"); + + var rec = new OrgExport + { + OrganizationId = orgId, + RequestedByUserId = userId.Value, + Status = OrgExportStatus.Pending, + NotifyEmail = email, + }; + _db.OrgExports.Add(rec); + await _db.SaveChangesAsync(ct); + + _jobs.Enqueue(j => j.RunAsync(rec.Id, CancellationToken.None)); + + return Accepted(ToDto(rec)); + } + + [HttpGet, Authorize, RequiresPermission("OrgSettingsManage")] + public async Task>> List(CancellationToken ct) + { + var items = await _db.OrgExports.AsNoTracking() + .OrderByDescending(x => x.CreatedAt) + .Take(50) + .ToListAsync(ct); + return items.Select(ToDto).ToList(); + } + + [HttpGet("{id:guid}"), Authorize, RequiresPermission("OrgSettingsManage")] + public async Task> Get(Guid id, CancellationToken ct) + { + var rec = await _db.OrgExports.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); + return rec is null ? NotFound() : ToDto(rec); + } + + /// Anonymous download по токену. Не требует авторизации — + /// security через 256-битный random token + TTL 24h. + [HttpGet("download/{token}"), AllowAnonymous] + public async Task Download(string token, CancellationToken ct) + { + if (string.IsNullOrEmpty(token) || token.Length != 64) + return NotFound(); + // IgnoreQueryFilters — download cross-tenant by design (token-protected). + var rec = await _db.OrgExports.IgnoreQueryFilters() + .FirstOrDefaultAsync(x => x.DownloadToken == token, ct); + if (rec is null) return NotFound(); + if (rec.Status != OrgExportStatus.Ready) + return BadRequest(new { error = $"Export ещё не готов (status={rec.Status})" }); + if (rec.DownloadTokenExpiresAt is null || rec.DownloadTokenExpiresAt < DateTime.UtcNow) + { + rec.Status = OrgExportStatus.Expired; + await _db.SaveChangesAsync(ct); + return BadRequest(new { error = "Token expired. Запросите экспорт заново." }); + } + if (string.IsNullOrEmpty(rec.StorageKey)) + return BadRequest(new { error = "Storage key empty (internal error)" }); + + var obj = await _storage.OpenAsync(rec.StorageKey, ct); + if (obj is null) return NotFound(new { error = "Файл не найден в хранилище (возможно, удалён по retention)" }); + + var fileName = $"food-market-export-{rec.OrganizationId:N}-{rec.CompletedAt:yyyyMMdd-HHmmss}.zip"; + return File(obj.Value.Stream, "application/zip", fileName); + } + + private ExportDto ToDto(OrgExport e) + { + string? url = null; + if (e.Status == OrgExportStatus.Ready && !string.IsNullOrEmpty(e.DownloadToken) + && e.DownloadTokenExpiresAt is not null && e.DownloadTokenExpiresAt > DateTime.UtcNow) + { + url = $"/api/org/export/download/{e.DownloadToken}"; + } + return new ExportDto(e.Id, e.Status, e.CreatedAt, e.StartedAt, e.CompletedAt, + e.SizeBytes, url, e.DownloadTokenExpiresAt, e.Error); + } + + private Guid? ParseUserId() + { + var raw = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"); + return Guid.TryParse(raw, out var id) ? id : null; + } +} diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index 3c028d9..fc0e611 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -410,6 +410,9 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme // Sprint 20: DB VACUUM ANALYZE + disk monitoring. builder.Services.AddScoped(); builder.Services.AddScoped(); + // Sprint 22: GDPR org export + DB schema docs. + builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); // Telegram-бот владельца. Token + username берём из конфига; если token diff --git a/src/food-market.domain/Common/OrgExport.cs b/src/food-market.domain/Common/OrgExport.cs new file mode 100644 index 0000000..53c646d --- /dev/null +++ b/src/food-market.domain/Common/OrgExport.cs @@ -0,0 +1,42 @@ +using foodmarket.Domain.Common; + +namespace foodmarket.Domain.Common; + +public enum OrgExportStatus +{ + Pending = 0, + Running = 1, + Ready = 2, + Failed = 3, + Expired = 4, +} + +/// Sprint 22: запись о GDPR-export'е организации. +/// +/// Создаётся при `POST /api/org/export`, обрабатывается фоновым +/// Hangfire-job'ом (см. OrgExportJob). После генерации ZIP-файла +/// в хранилище — Status=Ready, в DownloadToken записан random'ый +/// 32-байтный hex, по которому можно скачать архив анонимно +/// (`GET /api/org/export/download/{token}`) до DownloadTokenExpiresAt. +/// +/// Файл удаляется через 7 дней (Hangfire-cleanup job) — даже если токен +/// ещё валиден; токен только для контроля доступа в первые 24h. +/// +/// Multi-tenant: TenantEntity → query-filter применяется. +/// Per-user: RequestedByUserId — кто запросил (для audit). +public class OrgExport : TenantEntity +{ + public Guid RequestedByUserId { get; set; } + public OrgExportStatus Status { get; set; } + public DateTime? StartedAt { get; set; } + public DateTime? CompletedAt { get; set; } + /// Storage key (для IObjectStorage) — относительный путь к ZIP. + public string? StorageKey { get; set; } + public long? SizeBytes { get; set; } + /// Hex-токен 32 байта (64 char) для download endpoint'a. + public string? DownloadToken { get; set; } + public DateTime? DownloadTokenExpiresAt { get; set; } + /// Email на который отправили download link (= email юзера). + public string? NotifyEmail { get; set; } + public string? Error { get; set; } +} diff --git a/src/food-market.infrastructure/Persistence/AppDbContext.cs b/src/food-market.infrastructure/Persistence/AppDbContext.cs index 7ab5f27..9045738 100644 --- a/src/food-market.infrastructure/Persistence/AppDbContext.cs +++ b/src/food-market.infrastructure/Persistence/AppDbContext.cs @@ -78,6 +78,7 @@ public AppDbContext(DbContextOptions options, ITenantContext tenan public DbSet OrgAuditLogs => Set(); public DbSet ImportJobs => Set(); public DbSet UserPresets => Set(); + public DbSet OrgExports => Set(); /// Если true — не пишет audit-строки /// для этого SaveChanges. Используется сидерами/миграциями, фоновыми @@ -154,6 +155,17 @@ protected override void OnModelCreating(ModelBuilder builder) b.HasIndex(x => new { x.OrganizationId, x.UserId, x.CreatedAt }); }); + builder.Entity(b => + { + b.ToTable("org_exports"); + b.Property(x => x.StorageKey).HasMaxLength(500); + b.Property(x => x.DownloadToken).HasMaxLength(128); + b.Property(x => x.NotifyEmail).HasMaxLength(200); + b.Property(x => x.Error).HasMaxLength(2000); + b.HasIndex(x => new { x.OrganizationId, x.CreatedAt }); + b.HasIndex(x => x.DownloadToken).IsUnique(); // быстрый lookup по download token + }); + builder.Entity(b => { b.ToTable("user_presets"); diff --git a/src/food-market.infrastructure/Persistence/Migrations/20260607220000_Phase22a_OrgExports.cs b/src/food-market.infrastructure/Persistence/Migrations/20260607220000_Phase22a_OrgExports.cs new file mode 100644 index 0000000..e30c286 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20260607220000_Phase22a_OrgExports.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Phase22a — org_exports таблица для GDPR-ready экспорта + /// данных организации. Unique index на DownloadToken для O(1) lookup + /// в download-endpoint'е. PartialIndex по org+createdAt — для UI + /// «история моих экспортов». + [DbContext(typeof(AppDbContext))] + [Migration("20260607220000_Phase22a_OrgExports")] + public partial class Phase22a_OrgExports : Migration + { + protected override void Up(MigrationBuilder b) + { + b.Sql(@" + CREATE TABLE IF NOT EXISTS public.org_exports ( + ""Id"" uuid PRIMARY KEY, + ""OrganizationId"" uuid NOT NULL, + ""RequestedByUserId"" uuid NOT NULL, + ""Status"" int NOT NULL DEFAULT 0, + ""StartedAt"" timestamp with time zone NULL, + ""CompletedAt"" timestamp with time zone NULL, + ""StorageKey"" varchar(500) NULL, + ""SizeBytes"" bigint NULL, + ""DownloadToken"" varchar(128) NULL, + ""DownloadTokenExpiresAt"" timestamp with time zone NULL, + ""NotifyEmail"" varchar(200) NULL, + ""Error"" varchar(2000) NULL, + ""CreatedAt"" timestamp with time zone NOT NULL DEFAULT now() + ); + + CREATE INDEX IF NOT EXISTS ""IX_org_exports_Org_CreatedAt"" + ON public.org_exports (""OrganizationId"", ""CreatedAt"" DESC); + CREATE UNIQUE INDEX IF NOT EXISTS ""IX_org_exports_DownloadToken"" + ON public.org_exports (""DownloadToken"") + WHERE ""DownloadToken"" IS NOT NULL; + "); + } + + protected override void Down(MigrationBuilder b) + { + b.Sql("DROP TABLE IF EXISTS public.org_exports;"); + } + } +}