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:
@@ -3,6 +3,7 @@ import {
|
||||
apiClient,
|
||||
type AuditFilters,
|
||||
type AuditPage,
|
||||
type CascadePlan,
|
||||
type DictionaryDefinition,
|
||||
type DictionaryDetail,
|
||||
type DlqPage,
|
||||
@@ -331,6 +332,31 @@ export const useRecordDependents = (
|
||||
enabled: Boolean(businessKey),
|
||||
})
|
||||
|
||||
/**
|
||||
* Cascade preview — read-only план: blockers / warnings / cascade entries.
|
||||
* Используется UI'ем перед открытием cascade confirmation dialog.
|
||||
*
|
||||
* <p>Не cache'ится агрессивно (staleTime=0), потому что dependents
|
||||
* быстро меняются — preview всегда должен быть fresh.
|
||||
*/
|
||||
export const cascadePreviewQuery = (dict: string, businessKey: string) =>
|
||||
queryOptions({
|
||||
queryKey: ['cascade-preview', dict, businessKey] as const,
|
||||
queryFn: async (): Promise<CascadePlan> => {
|
||||
const { data } = await apiClient.get<CascadePlan>(
|
||||
`/dictionaries/${encodeURIComponent(dict)}/records/${encodeURIComponent(businessKey)}/cascade-preview`,
|
||||
)
|
||||
return data
|
||||
},
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
export const useCascadePreview = (dict: string, businessKey: string | undefined) =>
|
||||
useQuery({
|
||||
...cascadePreviewQuery(dict, businessKey ?? ''),
|
||||
enabled: Boolean(businessKey),
|
||||
})
|
||||
|
||||
export const useDictionaries = () => useQuery(dictionariesQuery)
|
||||
export const useDictionaryDetail = (name: string) => useQuery(dictionaryDetailQuery(name))
|
||||
export const useRecords = (
|
||||
|
||||
Reference in New Issue
Block a user