feat(s22): data tooling — export/import + schema docs + anon dump (7 пунктов)
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions

1. GDPR org export — domain OrgExport + Phase22a миграция, OrgExportJob
   собирает ZIP с JSON по каждой сущности через IObjectStorage,
   DownloadToken 64-hex + 24h TTL + email-notify.
   POST /api/org/export, GET /api/org/export[/{id}], GET download/{token}.

2. 1C CSV import — POST /api/catalog/products/import/1c-csv:
   Windows-1251/UTF-8 BOM auto-detect, разделитель ;/, русские заголовки
   (Артикул/Наименование/Единица/Цена/Группа/Штрихкод) или английские.
   Нормализация unit-кодов (шт/кг/г/л/мл/упак). Делегирует на ImportCsv
   (транзакция, multi-tenant). docs/imports.md.

3. deploy/anonymize-prod.sh — pg_dump прода → restore во временную БД →
   UPDATE PII (email→user{N}@example.kz, phone→+7700111{N:04}, password→
   тестовый hash, BIN/IIN синтетические, MoySkladToken=NULL, аудиты
   TRUNCATE) → pg_dump → gz файл.

4. DbSchemaDocsJob (weekly вс 05:00 UTC) — information_schema → md с
   таблицами + колонками + FK + mermaid ER-диаграммой (топ-20 таблиц).
   Сохраняет в content-root db-schema-generated.md.

5. POST /api/admin/audit-log/export?format=csv|jsonl — streaming через
   AsAsyncEnumerable. UTF-8 BOM для CSV, JSONL для grep'a. Multi-tenant.

6. GET /api/moysklad/sync-status — агрегат по import_jobs:
   { configured, lastSuccessAt, errorCountLast7Days, pendingCount,
     byKind: { products: KindStatus, counterparties: KindStatus } }.
   Stub если MoySkladToken=null.

7. docs/ARCHITECTURE.md — финальный итог 22 спринтов:
   - Sprint 13-22 changes-сводка
   - «Реализовано полностью» секция
   - «Scaffolding» таблица с указанием что нужно от user'а
   - «Не реализовано» секция (прод, SSO callback, KZ-перевод, POS-тест)
   - Актуальная файловая структура

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-06-07 23:00:54 +05:00
parent 843fc4bd03
commit aa83f82dc5
15 changed files with 1346 additions and 1 deletions

185
deploy/anonymize-prod.sh Executable file
View file

@ -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 <conn-uri>] [--target <conn-uri>]
# [--out <file>] [--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 <<SQL
-- 1. AspNetUsers (Identity): email + phone + password hash + security stamp
WITH numbered AS (
SELECT "Id", row_number() OVER (ORDER BY "Id") AS rn FROM "AspNetUsers"
)
UPDATE "AspNetUsers" u
SET
"Email" = 'user' || n.rn || '@example.kz',
"NormalizedEmail" = upper('user' || n.rn || '@example.kz'),
"UserName" = 'user' || n.rn || '@example.kz',
"NormalizedUserName" = upper('user' || n.rn || '@example.kz'),
"PhoneNumber" = '+7700111' || lpad(n.rn::text, 4, '0'),
"PasswordHash" = '$TEST_PASS_HASH',
"SecurityStamp" = encode(gen_random_bytes(16), 'hex'),
"ConcurrencyStamp" = gen_random_uuid()::text
FROM numbered n
WHERE u."Id" = n."Id";
-- 2. employees — рабочий email/телефон + полное имя
WITH numbered AS (
SELECT "Id", row_number() OVER (ORDER BY "Id") AS rn FROM employees
)
UPDATE employees e
SET
"Email" = CASE WHEN e."Email" IS NOT NULL THEN 'emp' || n.rn || '@example.kz' END,
"Phone" = CASE WHEN e."Phone" IS NOT NULL THEN '+7700222' || lpad(n.rn::text, 4, '0') END,
"FirstName" = 'Тест',
"LastName" = 'Тестов' || n.rn,
"MiddleName" = NULL,
"TaxNumber" = NULL
FROM numbered n
WHERE e."Id" = n."Id";
-- 3. counterparties — БИН/ИИН/имена/контакты
WITH numbered AS (
SELECT "Id", row_number() OVER (ORDER BY "Id") AS rn FROM counterparties
)
UPDATE counterparties c
SET
"Name" = 'Контрагент-' || n.rn,
"LegalName" = CASE WHEN c."LegalName" IS NOT NULL THEN 'ТОО Контрагент-' || n.rn END,
-- Синтетические BIN/IIN (12 цифр, не валидируем checksum здесь).
"Bin" = CASE WHEN c."Bin" IS NOT NULL THEN lpad(n.rn::text, 12, '9') END,
"Iin" = CASE WHEN c."Iin" IS NOT NULL THEN lpad(n.rn::text, 12, '8') END,
"Phone" = CASE WHEN c."Phone" IS NOT NULL THEN '+7700333' || lpad(n.rn::text, 4, '0') END,
"Email" = CASE WHEN c."Email" IS NOT NULL THEN 'cp' || n.rn || '@example.kz' END,
"Address" = CASE WHEN c."Address" IS NOT NULL THEN 'г. Тестовый, ул. Тестовая ' || n.rn END,
"BankAccount" = CASE WHEN c."BankAccount" IS NOT NULL THEN 'KZ000000' || lpad(n.rn::text, 14, '0') END,
"ContactPerson" = CASE WHEN c."ContactPerson" IS NOT NULL THEN 'Контакт ' || n.rn END,
"Notes" = NULL
FROM numbered n
WHERE c."Id" = n."Id";
-- 4. organizations: имя/БИН/телефон владельца/MoySkladToken
UPDATE organizations
SET
"MoySkladToken" = NULL,
"OwnerTelegramChatId" = NULL,
"Bin" = CASE WHEN "Bin" IS NOT NULL THEN '700700700700' END,
"Name" = 'TestOrg-' || substr("Id"::text, 1, 8);
-- 5. refresh tokens revoke all (чтобы старые stage-токены не работали)
UPDATE "OpenIddictTokens" SET "Status" = 'revoked' WHERE "Status" = 'valid';
-- 6. чистим аудит-логи и feedback (могут содержать персональные тексты)
TRUNCATE TABLE org_audit_log;
TRUNCATE TABLE super_admin_audit_log;
SQL
log "anonymize done"
fi
# ── 4. pg_dump anonymized → out ──────────────────────────────────────
log "Step 4/5: pg_dump $TARGET_DB$OUT"
if [[ $DRY_RUN -eq 0 ]]; then
pg_dump -h "$LOCAL_HOST" -p "$LOCAL_PORT" -U "$LOCAL_USER" -d "$TARGET_DB" \
--no-owner --no-privileges --clean --if-exists \
| gzip > "$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

