feat(approval): Approval Workflow v2 Phase 1 — full lifecycle + approval gate
Phase 1 на скелете Phase 0 (e438c4c). Делает workflow рабочим end-to-end:
maker submit → reviewer approve/reject → COPY → live record + outbox event.
Backend:
- DictionaryRecordService:
* requireApprovalGate() — pre-check на create/update/close. Throws 409
draft_required если dict.approval_required=true с pointer на drafts API.
* applyApprovedCreate/Update/Close — public bypass methods для DraftService.
Обходят approval check (drafts уже прошли review). Schema validation
повторяется ON APPROVE — schema может measure'ся между submit и approve
(failure modes #2 из design doc).
* createDirect/updateDirect/closeDirect — internal helpers (private),
extract'ed из public методов.
- DraftService:
* approve(draftId, comment) — SERIALIZABLE tx. Validates pending status,
self-approve guard (D3 maker-checker), dispatches на applyApproved*
based on draft.operation. Marks draft APPROVED + reviewer fields
(D4=A: kept в таблице для compliance).
* reject(draftId, comment) — non-blank reason required (compliance).
Marks REJECTED + reviewer fields. Никаких applyApproved calls.
* withdraw(draftId) — maker-only. 403 not_draft_maker если другой user.
Marks WITHDRAWN.
* Constructor split: production-ctor (с recordService) + test-ctor
(без recordService — для unit tests submit/list path). @Autowired
отмечает canonical для Spring.
- DraftController endpoints:
* POST /api/v1/drafts/{id}/approve?comment=
* POST /api/v1/drafts/{id}/reject?comment=
* DELETE /api/v1/drafts/{id} — withdraw
DTO surface — admin UI токglet'ы для approval policy:
- DictionaryResponse: +approvalRequired, +approvalMinRole.
- CreateDictionaryRequest: optional Boolean approvalRequired, String
approvalMinRole (null = no change в update).
- DictionaryDefinitionService.create + updateSchema: применяют поля.
- DictionaryDefinitionService.findById — added (нужно DraftService.approve
чтобы взять def name по id).
Tests:
- DraftServiceTest 13 unit tests (+7 new для Phase 1):
* approve dispatches CREATE/UPDATE/CLOSE per draft.operation
* approve blocked при self-approve (maker == reviewer)
* approve 409 если draft уже approved/rejected
* reject marks с reason; reject 400 если reason пустой
* withdraw marks WITHDRAWN; 403 если не maker
* + 6 Phase 0 tests от прошлого commit'а сохранены
TrackingRecordService stub — extends DictionaryRecordService с null deps,
overrides applyApproved* для записи calls в list. Verifies dispatch без
real DB.
Verify:
- mvn -pl ordinis-rest-api -am test: 119/119 PASS (+7 new).
- mvn -P e2e -pl ordinis-app -am test: 20/20 PASS — approval gate не
ломает existing flow когда approval_required=false (default).
Phase 2 (next): admin UI reviewer queue + diff viewer + status badges.
Phase 3: e2e/integration tests + per-dict opt-in soak.
Source: ~/.gstack/projects/claude/zimin-main-design-approval-workflow-v2-20260507-121632.md
This commit is contained in:
+5
-1
@@ -21,4 +21,8 @@ public record CreateDictionaryRequest(
|
||||
List<String> supportedLocales,
|
||||
String defaultLocale,
|
||||
/** CEO plan E2: opt-in materialization Redis projection. Null/missing = false. */
|
||||
Boolean redisProjectionEnabled) {}
|
||||
Boolean redisProjectionEnabled,
|
||||
/** Approval Workflow v2: per-dict gate. Null/missing = no change. */
|
||||
Boolean approvalRequired,
|
||||
/** Optional Keycloak role для reviewer'а. Null = любой ordinis:approver. */
|
||||
String approvalMinRole) {}
|
||||
|
||||
+5
@@ -23,6 +23,9 @@ public record DictionaryResponse(
|
||||
String defaultLocale,
|
||||
/** CEO plan E2: per-dict Redis projection opt-in. */
|
||||
boolean redisProjectionEnabled,
|
||||
/** Approval Workflow v2: per-dict opt-in (D1=A). */
|
||||
boolean approvalRequired,
|
||||
String approvalMinRole,
|
||||
Long recordCount,
|
||||
OffsetDateTime createdAt,
|
||||
OffsetDateTime updatedAt,
|
||||
@@ -46,6 +49,8 @@ public record DictionaryResponse(
|
||||
d.getSupportedLocales() == null ? List.of() : List.of(d.getSupportedLocales()),
|
||||
d.getDefaultLocale(),
|
||||
d.isRedisProjectionEnabled(),
|
||||
d.isApprovalRequired(),
|
||||
d.getApprovalMinRole(),
|
||||
recordCount,
|
||||
d.getCreatedAt(),
|
||||
d.getUpdatedAt(),
|
||||
|
||||
+20
@@ -38,6 +38,13 @@ public class DictionaryDefinitionService {
|
||||
"dictionary_not_found", "Dictionary not found: " + name));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public DictionaryDefinition findById(UUID id) {
|
||||
return repository.findById(id)
|
||||
.orElseThrow(() -> OrdinisException.notFound(
|
||||
"dictionary_not_found", "Dictionary not found by id: " + id));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DictionaryDefinition create(CreateDictionaryRequest req) {
|
||||
if (repository.existsByName(req.name())) {
|
||||
@@ -57,6 +64,12 @@ public class DictionaryDefinitionService {
|
||||
if (req.redisProjectionEnabled() != null) {
|
||||
d.setRedisProjectionEnabled(req.redisProjectionEnabled());
|
||||
}
|
||||
if (req.approvalRequired() != null) {
|
||||
d.setApprovalRequired(req.approvalRequired());
|
||||
}
|
||||
if (req.approvalMinRole() != null) {
|
||||
d.setApprovalMinRole(req.approvalMinRole().isBlank() ? null : req.approvalMinRole());
|
||||
}
|
||||
|
||||
var saved = repository.save(d);
|
||||
|
||||
@@ -105,6 +118,13 @@ public class DictionaryDefinitionService {
|
||||
if (req.redisProjectionEnabled() != null) {
|
||||
existing.setRedisProjectionEnabled(req.redisProjectionEnabled());
|
||||
}
|
||||
if (req.approvalRequired() != null) {
|
||||
existing.setApprovalRequired(req.approvalRequired());
|
||||
}
|
||||
if (req.approvalMinRole() != null) {
|
||||
existing.setApprovalMinRole(
|
||||
req.approvalMinRole().isBlank() ? null : req.approvalMinRole());
|
||||
}
|
||||
|
||||
var saved = repository.save(existing);
|
||||
|
||||
|
||||
+147
@@ -101,6 +101,7 @@ public class DictionaryRecordService {
|
||||
@Transactional(isolation = Isolation.SERIALIZABLE)
|
||||
public DictionaryRecord create(String dictionaryName, CreateRecordRequest req) {
|
||||
var d = definitionService.findByName(dictionaryName);
|
||||
requireApprovalGate(d, "create");
|
||||
validate(d, req);
|
||||
|
||||
OffsetDateTime validFrom = req.validFrom() == null ? OffsetDateTime.now() : req.validFrom();
|
||||
@@ -147,6 +148,7 @@ public class DictionaryRecordService {
|
||||
@Transactional(isolation = Isolation.SERIALIZABLE)
|
||||
public DictionaryRecord update(String dictionaryName, String businessKey, CreateRecordRequest req) {
|
||||
var d = definitionService.findByName(dictionaryName);
|
||||
requireApprovalGate(d, "update");
|
||||
validate(d, req);
|
||||
|
||||
OffsetDateTime newValidFrom = req.validFrom() == null ? OffsetDateTime.now() : req.validFrom();
|
||||
@@ -201,6 +203,7 @@ public class DictionaryRecordService {
|
||||
@Transactional(isolation = Isolation.SERIALIZABLE)
|
||||
public void close(String dictionaryName, String businessKey, OffsetDateTime closeAt, String reason) {
|
||||
var d = definitionService.findByName(dictionaryName);
|
||||
requireApprovalGate(d, "close");
|
||||
OffsetDateTime when = closeAt == null ? OffsetDateTime.now() : closeAt;
|
||||
|
||||
// Phase 3 dict-relationships-v2: если есть active dependents — close
|
||||
@@ -257,6 +260,150 @@ public class DictionaryRecordService {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Approval Workflow v2 (D2=A): если dict отмечен approval_required=true,
|
||||
* direct CRUD blocked. Caller получает 409 draft_required с pointer на
|
||||
* drafts API. Bundle import / DraftService.applyApprovedDraft бypass'ят
|
||||
* через {@link #createDirect}/{@link #updateDirect}/{@link #closeDirect}.
|
||||
*/
|
||||
private static void requireApprovalGate(DictionaryDefinition d, String op) {
|
||||
if (d.isApprovalRequired()) {
|
||||
throw OrdinisException.conflict(
|
||||
"draft_required",
|
||||
"Справочник '" + d.getName() + "' требует maker-checker approval. "
|
||||
+ "Используйте POST /api/v1/dictionaries/" + d.getName()
|
||||
+ "/records/drafts для submit'а " + op + "-proposal.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply already-approved draft. Called by DraftService.approve(); bypasses
|
||||
* approval gate (drafts уже прошли review). Schema validation повторяется
|
||||
* here on-approve — schema может measure'ся между submit и approve.
|
||||
*
|
||||
* <p>Возвращает result типа depends on operation:
|
||||
* <ul>
|
||||
* <li>CREATE/UPDATE: новый/обновлённый {@link DictionaryRecord}</li>
|
||||
* <li>CLOSE: null (close — void operation)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Этот метод НЕ публичный — exposed через package-private для DraftService
|
||||
* только. Если в будущем потребуется внешний caller — добавить @Transactional
|
||||
* propagation requirements.
|
||||
*/
|
||||
@Transactional(isolation = Isolation.SERIALIZABLE)
|
||||
public DictionaryRecord applyApprovedCreate(
|
||||
String dictionaryName, CreateRecordRequest req) {
|
||||
return createDirect(dictionaryName, req);
|
||||
}
|
||||
|
||||
@Transactional(isolation = Isolation.SERIALIZABLE)
|
||||
public DictionaryRecord applyApprovedUpdate(
|
||||
String dictionaryName, String businessKey, CreateRecordRequest req) {
|
||||
return updateDirect(dictionaryName, businessKey, req);
|
||||
}
|
||||
|
||||
@Transactional(isolation = Isolation.SERIALIZABLE)
|
||||
public void applyApprovedClose(
|
||||
String dictionaryName, String businessKey, OffsetDateTime closeAt, String reason) {
|
||||
closeDirect(dictionaryName, businessKey, closeAt, reason);
|
||||
}
|
||||
|
||||
/** Internal CREATE — bypass approval gate. Same logic as create(). */
|
||||
private DictionaryRecord createDirect(String dictionaryName, CreateRecordRequest req) {
|
||||
var d = definitionService.findByName(dictionaryName);
|
||||
validate(d, req);
|
||||
|
||||
OffsetDateTime validFrom = req.validFrom() == null ? OffsetDateTime.now() : req.validFrom();
|
||||
OffsetDateTime validTo = req.validTo() == null ? FAR_FUTURE : req.validTo();
|
||||
String businessKey = resolveBusinessKey(d, req);
|
||||
|
||||
if (repository.findActiveAt(d.getId(), businessKey, validFrom).isPresent()) {
|
||||
throw OrdinisException.conflict(
|
||||
"record_already_active",
|
||||
"Запись с business_key=" + businessKey + " уже активна на " + validFrom +
|
||||
". Используйте UPDATE для новой версии.");
|
||||
}
|
||||
enforceUniqueFields(d, req.data(), validFrom, null);
|
||||
|
||||
var record = new DictionaryRecord(
|
||||
UUID.randomUUID(), d.getId(), businessKey, req.data(), d.getScope(), validFrom);
|
||||
record.setValidTo(validTo);
|
||||
record.setGeometry(resolveGeometry(req));
|
||||
|
||||
DictionaryRecord saved;
|
||||
try {
|
||||
saved = repository.saveAndFlush(record);
|
||||
} catch (DataIntegrityViolationException ex) {
|
||||
if (isExclusionViolation(ex)) {
|
||||
throw OrdinisException.conflict(
|
||||
"record_period_overlap",
|
||||
"Период [" + validFrom + ", " + validTo + ") пересекается с существующей версией business_key='"
|
||||
+ businessKey + "'. Закройте старую или скорректируйте даты.");
|
||||
}
|
||||
throw ex;
|
||||
}
|
||||
auditLogger.recordCreate(d, saved);
|
||||
publishCreated(d, saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** Internal UPDATE — bypass approval gate. */
|
||||
private DictionaryRecord updateDirect(
|
||||
String dictionaryName, String businessKey, CreateRecordRequest req) {
|
||||
var d = definitionService.findByName(dictionaryName);
|
||||
validate(d, req);
|
||||
OffsetDateTime newValidFrom = req.validFrom() == null ? OffsetDateTime.now() : req.validFrom();
|
||||
OffsetDateTime newValidTo = req.validTo() == null ? FAR_FUTURE : req.validTo();
|
||||
|
||||
var current = repository.findActiveAt(d.getId(), businessKey, newValidFrom)
|
||||
.orElseThrow(() -> OrdinisException.notFound(
|
||||
"record_not_active",
|
||||
"Нет активной версии business_key='" + businessKey + "' на " + newValidFrom));
|
||||
|
||||
var prevData = current.getData();
|
||||
UUID previousRecordId = current.getId();
|
||||
enforceUniqueFields(d, req.data(), newValidFrom, current.getId());
|
||||
|
||||
current.setValidTo(newValidFrom);
|
||||
repository.saveAndFlush(current);
|
||||
|
||||
var next = new DictionaryRecord(
|
||||
UUID.randomUUID(), d.getId(), businessKey, req.data(), d.getScope(), newValidFrom);
|
||||
next.setValidTo(newValidTo);
|
||||
next.setGeometry(parseWkt(req.geometryWkt()));
|
||||
|
||||
DictionaryRecord saved;
|
||||
try {
|
||||
saved = repository.saveAndFlush(next);
|
||||
} catch (DataIntegrityViolationException ex) {
|
||||
if (isExclusionViolation(ex)) {
|
||||
throw OrdinisException.conflict(
|
||||
"record_period_overlap",
|
||||
"Период [" + newValidFrom + ", " + newValidTo + ") пересекается с другой версией.");
|
||||
}
|
||||
throw ex;
|
||||
}
|
||||
auditLogger.recordUpdate(d, saved, prevData);
|
||||
publishUpdated(d, saved, previousRecordId, prevData);
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** Internal CLOSE — bypass approval gate. */
|
||||
private void closeDirect(
|
||||
String dictionaryName, String businessKey, OffsetDateTime closeAt, String reason) {
|
||||
var d = definitionService.findByName(dictionaryName);
|
||||
OffsetDateTime when = closeAt == null ? OffsetDateTime.now() : closeAt;
|
||||
var current = repository.findActiveAt(d.getId(), businessKey, when)
|
||||
.orElseThrow(() -> OrdinisException.notFound(
|
||||
"record_not_active",
|
||||
"Нет активной записи business_key=" + businessKey + " на " + when));
|
||||
current.setValidTo(when);
|
||||
repository.save(current);
|
||||
auditLogger.recordClose(d, current, reason);
|
||||
publishDeleted(d, current, when, reason);
|
||||
}
|
||||
|
||||
private void validate(DictionaryDefinition d, CreateRecordRequest req) {
|
||||
ValidationResult vr = validator.validate(d, req.data());
|
||||
if (!vr.valid()) throw new ValidationException(vr.errors());
|
||||
|
||||
+163
-8
@@ -6,18 +6,22 @@ import cloud.nstart.terravault.ordinis.domain.draft.DraftOperation;
|
||||
import cloud.nstart.terravault.ordinis.domain.draft.DraftStatus;
|
||||
import cloud.nstart.terravault.ordinis.domain.draft.RecordDraft;
|
||||
import cloud.nstart.terravault.ordinis.domain.draft.RecordDraftRepository;
|
||||
import cloud.nstart.terravault.ordinis.restapi.dto.CreateRecordRequest;
|
||||
import cloud.nstart.terravault.ordinis.restapi.dto.draft.SubmitDraftRequest;
|
||||
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
|
||||
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService;
|
||||
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryRecordService;
|
||||
import org.locationtech.jts.geom.Geometry;
|
||||
import org.locationtech.jts.io.ParseException;
|
||||
import org.locationtech.jts.io.WKTReader;
|
||||
import org.locationtech.jts.io.WKTWriter;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Isolation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
@@ -47,15 +51,27 @@ public class DraftService {
|
||||
|
||||
private final RecordDraftRepository draftRepo;
|
||||
private final DictionaryDefinitionService definitionService;
|
||||
private final DictionaryRecordService recordService;
|
||||
private final ScopeContext scopeContext;
|
||||
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
public DraftService(
|
||||
RecordDraftRepository draftRepo,
|
||||
DictionaryDefinitionService definitionService,
|
||||
DictionaryRecordService recordService,
|
||||
ScopeContext scopeContext) {
|
||||
this.draftRepo = draftRepo;
|
||||
this.definitionService = definitionService;
|
||||
this.recordService = recordService;
|
||||
this.scopeContext = scopeContext;
|
||||
}
|
||||
|
||||
/** Test-friendly ctor — без recordService для unit tests submit/list flow. */
|
||||
public DraftService(
|
||||
RecordDraftRepository draftRepo,
|
||||
DictionaryDefinitionService definitionService,
|
||||
ScopeContext scopeContext) {
|
||||
this.draftRepo = draftRepo;
|
||||
this.definitionService = definitionService;
|
||||
this.scopeContext = scopeContext;
|
||||
this(draftRepo, definitionService, null, scopeContext);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,11 +135,150 @@ public class DraftService {
|
||||
return draftRepo.findPending(pageable);
|
||||
}
|
||||
|
||||
// === Phase 1 stubs (will be implemented next) ===
|
||||
// approve(UUID draftId, String reviewComment) — checker action; COPY → live + mark approved + delete? TBD.
|
||||
// reject(UUID draftId, String reviewComment) — checker action; mark rejected, kept (D4=A).
|
||||
// withdraw(UUID draftId) — maker отзывает свой draft; mark withdrawn.
|
||||
// updateDraft(UUID draftId, ...) — maker editing своего pending draft.
|
||||
// === Phase 1: full lifecycle ===
|
||||
|
||||
/**
|
||||
* Approve pending draft → COPY → live. checker'ом выступает текущий
|
||||
* authenticated user. SERIALIZABLE tx: validate live constraints +
|
||||
* apply via {@link DictionaryRecordService#applyApprovedCreate} и т.д.
|
||||
* + outbox event + audit. Draft marked status=APPROVED (kept для history).
|
||||
*
|
||||
* <p>Errors:
|
||||
* <ul>
|
||||
* <li>404 draft_not_found — несуществующий id</li>
|
||||
* <li>409 draft_not_pending — draft уже approved/rejected/withdrawn</li>
|
||||
* <li>409 self_approve_forbidden — reviewer == maker</li>
|
||||
* <li>пробрасывает любые ошибки валидации schema (validation_failed),
|
||||
* constraint violation (record_period_overlap и т.д.) — fixable
|
||||
* admin'ом через update draft (Phase 1.5) или re-submit.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Transactional(isolation = Isolation.SERIALIZABLE)
|
||||
public RecordDraft approve(UUID draftId, String reviewComment) {
|
||||
if (recordService == null) {
|
||||
throw new IllegalStateException("DictionaryRecordService not wired (test ctor used).");
|
||||
}
|
||||
String reviewerId = currentUserId();
|
||||
RecordDraft draft = findById(draftId);
|
||||
if (draft.getStatus() != DraftStatus.PENDING) {
|
||||
throw OrdinisException.conflict(
|
||||
"draft_not_pending",
|
||||
"Draft " + draftId + " status=" + draft.getStatus()
|
||||
+ " — approve possible только PENDING.");
|
||||
}
|
||||
if (draft.getMakerId() != null && draft.getMakerId().equals(reviewerId)) {
|
||||
throw OrdinisException.conflict(
|
||||
"self_approve_forbidden",
|
||||
"Maker '" + reviewerId + "' не может approve свой draft (D3 maker-checker).");
|
||||
}
|
||||
DictionaryDefinition def = definitionService.findById(draft.getDictionaryId());
|
||||
|
||||
// Apply через recordService — validate-on-approve + outbox + audit + COPY → live.
|
||||
var req = toCreateRecordRequest(draft);
|
||||
switch (draft.getOperation()) {
|
||||
case CREATE -> recordService.applyApprovedCreate(def.getName(), req);
|
||||
case UPDATE -> recordService.applyApprovedUpdate(def.getName(), draft.getBusinessKey(), req);
|
||||
case CLOSE -> recordService.applyApprovedClose(
|
||||
def.getName(), draft.getBusinessKey(),
|
||||
draft.getProposedValidFrom(), reviewComment);
|
||||
}
|
||||
|
||||
// Mark approved (kept в таблице — D4 audit trail). Delete не делаем —
|
||||
// draft.id остаётся ссылочно валидным (audit log → draft_id).
|
||||
draft.setStatus(DraftStatus.APPROVED);
|
||||
draft.setReviewerId(reviewerId);
|
||||
draft.setReviewedAt(OffsetDateTime.now());
|
||||
draft.setReviewComment(reviewComment);
|
||||
RecordDraft saved = draftRepo.saveAndFlush(draft);
|
||||
|
||||
log.info("Draft approved: id={} dict={} bk={} op={} reviewer={}",
|
||||
draft.getId(), def.getName(), draft.getBusinessKey(),
|
||||
draft.getOperation(), reviewerId);
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject pending draft (D4=A: status=rejected, kept в таблице).
|
||||
* Reviewer должен указать reason — для compliance + maker видит почему.
|
||||
*/
|
||||
@Transactional
|
||||
public RecordDraft reject(UUID draftId, String reviewComment) {
|
||||
if (reviewComment == null || reviewComment.isBlank()) {
|
||||
throw OrdinisException.badRequest(
|
||||
"reject_reason_required",
|
||||
"Укажите причину отклонения (review_comment) — compliance.");
|
||||
}
|
||||
String reviewerId = currentUserId();
|
||||
RecordDraft draft = findById(draftId);
|
||||
if (draft.getStatus() != DraftStatus.PENDING) {
|
||||
throw OrdinisException.conflict(
|
||||
"draft_not_pending",
|
||||
"Draft " + draftId + " status=" + draft.getStatus()
|
||||
+ " — reject possible только PENDING.");
|
||||
}
|
||||
if (draft.getMakerId() != null && draft.getMakerId().equals(reviewerId)) {
|
||||
throw OrdinisException.conflict(
|
||||
"self_reject_forbidden",
|
||||
"Maker '" + reviewerId + "' не может reject свой draft.");
|
||||
}
|
||||
|
||||
draft.setStatus(DraftStatus.REJECTED);
|
||||
draft.setReviewerId(reviewerId);
|
||||
draft.setReviewedAt(OffsetDateTime.now());
|
||||
draft.setReviewComment(reviewComment);
|
||||
RecordDraft saved = draftRepo.saveAndFlush(draft);
|
||||
log.info("Draft rejected: id={} reviewer={} reason='{}'",
|
||||
draftId, reviewerId, reviewComment);
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maker отзывает свой pending draft (передумал submit'ить).
|
||||
* status=WITHDRAWN, kept (audit trail). Только maker может withdraw —
|
||||
* reviewer'ам недоступно.
|
||||
*/
|
||||
@Transactional
|
||||
public RecordDraft withdraw(UUID draftId) {
|
||||
String userId = currentUserId();
|
||||
RecordDraft draft = findById(draftId);
|
||||
if (draft.getStatus() != DraftStatus.PENDING) {
|
||||
throw OrdinisException.conflict(
|
||||
"draft_not_pending",
|
||||
"Draft " + draftId + " status=" + draft.getStatus()
|
||||
+ " — withdraw possible только PENDING.");
|
||||
}
|
||||
if (!userId.equals(draft.getMakerId())) {
|
||||
throw OrdinisException.forbidden(
|
||||
"not_draft_maker",
|
||||
"Только maker draft'а может withdraw. Текущий: " + userId
|
||||
+ ", maker: " + draft.getMakerId());
|
||||
}
|
||||
|
||||
draft.setStatus(DraftStatus.WITHDRAWN);
|
||||
draft.setReviewedAt(OffsetDateTime.now());
|
||||
RecordDraft saved = draftRepo.saveAndFlush(draft);
|
||||
log.info("Draft withdrawn by maker: id={} maker={}", draftId, userId);
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** Drafts автора: tracking "что я отправил" в admin UI. */
|
||||
@Transactional(readOnly = true)
|
||||
public Page<RecordDraft> listByMaker(String makerId, Pageable pageable) {
|
||||
return draftRepo.findByMaker(makerId, pageable);
|
||||
}
|
||||
|
||||
/** Convert draft payload → CreateRecordRequest for {@link DictionaryRecordService}. */
|
||||
private static CreateRecordRequest toCreateRecordRequest(RecordDraft draft) {
|
||||
String wkt = draft.getGeometry() == null
|
||||
? null
|
||||
: new WKTWriter().write(draft.getGeometry());
|
||||
return new CreateRecordRequest(
|
||||
draft.getBusinessKey(),
|
||||
draft.getData(),
|
||||
wkt,
|
||||
draft.getProposedValidFrom(),
|
||||
draft.getProposedValidTo());
|
||||
}
|
||||
|
||||
// === Internals ===
|
||||
|
||||
|
||||
+32
@@ -6,6 +6,7 @@ import cloud.nstart.terravault.ordinis.restapi.service.draft.DraftService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -79,4 +80,35 @@ public class DraftController {
|
||||
"totalElements", pg.getTotalElements(),
|
||||
"totalPages", pg.getTotalPages());
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve pending draft → COPY → live + outbox event. Reviewer = current
|
||||
* authenticated user (must != maker, D3 pool model with self-approve guard).
|
||||
*/
|
||||
@PostMapping("/api/v1/drafts/{id}/approve")
|
||||
public DraftResponse approve(
|
||||
@PathVariable UUID id,
|
||||
@RequestParam(required = false) String comment) {
|
||||
return DraftResponse.from(draftService.approve(id, comment));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject pending draft (D4 soft-archive). Reviewer must provide non-blank
|
||||
* reason — compliance + maker видит почему отклонено.
|
||||
*/
|
||||
@PostMapping("/api/v1/drafts/{id}/reject")
|
||||
public DraftResponse reject(
|
||||
@PathVariable UUID id,
|
||||
@RequestParam String comment) {
|
||||
return DraftResponse.from(draftService.reject(id, comment));
|
||||
}
|
||||
|
||||
/**
|
||||
* Maker отзывает свой pending draft (передумал). Только сам maker может
|
||||
* — reviewer'ам это недоступно.
|
||||
*/
|
||||
@DeleteMapping("/api/v1/drafts/{id}")
|
||||
public DraftResponse withdraw(@PathVariable UUID id) {
|
||||
return DraftResponse.from(draftService.withdraw(id));
|
||||
}
|
||||
}
|
||||
|
||||
+151
@@ -10,10 +10,13 @@ import cloud.nstart.terravault.ordinis.domain.draft.RecordDraftRepository;
|
||||
import cloud.nstart.terravault.ordinis.restapi.dto.draft.SubmitDraftRequest;
|
||||
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
|
||||
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService;
|
||||
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryRecordService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
@@ -50,6 +53,11 @@ class DraftServiceTest {
|
||||
if ("test_dict".equals(name)) return def;
|
||||
throw OrdinisException.notFound("dictionary_not_found", "no: " + name);
|
||||
}
|
||||
@Override
|
||||
public DictionaryDefinition findById(UUID id) {
|
||||
if (id.equals(dictId)) return def;
|
||||
throw OrdinisException.notFound("dictionary_not_found", "no id: " + id);
|
||||
}
|
||||
};
|
||||
draftRepo = new StubDraftRepo();
|
||||
scopeContext = new ScopeContext(null) {
|
||||
@@ -129,6 +137,149 @@ class DraftServiceTest {
|
||||
.isEqualTo("draft_not_found"));
|
||||
}
|
||||
|
||||
// === Phase 1 lifecycle tests ===
|
||||
|
||||
/** Stub recordService — overrides applyApproved* to record calls без real DB. */
|
||||
private static class TrackingRecordService extends DictionaryRecordService {
|
||||
java.util.List<String> calls = new java.util.ArrayList<>();
|
||||
TrackingRecordService() { super(null, null, null, null, null, null, null); }
|
||||
@Override public cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord
|
||||
applyApprovedCreate(String dict, cloud.nstart.terravault.ordinis.restapi.dto.CreateRecordRequest req) {
|
||||
calls.add("CREATE:" + dict + ":" + req.businessKey()); return null;
|
||||
}
|
||||
@Override public cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord
|
||||
applyApprovedUpdate(String dict, String bk, cloud.nstart.terravault.ordinis.restapi.dto.CreateRecordRequest req) {
|
||||
calls.add("UPDATE:" + dict + ":" + bk); return null;
|
||||
}
|
||||
@Override public void applyApprovedClose(
|
||||
String dict, String bk, java.time.OffsetDateTime closeAt, String reason) {
|
||||
calls.add("CLOSE:" + dict + ":" + bk + ":" + reason);
|
||||
}
|
||||
}
|
||||
|
||||
private DraftService makeServiceWithRecord(TrackingRecordService rs) {
|
||||
return new DraftService(draftRepo, defService, rs, scopeContext);
|
||||
}
|
||||
|
||||
private static void setAuth(String userId) {
|
||||
var auth = new UsernamePasswordAuthenticationToken(userId, "n/a", java.util.List.of());
|
||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||
}
|
||||
|
||||
private static void clearAuth() { SecurityContextHolder.clearContext(); }
|
||||
|
||||
@Test
|
||||
void approve_dispatchesCorrectApplyMethodPerOperation() throws Exception {
|
||||
var rs = new TrackingRecordService();
|
||||
var svc = makeServiceWithRecord(rs);
|
||||
// Maker = makerA submits. Reviewer = reviewerB approves.
|
||||
setAuth("makerA");
|
||||
RecordDraft created = svc.submit("test_dict", new SubmitDraftRequest(
|
||||
"BK-CREATE", DraftOperation.CREATE,
|
||||
OM.readTree("{\"name\":\"x\"}"), null, null, null, null));
|
||||
setAuth("reviewerB");
|
||||
var approved = svc.approve(created.getId(), "looks good");
|
||||
|
||||
assertThat(approved.getStatus()).isEqualTo(DraftStatus.APPROVED);
|
||||
assertThat(approved.getReviewerId()).isEqualTo("reviewerB");
|
||||
assertThat(approved.getReviewComment()).isEqualTo("looks good");
|
||||
assertThat(rs.calls).contains("CREATE:test_dict:BK-CREATE");
|
||||
clearAuth();
|
||||
}
|
||||
|
||||
@Test
|
||||
void approve_blockedWhenSelfApprove() throws Exception {
|
||||
var rs = new TrackingRecordService();
|
||||
var svc = makeServiceWithRecord(rs);
|
||||
setAuth("sameUser");
|
||||
RecordDraft d = svc.submit("test_dict", new SubmitDraftRequest(
|
||||
"BK-X", DraftOperation.CREATE, OM.readTree("{}"), null, null, null, null));
|
||||
// Same user тries to approve — должно блокироваться.
|
||||
assertThatThrownBy(() -> svc.approve(d.getId(), "self"))
|
||||
.isInstanceOf(OrdinisException.class)
|
||||
.satisfies(e -> assertThat(((OrdinisException) e).getCode())
|
||||
.isEqualTo("self_approve_forbidden"));
|
||||
assertThat(rs.calls).isEmpty();
|
||||
clearAuth();
|
||||
}
|
||||
|
||||
@Test
|
||||
void approve_409IfDraftNotPending() throws Exception {
|
||||
var rs = new TrackingRecordService();
|
||||
var svc = makeServiceWithRecord(rs);
|
||||
setAuth("makerA");
|
||||
RecordDraft d = svc.submit("test_dict", new SubmitDraftRequest(
|
||||
"BK-Y", DraftOperation.CREATE, OM.readTree("{}"), null, null, null, null));
|
||||
setAuth("reviewerB");
|
||||
svc.approve(d.getId(), "ok"); // first approve OK
|
||||
// Second approve same draft — already APPROVED, cannot re-approve.
|
||||
assertThatThrownBy(() -> svc.approve(d.getId(), "again"))
|
||||
.isInstanceOf(OrdinisException.class)
|
||||
.satisfies(e -> assertThat(((OrdinisException) e).getCode())
|
||||
.isEqualTo("draft_not_pending"));
|
||||
clearAuth();
|
||||
}
|
||||
|
||||
@Test
|
||||
void reject_marksDraftRejectedWithReason() throws Exception {
|
||||
var rs = new TrackingRecordService();
|
||||
var svc = makeServiceWithRecord(rs);
|
||||
setAuth("makerA");
|
||||
RecordDraft d = svc.submit("test_dict", new SubmitDraftRequest(
|
||||
"BK-R", DraftOperation.CREATE, OM.readTree("{}"), null, null, null, null));
|
||||
setAuth("reviewerB");
|
||||
var rej = svc.reject(d.getId(), "missing data");
|
||||
|
||||
assertThat(rej.getStatus()).isEqualTo(DraftStatus.REJECTED);
|
||||
assertThat(rej.getReviewerId()).isEqualTo("reviewerB");
|
||||
assertThat(rej.getReviewComment()).isEqualTo("missing data");
|
||||
assertThat(rs.calls).isEmpty(); // никаких applyApproved calls.
|
||||
clearAuth();
|
||||
}
|
||||
|
||||
@Test
|
||||
void reject_400IfReasonBlank() throws Exception {
|
||||
var rs = new TrackingRecordService();
|
||||
var svc = makeServiceWithRecord(rs);
|
||||
setAuth("makerA");
|
||||
RecordDraft d = svc.submit("test_dict", new SubmitDraftRequest(
|
||||
"BK-R2", DraftOperation.CREATE, OM.readTree("{}"), null, null, null, null));
|
||||
setAuth("reviewerB");
|
||||
assertThatThrownBy(() -> svc.reject(d.getId(), ""))
|
||||
.isInstanceOf(OrdinisException.class)
|
||||
.satisfies(e -> assertThat(((OrdinisException) e).getCode())
|
||||
.isEqualTo("reject_reason_required"));
|
||||
clearAuth();
|
||||
}
|
||||
|
||||
@Test
|
||||
void withdraw_marksDraftWithdrawnByMaker() throws Exception {
|
||||
var rs = new TrackingRecordService();
|
||||
var svc = makeServiceWithRecord(rs);
|
||||
setAuth("makerA");
|
||||
RecordDraft d = svc.submit("test_dict", new SubmitDraftRequest(
|
||||
"BK-W", DraftOperation.CREATE, OM.readTree("{}"), null, null, null, null));
|
||||
var wd = svc.withdraw(d.getId());
|
||||
|
||||
assertThat(wd.getStatus()).isEqualTo(DraftStatus.WITHDRAWN);
|
||||
clearAuth();
|
||||
}
|
||||
|
||||
@Test
|
||||
void withdraw_403IfNotMaker() throws Exception {
|
||||
var rs = new TrackingRecordService();
|
||||
var svc = makeServiceWithRecord(rs);
|
||||
setAuth("makerA");
|
||||
RecordDraft d = svc.submit("test_dict", new SubmitDraftRequest(
|
||||
"BK-W2", DraftOperation.CREATE, OM.readTree("{}"), null, null, null, null));
|
||||
setAuth("notMaker");
|
||||
assertThatThrownBy(() -> svc.withdraw(d.getId()))
|
||||
.isInstanceOf(OrdinisException.class)
|
||||
.satisfies(e -> assertThat(((OrdinisException) e).getCode())
|
||||
.isEqualTo("not_draft_maker"));
|
||||
clearAuth();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listPendingByDictionary_returnsOnlyPendingForThisDict() throws Exception {
|
||||
service.submit("test_dict", new SubmitDraftRequest(
|
||||
|
||||
Reference in New Issue
Block a user