From 540a6f5d8881cd91f2f709bd8af672f9cd9d5e78 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 11 Aug 2026 09:25:41 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/service-map.yml | 4 + deploy/sql/头程数据库统一增量-MySQL8.sql | 51 +++- finance/pom.xml | 31 +++ .../finance/BaiyueFinanceApplication.java | 13 + .../controller/PlatformFinanceController.java | 64 +++++ .../finance/service/FinanceService.java | 137 +++++++++++ .../src/main/resources/application-dev.yml | 6 + finance/src/main/resources/application.yml | 32 +++ finance/src/main/resources/db/finance.sql | 227 ++++++++++++++++++ .../loading/mapper/LoadingInboundMapper.java | 28 ++- .../service/LoadingDeliveryService.java | 2 +- .../mapper/InboundSplitSettlementMapper.java | 50 +++- .../service/PdaPickSettlementService.java | 67 +++--- .../service/LoadingDeliveryServiceTest.java | 12 +- .../service/PdaPickSettlementServiceTest.java | 39 +-- order/src/main/resources/db/order.sql | 32 ++- .../db/phase3-order-parent-child-schema.sql | 51 +++- pom.xml | 1 + 18 files changed, 757 insertions(+), 90 deletions(-) create mode 100644 finance/pom.xml create mode 100644 finance/src/main/java/com/baiyue/finance/BaiyueFinanceApplication.java create mode 100644 finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java create mode 100644 finance/src/main/java/com/baiyue/finance/service/FinanceService.java create mode 100644 finance/src/main/resources/application-dev.yml create mode 100644 finance/src/main/resources/application.yml create mode 100644 finance/src/main/resources/db/finance.sql diff --git a/deploy/service-map.yml b/deploy/service-map.yml index 6995fcb9d..bfd0c98cb 100644 --- a/deploy/service-map.yml +++ b/deploy/service-map.yml @@ -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 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 96d7c4b82..7e6099d7d 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -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(); diff --git a/finance/pom.xml b/finance/pom.xml new file mode 100644 index 000000000..261a622b6 --- /dev/null +++ b/finance/pom.xml @@ -0,0 +1,31 @@ + + + 4.0.0 + + com.baiyue.firstleg + baiyue-firstleg-platform + 1.0.0-SNAPSHOT + ../pom.xml + + finance-service + + com.baiyue.sharedbaiyue-common-web${project.version} + com.baiyue.sharedbaiyue-common-openapi${project.version} + com.baiyue.sharedbaiyue-common-security${project.version} + com.baiyue.sharedbaiyue-common-audit${project.version} + com.baomidoumybatis-plus-spring-boot3-starter + org.springframework.bootspring-boot-starter-jdbc + com.mysqlmysql-connector-jruntime + com.alibaba.cloudspring-cloud-starter-alibaba-nacos-discovery + com.alibaba.cloudspring-cloud-starter-alibaba-nacos-config + org.springframework.bootspring-boot-starter-actuator + org.springframework.bootspring-boot-starter-testtest + + + + org.springframework.bootspring-boot-maven-plugin + + + diff --git a/finance/src/main/java/com/baiyue/finance/BaiyueFinanceApplication.java b/finance/src/main/java/com/baiyue/finance/BaiyueFinanceApplication.java new file mode 100644 index 000000000..1c60aee94 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/BaiyueFinanceApplication.java @@ -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); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java new file mode 100644 index 000000000..c9bf7a651 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java @@ -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> 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> 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> 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> 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> 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> 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) {} +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceService.java new file mode 100644 index 000000000..5cdf15a3c --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceService.java @@ -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 customerTerms(long tenantId) { + requireCustomer(tenantId); + List> 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 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 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 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 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 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 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 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 voucherFileIds, String remark, List 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 voucherFileIds, String remark) {} +} diff --git a/finance/src/main/resources/application-dev.yml b/finance/src/main/resources/application-dev.yml new file mode 100644 index 000000000..3c78aee86 --- /dev/null +++ b/finance/src/main/resources/application-dev.yml @@ -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} diff --git a/finance/src/main/resources/application.yml b/finance/src/main/resources/application.yml new file mode 100644 index 000000000..9ccb4b825 --- /dev/null +++ b/finance/src/main/resources/application.yml @@ -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}" diff --git a/finance/src/main/resources/db/finance.sql b/finance/src/main/resources/db/finance.sql new file mode 100644 index 000000000..934373730 --- /dev/null +++ b/finance/src/main/resources/db/finance.sql @@ -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不可变业务审计明细'; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingInboundMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingInboundMapper.java index 31d7ebd29..1c7c3cc7c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingInboundMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingInboundMapper.java @@ -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; + } + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java index 5ed34234e..81c146df0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java @@ -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))); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java index 47dd33d21..a252cd986 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java @@ -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); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java index 1006e16c9..47e687865 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java @@ -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("订单服务返回的父子订单、柜次或数量不一致"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java index b8a7b630b..9bfc67e17 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java @@ -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); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java index d35e1951a..c88e36722 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java @@ -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 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); diff --git a/order/src/main/resources/db/order.sql b/order/src/main/resources/db/order.sql index 2d9285b6d..53709f630 100644 --- a/order/src/main/resources/db/order.sql +++ b/order/src/main/resources/db/order.sql @@ -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', diff --git a/order/src/main/resources/db/phase3-order-parent-child-schema.sql b/order/src/main/resources/db/phase3-order-parent-child-schema.sql index 9610a9449..da9d7f092 100644 --- a/order/src/main/resources/db/phase3-order-parent-child-schema.sql +++ b/order/src/main/resources/db/phase3-order-parent-child-schema.sql @@ -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(); diff --git a/pom.xml b/pom.xml index 6fa5772a2..c66bdf057 100644 --- a/pom.xml +++ b/pom.xml @@ -33,6 +33,7 @@ file print audit-service + finance integration-service event-bridge-service