View file

@ -373,13 +373,94 @@ Post-операции, изменяющие остаток, идут под `Iso
по спринтам. по спринтам.
- **Load** (`tests/load/`) — k6 (Sprint 12). См. `docs/performance-baseline.md`. - **Load** (`tests/load/`) — k6 (Sprint 12). См. `docs/performance-baseline.md`.
### Sprint 13-15 changes (быстрая сводка) ### Sprint 13-22 changes (быстрая сводка)
| Sprint | Что добавлено / изменено | | 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). | | **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 при загрузке + `<ProductImage>` с `<picture>` srcset; Npgsql pool (Min=10/Max=100/AutoPrepare=20); `JobTimingFilter` для Hangfire-jobs. | | **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 при загрузке + `<ProductImage>` с `<picture>` 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. | | **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<TDto>(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, disk5GB, /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<YYYYMMDD>.<N>). Все скрипты — `--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
```
## Релиз-цикл ## Релиз-цикл

91
docs/imports.md Normal file
View file

@ -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-токен МойСклада).

35
docs/sprint22-progress.md Normal file
View file

@ -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.

View file

@ -0,0 +1,154 @@
using System.Text;
using foodmarket.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Background;
/// <summary>Sprint 22: weekly-job который генерирует
/// <c>docs/db-schema.md</c> с актуальным списком таблиц + колонок +
/// 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.</summary>
public class DbSchemaDocsJob
{
private readonly AppDbContext _db;
private readonly IConfiguration _cfg;
private readonly ILogger<DbSchemaDocsJob> _log;
private readonly IWebHostEnvironment _env;
public DbSchemaDocsJob(AppDbContext db, IConfiguration cfg, ILogger<DbSchemaDocsJob> log,
IWebHostEnvironment env)
{
_db = db; _cfg = cfg; _log = log; _env = env;
}
public async Task<string> 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<ColumnInfo>($@"
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<FkInfo>($@"
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);
}

View file

@ -79,6 +79,14 @@ public Task StartAsync(CancellationToken ct)
cronExpression: cronDisk, cronExpression: cronDisk,
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); 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<DbSchemaDocsJob>(
recurringJobId: "db-schema-docs",
methodCall: j => j.GenerateAsync(CancellationToken.None),
cronExpression: cronSchema,
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
// Email-уведомления: weekly-summary в понедельник 07:00 UTC, // Email-уведомления: weekly-summary в понедельник 07:00 UTC,
// low-stock каждый день в 08:00 UTC (после weekly чтобы не дублить // low-stock каждый день в 08:00 UTC (после weekly чтобы не дублить
// если оба совпадают). Cron-ы конфигурируются — на тестовом стенде // если оба совпадают). Cron-ы конфигурируются — на тестовом стенде

View file

@ -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;
/// <summary>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.</summary>
public class OrgExportJob
{
private readonly AppDbContext _db;
private readonly IObjectStorage _storage;
private readonly IEmailSender _email;
private readonly IConfiguration _cfg;
private readonly ILogger<OrgExportJob> _log;
public OrgExportJob(AppDbContext db, IObjectStorage storage, IEmailSender email,
IConfiguration cfg, ILogger<OrgExportJob> 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 = $@"<p>Ваш экспорт данных Food Market готов.</p>
<p>Скачать (действительно 24 часа):</p>
<p><a href=""{link}"">{link}</a></p>
<p>Размер архива: {ms.Length / 1024:N0} KB</p>
<p>Если вы не запрашивали экспорт сообщите администратору организации.</p>";
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<T>(ZipArchive zip, string name,
IQueryable<T> 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();
}
}

View file

@ -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;
/// <summary>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 }` с пустыми счётчиками.</summary>
[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<string, KindStatus> ByKind);
public record KindStatus(
DateTime? LastRunAt,
DateTime? LastSuccessAt,
ImportJobStatus? LastStatus,
int Last7DaysRuns,
int Last7DaysSucceeded,
int Last7DaysFailed);
[HttpGet("sync-status"), RequiresPermission("OrgSettingsManage")]
public async Task<ActionResult<SyncStatusResponse>> 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<string, KindStatus>();
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);
}
}

