From 444c004e3c99ff0eb5b67095f8cd85a37d2a24e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=97=D0=B8=D0=BC=D0=B8=D0=BD?= Date: Mon, 22 Jun 2026 09:18:50 +0000 Subject: [PATCH] =?UTF-8?q?feat(record):=20REOPEN=20operation=20=E2=80=94?= =?UTF-8?q?=20=D0=B2=D0=B5=D1=80=D0=BD=D1=83=D1=82=D1=8C=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BA=D1=80=D1=8B=D1=82=D1=83=D1=8E=20=D0=B7=D0=B0=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ordinis-admin-ui/src/api/client.ts | 2 +- .../components/record/RecordHistoryDrawer.tsx | 19 ++++- ordinis-admin-ui/src/i18n.ts | 4 + .../src/routes/dictionaries.$name.tsx | 20 +++++ .../ordinis/domain/draft/DraftOperation.java | 4 +- .../ordinis/restapi/audit/AuditLogger.java | 5 ++ .../restapi/dto/draft/SubmitDraftRequest.java | 2 +- .../service/DictionaryRecordService.java | 80 +++++++++++++++++++ .../restapi/service/draft/DraftService.java | 2 + .../service/draft/DraftServiceTest.java | 18 +++++ 10 files changed, 151 insertions(+), 5 deletions(-) diff --git a/ordinis-admin-ui/src/api/client.ts b/ordinis-admin-ui/src/api/client.ts index 9265333..fddd896 100644 --- a/ordinis-admin-ui/src/api/client.ts +++ b/ordinis-admin-ui/src/api/client.ts @@ -359,7 +359,7 @@ export type WebhookDeliveryStats = { // === Approval Workflow v2 === export type DraftStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | 'WITHDRAWN' -export type DraftOperation = 'CREATE' | 'UPDATE' | 'CLOSE' +export type DraftOperation = 'CREATE' | 'UPDATE' | 'CLOSE' | 'REOPEN' export type DraftResponse = { id: string diff --git a/ordinis-admin-ui/src/components/record/RecordHistoryDrawer.tsx b/ordinis-admin-ui/src/components/record/RecordHistoryDrawer.tsx index 45eee68..83144ac 100644 --- a/ordinis-admin-ui/src/components/record/RecordHistoryDrawer.tsx +++ b/ordinis-admin-ui/src/components/record/RecordHistoryDrawer.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { Badge, Button, Drawer, LoadingBlock, QueryErrorState } from '@/ui' -import { ArrowsLeftRightIcon, ArrowCounterClockwiseIcon, BracketsCurlyIcon, InfoIcon } from '@phosphor-icons/react' +import { ArrowsLeftRightIcon, ArrowCounterClockwiseIcon, ArrowUUpLeftIcon, BracketsCurlyIcon, InfoIcon } from '@phosphor-icons/react' import { useRecordHistory } from '@/api/queries' import { RecordDependentsPanel } from '@/components/lineage/RecordDependentsPanel' import { UserCell } from '@/lib/useUserDisplay' @@ -18,10 +18,14 @@ type Props = { /** Optional — invoked для "Откатить к v1.X.X". Parent submits update * mutation с этой версией как payload. Если undefined — кнопка скрыта. */ onRevert?: (versionData: unknown, displayVersion: number) => void + /** Optional — invoked для "Открыть заново" самой свежей closed-версии. + * Parent submits REOPEN draft через submitDraftMut. Если undefined — кнопка + * скрыта (нет права record:close / non-admin context). */ + onReopen?: () => void } export const RecordHistoryDrawer = ({ - open, onClose, dictionaryName, businessKey, onCompare, onRevert, + open, onClose, dictionaryName, businessKey, onCompare, onRevert, onReopen, }: Props) => { const { t } = useTranslation() const { data, isLoading, error, refetch } = useRecordHistory(dictionaryName, open ? businessKey : undefined) @@ -157,6 +161,17 @@ export const RecordHistoryDrawer = ({ {t('history.revert', { defaultValue: `Откатить к v${displayVersion}` })} )} + {isLatest && status === 'expired' && onReopen && ( + + )} {/* Single-version hint: объясняем, почему Compare/Revert * отсутствуют. Раньше пользователь видел только JSON-ссылку diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index 4a3f38d..6b19081 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -530,6 +530,8 @@ i18n 'history.compare': 'Сравнить с текущим', 'history.revert': 'Откатить к v{{v}}', 'history.revertConfirm': 'Откатить к v{{v}}? Создастся новая активная версия.', + 'history.reopen': 'Открыть заново', + 'history.reopenConfirm': 'Открыть запись заново? Будет создан REOPEN-draft, требуется approve.', 'history.singleVersionHint': 'Это единственная версия. Кнопки «Сравнить» и «Откатить» появятся, когда у записи будет более одной версии.', // === Changelog (schema timeline + diff) === 'changelog.empty.title': 'История пуста', @@ -1386,6 +1388,8 @@ i18n 'history.compare': 'Compare with current', 'history.revert': 'Revert to v{{v}}', 'history.revertConfirm': 'Revert to v{{v}}? A new active version will be created.', + 'history.reopen': 'Reopen', + 'history.reopenConfirm': 'Reopen this record? A REOPEN draft will be created and needs approval.', 'history.singleVersionHint': 'This is the only version. Compare and Revert actions will appear once the record has more than one version.', // === Changelog (schema timeline + diff) === 'changelog.empty.title': 'No history yet', diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx index d07d77b..2198937 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx @@ -1719,6 +1719,26 @@ function DictionaryDetail() { }, }) } : undefined} + // Reopen: submit REOPEN draft. Same approval gate как CLOSE — пройдёт + // через DRAFT_SUBMIT → DRAFT_APPROVE. Admin self-approve работает + // если admin role есть. Backend применит applyApprovedReopen и + // создаст новую active версию с данными последней closed-версии. + onReopen={canMutate ? () => { + if (!historyKey) return + const confirmed = window.confirm( + t('history.reopenConfirm', { defaultValue: 'Открыть запись заново? Будет создан REOPEN-draft, требуется approve.' }) as string + ) + if (!confirmed) return + submitDraftMut.mutate({ + businessKey: historyKey, + operation: 'REOPEN', + comment: null, + }, { + onSuccess: () => { + setHistoryKey(undefined) + }, + }) + } : undefined} /> Validation: + * + * + *

Audit: {@code RECORD_REOPENED}, event: {@code RecordUpdated} + * (consumers смотрят на validTo и видят, что запись снова активна). + */ + @Transactional(isolation = Isolation.SERIALIZABLE) + public void applyApprovedReopen(String dictionaryName, String businessKey, String reason) { + reopenDirect(dictionaryName, businessKey, reason); + } + /** Internal CREATE — bypass approval gate. Same logic as create(). */ private DictionaryRecord createDirect(String dictionaryName, CreateRecordRequest req) { var d = definitionService.findByName(dictionaryName); @@ -404,6 +424,66 @@ public class DictionaryRecordService { publishDeleted(d, current, when, reason); } + /** + * Internal REOPEN — bypass approval gate. Берёт самую недавно закрытую + * версию (max valid_to среди validTo < FAR_FUTURE) и продлевает её до + * infinity. + */ + private void reopenDirect(String dictionaryName, String businessKey, String reason) { + var d = definitionService.findByName(dictionaryName); + OffsetDateTime now = OffsetDateTime.now(); + + // Не должно быть активной версии прямо сейчас (иначе exclusion constraint + // бросит непонятную DB-ошибку). + if (repository.findActiveAt(d.getId(), businessKey, now).isPresent()) { + throw OrdinisException.conflict( + "record_already_active", + "business_key='" + businessKey + "' уже активен — re-open не нужен."); + } + + // findByDictionaryIdAndBusinessKeyOrderByValidFromDesc возвращает версии + // отсортированными по validFrom desc. Самая недавно закрытая — это первый + // элемент, у которого validTo != FAR_FUTURE (closed) и validTo больше всех + // других closed. Так как версии bitemporal не пересекаются (exclusion + // constraint), а closed period — последний по validFrom, то это и есть + // первый matching элемент. + var prevData = repository + .findByDictionaryIdAndBusinessKeyOrderByValidFromDesc(d.getId(), businessKey) + .stream() + .filter(r -> r.getValidTo() != null && r.getValidTo().isBefore(FAR_FUTURE)) + .findFirst() + .orElseThrow(() -> OrdinisException.notFound( + "record_not_closed", + "Нет закрытой версии business_key='" + businessKey + "' для re-open.")); + + UUID previousRecordId = prevData.getId(); + var prevDataJson = prevData.getData(); + + // Создаём новую версию (новый UUID) с теми же данными и validFrom = now, + // validTo = infinity. Старая closed-версия остаётся в истории как trail — + // это симметрично UPDATE flow, не in-place mutation. Bitemporal: история + // append-only. + var next = new DictionaryRecord( + UUID.randomUUID(), d.getId(), businessKey, + prevData.getData(), prevData.getDataScope(), now); + next.setValidTo(FAR_FUTURE); + next.setGeometry(prevData.getGeometry()); + + DictionaryRecord saved; + try { + saved = repository.saveAndFlush(next); + } catch (DataIntegrityViolationException ex) { + if (isExclusionViolation(ex)) { + throw OrdinisException.conflict( + "record_period_overlap", + "Re-open business_key='" + businessKey + "' пересекается с существующим периодом."); + } + throw ex; + } + auditLogger.recordReopen(d, saved); + publishUpdated(d, saved, previousRecordId, prevDataJson); + } + private void validate(DictionaryDefinition d, CreateRecordRequest req) { ValidationResult vr = validator.validate(d, req.data()); if (!vr.valid()) throw new ValidationException(vr.errors()); diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftService.java index 53f765d..e7214b2 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftService.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftService.java @@ -289,6 +289,8 @@ public class DraftService { case CLOSE -> recordService.applyApprovedClose( def.getName(), draft.getBusinessKey(), draft.getProposedValidFrom(), reviewComment); + case REOPEN -> recordService.applyApprovedReopen( + def.getName(), draft.getBusinessKey(), reviewComment); } // Mark approved (kept в таблице — D4 audit trail). Delete не делаем — diff --git a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftServiceTest.java b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftServiceTest.java index f752bf7..e4e24b0 100644 --- a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftServiceTest.java +++ b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftServiceTest.java @@ -155,6 +155,9 @@ class DraftServiceTest { String dict, String bk, java.time.OffsetDateTime closeAt, String reason) { calls.add("CLOSE:" + dict + ":" + bk + ":" + reason); } + @Override public void applyApprovedReopen(String dict, String bk, String reason) { + calls.add("REOPEN:" + dict + ":" + bk + ":" + reason); + } } private DraftService makeServiceWithRecord(TrackingRecordService rs) { @@ -187,6 +190,21 @@ class DraftServiceTest { clearAuth(); } + @Test + void approve_dispatchesReopenToRecordService() throws Exception { + var rs = new TrackingRecordService(); + var svc = makeServiceWithRecord(rs); + setAuth("makerA"); + RecordDraft created = svc.submit("test_dict", new SubmitDraftRequest( + "BK-REOPEN", DraftOperation.REOPEN, + null, null, null, null, null)); + setAuth("reviewerB"); + svc.approve(created.getId(), "reopen approved"); + + assertThat(rs.calls).contains("REOPEN:test_dict:BK-REOPEN:reopen approved"); + clearAuth(); + } + @Test void approve_blockedWhenSelfApprove() throws Exception { var rs = new TrackingRecordService();