+
+ )
+}
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}.