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
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:
parent
843fc4bd03
commit
aa83f82dc5
185
deploy/anonymize-prod.sh
Executable file
185
deploy/anonymize-prod.sh
Executable 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
|
||||||
|
|
@ -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, 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<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
91
docs/imports.md
Normal 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
35
docs/sprint22-progress.md
Normal 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.
|
||||||
154
src/food-market.api/Background/DbSchemaDocsJob.cs
Normal file
154
src/food-market.api/Background/DbSchemaDocsJob.cs
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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-ы конфигурируются — на тестовом стенде
|
||||||
|
|
|
||||||
201
src/food-market.api/Background/OrgExportJob.cs
Normal file
201
src/food-market.api/Background/OrgExportJob.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
42
src/food-market.domain/Common/OrgExport.cs
Normal file
42
src/food-market.domain/Common/OrgExport.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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;");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue