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`.
|
||||
|
||||
### Sprint 13-15 changes (быстрая сводка)
|
||||
### Sprint 13-22 changes (быстрая сводка)
|
||||
|
||||
| Sprint | Что добавлено / изменено |
|
||||
|---|---|
|
||||
| **13** (security) | `SecurityHeadersMiddleware` (CSP, X-Frame, HSTS); rate-limit на signup (3/h IP) и forgot-password (3/h email + 10/h IP); `SensitiveOpsAudit` сервис для logged-ops; `POST /api/me/sessions/revoke-all` через `IOpenIddictAuthorizationManager`; Hangfire-dashboard под `SuperAdminHangfireFilter` + nginx /hangfire location. Также: dedicated PG-роль `food_market_server_app` для legacy back.food-market.kz (без superuser). |
|
||||
| **14** (perf) | Phase14a индексы (composite + partial с INCLUDE на `retail_sales`); N+1 fix в `SalesReportController.FetchAsync`; React.lazy на 30 редких страниц + Recharts lazy → bundle 1456→706 KB (−51%); ImageSharp генерирует thumb/medium WebP при загрузке + `<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. |
|
||||
| **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,
|
||||
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,
|
||||
// low-stock каждый день в 08:00 UTC (после weekly чтобы не дублить
|
||||
// если оба совпадают). 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(),
|
||||
_ => 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 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")]
|
||||
public async Task<ActionResult<CsvImportResponse>> ImportCsv(
|
||||
[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.
|
||||
builder.Services.AddScoped<foodmarket.Api.Background.DatabaseMaintenanceJobs>();
|
||||
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>();
|
||||
|
||||
// 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<foodmarket.Domain.Integrations.ImportJob> ImportJobs => Set<foodmarket.Domain.Integrations.ImportJob>();
|
||||
public DbSet<UserPreset> UserPresets => Set<UserPreset>();
|
||||
public DbSet<OrgExport> OrgExports => Set<OrgExport>();
|
||||
|
||||
/// <summary>Если true — <see cref="OrgAuditInterceptor"/> не пишет audit-строки
|
||||
/// для этого SaveChanges. Используется сидерами/миграциями, фоновыми
|
||||
|
|
@ -154,6 +155,17 @@ protected override void OnModelCreating(ModelBuilder builder)
|
|||
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 =>
|
||||
{
|
||||
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