findByMaker(@Param("makerId") String makerId, Pageable pageable);
+}
diff --git a/ordinis-migrations/src/main/resources/db/changelog/changes/0019-approval-workflow-v2.xml b/ordinis-migrations/src/main/resources/db/changelog/changes/0019-approval-workflow-v2.xml
new file mode 100644
index 0000000..0791c60
--- /dev/null
+++ b/ordinis-migrations/src/main/resources/db/changelog/changes/0019-approval-workflow-v2.xml
@@ -0,0 +1,126 @@
+
+
+
+
+
+ record_drafts table — DRAFT layer для maker-checker workflow
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ALTER TABLE record_drafts
+ ADD CONSTRAINT record_drafts_status_check
+ CHECK (status IN ('pending', 'approved', 'rejected', 'withdrawn'));
+
+
+ ALTER TABLE record_drafts
+ ADD CONSTRAINT record_drafts_operation_check
+ CHECK (operation IN ('CREATE', 'UPDATE', 'CLOSE'));
+
+
+
+
+ ALTER TABLE record_drafts
+ ADD CONSTRAINT record_drafts_no_self_approve
+ CHECK (reviewer_id IS NULL OR maker_id != reviewer_id);
+
+
+
+
+ ALTER TABLE record_drafts
+ ADD CONSTRAINT record_drafts_one_pending_per_key
+ EXCLUDE USING btree (
+ dictionary_id WITH =,
+ business_key WITH =
+ ) WHERE (status = 'pending');
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Per-dictionary opt-in: approval_required + min reviewer role (D1=A)
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ordinis-migrations/src/main/resources/db/changelog/master.xml b/ordinis-migrations/src/main/resources/db/changelog/master.xml
index 781ea28..7b96e57 100644
--- a/ordinis-migrations/src/main/resources/db/changelog/master.xml
+++ b/ordinis-migrations/src/main/resources/db/changelog/master.xml
@@ -28,5 +28,6 @@
+
diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/draft/DraftResponse.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/draft/DraftResponse.java
new file mode 100644
index 0000000..7399fc2
--- /dev/null
+++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/draft/DraftResponse.java
@@ -0,0 +1,52 @@
+package cloud.nstart.terravault.ordinis.restapi.dto.draft;
+
+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 com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.JsonNode;
+import org.locationtech.jts.io.WKTWriter;
+
+import java.time.OffsetDateTime;
+import java.util.UUID;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public record DraftResponse(
+ UUID id,
+ UUID dictionaryId,
+ String businessKey,
+ UUID parentRecordId,
+ DraftOperation operation,
+ JsonNode data,
+ String geometryWkt,
+ OffsetDateTime proposedValidFrom,
+ OffsetDateTime proposedValidTo,
+ DraftStatus status,
+ String makerId,
+ String makerComment,
+ OffsetDateTime submittedAt,
+ String reviewerId,
+ OffsetDateTime reviewedAt,
+ String reviewComment) {
+
+ public static DraftResponse from(RecordDraft d) {
+ String wkt = d.getGeometry() == null ? null : new WKTWriter().write(d.getGeometry());
+ return new DraftResponse(
+ d.getId(),
+ d.getDictionaryId(),
+ d.getBusinessKey(),
+ d.getParentRecordId(),
+ d.getOperation(),
+ d.getData(),
+ wkt,
+ d.getProposedValidFrom(),
+ d.getProposedValidTo(),
+ d.getStatus(),
+ d.getMakerId(),
+ d.getMakerComment(),
+ d.getSubmittedAt(),
+ d.getReviewerId(),
+ d.getReviewedAt(),
+ d.getReviewComment());
+ }
+}
diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/draft/SubmitDraftRequest.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/draft/SubmitDraftRequest.java
new file mode 100644
index 0000000..d8495c3
--- /dev/null
+++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/draft/SubmitDraftRequest.java
@@ -0,0 +1,25 @@
+package cloud.nstart.terravault.ordinis.restapi.dto.draft;
+
+import cloud.nstart.terravault.ordinis.domain.draft.DraftOperation;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.JsonNode;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+
+import java.time.OffsetDateTime;
+
+/**
+ * Maker submits a proposed change. Approval workflow v2 (D2=A: legacy POST на
+ * approval-required dict вернёт 409 с hint'ом use this endpoint).
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public record SubmitDraftRequest(
+ @NotBlank String businessKey,
+ @NotNull DraftOperation operation,
+ /** CREATE/UPDATE: новый payload. CLOSE: null OK. */
+ JsonNode data,
+ String geometryWkt,
+ OffsetDateTime validFrom,
+ OffsetDateTime validTo,
+ /** Optional maker note. */
+ String comment) {}
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
new file mode 100644
index 0000000..2c4c48f
--- /dev/null
+++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftService.java
@@ -0,0 +1,157 @@
+package cloud.nstart.terravault.ordinis.restapi.service.draft;
+
+import cloud.nstart.terravault.ordinis.auth.ScopeContext;
+import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
+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.draft.SubmitDraftRequest;
+import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
+import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService;
+import org.locationtech.jts.geom.Geometry;
+import org.locationtech.jts.io.ParseException;
+import org.locationtech.jts.io.WKTReader;
+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.Transactional;
+
+import java.time.OffsetDateTime;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * Approval workflow v2 — Phase 0 skeleton.
+ *
+ * Текущий scope: submit + list + get (read-only operations + new draft
+ * insert). Полный lifecycle (approve / reject / withdraw + COPY-on-approve)
+ * — Phase 1.
+ *
+ *
Decisions (auto, per design doc):
+ *
+ * - D1=A: per-dictionary policy через колонки на dictionary_definitions.
+ * - D2=A: existing endpoints вернут 409 если approval_required=true
+ * (handled в DictionaryRecordService — TBD Phase 1).
+ * - D3=A: pool model для reviewer'ов (нет explicit assignment).
+ * - D4=A: rejected drafts kept (status=rejected, kept в таблице).
+ *
+ */
+@Service
+public class DraftService {
+
+ private static final Logger log = LoggerFactory.getLogger(DraftService.class);
+
+ private final RecordDraftRepository draftRepo;
+ private final DictionaryDefinitionService definitionService;
+ private final ScopeContext scopeContext;
+
+ public DraftService(
+ RecordDraftRepository draftRepo,
+ DictionaryDefinitionService definitionService,
+ ScopeContext scopeContext) {
+ this.draftRepo = draftRepo;
+ this.definitionService = definitionService;
+ this.scopeContext = scopeContext;
+ }
+
+ /**
+ * Submit новой draft. Maker = current authenticated user.
+ *
+ * Pre-conditions:
+ *
+ * - Dict существует и виден caller'у в scope.
+ * - Нет другого pending draft на этот business_key (DB exclusion
+ * constraint обеспечивает; конфликт = 409).
+ *
+ *
+ * Validation схемы — Phase 1 (нужен RecordValidator + ReferenceValidator
+ * inject + apply on submit). Для skeleton принимаем data как-есть.
+ */
+ @Transactional
+ public RecordDraft submit(String dictionaryName, SubmitDraftRequest req) {
+ DictionaryDefinition def = definitionService.findByName(dictionaryName);
+ String makerId = currentUserId();
+
+ var draft = new RecordDraft(
+ UUID.randomUUID(),
+ def.getId(),
+ req.businessKey(),
+ req.operation(),
+ makerId);
+ draft.setData(req.data());
+ draft.setGeometry(parseWkt(req.geometryWkt()));
+ draft.setProposedValidFrom(req.validFrom());
+ draft.setProposedValidTo(req.validTo());
+ draft.setMakerComment(req.comment());
+
+ try {
+ RecordDraft saved = draftRepo.saveAndFlush(draft);
+ log.info("Draft submitted: dict={} bk={} op={} maker={}",
+ dictionaryName, req.businessKey(), req.operation(), makerId);
+ return saved;
+ } catch (DataIntegrityViolationException ex) {
+ // record_drafts_one_pending_per_key — exclusion violation.
+ throw OrdinisException.conflict(
+ "draft_already_pending",
+ "На запись business_key=" + req.businessKey() + " уже есть pending draft.");
+ }
+ }
+
+ @Transactional(readOnly = true)
+ public RecordDraft findById(UUID id) {
+ return draftRepo.findById(id)
+ .orElseThrow(() -> OrdinisException.notFound(
+ "draft_not_found", "Draft not found: " + id));
+ }
+
+ @Transactional(readOnly = true)
+ public List listPendingByDictionary(String dictionaryName) {
+ DictionaryDefinition def = definitionService.findByName(dictionaryName);
+ return draftRepo.findPendingByDictionary(def.getId());
+ }
+
+ @Transactional(readOnly = true)
+ public Page listPendingGlobal(Pageable pageable) {
+ 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.
+
+ // === Internals ===
+
+ /** Resolve current user from JWT subject. Если нет auth — fallback "anonymous". */
+ private String currentUserId() {
+ var auth = org.springframework.security.core.context.SecurityContextHolder.getContext()
+ .getAuthentication();
+ if (auth == null || !auth.isAuthenticated()) return "anonymous";
+ return auth.getName();
+ }
+
+ private static Geometry parseWkt(String wkt) {
+ if (wkt == null || wkt.isBlank()) return null;
+ try {
+ Geometry g = new WKTReader().read(wkt);
+ g.setSRID(4326);
+ return g;
+ } catch (ParseException e) {
+ throw OrdinisException.badRequest(
+ "invalid_geometry_wkt", "Cannot parse WKT: " + e.getMessage());
+ }
+ }
+
+ /** ScopeContext kept for future scope-filter Phase 1 (когда draft list
+ * может ограничиваться по scope). Suppress unused warning сейчас. */
+ @SuppressWarnings("unused")
+ private ScopeContext scopeContext() { return scopeContext; }
+
+ /** Convenience overload: всё что нужно от datetime. */
+ static OffsetDateTime nowUtc() { return OffsetDateTime.now(); }
+}
diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DraftController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DraftController.java
new file mode 100644
index 0000000..1a2aa11
--- /dev/null
+++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DraftController.java
@@ -0,0 +1,82 @@
+package cloud.nstart.terravault.ordinis.restapi.web;
+
+import cloud.nstart.terravault.ordinis.restapi.dto.draft.DraftResponse;
+import cloud.nstart.terravault.ordinis.restapi.dto.draft.SubmitDraftRequest;
+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.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * Approval workflow draft endpoints — Phase 0 skeleton.
+ *
+ * Submit + list per dict + global queue + get by id.
+ * Approve / reject / withdraw / update — Phase 1.
+ *
+ *
Дизайн doc: ~/.gstack/projects/claude/zimin-main-design-approval-workflow-v2-20260507-121632.md
+ */
+@RestController
+public class DraftController {
+
+ private final DraftService draftService;
+
+ public DraftController(DraftService draftService) {
+ this.draftService = draftService;
+ }
+
+ /**
+ * Maker submits новый proposal. Returns 201 Created с full draft response.
+ * Если pending draft на этот business_key уже есть → 409 draft_already_pending.
+ */
+ @PostMapping("/api/v1/dictionaries/{name}/records/drafts")
+ @ResponseStatus(HttpStatus.CREATED)
+ public DraftResponse submit(
+ @PathVariable String name,
+ @Valid @RequestBody SubmitDraftRequest req) {
+ return DraftResponse.from(draftService.submit(name, req));
+ }
+
+ /** Per-dictionary pending list. */
+ @RequestMapping(method = org.springframework.web.bind.annotation.RequestMethod.GET,
+ value = "/api/v1/dictionaries/{name}/records/drafts")
+ public List listByDict(@PathVariable String name) {
+ return draftService.listPendingByDictionary(name).stream()
+ .map(DraftResponse::from)
+ .toList();
+ }
+
+ /** Single draft по id (для drilling в reviewer queue). */
+ @GetMapping("/api/v1/drafts/{id}")
+ public DraftResponse getById(@PathVariable UUID id) {
+ return DraftResponse.from(draftService.findById(id));
+ }
+
+ /**
+ * Global reviewer queue (D3=A pool model). Все pending drafts отсортированы
+ * by submitted_at ASC (oldest first — fairness).
+ */
+ @GetMapping("/api/v1/admin/reviews/pending")
+ public Map globalQueue(
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "50") int size) {
+ var pg = draftService.listPendingGlobal(PageRequest.of(page, Math.min(size, 200)));
+ return Map.of(
+ "items", pg.getContent().stream().map(DraftResponse::from).toList(),
+ "page", pg.getNumber(),
+ "size", pg.getSize(),
+ "totalElements", pg.getTotalElements(),
+ "totalPages", pg.getTotalPages());
+ }
+}
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
new file mode 100644
index 0000000..e014925
--- /dev/null
+++ b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftServiceTest.java
@@ -0,0 +1,253 @@
+package cloud.nstart.terravault.ordinis.restapi.service.draft;
+
+import cloud.nstart.terravault.ordinis.auth.ScopeContext;
+import cloud.nstart.terravault.ordinis.domain.DataScope;
+import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
+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.draft.SubmitDraftRequest;
+import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
+import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.dao.DataIntegrityViolationException;
+
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Unit tests {@link DraftService} Phase 0 skeleton.
+ *
+ * JDK 25 + Mockito incompat для concrete classes — используем anonymous
+ * subclass для DictionaryDefinitionService и in-memory stub repository.
+ */
+class DraftServiceTest {
+
+ private static final ObjectMapper OM = new ObjectMapper();
+
+ private final UUID dictId = UUID.randomUUID();
+ private DictionaryDefinitionService defService;
+ private StubDraftRepo draftRepo;
+ private ScopeContext scopeContext;
+ private DraftService service;
+
+ @BeforeEach
+ void setUp() {
+ DictionaryDefinition def = new DictionaryDefinition(
+ dictId, "test_dict", DataScope.PUBLIC, OM.createObjectNode());
+ def.setApprovalRequired(true);
+ defService = new DictionaryDefinitionService(null, null) {
+ @Override
+ public DictionaryDefinition findByName(String name) {
+ if ("test_dict".equals(name)) return def;
+ throw OrdinisException.notFound("dictionary_not_found", "no: " + name);
+ }
+ };
+ draftRepo = new StubDraftRepo();
+ scopeContext = new ScopeContext(null) {
+ @Override
+ public Set currentScopes() {
+ return Set.of(DataScope.PUBLIC);
+ }
+ };
+ service = new DraftService(draftRepo, defService, scopeContext);
+ }
+
+ @Test
+ void submit_createsDraftWithPendingStatus() throws Exception {
+ var req = new SubmitDraftRequest(
+ "BK-001", DraftOperation.CREATE,
+ OM.readTree("{\"name\":\"test\"}"),
+ null, null, null, "initial submit");
+
+ RecordDraft saved = service.submit("test_dict", req);
+
+ assertThat(saved.getStatus()).isEqualTo(DraftStatus.PENDING);
+ assertThat(saved.getDictionaryId()).isEqualTo(dictId);
+ assertThat(saved.getBusinessKey()).isEqualTo("BK-001");
+ assertThat(saved.getOperation()).isEqualTo(DraftOperation.CREATE);
+ assertThat(saved.getMakerComment()).isEqualTo("initial submit");
+ // makerId = "anonymous" если auth context пустой (test env).
+ assertThat(saved.getMakerId()).isEqualTo("anonymous");
+ assertThat(saved.getSubmittedAt()).isNotNull();
+ }
+
+ @Test
+ void submit_409WhenPendingExists() throws Exception {
+ var req = new SubmitDraftRequest(
+ "BK-001", DraftOperation.CREATE,
+ OM.readTree("{}"), null, null, null, null);
+ service.submit("test_dict", req); // first OK
+
+ // Stub raises DataIntegrityViolationException на second insert с тем же business_key.
+ assertThatThrownBy(() -> service.submit("test_dict", req))
+ .isInstanceOf(OrdinisException.class)
+ .satisfies(e -> assertThat(((OrdinisException) e).getCode())
+ .isEqualTo("draft_already_pending"));
+ }
+
+ @Test
+ void submit_invalidGeometryWkt_throwsBadRequest() throws Exception {
+ var req = new SubmitDraftRequest(
+ "BK-001", DraftOperation.CREATE,
+ OM.readTree("{}"),
+ "NOT-A-WKT-STRING",
+ null, null, null);
+
+ assertThatThrownBy(() -> service.submit("test_dict", req))
+ .isInstanceOf(OrdinisException.class)
+ .satisfies(e -> assertThat(((OrdinisException) e).getCode())
+ .isEqualTo("invalid_geometry_wkt"));
+ }
+
+ @Test
+ void submit_dictNotFound_throwsNotFound() throws Exception {
+ var req = new SubmitDraftRequest(
+ "BK-001", DraftOperation.CREATE,
+ OM.readTree("{}"), null, null, null, null);
+
+ assertThatThrownBy(() -> service.submit("missing_dict", req))
+ .isInstanceOf(OrdinisException.class)
+ .satisfies(e -> assertThat(((OrdinisException) e).getCode())
+ .isEqualTo("dictionary_not_found"));
+ }
+
+ @Test
+ void findById_unknownThrowsNotFound() {
+ UUID id = UUID.randomUUID();
+ assertThatThrownBy(() -> service.findById(id))
+ .isInstanceOf(OrdinisException.class)
+ .satisfies(e -> assertThat(((OrdinisException) e).getCode())
+ .isEqualTo("draft_not_found"));
+ }
+
+ @Test
+ void listPendingByDictionary_returnsOnlyPendingForThisDict() throws Exception {
+ service.submit("test_dict", new SubmitDraftRequest(
+ "BK-001", DraftOperation.CREATE, OM.readTree("{}"),
+ null, null, null, null));
+ // Another business_key — should also be pending.
+ service.submit("test_dict", new SubmitDraftRequest(
+ "BK-002", DraftOperation.CREATE, OM.readTree("{}"),
+ null, null, null, null));
+
+ var list = service.listPendingByDictionary("test_dict");
+ assertThat(list).hasSize(2);
+ assertThat(list).allSatisfy(d ->
+ assertThat(d.getStatus()).isEqualTo(DraftStatus.PENDING));
+ }
+
+ // === In-memory stub repo ===
+
+ /** Mimics DB exclusion constraint: one_pending_per_key per (dict, key). */
+ private static class StubDraftRepo implements RecordDraftRepository {
+ private final java.util.Map store = new java.util.LinkedHashMap<>();
+
+ @Override
+ public RecordDraft saveAndFlush(RecordDraft draft) {
+ // Simulate exclusion constraint.
+ boolean conflict = store.values().stream().anyMatch(d ->
+ d.getStatus() == DraftStatus.PENDING
+ && d.getDictionaryId().equals(draft.getDictionaryId())
+ && d.getBusinessKey().equals(draft.getBusinessKey())
+ && !d.getId().equals(draft.getId()));
+ if (conflict) {
+ throw new DataIntegrityViolationException(
+ "record_drafts_one_pending_per_key");
+ }
+ store.put(draft.getId(), draft);
+ return draft;
+ }
+
+ @Override
+ public S save(S entity) {
+ saveAndFlush(entity);
+ return entity;
+ }
+
+ @Override
+ public Optional findById(UUID id) {
+ return Optional.ofNullable(store.get(id));
+ }
+
+ @Override
+ public Optional findPendingByKey(UUID dictionaryId, String businessKey) {
+ return store.values().stream()
+ .filter(d -> d.getStatus() == DraftStatus.PENDING
+ && d.getDictionaryId().equals(dictionaryId)
+ && d.getBusinessKey().equals(businessKey))
+ .findFirst();
+ }
+
+ @Override
+ public org.springframework.data.domain.Page findPending(
+ org.springframework.data.domain.Pageable pageable) {
+ var list = store.values().stream()
+ .filter(d -> d.getStatus() == DraftStatus.PENDING)
+ .toList();
+ return new org.springframework.data.domain.PageImpl<>(list, pageable, list.size());
+ }
+
+ @Override
+ public java.util.List findPendingByDictionary(UUID dictionaryId) {
+ return store.values().stream()
+ .filter(d -> d.getStatus() == DraftStatus.PENDING
+ && d.getDictionaryId().equals(dictionaryId))
+ .toList();
+ }
+
+ @Override
+ public org.springframework.data.domain.Page findByMaker(
+ String makerId, org.springframework.data.domain.Pageable pageable) {
+ var list = store.values().stream()
+ .filter(d -> d.getMakerId().equals(makerId))
+ .toList();
+ return new org.springframework.data.domain.PageImpl<>(list, pageable, list.size());
+ }
+
+ // Unused JpaRepository methods stubbed.
+ @Override public java.util.List findAll() { return store.values().stream().toList(); }
+ @Override public java.util.List findAll(org.springframework.data.domain.Sort sort) { return findAll(); }
+ @Override public org.springframework.data.domain.Page findAll(org.springframework.data.domain.Pageable pageable) { throw new UnsupportedOperationException(); }
+ @Override public java.util.List findAllById(Iterable ids) { throw new UnsupportedOperationException(); }
+ @Override public long count() { return store.size(); }
+ @Override public void deleteById(UUID id) { store.remove(id); }
+ @Override public void delete(RecordDraft entity) { store.remove(entity.getId()); }
+ @Override public void deleteAllById(Iterable extends UUID> ids) { ids.forEach(store::remove); }
+ @Override public void deleteAll(Iterable extends RecordDraft> entities) { entities.forEach(this::delete); }
+ @Override public void deleteAll() { store.clear(); }
+ @Override public boolean existsById(UUID id) { return store.containsKey(id); }
+ @Override public java.util.List saveAll(Iterable entities) {
+ java.util.List out = new java.util.ArrayList<>();
+ for (S e : entities) out.add(save(e));
+ return out;
+ }
+ @Override public java.util.List saveAllAndFlush(Iterable entities) { return saveAll(entities); }
+ @Override public void flush() {}
+ @Override public void deleteAllInBatch(Iterable entities) { deleteAll(entities); }
+ @Override public void deleteAllByIdInBatch(Iterable ids) { deleteAllById(ids); }
+ @Override public void deleteAllInBatch() { deleteAll(); }
+ @Override public RecordDraft getOne(UUID id) { return store.get(id); }
+ @Override public RecordDraft getById(UUID id) { return store.get(id); }
+ @Override public RecordDraft getReferenceById(UUID id) { return store.get(id); }
+ @Override public java.util.List findAll(org.springframework.data.domain.Example example) { throw new UnsupportedOperationException(); }
+ @Override public java.util.List findAll(org.springframework.data.domain.Example example, org.springframework.data.domain.Sort sort) { throw new UnsupportedOperationException(); }
+ @Override public org.springframework.data.domain.Page findAll(org.springframework.data.domain.Example example, org.springframework.data.domain.Pageable pageable) { throw new UnsupportedOperationException(); }
+ @Override public long count(org.springframework.data.domain.Example example) { return 0; }
+ @Override public boolean exists(org.springframework.data.domain.Example example) { return false; }
+ @Override public Optional findOne(org.springframework.data.domain.Example example) { return Optional.empty(); }
+ @Override public R findBy(org.springframework.data.domain.Example example, java.util.function.Function, R> queryFunction) { throw new UnsupportedOperationException(); }
+ }
+
+ /** ScopeContext getter — match service's protected accessor through field. */
+ @SuppressWarnings("unused")
+ private final AtomicReference