View file

@ -68,4 +68,77 @@ public record AuditRow(
DateTimeKind.Local => d.Value.ToUniversalTime(), DateTimeKind.Local => d.Value.ToUniversalTime(),
_ => DateTime.SpecifyKind(d.Value, DateTimeKind.Utc), _ => DateTime.SpecifyKind(d.Value, DateTimeKind.Utc),
}; };
/// <summary>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? — фильтр по пользователю</summary>
[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);
}
}
}
} }

View file

@ -521,6 +521,173 @@ public record CsvImportRequest(IReadOnlyList<CsvProductRow> Rows, bool AutoCreat
public record CsvImportRowError(int Row, string Error); public record CsvImportRowError(int Row, string Error);
public record CsvImportResponse(int Created, IReadOnlyList<CsvImportRowError> Errors, IReadOnlyList<Guid> Ids); public record CsvImportResponse(int Created, IReadOnlyList<CsvImportRowError> Errors, IReadOnlyList<Guid> Ids);
/// <summary>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, чтобы не дублировать транзакционную логику.</summary>
public record OneCImportResponse(int Created, int Skipped, IReadOnlyList<CsvImportRowError> Errors, IReadOnlyList<Guid> Ids);
[HttpPost("import/1c-csv"), RequiresPermission("ProductsEdit")]
[Microsoft.AspNetCore.Mvc.Consumes("text/csv", "text/plain", "application/octet-stream", "multipart/form-data")]
public async Task<ActionResult<OneCImportResponse>> 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);
}
/// <summary>Детект кодировки: BOM UTF-8 → utf-8; иначе предполагаем
/// Windows-1251 (стандарт 1С). После чтения первых байтов перематываем
/// поток в начало.</summary>
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;
}
}
/// <summary>Парсер 1С-CSV → CsvProductRow. Возвращает (rows, errors).
/// Колонки матчатся по русскому имени case-insensitive или по
/// английскому (Code/Name/Unit/Price/Category/Barcode).</summary>
private static (List<CsvProductRow>, List<CsvImportRowError>) Parse1cCsv(string text)
{
var errors = new List<CsvImportRowError>();
var rows = new List<CsvProductRow>();
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);
}
/// <summary>1С пишет «шт»/«шт.»/«штука»/«PCS» — нормализуем к коду
/// в БД (см. UnitsOfMeasure.Code). Если не распознали — возвращаем
/// сырую строку; ImportCsv упадёт на UnitCode-lookup'е если не
/// найдёт, и распишет ошибки построчно.</summary>
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")] [HttpPost("import-csv"), RequiresPermission("ProductsEdit")]
public async Task<ActionResult<CsvImportResponse>> ImportCsv( public async Task<ActionResult<CsvImportResponse>> ImportCsv(
[FromBody] CsvImportRequest req, CancellationToken ct) [FromBody] CsvImportRequest req, CancellationToken ct)

View file

