Merge branch 'feat/record-reopen' into 'main'
feat(record): REOPEN operation — вернуть закрытую запись See merge request 2-6/2-6-4/terravault/ordinis!304
This commit is contained in:
@@ -359,7 +359,7 @@ export type WebhookDeliveryStats = {
|
|||||||
// === Approval Workflow v2 ===
|
// === Approval Workflow v2 ===
|
||||||
|
|
||||||
export type DraftStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | 'WITHDRAWN'
|
export type DraftStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | 'WITHDRAWN'
|
||||||
export type DraftOperation = 'CREATE' | 'UPDATE' | 'CLOSE'
|
export type DraftOperation = 'CREATE' | 'UPDATE' | 'CLOSE' | 'REOPEN'
|
||||||
|
|
||||||
export type DraftResponse = {
|
export type DraftResponse = {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Badge, Button, Drawer, LoadingBlock, QueryErrorState } from '@/ui'
|
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 { useRecordHistory } from '@/api/queries'
|
||||||
import { RecordDependentsPanel } from '@/components/lineage/RecordDependentsPanel'
|
import { RecordDependentsPanel } from '@/components/lineage/RecordDependentsPanel'
|
||||||
import { UserCell } from '@/lib/useUserDisplay'
|
import { UserCell } from '@/lib/useUserDisplay'
|
||||||
@@ -18,10 +18,14 @@ type Props = {
|
|||||||
/** Optional — invoked для "Откатить к v1.X.X". Parent submits update
|
/** Optional — invoked для "Откатить к v1.X.X". Parent submits update
|
||||||
* mutation с этой версией как payload. Если undefined — кнопка скрыта. */
|
* mutation с этой версией как payload. Если undefined — кнопка скрыта. */
|
||||||
onRevert?: (versionData: unknown, displayVersion: number) => void
|
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 = ({
|
export const RecordHistoryDrawer = ({
|
||||||
open, onClose, dictionaryName, businessKey, onCompare, onRevert,
|
open, onClose, dictionaryName, businessKey, onCompare, onRevert, onReopen,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { data, isLoading, error, refetch } = useRecordHistory(dictionaryName, open ? businessKey : undefined)
|
const { data, isLoading, error, refetch } = useRecordHistory(dictionaryName, open ? businessKey : undefined)
|
||||||
@@ -157,6 +161,17 @@ export const RecordHistoryDrawer = ({
|
|||||||
{t('history.revert', { defaultValue: `Откатить к v${displayVersion}` })}
|
{t('history.revert', { defaultValue: `Откатить к v${displayVersion}` })}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{isLatest && status === 'expired' && onReopen && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={onReopen}
|
||||||
|
leftIcon={<ArrowUUpLeftIcon weight="regular" size={14} />}
|
||||||
|
>
|
||||||
|
{t('history.reopen', { defaultValue: 'Открыть заново' })}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Single-version hint: объясняем, почему Compare/Revert
|
{/* Single-version hint: объясняем, почему Compare/Revert
|
||||||
* отсутствуют. Раньше пользователь видел только JSON-ссылку
|
* отсутствуют. Раньше пользователь видел только JSON-ссылку
|
||||||
|
|||||||
@@ -530,6 +530,8 @@ i18n
|
|||||||
'history.compare': 'Сравнить с текущим',
|
'history.compare': 'Сравнить с текущим',
|
||||||
'history.revert': 'Откатить к v{{v}}',
|
'history.revert': 'Откатить к v{{v}}',
|
||||||
'history.revertConfirm': 'Откатить к v{{v}}? Создастся новая активная версия.',
|
'history.revertConfirm': 'Откатить к v{{v}}? Создастся новая активная версия.',
|
||||||
|
'history.reopen': 'Открыть заново',
|
||||||
|
'history.reopenConfirm': 'Открыть запись заново? Будет создан REOPEN-draft, требуется approve.',
|
||||||
'history.singleVersionHint': 'Это единственная версия. Кнопки «Сравнить» и «Откатить» появятся, когда у записи будет более одной версии.',
|
'history.singleVersionHint': 'Это единственная версия. Кнопки «Сравнить» и «Откатить» появятся, когда у записи будет более одной версии.',
|
||||||
// === Changelog (schema timeline + diff) ===
|
// === Changelog (schema timeline + diff) ===
|
||||||
'changelog.empty.title': 'История пуста',
|
'changelog.empty.title': 'История пуста',
|
||||||
@@ -1386,6 +1388,8 @@ i18n
|
|||||||
'history.compare': 'Compare with current',
|
'history.compare': 'Compare with current',
|
||||||
'history.revert': 'Revert to v{{v}}',
|
'history.revert': 'Revert to v{{v}}',
|
||||||
'history.revertConfirm': 'Revert to v{{v}}? A new active version will be created.',
|
'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.',
|
'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 (schema timeline + diff) ===
|
||||||
'changelog.empty.title': 'No history yet',
|
'changelog.empty.title': 'No history yet',
|
||||||
|
|||||||
@@ -1719,6 +1719,26 @@ function DictionaryDetail() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
} : undefined}
|
} : 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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CascadeConfirmDialog
|
<CascadeConfirmDialog
|
||||||
|
|||||||
+3
-1
@@ -4,5 +4,7 @@ package cloud.nstart.terravault.ordinis.domain.draft;
|
|||||||
public enum DraftOperation {
|
public enum DraftOperation {
|
||||||
CREATE,
|
CREATE,
|
||||||
UPDATE,
|
UPDATE,
|
||||||
CLOSE
|
CLOSE,
|
||||||
|
/** Re-open a previously CLOSE'd record — sets valid_to back to far-future. */
|
||||||
|
REOPEN
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -86,6 +86,11 @@ public class AuditLogger {
|
|||||||
write("RECORD_CLOSED", "CLOSE", d, r, r.getData(), null);
|
write("RECORD_CLOSED", "CLOSE", d, r, r.getData(), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Re-open closed record: valid_to ⇒ infinity. Симметричен к {@link #recordClose}. */
|
||||||
|
public void recordReopen(DictionaryDefinition d, DictionaryRecord r) {
|
||||||
|
write("RECORD_REOPENED", "REOPEN", d, r, null, r.getData());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schema workflow audit events (Phase 1 v2.2.0). Schema draft lifecycle
|
* Schema workflow audit events (Phase 1 v2.2.0). Schema draft lifecycle
|
||||||
* пишется в audit_log дополнительно к outbox чтобы ChangelogService
|
* пишется в audit_log дополнительно к outbox чтобы ChangelogService
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ import java.time.OffsetDateTime;
|
|||||||
public record SubmitDraftRequest(
|
public record SubmitDraftRequest(
|
||||||
@NotBlank String businessKey,
|
@NotBlank String businessKey,
|
||||||
@NotNull DraftOperation operation,
|
@NotNull DraftOperation operation,
|
||||||
/** CREATE/UPDATE: новый payload. CLOSE: null OK. */
|
/** CREATE/UPDATE: новый payload. CLOSE / REOPEN: null OK. */
|
||||||
JsonNode data,
|
JsonNode data,
|
||||||
String geometryWkt,
|
String geometryWkt,
|
||||||
OffsetDateTime validFrom,
|
OffsetDateTime validFrom,
|
||||||
|
|||||||
+80
@@ -309,6 +309,26 @@ public class DictionaryRecordService {
|
|||||||
closeDirect(dictionaryName, businessKey, closeAt, reason);
|
closeDirect(dictionaryName, businessKey, closeAt, reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-open closed record: берёт самую свежую closed-версию (с max valid_to,
|
||||||
|
* но valid_to < FAR_FUTURE), и продлевает её valid_to до infinity.
|
||||||
|
*
|
||||||
|
* <p>Validation:
|
||||||
|
* <ul>
|
||||||
|
* <li>Должна найтись хотя бы одна closed запись.</li>
|
||||||
|
* <li>На момент re-open не должно быть активной версии того же
|
||||||
|
* business_key (иначе exclusion constraint бы упал — но проверяем
|
||||||
|
* заранее для понятной ошибки).</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>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(). */
|
/** Internal CREATE — bypass approval gate. Same logic as create(). */
|
||||||
private DictionaryRecord createDirect(String dictionaryName, CreateRecordRequest req) {
|
private DictionaryRecord createDirect(String dictionaryName, CreateRecordRequest req) {
|
||||||
var d = definitionService.findByName(dictionaryName);
|
var d = definitionService.findByName(dictionaryName);
|
||||||
@@ -404,6 +424,66 @@ public class DictionaryRecordService {
|
|||||||
publishDeleted(d, current, when, reason);
|
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) {
|
private void validate(DictionaryDefinition d, CreateRecordRequest req) {
|
||||||
ValidationResult vr = validator.validate(d, req.data());
|
ValidationResult vr = validator.validate(d, req.data());
|
||||||
if (!vr.valid()) throw new ValidationException(vr.errors());
|
if (!vr.valid()) throw new ValidationException(vr.errors());
|
||||||
|
|||||||
+2
@@ -289,6 +289,8 @@ public class DraftService {
|
|||||||
case CLOSE -> recordService.applyApprovedClose(
|
case CLOSE -> recordService.applyApprovedClose(
|
||||||
def.getName(), draft.getBusinessKey(),
|
def.getName(), draft.getBusinessKey(),
|
||||||
draft.getProposedValidFrom(), reviewComment);
|
draft.getProposedValidFrom(), reviewComment);
|
||||||
|
case REOPEN -> recordService.applyApprovedReopen(
|
||||||
|
def.getName(), draft.getBusinessKey(), reviewComment);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark approved (kept в таблице — D4 audit trail). Delete не делаем —
|
// Mark approved (kept в таблице — D4 audit trail). Delete не делаем —
|
||||||
|
|||||||
+18
@@ -155,6 +155,9 @@ class DraftServiceTest {
|
|||||||
String dict, String bk, java.time.OffsetDateTime closeAt, String reason) {
|
String dict, String bk, java.time.OffsetDateTime closeAt, String reason) {
|
||||||
calls.add("CLOSE:" + dict + ":" + bk + ":" + 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) {
|
private DraftService makeServiceWithRecord(TrackingRecordService rs) {
|
||||||
@@ -187,6 +190,21 @@ class DraftServiceTest {
|
|||||||
clearAuth();
|
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
|
@Test
|
||||||
void approve_blockedWhenSelfApprove() throws Exception {
|
void approve_blockedWhenSelfApprove() throws Exception {
|
||||||
var rs = new TrackingRecordService();
|
var rs = new TrackingRecordService();
|
||||||
|
|||||||
Reference in New Issue
Block a user