feat(cascade): Phase 3 — CascadeCloseService + 409 routing + UI confirm dialog

dict-relationships-v2 epic, Phase 3. Cascade engine + UX guards.

Backend:
- New CascadeCloseService:
  * evaluatePlan(target, key, at) — read-only, splits dependents по
    onClose mode (BLOCK / WARN / CASCADE).
  * executeCascade(target, key, when, reason, confirmed) —
    @Transactional(timeout=30, isolation=SERIALIZABLE) atomic close
    target + all CASCADE deps в one hop (design F.OUT). 503
    x_references_cascade_timeout если tx exceeds 30s, 400
    x_references_cascade_too_large если N > 500.
  * RecordCloseSink interface — test seam над repo+audit+outbox
    mechanics (concrete classes на JDK 25 + Mockito incompat).
- Wired DictionaryRecordService.close():
  * Optional<CascadeCloseService> inject через ctor (back-compat ctor
    для тестов с null deps).
  * Pre-close evaluatePlan: blockers → 409 x_references_blocked_by_dependents,
    cascade non-empty → 409 x_references_cascade_required (просит cascade-close
    endpoint). WARN-only — close проходит.
- Endpoints в DictionaryRecordController:
  * GET /dictionaries/{dict}/records/{key}/cascade-preview — read-only план.
  * POST /dictionaries/{dict}/records/{key}/cascade-close?confirmed=true —
    atomic execute. Existing DELETE остался (теперь учитывает план).

Tests (8 unit tests):
- evaluatePlan empty / split-by-mode (BLOCK/WARN/CASCADE)
- 409 blockers exist (not closed)
- 409 cascade-required без confirmed
- WARN-only — closes target only, warnings returned
- CASCADE — atomic close target + 2 cascaded (verifies order + reasons)
- BLOCK overrides CASCADE — 409 даже при confirmed=true
- Empty plan — closes only target

Admin UI:
- New CascadeConfirmDialog component:
  * Trigger: 409 на existing close → opens dialog с GET cascade-preview.
  * BLOCKERS section — Alert error, list первых 5, "resolve first" hint.
  * CASCADE section — per-source grouping, sample 3 + overflow,
    badges per (count, onClose mode).
  * WARN section — yellow Alert "будут orphan".
  * Reason input + "Type CONFIRM" gate если N > 50 (UX guard).
  * Cancel — close без changes.
- Wired в dictionaries.$name.tsx: existing close-confirm flow на 409
  cascade error pivots в new dialog (same UX path, transparent для user).
- Types в client.ts: CascadeEntry, CascadePlan, CascadeCloseResult.
- Mutation useCascadeCloseRecord, query useCascadePreview.
- i18n RU/EN: cascade.* (35 keys, plurals для счётчиков).

Verify:
- mvn -pl ordinis-rest-api -am test: 106/106 PASS (8 new cascade tests).
- mvn verify -pl '!ordinis-app': все модули SUCCESS (5.1s).
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS.
- pnpm build: clean.