@ -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;
/// <summary>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 бит → невозможно подобрать).</summary>
[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);
/// <summary>Создать новый экспорт. Возвращает 202 + Id; полезно
/// сразу polled'ить GET /api/org/export/{id} до Status=Ready.</summary>
[HttpPost, Authorize, RequiresPermission("OrgSettingsManage")]
public async Task<ActionResult<ExportDto>> 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<OrgExportJob>(j => j.RunAsync(rec.Id, CancellationToken.None));
return Accepted(ToDto(rec));
}
[HttpGet, Authorize, RequiresPermission("OrgSettingsManage")]
public async Task<ActionResult<IReadOnlyList<ExportDto>>> 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<ActionResult<ExportDto>> Get(Guid id, CancellationToken ct)
{
var rec = await _db.OrgExports.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
return rec is null ? NotFound() : ToDto(rec);
}
/// <summary>Anonymous download по токену. Не требует авторизации —
/// security через 256-битный random token + TTL 24h.</summary>
[HttpGet("download/{token}"), AllowAnonymous]
public async Task<IActionResult> 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;
}
}

View file

@ -410,6 +410,9 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
// Sprint 20: DB VACUUM ANALYZE + disk monitoring. // Sprint 20: DB VACUUM ANALYZE + disk monitoring.
builder.Services.AddScoped<foodmarket.Api.Background.DatabaseMaintenanceJobs>(); builder.Services.AddScoped<foodmarket.Api.Background.DatabaseMaintenanceJobs>();
builder.Services.AddScoped<foodmarket.Api.Background.DiskMonitoringJob>(); builder.Services.AddScoped<foodmarket.Api.Background.DiskMonitoringJob>();
// Sprint 22: GDPR org export + DB schema docs.
builder.Services.AddScoped<foodmarket.Api.Background.OrgExportJob>();
builder.Services.AddScoped<foodmarket.Api.Background.DbSchemaDocsJob>();
builder.Services.AddScoped<foodmarket.Api.Background.EmailNotificationJobs>(); builder.Services.AddScoped<foodmarket.Api.Background.EmailNotificationJobs>();
// Telegram-бот владельца. Token + username берём из конфига; если token // Telegram-бот владельца. Token + username берём из конфига; если token

View file

@ -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,
}
/// <summary>Sprint 22: запись о GDPR-export'е организации.
///
/// Создаётся при `POST /api/org/export`, обрабатывается фоновым
/// Hangfire-job'ом (см. <c>OrgExportJob</c>). После генерации 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).</summary>
public class OrgExport : TenantEntity
{
public Guid RequestedByUserId { get; set; }
public OrgExportStatus Status { get; set; }
public DateTime? StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
/// <summary>Storage key (для IObjectStorage) — относительный путь к ZIP.</summary>
public string? StorageKey { get; set; }
public long? SizeBytes { get; set; }
/// <summary>Hex-токен 32 байта (64 char) для download endpoint'a.</summary>
public string? DownloadToken { get; set; }
public DateTime? DownloadTokenExpiresAt { get; set; }
/// <summary>Email на который отправили download link (= email юзера).</summary>
public string? NotifyEmail { get; set; }
public string? Error { get; set; }
}

View file

@ -78,6 +78,7 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
public DbSet<OrgAuditLog> OrgAuditLogs => Set<OrgAuditLog>(); public DbSet<OrgAuditLog> OrgAuditLogs => Set<OrgAuditLog>();
public DbSet<foodmarket.Domain.Integrations.ImportJob> ImportJobs => Set<foodmarket.Domain.Integrations.ImportJob>(); public DbSet<foodmarket.Domain.Integrations.ImportJob> ImportJobs => Set<foodmarket.Domain.Integrations.ImportJob>();
public DbSet<UserPreset> UserPresets => Set<UserPreset>(); public DbSet<UserPreset> UserPresets => Set<UserPreset>();
public DbSet<OrgExport> OrgExports => Set<OrgExport>();
/// <summary>Если true — <see cref="OrgAuditInterceptor"/> не пишет audit-строки /// <summary>Если true — <see cref="OrgAuditInterceptor"/> не пишет audit-строки
/// для этого SaveChanges. Используется сидерами/миграциями, фоновыми /// для этого SaveChanges. Используется сидерами/миграциями, фоновыми
@ -154,6 +155,17 @@ protected override void OnModelCreating(ModelBuilder builder)
b.HasIndex(x => new { x.OrganizationId, x.UserId, x.CreatedAt }); b.HasIndex(x => new { x.OrganizationId, x.UserId, x.CreatedAt });
}); });
builder.Entity<OrgExport>(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<UserPreset>(b => builder.Entity<UserPreset>(b =>
{ {
b.ToTable("user_presets"); b.ToTable("user_presets");

View file

@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Phase22a — org_exports таблица для GDPR-ready экспорта
/// данных организации. Unique index на DownloadToken для O(1) lookup
/// в download-endpoint'е. PartialIndex по org+createdAt — для UI
/// «история моих экспортов».</summary>
[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;");
}
}
}