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:
@@ -29,6 +29,7 @@ import type { BulkCloseResponse, CreateRecordRequest, DataScope, FlattenedRecord
|
||||
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
|
||||
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
|
||||
import { DictionaryDependentsPanel } from '@/components/lineage/DictionaryDependentsPanel'
|
||||
import { CascadeConfirmDialog } from '@/components/lineage/CascadeConfirmDialog'
|
||||
import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer'
|
||||
import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog'
|
||||
import { nowIsoLocal } from '@/lib/dates'
|
||||
@@ -107,6 +108,9 @@ function DictionaryDetail() {
|
||||
const [schemaEditOpen, setSchemaEditOpen] = useState(false)
|
||||
const [historyKey, setHistoryKey] = useState<string | undefined>(undefined)
|
||||
const [aoiOpen, setAoiOpen] = useState(false)
|
||||
/** Phase 3 dict-relationships-v2: cascade dialog state. Открывается из 409
|
||||
* на close, или явным action из record menu. */
|
||||
const [cascadeKey, setCascadeKey] = useState<string | undefined>(undefined)
|
||||
|
||||
// Multi-select state. Set<businessKey> — стабильное id'шник. При изменении
|
||||
// records data (mutation success / refetch / filter change) — selection
|
||||
@@ -333,13 +337,31 @@ function DictionaryDetail() {
|
||||
|
||||
const handleClose = () => {
|
||||
if (edit.kind !== 'close-confirm') return
|
||||
const targetKey = edit.record.businessKey
|
||||
closeMut.mutate(
|
||||
{ businessKey: edit.record.businessKey, reason: closeReason || undefined },
|
||||
{ businessKey: targetKey, reason: closeReason || undefined },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setEdit({ kind: 'closed' })
|
||||
setCloseReason('')
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
// Phase 3 dict-relationships-v2: 409 с cascade-related code
|
||||
// открывает CascadeConfirmDialog. Иначе error inline в close-confirm.
|
||||
const status =
|
||||
(err as { response?: { status?: number } })?.response?.status
|
||||
const code = (err as { response?: { data?: { code?: string } } })
|
||||
?.response?.data?.code
|
||||
if (
|
||||
status === 409 &&
|
||||
(code === 'x_references_blocked_by_dependents' ||
|
||||
code === 'x_references_cascade_required')
|
||||
) {
|
||||
setEdit({ kind: 'closed' })
|
||||
setCloseReason('')
|
||||
setCascadeKey(targetKey)
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -757,6 +779,17 @@ function DictionaryDetail() {
|
||||
businessKey={historyKey}
|
||||
/>
|
||||
|
||||
<CascadeConfirmDialog
|
||||
open={Boolean(cascadeKey)}
|
||||
onClose={() => setCascadeKey(undefined)}
|
||||
onSuccess={() => {
|
||||
setCascadeKey(undefined)
|
||||
recordsResult.refetch()
|
||||
}}
|
||||
dictionaryName={name}
|
||||
businessKey={cascadeKey}
|
||||
/>
|
||||
|
||||
<AoiPickerDialog
|
||||
isOpen={aoiOpen}
|
||||
onClose={() => setAoiOpen(false)}
|
||||
|
||||
Reference in New Issue
Block a user