优化
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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}"
|
||||
@@ -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
-12
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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)));
|
||||
|
||||
+37
-13
@@ -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("订单服务返回的父子订单、柜次或数量不一致");
|
||||
}
|
||||
|
||||
+10
-2
@@ -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);
|
||||
|
||||
+20
-19
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user