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();