This commit is contained in:
2026-08-11 09:25:41 +08:00
parent 66b3a0cc9b
commit 540a6f5d88
18 changed files with 757 additions and 90 deletions
+4
View File
@@ -43,3 +43,7 @@ services:
artifactId: audit-service
jar: audit-service/target/audit-service-1.0.0-SNAPSHOT.jar
port: 9700
finance-service:
artifactId: finance-service
jar: finance/target/finance-service-1.0.0-SNAPSHOT.jar
port: 9203
@@ -7282,7 +7282,7 @@ CREATE TABLE IF NOT EXISTS ord_order_fulfillment_split (
PRIMARY KEY(id),
UNIQUE KEY uk_order_split_operation (tenant_id,operation_id,split_role),
UNIQUE KEY uk_order_split_idempotency (tenant_id,parent_order_id,idempotency_key,split_role),
UNIQUE KEY uk_order_split_child (tenant_id,child_order_id),
UNIQUE KEY uk_order_split_child_role (tenant_id,child_order_id,split_role),
KEY idx_order_split_parent (tenant_id,parent_order_id,created_at,id),
KEY idx_order_split_source (tenant_id,source_order_id,split_role),
KEY idx_order_split_container (tenant_id,container_booking_id,created_at,id),
@@ -7294,6 +7294,8 @@ CREATE TABLE IF NOT EXISTS ord_order_fulfillment_split (
DROP TRIGGER IF EXISTS trg_ord_order_parent_insert;
DROP TRIGGER IF EXISTS trg_ord_order_parent_update;
DROP TRIGGER IF EXISTS trg_order_fulfillment_split_insert;
DROP TRIGGER IF EXISTS trg_order_fulfillment_split_update;
DELIMITER $$
CREATE TRIGGER trg_ord_order_parent_insert BEFORE INSERT ON ord_order FOR EACH ROW
BEGIN
@@ -7353,6 +7355,10 @@ BEGIN
AND table_name='ord_order_fulfillment_split' AND index_name='uk_order_split_idempotency') THEN
ALTER TABLE ord_order_fulfillment_split DROP INDEX uk_order_split_idempotency;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='ord_order_fulfillment_split' AND index_name='uk_order_split_child') THEN
ALTER TABLE ord_order_fulfillment_split DROP INDEX uk_order_split_child;
END IF;
END$$
DELIMITER ;
CALL phase3_order_drop_split_index();
@@ -7361,6 +7367,36 @@ CALL phase3_order_add_index('ord_order_fulfillment_split','uk_order_split_operat
'UNIQUE KEY `uk_order_split_operation` (`tenant_id`,`operation_id`,`split_role`)');
CALL phase3_order_add_index('ord_order_fulfillment_split','uk_order_split_idempotency',
'UNIQUE KEY `uk_order_split_idempotency` (`tenant_id`,`parent_order_id`,`idempotency_key`,`split_role`)');
CALL phase3_order_add_index('ord_order_fulfillment_split','uk_order_split_child_role',
'UNIQUE KEY `uk_order_split_child_role` (`tenant_id`,`child_order_id`,`split_role`)');
DELIMITER $$
CREATE TRIGGER trg_order_fulfillment_split_insert
BEFORE INSERT ON ord_order_fulfillment_split FOR EACH ROW
BEGIN
IF NEW.split_role NOT IN ('LOADED','REMAINDER') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订单履约分片角色非法';
END IF;
IF NEW.split_role='LOADED' AND NEW.child_order_id<>NEW.source_order_id THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='实装台账必须由来源订单自身承接';
END IF;
IF NEW.split_role='REMAINDER'
AND (NEW.child_order_id=NEW.source_order_id OR NEW.remaining_package_count=0) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='余量台账必须指向有剩余数量的新子订单';
END IF;
END$$
CREATE TRIGGER trg_order_fulfillment_split_update
BEFORE UPDATE ON ord_order_fulfillment_split FOR EACH ROW
BEGIN
IF NOT (NEW.tenant_id<=>OLD.tenant_id)
OR NOT (NEW.parent_order_id<=>OLD.parent_order_id)
OR NOT (NEW.source_order_id<=>OLD.source_order_id)
OR NOT (NEW.child_order_id<=>OLD.child_order_id)
OR NOT (NEW.split_role<=>OLD.split_role) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订单履约分片来源、子片与角色不可修改';
END IF;
END$$
DELIMITER ;
DROP PROCEDURE IF EXISTS phase3_order_add_column;
DROP PROCEDURE IF EXISTS phase3_order_add_index;
@@ -7384,9 +7420,20 @@ BEGIN
IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics
WHERE table_schema=DATABASE() AND table_name='ord_order_fulfillment_split'
AND index_name IN ('idx_order_split_source','uk_order_split_operation',
'uk_order_split_idempotency'))<>3 THEN
'uk_order_split_idempotency','uk_order_split_child_role'))<>4 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订单拆分来源或幂等索引未完整创建';
END IF;
IF EXISTS (SELECT 1 FROM information_schema.statistics
WHERE table_schema=DATABASE() AND table_name='ord_order_fulfillment_split'
AND index_name='uk_order_split_child') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='旧订单拆分子单唯一索引仍阻止跨角色分片';
END IF;
IF (SELECT COUNT(*) FROM information_schema.triggers
WHERE trigger_schema=DATABASE()
AND trigger_name IN ('trg_order_fulfillment_split_insert',
'trg_order_fulfillment_split_update'))<>2 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订单履约分片写入保护触发器未完整创建';
END IF;
END$$
DELIMITER ;
CALL phase3_order_assert();
+31
View File
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.baiyue.firstleg</groupId>
<artifactId>baiyue-firstleg-platform</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>finance-service</artifactId>
<dependencies>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-web</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-openapi</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-security</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-audit</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-spring-boot3-starter</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId></dependency>
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency>
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId></dependency>
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
</dependencies>
<build>
<plugins>
<plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,13 @@
package com.baiyue.finance;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/** Finance 独立服务启动入口。首期只承载人工账单、收款和成本台账。 */
@SpringBootApplication
public class BaiyueFinanceApplication {
public static void main(String[] args) {
SpringApplication.run(BaiyueFinanceApplication.class, args);
}
}
@@ -0,0 +1,64 @@
package com.baiyue.finance.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.finance.service.FinanceService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@Tag(name = "人工财务台账")
@RestController
@RequestMapping("/api/platform/v1/finance")
public class PlatformFinanceController {
private final FinanceService service;
public PlatformFinanceController(FinanceService service) { this.service = service; }
@GetMapping("/customers/{tenantId}/terms")
@PreAuthorize("hasAuthority('finance:customer-terms:view')")
@Operation(summary = "查询客户账期")
public ApiResponse<Map<String, Object>> terms(@PathVariable long tenantId) { return ApiResponse.success(service.customerTerms(tenantId)); }
@PutMapping("/customers/{tenantId}/terms")
@PreAuthorize("hasAuthority('finance:customer-terms:update')")
@BusinessAudit(event = "FINANCE_CUSTOMER_TERMS_UPDATE", resourceType = "CUSTOMER")
public ApiResponse<Map<String, Object>> saveTerms(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId, @RequestHeader("Idempotency-Key") String key, @RequestBody TermsRequest request) {
return ApiResponse.success(service.saveCustomerTerms(tenantId, user.userId(), request.customerName(), request.creditDays(), key));
}
@PostMapping("/customers/{tenantId}/bills")
@PreAuthorize("hasAuthority('finance:bill:create')")
@BusinessAudit(event = "FINANCE_BILL_CREATE", resourceType = "FINANCE_BILL")
public ApiResponse<Map<String, Object>> createBill(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody FinanceService.BillRequest request) {
return ApiResponse.success(service.createBill(tenantId, user.userId(), request == null ? "客户" + tenantId : request.billNo(), request, key));
}
@PostMapping("/customers/{tenantId}/bills/{billId}/issue")
@PreAuthorize("hasAuthority('finance:bill:issue')")
@BusinessAudit(event = "FINANCE_BILL_ISSUE", resourceType = "FINANCE_BILL")
public ApiResponse<Map<String, Object>> issueBill(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key) {
return ApiResponse.success(service.issueBill(tenantId, user.userId(), billId, key));
}
@PostMapping("/customers/{tenantId}/receipts")
@PreAuthorize("hasAuthority('finance:receipt:create')")
@BusinessAudit(event = "FINANCE_RECEIPT_CREATE", resourceType = "FINANCE_RECEIPT")
public ApiResponse<Map<String, Object>> receipt(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody FinanceService.ReceiptRequest request) {
return ApiResponse.success(service.createReceipt(tenantId, user.userId(), request, key));
}
@PostMapping("/customers/{tenantId}/costs")
@PreAuthorize("hasAuthority('finance:cost:create')")
@BusinessAudit(event = "FINANCE_COST_CREATE", resourceType = "FINANCE_COST")
public ApiResponse<Map<String, Object>> cost(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody FinanceService.CostRequest request) {
return ApiResponse.success(service.createCost(tenantId, user.userId(), request, key));
}
public record TermsRequest(String customerName, Integer creditDays) {}
}
@@ -0,0 +1,137 @@
package com.baiyue.finance.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/** 首期人工财务台账:账单、收款和成本,不承载钱包、发票或复杂核销。 */
@Service
public class FinanceService {
private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
public FinanceService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
}
public Map<String, Object> customerTerms(long tenantId) {
requireCustomer(tenantId);
List<Map<String, Object>> rows = jdbc.queryForList(
"SELECT tenant_id, customer_name_snapshot, credit_days, status FROM fin_customer_settlement_profile WHERE tenant_id=?",
tenantId);
return rows.isEmpty() ? Map.of("tenantId", tenantId, "creditDays", 30, "status", "ACTIVE") : rows.get(0);
}
@Transactional
public Map<String, Object> saveCustomerTerms(long tenantId, long operatorId, String customerName,
Integer creditDays, String idempotencyKey) {
requireCustomer(tenantId);
reserve(tenantId, operatorId, "CUSTOMER_TERMS_SAVE", idempotencyKey);
int days = creditDays == null ? 30 : creditDays;
if (days < 0 || days > 3650) throw bad("账期必须在0到3650天之间");
jdbc.update("INSERT INTO fin_customer_settlement_profile(tenant_id,customer_name_snapshot,credit_days,created_by,updated_by) VALUES(?,?,?,?,?) "
+ "ON DUPLICATE KEY UPDATE customer_name_snapshot=VALUES(customer_name_snapshot), credit_days=VALUES(credit_days), updated_by=VALUES(updated_by), version=version+1",
tenantId, StringUtils.hasText(customerName) ? customerName : "客户" + tenantId, days, operatorId, operatorId);
return customerTerms(tenantId);
}
@Transactional
public Map<String, Object> createBill(long tenantId, long operatorId, String customerName, BillRequest request,
String idempotencyKey) {
requireCustomer(tenantId); reserve(tenantId, operatorId, "BILL_CREATE", idempotencyKey);
if (request.lines() == null || request.lines().isEmpty()) throw bad("账单至少需要一条费用明细");
BigDecimal total = request.lines().stream().map(BillLine::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
if (total.signum() < 0) throw bad("账单金额不能为负数");
int days = ((Number) customerTerms(tenantId).get("credit_days")).intValue();
LocalDate billDate = request.billDate() == null ? LocalDate.now() : request.billDate();
KeyHolder holder = new GeneratedKeyHolder();
jdbc.update(connection -> {
PreparedStatement ps = connection.prepareStatement("INSERT INTO fin_bill(tenant_id,bill_no,subject_id,customer_name_snapshot,currency_code,bill_date,due_date,total_amount,outstanding_amount,remark,created_by,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", Statement.RETURN_GENERATED_KEYS);
ps.setLong(1, tenantId); ps.setString(2, request.billNo()); ps.setLong(3, request.subjectId()); ps.setString(4, customerName);
ps.setString(5, "CNY"); ps.setObject(6, billDate); ps.setObject(7, billDate.plusDays(days)); ps.setBigDecimal(8, total); ps.setBigDecimal(9, total);
ps.setString(10, request.remark()); ps.setLong(11, operatorId); ps.setLong(12, operatorId); return ps;
}, holder);
long billId = holder.getKey().longValue();
for (BillLine line : request.lines()) {
jdbc.update("INSERT INTO fin_bill_line(tenant_id,bill_id,source_type,source_id,source_version,source_no,order_id,inbound_id,fee_item_code,fee_item_name,amount) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
tenantId, billId, line.sourceType(), line.sourceId(), line.sourceVersion(), line.sourceNo(), line.orderId(), line.inboundId(), line.feeItemCode(), line.feeItemName(), line.amount());
}
return jdbc.queryForMap("SELECT * FROM fin_bill WHERE id=?", billId);
}
@Transactional
public Map<String, Object> issueBill(long tenantId, long operatorId, long billId, String idempotencyKey) {
requireCustomer(tenantId); reserve(tenantId, operatorId, "BILL_ISSUE", idempotencyKey);
int updated = jdbc.update("UPDATE fin_bill SET status='ISSUED', issued_at=?, issued_by=?, updated_by=?, version=version+1 WHERE id=? AND tenant_id=? AND status='DRAFT'",
LocalDateTime.now(), operatorId, operatorId, billId, tenantId);
if (updated != 1) throw bad("账单不存在或已出账");
return jdbc.queryForMap("SELECT * FROM fin_bill WHERE id=? AND tenant_id=?", billId, tenantId);
}
@Transactional
public Map<String, Object> createReceipt(long tenantId, long operatorId, ReceiptRequest request, String idempotencyKey) {
requireCustomer(tenantId); reserve(tenantId, operatorId, "RECEIPT_CREATE", idempotencyKey);
if (request.allocations() == null || request.allocations().isEmpty()) throw bad("收款至少需要分配一张账单");
BigDecimal total = request.allocations().stream().map(ReceiptAllocation::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
if (total.signum() <= 0) throw bad("收款金额必须大于0");
for (ReceiptAllocation allocation : request.allocations()) {
Map<String, Object> bill = jdbc.queryForMap("SELECT outstanding_amount,status FROM fin_bill WHERE id=? AND tenant_id=? FOR UPDATE", allocation.billId(), tenantId);
BigDecimal outstanding = new BigDecimal(bill.get("outstanding_amount").toString());
if ("VOIDED".equals(bill.get("status")) || allocation.amount().compareTo(outstanding) > 0) throw bad("收款金额超过账单未收余额");
}
KeyHolder holder = new GeneratedKeyHolder();
jdbc.update(connection -> {
PreparedStatement ps = connection.prepareStatement("INSERT INTO fin_receipt(tenant_id,receipt_no,subject_id,received_amount,received_at,payment_method,voucher_no,voucher_file_ids,remark,created_by,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?)", Statement.RETURN_GENERATED_KEYS);
ps.setLong(1, tenantId); ps.setString(2, request.receiptNo()); ps.setLong(3, request.subjectId()); ps.setBigDecimal(4, total); ps.setObject(5, request.receivedAt() == null ? LocalDateTime.now() : request.receivedAt()); ps.setString(6, request.paymentMethod()); ps.setString(7, request.voucherNo()); ps.setString(8, json(request.voucherFileIds())); ps.setString(9, request.remark()); ps.setLong(10, operatorId); ps.setLong(11, operatorId); return ps;
}, holder);
long receiptId = holder.getKey().longValue();
for (ReceiptAllocation allocation : request.allocations()) {
jdbc.update("INSERT INTO fin_receipt_bill(tenant_id,receipt_id,bill_id,applied_amount,created_by) VALUES(?,?,?,?,?)", tenantId, receiptId, allocation.billId(), allocation.amount(), operatorId);
jdbc.update("UPDATE fin_bill SET received_amount=received_amount+?, outstanding_amount=outstanding_amount-?, status=CASE WHEN outstanding_amount-?=0 THEN 'PAID' ELSE 'PARTIALLY_PAID' END, updated_by=?, version=version+1 WHERE id=? AND tenant_id=?", allocation.amount(), allocation.amount(), allocation.amount(), operatorId, allocation.billId(), tenantId);
}
return jdbc.queryForMap("SELECT * FROM fin_receipt WHERE id=?", receiptId);
}
@Transactional
public Map<String, Object> createCost(long tenantId, long operatorId, CostRequest request, String idempotencyKey) {
requireCustomer(tenantId); reserve(tenantId, operatorId, "COST_CREATE", idempotencyKey);
if (!"TICKET".equals(request.costType()) && !"CONTAINER".equals(request.costType())) throw bad("成本类型只能是TICKET或CONTAINER");
if (request.amount() == null || request.amount().signum() <= 0) throw bad("成本金额必须大于0");
jdbc.update("INSERT INTO fin_cost(tenant_id,cost_no,cost_type,order_id,inbound_id,container_id,supplier_id,supplier_name_snapshot,fee_item_code,fee_item_name,cost_amount,voucher_file_ids,remark,created_by,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
tenantId, request.costNo(), request.costType(), request.orderId(), request.inboundId(), request.containerId(), request.supplierId(), request.supplierName(), request.feeItemCode(), request.feeItemName(), request.amount(), json(request.voucherFileIds()), request.remark(), operatorId, operatorId);
return jdbc.queryForMap("SELECT * FROM fin_cost WHERE tenant_id=? AND cost_no=?", tenantId, request.costNo());
}
private void reserve(long tenantId, long operatorId, String operation, String key) {
if (!StringUtils.hasText(key) || key.length() > 128) throw bad("必须提供有效的 Idempotency-Key");
Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_idempotency_record WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=?", Integer.class, tenantId, operatorId, operation, key);
if (count != null && count > 0) throw new BizException(ResultCode.CONFLICT, "相同请求已提交");
jdbc.update("INSERT INTO fin_idempotency_record(tenant_id,operator_id,operation,idempotency_key,request_hash,status) VALUES(?,?,?,?,SHA2(?,256),'SUCCEEDED')", tenantId, operatorId, operation, key, key);
}
private static void requireCustomer(long tenantId) { if (tenantId <= 0) throw bad("客户范围无效"); }
private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
private String json(Object value) { try { return objectMapper.writeValueAsString(value == null ? List.of() : value); } catch (JsonProcessingException e) { throw new IllegalArgumentException("凭证字段格式错误", e); } }
public record BillRequest(String billNo, long subjectId, LocalDate billDate, String remark, List<BillLine> lines) {}
public record BillLine(String sourceType, long sourceId, int sourceVersion, String sourceNo, Long orderId, Long inboundId, String feeItemCode, String feeItemName, BigDecimal amount) {}
public record ReceiptRequest(String receiptNo, long subjectId, LocalDateTime receivedAt, String paymentMethod, String voucherNo, List<String> voucherFileIds, String remark, List<ReceiptAllocation> allocations) {}
public record ReceiptAllocation(long billId, BigDecimal amount) {}
public record CostRequest(String costNo, String costType, Long orderId, Long inboundId, Long containerId, Long supplierId, String supplierName, String feeItemCode, String feeItemName, BigDecimal amount, List<String> voucherFileIds, String remark) {}
}
@@ -0,0 +1,6 @@
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${FINANCE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_finance?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${FINANCE_DB_USERNAME:root}
password: ${FINANCE_DB_PASSWORD:123456}
@@ -0,0 +1,32 @@
server:
port: ${FINANCE_PORT:9203}
spring:
profiles:
active: ${SPRING_PROFILES_ACTIVE:dev}
application:
name: finance-service
config:
import:
- optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true
cloud:
nacos:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PASSWORD:nacos}
discovery:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
enabled: ${NACOS_DISCOVERY_ENABLED:true}
register-enabled: ${NACOS_REGISTER_ENABLED:true}
group: ${NACOS_GROUP:BAIYUE}
namespace: ${NACOS_NAMESPACE:public}
config:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
namespace: ${NACOS_NAMESPACE:public}
management.endpoints.web.exposure.include: health,info,prometheus
logging:
file:
name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}"
+227
View File
@@ -0,0 +1,227 @@
CREATE DATABASE IF NOT EXISTS baiyue_finance
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE baiyue_finance;
CREATE TABLE IF NOT EXISTS fin_subject (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
subject_code VARCHAR(64) NOT NULL,
subject_name VARCHAR(200) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
version INT UNSIGNED NOT NULL DEFAULT 0,
created_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_fin_subject_code (subject_code)
) ENGINE=InnoDB COMMENT='平台财务主体;首期仅一个有效主体';
CREATE TABLE IF NOT EXISTS fin_customer_settlement_profile (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
customer_name_snapshot VARCHAR(200) NOT NULL,
credit_days INT UNSIGNED NOT NULL DEFAULT 30 COMMENT '默认30个自然日',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
version INT UNSIGNED NOT NULL DEFAULT 0,
created_by BIGINT UNSIGNED NOT NULL,
updated_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_fin_customer_profile (tenant_id),
CONSTRAINT chk_fin_customer_profile_tenant CHECK (tenant_id > 0),
CONSTRAINT chk_fin_customer_profile_days CHECK (credit_days <= 3650)
) ENGINE=InnoDB COMMENT='客户结算配置;一客户一个结算对象';
CREATE TABLE IF NOT EXISTS fin_bill (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
bill_no VARCHAR(64) NOT NULL,
subject_id BIGINT UNSIGNED NOT NULL,
customer_name_snapshot VARCHAR(200) NOT NULL,
currency_code CHAR(3) NOT NULL DEFAULT 'CNY',
bill_date DATE NOT NULL,
due_date DATE NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT/ISSUED/PARTIALLY_PAID/PAID/VOIDED',
total_amount DECIMAL(20,6) NOT NULL DEFAULT 0,
received_amount DECIMAL(20,6) NOT NULL DEFAULT 0,
outstanding_amount DECIMAL(20,6) NOT NULL DEFAULT 0,
remark VARCHAR(500) NULL,
issued_at DATETIME(3) NULL,
issued_by BIGINT UNSIGNED NULL,
voided_at DATETIME(3) NULL,
voided_by BIGINT UNSIGNED NULL,
void_reason VARCHAR(500) NULL,
version INT UNSIGNED NOT NULL DEFAULT 0,
created_by BIGINT UNSIGNED NOT NULL,
updated_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_fin_bill_no (tenant_id, bill_no),
KEY idx_fin_bill_customer_status_date (tenant_id, status, bill_date),
CONSTRAINT chk_fin_bill_tenant CHECK (tenant_id > 0),
CONSTRAINT chk_fin_bill_currency CHECK (currency_code = 'CNY'),
CONSTRAINT chk_fin_bill_amounts CHECK (total_amount >= 0 AND received_amount >= 0 AND outstanding_amount >= 0)
) ENGINE=InnoDB COMMENT='客户CNY账单;首期账单即应收台账';
CREATE TABLE IF NOT EXISTS fin_bill_line (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
bill_id BIGINT UNSIGNED NOT NULL,
source_type VARCHAR(32) NOT NULL DEFAULT 'FIRSTLEG_FEE',
source_id BIGINT UNSIGNED NOT NULL,
source_version INT UNSIGNED NOT NULL,
source_no VARCHAR(64) NULL,
order_id BIGINT UNSIGNED NULL,
inbound_id BIGINT UNSIGNED NULL,
fee_item_code VARCHAR(64) NOT NULL,
fee_item_name VARCHAR(200) NOT NULL,
amount DECIMAL(20,6) NOT NULL,
currency_code CHAR(3) NOT NULL DEFAULT 'CNY',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_fin_bill_line_source (source_type, source_id, source_version),
KEY idx_fin_bill_line_bill (tenant_id, bill_id),
CONSTRAINT chk_fin_bill_line_tenant CHECK (tenant_id > 0),
CONSTRAINT chk_fin_bill_line_currency CHECK (currency_code = 'CNY'),
CONSTRAINT chk_fin_bill_line_amount CHECK (amount >= 0)
) ENGINE=InnoDB COMMENT='账单费用来源快照明细';
CREATE TABLE IF NOT EXISTS fin_receipt (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
receipt_no VARCHAR(64) NOT NULL,
subject_id BIGINT UNSIGNED NOT NULL,
currency_code CHAR(3) NOT NULL DEFAULT 'CNY',
received_amount DECIMAL(20,6) NOT NULL,
received_at DATETIME(3) NOT NULL,
payment_method VARCHAR(32) NULL,
voucher_no VARCHAR(128) NULL,
voucher_file_ids JSON NOT NULL COMMENT 'File服务文件ID字符串数组',
status VARCHAR(16) NOT NULL DEFAULT 'CONFIRMED' COMMENT 'CONFIRMED/VOIDED',
remark VARCHAR(500) NULL,
voided_at DATETIME(3) NULL,
voided_by BIGINT UNSIGNED NULL,
void_reason VARCHAR(500) NULL,
version INT UNSIGNED NOT NULL DEFAULT 0,
created_by BIGINT UNSIGNED NOT NULL,
updated_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_fin_receipt_no (tenant_id, receipt_no),
KEY idx_fin_receipt_customer_time (tenant_id, received_at),
CONSTRAINT chk_fin_receipt_tenant CHECK (tenant_id > 0),
CONSTRAINT chk_fin_receipt_currency CHECK (currency_code = 'CNY'),
CONSTRAINT chk_fin_receipt_amount CHECK (received_amount > 0)
) ENGINE=InnoDB COMMENT='平台财务人工登记收款及凭证';
CREATE TABLE IF NOT EXISTS fin_receipt_bill (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
receipt_id BIGINT UNSIGNED NOT NULL,
bill_id BIGINT UNSIGNED NOT NULL,
applied_amount DECIMAL(20,6) NOT NULL,
created_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_fin_receipt_bill_pair (receipt_id, bill_id),
KEY idx_fin_receipt_bill_bill (tenant_id, bill_id),
CONSTRAINT chk_fin_receipt_bill_tenant CHECK (tenant_id > 0),
CONSTRAINT chk_fin_receipt_bill_amount CHECK (applied_amount > 0)
) ENGINE=InnoDB COMMENT='人工收款对应账单金额;UI不使用复杂核销术语';
CREATE TABLE IF NOT EXISTS fin_cost (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
cost_no VARCHAR(64) NOT NULL,
cost_type VARCHAR(24) NOT NULL COMMENT 'TICKET/CONTAINER',
order_id BIGINT UNSIGNED NULL,
inbound_id BIGINT UNSIGNED NULL,
container_id BIGINT UNSIGNED NULL,
supplier_id BIGINT UNSIGNED NULL,
supplier_name_snapshot VARCHAR(200) NULL,
fee_item_code VARCHAR(64) NOT NULL,
fee_item_name VARCHAR(200) NOT NULL,
currency_code CHAR(3) NOT NULL DEFAULT 'CNY',
cost_amount DECIMAL(20,6) NOT NULL,
voucher_file_ids JSON NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT/CONFIRMED/VOIDED',
remark VARCHAR(500) NULL,
confirmed_at DATETIME(3) NULL,
confirmed_by BIGINT UNSIGNED NULL,
voided_at DATETIME(3) NULL,
voided_by BIGINT UNSIGNED NULL,
void_reason VARCHAR(500) NULL,
version INT UNSIGNED NOT NULL DEFAULT 0,
created_by BIGINT UNSIGNED NOT NULL,
updated_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_fin_cost_no (tenant_id, cost_no),
KEY idx_fin_cost_ticket (tenant_id, order_id, inbound_id),
KEY idx_fin_cost_container (tenant_id, container_id),
CONSTRAINT chk_fin_cost_tenant CHECK (tenant_id > 0),
CONSTRAINT chk_fin_cost_currency CHECK (currency_code = 'CNY'),
CONSTRAINT chk_fin_cost_amount CHECK (cost_amount > 0),
CONSTRAINT chk_fin_cost_target CHECK (
(cost_type = 'TICKET' AND order_id IS NOT NULL AND container_id IS NULL)
OR (cost_type = 'CONTAINER' AND container_id IS NOT NULL)
)
) ENGINE=InnoDB COMMENT='人工成本;单票直接归属,整柜按体积分摊';
CREATE TABLE IF NOT EXISTS fin_cost_allocation (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
cost_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NOT NULL,
inbound_id BIGINT UNSIGNED NULL,
volume_cbm DECIMAL(20,6) NOT NULL,
total_volume_cbm DECIMAL(20,6) NOT NULL,
allocation_rate DECIMAL(20,10) NOT NULL,
allocated_amount DECIMAL(20,6) NOT NULL,
rounding_adjustment TINYINT NOT NULL DEFAULT 0 COMMENT '1表示尾差归集行',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_fin_cost_allocation_order (cost_id, order_id),
KEY idx_fin_cost_allocation_query (tenant_id, order_id),
CONSTRAINT chk_fin_cost_allocation_volume CHECK (volume_cbm >= 0 AND total_volume_cbm > 0),
CONSTRAINT chk_fin_cost_allocation_amount CHECK (allocated_amount >= 0)
) ENGINE=InnoDB COMMENT='整柜成本按有效体积分摊的可审计结果';
CREATE TABLE IF NOT EXISTS fin_idempotency_record (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operation VARCHAR(64) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL COMMENT 'PROCESSING/SUCCEEDED/FAILED',
result_resource_id BIGINT UNSIGNED NULL,
error_code VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_fin_idempotency (tenant_id, operator_id, operation, idempotency_key)
) ENGINE=InnoDB COMMENT='Finance人工写操作幂等记录';
CREATE TABLE IF NOT EXISTS fin_audit_log (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
resource_type VARCHAR(32) NOT NULL,
resource_id BIGINT UNSIGNED NOT NULL,
action VARCHAR(64) NOT NULL,
reason VARCHAR(500) NULL,
before_data JSON NULL,
after_data JSON NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operator_name_snapshot VARCHAR(128) NOT NULL,
request_id VARCHAR(128) NULL,
trace_id VARCHAR(128) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_fin_audit_resource (tenant_id, resource_type, resource_id, created_at),
KEY idx_fin_audit_operator (operator_id, created_at)
) ENGINE=InnoDB COMMENT='Finance不可变业务审计明细';
@@ -16,11 +16,12 @@ public interface LoadingInboundMapper {
batch.loaded_child_inbound_id AS loadedChildInboundId,
batch.parent_inbound_id AS rootInboundId,
source.process_status AS sourceStatus, source.version AS sourceVersion,
child.process_status AS loadedChildStatus, child.version AS loadedChildVersion
CASE WHEN batch.loaded_child_inbound_id IS NULL THEN source.process_status ELSE child.process_status END AS loadedChildStatus,
CASE WHEN batch.loaded_child_inbound_id IS NULL THEN source.version ELSE child.version END AS loadedChildVersion
FROM fl_inbound_split_batch batch
JOIN fl_inbound_order source ON source.id=batch.source_inbound_id
AND source.tenant_id=batch.tenant_id AND source.deleted=0
JOIN fl_inbound_order child ON child.id=batch.loaded_child_inbound_id
LEFT JOIN fl_inbound_order child ON child.id=batch.loaded_child_inbound_id
AND child.tenant_id=batch.tenant_id AND child.deleted=0
WHERE batch.tenant_id=#{tenantId} AND batch.status='APPLIED'
AND (batch.source_inbound_id=#{inboundId} OR batch.loaded_child_inbound_id=#{inboundId})
@@ -35,15 +36,14 @@ public interface LoadingInboundMapper {
@Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion);
@Update("""
UPDATE fl_inbound_order child
JOIN fl_inbound_split_batch batch ON batch.loaded_child_inbound_id=child.id
AND batch.tenant_id=child.tenant_id AND batch.status='APPLIED'
JOIN fl_inbound_order source ON source.id=batch.source_inbound_id
AND source.tenant_id=batch.tenant_id AND source.deleted=0
SET child.process_status='LOADED', child.updated_by=#{operatorId}, child.version=child.version+1
WHERE child.tenant_id=#{tenantId} AND batch.source_inbound_id=#{sourceInboundId}
AND child.deleted=0 AND child.process_status='LOADING'
AND source.version=#{expectedSourceVersion}
UPDATE fl_inbound_split_batch batch
JOIN fl_inbound_order target
ON target.id=COALESCE(batch.loaded_child_inbound_id,batch.source_inbound_id)
AND target.tenant_id=batch.tenant_id AND target.deleted=0
SET target.process_status='LOADED', target.updated_by=#{operatorId}, target.version=target.version+1
WHERE batch.tenant_id=#{tenantId} AND batch.source_inbound_id=#{sourceInboundId}
AND batch.status='APPLIED' AND target.process_status='LOADING'
AND target.version=#{expectedSourceVersion}
""")
int completeLoadedChild(@Param("tenantId") long tenantId,
@Param("sourceInboundId") long sourceInboundId,
@@ -73,5 +73,9 @@ public interface LoadingInboundMapper {
record Snapshot(Long id, Long tenantId, String processStatus, Integer version) {}
record CompletionTarget(Long sourceInboundId, Long loadedChildInboundId, Long rootInboundId,
String sourceStatus, Integer sourceVersion,
String loadedChildStatus, Integer loadedChildVersion) {}
String loadedChildStatus, Integer loadedChildVersion) {
public Long completionInboundId() {
return loadedChildInboundId == null ? sourceInboundId : loadedChildInboundId;
}
}
}
@@ -138,7 +138,7 @@ public class LoadingDeliveryService {
if (repository.completeLoadedChild(tenantId, target.sourceInboundId(), operatorId,
request.expectedVersion()) != 1) throw concurrent();
repository.aggregateRootLoaded(tenantId, target.rootInboundId(), operatorId);
addHistory(tenantId, target.loadedChildInboundId(), "LOADING", "LOADED", COMPLETE_LOADING,
addHistory(tenantId, target.completionInboundId(), "LOADING", "LOADED", COMPLETE_LOADING,
request.remark(), operatorId, operatorName);
return complete(tenantId, evidenceInboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse("LOADED", evidenceResponse(tenantId, evidenceInboundId)));
@@ -54,7 +54,7 @@ public interface InboundSplitSettlementMapper {
#{packageCount},#{packageCount},actual_gross_weight_kg,chargeable_weight_kg,actual_volume_cbm,chargeable_volume_cbm,
pallet_count,volume_weight_kg,actual_net_weight_kg,measure_completed_at,inbounded_at,express_company_code,
express_company_name,express_tracking_no,public_remark,#{operatorId},#{operatorName},CURRENT_TIMESTAMP(3),
#{operatorId},CURRENT_TIMESTAMP(3),0,0,'CHILD',#{parentInboundId},#{rootInboundId},#{splitSequence},'NOT_SPLIT',#{splitPurpose}
#{operatorId},CURRENT_TIMESTAMP(3),0,0,'CHILD',#{rootInboundId},#{rootInboundId},#{splitSequence},'NOT_SPLIT',#{splitPurpose}
FROM fl_inbound_order WHERE id=#{parentInboundId} AND tenant_id=#{accountId} AND deleted=0
""")
int cloneChildWithPurpose(@Param("accountId") long accountId, @Param("parentInboundId") long parentInboundId,
@@ -70,7 +70,7 @@ public interface InboundSplitSettlementMapper {
loaded_package_count,remaining_package_count,loading_plan_id,container_booking_id,status,remark,
created_by,created_by_name,created_at,version)
VALUES (#{batchId},#{accountId},#{parentInboundId},#{sourceInboundId},#{operationId},#{idempotencyKey},#{expectedParentVersion},
#{packageCount},#{loadedChildId},#{remainderChildId},#{loadedPackageCount},#{remainingPackageCount},
#{packageCount},NULL,#{remainderChildId},#{loadedPackageCount},#{remainingPackageCount},
#{loadingPlanId},#{containerBookingId},'PREPARED',#{remark},#{operatorId},#{operatorName},CURRENT_TIMESTAMP(3),0)
""")
int insertBatchWithChildren(@Param("batchId") long batchId, @Param("accountId") long accountId,
@@ -157,17 +157,41 @@ public interface InboundSplitSettlementMapper {
@Param("packageCount") int packageCount, @Param("operatorId") long operatorId,
@Param("expectedVersion") int expectedVersion);
@Update("UPDATE fl_inbound_order SET package_count=0,split_status='FULLY_SPLIT',updated_by=#{operatorId},version=version+1 "
+ "WHERE id=#{parentInboundId} AND tenant_id=#{accountId} AND inbound_role='MASTER' AND deleted=0 "
+ "AND process_status IN ('INBOUNDED','LOADING') AND version=#{expectedVersion}")
int markRootSplit(@Param("accountId") long accountId, @Param("parentInboundId") long parentInboundId,
@Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion);
@Update("UPDATE fl_inbound_order SET package_count=#{loadedPackageCount},process_status='LOADING',"
+ "split_status='FULLY_SPLIT',updated_by=#{operatorId},version=version+1 "
+ "WHERE id=#{sourceInboundId} AND tenant_id=#{accountId} AND deleted=0 "
+ "AND process_status IN ('INBOUNDED','LOADING') AND split_status='NOT_SPLIT' "
+ "AND COALESCE(package_count,total_package_count)>=#{loadedPackageCount} AND version=#{expectedVersion}")
int markSourceLoaded(@Param("accountId") long accountId, @Param("sourceInboundId") long sourceInboundId,
@Param("loadedPackageCount") int loadedPackageCount,
@Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion);
@Update("UPDATE fl_inbound_order SET split_status='FULLY_SPLIT',updated_by=#{operatorId},version=version+1 "
+ "WHERE id=#{sourceInboundId} AND tenant_id=#{accountId} AND inbound_role='CHILD' "
+ "AND split_purpose='REMAINDER' AND split_status='NOT_SPLIT' AND deleted=0 AND version=#{expectedVersion}")
int markRemainderConsumed(@Param("accountId") long accountId, @Param("sourceInboundId") long sourceInboundId,
@Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion);
@Update("UPDATE fl_inbound_order SET updated_by=#{operatorId},version=version+1 "
+ "WHERE id=#{rootInboundId} AND tenant_id=#{accountId} AND inbound_role='MASTER' AND deleted=0 "
+ "AND version=#{expectedVersion}")
int touchRootSplit(@Param("accountId") long accountId, @Param("rootInboundId") long rootInboundId,
@Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion);
@Update("""
UPDATE fl_inbound_measurement_item
SET package_count=#{packageCount},
total_weight_kg=CASE WHEN unit_weight_kg IS NULL THEN NULL ELSE unit_weight_kg*#{packageCount} END,
total_volume_cbm=unit_volume_cbm*#{packageCount},
volume_weight_kg=volume_weight_kg/NULLIF(package_count,0)*#{packageCount},
updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{measurementItemId} AND tenant_id=#{accountId}
""")
int updateMeasurementItemForLoaded(@Param("accountId") long accountId,
@Param("measurementItemId") long measurementItemId,
@Param("packageCount") int packageCount);
@Update("UPDATE fl_inbound_measurement SET total_package_count=#{packageCount},updated_at=CURRENT_TIMESTAMP(3),"
+ "version=version+1 WHERE id=#{measurementId} AND tenant_id=#{accountId} AND inbound_id=#{inboundId} "
+ "AND status='APPROVED'")
int updateMeasurementForLoaded(@Param("accountId") long accountId,
@Param("inboundId") long inboundId,
@Param("measurementId") long measurementId,
@Param("packageCount") int packageCount);
@Update("""
UPDATE fl_inbound_split_batch
@@ -262,7 +286,7 @@ public interface InboundSplitSettlementMapper {
child.volume_weight_kg=(SELECT COALESCE(SUM(item.volume_weight_kg),0)
FROM fl_inbound_measurement_item item WHERE item.measurement_id=measurement.id),
child.actual_net_weight_kg=NULL,child.pallet_count=0,child.version=child.version+1
WHERE child.id=#{childInboundId} AND child.tenant_id=#{accountId} AND child.inbound_role='CHILD'
WHERE child.id=#{childInboundId} AND child.tenant_id=#{accountId} AND child.deleted=0
""")
int normalizeChildMetrics(@Param("accountId") long accountId, @Param("childInboundId") long childInboundId,
@Param("measurementId") long measurementId);
@@ -161,30 +161,23 @@ public class PdaPickSettlementService {
OrderFulfillmentSplitClient.Response orderSplit = createOrderSplit(parent, root, measurementLines,
pickedPackageCount, childPackageCount, loadingPlanId, containerBookingId, operationId,
"pda-task-" + taskId, operatorId, operatorName);
long loadedChildId = IdWorker.getId();
long remainderChildId = childPackageCount > 0 ? IdWorker.getId() : 0L;
long batchId = IdWorker.getId();
String idempotencyKey = "pda-task-" + taskId;
int parentVersion = root.version() == null ? 0 : root.version();
int rootVersion = root.version() == null ? 0 : root.version();
int sourceVersion = parent.version() == null ? 0 : parent.version();
int sequence = splits.nextSplitSequence(parent.tenantId(), rootInboundId);
String loadedChildCode = childInboundCode(root.inboundCode(), sequence);
String remainderChildCode = childPackageCount > 0
? childInboundCode(root.inboundCode(), sequence + 1) : null;
requireAffected(splits.cloneChildWithPurpose(parent.tenantId(), rootInboundId, rootInboundId, loadedChildId,
orderSplit.loadedChild().orderId(), orderSplit.loadedChild().orderNo(),
loadedChildCode, pickedPackageCount, sequence, "LOADED", operatorId, operatorName), "实装子入库单");
? childInboundCode(root.inboundCode(), sequence) : null;
if (remainderChildId != 0L) {
requireAffected(splits.cloneChildWithPurpose(parent.tenantId(), rootInboundId, rootInboundId, remainderChildId,
requireAffected(splits.cloneChildWithPurpose(parent.tenantId(), parentInboundId, rootInboundId, remainderChildId,
orderSplit.remainderChild().orderId(), orderSplit.remainderChild().orderNo(),
remainderChildCode, childPackageCount, sequence + 1, "REMAINDER", operatorId, operatorName), "余量子入库单");
remainderChildCode, childPackageCount, sequence, "REMAINDER", operatorId, operatorName), "余量子入库单");
}
requireAffected(splits.insertBatchWithChildren(batchId, parent.tenantId(), rootInboundId, operationId, parentInboundId,
idempotencyKey, parentVersion, parentAvailable, loadedChildId, remainderChildId == 0L ? null : remainderChildId,
idempotencyKey, rootVersion, parentAvailable, null, remainderChildId == 0L ? null : remainderChildId,
pickedPackageCount, childPackageCount, loadingPlanId, containerBookingId,
"PDA拣货后生成实装/余量子单", operatorId, operatorName), "拆分批次");
long loadedMeasurementId = IdWorker.getId();
requireAffected(splits.cloneMeasurement(parent.tenantId(), parentInboundId, sourceMeasurementId,
loadedChildId, loadedMeasurementId, batchId, pickedPackageCount, operatorId), "实装子入库测量批次");
long childMeasurementId = childPackageCount > 0 ? IdWorker.getId() : 0L;
if (childPackageCount > 0) {
requireAffected(splits.cloneMeasurement(parent.tenantId(), parentInboundId, sourceMeasurementId,
@@ -192,51 +185,46 @@ public class PdaPickSettlementService {
}
int lineNo = 1;
for (MeasurementSplitLine line : measurementLines) {
if (line.pickedPackageCount() <= 0) continue;
long splitItemId = IdWorker.getId();
requireAffected(splits.insertSplitItem(splitItemId, parent.tenantId(), batchId, rootInboundId,
loadedChildId, lineNo, line.item().inboundDetailId(), sourceMeasurementId,
line.item().measurementItemId(), line.pickedPackageCount()), "实装拆分明细");
requireAffected(splits.cloneMeasurementItem(parent.tenantId(), sourceMeasurementId,
line.item().measurementItemId(), loadedMeasurementId, splitItemId, IdWorker.getId(), lineNo,
line.pickedPackageCount()), "实装子入库测量明细");
lineNo++;
}
for (MeasurementSplitLine line : measurementLines) {
if (line.remainingPackageCount() <= 0) {
continue;
}
long splitItemId = IdWorker.getId();
requireAffected(splits.insertSplitItem(splitItemId, parent.tenantId(), batchId, rootInboundId,
remainderChildId == 0L ? loadedChildId : remainderChildId, lineNo, line.item().inboundDetailId(), sourceMeasurementId,
remainderChildId, lineNo, line.item().inboundDetailId(), sourceMeasurementId,
line.item().measurementItemId(), line.remainingPackageCount()), "拆分明细");
requireAffected(splits.cloneMeasurementItem(parent.tenantId(), sourceMeasurementId,
line.item().measurementItemId(), childMeasurementId, splitItemId, IdWorker.getId(), lineNo,
line.remainingPackageCount()), "子入库测量明细");
lineNo++;
}
requireAffected(splits.normalizeChildMetrics(parent.tenantId(), loadedChildId, loadedMeasurementId),
"实装子单重量体积归一化");
requireAffected(splits.markSourceLoaded(parent.tenantId(), parentInboundId, pickedPackageCount,
operatorId, sourceVersion), "来源分片状态");
if (parentInboundId != rootInboundId) {
requireAffected(splits.touchRootSplit(parent.tenantId(), rootInboundId, operatorId, rootVersion), "根单拆分状态");
}
requireAffected(splits.applyBatch(parent.tenantId(), batchId), "拆分批次生效");
for (MeasurementSplitLine line : measurementLines) {
requireAffected(splits.updateMeasurementItemForLoaded(parent.tenantId(),
line.item().measurementItemId(), line.pickedPackageCount()), "来源测量明细");
}
requireAffected(splits.updateMeasurementForLoaded(parent.tenantId(), parentInboundId, sourceMeasurementId,
pickedPackageCount), "来源测量批次");
requireAffected(splits.normalizeChildMetrics(parent.tenantId(), parentInboundId, sourceMeasurementId),
"来源单重量体积归一化");
if (remainderChildId != 0L) {
requireAffected(splits.normalizeChildMetrics(parent.tenantId(), remainderChildId, childMeasurementId),
"余量子单重量体积归一化");
}
requireAffected(splits.markRootSplit(parent.tenantId(), rootInboundId, operatorId, parentVersion), "主入库单拆分状态");
if ("CHILD".equals(parent.inboundRole())) {
requireAffected(splits.markRemainderConsumed(parent.tenantId(), parentInboundId, operatorId,
parent.version() == null ? 0 : parent.version()), "余量子单消费状态");
}
requireAffected(splits.applyBatch(parent.tenantId(), batchId), "拆分批次生效");
requireAffected(splits.insertHistory(IdWorker.getId(), parent.tenantId(), batchId,
operationId + "-APPLY", "{\"sourceInboundId\":" + parentInboundId
+ ",\"loadedChildInboundId\":" + loadedChildId
+ ",\"loadedChildInboundId\":null"
+ ",\"remainderChildInboundId\":" + (remainderChildId == 0L ? "null" : remainderChildId)
+ ",\"loadedPackageCount\":" + pickedPackageCount
+ ",\"remainingPackageCount\":" + childPackageCount
+ ",\"loadingPlanId\":" + loadingPlanId
+ ",\"containerBookingId\":" + containerBookingId + "}", operatorId, operatorName,
"PDA拣货自动生成实装/余量子入库单"), "拆分审计");
"PDA拣货后来源单承接实装并生成余量子入库单"), "拆分审计");
if (remainderChildId != 0L) {
allocations.copyRemainingToChild(parent.tenantId(), remainderChildId, confirmedAllocations);
@@ -245,7 +233,7 @@ public class PdaPickSettlementService {
releasedReservationIds);
allocations.markReleased(parent.tenantId(), parentInboundId, releasedReservationIds);
settleWarehouseResources(parentInboundId, remainderChildId,
remainderChildCode == null ? loadedChildCode : remainderChildCode,
remainderChildCode == null ? root.inboundCode() : remainderChildCode,
taskId, operatorId, operatorName,
warehouseId, parentAvailable, childPackageCount, releasedReservationIds, transferItems);
return true;
@@ -282,14 +270,17 @@ public class PdaPickSettlementService {
boolean remainderMatches = remainingPackageCount == 0 ? split.remainderChild() == null
: validOrderChild(split.remainderChild(), "REMAINDER", source.orderId(), loadingPlanId,
containerBookingId, remainingPackageCount);
boolean sourceLoadedMatches = split.loadedChild() == null
|| (Objects.equals(split.loadedChild().orderId(), source.orderId())
&& validOrderChild(split.loadedChild(), "LOADED", source.orderId(), loadingPlanId,
containerBookingId, loadedPackageCount));
if (!Objects.equals(split.parentOrderId(), root.orderId())
|| !Objects.equals(split.sourcePackageCount(), loadedPackageCount + remainingPackageCount)
|| !Objects.equals(split.loadedPackageCount(), loadedPackageCount)
|| !Objects.equals(split.remainingPackageCount(), remainingPackageCount)
|| !Objects.equals(split.loadingPlanId(), loadingPlanId)
|| !Objects.equals(split.containerBookingId(), containerBookingId)
|| !validOrderChild(split.loadedChild(), "LOADED", source.orderId(), loadingPlanId,
containerBookingId, loadedPackageCount)
|| !sourceLoadedMatches
|| !remainderMatches) {
throw conflict("订单服务返回的父子订单、柜次或数量不一致");
}
@@ -85,7 +85,7 @@ class LoadingDeliveryServiceTest {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "INBOUNDED", 3));
when(repository.completionTarget(10, 20)).thenReturn(new CompletionTarget(
20L, 21L, 20L, "INBOUNDED", 3, "LOADING", 1));
20L, null, 20L, "LOADING", 3, "LOADING", 3));
LoadingPickItemMapper.InboundProgress progress = mock(LoadingPickItemMapper.InboundProgress.class);
when(progress.getExpectedCount()).thenReturn(5);
when(progress.getPickedCount()).thenReturn(5);
@@ -114,10 +114,18 @@ class LoadingDeliveryServiceTest {
verify(repository).completeLoadedChild(10, 20, 7, 3);
verify(repository).aggregateRootLoaded(10, 20, 7);
verify(histories).insert(argThat((InboundStatusHistoryEntity history) ->
Long.valueOf(21L).equals(history.getInboundId())));
Long.valueOf(20L).equals(history.getInboundId())));
verify(repository, never()).completeLoading(anyLong(), anyLong(), anyLong(), anyInt());
}
@Test
void keepsHistoricalLoadedChildAsCompletionTarget() {
CompletionTarget historical = new CompletionTarget(
20L, 21L, 20L, "INBOUNDED", 3, "LOADING", 1);
assertThat(historical.completionInboundId()).isEqualTo(21L);
}
@Test
void rejectsCompletionWhenEvidenceIsIncomplete() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
@@ -62,11 +62,9 @@ class PdaPickSettlementServiceTest {
service.settle(1L, 9L, "operator");
verify(splits).cloneChildWithPurpose(eq(10001L), eq(31L), eq(31L), anyLong(),
eq(1001L), eq("ORDER-LOADED"), eq("IN-001-1"), eq(8),
eq(1), eq("LOADED"), eq(9L), eq("operator"));
verify(splits).cloneChildWithPurpose(eq(10001L), eq(31L), eq(31L), anyLong(),
eq(1002L), eq("ORDER-REMAINDER"), eq("IN-001-2"), eq(12),
eq(2), eq("REMAINDER"), eq(9L), eq("operator"));
eq(1002L), eq("ORDER-REMAINDER"), eq("IN-001-1"), eq(12),
eq(1), eq("REMAINDER"), eq(9L), eq("operator"));
verify(splits).markSourceLoaded(10001L, 31L, 8, 9L, 3);
verify(allocations).consume(10001L, 31L, 1L, 8);
verify(warehouse).settle(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request ->
request.warehouseId() == 701L && request.sourceBusinessId() == 31L
@@ -115,7 +113,7 @@ class PdaPickSettlementServiceTest {
}
@Test
void fullyPickedInboundCreatesLoadedChildAndReleasesAllResources() {
void fullyPickedInboundKeepsSourceAsLoadedSliceAndReleasesAllResources() {
LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 20, 20, "PICKED");
resolved(List.of(item));
when(splits.lockParent(31L)).thenReturn(parent(20));
@@ -132,9 +130,9 @@ class PdaPickSettlementServiceTest {
verify(allocations).consume(10001L, 31L, 1L, 10);
verify(allocations).consume(10001L, 31L, 2L, 10);
verify(splits).cloneChildWithPurpose(eq(10001L), eq(31L), eq(31L), anyLong(),
eq(1001L), eq("ORDER-LOADED"), anyString(), eq(20),
anyInt(), eq("LOADED"), eq(9L), eq("operator"));
verify(splits, never()).cloneChildWithPurpose(anyLong(), anyLong(), anyLong(), anyLong(),
anyLong(), anyString(), anyString(), anyInt(), anyInt(), anyString(), anyLong(), anyString());
verify(splits).markSourceLoaded(10001L, 31L, 20, 9L, 3);
verify(warehouse).settle(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request ->
request.targetBusinessId() == 0L && request.targetPackageCount() == 0
&& request.releasedReservationIds().equals(List.of(91L, 92L))
@@ -168,8 +166,9 @@ class PdaPickSettlementServiceTest {
service.settle(1L, 9L, "operator");
verify(splits).cloneChildWithPurpose(eq(10001L), eq(31L), eq(31L), anyLong(),
eq(1001L), eq("ORDER-LOADED"), anyString(), eq(20), anyInt(), eq("LOADED"), eq(9L), eq("operator"));
verify(splits).markSourceLoaded(10001L, 31L, 20, 9L, 3);
verify(splits, never()).cloneChildWithPurpose(anyLong(), anyLong(), anyLong(), anyLong(),
anyLong(), anyString(), anyString(), anyInt(), anyInt(), anyString(), anyLong(), anyString());
}
@Test
@@ -188,7 +187,7 @@ class PdaPickSettlementServiceTest {
service.settle(1L, 9L, "operator");
verify(splits).markRootSplit(10001L, 31L, 9L, 3);
verify(splits).markSourceLoaded(10001L, 31L, 20, 9L, 3);
}
@Test
@@ -240,9 +239,8 @@ class PdaPickSettlementServiceTest {
verify(orderSplits, times(2)).split(eq("internal-token"), eq(9L),
org.mockito.ArgumentMatchers.argThat(request -> request.idempotencyKey().equals("pda-task-1")));
verify(splits).cloneChildWithPurpose(eq(10001L), eq(31L), eq(31L), anyLong(),
eq(1001L), eq("ORDER-LOADED"), anyString(), eq(20), anyInt(), eq("LOADED"),
eq(9L), eq("operator"));
verify(splits, never()).cloneChildWithPurpose(anyLong(), anyLong(), anyLong(), anyLong(),
anyLong(), anyString(), anyString(), anyInt(), anyInt(), anyString(), anyLong(), anyString());
}
private void resolved(List<LoadingPickItemEntity> items) {
@@ -261,10 +259,10 @@ class PdaPickSettlementServiceTest {
when(orderSplits.split(eq("internal-token"), eq(9L), any())).thenAnswer(invocation -> {
OrderFulfillmentSplitClient.CreateRequest request = invocation.getArgument(2);
OrderFulfillmentSplitClient.Child loaded = new OrderFulfillmentSplitClient.Child(
1001L, "ORDER-LOADED", 1, "LOADED", request.loadedPackageCount(),
request.sourceOrderId(), "ORDER-001", null, "LOADED", request.loadedPackageCount(),
request.sourceOrderId(), request.loadingPlanId(), request.containerBookingId());
OrderFulfillmentSplitClient.Child remainder = request.remainingPackageCount() == 0 ? null
: new OrderFulfillmentSplitClient.Child(1002L, "ORDER-REMAINDER", 2, "REMAINDER",
: new OrderFulfillmentSplitClient.Child(1002L, "ORDER-REMAINDER", 1, "REMAINDER",
request.remainingPackageCount(), request.sourceOrderId(), request.loadingPlanId(),
request.containerBookingId());
return ApiResponse.success(new OrderFulfillmentSplitClient.Response(
@@ -279,15 +277,18 @@ class PdaPickSettlementServiceTest {
when(splits.cloneChildWithPurpose(eq(10001L), eq(31L), eq(31L), anyLong(), anyLong(), anyString(), anyString(), anyInt(),
anyInt(), anyString(), eq(9L), eq("operator"))).thenReturn(1);
when(splits.insertBatchWithChildren(anyLong(), eq(10001L), eq(31L), anyString(), eq(31L), anyString(), eq(3),
anyInt(), anyLong(), any(), anyInt(), anyInt(), eq(701L), eq(801L), anyString(), eq(9L), eq("operator"))).thenReturn(1);
anyInt(), org.mockito.ArgumentMatchers.isNull(), any(), anyInt(), anyInt(), eq(701L), eq(801L),
anyString(), eq(9L), eq("operator"))).thenReturn(1);
when(splits.cloneMeasurement(eq(10001L), eq(31L), eq(61L), anyLong(), anyLong(), anyLong(),
anyInt(), eq(9L))).thenReturn(1);
when(splits.insertSplitItem(anyLong(), eq(10001L), anyLong(), eq(31L), anyLong(), anyInt(),
eq(41L), eq(61L), eq(51L), anyInt())).thenReturn(1);
when(splits.cloneMeasurementItem(eq(10001L), eq(61L), eq(51L), anyLong(), anyLong(), anyLong(),
anyInt(), anyInt())).thenReturn(1);
when(splits.updateMeasurementItemForLoaded(eq(10001L), eq(51L), anyInt())).thenReturn(1);
when(splits.updateMeasurementForLoaded(eq(10001L), eq(31L), eq(61L), anyInt())).thenReturn(1);
when(splits.normalizeChildMetrics(eq(10001L), anyLong(), anyLong())).thenReturn(1);
when(splits.markRootSplit(10001L, 31L, 9L, 3)).thenReturn(1);
when(splits.markSourceLoaded(eq(10001L), eq(31L), anyInt(), eq(9L), eq(3))).thenReturn(1);
when(splits.applyBatch(eq(10001L), anyLong())).thenReturn(1);
when(splits.insertHistory(anyLong(), eq(10001L), anyLong(), anyString(), anyString(), eq(9L),
eq("operator"), anyString())).thenReturn(1);
+31 -1
View File
@@ -83,7 +83,7 @@ CREATE TABLE IF NOT EXISTS ord_order_fulfillment_split (
PRIMARY KEY (id),
UNIQUE KEY uk_order_split_operation (tenant_id, operation_id, split_role),
UNIQUE KEY uk_order_split_idempotency (tenant_id, parent_order_id, idempotency_key, split_role),
UNIQUE KEY uk_order_split_child (tenant_id, child_order_id),
UNIQUE KEY uk_order_split_child_role (tenant_id, child_order_id, split_role),
KEY idx_order_split_parent (tenant_id, parent_order_id, created_at, id),
KEY idx_order_split_source (tenant_id, source_order_id, split_role),
KEY idx_order_split_container (tenant_id, container_booking_id, created_at, id),
@@ -95,6 +95,36 @@ CREATE TABLE IF NOT EXISTS ord_order_fulfillment_split (
)
) ENGINE=InnoDB COMMENT='父订单到子订单的装柜数量及柜次关联台账';
DROP TRIGGER IF EXISTS trg_order_fulfillment_split_insert;
DROP TRIGGER IF EXISTS trg_order_fulfillment_split_update;
DELIMITER $$
CREATE TRIGGER trg_order_fulfillment_split_insert
BEFORE INSERT ON ord_order_fulfillment_split FOR EACH ROW
BEGIN
IF NEW.split_role NOT IN ('LOADED','REMAINDER') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订单履约分片角色非法';
END IF;
IF NEW.split_role='LOADED' AND NEW.child_order_id<>NEW.source_order_id THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='实装台账必须由来源订单自身承接';
END IF;
IF NEW.split_role='REMAINDER'
AND (NEW.child_order_id=NEW.source_order_id OR NEW.remaining_package_count=0) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='余量台账必须指向有剩余数量的新子订单';
END IF;
END$$
CREATE TRIGGER trg_order_fulfillment_split_update
BEFORE UPDATE ON ord_order_fulfillment_split FOR EACH ROW
BEGIN
IF NOT (NEW.tenant_id<=>OLD.tenant_id)
OR NOT (NEW.parent_order_id<=>OLD.parent_order_id)
OR NOT (NEW.source_order_id<=>OLD.source_order_id)
OR NOT (NEW.child_order_id<=>OLD.child_order_id)
OR NOT (NEW.split_role<=>OLD.split_role) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订单履约分片来源、子片与角色不可修改';
END IF;
END$$
DELIMITER ;
CREATE TABLE IF NOT EXISTS ord_order_party (
id BIGINT UNSIGNED NOT NULL COMMENT '参与方快照ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
@@ -71,7 +71,7 @@ CREATE TABLE IF NOT EXISTS ord_order_fulfillment_split (
PRIMARY KEY(id),
UNIQUE KEY uk_order_split_operation (tenant_id,operation_id,split_role),
UNIQUE KEY uk_order_split_idempotency (tenant_id,parent_order_id,idempotency_key,split_role),
UNIQUE KEY uk_order_split_child (tenant_id,child_order_id),
UNIQUE KEY uk_order_split_child_role (tenant_id,child_order_id,split_role),
KEY idx_order_split_parent (tenant_id,parent_order_id,created_at,id),
KEY idx_order_split_source (tenant_id,source_order_id,split_role),
KEY idx_order_split_container (tenant_id,container_booking_id,created_at,id),
@@ -83,6 +83,8 @@ CREATE TABLE IF NOT EXISTS ord_order_fulfillment_split (
DROP TRIGGER IF EXISTS trg_ord_order_parent_insert;
DROP TRIGGER IF EXISTS trg_ord_order_parent_update;
DROP TRIGGER IF EXISTS trg_order_fulfillment_split_insert;
DROP TRIGGER IF EXISTS trg_order_fulfillment_split_update;
DELIMITER $$
CREATE TRIGGER trg_ord_order_parent_insert BEFORE INSERT ON ord_order FOR EACH ROW
BEGIN
@@ -142,6 +144,10 @@ BEGIN
AND table_name='ord_order_fulfillment_split' AND index_name='uk_order_split_idempotency') THEN
ALTER TABLE ord_order_fulfillment_split DROP INDEX uk_order_split_idempotency;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='ord_order_fulfillment_split' AND index_name='uk_order_split_child') THEN
ALTER TABLE ord_order_fulfillment_split DROP INDEX uk_order_split_child;
END IF;
END$$
DELIMITER ;
CALL phase3_order_drop_split_index();
@@ -150,6 +156,36 @@ CALL phase3_order_add_index('ord_order_fulfillment_split','uk_order_split_operat
'UNIQUE KEY `uk_order_split_operation` (`tenant_id`,`operation_id`,`split_role`)');
CALL phase3_order_add_index('ord_order_fulfillment_split','uk_order_split_idempotency',
'UNIQUE KEY `uk_order_split_idempotency` (`tenant_id`,`parent_order_id`,`idempotency_key`,`split_role`)');
CALL phase3_order_add_index('ord_order_fulfillment_split','uk_order_split_child_role',
'UNIQUE KEY `uk_order_split_child_role` (`tenant_id`,`child_order_id`,`split_role`)');
DELIMITER $$
CREATE TRIGGER trg_order_fulfillment_split_insert
BEFORE INSERT ON ord_order_fulfillment_split FOR EACH ROW
BEGIN
IF NEW.split_role NOT IN ('LOADED','REMAINDER') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订单履约分片角色非法';
END IF;
IF NEW.split_role='LOADED' AND NEW.child_order_id<>NEW.source_order_id THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='实装台账必须由来源订单自身承接';
END IF;
IF NEW.split_role='REMAINDER'
AND (NEW.child_order_id=NEW.source_order_id OR NEW.remaining_package_count=0) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='余量台账必须指向有剩余数量的新子订单';
END IF;
END$$
CREATE TRIGGER trg_order_fulfillment_split_update
BEFORE UPDATE ON ord_order_fulfillment_split FOR EACH ROW
BEGIN
IF NOT (NEW.tenant_id<=>OLD.tenant_id)
OR NOT (NEW.parent_order_id<=>OLD.parent_order_id)
OR NOT (NEW.source_order_id<=>OLD.source_order_id)
OR NOT (NEW.child_order_id<=>OLD.child_order_id)
OR NOT (NEW.split_role<=>OLD.split_role) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订单履约分片来源、子片与角色不可修改';
END IF;
END$$
DELIMITER ;
DROP PROCEDURE IF EXISTS phase3_order_add_column;
DROP PROCEDURE IF EXISTS phase3_order_add_index;
@@ -173,9 +209,20 @@ BEGIN
IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics
WHERE table_schema=DATABASE() AND table_name='ord_order_fulfillment_split'
AND index_name IN ('idx_order_split_source','uk_order_split_operation',
'uk_order_split_idempotency'))<>3 THEN
'uk_order_split_idempotency','uk_order_split_child_role'))<>4 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订单拆分来源或幂等索引未完整创建';
END IF;
IF EXISTS (SELECT 1 FROM information_schema.statistics
WHERE table_schema=DATABASE() AND table_name='ord_order_fulfillment_split'
AND index_name='uk_order_split_child') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='旧订单拆分子单唯一索引仍阻止跨角色分片';
END IF;
IF (SELECT COUNT(*) FROM information_schema.triggers
WHERE trigger_schema=DATABASE()
AND trigger_name IN ('trg_order_fulfillment_split_insert',
'trg_order_fulfillment_split_update'))<>2 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订单履约分片写入保护触发器未完整创建';
END IF;
END$$
DELIMITER ;
CALL phase3_order_assert();
+1
View File
@@ -33,6 +33,7 @@
<module>file</module>
<module>print</module>
<module>audit-service</module>
<module>finance</module>
<module>integration-service</module>
<module>event-bridge-service</module>
</modules>