mirror of
https://github.com/hpd840321/craftlabs-authorization-sdk.git
synced 2026-06-09 18:10:30 +08:00
feat(platform): I3 contracts, lines, status machine, and audit API
Add Flyway V3 tables, contract CRUD and line endpoints, PATCH status transitions with validation, M10-F01 audit-events listing, 409 handler, and integration tests. Refresh OpenAPI contract snapshot. Made-with: Cursor
This commit is contained in:
+14
@@ -0,0 +1,14 @@
|
||||
package cn.craftlabs.platform.api.audit;
|
||||
|
||||
/** 审计动作常量(M10-F01)。 */
|
||||
public final class AuditActions {
|
||||
|
||||
public static final String CONTRACT_CREATED = "CONTRACT_CREATED";
|
||||
public static final String CONTRACT_UPDATED = "CONTRACT_UPDATED";
|
||||
public static final String CONTRACT_LINE_ADDED = "CONTRACT_LINE_ADDED";
|
||||
public static final String CONTRACT_LINE_UPDATED = "CONTRACT_LINE_UPDATED";
|
||||
public static final String CONTRACT_LINE_DELETED = "CONTRACT_LINE_DELETED";
|
||||
public static final String CONTRACT_STATUS_CHANGED = "CONTRACT_STATUS_CHANGED";
|
||||
|
||||
private AuditActions() {}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package cn.craftlabs.platform.api.audit;
|
||||
|
||||
import cn.craftlabs.platform.api.service.AuditService;
|
||||
import cn.craftlabs.platform.api.web.dto.AuditEventResponse;
|
||||
import cn.craftlabs.platform.api.web.dto.PageResponse;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/audit-events")
|
||||
@Validated
|
||||
public class AuditController {
|
||||
|
||||
private final AuditService auditService;
|
||||
|
||||
public AuditController(AuditService auditService) {
|
||||
this.auditService = auditService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public PageResponse<AuditEventResponse> list(
|
||||
@RequestParam("entityType") @NotBlank String entityType,
|
||||
@RequestParam("entityId") @NotNull Long entityId,
|
||||
@RequestParam(value = "page", defaultValue = "0") @Min(0) int page,
|
||||
@RequestParam(value = "size", defaultValue = "20") @Min(1) @Max(200) int size) {
|
||||
return auditService.page(entityType, entityId, page, size);
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package cn.craftlabs.platform.api.audit;
|
||||
|
||||
public final class AuditEntityTypes {
|
||||
|
||||
public static final String CONTRACT = "CONTRACT";
|
||||
|
||||
private AuditEntityTypes() {}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package cn.craftlabs.platform.api.config;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class ApiExceptionHandler {
|
||||
|
||||
@ExceptionHandler(ResponseStatusException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleResponseStatus(ResponseStatusException ex) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", ex.getStatusCode().value());
|
||||
body.put("message", ex.getReason() != null ? ex.getReason() : "");
|
||||
return ResponseEntity.status(ex.getStatusCode()).body(body);
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package cn.craftlabs.platform.api.contracts;
|
||||
|
||||
import cn.craftlabs.platform.api.service.ContractService;
|
||||
import cn.craftlabs.platform.api.web.dto.ContractCreateRequest;
|
||||
import cn.craftlabs.platform.api.web.dto.ContractLineRequest;
|
||||
import cn.craftlabs.platform.api.web.dto.ContractLineResponse;
|
||||
import cn.craftlabs.platform.api.web.dto.ContractResponse;
|
||||
import cn.craftlabs.platform.api.web.dto.ContractStatusPatchRequest;
|
||||
import cn.craftlabs.platform.api.web.dto.ContractUpdateRequest;
|
||||
import cn.craftlabs.platform.api.web.dto.PageResponse;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
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;
|
||||
|
||||
/** 合同 API:头信息与行挂在同一资源树下(嵌套路由)。 */
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/contracts")
|
||||
@Validated
|
||||
public class ContractController {
|
||||
|
||||
private final ContractService contractService;
|
||||
|
||||
public ContractController(ContractService contractService) {
|
||||
this.contractService = contractService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public PageResponse<ContractResponse> list(
|
||||
@RequestParam(value = "page", defaultValue = "0") @Min(0) int page,
|
||||
@RequestParam(value = "size", defaultValue = "20") @Min(1) @Max(200) int size,
|
||||
@RequestParam(value = "customerId", required = false) Long customerId,
|
||||
@RequestParam(value = "projectId", required = false) Long projectId,
|
||||
@RequestParam(value = "keyword", required = false) String keyword) {
|
||||
return contractService.page(page, size, customerId, projectId, keyword);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
public ContractResponse create(@Valid @RequestBody ContractCreateRequest request) {
|
||||
return contractService.create(request);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ContractResponse get(@PathVariable("id") long id) {
|
||||
return contractService.getById(id);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ContractResponse update(
|
||||
@PathVariable("id") long id, @Valid @RequestBody ContractUpdateRequest request) {
|
||||
return contractService.update(id, request);
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}/status")
|
||||
public ContractResponse patchStatus(
|
||||
@PathVariable("id") long id, @Valid @RequestBody ContractStatusPatchRequest request) {
|
||||
return contractService.patchStatus(id, request);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/lines")
|
||||
public List<ContractLineResponse> listLines(@PathVariable("id") long contractId) {
|
||||
return contractService.listLines(contractId);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/lines")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
public ContractLineResponse addLine(
|
||||
@PathVariable("id") long contractId, @Valid @RequestBody ContractLineRequest request) {
|
||||
return contractService.addLine(contractId, request);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/lines/{lineId}")
|
||||
public ContractLineResponse updateLine(
|
||||
@PathVariable("id") long contractId,
|
||||
@PathVariable("lineId") long lineId,
|
||||
@Valid @RequestBody ContractLineRequest request) {
|
||||
return contractService.updateLine(contractId, lineId, request);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}/lines/{lineId}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void deleteLine(@PathVariable("id") long contractId, @PathVariable("lineId") long lineId) {
|
||||
contractService.deleteLine(contractId, lineId);
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package cn.craftlabs.platform.api.domain;
|
||||
|
||||
/**
|
||||
* 合同生命周期状态。
|
||||
*
|
||||
* <p>允许的状态迁移(非法迁移返回 409):
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link #DRAFT} → {@link #PENDING_EFFECTIVE} → {@link #EFFECTIVE}
|
||||
* <li>{@link #EFFECTIVE} → {@link #CHANGING} → {@link #EFFECTIVE}
|
||||
* <li>{@link #EFFECTIVE} → {@link #TERMINATED}(自生效态终止;终止后不可再迁移)
|
||||
* </ul>
|
||||
*/
|
||||
public enum ContractStatus {
|
||||
DRAFT,
|
||||
PENDING_EFFECTIVE,
|
||||
EFFECTIVE,
|
||||
CHANGING,
|
||||
TERMINATED;
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package cn.craftlabs.platform.api.persistence.audit;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
@TableName("platform_audit_event")
|
||||
public class PlatformAuditEvent {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
@TableField("entity_type")
|
||||
private String entityType;
|
||||
|
||||
@TableField("entity_id")
|
||||
private Long entityId;
|
||||
|
||||
private String action;
|
||||
|
||||
@TableField("field_name")
|
||||
private String fieldName;
|
||||
|
||||
@TableField("old_value")
|
||||
private String oldValue;
|
||||
|
||||
@TableField("new_value")
|
||||
private String newValue;
|
||||
|
||||
@TableField("actor_user_id")
|
||||
private String actorUserId;
|
||||
|
||||
@TableField("created_at")
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getEntityType() {
|
||||
return entityType;
|
||||
}
|
||||
|
||||
public void setEntityType(String entityType) {
|
||||
this.entityType = entityType;
|
||||
}
|
||||
|
||||
public Long getEntityId() {
|
||||
return entityId;
|
||||
}
|
||||
|
||||
public void setEntityId(Long entityId) {
|
||||
this.entityId = entityId;
|
||||
}
|
||||
|
||||
public String getAction() {
|
||||
return action;
|
||||
}
|
||||
|
||||
public void setAction(String action) {
|
||||
this.action = action;
|
||||
}
|
||||
|
||||
public String getFieldName() {
|
||||
return fieldName;
|
||||
}
|
||||
|
||||
public void setFieldName(String fieldName) {
|
||||
this.fieldName = fieldName;
|
||||
}
|
||||
|
||||
public String getOldValue() {
|
||||
return oldValue;
|
||||
}
|
||||
|
||||
public void setOldValue(String oldValue) {
|
||||
this.oldValue = oldValue;
|
||||
}
|
||||
|
||||
public String getNewValue() {
|
||||
return newValue;
|
||||
}
|
||||
|
||||
public void setNewValue(String newValue) {
|
||||
this.newValue = newValue;
|
||||
}
|
||||
|
||||
public String getActorUserId() {
|
||||
return actorUserId;
|
||||
}
|
||||
|
||||
public void setActorUserId(String actorUserId) {
|
||||
this.actorUserId = actorUserId;
|
||||
}
|
||||
|
||||
public OffsetDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(OffsetDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package cn.craftlabs.platform.api.persistence.audit;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface PlatformAuditEventMapper extends BaseMapper<PlatformAuditEvent> {}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
package cn.craftlabs.platform.api.persistence.contract;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
@TableName("platform_contract")
|
||||
public class PlatformContract {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
@TableField("customer_id")
|
||||
private Long customerId;
|
||||
|
||||
@TableField("project_id")
|
||||
private Long projectId;
|
||||
|
||||
private String title;
|
||||
|
||||
private String remarks;
|
||||
|
||||
private String status;
|
||||
|
||||
@TableField("created_at")
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
@TableField("updated_at")
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public Long getCustomerId() {
|
||||
return customerId;
|
||||
}
|
||||
|
||||
public void setCustomerId(Long customerId) {
|
||||
this.customerId = customerId;
|
||||
}
|
||||
|
||||
public Long getProjectId() {
|
||||
return projectId;
|
||||
}
|
||||
|
||||
public void setProjectId(Long projectId) {
|
||||
this.projectId = projectId;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public void setTitle(String title) {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
public String getRemarks() {
|
||||
return remarks;
|
||||
}
|
||||
|
||||
public void setRemarks(String remarks) {
|
||||
this.remarks = remarks;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public OffsetDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(OffsetDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public OffsetDateTime getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(OffsetDateTime updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package cn.craftlabs.platform.api.persistence.contract;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
@TableName("platform_contract_line")
|
||||
public class PlatformContractLine {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
@TableField("contract_id")
|
||||
private Long contractId;
|
||||
|
||||
@TableField("sort_order")
|
||||
private Integer sortOrder;
|
||||
|
||||
@TableField("item_name")
|
||||
private String itemName;
|
||||
|
||||
private BigDecimal quantity;
|
||||
|
||||
private String unit;
|
||||
|
||||
private BigDecimal amount;
|
||||
|
||||
private String remark;
|
||||
|
||||
@TableField("created_at")
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
@TableField("updated_at")
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public Long getContractId() {
|
||||
return contractId;
|
||||
}
|
||||
|
||||
public void setContractId(Long contractId) {
|
||||
this.contractId = contractId;
|
||||
}
|
||||
|
||||
public Integer getSortOrder() {
|
||||
return sortOrder;
|
||||
}
|
||||
|
||||
public void setSortOrder(Integer sortOrder) {
|
||||
this.sortOrder = sortOrder;
|
||||
}
|
||||
|
||||
public String getItemName() {
|
||||
return itemName;
|
||||
}
|
||||
|
||||
public void setItemName(String itemName) {
|
||||
this.itemName = itemName;
|
||||
}
|
||||
|
||||
public BigDecimal getQuantity() {
|
||||
return quantity;
|
||||
}
|
||||
|
||||
public void setQuantity(BigDecimal quantity) {
|
||||
this.quantity = quantity;
|
||||
}
|
||||
|
||||
public String getUnit() {
|
||||
return unit;
|
||||
}
|
||||
|
||||
public void setUnit(String unit) {
|
||||
this.unit = unit;
|
||||
}
|
||||
|
||||
public BigDecimal getAmount() {
|
||||
return amount;
|
||||
}
|
||||
|
||||
public void setAmount(BigDecimal amount) {
|
||||
this.amount = amount;
|
||||
}
|
||||
|
||||
public String getRemark() {
|
||||
return remark;
|
||||
}
|
||||
|
||||
public void setRemark(String remark) {
|
||||
this.remark = remark;
|
||||
}
|
||||
|
||||
public OffsetDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(OffsetDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public OffsetDateTime getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(OffsetDateTime updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package cn.craftlabs.platform.api.persistence.contract;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface PlatformContractLineMapper extends BaseMapper<PlatformContractLine> {}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package cn.craftlabs.platform.api.persistence.contract;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface PlatformContractMapper extends BaseMapper<PlatformContract> {}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package cn.craftlabs.platform.api.service;
|
||||
|
||||
import cn.craftlabs.platform.api.persistence.audit.PlatformAuditEvent;
|
||||
import cn.craftlabs.platform.api.persistence.audit.PlatformAuditEventMapper;
|
||||
import cn.craftlabs.platform.api.web.dto.AuditEventResponse;
|
||||
import cn.craftlabs.platform.api.web.dto.PageResponse;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class AuditService {
|
||||
|
||||
private final PlatformAuditEventMapper auditEventMapper;
|
||||
|
||||
public AuditService(PlatformAuditEventMapper auditEventMapper) {
|
||||
this.auditEventMapper = auditEventMapper;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void record(
|
||||
String entityType,
|
||||
long entityId,
|
||||
String action,
|
||||
String fieldName,
|
||||
String oldValue,
|
||||
String newValue) {
|
||||
PlatformAuditEvent e = new PlatformAuditEvent();
|
||||
e.setEntityType(entityType);
|
||||
e.setEntityId(entityId);
|
||||
e.setAction(action);
|
||||
e.setFieldName(blankToNull(fieldName));
|
||||
e.setOldValue(oldValue);
|
||||
e.setNewValue(newValue);
|
||||
e.setActorUserId(currentActorId());
|
||||
e.setCreatedAt(OffsetDateTime.now(ZoneOffset.UTC));
|
||||
auditEventMapper.insert(e);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<AuditEventResponse> page(
|
||||
String entityType, Long entityId, int page, int size) {
|
||||
LambdaQueryWrapper<PlatformAuditEvent> q =
|
||||
Wrappers.lambdaQuery(PlatformAuditEvent.class)
|
||||
.eq(PlatformAuditEvent::getEntityType, entityType.trim())
|
||||
.eq(PlatformAuditEvent::getEntityId, entityId)
|
||||
.orderByDesc(PlatformAuditEvent::getId);
|
||||
Page<PlatformAuditEvent> mpPage = new Page<>(page + 1L, size);
|
||||
auditEventMapper.selectPage(mpPage, q);
|
||||
List<AuditEventResponse> content =
|
||||
mpPage.getRecords().stream().map(this::toResponse).collect(Collectors.toList());
|
||||
return new PageResponse<>(content, mpPage.getTotal(), page, size);
|
||||
}
|
||||
|
||||
private AuditEventResponse toResponse(PlatformAuditEvent e) {
|
||||
AuditEventResponse r = new AuditEventResponse();
|
||||
r.setId(e.getId());
|
||||
r.setEntityType(e.getEntityType());
|
||||
r.setEntityId(e.getEntityId());
|
||||
r.setAction(e.getAction());
|
||||
r.setFieldName(e.getFieldName());
|
||||
r.setOldValue(e.getOldValue());
|
||||
r.setNewValue(e.getNewValue());
|
||||
r.setActorUserId(e.getActorUserId());
|
||||
r.setCreatedAt(e.getCreatedAt());
|
||||
return r;
|
||||
}
|
||||
|
||||
private static String blankToNull(String s) {
|
||||
return StringUtils.hasText(s) ? s : null;
|
||||
}
|
||||
|
||||
private static String currentActorId() {
|
||||
Authentication a = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (a == null || !a.isAuthenticated()) {
|
||||
return null;
|
||||
}
|
||||
return a.getName();
|
||||
}
|
||||
}
|
||||
+376
@@ -0,0 +1,376 @@
|
||||
package cn.craftlabs.platform.api.service;
|
||||
|
||||
import cn.craftlabs.platform.api.audit.AuditActions;
|
||||
import cn.craftlabs.platform.api.audit.AuditEntityTypes;
|
||||
import cn.craftlabs.platform.api.domain.ContractStatus;
|
||||
import cn.craftlabs.platform.api.persistence.contract.PlatformContract;
|
||||
import cn.craftlabs.platform.api.persistence.contract.PlatformContractLine;
|
||||
import cn.craftlabs.platform.api.persistence.contract.PlatformContractLineMapper;
|
||||
import cn.craftlabs.platform.api.persistence.contract.PlatformContractMapper;
|
||||
import cn.craftlabs.platform.api.persistence.project.PlatformProject;
|
||||
import cn.craftlabs.platform.api.persistence.project.PlatformProjectMapper;
|
||||
import cn.craftlabs.platform.api.web.dto.ContractCreateRequest;
|
||||
import cn.craftlabs.platform.api.web.dto.ContractLineRequest;
|
||||
import cn.craftlabs.platform.api.web.dto.ContractLineResponse;
|
||||
import cn.craftlabs.platform.api.web.dto.ContractResponse;
|
||||
import cn.craftlabs.platform.api.web.dto.ContractStatusPatchRequest;
|
||||
import cn.craftlabs.platform.api.web.dto.ContractUpdateRequest;
|
||||
import cn.craftlabs.platform.api.web.dto.PageResponse;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class ContractService {
|
||||
|
||||
private final PlatformContractMapper contractMapper;
|
||||
private final PlatformContractLineMapper lineMapper;
|
||||
private final PlatformProjectMapper projectMapper;
|
||||
private final ContractStatusTransitionService transitionService;
|
||||
private final AuditService auditService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public ContractService(
|
||||
PlatformContractMapper contractMapper,
|
||||
PlatformContractLineMapper lineMapper,
|
||||
PlatformProjectMapper projectMapper,
|
||||
ContractStatusTransitionService transitionService,
|
||||
AuditService auditService,
|
||||
ObjectMapper objectMapper) {
|
||||
this.contractMapper = contractMapper;
|
||||
this.lineMapper = lineMapper;
|
||||
this.projectMapper = projectMapper;
|
||||
this.transitionService = transitionService;
|
||||
this.auditService = auditService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ContractResponse create(ContractCreateRequest request) {
|
||||
validateProjectBelongsToCustomer(request.getProjectId(), request.getCustomerId());
|
||||
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
|
||||
PlatformContract c = new PlatformContract();
|
||||
c.setCustomerId(request.getCustomerId());
|
||||
c.setProjectId(request.getProjectId());
|
||||
c.setTitle(blankToNull(request.getTitle()));
|
||||
c.setRemarks(blankToNull(request.getRemarks()));
|
||||
c.setStatus(ContractStatus.DRAFT.name());
|
||||
c.setCreatedAt(now);
|
||||
c.setUpdatedAt(now);
|
||||
contractMapper.insert(c);
|
||||
auditService.record(
|
||||
AuditEntityTypes.CONTRACT,
|
||||
c.getId(),
|
||||
AuditActions.CONTRACT_CREATED,
|
||||
null,
|
||||
null,
|
||||
toJson(headerSnapshot(c)));
|
||||
return toResponse(c);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<ContractResponse> page(
|
||||
int page, int size, Long customerId, Long projectId, String keyword) {
|
||||
String kw = StringUtils.hasText(keyword) ? keyword.trim() : null;
|
||||
LambdaQueryWrapper<PlatformContract> q =
|
||||
Wrappers.lambdaQuery(PlatformContract.class)
|
||||
.eq(customerId != null, PlatformContract::getCustomerId, customerId)
|
||||
.eq(projectId != null, PlatformContract::getProjectId, projectId)
|
||||
.like(kw != null, PlatformContract::getTitle, kw)
|
||||
.orderByDesc(PlatformContract::getId);
|
||||
Page<PlatformContract> mpPage = new Page<>(page + 1L, size);
|
||||
contractMapper.selectPage(mpPage, q);
|
||||
List<ContractResponse> content =
|
||||
mpPage.getRecords().stream().map(this::toResponse).collect(Collectors.toList());
|
||||
return new PageResponse<>(content, mpPage.getTotal(), page, size);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public ContractResponse getById(long id) {
|
||||
PlatformContract c = requireContract(id);
|
||||
ContractResponse r = toResponse(c);
|
||||
r.setLines(listLines(id));
|
||||
return r;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ContractResponse update(long id, ContractUpdateRequest request) {
|
||||
PlatformContract c = requireContract(id);
|
||||
requireDraftForHeaderEdit(c);
|
||||
if (request.getTitle() == null && request.getRemarks() == null) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "at least one of title or remarks must be provided");
|
||||
}
|
||||
String oldJson = toJson(headerSnapshot(c));
|
||||
if (request.getTitle() != null) {
|
||||
c.setTitle(blankToNull(request.getTitle()));
|
||||
}
|
||||
if (request.getRemarks() != null) {
|
||||
c.setRemarks(blankToNull(request.getRemarks()));
|
||||
}
|
||||
c.setUpdatedAt(OffsetDateTime.now(ZoneOffset.UTC));
|
||||
contractMapper.updateById(c);
|
||||
auditService.record(
|
||||
AuditEntityTypes.CONTRACT,
|
||||
id,
|
||||
AuditActions.CONTRACT_UPDATED,
|
||||
null,
|
||||
oldJson,
|
||||
toJson(headerSnapshot(c)));
|
||||
return toResponse(c);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ContractResponse patchStatus(long id, ContractStatusPatchRequest request) {
|
||||
PlatformContract c = requireContract(id);
|
||||
ContractStatus from = parseStatus(c.getStatus());
|
||||
ContractStatus to = parseStatusOrBadRequest(request.getStatus());
|
||||
transitionService.requireTransition(from, to);
|
||||
if (from == to) {
|
||||
return toResponse(c);
|
||||
}
|
||||
String oldJson = toJson(Map.of("status", from.name()));
|
||||
c.setStatus(to.name());
|
||||
c.setUpdatedAt(OffsetDateTime.now(ZoneOffset.UTC));
|
||||
contractMapper.updateById(c);
|
||||
auditService.record(
|
||||
AuditEntityTypes.CONTRACT,
|
||||
id,
|
||||
AuditActions.CONTRACT_STATUS_CHANGED,
|
||||
"status",
|
||||
oldJson,
|
||||
toJson(Map.of("status", to.name())));
|
||||
return toResponse(c);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<ContractLineResponse> listLines(long contractId) {
|
||||
requireContract(contractId);
|
||||
LambdaQueryWrapper<PlatformContractLine> q =
|
||||
Wrappers.lambdaQuery(PlatformContractLine.class)
|
||||
.eq(PlatformContractLine::getContractId, contractId)
|
||||
.orderByAsc(PlatformContractLine::getSortOrder)
|
||||
.orderByAsc(PlatformContractLine::getId);
|
||||
return lineMapper.selectList(q).stream().map(this::toLineResponse).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ContractLineResponse addLine(long contractId, ContractLineRequest request) {
|
||||
PlatformContract c = requireContract(contractId);
|
||||
requireDraftForLineMutation(c);
|
||||
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
|
||||
PlatformContractLine line = new PlatformContractLine();
|
||||
line.setContractId(contractId);
|
||||
line.setSortOrder(resolveSortOrder(contractId, request.getSortOrder()));
|
||||
line.setItemName(request.getItemName().trim());
|
||||
line.setQuantity(request.getQuantity());
|
||||
line.setUnit(blankToNull(request.getUnit()));
|
||||
line.setAmount(request.getAmount());
|
||||
line.setRemark(blankToNull(request.getRemark()));
|
||||
line.setCreatedAt(now);
|
||||
line.setUpdatedAt(now);
|
||||
lineMapper.insert(line);
|
||||
auditService.record(
|
||||
AuditEntityTypes.CONTRACT,
|
||||
contractId,
|
||||
AuditActions.CONTRACT_LINE_ADDED,
|
||||
null,
|
||||
null,
|
||||
toJson(lineSnapshot(line)));
|
||||
return toLineResponse(line);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ContractLineResponse updateLine(long contractId, long lineId, ContractLineRequest request) {
|
||||
PlatformContract c = requireContract(contractId);
|
||||
requireDraftForLineMutation(c);
|
||||
PlatformContractLine line = requireLine(contractId, lineId);
|
||||
String oldJson = toJson(lineSnapshot(line));
|
||||
line.setSortOrder(resolveSortOrder(contractId, request.getSortOrder(), line.getSortOrder()));
|
||||
line.setItemName(request.getItemName().trim());
|
||||
line.setQuantity(request.getQuantity());
|
||||
line.setUnit(blankToNull(request.getUnit()));
|
||||
line.setAmount(request.getAmount());
|
||||
line.setRemark(blankToNull(request.getRemark()));
|
||||
line.setUpdatedAt(OffsetDateTime.now(ZoneOffset.UTC));
|
||||
lineMapper.updateById(line);
|
||||
auditService.record(
|
||||
AuditEntityTypes.CONTRACT,
|
||||
contractId,
|
||||
AuditActions.CONTRACT_LINE_UPDATED,
|
||||
"line:" + lineId,
|
||||
oldJson,
|
||||
toJson(lineSnapshot(line)));
|
||||
return toLineResponse(line);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteLine(long contractId, long lineId) {
|
||||
PlatformContract c = requireContract(contractId);
|
||||
requireDraftForLineMutation(c);
|
||||
PlatformContractLine line = requireLine(contractId, lineId);
|
||||
String oldJson = toJson(lineSnapshot(line));
|
||||
lineMapper.deleteById(lineId);
|
||||
auditService.record(
|
||||
AuditEntityTypes.CONTRACT,
|
||||
contractId,
|
||||
AuditActions.CONTRACT_LINE_DELETED,
|
||||
"line:" + lineId,
|
||||
oldJson,
|
||||
null);
|
||||
}
|
||||
|
||||
private void validateProjectBelongsToCustomer(long projectId, long customerId) {
|
||||
PlatformProject p = projectMapper.selectById(projectId);
|
||||
if (p == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "project not found");
|
||||
}
|
||||
if (!p.getCustomerId().equals(customerId)) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "project does not belong to the given customer");
|
||||
}
|
||||
}
|
||||
|
||||
private PlatformContract requireContract(long id) {
|
||||
PlatformContract c = contractMapper.selectById(id);
|
||||
if (c == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "contract not found");
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
private PlatformContractLine requireLine(long contractId, long lineId) {
|
||||
PlatformContractLine line = lineMapper.selectById(lineId);
|
||||
if (line == null || !line.getContractId().equals(contractId)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "contract line not found");
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
private void requireDraftForHeaderEdit(PlatformContract c) {
|
||||
if (parseStatus(c.getStatus()) != ContractStatus.DRAFT) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.CONFLICT,
|
||||
"contract header and lines can only be edited in DRAFT status");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireDraftForLineMutation(PlatformContract c) {
|
||||
requireDraftForHeaderEdit(c);
|
||||
}
|
||||
|
||||
private static ContractStatus parseStatus(String raw) {
|
||||
try {
|
||||
return ContractStatus.valueOf(raw);
|
||||
} catch (Exception e) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.INTERNAL_SERVER_ERROR, "invalid contract status stored: " + raw);
|
||||
}
|
||||
}
|
||||
|
||||
private static ContractStatus parseStatusOrBadRequest(String raw) {
|
||||
try {
|
||||
return ContractStatus.valueOf(raw.trim());
|
||||
} catch (Exception e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "unknown contract status: " + raw);
|
||||
}
|
||||
}
|
||||
|
||||
private int resolveSortOrder(long contractId, Integer requested) {
|
||||
return resolveSortOrder(contractId, requested, null);
|
||||
}
|
||||
|
||||
private int resolveSortOrder(long contractId, Integer requested, Integer fallbackExisting) {
|
||||
if (requested != null) {
|
||||
return requested;
|
||||
}
|
||||
if (fallbackExisting != null) {
|
||||
return fallbackExisting;
|
||||
}
|
||||
LambdaQueryWrapper<PlatformContractLine> q =
|
||||
Wrappers.lambdaQuery(PlatformContractLine.class)
|
||||
.eq(PlatformContractLine::getContractId, contractId)
|
||||
.orderByDesc(PlatformContractLine::getSortOrder)
|
||||
.last("LIMIT 1");
|
||||
PlatformContractLine last = lineMapper.selectOne(q);
|
||||
return last == null || last.getSortOrder() == null ? 0 : last.getSortOrder() + 1;
|
||||
}
|
||||
|
||||
private static String blankToNull(String s) {
|
||||
return StringUtils.hasText(s) ? s.trim() : null;
|
||||
}
|
||||
|
||||
private Map<String, Object> headerSnapshot(PlatformContract c) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("id", c.getId());
|
||||
m.put("customerId", c.getCustomerId());
|
||||
m.put("projectId", c.getProjectId());
|
||||
m.put("title", c.getTitle());
|
||||
m.put("remarks", c.getRemarks());
|
||||
m.put("status", c.getStatus());
|
||||
return m;
|
||||
}
|
||||
|
||||
private Map<String, Object> lineSnapshot(PlatformContractLine line) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("id", line.getId());
|
||||
m.put("contractId", line.getContractId());
|
||||
m.put("sortOrder", line.getSortOrder());
|
||||
m.put("itemName", line.getItemName());
|
||||
m.put("quantity", line.getQuantity());
|
||||
m.put("unit", line.getUnit());
|
||||
m.put("amount", line.getAmount());
|
||||
m.put("remark", line.getRemark());
|
||||
return m;
|
||||
}
|
||||
|
||||
private String toJson(Object value) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(value);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private ContractResponse toResponse(PlatformContract c) {
|
||||
ContractResponse r = new ContractResponse();
|
||||
r.setId(c.getId());
|
||||
r.setCustomerId(c.getCustomerId());
|
||||
r.setProjectId(c.getProjectId());
|
||||
r.setTitle(c.getTitle());
|
||||
r.setRemarks(c.getRemarks());
|
||||
r.setStatus(c.getStatus());
|
||||
r.setCreatedAt(c.getCreatedAt());
|
||||
r.setUpdatedAt(c.getUpdatedAt());
|
||||
return r;
|
||||
}
|
||||
|
||||
private ContractLineResponse toLineResponse(PlatformContractLine line) {
|
||||
ContractLineResponse r = new ContractLineResponse();
|
||||
r.setId(line.getId());
|
||||
r.setContractId(line.getContractId());
|
||||
r.setSortOrder(line.getSortOrder());
|
||||
r.setItemName(line.getItemName());
|
||||
r.setQuantity(line.getQuantity());
|
||||
r.setUnit(line.getUnit());
|
||||
r.setAmount(line.getAmount());
|
||||
r.setRemark(line.getRemark());
|
||||
r.setCreatedAt(line.getCreatedAt());
|
||||
r.setUpdatedAt(line.getUpdatedAt());
|
||||
return r;
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package cn.craftlabs.platform.api.service;
|
||||
|
||||
import cn.craftlabs.platform.api.domain.ContractStatus;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/**
|
||||
* 校验合同状态迁移是否合法;不合法时抛出 {@link HttpStatus#CONFLICT}。
|
||||
*
|
||||
* <p>自 {@link ContractStatus#EFFECTIVE} 可直接进入 {@link ContractStatus#TERMINATED}(业务上表示解约/终止生效合同)。
|
||||
*/
|
||||
@Service
|
||||
public class ContractStatusTransitionService {
|
||||
|
||||
public void requireTransition(ContractStatus from, ContractStatus to) {
|
||||
if (from == to) {
|
||||
return;
|
||||
}
|
||||
if (!isAllowed(from, to)) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.CONFLICT,
|
||||
"illegal contract status transition: " + from + " -> " + to);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isAllowed(ContractStatus from, ContractStatus to) {
|
||||
if (from == ContractStatus.DRAFT) {
|
||||
return to == ContractStatus.PENDING_EFFECTIVE;
|
||||
}
|
||||
if (from == ContractStatus.PENDING_EFFECTIVE) {
|
||||
return to == ContractStatus.EFFECTIVE;
|
||||
}
|
||||
if (from == ContractStatus.EFFECTIVE) {
|
||||
return to == ContractStatus.CHANGING || to == ContractStatus.TERMINATED;
|
||||
}
|
||||
if (from == ContractStatus.CHANGING) {
|
||||
return to == ContractStatus.EFFECTIVE;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package cn.craftlabs.platform.api.web.dto;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
public class AuditEventResponse {
|
||||
|
||||
private Long id;
|
||||
private String entityType;
|
||||
private Long entityId;
|
||||
private String action;
|
||||
private String fieldName;
|
||||
private String oldValue;
|
||||
private String newValue;
|
||||
private String actorUserId;
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getEntityType() {
|
||||
return entityType;
|
||||
}
|
||||
|
||||
public void setEntityType(String entityType) {
|
||||
this.entityType = entityType;
|
||||
}
|
||||
|
||||
public Long getEntityId() {
|
||||
return entityId;
|
||||
}
|
||||
|
||||
public void setEntityId(Long entityId) {
|
||||
this.entityId = entityId;
|
||||
}
|
||||
|
||||
public String getAction() {
|
||||
return action;
|
||||
}
|
||||
|
||||
public void setAction(String action) {
|
||||
this.action = action;
|
||||
}
|
||||
|
||||
public String getFieldName() {
|
||||
return fieldName;
|
||||
}
|
||||
|
||||
public void setFieldName(String fieldName) {
|
||||
this.fieldName = fieldName;
|
||||
}
|
||||
|
||||
public String getOldValue() {
|
||||
return oldValue;
|
||||
}
|
||||
|
||||
public void setOldValue(String oldValue) {
|
||||
this.oldValue = oldValue;
|
||||
}
|
||||
|
||||
public String getNewValue() {
|
||||
return newValue;
|
||||
}
|
||||
|
||||
public void setNewValue(String newValue) {
|
||||
this.newValue = newValue;
|
||||
}
|
||||
|
||||
public String getActorUserId() {
|
||||
return actorUserId;
|
||||
}
|
||||
|
||||
public void setActorUserId(String actorUserId) {
|
||||
this.actorUserId = actorUserId;
|
||||
}
|
||||
|
||||
public OffsetDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(OffsetDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package cn.craftlabs.platform.api.web.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public class ContractCreateRequest {
|
||||
|
||||
@NotNull private Long customerId;
|
||||
|
||||
@NotNull private Long projectId;
|
||||
|
||||
@Size(max = 256)
|
||||
private String title;
|
||||
|
||||
@Size(max = 4000)
|
||||
private String remarks;
|
||||
|
||||
public Long getCustomerId() {
|
||||
return customerId;
|
||||
}
|
||||
|
||||
public void setCustomerId(Long customerId) {
|
||||
this.customerId = customerId;
|
||||
}
|
||||
|
||||
public Long getProjectId() {
|
||||
return projectId;
|
||||
}
|
||||
|
||||
public void setProjectId(Long projectId) {
|
||||
this.projectId = projectId;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public void setTitle(String title) {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
public String getRemarks() {
|
||||
return remarks;
|
||||
}
|
||||
|
||||
public void setRemarks(String remarks) {
|
||||
this.remarks = remarks;
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package cn.craftlabs.platform.api.web.dto;
|
||||
|
||||
import jakarta.validation.constraints.DecimalMin;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public class ContractLineRequest {
|
||||
|
||||
private Integer sortOrder;
|
||||
|
||||
@NotBlank
|
||||
@Size(max = 256)
|
||||
private String itemName;
|
||||
|
||||
@NotNull
|
||||
@DecimalMin(value = "0.0001", inclusive = true)
|
||||
private BigDecimal quantity;
|
||||
|
||||
@Size(max = 32)
|
||||
private String unit;
|
||||
|
||||
private BigDecimal amount;
|
||||
|
||||
@Size(max = 512)
|
||||
private String remark;
|
||||
|
||||
public Integer getSortOrder() {
|
||||
return sortOrder;
|
||||
}
|
||||
|
||||
public void setSortOrder(Integer sortOrder) {
|
||||
this.sortOrder = sortOrder;
|
||||
}
|
||||
|
||||
public String getItemName() {
|
||||
return itemName;
|
||||
}
|
||||
|
||||
public void setItemName(String itemName) {
|
||||
this.itemName = itemName;
|
||||
}
|
||||
|
||||
public BigDecimal getQuantity() {
|
||||
return quantity;
|
||||
}
|
||||
|
||||
public void setQuantity(BigDecimal quantity) {
|
||||
this.quantity = quantity;
|
||||
}
|
||||
|
||||
public String getUnit() {
|
||||
return unit;
|
||||
}
|
||||
|
||||
public void setUnit(String unit) {
|
||||
this.unit = unit;
|
||||
}
|
||||
|
||||
public BigDecimal getAmount() {
|
||||
return amount;
|
||||
}
|
||||
|
||||
public void setAmount(BigDecimal amount) {
|
||||
this.amount = amount;
|
||||
}
|
||||
|
||||
public String getRemark() {
|
||||
return remark;
|
||||
}
|
||||
|
||||
public void setRemark(String remark) {
|
||||
this.remark = remark;
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
package cn.craftlabs.platform.api.web.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
public class ContractLineResponse {
|
||||
|
||||
private Long id;
|
||||
private Long contractId;
|
||||
private Integer sortOrder;
|
||||
private String itemName;
|
||||
private BigDecimal quantity;
|
||||
private String unit;
|
||||
private BigDecimal amount;
|
||||
private String remark;
|
||||
private OffsetDateTime createdAt;
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public Long getContractId() {
|
||||
return contractId;
|
||||
}
|
||||
|
||||
public void setContractId(Long contractId) {
|
||||
this.contractId = contractId;
|
||||
}
|
||||
|
||||
public Integer getSortOrder() {
|
||||
return sortOrder;
|
||||
}
|
||||
|
||||
public void setSortOrder(Integer sortOrder) {
|
||||
this.sortOrder = sortOrder;
|
||||
}
|
||||
|
||||
public String getItemName() {
|
||||
return itemName;
|
||||
}
|
||||
|
||||
public void setItemName(String itemName) {
|
||||
this.itemName = itemName;
|
||||
}
|
||||
|
||||
public BigDecimal getQuantity() {
|
||||
return quantity;
|
||||
}
|
||||
|
||||
public void setQuantity(BigDecimal quantity) {
|
||||
this.quantity = quantity;
|
||||
}
|
||||
|
||||
public String getUnit() {
|
||||
return unit;
|
||||
}
|
||||
|
||||
public void setUnit(String unit) {
|
||||
this.unit = unit;
|
||||
}
|
||||
|
||||
public BigDecimal getAmount() {
|
||||
return amount;
|
||||
}
|
||||
|
||||
public void setAmount(BigDecimal amount) {
|
||||
this.amount = amount;
|
||||
}
|
||||
|
||||
public String getRemark() {
|
||||
return remark;
|
||||
}
|
||||
|
||||
public void setRemark(String remark) {
|
||||
this.remark = remark;
|
||||
}
|
||||
|
||||
public OffsetDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(OffsetDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public OffsetDateTime getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(OffsetDateTime updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package cn.craftlabs.platform.api.web.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public class ContractResponse {
|
||||
|
||||
private Long id;
|
||||
private Long customerId;
|
||||
private Long projectId;
|
||||
private String title;
|
||||
private String remarks;
|
||||
private String status;
|
||||
private OffsetDateTime createdAt;
|
||||
private OffsetDateTime updatedAt;
|
||||
/** 仅详情接口填充;列表分页省略该字段。 */
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
private List<ContractLineResponse> lines;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public Long getCustomerId() {
|
||||
return customerId;
|
||||
}
|
||||
|
||||
public void setCustomerId(Long customerId) {
|
||||
this.customerId = customerId;
|
||||
}
|
||||
|
||||
public Long getProjectId() {
|
||||
return projectId;
|
||||
}
|
||||
|
||||
public void setProjectId(Long projectId) {
|
||||
this.projectId = projectId;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public void setTitle(String title) {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
public String getRemarks() {
|
||||
return remarks;
|
||||
}
|
||||
|
||||
public void setRemarks(String remarks) {
|
||||
this.remarks = remarks;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public OffsetDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(OffsetDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public OffsetDateTime getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(OffsetDateTime updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
|
||||
public List<ContractLineResponse> getLines() {
|
||||
return lines;
|
||||
}
|
||||
|
||||
public void setLines(List<ContractLineResponse> lines) {
|
||||
this.lines = lines;
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package cn.craftlabs.platform.api.web.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public class ContractStatusPatchRequest {
|
||||
|
||||
@NotBlank
|
||||
private String status;
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package cn.craftlabs.platform.api.web.dto;
|
||||
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public class ContractUpdateRequest {
|
||||
|
||||
@Size(max = 256)
|
||||
private String title;
|
||||
|
||||
@Size(max = 4000)
|
||||
private String remarks;
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public void setTitle(String title) {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
public String getRemarks() {
|
||||
return remarks;
|
||||
}
|
||||
|
||||
public void setRemarks(String remarks) {
|
||||
this.remarks = remarks;
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
-- M2 P0:合同与行;M10-F01:审计事件(PostgreSQL 15;H2 MODE=PostgreSQL 单测)
|
||||
CREATE TABLE platform_contract (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
customer_id BIGINT NOT NULL REFERENCES platform_customer (id),
|
||||
project_id BIGINT NOT NULL REFERENCES platform_project (id),
|
||||
title VARCHAR(256),
|
||||
remarks TEXT,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'DRAFT',
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_platform_contract_customer_id ON platform_contract (customer_id);
|
||||
CREATE INDEX idx_platform_contract_project_id ON platform_contract (project_id);
|
||||
CREATE INDEX idx_platform_contract_status ON platform_contract (status);
|
||||
|
||||
CREATE TABLE platform_contract_line (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
contract_id BIGINT NOT NULL REFERENCES platform_contract (id) ON DELETE CASCADE,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
item_name VARCHAR(256) NOT NULL,
|
||||
quantity NUMERIC(18, 4) NOT NULL DEFAULT 1,
|
||||
unit VARCHAR(32),
|
||||
amount NUMERIC(18, 2),
|
||||
remark VARCHAR(512),
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_platform_contract_line_contract_id ON platform_contract_line (contract_id);
|
||||
|
||||
CREATE TABLE platform_audit_event (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
entity_type VARCHAR(64) NOT NULL,
|
||||
entity_id BIGINT NOT NULL,
|
||||
action VARCHAR(64) NOT NULL,
|
||||
field_name VARCHAR(256),
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
actor_user_id VARCHAR(256),
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_platform_audit_event_entity ON platform_audit_event (entity_type, entity_id);
|
||||
CREATE INDEX idx_platform_audit_event_created_at ON platform_audit_event (created_at);
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
package cn.craftlabs.platform.api.contracts;
|
||||
|
||||
import cn.craftlabs.platform.api.support.JwtTestSupport;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Transactional
|
||||
class ContractControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Test
|
||||
void contractDraftLineTransitionAuditAndIllegalTransition() throws Exception {
|
||||
String token = JwtTestSupport.obtainBearerToken(mockMvc, objectMapper);
|
||||
String auth = "Bearer " + token;
|
||||
|
||||
String customerBody = "{\"name\":\"合同客户\",\"creditCode\":\"CC001\",\"status\":\"ACTIVE\"}";
|
||||
String customerJson =
|
||||
mockMvc.perform(
|
||||
post("/api/v1/customers")
|
||||
.header("Authorization", auth)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(customerBody))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
long customerId = objectMapper.readTree(customerJson).get("id").asLong();
|
||||
|
||||
String projectBody =
|
||||
String.format(
|
||||
"{\"customerId\":%d,\"name\":\"合同项目\",\"phase\":\"PLANNING\"}", customerId);
|
||||
String projectJson =
|
||||
mockMvc.perform(
|
||||
post("/api/v1/projects")
|
||||
.header("Authorization", auth)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(projectBody))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
long projectId = objectMapper.readTree(projectJson).get("id").asLong();
|
||||
|
||||
String contractBody =
|
||||
String.format(
|
||||
"{\"customerId\":%d,\"projectId\":%d,\"title\":\"框架协议\",\"remarks\":\"备注\"}",
|
||||
customerId, projectId);
|
||||
String contractJson =
|
||||
mockMvc.perform(
|
||||
post("/api/v1/contracts")
|
||||
.header("Authorization", auth)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(contractBody))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.status").value("DRAFT"))
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
long contractId = objectMapper.readTree(contractJson).get("id").asLong();
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v1/contracts")
|
||||
.header("Authorization", auth)
|
||||
.param("page", "0")
|
||||
.param("size", "10")
|
||||
.param("keyword", "框架"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.content[0].id").value(contractId))
|
||||
.andExpect(jsonPath("$.content[0].lines").doesNotExist());
|
||||
|
||||
String lineBody =
|
||||
"{\"itemName\":\"交付项A\",\"quantity\":2,\"unit\":\"套\",\"amount\":10000,\"remark\":\"首行\"}";
|
||||
mockMvc.perform(
|
||||
post("/api/v1/contracts/" + contractId + "/lines")
|
||||
.header("Authorization", auth)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(lineBody))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.itemName").value("交付项A"));
|
||||
|
||||
mockMvc.perform(
|
||||
patch("/api/v1/contracts/" + contractId + "/status")
|
||||
.header("Authorization", auth)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"status\":\"PENDING_EFFECTIVE\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status").value("PENDING_EFFECTIVE"));
|
||||
|
||||
mockMvc.perform(
|
||||
patch("/api/v1/contracts/" + contractId + "/status")
|
||||
.header("Authorization", auth)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"status\":\"EFFECTIVE\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status").value("EFFECTIVE"));
|
||||
|
||||
mockMvc.perform(
|
||||
patch("/api/v1/contracts/" + contractId + "/status")
|
||||
.header("Authorization", auth)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"status\":\"DRAFT\"}"))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(
|
||||
jsonPath("$.message")
|
||||
.value(containsString("illegal contract status transition")));
|
||||
|
||||
String auditBody =
|
||||
mockMvc.perform(
|
||||
get("/api/v1/audit-events")
|
||||
.header("Authorization", auth)
|
||||
.param("entityType", "CONTRACT")
|
||||
.param("entityId", String.valueOf(contractId))
|
||||
.param("page", "0")
|
||||
.param("size", "50"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.totalElements").value(4))
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
JsonNode root = objectMapper.readTree(auditBody);
|
||||
boolean hasCreated = false;
|
||||
boolean hasLine = false;
|
||||
for (JsonNode row : root.get("content")) {
|
||||
String action = row.get("action").asText();
|
||||
if ("CONTRACT_CREATED".equals(action)) {
|
||||
hasCreated = true;
|
||||
}
|
||||
if ("CONTRACT_LINE_ADDED".equals(action)) {
|
||||
hasLine = true;
|
||||
}
|
||||
}
|
||||
assertThat(hasCreated).isTrue();
|
||||
assertThat(hasLine).isTrue();
|
||||
assertThat(root.get("number").asInt()).isZero();
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package cn.craftlabs.platform.api.service;
|
||||
|
||||
import cn.craftlabs.platform.api.domain.ContractStatus;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.EnumSource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class ContractStatusTransitionServiceTest {
|
||||
|
||||
private final ContractStatusTransitionService service = new ContractStatusTransitionService();
|
||||
|
||||
@Test
|
||||
void sameStatusIsNoOp() {
|
||||
assertThatCode(() -> service.requireTransition(ContractStatus.DRAFT, ContractStatus.DRAFT))
|
||||
.doesNotThrowAnyException();
|
||||
assertThatCode(() -> service.requireTransition(ContractStatus.EFFECTIVE, ContractStatus.EFFECTIVE))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void happyPathMainFlow() {
|
||||
assertThatCode(
|
||||
() ->
|
||||
service.requireTransition(
|
||||
ContractStatus.DRAFT, ContractStatus.PENDING_EFFECTIVE))
|
||||
.doesNotThrowAnyException();
|
||||
assertThatCode(
|
||||
() ->
|
||||
service.requireTransition(
|
||||
ContractStatus.PENDING_EFFECTIVE, ContractStatus.EFFECTIVE))
|
||||
.doesNotThrowAnyException();
|
||||
assertThatCode(
|
||||
() ->
|
||||
service.requireTransition(
|
||||
ContractStatus.EFFECTIVE, ContractStatus.CHANGING))
|
||||
.doesNotThrowAnyException();
|
||||
assertThatCode(
|
||||
() ->
|
||||
service.requireTransition(
|
||||
ContractStatus.CHANGING, ContractStatus.EFFECTIVE))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
/** 自 {@link ContractStatus#EFFECTIVE} 可进入 {@link ContractStatus#TERMINATED}(解约/终止)。 */
|
||||
@Test
|
||||
void effectiveToTerminatedAllowed() {
|
||||
assertThatCode(
|
||||
() ->
|
||||
service.requireTransition(
|
||||
ContractStatus.EFFECTIVE, ContractStatus.TERMINATED))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(
|
||||
value = ContractStatus.class,
|
||||
names = {"EFFECTIVE", "CHANGING", "TERMINATED"})
|
||||
void draftRejectsSkipPendingEffective(ContractStatus to) {
|
||||
assertConflict(ContractStatus.DRAFT, to);
|
||||
}
|
||||
|
||||
@Test
|
||||
void effectiveToDraftRejected() {
|
||||
assertConflict(ContractStatus.EFFECTIVE, ContractStatus.DRAFT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void changingToTerminatedRejected() {
|
||||
assertConflict(ContractStatus.CHANGING, ContractStatus.TERMINATED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void terminatedIsTerminal() {
|
||||
assertThatCode(
|
||||
() ->
|
||||
service.requireTransition(
|
||||
ContractStatus.TERMINATED, ContractStatus.TERMINATED))
|
||||
.doesNotThrowAnyException();
|
||||
for (ContractStatus to : ContractStatus.values()) {
|
||||
if (to == ContractStatus.TERMINATED) {
|
||||
continue;
|
||||
}
|
||||
assertConflict(ContractStatus.TERMINATED, to);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void illegalMessageMentionsTransition() {
|
||||
assertThatThrownBy(() -> service.requireTransition(ContractStatus.DRAFT, ContractStatus.EFFECTIVE))
|
||||
.isInstanceOfSatisfying(
|
||||
ResponseStatusException.class,
|
||||
ex -> {
|
||||
assertThat(ex.getStatusCode().value()).isEqualTo(409);
|
||||
assertThat(ex.getReason()).contains("illegal contract status transition");
|
||||
});
|
||||
}
|
||||
|
||||
private void assertConflict(ContractStatus from, ContractStatus to) {
|
||||
assertThatThrownBy(() -> service.requireTransition(from, to))
|
||||
.isInstanceOfSatisfying(
|
||||
ResponseStatusException.class,
|
||||
ex -> assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.CONFLICT));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user