From 3eeaba058f2d4637fd5ccd344f74bb0cfa5e0f67 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Fri, 8 May 2026 11:23:46 +0300 Subject: [PATCH] =?UTF-8?q?feat(cascade):=20Phase=203=20=E2=80=94=20Cascad?= =?UTF-8?q?eCloseService=20+=20409=20routing=20+=20UI=20confirm=20dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 покрывает. --- ordinis-admin-ui/src/api/client.ts | 26 ++ ordinis-admin-ui/src/api/mutations.ts | 44 +++ ordinis-admin-ui/src/api/queries.ts | 26 ++ .../lineage/CascadeConfirmDialog.tsx | 289 ++++++++++++++++++ ordinis-admin-ui/src/i18n.ts | 56 ++++ .../src/routes/dictionaries.$name.tsx | 35 ++- .../service/DictionaryRecordService.java | 55 +++- .../reference/CascadeCloseService.java | 264 ++++++++++++++++ .../web/DictionaryRecordController.java | 48 ++- .../reference/CascadeCloseServiceTest.java | 221 ++++++++++++++ 10 files changed, 1055 insertions(+), 9 deletions(-) create mode 100644 ordinis-admin-ui/src/components/lineage/CascadeConfirmDialog.tsx create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/CascadeCloseService.java create mode 100644 ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/reference/CascadeCloseServiceTest.java diff --git a/ordinis-admin-ui/src/api/client.ts b/ordinis-admin-ui/src/api/client.ts index 87b97c8..9712426 100644 --- a/ordinis-admin-ui/src/api/client.ts +++ b/ordinis-admin-ui/src/api/client.ts @@ -286,6 +286,32 @@ export type LineageMeta = { 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 = { targetDict: string targetBusinessKey: string diff --git a/ordinis-admin-ui/src/api/mutations.ts b/ordinis-admin-ui/src/api/mutations.ts index a8a13a9..1a88fb4 100644 --- a/ordinis-admin-ui/src/api/mutations.ts +++ b/ordinis-admin-ui/src/api/mutations.ts @@ -3,6 +3,7 @@ import { apiClient, type BulkCloseRequest, type BulkCloseResponse, + type CascadeCloseResult, type CreateDictionaryRequest, type CreateRecordRequest, 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`. + * + *

Backend errors: + *

+ */ +export const useCascadeCloseRecord = (dictionaryName: string) => { + const qc = useQueryClient() + return useMutation({ + mutationFn: async (params: { + businessKey: string + reason?: string + confirmed: boolean + }): Promise => { + const queryParams: Record = { + confirmed: params.confirmed, + } + if (params.reason) queryParams.reason = params.reason + const { data } = await apiClient.post( + `/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 транзакция, * partial success возможен (already_closed / not_found попадает в skipped). diff --git a/ordinis-admin-ui/src/api/queries.ts b/ordinis-admin-ui/src/api/queries.ts index bb290f4..fbd262c 100644 --- a/ordinis-admin-ui/src/api/queries.ts +++ b/ordinis-admin-ui/src/api/queries.ts @@ -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. + * + *

Не cache'ится агрессивно (staleTime=0), потому что dependents + * быстро меняются — preview всегда должен быть fresh. + */ +export const cascadePreviewQuery = (dict: string, businessKey: string) => + queryOptions({ + queryKey: ['cascade-preview', dict, businessKey] as const, + queryFn: async (): Promise => { + const { data } = await apiClient.get( + `/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 = ( diff --git a/ordinis-admin-ui/src/components/lineage/CascadeConfirmDialog.tsx b/ordinis-admin-ui/src/components/lineage/CascadeConfirmDialog.tsx new file mode 100644 index 0000000..353cbd2 --- /dev/null +++ b/ordinis-admin-ui/src/components/lineage/CascadeConfirmDialog.tsx @@ -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. + * + *

Поток: + *

    + *
  1. Открывается из existing close flow когда DELETE возвращает 409 + * (x_references_blocked_by_dependents или x_references_cascade_required).
  2. + *
  3. Загружает preview через {@code GET /cascade-preview}.
  4. + *
  5. Если есть BLOCKERS — показывает их + объясняет что resolve first; + * confirm button disabled.
  6. + *
  7. Если есть только CASCADE/WARN — показывает per-source breakdown + + * sample 3 records per source, plus "Type CONFIRM" guard если N {@literal >} 50.
  8. + *
  9. Confirm → {@code POST /cascade-close?confirmed=true} → success message.
  10. + *
  11. Cancel → close dialog без изменений.
  12. + *
+ * + *

UX guards: + *

    + *
  • "Type CONFIRM" gate когда total > 50 (защита от accidental cascade).
  • + *
  • Reason field — optional; пустой = backend audit fields пустые.
  • + *
+ */ +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() + 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 ( + +
+ {preview.isLoading && } + + {preview.error && ( + + {String(preview.error)} + + )} + + {plan && ( + <> + {/* Header summary */} +

+ {hasBlockers + ? t('cascade.summary.blocked', { + blockers: plan.blockers.length, + target: businessKey, + }) + : t('cascade.summary.willClose', { + total: totalToClose, + target: businessKey, + })} +

+ + {/* BLOCKERS section — non-resolvable. */} + {plan.blockers.length > 0 && ( + +
    + {plan.blockers.slice(0, 5).map((b) => ( +
  • + + + {b.sourceDict}/{b.businessKey} + + .{b.sourceField} +
  • + ))} + {plan.blockers.length > 5 && ( +
  • + {t('cascade.blockers.more', { count: plan.blockers.length - 5 })} +
  • + )} +
+

+ {t('cascade.blockers.resolveFirst')} +

+
+ )} + + {/* CASCADE section — preview per-source. */} + {!hasBlockers && plan.cascade.length > 0 && ( +
+
+ +

+ {t('cascade.cascade.title', { count: plan.cascade.length })} +

+
+
    + {cascadeBySource.map(([key, entries]) => { + const first = entries[0] + const sample = entries.slice(0, SAMPLE_PER_SOURCE) + const overflow = entries.length - sample.length + return ( +
  • +
    + + {first.sourceDisplayName ?? first.sourceDict} + + + .{first.sourceField} + + + {t(`lineage.onClose.${first.onClose}`)} + + {entries.length} +
    +
      + {sample.map((e) => ( +
    • + · {e.businessKey} +
    • + ))} + {overflow > 0 && ( +
    • + {t('cascade.cascade.more', { count: overflow })} +
    • + )} +
    +
  • + ) + })} +
+
+ )} + + {/* WARNINGS section — proceed but emit warnings. */} + {!hasBlockers && plan.warnings.length > 0 && ( + +

{t('cascade.warnings.note')}

+
+ )} + + {/* Reason input — only when going to proceed. */} + {!hasBlockers && (plan.cascade.length > 0 || plan.warnings.length > 0) && ( +
+ + setReason(e.target.value)} + placeholder={t('cascade.reason.placeholder')} + /> +
+ )} + + {/* Type CONFIRM gate when N > 50. */} + {!hasBlockers && requiresGate && ( +
+ + setConfirmText(e.target.value)} + placeholder="CONFIRM" + /> +
+ )} + + {cascadeMut.error && ( + + {String(cascadeMut.error)} + + )} + + )} + + {/* Footer actions */} +
+ + {!hasBlockers && plan && (plan.cascade.length > 0 || plan.warnings.length > 0) && ( + + )} +
+
+
+ ) +} diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index 5ae2774..7b592a1 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -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', diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx index 0dcc5aa..dfdd78c 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx @@ -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(undefined) const [aoiOpen, setAoiOpen] = useState(false) + /** Phase 3 dict-relationships-v2: cascade dialog state. Открывается из 409 + * на close, или явным action из record menu. */ + const [cascadeKey, setCascadeKey] = useState(undefined) // Multi-select state. Set — стабильное 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} /> + setCascadeKey(undefined)} + onSuccess={() => { + setCascadeKey(undefined) + recordsResult.refetch() + }} + dictionaryName={name} + businessKey={cascadeKey} + /> + setAoiOpen(false)} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordService.java index 6d52aa6..299abea 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordService.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordService.java @@ -47,9 +47,33 @@ public class DictionaryRecordService { private final cloud.nstart.terravault.ordinis.auth.ScopeContext scopeContext; private final OutboxRecorder outbox; private final AuditLogger auditLogger; + /** Phase 3 dict-relationships-v2: pre-close проверка cascade plan. Optional — + * тесты с null deps инжектят null. */ + private final java.util.Optional cascadeService; 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 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( DictionaryRecordRepository repository, DictionaryDefinitionService definitionService, @@ -58,13 +82,8 @@ public class DictionaryRecordService { cloud.nstart.terravault.ordinis.auth.ScopeContext scopeContext, OutboxRecorder outbox, AuditLogger auditLogger) { - this.repository = repository; - this.definitionService = definitionService; - this.validator = validator; - this.referenceValidator = referenceValidator; - this.scopeContext = scopeContext; - this.outbox = outbox; - this.auditLogger = auditLogger; + this(repository, definitionService, validator, referenceValidator, + scopeContext, outbox, auditLogger, java.util.Optional.empty()); } @Transactional(readOnly = true) @@ -184,6 +203,28 @@ public class DictionaryRecordService { var d = definitionService.findByName(dictionaryName); 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) .orElseThrow(() -> OrdinisException.notFound( "record_not_active", diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/CascadeCloseService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/CascadeCloseService.java new file mode 100644 index 0000000..98e7fa0 --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/CascadeCloseService.java @@ -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. + * + *

