feat(record): REOPEN operation — вернуть закрытую запись
This commit is contained in:
+5
@@ -86,6 +86,11 @@ public class AuditLogger {
|
||||
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
|
||||
* пишется в audit_log дополнительно к outbox чтобы ChangelogService
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ import java.time.OffsetDateTime;
|
||||
public record SubmitDraftRequest(
|
||||
@NotBlank String businessKey,
|
||||
@NotNull DraftOperation operation,
|
||||
/** CREATE/UPDATE: новый payload. CLOSE: null OK. */
|
||||
/** CREATE/UPDATE: новый payload. CLOSE / REOPEN: null OK. */
|
||||
JsonNode data,
|
||||
String geometryWkt,
|
||||
OffsetDateTime validFrom,
|
||||
|
||||
+80
@@ -309,6 +309,26 @@ public class DictionaryRecordService {
|
||||
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(). */
|
||||
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());
|
||||
|
||||
+2
@@ -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 не делаем —
|
||||
|
||||
+18
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user