Backward-compat: bundle v1.2.1 (без x-references-on-close) → onClose=BLOCK
default → existing dependents теперь блокируют close (правильное поведение
для FK semantics). Если был race condition close-with-deps в v1, он
exposed теперь как 409. CRITICAL: REGRESSION test покрывает.
This commit is contained in:
Zimin A.N.
2026-05-08 11:23:46 +03:00
parent c06ae263e0
commit 3eeaba058f
10 changed files with 1055 additions and 9 deletions
+56
View File
@@ -251,6 +251,40 @@ i18n
'lineage.refs.openSourceRecord': 'Открыть запись {{key}} в источнике',
'lineage.refs.stale': 'обновлено {{minutes}} мин назад',
'lineage.refs.staleHint': 'Данные из materialized view; обновляются раз в минуту. Закрытые между обновлениями записи отфильтрованы по valid_to.',
// === Cascade close (Phase 3) ===
'cascade.title': 'Каскадное закрытие',
'cascade.summary.willClose_one': 'Будет закрыта {{total}} запись (включая «{{target}}»). Подтверждение требуется.',
'cascade.summary.willClose_few': 'Будут закрыты {{total}} записи (включая «{{target}}»). Подтверждение требуется.',
'cascade.summary.willClose_many': 'Будут закрыты {{total}} записей (включая «{{target}}»). Подтверждение требуется.',
'cascade.summary.willClose_other': 'Будут закрыты {{total}} записей (включая «{{target}}»). Подтверждение требуется.',
'cascade.summary.blocked_one': 'Закрытие «{{target}}» заблокировано: есть {{blockers}} активная зависимость.',
'cascade.summary.blocked_few': 'Закрытие «{{target}}» заблокировано: есть {{blockers}} активные зависимости.',
'cascade.summary.blocked_many': 'Закрытие «{{target}}» заблокировано: есть {{blockers}} активных зависимостей.',
'cascade.summary.blocked_other': 'Закрытие «{{target}}» заблокировано: есть {{blockers}} активных зависимостей.',
'cascade.blockers.title_one': '{{count}} запись блокирует закрытие',
'cascade.blockers.title_few': '{{count}} записи блокируют закрытие',
'cascade.blockers.title_many': '{{count}} записей блокируют закрытие',
'cascade.blockers.title_other': '{{count}} записей блокируют закрытие',
'cascade.blockers.more': 'и ещё {{count}}…',
'cascade.blockers.resolveFirst': 'Сначала закройте или замените эти ссылки, затем повторите попытку.',
'cascade.cascade.title_one': 'Будет каскадно закрыта {{count}} зависимая запись',
'cascade.cascade.title_few': 'Будут каскадно закрыты {{count}} зависимых записи',
'cascade.cascade.title_many': 'Будет каскадно закрыто {{count}} зависимых записей',
'cascade.cascade.title_other': 'Будет каскадно закрыто {{count}} зависимых записей',
'cascade.cascade.more': 'и ещё {{count}}…',
'cascade.warnings.title_one': '{{count}} запись останется без ссылки (warn-mode)',
'cascade.warnings.title_few': '{{count}} записи останутся без ссылки (warn-mode)',
'cascade.warnings.title_many': '{{count}} записей останутся без ссылки (warn-mode)',
'cascade.warnings.title_other': '{{count}} записей останутся без ссылки (warn-mode)',
'cascade.warnings.note': 'После закрытия они станут orphan и будут видны на orphan scanner. Можно поправить ссылки потом.',
'cascade.reason.label': 'Причина (необязательно)',
'cascade.reason.placeholder': 'Например: декомиссия станции SAR-X',
'cascade.confirmGate.label': 'Введите слово {{word}} для подтверждения',
'cascade.action.cancel': 'Отмена',
'cascade.action.confirm_one': 'Закрыть {{count}} запись',
'cascade.action.confirm_few': 'Закрыть {{count}} записи',
'cascade.action.confirm_many': 'Закрыть {{count}} записей',
'cascade.action.confirm_other': 'Закрыть {{count}} записей',
'lineage.onClose.BLOCK': 'block',
'lineage.onClose.WARN': 'warn',
'lineage.onClose.CASCADE': 'cascade',
@@ -556,6 +590,28 @@ i18n
'lineage.refs.openSourceRecord': 'Open referencing record {{key}}',
'lineage.refs.stale': 'refreshed {{minutes}} min ago',
'lineage.refs.staleHint': 'Data from materialized view, refreshed once per minute. Records closed between refreshes are filtered by valid_to.',
// === Cascade close (Phase 3) ===
'cascade.title': 'Cascade close',
'cascade.summary.willClose_one': '{{total}} record will be closed (including «{{target}}»). Confirmation required.',
'cascade.summary.willClose_other': '{{total}} records will be closed (including «{{target}}»). Confirmation required.',
'cascade.summary.blocked_one': 'Cannot close «{{target}}»: {{blockers}} active dependency.',
'cascade.summary.blocked_other': 'Cannot close «{{target}}»: {{blockers}} active dependencies.',
'cascade.blockers.title_one': '{{count}} record blocks close',
'cascade.blockers.title_other': '{{count}} records block close',
'cascade.blockers.more': 'and {{count}} more…',
'cascade.blockers.resolveFirst': 'Close or repoint these references first, then retry.',
'cascade.cascade.title_one': '{{count}} dependent record will be cascaded',
'cascade.cascade.title_other': '{{count}} dependent records will be cascaded',
'cascade.cascade.more': 'and {{count}} more…',
'cascade.warnings.title_one': '{{count}} record will become orphan (warn-mode)',
'cascade.warnings.title_other': '{{count}} records will become orphan (warn-mode)',
'cascade.warnings.note': 'After close they will become orphan and visible to orphan scanner. References can be fixed later.',
'cascade.reason.label': 'Reason (optional)',
'cascade.reason.placeholder': 'e.g. decommissioning station SAR-X',
'cascade.confirmGate.label': 'Type {{word}} to confirm',
'cascade.action.cancel': 'Cancel',
'cascade.action.confirm_one': 'Close {{count}} record',
'cascade.action.confirm_other': 'Close {{count}} records',
'lineage.onClose.BLOCK': 'block',
'lineage.onClose.WARN': 'warn',
'lineage.onClose.CASCADE': 'cascade',