feat(approval): Approval Workflow v2 Phase 0 — schema + skeleton API
Approval Workflow v2 epic, Phase 0 foundation. Maker-checker pattern для
критичных справочников через record_drafts отдельную таблицу (Approach A
из design doc).
Decisions made (auto, per design doc recommendations):
- D1=A: per-dict policy через колонки на dictionary_definitions
(approval_required + approval_min_role).
- D2=A: legacy POST на approval-required dict вернёт 409 (Phase 1 wire'ит
в DictionaryRecordService).
- D3=A: pool model для reviewer'ов — все ordinis:approver видят queue.
- D4=A: rejected drafts kept (status='rejected'); compliance audit trail.
Migration 0019:
- record_drafts table:
* id, dictionary_id, business_key, parent_record_id, operation
* data (jsonb), geometry, proposed_valid_from/to
* status, maker_id, maker_comment, submitted_at
* reviewer_id, reviewed_at, review_comment, version
- 4 constraints:
* status_check: pending/approved/rejected/withdrawn
* operation_check: CREATE/UPDATE/CLOSE
* no_self_approve: maker_id != reviewer_id
* one_pending_per_key: EXCLUDE WHERE status=pending
(concurrent edits → последний wins на submit, prevents merge conflicts)
- 3 indices: pending_by_dict, by_maker, pending_global.
- dictionary_definitions: +approval_required (BOOLEAN, default false),
+approval_min_role (VARCHAR 64, optional Keycloak role).
Backend:
- Entity RecordDraft + Repository с JPQL queries для:
* findPendingByKey (single pending lookup)
* findPending (global queue для approver pool, paginated)
* findPendingByDictionary (per-dict list)
* findByMaker (own drafts tracking, paginated)
- DTO SubmitDraftRequest, DraftResponse.
- DraftService skeleton:
* submit() — creates pending draft. 409 на exclusion violation.
* findById, listPendingByDictionary, listPendingGlobal — read-only.
* approve / reject / withdraw / updateDraft — Phase 1 stubs (комментарии).
- DraftController с 4 endpoints:
* POST /api/v1/dictionaries/{name}/records/drafts — submit
* GET /api/v1/dictionaries/{name}/records/drafts — list per dict
* GET /api/v1/drafts/{id} — single draft
* GET /api/v1/admin/reviews/pending — global queue (paginated)
Tests:
- DraftServiceTest 6 unit tests:
* submit creates with PENDING status
* 409 на duplicate pending business_key (exclusion constraint)
* invalid WKT → 400
* dict not found → 404
* findById unknown → 404
* listPendingByDictionary returns multiple pending
- StubDraftRepo simulates DB exclusion constraint в memory.
Verify:
- mvn -pl ordinis-rest-api -am test: 112/112 PASS (+6 new).
- mvn -P e2e -pl ordinis-app -am test: 20/20 PASS — migration 0019
applies clean, no regression.
- helm/yaml configs не трогали — Phase 0 чистый schema + code.
Phase 1 (next session) — full lifecycle:
- DraftService.approve(): SERIALIZABLE tx с COPY draft → live + outbox
RecordCreated/Updated/Deleted event + audit. Validate against schema
ON APPROVE (могла schema измениться с момента submit'а).
- reject() — mark status=rejected, kept (D4).
- withdraw() — maker отзывает свой pending.
- DictionaryRecordService.create/update/close: pre-check
approval_required → 409 draft_required. JWT subject → maker_id.
- Reviewer queue + diff viewer admin UI.
Source: ~/.gstack/projects/claude/zimin-main-design-approval-workflow-v2-20260507-121632.md
This commit is contained in:
+20
@@ -76,6 +76,22 @@ public class DictionaryDefinition {
|
|||||||
@Column(name = "redis_projection_enabled", nullable = false)
|
@Column(name = "redis_projection_enabled", nullable = false)
|
||||||
private boolean redisProjectionEnabled = false;
|
private boolean redisProjectionEnabled = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-dictionary approval workflow opt-in (Approval v2 epic, D1=A).
|
||||||
|
* False (default) — direct CRUD continues. True — все mutations должны
|
||||||
|
* пройти через draft → review → approve flow в record_drafts table.
|
||||||
|
*/
|
||||||
|
@Column(name = "approval_required", nullable = false)
|
||||||
|
private boolean approvalRequired = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional минимальная Keycloak роль для reviewer'а на этот dict.
|
||||||
|
* Например {@code "ordinis:restricted"} для критичных справочников.
|
||||||
|
* NULL = любой пользователь с ролью {@code ordinis:approver}.
|
||||||
|
*/
|
||||||
|
@Column(name = "approval_min_role", length = 64)
|
||||||
|
private String approvalMinRole;
|
||||||
|
|
||||||
@CreatedDate
|
@CreatedDate
|
||||||
@Column(name = "created_at", nullable = false, updatable = false)
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
private OffsetDateTime createdAt;
|
private OffsetDateTime createdAt;
|
||||||
@@ -116,6 +132,8 @@ public class DictionaryDefinition {
|
|||||||
public String[] getSupportedLocales() { return supportedLocales; }
|
public String[] getSupportedLocales() { return supportedLocales; }
|
||||||
public String getDefaultLocale() { return defaultLocale; }
|
public String getDefaultLocale() { return defaultLocale; }
|
||||||
public boolean isRedisProjectionEnabled() { return redisProjectionEnabled; }
|
public boolean isRedisProjectionEnabled() { return redisProjectionEnabled; }
|
||||||
|
public boolean isApprovalRequired() { return approvalRequired; }
|
||||||
|
public String getApprovalMinRole() { return approvalMinRole; }
|
||||||
public OffsetDateTime getCreatedAt() { return createdAt; }
|
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||||
public OffsetDateTime getUpdatedAt() { return updatedAt; }
|
public OffsetDateTime getUpdatedAt() { return updatedAt; }
|
||||||
public String getCreatedBy() { return createdBy; }
|
public String getCreatedBy() { return createdBy; }
|
||||||
@@ -130,4 +148,6 @@ public class DictionaryDefinition {
|
|||||||
public void setSupportedLocales(String[] v) { this.supportedLocales = v; }
|
public void setSupportedLocales(String[] v) { this.supportedLocales = v; }
|
||||||
public void setDefaultLocale(String v) { this.defaultLocale = v; }
|
public void setDefaultLocale(String v) { this.defaultLocale = v; }
|
||||||
public void setRedisProjectionEnabled(boolean v) { this.redisProjectionEnabled = v; }
|
public void setRedisProjectionEnabled(boolean v) { this.redisProjectionEnabled = v; }
|
||||||
|
public void setApprovalRequired(boolean v) { this.approvalRequired = v; }
|
||||||
|
public void setApprovalMinRole(String v) { this.approvalMinRole = v; }
|
||||||
}
|
}
|
||||||
|
|||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.domain.draft;
|
||||||
|
|
||||||
|
/** What kind of mutation maker proposes. */
|
||||||
|
public enum DraftOperation {
|
||||||
|
CREATE,
|
||||||
|
UPDATE,
|
||||||
|
CLOSE
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.domain.draft;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approval workflow draft state machine (v2).
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link #PENDING} — submitted maker'ом, ждёт reviewer action.</li>
|
||||||
|
* <li>{@link #APPROVED} — terminal: COPY → live + delete draft (только в logs).</li>
|
||||||
|
* <li>{@link #REJECTED} — terminal: kept в таблице (D4=A soft-archive,
|
||||||
|
* compliance + maker видит причину).</li>
|
||||||
|
* <li>{@link #WITHDRAWN} — terminal: maker сам отозвал before review.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public enum DraftStatus {
|
||||||
|
PENDING,
|
||||||
|
APPROVED,
|
||||||
|
REJECTED,
|
||||||
|
WITHDRAWN
|
||||||
|
}
|
||||||
+137
@@ -0,0 +1,137 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.domain.draft;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import io.hypersistence.utils.hibernate.type.json.JsonType;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.EntityListeners;
|
||||||
|
import jakarta.persistence.EnumType;
|
||||||
|
import jakarta.persistence.Enumerated;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import jakarta.persistence.Version;
|
||||||
|
import org.hibernate.annotations.Type;
|
||||||
|
import org.locationtech.jts.geom.Geometry;
|
||||||
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approval workflow draft (v2 epic). Sits в DRAFT layer — не виден existing
|
||||||
|
* read endpoints (`/dictionaries/{name}/records`) до approve'а. Approved
|
||||||
|
* drafts COPY'ятся в {@link cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord}
|
||||||
|
* + delete'ятся (либо kept для status=approved log; design TBD Phase 1).
|
||||||
|
*
|
||||||
|
* <p>Constraints в БД (см. Liquibase 0019):
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code record_drafts_status_check} — pending/approved/rejected/withdrawn.</li>
|
||||||
|
* <li>{@code record_drafts_operation_check} — CREATE/UPDATE/CLOSE.</li>
|
||||||
|
* <li>{@code record_drafts_no_self_approve} — maker_id != reviewer_id.</li>
|
||||||
|
* <li>{@code record_drafts_one_pending_per_key} — EXCLUDE WHERE status=pending.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "record_drafts")
|
||||||
|
@EntityListeners(AuditingEntityListener.class)
|
||||||
|
public class RecordDraft {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "id", nullable = false, updatable = false)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@Column(name = "dictionary_id", nullable = false)
|
||||||
|
private UUID dictionaryId;
|
||||||
|
|
||||||
|
@Column(name = "business_key", nullable = false, length = 256)
|
||||||
|
private String businessKey;
|
||||||
|
|
||||||
|
/** NULL для CREATE; точка истории live record для UPDATE/CLOSE. */
|
||||||
|
@Column(name = "parent_record_id")
|
||||||
|
private UUID parentRecordId;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "operation", nullable = false, length = 16)
|
||||||
|
private DraftOperation operation;
|
||||||
|
|
||||||
|
@Type(JsonType.class)
|
||||||
|
@Column(name = "data", columnDefinition = "jsonb")
|
||||||
|
private JsonNode data;
|
||||||
|
|
||||||
|
@Column(name = "geometry", columnDefinition = "geometry(Geometry, 4326)")
|
||||||
|
private Geometry geometry;
|
||||||
|
|
||||||
|
@Column(name = "proposed_valid_from")
|
||||||
|
private OffsetDateTime proposedValidFrom;
|
||||||
|
|
||||||
|
@Column(name = "proposed_valid_to")
|
||||||
|
private OffsetDateTime proposedValidTo;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "status", nullable = false, length = 16)
|
||||||
|
private DraftStatus status = DraftStatus.PENDING;
|
||||||
|
|
||||||
|
@Column(name = "maker_id", nullable = false, length = 128, updatable = false)
|
||||||
|
private String makerId;
|
||||||
|
|
||||||
|
@Column(name = "maker_comment", columnDefinition = "TEXT")
|
||||||
|
private String makerComment;
|
||||||
|
|
||||||
|
@Column(name = "submitted_at", nullable = false, updatable = false)
|
||||||
|
private OffsetDateTime submittedAt;
|
||||||
|
|
||||||
|
@Column(name = "reviewer_id", length = 128)
|
||||||
|
private String reviewerId;
|
||||||
|
|
||||||
|
@Column(name = "reviewed_at")
|
||||||
|
private OffsetDateTime reviewedAt;
|
||||||
|
|
||||||
|
@Column(name = "review_comment", columnDefinition = "TEXT")
|
||||||
|
private String reviewComment;
|
||||||
|
|
||||||
|
@Version
|
||||||
|
@Column(name = "version")
|
||||||
|
private Long version;
|
||||||
|
|
||||||
|
protected RecordDraft() {}
|
||||||
|
|
||||||
|
public RecordDraft(
|
||||||
|
UUID id, UUID dictionaryId, String businessKey,
|
||||||
|
DraftOperation operation, String makerId) {
|
||||||
|
this.id = id;
|
||||||
|
this.dictionaryId = dictionaryId;
|
||||||
|
this.businessKey = businessKey;
|
||||||
|
this.operation = operation;
|
||||||
|
this.makerId = makerId;
|
||||||
|
this.submittedAt = OffsetDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getId() { return id; }
|
||||||
|
public UUID getDictionaryId() { return dictionaryId; }
|
||||||
|
public String getBusinessKey() { return businessKey; }
|
||||||
|
public UUID getParentRecordId() { return parentRecordId; }
|
||||||
|
public DraftOperation getOperation() { return operation; }
|
||||||
|
public JsonNode getData() { return data; }
|
||||||
|
public Geometry getGeometry() { return geometry; }
|
||||||
|
public OffsetDateTime getProposedValidFrom() { return proposedValidFrom; }
|
||||||
|
public OffsetDateTime getProposedValidTo() { return proposedValidTo; }
|
||||||
|
public DraftStatus getStatus() { return status; }
|
||||||
|
public String getMakerId() { return makerId; }
|
||||||
|
public String getMakerComment() { return makerComment; }
|
||||||
|
public OffsetDateTime getSubmittedAt() { return submittedAt; }
|
||||||
|
public String getReviewerId() { return reviewerId; }
|
||||||
|
public OffsetDateTime getReviewedAt() { return reviewedAt; }
|
||||||
|
public String getReviewComment() { return reviewComment; }
|
||||||
|
public Long getVersion() { return version; }
|
||||||
|
|
||||||
|
public void setParentRecordId(UUID v) { this.parentRecordId = v; }
|
||||||
|
public void setData(JsonNode v) { this.data = v; }
|
||||||
|
public void setGeometry(Geometry v) { this.geometry = v; }
|
||||||
|
public void setProposedValidFrom(OffsetDateTime v) { this.proposedValidFrom = v; }
|
||||||
|
public void setProposedValidTo(OffsetDateTime v) { this.proposedValidTo = v; }
|
||||||
|
public void setStatus(DraftStatus v) { this.status = v; }
|
||||||
|
public void setMakerComment(String v) { this.makerComment = v; }
|
||||||
|
public void setReviewerId(String v) { this.reviewerId = v; }
|
||||||
|
public void setReviewedAt(OffsetDateTime v) { this.reviewedAt = v; }
|
||||||
|
public void setReviewComment(String v) { this.reviewComment = v; }
|
||||||
|
}
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.domain.draft;
|
||||||
|
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public interface RecordDraftRepository extends JpaRepository<RecordDraft, UUID> {
|
||||||
|
|
||||||
|
/** Pending draft на business_key (only one — exclusion constraint). */
|
||||||
|
@Query("""
|
||||||
|
SELECT d FROM RecordDraft d
|
||||||
|
WHERE d.dictionaryId = :dictionaryId
|
||||||
|
AND d.businessKey = :businessKey
|
||||||
|
AND d.status = cloud.nstart.terravault.ordinis.domain.draft.DraftStatus.PENDING
|
||||||
|
""")
|
||||||
|
Optional<RecordDraft> findPendingByKey(
|
||||||
|
@Param("dictionaryId") UUID dictionaryId,
|
||||||
|
@Param("businessKey") String businessKey);
|
||||||
|
|
||||||
|
/** Global queue для approver pool (D3=A). */
|
||||||
|
@Query("""
|
||||||
|
SELECT d FROM RecordDraft d
|
||||||
|
WHERE d.status = cloud.nstart.terravault.ordinis.domain.draft.DraftStatus.PENDING
|
||||||
|
ORDER BY d.submittedAt ASC
|
||||||
|
""")
|
||||||
|
Page<RecordDraft> findPending(Pageable pageable);
|
||||||
|
|
||||||
|
/** Per-dictionary pending list. */
|
||||||
|
@Query("""
|
||||||
|
SELECT d FROM RecordDraft d
|
||||||
|
WHERE d.dictionaryId = :dictionaryId
|
||||||
|
AND d.status = cloud.nstart.terravault.ordinis.domain.draft.DraftStatus.PENDING
|
||||||
|
ORDER BY d.submittedAt ASC
|
||||||
|
""")
|
||||||
|
List<RecordDraft> findPendingByDictionary(@Param("dictionaryId") UUID dictionaryId);
|
||||||
|
|
||||||
|
/** Drafts автора (любого status'а) — для UI tracking "что я отправил". */
|
||||||
|
@Query("""
|
||||||
|
SELECT d FROM RecordDraft d
|
||||||
|
WHERE d.makerId = :makerId
|
||||||
|
ORDER BY d.submittedAt DESC
|
||||||
|
""")
|
||||||
|
Page<RecordDraft> findByMaker(@Param("makerId") String makerId, Pageable pageable);
|
||||||
|
}
|
||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<databaseChangeLog
|
||||||
|
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Approval Workflow v2 — Phase 0: schema foundation.
|
||||||
|
|
||||||
|
Decisions made (auto, per design doc recommendations):
|
||||||
|
- D1=A: per-dict policy через колонки на dictionary_definitions.
|
||||||
|
- D2=A: legacy POST на approval-required dict → 409 (handled в service).
|
||||||
|
- D3=A: pool model для reviewer'ов (no assigned column в schema).
|
||||||
|
- D4=A: soft-archive rejected drafts (status='rejected' kept).
|
||||||
|
|
||||||
|
Layered model (Approach A из design doc):
|
||||||
|
- record_drafts (DRAFT layer) — pending/approved/rejected/withdrawn
|
||||||
|
- dictionary_records (LIVE layer) — only approved versions
|
||||||
|
- ON APPROVE: COPY draft → live + DELETE draft + outbox event
|
||||||
|
|
||||||
|
Source: ~/.gstack/projects/claude/zimin-main-design-approval-workflow-v2-20260507-121632.md
|
||||||
|
-->
|
||||||
|
<changeSet id="0019-1-record-drafts" author="ordinis">
|
||||||
|
<comment>record_drafts table — DRAFT layer для maker-checker workflow</comment>
|
||||||
|
<createTable tableName="record_drafts">
|
||||||
|
<column name="id" type="UUID" defaultValueComputed="gen_random_uuid()">
|
||||||
|
<constraints primaryKey="true" nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="dictionary_id" type="UUID">
|
||||||
|
<constraints nullable="false" foreignKeyName="fk_draft_dictionary"
|
||||||
|
references="dictionary_definitions(id)"/>
|
||||||
|
</column>
|
||||||
|
<column name="business_key" type="VARCHAR(256)">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<!-- NULL для CREATE; точка истории для UPDATE/CLOSE (live record id). -->
|
||||||
|
<column name="parent_record_id" type="UUID"/>
|
||||||
|
<column name="operation" type="VARCHAR(16)">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
|
||||||
|
<!-- Payload (для CREATE/UPDATE — full record; для CLOSE — пусто). -->
|
||||||
|
<column name="data" type="JSONB"/>
|
||||||
|
<column name="geometry" type="GEOMETRY(Geometry, 4326)"/>
|
||||||
|
<column name="proposed_valid_from" type="TIMESTAMPTZ"/>
|
||||||
|
<column name="proposed_valid_to" type="TIMESTAMPTZ"/>
|
||||||
|
|
||||||
|
<!-- Workflow state. -->
|
||||||
|
<column name="status" type="VARCHAR(16)">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="maker_id" type="VARCHAR(128)">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="maker_comment" type="TEXT"/>
|
||||||
|
<column name="submitted_at" type="TIMESTAMPTZ" defaultValueComputed="NOW()">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="reviewer_id" type="VARCHAR(128)"/>
|
||||||
|
<column name="reviewed_at" type="TIMESTAMPTZ"/>
|
||||||
|
<column name="review_comment" type="TEXT"/>
|
||||||
|
<column name="version" type="BIGINT" defaultValueNumeric="0">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
</createTable>
|
||||||
|
|
||||||
|
<sql>
|
||||||
|
ALTER TABLE record_drafts
|
||||||
|
ADD CONSTRAINT record_drafts_status_check
|
||||||
|
CHECK (status IN ('pending', 'approved', 'rejected', 'withdrawn'));
|
||||||
|
</sql>
|
||||||
|
<sql>
|
||||||
|
ALTER TABLE record_drafts
|
||||||
|
ADD CONSTRAINT record_drafts_operation_check
|
||||||
|
CHECK (operation IN ('CREATE', 'UPDATE', 'CLOSE'));
|
||||||
|
</sql>
|
||||||
|
|
||||||
|
<!-- D3 pool model + D4 self-approval guard:
|
||||||
|
maker_id != reviewer_id если оба set'ы. -->
|
||||||
|
<sql>
|
||||||
|
ALTER TABLE record_drafts
|
||||||
|
ADD CONSTRAINT record_drafts_no_self_approve
|
||||||
|
CHECK (reviewer_id IS NULL OR maker_id != reviewer_id);
|
||||||
|
</sql>
|
||||||
|
|
||||||
|
<!-- Только один pending draft на business_key одновременно
|
||||||
|
(concurrent edits = последний wins на submit, prevents merge conflicts). -->
|
||||||
|
<sql>
|
||||||
|
ALTER TABLE record_drafts
|
||||||
|
ADD CONSTRAINT record_drafts_one_pending_per_key
|
||||||
|
EXCLUDE USING btree (
|
||||||
|
dictionary_id WITH =,
|
||||||
|
business_key WITH =
|
||||||
|
) WHERE (status = 'pending');
|
||||||
|
</sql>
|
||||||
|
|
||||||
|
<createIndex indexName="idx_drafts_pending_by_dict" tableName="record_drafts">
|
||||||
|
<column name="dictionary_id"/>
|
||||||
|
<column name="submitted_at"/>
|
||||||
|
</createIndex>
|
||||||
|
<createIndex indexName="idx_drafts_by_maker" tableName="record_drafts">
|
||||||
|
<column name="maker_id"/>
|
||||||
|
<column name="submitted_at" descending="true"/>
|
||||||
|
</createIndex>
|
||||||
|
<createIndex indexName="idx_drafts_pending_global" tableName="record_drafts">
|
||||||
|
<column name="status"/>
|
||||||
|
<column name="submitted_at"/>
|
||||||
|
</createIndex>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet id="0019-2-dict-approval-policy" author="ordinis">
|
||||||
|
<comment>Per-dictionary opt-in: approval_required + min reviewer role (D1=A)</comment>
|
||||||
|
<addColumn tableName="dictionary_definitions">
|
||||||
|
<column name="approval_required" type="BOOLEAN" defaultValueBoolean="false">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="approval_min_role" type="VARCHAR(64)"/>
|
||||||
|
</addColumn>
|
||||||
|
<createIndex indexName="idx_dict_def_approval_required"
|
||||||
|
tableName="dictionary_definitions">
|
||||||
|
<column name="approval_required"/>
|
||||||
|
</createIndex>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
@@ -28,5 +28,6 @@
|
|||||||
<include file="changes/0016-record-dependents-index.xml" relativeToChangelogFile="true"/>
|
<include file="changes/0016-record-dependents-index.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="changes/0017-redis-projection-flag.xml" relativeToChangelogFile="true"/>
|
<include file="changes/0017-redis-projection-flag.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="changes/0018-trgm-search-index.xml" relativeToChangelogFile="true"/>
|
<include file="changes/0018-trgm-search-index.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changes/0019-approval-workflow-v2.xml" relativeToChangelogFile="true"/>
|
||||||
|
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|||||||
+52
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
@@ -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) {}
|
||||||
+157
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>Текущий scope: submit + list + get (read-only operations + new draft
|
||||||
|
* insert). Полный lifecycle (approve / reject / withdraw + COPY-on-approve)
|
||||||
|
* — Phase 1.
|
||||||
|
*
|
||||||
|
* <p>Decisions (auto, per design doc):
|
||||||
|
* <ul>
|
||||||
|
* <li>D1=A: per-dictionary policy через колонки на dictionary_definitions.</li>
|
||||||
|
* <li>D2=A: existing endpoints вернут 409 если approval_required=true
|
||||||
|
* (handled в DictionaryRecordService — TBD Phase 1).</li>
|
||||||
|
* <li>D3=A: pool model для reviewer'ов (нет explicit assignment).</li>
|
||||||
|
* <li>D4=A: rejected drafts kept (status=rejected, kept в таблице).</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@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.
|
||||||
|
*
|
||||||
|
* <p>Pre-conditions:
|
||||||
|
* <ul>
|
||||||
|
* <li>Dict существует и виден caller'у в scope.</li>
|
||||||
|
* <li>Нет другого pending draft на этот business_key (DB exclusion
|
||||||
|
* constraint обеспечивает; конфликт = 409).</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>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<RecordDraft> listPendingByDictionary(String dictionaryName) {
|
||||||
|
DictionaryDefinition def = definitionService.findByName(dictionaryName);
|
||||||
|
return draftRepo.findPendingByDictionary(def.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Page<RecordDraft> 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(); }
|
||||||
|
}
|
||||||
+82
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>Submit + list per dict + global queue + get by id.
|
||||||
|
* Approve / reject / withdraw / update — Phase 1.
|
||||||
|
*
|
||||||
|
* <p>Дизайн 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<DraftResponse> 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<String, Object> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+253
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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<DataScope> 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<UUID, RecordDraft> 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 extends RecordDraft> S save(S entity) {
|
||||||
|
saveAndFlush(entity);
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<RecordDraft> findById(UUID id) {
|
||||||
|
return Optional.ofNullable(store.get(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<RecordDraft> 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<RecordDraft> 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<RecordDraft> 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<RecordDraft> 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<RecordDraft> findAll() { return store.values().stream().toList(); }
|
||||||
|
@Override public java.util.List<RecordDraft> findAll(org.springframework.data.domain.Sort sort) { return findAll(); }
|
||||||
|
@Override public org.springframework.data.domain.Page<RecordDraft> findAll(org.springframework.data.domain.Pageable pageable) { throw new UnsupportedOperationException(); }
|
||||||
|
@Override public java.util.List<RecordDraft> findAllById(Iterable<UUID> 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 <S extends RecordDraft> java.util.List<S> saveAll(Iterable<S> entities) {
|
||||||
|
java.util.List<S> out = new java.util.ArrayList<>();
|
||||||
|
for (S e : entities) out.add(save(e));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
@Override public <S extends RecordDraft> java.util.List<S> saveAllAndFlush(Iterable<S> entities) { return saveAll(entities); }
|
||||||
|
@Override public void flush() {}
|
||||||
|
@Override public void deleteAllInBatch(Iterable<RecordDraft> entities) { deleteAll(entities); }
|
||||||
|
@Override public void deleteAllByIdInBatch(Iterable<UUID> 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 <S extends RecordDraft> java.util.List<S> findAll(org.springframework.data.domain.Example<S> example) { throw new UnsupportedOperationException(); }
|
||||||
|
@Override public <S extends RecordDraft> java.util.List<S> findAll(org.springframework.data.domain.Example<S> example, org.springframework.data.domain.Sort sort) { throw new UnsupportedOperationException(); }
|
||||||
|
@Override public <S extends RecordDraft> org.springframework.data.domain.Page<S> findAll(org.springframework.data.domain.Example<S> example, org.springframework.data.domain.Pageable pageable) { throw new UnsupportedOperationException(); }
|
||||||
|
@Override public <S extends RecordDraft> long count(org.springframework.data.domain.Example<S> example) { return 0; }
|
||||||
|
@Override public <S extends RecordDraft> boolean exists(org.springframework.data.domain.Example<S> example) { return false; }
|
||||||
|
@Override public <S extends RecordDraft> Optional<S> findOne(org.springframework.data.domain.Example<S> example) { return Optional.empty(); }
|
||||||
|
@Override public <S extends RecordDraft, R> R findBy(org.springframework.data.domain.Example<S> example, java.util.function.Function<org.springframework.data.repository.query.FluentQuery.FetchableFluentQuery<S>, R> queryFunction) { throw new UnsupportedOperationException(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ScopeContext getter — match service's protected accessor through field. */
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
private final AtomicReference<Object> _silenceUnusedScope = new AtomicReference<>();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user