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:
2026-04-06 21:29:21 +08:00
parent 5b50bf0fd8
commit 69f7ee11df
26 changed files with 2439 additions and 0 deletions
@@ -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() {}
}
@@ -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);
}
}
@@ -0,0 +1,8 @@
package cn.craftlabs.platform.api.audit;
public final class AuditEntityTypes {
public static final String CONTRACT = "CONTRACT";
private AuditEntityTypes() {}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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;
}
@@ -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;
}
}
@@ -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> {}
@@ -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;
}
}
@@ -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;
}
}
@@ -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> {}
@@ -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> {}
@@ -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();
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -0,0 +1,45 @@
-- M2 P0:合同与行;M10-F01:审计事件(PostgreSQL 15H2 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);