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:
@@ -286,6 +286,32 @@ export type LineageMeta = {
|
|||||||
lastDurationMs: number | null
|
lastDurationMs: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CascadeEntry = {
|
||||||
|
sourceDict: string
|
||||||
|
sourceDisplayName?: string | null
|
||||||
|
businessKey: string
|
||||||
|
sourceField: string
|
||||||
|
onClose: OnCloseAction
|
||||||
|
recordId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CascadePlan = {
|
||||||
|
targetDict: string
|
||||||
|
targetKey: string
|
||||||
|
blockers: CascadeEntry[]
|
||||||
|
warnings: CascadeEntry[]
|
||||||
|
cascade: CascadeEntry[]
|
||||||
|
totalDependents: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CascadeCloseResult = {
|
||||||
|
targetDict: string
|
||||||
|
targetKey: string
|
||||||
|
cascadedRecords: CascadeEntry[]
|
||||||
|
warnings: CascadeEntry[]
|
||||||
|
totalClosed: number
|
||||||
|
}
|
||||||
|
|
||||||
export type RecordDependentsPage = {
|
export type RecordDependentsPage = {
|
||||||
targetDict: string
|
targetDict: string
|
||||||
targetBusinessKey: string
|
targetBusinessKey: string
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
apiClient,
|
apiClient,
|
||||||
type BulkCloseRequest,
|
type BulkCloseRequest,
|
||||||
type BulkCloseResponse,
|
type BulkCloseResponse,
|
||||||
|
type CascadeCloseResult,
|
||||||
type CreateDictionaryRequest,
|
type CreateDictionaryRequest,
|
||||||
type CreateRecordRequest,
|
type CreateRecordRequest,
|
||||||
type CreateWebhookSubscriptionRequest,
|
type CreateWebhookSubscriptionRequest,
|
||||||
@@ -103,6 +104,49 @@ export const useCloseRecord = (dictionaryName: string) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cascade close — Phase 3 dict-relationships-v2. Атомарно закрывает target +
|
||||||
|
* все CASCADE dependents в одной transaction. UI должен сначала вызвать
|
||||||
|
* `cascade-preview` query, показать confirmation, и только потом запускать
|
||||||
|
* этот мутатор с `confirmed=true`.
|
||||||
|
*
|
||||||
|
* <p>Backend errors:
|
||||||
|
* <ul>
|
||||||
|
* <li>409 x_references_blocked_by_dependents — есть BLOCK deps. UI отображает
|
||||||
|
* список и просит resolve.</li>
|
||||||
|
* <li>409 x_references_cascade_required — confirmed=false при наличии cascade.</li>
|
||||||
|
* <li>503 x_references_cascade_timeout — tx timeout (>30s). User retry'ит.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
export const useCascadeCloseRecord = (dictionaryName: string) => {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (params: {
|
||||||
|
businessKey: string
|
||||||
|
reason?: string
|
||||||
|
confirmed: boolean
|
||||||
|
}): Promise<CascadeCloseResult> => {
|
||||||
|
const queryParams: Record<string, string | boolean> = {
|
||||||
|
confirmed: params.confirmed,
|
||||||
|
}
|
||||||
|
if (params.reason) queryParams.reason = params.reason
|
||||||
|
const { data } = await apiClient.post<CascadeCloseResult>(
|
||||||
|
`/dictionaries/${dictionaryName}/records/${encodeURIComponent(params.businessKey)}/cascade-close`,
|
||||||
|
null,
|
||||||
|
{ params: queryParams },
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate всё что может быть affected: records list + dependents
|
||||||
|
// queries (target и cascaded sources меняют состояние).
|
||||||
|
qc.invalidateQueries({ queryKey: ['records', dictionaryName] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['record-dependents'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['records'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bulk close: закрыть N records одним запросом. Backend per-record транзакция,
|
* Bulk close: закрыть N records одним запросом. Backend per-record транзакция,
|
||||||
* partial success возможен (already_closed / not_found попадает в skipped).
|
* partial success возможен (already_closed / not_found попадает в skipped).
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
apiClient,
|
apiClient,
|
||||||
type AuditFilters,
|
type AuditFilters,
|
||||||
type AuditPage,
|
type AuditPage,
|
||||||
|
type CascadePlan,
|
||||||
type DictionaryDefinition,
|
type DictionaryDefinition,
|
||||||
type DictionaryDetail,
|
type DictionaryDetail,
|
||||||
type DlqPage,
|
type DlqPage,
|
||||||
@@ -331,6 +332,31 @@ export const useRecordDependents = (
|
|||||||
enabled: Boolean(businessKey),
|
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 useDictionaries = () => useQuery(dictionariesQuery)
|
||||||
export const useDictionaryDetail = (name: string) => useQuery(dictionaryDetailQuery(name))
|
export const useDictionaryDetail = (name: string) => useQuery(dictionaryDetailQuery(name))
|
||||||
export const useRecords = (
|
export const useRecords = (
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Alert, Badge, Button, LoadingBlock, Modal, TextInput } from '@nstart/ui'
|
||||||
|
import { WarningIcon, XCircleIcon } from '@phosphor-icons/react'
|
||||||
|
import { useCascadePreview } from '@/api/queries'
|
||||||
|
import { useCascadeCloseRecord } from '@/api/mutations'
|
||||||
|
import type { CascadeEntry, OnCloseAction } from '@/api/client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cascade close confirmation dialog — Phase 3 dict-relationships-v2.
|
||||||
|
*
|
||||||
|
* <p>Поток:
|
||||||
|
* <ol>
|
||||||
|
* <li>Открывается из existing close flow когда DELETE возвращает 409
|
||||||
|
* (x_references_blocked_by_dependents или x_references_cascade_required).</li>
|
||||||
|
* <li>Загружает preview через {@code GET /cascade-preview}.</li>
|
||||||
|
* <li>Если есть BLOCKERS — показывает их + объясняет что resolve first;
|
||||||
|
* confirm button disabled.</li>
|
||||||
|
* <li>Если есть только CASCADE/WARN — показывает per-source breakdown +
|
||||||
|
* sample 3 records per source, plus "Type CONFIRM" guard если N {@literal >} 50.</li>
|
||||||
|
* <li>Confirm → {@code POST /cascade-close?confirmed=true} → success message.</li>
|
||||||
|
* <li>Cancel → close dialog без изменений.</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>UX guards:
|
||||||
|
* <ul>
|
||||||
|
* <li>"Type CONFIRM" gate когда total > 50 (защита от accidental cascade).</li>
|
||||||
|
* <li>Reason field — optional; пустой = backend audit fields пустые.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
type Props = {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
/** Called after successful cascade close. */
|
||||||
|
onSuccess: () => void
|
||||||
|
dictionaryName: string
|
||||||
|
businessKey: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCloseVariant = (
|
||||||
|
a: OnCloseAction,
|
||||||
|
): 'neutral' | 'warning' | 'error' => {
|
||||||
|
if (a === 'CASCADE') return 'error'
|
||||||
|
if (a === 'WARN') return 'warning'
|
||||||
|
return 'neutral'
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAMPLE_PER_SOURCE = 3
|
||||||
|
const CONFIRM_GATE_THRESHOLD = 50
|
||||||
|
|
||||||
|
export const CascadeConfirmDialog = ({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
dictionaryName,
|
||||||
|
businessKey,
|
||||||
|
}: Props) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const preview = useCascadePreview(dictionaryName, open ? businessKey : undefined)
|
||||||
|
const cascadeMut = useCascadeCloseRecord(dictionaryName)
|
||||||
|
const [reason, setReason] = useState('')
|
||||||
|
const [confirmText, setConfirmText] = useState('')
|
||||||
|
|
||||||
|
// Reset state каждый раз когда dialog открывается с новой target.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setReason('')
|
||||||
|
setConfirmText('')
|
||||||
|
cascadeMut.reset()
|
||||||
|
}
|
||||||
|
}, [open, businessKey, cascadeMut])
|
||||||
|
|
||||||
|
const plan = preview.data
|
||||||
|
const hasBlockers = (plan?.blockers.length ?? 0) > 0
|
||||||
|
const totalToClose = 1 + (plan?.cascade.length ?? 0)
|
||||||
|
const requiresGate = totalToClose > CONFIRM_GATE_THRESHOLD
|
||||||
|
const gatePassed = !requiresGate || confirmText.trim().toUpperCase() === 'CONFIRM'
|
||||||
|
|
||||||
|
// Group cascade entries by source dict для compact display.
|
||||||
|
const cascadeBySource = useMemo(() => {
|
||||||
|
const map = new Map<string, CascadeEntry[]>()
|
||||||
|
for (const e of plan?.cascade ?? []) {
|
||||||
|
const key = `${e.sourceDict}.${e.sourceField}`
|
||||||
|
if (!map.has(key)) map.set(key, [])
|
||||||
|
map.get(key)!.push(e)
|
||||||
|
}
|
||||||
|
return Array.from(map.entries())
|
||||||
|
}, [plan?.cascade])
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (!businessKey) return
|
||||||
|
cascadeMut.mutate(
|
||||||
|
{ businessKey, reason: reason.trim() || undefined, confirmed: true },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
onSuccess()
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={t('cascade.title')}
|
||||||
|
maxWidth="max-w-2xl"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{preview.isLoading && <LoadingBlock size="md" label={t('loading')} />}
|
||||||
|
|
||||||
|
{preview.error && (
|
||||||
|
<Alert variant="error" title={t('error.failed')}>
|
||||||
|
{String(preview.error)}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{plan && (
|
||||||
|
<>
|
||||||
|
{/* Header summary */}
|
||||||
|
<p className="text-sm text-carbon">
|
||||||
|
{hasBlockers
|
||||||
|
? t('cascade.summary.blocked', {
|
||||||
|
blockers: plan.blockers.length,
|
||||||
|
target: businessKey,
|
||||||
|
})
|
||||||
|
: t('cascade.summary.willClose', {
|
||||||
|
total: totalToClose,
|
||||||
|
target: businessKey,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* BLOCKERS section — non-resolvable. */}
|
||||||
|
{plan.blockers.length > 0 && (
|
||||||
|
<Alert
|
||||||
|
variant="error"
|
||||||
|
title={t('cascade.blockers.title', { count: plan.blockers.length })}
|
||||||
|
>
|
||||||
|
<ul className="space-y-1.5 mt-2 text-2xs">
|
||||||
|
{plan.blockers.slice(0, 5).map((b) => (
|
||||||
|
<li key={b.recordId} className="flex items-center gap-2">
|
||||||
|
<XCircleIcon weight="bold" size={12} className="text-aurora shrink-0" />
|
||||||
|
<span className="font-mono">
|
||||||
|
{b.sourceDict}/{b.businessKey}
|
||||||
|
</span>
|
||||||
|
<span className="text-carbon/60">.{b.sourceField}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{plan.blockers.length > 5 && (
|
||||||
|
<li className="text-carbon/60">
|
||||||
|
{t('cascade.blockers.more', { count: plan.blockers.length - 5 })}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
<p className="mt-2 text-2xs text-carbon/70">
|
||||||
|
{t('cascade.blockers.resolveFirst')}
|
||||||
|
</p>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* CASCADE section — preview per-source. */}
|
||||||
|
{!hasBlockers && plan.cascade.length > 0 && (
|
||||||
|
<section className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<WarningIcon weight="bold" size={14} className="text-horizon" />
|
||||||
|
<h4 className="text-sm font-primary">
|
||||||
|
{t('cascade.cascade.title', { count: plan.cascade.length })}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{cascadeBySource.map(([key, entries]) => {
|
||||||
|
const first = entries[0]
|
||||||
|
const sample = entries.slice(0, SAMPLE_PER_SOURCE)
|
||||||
|
const overflow = entries.length - sample.length
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={key}
|
||||||
|
className="border border-regolith rounded-sm px-3 py-2 text-2xs"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="font-mono text-ultramarain">
|
||||||
|
{first.sourceDisplayName ?? first.sourceDict}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-carbon/70">
|
||||||
|
.{first.sourceField}
|
||||||
|
</span>
|
||||||
|
<Badge variant={onCloseVariant(first.onClose)}>
|
||||||
|
{t(`lineage.onClose.${first.onClose}`)}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="neutral">{entries.length}</Badge>
|
||||||
|
</div>
|
||||||
|
<ul className="text-2xs text-carbon/70 ml-1 space-y-0.5">
|
||||||
|
{sample.map((e) => (
|
||||||
|
<li key={e.recordId} className="font-mono">
|
||||||
|
· {e.businessKey}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{overflow > 0 && (
|
||||||
|
<li className="text-carbon/50">
|
||||||
|
{t('cascade.cascade.more', { count: overflow })}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* WARNINGS section — proceed but emit warnings. */}
|
||||||
|
{!hasBlockers && plan.warnings.length > 0 && (
|
||||||
|
<Alert
|
||||||
|
variant="warning"
|
||||||
|
title={t('cascade.warnings.title', { count: plan.warnings.length })}
|
||||||
|
>
|
||||||
|
<p className="text-2xs">{t('cascade.warnings.note')}</p>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reason input — only when going to proceed. */}
|
||||||
|
{!hasBlockers && (plan.cascade.length > 0 || plan.warnings.length > 0) && (
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="cascade-reason"
|
||||||
|
className="text-2xs text-carbon/70 uppercase tracking-label"
|
||||||
|
>
|
||||||
|
{t('cascade.reason.label')}
|
||||||
|
</label>
|
||||||
|
<TextInput
|
||||||
|
id="cascade-reason"
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
placeholder={t('cascade.reason.placeholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Type CONFIRM gate when N > 50. */}
|
||||||
|
{!hasBlockers && requiresGate && (
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="cascade-confirm"
|
||||||
|
className="text-2xs text-carbon/70 uppercase tracking-label"
|
||||||
|
>
|
||||||
|
{t('cascade.confirmGate.label', { word: 'CONFIRM' })}
|
||||||
|
</label>
|
||||||
|
<TextInput
|
||||||
|
id="cascade-confirm"
|
||||||
|
value={confirmText}
|
||||||
|
onChange={(e) => setConfirmText(e.target.value)}
|
||||||
|
placeholder="CONFIRM"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{cascadeMut.error && (
|
||||||
|
<Alert variant="error" title={t('error.failed')}>
|
||||||
|
{String(cascadeMut.error)}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer actions */}
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-2 border-t border-regolith">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={cascadeMut.isPending}
|
||||||
|
>
|
||||||
|
{t('cascade.action.cancel')}
|
||||||
|
</Button>
|
||||||
|
{!hasBlockers && plan && (plan.cascade.length > 0 || plan.warnings.length > 0) && (
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={cascadeMut.isPending || !gatePassed}
|
||||||
|
loading={cascadeMut.isPending}
|
||||||
|
>
|
||||||
|
{t('cascade.action.confirm', { count: totalToClose })}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -251,6 +251,40 @@ i18n
|
|||||||
'lineage.refs.openSourceRecord': 'Открыть запись {{key}} в источнике',
|
'lineage.refs.openSourceRecord': 'Открыть запись {{key}} в источнике',
|
||||||
'lineage.refs.stale': 'обновлено {{minutes}} мин назад',
|
'lineage.refs.stale': 'обновлено {{minutes}} мин назад',
|
||||||
'lineage.refs.staleHint': 'Данные из materialized view; обновляются раз в минуту. Закрытые между обновлениями записи отфильтрованы по valid_to.',
|
'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.BLOCK': 'block',
|
||||||
'lineage.onClose.WARN': 'warn',
|
'lineage.onClose.WARN': 'warn',
|
||||||
'lineage.onClose.CASCADE': 'cascade',
|
'lineage.onClose.CASCADE': 'cascade',
|
||||||
@@ -556,6 +590,28 @@ i18n
|
|||||||
'lineage.refs.openSourceRecord': 'Open referencing record {{key}}',
|
'lineage.refs.openSourceRecord': 'Open referencing record {{key}}',
|
||||||
'lineage.refs.stale': 'refreshed {{minutes}} min ago',
|
'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.',
|
'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.BLOCK': 'block',
|
||||||
'lineage.onClose.WARN': 'warn',
|
'lineage.onClose.WARN': 'warn',
|
||||||
'lineage.onClose.CASCADE': 'cascade',
|
'lineage.onClose.CASCADE': 'cascade',
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import type { BulkCloseResponse, CreateRecordRequest, DataScope, FlattenedRecord
|
|||||||
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
|
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
|
||||||
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
|
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
|
||||||
import { DictionaryDependentsPanel } from '@/components/lineage/DictionaryDependentsPanel'
|
import { DictionaryDependentsPanel } from '@/components/lineage/DictionaryDependentsPanel'
|
||||||
|
import { CascadeConfirmDialog } from '@/components/lineage/CascadeConfirmDialog'
|
||||||
import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer'
|
import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer'
|
||||||
import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog'
|
import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog'
|
||||||
import { nowIsoLocal } from '@/lib/dates'
|
import { nowIsoLocal } from '@/lib/dates'
|
||||||
@@ -107,6 +108,9 @@ function DictionaryDetail() {
|
|||||||
const [schemaEditOpen, setSchemaEditOpen] = useState(false)
|
const [schemaEditOpen, setSchemaEditOpen] = useState(false)
|
||||||
const [historyKey, setHistoryKey] = useState<string | undefined>(undefined)
|
const [historyKey, setHistoryKey] = useState<string | undefined>(undefined)
|
||||||
const [aoiOpen, setAoiOpen] = useState(false)
|
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'шник. При изменении
|
// Multi-select state. Set<businessKey> — стабильное id'шник. При изменении
|
||||||
// records data (mutation success / refetch / filter change) — selection
|
// records data (mutation success / refetch / filter change) — selection
|
||||||
@@ -333,13 +337,31 @@ function DictionaryDetail() {
|
|||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
if (edit.kind !== 'close-confirm') return
|
if (edit.kind !== 'close-confirm') return
|
||||||
|
const targetKey = edit.record.businessKey
|
||||||
closeMut.mutate(
|
closeMut.mutate(
|
||||||
{ businessKey: edit.record.businessKey, reason: closeReason || undefined },
|
{ businessKey: targetKey, reason: closeReason || undefined },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setEdit({ kind: 'closed' })
|
setEdit({ kind: 'closed' })
|
||||||
setCloseReason('')
|
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}
|
businessKey={historyKey}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CascadeConfirmDialog
|
||||||
|
open={Boolean(cascadeKey)}
|
||||||
|
onClose={() => setCascadeKey(undefined)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setCascadeKey(undefined)
|
||||||
|
recordsResult.refetch()
|
||||||
|
}}
|
||||||
|
dictionaryName={name}
|
||||||
|
businessKey={cascadeKey}
|
||||||
|
/>
|
||||||
|
|
||||||
<AoiPickerDialog
|
<AoiPickerDialog
|
||||||
isOpen={aoiOpen}
|
isOpen={aoiOpen}
|
||||||
onClose={() => setAoiOpen(false)}
|
onClose={() => setAoiOpen(false)}
|
||||||
|
|||||||
+48
-7
@@ -47,9 +47,33 @@ public class DictionaryRecordService {
|
|||||||
private final cloud.nstart.terravault.ordinis.auth.ScopeContext scopeContext;
|
private final cloud.nstart.terravault.ordinis.auth.ScopeContext scopeContext;
|
||||||
private final OutboxRecorder outbox;
|
private final OutboxRecorder outbox;
|
||||||
private final AuditLogger auditLogger;
|
private final AuditLogger auditLogger;
|
||||||
|
/** Phase 3 dict-relationships-v2: pre-close проверка cascade plan. Optional —
|
||||||
|
* тесты с null deps инжектят null. */
|
||||||
|
private final java.util.Optional<cloud.nstart.terravault.ordinis.restapi.service.reference.CascadeCloseService> cascadeService;
|
||||||
|
|
||||||
private static final OffsetDateTime FAR_FUTURE = OffsetDateTime.parse("9999-12-31T23:59:59Z");
|
private static final OffsetDateTime FAR_FUTURE = OffsetDateTime.parse("9999-12-31T23:59:59Z");
|
||||||
|
|
||||||
|
@org.springframework.beans.factory.annotation.Autowired
|
||||||
|
public DictionaryRecordService(
|
||||||
|
DictionaryRecordRepository repository,
|
||||||
|
DictionaryDefinitionService definitionService,
|
||||||
|
RecordValidator validator,
|
||||||
|
cloud.nstart.terravault.ordinis.restapi.service.reference.ReferenceValidator referenceValidator,
|
||||||
|
cloud.nstart.terravault.ordinis.auth.ScopeContext scopeContext,
|
||||||
|
OutboxRecorder outbox,
|
||||||
|
AuditLogger auditLogger,
|
||||||
|
java.util.Optional<cloud.nstart.terravault.ordinis.restapi.service.reference.CascadeCloseService> cascadeService) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.definitionService = definitionService;
|
||||||
|
this.validator = validator;
|
||||||
|
this.referenceValidator = referenceValidator;
|
||||||
|
this.scopeContext = scopeContext;
|
||||||
|
this.outbox = outbox;
|
||||||
|
this.auditLogger = auditLogger;
|
||||||
|
this.cascadeService = cascadeService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Backward-compat ctor для тестов и subclassing (BulkRecordServiceTest). */
|
||||||
public DictionaryRecordService(
|
public DictionaryRecordService(
|
||||||
DictionaryRecordRepository repository,
|
DictionaryRecordRepository repository,
|
||||||
DictionaryDefinitionService definitionService,
|
DictionaryDefinitionService definitionService,
|
||||||
@@ -58,13 +82,8 @@ public class DictionaryRecordService {
|
|||||||
cloud.nstart.terravault.ordinis.auth.ScopeContext scopeContext,
|
cloud.nstart.terravault.ordinis.auth.ScopeContext scopeContext,
|
||||||
OutboxRecorder outbox,
|
OutboxRecorder outbox,
|
||||||
AuditLogger auditLogger) {
|
AuditLogger auditLogger) {
|
||||||
this.repository = repository;
|
this(repository, definitionService, validator, referenceValidator,
|
||||||
this.definitionService = definitionService;
|
scopeContext, outbox, auditLogger, java.util.Optional.empty());
|
||||||
this.validator = validator;
|
|
||||||
this.referenceValidator = referenceValidator;
|
|
||||||
this.scopeContext = scopeContext;
|
|
||||||
this.outbox = outbox;
|
|
||||||
this.auditLogger = auditLogger;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
@@ -184,6 +203,28 @@ public class DictionaryRecordService {
|
|||||||
var d = definitionService.findByName(dictionaryName);
|
var d = definitionService.findByName(dictionaryName);
|
||||||
OffsetDateTime when = closeAt == null ? OffsetDateTime.now() : closeAt;
|
OffsetDateTime when = closeAt == null ? OffsetDateTime.now() : closeAt;
|
||||||
|
|
||||||
|
// Phase 3 dict-relationships-v2: если есть active dependents — close
|
||||||
|
// блокируется (BLOCK или требует cascade-confirm для CASCADE). WARN
|
||||||
|
// dependents допускают close, но возвращают warnings (current
|
||||||
|
// contract: void return — warnings уйдут только в outbox + audit).
|
||||||
|
cascadeService.ifPresent(svc -> {
|
||||||
|
var plan = svc.evaluatePlan(dictionaryName, businessKey, when);
|
||||||
|
if (!plan.blockers().isEmpty()) {
|
||||||
|
throw OrdinisException.conflict(
|
||||||
|
"x_references_blocked_by_dependents",
|
||||||
|
"Cannot close " + dictionaryName + "/" + businessKey + " — "
|
||||||
|
+ plan.blockers().size()
|
||||||
|
+ " active dependents block close. Resolve them first.");
|
||||||
|
}
|
||||||
|
if (!plan.cascade().isEmpty()) {
|
||||||
|
throw OrdinisException.conflict(
|
||||||
|
"x_references_cascade_required",
|
||||||
|
plan.cascade().size() + " dependents will be cascaded. "
|
||||||
|
+ "Use POST /records/{dict}/{key}/cascade-close with confirmed=true to proceed.");
|
||||||
|
}
|
||||||
|
// WARN-only — продолжаем close (warnings emitted via outbox indirectly).
|
||||||
|
});
|
||||||
|
|
||||||
var current = repository.findActiveAt(d.getId(), businessKey, when)
|
var current = repository.findActiveAt(d.getId(), businessKey, when)
|
||||||
.orElseThrow(() -> OrdinisException.notFound(
|
.orElseThrow(() -> OrdinisException.notFound(
|
||||||
"record_not_active",
|
"record_not_active",
|
||||||
|
|||||||
+264
@@ -0,0 +1,264 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.restapi.service.reference;
|
||||||
|
|
||||||
|
import cloud.nstart.terravault.ordinis.auth.ScopeContext;
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.DataScope;
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord;
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository;
|
||||||
|
import cloud.nstart.terravault.ordinis.events.EventEnvelope;
|
||||||
|
import cloud.nstart.terravault.ordinis.events.record.DictionaryRecordDeleted;
|
||||||
|
import cloud.nstart.terravault.ordinis.outbox.OutboxRecorder;
|
||||||
|
import cloud.nstart.terravault.ordinis.restapi.audit.AuditLogger;
|
||||||
|
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
|
||||||
|
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.dao.QueryTimeoutException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.TransactionTimedOutException;
|
||||||
|
import org.springframework.transaction.annotation.Isolation;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cascade close engine — Phase 3 dict-relationships-v2 epic.
|
||||||
|
*
|
||||||
|
* <p>Запускается когда close target record имеет dependents (записи в других
|
||||||
|
* словарях через {@code x-references}). Branching по {@link OnCloseAction}
|
||||||
|
* объявленному в schema referencing dict'а:
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link OnCloseAction#BLOCK} — close отклоняется с 409
|
||||||
|
* {@code x_references_blocked_by_dependents}, dependents list в response.</li>
|
||||||
|
* <li>{@link OnCloseAction#WARN} — close проходит, warnings возвращаются
|
||||||
|
* в response (orphan scanner подхватит на metric).</li>
|
||||||
|
* <li>{@link OnCloseAction#CASCADE} — требует explicit confirm через
|
||||||
|
* cascade-close endpoint. Direct dependents закрываются в той же
|
||||||
|
* transaction (one hop, F.OUT по design).</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Concurrency: SERIALIZABLE isolation. Postgres exclusion constraint +
|
||||||
|
* SERIALIZABLE retry — second concurrent close видит conflict и retry'ится
|
||||||
|
* (или возвращает 409 если retry exhausted).
|
||||||
|
*
|
||||||
|
* <p>Tx timeout: {@code @Transactional(timeout = 30)} на {@link #executeCascade}.
|
||||||
|
* При timeout'е → 503 {@code x_references_cascade_timeout}, rollback clean.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class CascadeCloseService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(CascadeCloseService.class);
|
||||||
|
|
||||||
|
/** Hard upper bound на cascade size (one hop). Свыше — bulk-close ручкой. */
|
||||||
|
private static final int CASCADE_MAX_PER_REQUEST = 500;
|
||||||
|
|
||||||
|
private final LineageIndexService lineage;
|
||||||
|
private final RecordCloseSink closeSink;
|
||||||
|
private final ScopeContext scopeContext;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public CascadeCloseService(
|
||||||
|
LineageIndexService lineage,
|
||||||
|
DictionaryRecordRepository recordRepo,
|
||||||
|
DictionaryDefinitionService defService,
|
||||||
|
AuditLogger auditLogger,
|
||||||
|
OutboxRecorder outbox,
|
||||||
|
ScopeContext scopeContext) {
|
||||||
|
this(lineage,
|
||||||
|
new DefaultRecordCloseSink(recordRepo, defService, auditLogger, outbox),
|
||||||
|
scopeContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test-only ctor — sink инжектится напрямую (Mockito не мокает concrete). */
|
||||||
|
CascadeCloseService(
|
||||||
|
LineageIndexService lineage,
|
||||||
|
RecordCloseSink closeSink,
|
||||||
|
ScopeContext scopeContext) {
|
||||||
|
this.lineage = lineage;
|
||||||
|
this.closeSink = closeSink;
|
||||||
|
this.scopeContext = scopeContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only план: какие dependents есть, сгруппированные по mode.
|
||||||
|
* UI вызывает перед close чтобы показать preview dialog.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public CascadePlan evaluatePlan(
|
||||||
|
String targetDict, String targetKey, OffsetDateTime at) {
|
||||||
|
Set<DataScope> scopes = scopeContext.currentScopes();
|
||||||
|
OffsetDateTime when = at == null ? OffsetDateTime.now() : at;
|
||||||
|
var page = lineage.findRecordDependents(
|
||||||
|
targetDict, targetKey, scopes, 0, CASCADE_MAX_PER_REQUEST);
|
||||||
|
|
||||||
|
List<CascadeEntry> blockers = new ArrayList<>();
|
||||||
|
List<CascadeEntry> warnings = new ArrayList<>();
|
||||||
|
List<CascadeEntry> cascade = new ArrayList<>();
|
||||||
|
for (var d : page.items()) {
|
||||||
|
var entry = new CascadeEntry(
|
||||||
|
d.sourceDict(), d.sourceDisplayName(), d.businessKey(),
|
||||||
|
d.sourceField(), d.onClose(), d.id());
|
||||||
|
switch (d.onClose()) {
|
||||||
|
case BLOCK -> blockers.add(entry);
|
||||||
|
case WARN -> warnings.add(entry);
|
||||||
|
case CASCADE -> cascade.add(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new CascadePlan(
|
||||||
|
targetDict, targetKey, blockers, warnings, cascade, page.total());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomic execute: проверяет план, кидает 409 если blockers есть,
|
||||||
|
* иначе закрывает target + all CASCADE dependents в одной transaction.
|
||||||
|
* WARN dependents остаются orphan (warning'и возвращаются в response).
|
||||||
|
*
|
||||||
|
* @param requireConfirm true → если в плане CASCADE есть, требует explicit confirm.
|
||||||
|
* false → запускает только если blockers + cascade пусты
|
||||||
|
* (used by existing DELETE endpoint default path).
|
||||||
|
*/
|
||||||
|
@Transactional(isolation = Isolation.SERIALIZABLE, timeout = 30)
|
||||||
|
public CascadeCloseResult executeCascade(
|
||||||
|
String targetDict,
|
||||||
|
String targetKey,
|
||||||
|
OffsetDateTime closeAt,
|
||||||
|
String reason,
|
||||||
|
boolean confirmed) {
|
||||||
|
OffsetDateTime when = closeAt == null ? OffsetDateTime.now() : closeAt;
|
||||||
|
CascadePlan plan = evaluatePlan(targetDict, targetKey, when);
|
||||||
|
|
||||||
|
if (!plan.blockers().isEmpty()) {
|
||||||
|
throw OrdinisException.conflict(
|
||||||
|
"x_references_blocked_by_dependents",
|
||||||
|
"Cannot close " + targetDict + "/" + targetKey + " — "
|
||||||
|
+ plan.blockers().size() + " active dependents block close. "
|
||||||
|
+ "Resolve them first, or use cascade-close endpoint with confirmation.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!plan.cascade().isEmpty() && !confirmed) {
|
||||||
|
throw OrdinisException.conflict(
|
||||||
|
"x_references_cascade_required",
|
||||||
|
plan.cascade().size() + " dependents will be cascaded. "
|
||||||
|
+ "Re-call with confirmed=true to proceed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plan.cascade().size() > CASCADE_MAX_PER_REQUEST) {
|
||||||
|
throw OrdinisException.badRequest(
|
||||||
|
"x_references_cascade_too_large",
|
||||||
|
"Cascade size " + plan.cascade().size() + " exceeds limit "
|
||||||
|
+ CASCADE_MAX_PER_REQUEST + ". Split via bulk-close.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Close target first (FK semantics не enforce'ятся в Postgres для
|
||||||
|
// JSON refs, но логика "сначала parent, потом children" чище).
|
||||||
|
closeSink.closeRecord(targetDict, targetKey, when, reason);
|
||||||
|
String cascadeReason = "cascaded from " + targetDict + "/" + targetKey;
|
||||||
|
for (var dep : plan.cascade()) {
|
||||||
|
closeSink.closeRecord(dep.sourceDict(), dep.businessKey(), when, cascadeReason);
|
||||||
|
}
|
||||||
|
log.info("Cascade close OK: target={}/{} cascaded={} warnings={}",
|
||||||
|
targetDict, targetKey, plan.cascade().size(), plan.warnings().size());
|
||||||
|
return new CascadeCloseResult(
|
||||||
|
targetDict, targetKey, plan.cascade(), plan.warnings(), 1 + plan.cascade().size());
|
||||||
|
} catch (TransactionTimedOutException | QueryTimeoutException e) {
|
||||||
|
log.error("Cascade close timeout for {}/{} after 30s: {}", targetDict, targetKey, e.getMessage());
|
||||||
|
throw new OrdinisException(
|
||||||
|
org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
"x_references_cascade_timeout",
|
||||||
|
"Cascade close transaction exceeded 30s timeout. Rollback complete. "
|
||||||
|
+ "Consider splitting via bulk-close or reducing cascade fan-out.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Узкий test-friendly seam над close mechanics (repo + audit + outbox).
|
||||||
|
* Concrete prod impl ниже; тесты подменяют через subclass / lambda.
|
||||||
|
*/
|
||||||
|
interface RecordCloseSink {
|
||||||
|
void closeRecord(String dictionaryName, String businessKey,
|
||||||
|
OffsetDateTime when, String reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Production impl: repo save + audit + outbox в одной operation. */
|
||||||
|
static final class DefaultRecordCloseSink implements RecordCloseSink {
|
||||||
|
private final DictionaryRecordRepository recordRepo;
|
||||||
|
private final DictionaryDefinitionService defService;
|
||||||
|
private final AuditLogger auditLogger;
|
||||||
|
private final OutboxRecorder outbox;
|
||||||
|
|
||||||
|
DefaultRecordCloseSink(
|
||||||
|
DictionaryRecordRepository recordRepo,
|
||||||
|
DictionaryDefinitionService defService,
|
||||||
|
AuditLogger auditLogger,
|
||||||
|
OutboxRecorder outbox) {
|
||||||
|
this.recordRepo = recordRepo;
|
||||||
|
this.defService = defService;
|
||||||
|
this.auditLogger = auditLogger;
|
||||||
|
this.outbox = outbox;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void closeRecord(
|
||||||
|
String dictionaryName, String businessKey, OffsetDateTime when, String reason) {
|
||||||
|
DictionaryDefinition d = defService.findByName(dictionaryName);
|
||||||
|
DictionaryRecord current = recordRepo.findActiveAt(d.getId(), businessKey, when)
|
||||||
|
.orElseThrow(() -> OrdinisException.notFound(
|
||||||
|
"record_not_active",
|
||||||
|
"Нет активной записи business_key=" + businessKey + " в " + dictionaryName));
|
||||||
|
|
||||||
|
current.setValidTo(when);
|
||||||
|
recordRepo.save(current);
|
||||||
|
auditLogger.recordClose(d, current, reason);
|
||||||
|
publishDeleted(d, current, when, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void publishDeleted(
|
||||||
|
DictionaryDefinition d, DictionaryRecord r, OffsetDateTime closedAt, String reason) {
|
||||||
|
var payload = new DictionaryRecordDeleted(
|
||||||
|
r.getId(), r.getBusinessKey(), closedAt, OffsetDateTime.now(),
|
||||||
|
r.getUpdatedBy(), reason);
|
||||||
|
var envelope = EventEnvelope.of(
|
||||||
|
d.getName(),
|
||||||
|
cloud.nstart.terravault.ordinis.events.DataScope.valueOf(d.getScope().name()),
|
||||||
|
d.getBundle(), payload);
|
||||||
|
outbox.record(
|
||||||
|
"RecordDeleted", "DictionaryRecord", r.getId().toString(),
|
||||||
|
d.getName(), d.getScope(), d.getBundle(),
|
||||||
|
d.getName() + ":" + r.getBusinessKey(), envelope);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === DTOs ===
|
||||||
|
|
||||||
|
/** Single cascade entry: одна dependent record + её mode. */
|
||||||
|
public record CascadeEntry(
|
||||||
|
String sourceDict,
|
||||||
|
String sourceDisplayName,
|
||||||
|
String businessKey,
|
||||||
|
String sourceField,
|
||||||
|
OnCloseAction onClose,
|
||||||
|
java.util.UUID recordId) {}
|
||||||
|
|
||||||
|
/** Read-only план: что произойдёт при close. */
|
||||||
|
public record CascadePlan(
|
||||||
|
String targetDict,
|
||||||
|
String targetKey,
|
||||||
|
List<CascadeEntry> blockers,
|
||||||
|
List<CascadeEntry> warnings,
|
||||||
|
List<CascadeEntry> cascade,
|
||||||
|
long totalDependents) {}
|
||||||
|
|
||||||
|
/** Результат execute: сколько записей закрылось + warnings. */
|
||||||
|
public record CascadeCloseResult(
|
||||||
|
String targetDict,
|
||||||
|
String targetKey,
|
||||||
|
List<CascadeEntry> cascadedRecords,
|
||||||
|
List<CascadeEntry> warnings,
|
||||||
|
int totalClosed) {}
|
||||||
|
}
|
||||||
+47
-1
@@ -11,6 +11,7 @@ import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
|
|||||||
import cloud.nstart.terravault.ordinis.restapi.service.BulkRecordService;
|
import cloud.nstart.terravault.ordinis.restapi.service.BulkRecordService;
|
||||||
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService;
|
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService;
|
||||||
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryRecordService;
|
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryRecordService;
|
||||||
|
import cloud.nstart.terravault.ordinis.restapi.service.reference.CascadeCloseService;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
@@ -35,16 +36,19 @@ public class DictionaryRecordController {
|
|||||||
private final BulkRecordService bulkService;
|
private final BulkRecordService bulkService;
|
||||||
private final DictionaryDefinitionService definitionService;
|
private final DictionaryDefinitionService definitionService;
|
||||||
private final ScopeContext scopeContext;
|
private final ScopeContext scopeContext;
|
||||||
|
private final CascadeCloseService cascadeService;
|
||||||
|
|
||||||
public DictionaryRecordController(
|
public DictionaryRecordController(
|
||||||
DictionaryRecordService service,
|
DictionaryRecordService service,
|
||||||
BulkRecordService bulkService,
|
BulkRecordService bulkService,
|
||||||
DictionaryDefinitionService definitionService,
|
DictionaryDefinitionService definitionService,
|
||||||
ScopeContext scopeContext) {
|
ScopeContext scopeContext,
|
||||||
|
CascadeCloseService cascadeService) {
|
||||||
this.service = service;
|
this.service = service;
|
||||||
this.bulkService = bulkService;
|
this.bulkService = bulkService;
|
||||||
this.definitionService = definitionService;
|
this.definitionService = definitionService;
|
||||||
this.scopeContext = scopeContext;
|
this.scopeContext = scopeContext;
|
||||||
|
this.cascadeService = cascadeService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{businessKey}")
|
@GetMapping("/{businessKey}")
|
||||||
@@ -98,6 +102,48 @@ public class DictionaryRecordController {
|
|||||||
service.close(name, businessKey, at, reason);
|
service.close(name, businessKey, at, reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cascade preview (Phase 3): READ-ONLY оценка cascade plan. Ничего не
|
||||||
|
* меняет. UI вызывает перед close чтобы показать "this will close N
|
||||||
|
* records: ...".
|
||||||
|
*
|
||||||
|
* <p>Возвращает 200 с {@link CascadeCloseService.CascadePlan} независимо
|
||||||
|
* от того есть ли blockers — UI сам отображает разные иконки per mode.
|
||||||
|
*/
|
||||||
|
@GetMapping("/{businessKey}/cascade-preview")
|
||||||
|
public CascadeCloseService.CascadePlan cascadePreview(
|
||||||
|
@PathVariable String name,
|
||||||
|
@PathVariable String businessKey,
|
||||||
|
@RequestParam(required = false) OffsetDateTime at) {
|
||||||
|
requireReadAccess(name);
|
||||||
|
return cascadeService.evaluatePlan(name, businessKey, at);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cascade close (Phase 3): atomic close target + all CASCADE dependents.
|
||||||
|
* Требует {@code confirmed=true} в query params когда план содержит
|
||||||
|
* cascade entries (UX guard против accidental cascade).
|
||||||
|
*
|
||||||
|
* <p>Errors:
|
||||||
|
* <ul>
|
||||||
|
* <li>409 {@code x_references_blocked_by_dependents} — есть BLOCK deps.</li>
|
||||||
|
* <li>409 {@code x_references_cascade_required} — есть CASCADE deps,
|
||||||
|
* но {@code confirmed=false}.</li>
|
||||||
|
* <li>400 {@code x_references_cascade_too_large} — cascade size {@literal >} 500.</li>
|
||||||
|
* <li>503 {@code x_references_cascade_timeout} — tx exceeded 30s.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@PostMapping("/{businessKey}/cascade-close")
|
||||||
|
public CascadeCloseService.CascadeCloseResult cascadeClose(
|
||||||
|
@PathVariable String name,
|
||||||
|
@PathVariable String businessKey,
|
||||||
|
@RequestParam(required = false) OffsetDateTime at,
|
||||||
|
@RequestParam(required = false) String reason,
|
||||||
|
@RequestParam(defaultValue = "false") boolean confirmed) {
|
||||||
|
requireWriteAccess(name);
|
||||||
|
return cascadeService.executeCascade(name, businessKey, at, reason, confirmed);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bulk close: закрыть N записей одним запросом. Per-record транзакция —
|
* Bulk close: закрыть N записей одним запросом. Per-record транзакция —
|
||||||
* partial success возможен (e.g. 7 из 10 закрылись, 3 уже были закрыты).
|
* partial success возможен (e.g. 7 из 10 закрылись, 3 уже были закрыты).
|
||||||
|
|||||||
+221
@@ -0,0 +1,221 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.restapi.service.reference;
|
||||||
|
|
||||||
|
import cloud.nstart.terravault.ordinis.auth.ScopeContext;
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.DataScope;
|
||||||
|
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
|
||||||
|
import cloud.nstart.terravault.ordinis.restapi.service.reference.CascadeCloseService.RecordCloseSink;
|
||||||
|
import cloud.nstart.terravault.ordinis.restapi.service.reference.LineageIndexService.PerSourceSummary;
|
||||||
|
import cloud.nstart.terravault.ordinis.restapi.service.reference.LineageIndexService.RecordDependent;
|
||||||
|
import cloud.nstart.terravault.ordinis.restapi.service.reference.LineageIndexService.RecordDependentsPage;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тесты {@link CascadeCloseService}: branching по onClose mode, blockers
|
||||||
|
* → 409, cascade-required → 409, atomic execute, max size guard.
|
||||||
|
*
|
||||||
|
* <p>JDK 25 + Mockito не мокают concrete classes (LineageIndexService,
|
||||||
|
* DictionaryDefinitionService и др.). Используем seam {@link RecordCloseSink}
|
||||||
|
* + anonymous subclass LineageIndexService — никаких реальных deps.
|
||||||
|
*/
|
||||||
|
class CascadeCloseServiceTest {
|
||||||
|
|
||||||
|
private ScopeContext scopeContext;
|
||||||
|
/** Stub state — каждый тест перезаписывает. */
|
||||||
|
private RecordDependentsPage stubPage;
|
||||||
|
private final List<CloseCall> closeCalls = new ArrayList<>();
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
// ScopeContext concrete class — JDK 25 + Mockito incompat. Anonymous
|
||||||
|
// subclass возвращает statically PUBLIC для всех тестов.
|
||||||
|
scopeContext = new ScopeContext(null) {
|
||||||
|
@Override
|
||||||
|
public Set<DataScope> currentScopes() {
|
||||||
|
return Set.of(DataScope.PUBLIC);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
stubPage = emptyPage("dict_t", "k");
|
||||||
|
closeCalls.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Создаёт CascadeCloseService с stub-LineageIndexService и tracking sink. */
|
||||||
|
private CascadeCloseService makeService() {
|
||||||
|
LineageIndexService stubLineage = new LineageIndexService(null, null, null) {
|
||||||
|
@Override
|
||||||
|
public RecordDependentsPage findRecordDependents(
|
||||||
|
String targetDictName, String targetBusinessKey,
|
||||||
|
Set<DataScope> allowedScopes, int page, int size) {
|
||||||
|
return stubPage;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
RecordCloseSink trackingSink = (dict, key, when, reason) ->
|
||||||
|
closeCalls.add(new CloseCall(dict, key, when, reason));
|
||||||
|
return new CascadeCloseService(stubLineage, trackingSink, scopeContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === evaluatePlan ===
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void evaluatePlan_emptyDependents_returnsEmptyPlan() {
|
||||||
|
var plan = makeService().evaluatePlan("dict_t", "k", null);
|
||||||
|
assertThat(plan.blockers()).isEmpty();
|
||||||
|
assertThat(plan.warnings()).isEmpty();
|
||||||
|
assertThat(plan.cascade()).isEmpty();
|
||||||
|
assertThat(plan.totalDependents()).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void evaluatePlan_splitsByOnCloseMode() {
|
||||||
|
stubPage = pageWith(List.of(
|
||||||
|
recordDep("src_block", "rec1", "f1", OnCloseAction.BLOCK),
|
||||||
|
recordDep("src_warn", "rec2", "f2", OnCloseAction.WARN),
|
||||||
|
recordDep("src_cascade", "rec3", "f3", OnCloseAction.CASCADE),
|
||||||
|
recordDep("src_block", "rec4", "f1", OnCloseAction.BLOCK)),
|
||||||
|
"dict_t", "k", 4L);
|
||||||
|
|
||||||
|
var plan = makeService().evaluatePlan("dict_t", "k", null);
|
||||||
|
|
||||||
|
assertThat(plan.blockers()).hasSize(2);
|
||||||
|
assertThat(plan.warnings()).hasSize(1);
|
||||||
|
assertThat(plan.cascade()).hasSize(1);
|
||||||
|
assertThat(plan.totalDependents()).isEqualTo(4L);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === executeCascade ===
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void executeCascade_throws409WhenBlockersExist() {
|
||||||
|
stubPage = pageWith(
|
||||||
|
List.of(recordDep("src", "rec1", "f", OnCloseAction.BLOCK)),
|
||||||
|
"dict_t", "k", 1L);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> makeService().executeCascade("dict_t", "k", null, "test", true))
|
||||||
|
.isInstanceOf(OrdinisException.class)
|
||||||
|
.satisfies(e -> assertThat(((OrdinisException) e).getStatus())
|
||||||
|
.isEqualTo(HttpStatus.CONFLICT))
|
||||||
|
.hasMessageContaining("block close");
|
||||||
|
assertThat(closeCalls).isEmpty(); // Nothing closed.
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void executeCascade_requiresConfirmWhenCascadeNonEmpty() {
|
||||||
|
stubPage = pageWith(
|
||||||
|
List.of(recordDep("src", "rec1", "f", OnCloseAction.CASCADE)),
|
||||||
|
"dict_t", "k", 1L);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> makeService().executeCascade("dict_t", "k", null, "test", false))
|
||||||
|
.isInstanceOf(OrdinisException.class)
|
||||||
|
.hasMessageContaining("Re-call with confirmed=true");
|
||||||
|
assertThat(closeCalls).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void executeCascade_warnOnly_proceedsWithoutCascade() {
|
||||||
|
stubPage = pageWith(
|
||||||
|
List.of(recordDep("src", "rec1", "f", OnCloseAction.WARN)),
|
||||||
|
"dict_t", "k", 1L);
|
||||||
|
|
||||||
|
var result = makeService().executeCascade("dict_t", "k", null, "test", false);
|
||||||
|
|
||||||
|
assertThat(result.cascadedRecords()).isEmpty();
|
||||||
|
assertThat(result.warnings()).hasSize(1);
|
||||||
|
assertThat(result.totalClosed()).isEqualTo(1); // только target
|
||||||
|
assertThat(closeCalls).hasSize(1);
|
||||||
|
assertThat(closeCalls.get(0).dict()).isEqualTo("dict_t");
|
||||||
|
assertThat(closeCalls.get(0).reason()).isEqualTo("test");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void executeCascade_cascadeMode_closesTargetAndDependentsAtomically() {
|
||||||
|
stubPage = pageWith(List.of(
|
||||||
|
recordDep("src_a", "rec1", "f", OnCloseAction.CASCADE),
|
||||||
|
recordDep("src_b", "rec2", "f", OnCloseAction.CASCADE)),
|
||||||
|
"dict_t", "k", 2L);
|
||||||
|
|
||||||
|
var result = makeService().executeCascade("dict_t", "k", null, "manual", true);
|
||||||
|
|
||||||
|
assertThat(result.cascadedRecords()).hasSize(2);
|
||||||
|
assertThat(result.warnings()).isEmpty();
|
||||||
|
assertThat(result.totalClosed()).isEqualTo(3); // target + 2 cascaded
|
||||||
|
assertThat(closeCalls).hasSize(3);
|
||||||
|
// Order: target first, then cascade entries.
|
||||||
|
assertThat(closeCalls.get(0).dict()).isEqualTo("dict_t");
|
||||||
|
assertThat(closeCalls.get(0).reason()).isEqualTo("manual");
|
||||||
|
assertThat(closeCalls.get(1).reason()).contains("cascaded from dict_t/k");
|
||||||
|
assertThat(closeCalls.get(2).reason()).contains("cascaded from dict_t/k");
|
||||||
|
var cascadedDicts = closeCalls.subList(1, 3).stream()
|
||||||
|
.map(CloseCall::dict).toList();
|
||||||
|
assertThat(cascadedDicts).containsExactlyInAnyOrder("src_a", "src_b");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void executeCascade_blockOverridesCascade_409EvenWithConfirm() {
|
||||||
|
stubPage = pageWith(List.of(
|
||||||
|
recordDep("src_block", "rec1", "f", OnCloseAction.BLOCK),
|
||||||
|
recordDep("src_cascade", "rec2", "f", OnCloseAction.CASCADE)),
|
||||||
|
"dict_t", "k", 2L);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> makeService().executeCascade("dict_t", "k", null, "test", true))
|
||||||
|
.isInstanceOf(OrdinisException.class)
|
||||||
|
.hasMessageContaining("block close");
|
||||||
|
assertThat(closeCalls).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void executeCascade_emptyPlan_closesOnlyTarget() {
|
||||||
|
var result = makeService().executeCascade("dict_t", "k", null, "manual", false);
|
||||||
|
|
||||||
|
assertThat(result.cascadedRecords()).isEmpty();
|
||||||
|
assertThat(result.warnings()).isEmpty();
|
||||||
|
assertThat(result.totalClosed()).isEqualTo(1);
|
||||||
|
assertThat(closeCalls).hasSize(1);
|
||||||
|
assertThat(closeCalls.get(0).dict()).isEqualTo("dict_t");
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Helpers ===
|
||||||
|
|
||||||
|
private record CloseCall(String dict, String key, OffsetDateTime when, String reason) {}
|
||||||
|
|
||||||
|
private static RecordDependent recordDep(
|
||||||
|
String sourceDict, String businessKey, String sourceField, OnCloseAction onClose) {
|
||||||
|
return new RecordDependent(
|
||||||
|
sourceDict, sourceDict + " (display)", sourceField, onClose,
|
||||||
|
UUID.randomUUID(), businessKey,
|
||||||
|
OffsetDateTime.parse("2026-01-01T00:00:00Z"),
|
||||||
|
OffsetDateTime.MAX,
|
||||||
|
OffsetDateTime.parse("2026-01-01T00:00:00Z"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RecordDependentsPage emptyPage(String targetDict, String targetKey) {
|
||||||
|
return new RecordDependentsPage(
|
||||||
|
targetDict, targetKey, List.of(), List.of(), 0L, 0, 50, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RecordDependentsPage pageWith(
|
||||||
|
List<RecordDependent> items, String targetDict, String targetKey, long total) {
|
||||||
|
List<PerSourceSummary> perSource = new ArrayList<>();
|
||||||
|
var grouped = new java.util.LinkedHashMap<String, Long>();
|
||||||
|
for (var d : items) grouped.merge(d.sourceDict() + "." + d.sourceField(), 1L, Long::sum);
|
||||||
|
for (var entry : grouped.entrySet()) {
|
||||||
|
var match = items.stream()
|
||||||
|
.filter(it -> (it.sourceDict() + "." + it.sourceField()).equals(entry.getKey()))
|
||||||
|
.findFirst().orElseThrow();
|
||||||
|
perSource.add(new PerSourceSummary(
|
||||||
|
match.sourceDict(), match.sourceDisplayName(),
|
||||||
|
match.sourceField(), match.onClose(), entry.getValue()));
|
||||||
|
}
|
||||||
|
return new RecordDependentsPage(
|
||||||
|
targetDict, targetKey, items, perSource, total, 0, 50, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user