Запускается когда close target record имеет dependents (записи в других + * словарях через {@code x-references}). Branching по {@link OnCloseAction} + * объявленному в schema referencing dict'а: + * + *

    + *
  • {@link OnCloseAction#BLOCK} — close отклоняется с 409 + * {@code x_references_blocked_by_dependents}, dependents list в response.
  • + *
  • {@link OnCloseAction#WARN} — close проходит, warnings возвращаются + * в response (orphan scanner подхватит на metric).
  • + *
  • {@link OnCloseAction#CASCADE} — требует explicit confirm через + * cascade-close endpoint. Direct dependents закрываются в той же + * transaction (one hop, F.OUT по design).
  • + *
+ * + *

Concurrency: SERIALIZABLE isolation. Postgres exclusion constraint + + * SERIALIZABLE retry — second concurrent close видит conflict и retry'ится + * (или возвращает 409 если retry exhausted). + * + *

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 scopes = scopeContext.currentScopes(); + OffsetDateTime when = at == null ? OffsetDateTime.now() : at; + var page = lineage.findRecordDependents( + targetDict, targetKey, scopes, 0, CASCADE_MAX_PER_REQUEST); + + List blockers = new ArrayList<>(); + List warnings = new ArrayList<>(); + List 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 blockers, + List warnings, + List cascade, + long totalDependents) {} + + /** Результат execute: сколько записей закрылось + warnings. */ + public record CascadeCloseResult( + String targetDict, + String targetKey, + List cascadedRecords, + List warnings, + int totalClosed) {} +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryRecordController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryRecordController.java index 04dd12b..c8d9311 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryRecordController.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryRecordController.java @@ -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.DictionaryDefinitionService; import cloud.nstart.terravault.ordinis.restapi.service.DictionaryRecordService; +import cloud.nstart.terravault.ordinis.restapi.service.reference.CascadeCloseService; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.DeleteMapping; @@ -35,16 +36,19 @@ public class DictionaryRecordController { private final BulkRecordService bulkService; private final DictionaryDefinitionService definitionService; private final ScopeContext scopeContext; + private final CascadeCloseService cascadeService; public DictionaryRecordController( DictionaryRecordService service, BulkRecordService bulkService, DictionaryDefinitionService definitionService, - ScopeContext scopeContext) { + ScopeContext scopeContext, + CascadeCloseService cascadeService) { this.service = service; this.bulkService = bulkService; this.definitionService = definitionService; this.scopeContext = scopeContext; + this.cascadeService = cascadeService; } @GetMapping("/{businessKey}") @@ -98,6 +102,48 @@ public class DictionaryRecordController { service.close(name, businessKey, at, reason); } + /** + * Cascade preview (Phase 3): READ-ONLY оценка cascade plan. Ничего не + * меняет. UI вызывает перед close чтобы показать "this will close N + * records: ...". + * + *

Возвращает 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). + * + *

Errors: + *

    + *
  • 409 {@code x_references_blocked_by_dependents} — есть BLOCK deps.
  • + *
  • 409 {@code x_references_cascade_required} — есть CASCADE deps, + * но {@code confirmed=false}.
  • + *
  • 400 {@code x_references_cascade_too_large} — cascade size {@literal >} 500.
  • + *
  • 503 {@code x_references_cascade_timeout} — tx exceeded 30s.
  • + *
+ */ + @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 транзакция — * partial success возможен (e.g. 7 из 10 закрылись, 3 уже были закрыты). diff --git a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/reference/CascadeCloseServiceTest.java b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/reference/CascadeCloseServiceTest.java new file mode 100644 index 0000000..616a044 --- /dev/null +++ b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/reference/CascadeCloseServiceTest.java @@ -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. + * + *

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 closeCalls = new ArrayList<>(); + + @BeforeEach + void setUp() { + // ScopeContext concrete class — JDK 25 + Mockito incompat. Anonymous + // subclass возвращает statically PUBLIC для всех тестов. + scopeContext = new ScopeContext(null) { + @Override + public Set 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 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 items, String targetDict, String targetKey, long total) { + List perSource = new ArrayList<>(); + var grouped = new java.util.LinkedHashMap(); + 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); + } +}