Merge remote-tracking branch 'origin/test' into dev

# Conflicts:
#	deploy/sql/头程数据库统一增量-MySQL8.sql
#	event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java
#	firstleg/src/main/resources/db/phase3-container-customs-schema.sql
#	order/src/main/resources/db/order.sql
#	scripts/db/incremental.sql
This commit is contained in:
2026-09-02 17:56:20 +08:00
45 changed files with 1005 additions and 1063 deletions
File diff suppressed because it is too large Load Diff
@@ -1636,4 +1636,43 @@ END$$
DELIMITER ;
CALL assert_lastleg_security_projection_ready();
DROP PROCEDURE assert_lastleg_security_projection_ready;
CREATE TABLE IF NOT EXISTS ll_finance_fee_source (
id BIGINT NOT NULL COMMENT '费用版本记录ID', source_fee_id BIGINT NOT NULL COMMENT '跨版本稳定费用来源ID',
source_version INT NOT NULL COMMENT '费用来源版本,从1递增', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键',
customer_name VARCHAR(200) NULL COMMENT '客户名称快照', source_type VARCHAR(64) NOT NULL COMMENT '海外费用来源类型',
source_no VARCHAR(128) NOT NULL COMMENT '来源业务编号或入仓码快照', business_type VARCHAR(32) NOT NULL COMMENT '来源业务类型',
business_id BIGINT NULL COMMENT '来源业务记录ID', business_no VARCHAR(128) NOT NULL COMMENT '来源业务编号',
order_id BIGINT NULL COMMENT '关联订单ID', inbound_id BIGINT NULL COMMENT '关联海外入仓计划或入仓记录ID',
inbound_code VARCHAR(64) NULL COMMENT '关联入仓码;并非所有费用必填', container_id BIGINT NULL COMMENT '关联柜次ID',
container_booking_id BIGINT NULL COMMENT '来源订舱柜ID', container_no VARCHAR(64) NULL COMMENT '真实集装箱号快照',
warehouse_id BIGINT NULL COMMENT '关联海外仓ID', fee_item_code VARCHAR(64) NOT NULL COMMENT '费用项目编码快照',
fee_item_name VARCHAR(200) NOT NULL COMMENT '费用项目名称快照', direction VARCHAR(16) NOT NULL COMMENT 'RECEIVABLE应收或PAYABLE应付',
supplier_id BIGINT NULL COMMENT '应付供应商ID', supplier_name VARCHAR(200) NULL COMMENT '应付供应商名称快照',
supplier_category VARCHAR(32) NULL COMMENT '应付供应商类别约束', settlement_mode VARCHAR(16) NULL COMMENT 'ON_ACCOUNT账期应付或DIRECT_PAID业务已付',
paid_at DATETIME(3) NULL COMMENT '直接已付时间', payment_method VARCHAR(32) NULL COMMENT '直接已付方式',
payment_reference_no VARCHAR(128) NULL COMMENT '直接已付流水号', payment_voucher_file_ids JSON NULL COMMENT '直接已付凭证文件ID快照',
business_date DATE NOT NULL COMMENT '费用业务日期', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '财务结算币种,当前固定CNY',
amount DECIMAL(20,6) NOT NULL COMMENT '费用金额', review_status VARCHAR(16) NOT NULL COMMENT 'DRAFT、PENDING、APPROVED或REJECTED',
publication_status VARCHAR(16) NOT NULL COMMENT 'UNPUBLISHED或PUBLISHED', source_status VARCHAR(16) NOT NULL COMMENT 'ACTIVE、SUPERSEDED或VOIDED',
review_reason VARCHAR(500) NULL COMMENT '审核或作废原因', reviewer_id VARCHAR(128) NULL COMMENT '审核人ID', reviewed_at DATETIME(3) NULL COMMENT '审核时间',
created_by VARCHAR(128) NOT NULL COMMENT '创建人ID', updated_by VARCHAR(128) NOT NULL COMMENT '最后更新人ID',
created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_finance_fee_version(source_fee_id,source_version),
KEY idx_ll_finance_fee_customer(customer_id,source_status,created_at), KEY idx_ll_finance_fee_business(business_type,business_no,created_at),
CONSTRAINT chk_ll_finance_fee_amount CHECK(amount>0), CONSTRAINT chk_ll_finance_fee_currency CHECK(currency='CNY'),
CONSTRAINT chk_ll_finance_fee_direction CHECK(direction IN('RECEIVABLE','PAYABLE')),
CONSTRAINT chk_ll_finance_fee_review CHECK(review_status IN('DRAFT','PENDING','APPROVED','REJECTED')),
CONSTRAINT chk_ll_finance_fee_publication CHECK(publication_status IN('UNPUBLISHED','PUBLISHED')),
CONSTRAINT chk_ll_finance_fee_status CHECK(source_status IN('ACTIVE','SUPERSEDED','VOIDED'))
) ENGINE=InnoDB COMMENT='海外业务不可变费用来源版本;发布后经跨区事件进入Finance';
CREATE TABLE IF NOT EXISTS ll_finance_fee_command (
id BIGINT NOT NULL COMMENT '幂等命令ID', operator_id VARCHAR(128) NOT NULL COMMENT '操作人ID',
action VARCHAR(16) NOT NULL COMMENT 'CREATE、SUBMIT、REVIEW、ADJUST或VOID', idempotency_key VARCHAR(128) NOT NULL COMMENT '操作幂等键',
request_hash CHAR(64) NOT NULL COMMENT '请求内容SHA-256', source_fee_id BIGINT NOT NULL COMMENT '稳定费用来源ID',
source_version INT NOT NULL COMMENT '命令结果版本', created_at DATETIME(3) NOT NULL COMMENT '命令创建时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_finance_fee_command(operator_id,action,idempotency_key),
KEY idx_ll_finance_fee_command_source(source_fee_id,source_version)
) ENGINE=InnoDB COMMENT='海外费用来源写操作幂等审计';
SELECT 'PASS' AS migration_status, '海外仓业务、海外仓资源与IAM安全投影结构已就绪' AS message;
@@ -31,6 +31,7 @@ public class EventBridgeMetrics {
EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED,
EventTypes.OVERSEAS_CONTAINER_UNLOADED,
EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED,
EventTypes.BUSINESS_FEE_SOURCE_CHANGED,
EventTypes.IAM_USER_STATUS_CHANGED,
EventTypes.IAM_TENANT_STATUS_CHANGED,
EventTypes.IAM_PERMISSION_VERSION_CHANGED,
@@ -31,6 +31,7 @@ class OverseasEventDispatcherTest {
mock(com.baiyue.eventbridge.service.LastlegEventClient.class);
private final com.baiyue.eventbridge.service.FirstlegEventClient firstlegEventClient =
mock(com.baiyue.eventbridge.service.FirstlegEventClient.class);
private final FinanceEventClient financeEventClient = mock(FinanceEventClient.class);
private final SecurityProjectionEventClient securityProjectionEventClient = mock(SecurityProjectionEventClient.class);
private OverseasEventDispatcher dispatcher;
private BridgeInbox event;
@@ -48,6 +49,7 @@ class OverseasEventDispatcherTest {
ReflectionTestUtils.setField(dispatcher, "attemptMapper", attemptMapper);
ReflectionTestUtils.setField(dispatcher, "eventClient", eventClient);
ReflectionTestUtils.setField(dispatcher, "firstlegEventClient", firstlegEventClient);
ReflectionTestUtils.setField(dispatcher, "financeEventClient", financeEventClient);
ReflectionTestUtils.setField(dispatcher, "securityProjectionEventClient", securityProjectionEventClient);
ReflectionTestUtils.setField(dispatcher, "properties", properties);
ReflectionTestUtils.setField(dispatcher, "metrics", new EventBridgeMetrics(registry));
@@ -150,6 +152,22 @@ class OverseasEventDispatcherTest {
assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED");
}
@Test
void overseasFeeSourceIsDeliveredToFinanceAndNeverFirstleg() throws Exception {
event.setEventType(EventTypes.BUSINESS_FEE_SOURCE_CHANGED);
event.setSourceRegion("OVERSEAS");
event.setTargetRegion("CN");
when(financeEventClient.deliver(event)).thenReturn("200");
when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1);
dispatcher.dispatchOne("event-1");
verify(financeEventClient).deliver(event);
verifyNoInteractions(firstlegEventClient);
verifyNoInteractions(eventClient);
assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED");
}
@Test
void iamSecurityEventsAreDeliveredToSecurityProjectionAndNeverLastleg() throws Exception {
for (String eventType : List.of(EventTypes.IAM_USER_STATUS_CHANGED,
+1 -1
View File
@@ -22,7 +22,7 @@
| 财务单据编号 | `service/FinanceDocumentNumberGenerator.java`;对账单、应收结算单、应付结算单分别使用 `REC`、`AR`、`AP` 前缀 |
| Finance 导出数据 Provider | `controller/FinanceExportProviderController.java`、`service/FinanceExportProviderService.java`;由 File `ExportProviderClient` 按已登记 `exportType` 分批拉取,不从 Admin 直接生成文件 |
| 五页产品 OpenAPI | `../../docs/财务管理/openapi/finance-phase1-five-pages-v1.yaml`;人工财务补充契约 `finance-f1-manual-v1.yaml` |
| Firstleg 费用来源事件入口 | `controller/InternalFinanceFeeSourceController.java`、`service/FinanceFeeSourceEventService.java` |
| 业务费用来源事件入口 | Firstleg:`controller/InternalFinanceFeeSourceController.java`、`service/FinanceFeeSourceEventService.java`;海外完整快照:`controller/InternalBusinessFeeSourceController.java`、`service/BusinessFeeSourceEventService.java` |
| 查询模型与客户可见性 | `service/FinanceQueryService.java`、`dto/FinanceReadModels.java` |
| 账期、账单、收款、成本及幂等写模型 | `service/FinanceService.java` |
| Firstleg 费用来源与柜次成本快照 | `client/FirstlegFeeSourceClient.java`、`client/FirstlegCostSnapshotClient.java`;生产端见 `../firstleg/AGENTS.md` |
@@ -94,7 +94,7 @@ public class BusinessFeeSourceEventService {
number(source.containerBookingId()), trim(source.containerNo()), source.feeItemCode().trim(),
source.feeItemName().trim(), source.totalAmount(), source.currency(), trim(source.settlementMode()),
source.paidAt(), trim(source.paymentMethod()), trim(source.paymentReferenceNo()),
json(source.paymentVoucherFileIds()), source.sourceHash(), json(source));
voucherJson(source.paymentVoucherFileIds()), source.sourceHash(), json(source));
long id = jdbc.queryForObject("SELECT id FROM fin_fee_source WHERE source_system=? AND source_type=? AND source_fee_id=? AND source_fee_version=?",
Long.class, system, source.sourceType(), feeId, source.sourceVersion());
if ("PAYABLE".equals(source.direction()) && "DIRECT_PAID".equals(source.settlementMode()))
@@ -131,14 +131,14 @@ public class BusinessFeeSourceEventService {
|| (value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty())) {
throw error("FIN-EVT-012", "应收来源不得携带供应商或付款事实");
}
BusinessFeeSourceChangedV1 unsigned = new BusinessFeeSourceChangedV1(value.action(), value.sourceSystem(),
BusinessFeeSourceChangedV1 unsigned = new BusinessFeeSourceChangedV1(null, value.sourceSystem(),
value.sourceType(), value.sourceFeeId(), value.sourceVersion(), value.customerId(), value.customerName(),
value.sourceNo(), value.businessType(), value.businessId(), value.businessNo(), value.orderId(),
value.inboundId(), value.inboundCode(), value.containerId(), value.containerBookingId(), value.containerNo(),
value.warehouseId(), value.feeItemCode(), value.feeItemName(), value.direction(), value.supplierId(),
value.supplierName(), value.supplierCategory(), value.settlementMode(), value.paidAt(), value.paymentMethod(),
value.paymentReferenceNo(), value.paymentVoucherFileIds(), value.businessDate(), value.currency(),
value.totalAmount(), value.reviewStatus(), value.publicationStatus(), value.sourceStatus(), null);
value.totalAmount(), value.reviewStatus(), "PUBLISHED", "ACTIVE", null);
String expected = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(objectMapper.valueToTree(unsigned)));
if (!expected.equals(value.sourceHash())) throw error("FIN-EVT-004", "费用来源快照哈希不一致");
}
@@ -166,10 +166,10 @@ public class BusinessFeeSourceEventService {
}
private void materializeDirectPaidCost(String system, long feeId, BusinessFeeSourceChangedV1 value) {
jdbc.update("INSERT INTO fin_cost(tenant_id,cost_no,cost_type,cost_origin,source_system,source_type,source_fee_id,source_fee_version,source_hash,order_id,inbound_id,container_id,supplier_id,supplier_name_snapshot,fee_item_code,fee_item_name,currency_code,cost_amount,voucher_file_ids,paid_at,payment_method,payment_reference_no,status,confirmed_at,created_by,created_by_name,updated_by) VALUES(?,?,'SOURCE_DIRECT_PAID','BUSINESS_DIRECT_PAID',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED',CURRENT_TIMESTAMP(3),0,'系统',0)",
jdbc.update("INSERT INTO fin_cost(tenant_id,cost_no,cost_type,cost_origin,source_system,source_type,source_fee_id,source_fee_version,source_hash,order_id,inbound_id,container_id,warehouse_id,supplier_id,supplier_name_snapshot,fee_item_code,fee_item_name,currency_code,cost_amount,voucher_file_ids,paid_at,payment_method,payment_reference_no,status,confirmed_at,created_by,created_by_name,updated_by) VALUES(?,?,'SOURCE_DIRECT_PAID','BUSINESS_DIRECT_PAID',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED',CURRENT_TIMESTAMP(3),0,'系统',0)",
positive(value.customerId(), "customerId"), "DIRECT-" + system + '-' + value.sourceType() + '-' + feeId + "-V" + value.sourceVersion(),
system, value.sourceType(), feeId, value.sourceVersion(), value.sourceHash(), number(value.orderId()),
number(value.inboundId()), number(value.containerId()), positive(value.supplierId(), "supplierId"),
number(value.inboundId()), number(value.containerId()), number(value.warehouseId()), positive(value.supplierId(), "supplierId"),
value.supplierName(), value.feeItemCode(), value.feeItemName(), value.currency(), value.totalAmount(),
json(value.paymentVoucherFileIds()), value.paidAt(), value.paymentMethod(), value.paymentReferenceNo());
}
@@ -187,6 +187,9 @@ public class BusinessFeeSourceEventService {
try { return objectMapper.writeValueAsString(value == null ? List.of() : value); }
catch (JsonProcessingException exception) { throw error("FIN-EVT-001", "费用事件序列化失败"); }
}
private String voucherJson(List<String> values) {
return values == null || values.isEmpty() ? null : json(values);
}
private static Long number(String value) { return blank(value) ? null : positive(value, "business identifier"); }
private static long positive(String value, String name) {
try { long parsed = Long.parseLong(value); if (parsed > 0) return parsed; }
@@ -68,9 +68,9 @@ public class FinanceCustomerAccountService {
.forEach(row -> metric(result, row, "pendingBilling", "amount"));
jdbc.queryForList("SELECT tenant_id customer_id,SUM(received_amount) amount FROM fin_receipt WHERE tenant_id IN (" + placeholders + ") AND status='CONFIRMED' GROUP BY tenant_id", customerIds.toArray())
.forEach(row -> metric(result, row, "receivedInPeriod", "amount"));
jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM (SELECT customer_id,amount FROM fin_fee_source WHERE customer_id IN (" + placeholders + ") AND direction='PAYABLE' AND settlement_mode='ON_ACCOUNT' AND review_status='APPROVED' AND currency_code='CNY' AND amount>0 AND status NOT IN ('VOIDED','SUPERSEDED') UNION ALL SELECT tenant_id customer_id,cost_amount amount FROM fin_cost WHERE tenant_id IN (" + placeholders + ") AND cost_origin='FIRSTLEG_DIRECT_PAID' AND status='CONFIRMED') costs GROUP BY customer_id", concat(customerIds, 2))
jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM (SELECT customer_id,amount FROM fin_fee_source WHERE customer_id IN (" + placeholders + ") AND direction='PAYABLE' AND settlement_mode='ON_ACCOUNT' AND review_status='APPROVED' AND currency_code='CNY' AND amount>0 AND status NOT IN ('VOIDED','SUPERSEDED') UNION ALL SELECT tenant_id customer_id,cost_amount amount FROM fin_cost WHERE tenant_id IN (" + placeholders + ") AND cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID') AND status='CONFIRMED') costs GROUP BY customer_id", concat(customerIds, 2))
.forEach(row -> metric(result, row, "totalCost", "amount"));
jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM (SELECT tenant_id customer_id,cost_amount amount FROM fin_cost WHERE tenant_id IN (" + placeholders + ") AND cost_origin='FIRSTLEG_DIRECT_PAID' AND status='CONFIRMED' UNION ALL SELECT tenant_id customer_id,paid_amount amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='PAYABLE' AND status<>'VOIDED') paid_costs GROUP BY customer_id", concat(customerIds, 2))
jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM (SELECT tenant_id customer_id,cost_amount amount FROM fin_cost WHERE tenant_id IN (" + placeholders + ") AND cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID') AND status='CONFIRMED' UNION ALL SELECT tenant_id customer_id,paid_amount amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='PAYABLE' AND status<>'VOIDED') paid_costs GROUP BY customer_id", concat(customerIds, 2))
.forEach(row -> metric(result, row, "paidCost", "amount"));
jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM (SELECT tenant_id customer_id,outstanding_amount amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='PAYABLE' AND status<>'VOIDED' UNION ALL SELECT f.customer_id,f.amount FROM fin_fee_source f WHERE f.customer_id IN (" + placeholders + ") AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED') AND NOT EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.direction='PAYABLE' AND s.status<>'VOIDED')) payable_obligations GROUP BY customer_id", concat(customerIds, 2))
.forEach(row -> metric(result, row, "outstandingPayable", "amount"));
@@ -34,6 +34,7 @@ import java.util.Comparator;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Service
public class FinancePhase1OperationService {
@@ -69,8 +70,8 @@ public class FinancePhase1OperationService {
"(SELECT COALESCE(SUM(outstanding_amount),0) FROM fin_settlement ar WHERE ar.tenant_id=c.customer_id AND ar.direction='RECEIVABLE' AND ar.status<>'VOIDED') outstanding_receivable," +
"(SELECT COALESCE(SUM(amount),0) FROM fin_fee_source f WHERE f.customer_id=c.customer_id AND f.direction='RECEIVABLE' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status='AVAILABLE' AND NOT EXISTS(SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND bl.source_version=f.source_fee_version AND b.status<>'VOIDED')) pending_billing," +
"(SELECT COALESCE(SUM(received_amount),0) FROM fin_receipt r WHERE r.tenant_id=c.customer_id AND r.status='CONFIRMED') received_period," +
"((SELECT COALESCE(SUM(amount),0) FROM fin_fee_source f WHERE f.customer_id=c.customer_id AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED'))+(SELECT COALESCE(SUM(cost_amount),0) FROM fin_cost x WHERE x.tenant_id=c.customer_id AND x.cost_origin='FIRSTLEG_DIRECT_PAID' AND x.status='CONFIRMED')) total_cost," +
"((SELECT COALESCE(SUM(cost_amount),0) FROM fin_cost x WHERE x.tenant_id=c.customer_id AND x.cost_origin='FIRSTLEG_DIRECT_PAID' AND x.status='CONFIRMED')+(SELECT COALESCE(SUM(paid_amount),0) FROM fin_settlement x WHERE x.tenant_id=c.customer_id AND x.direction='PAYABLE' AND x.status<>'VOIDED')) paid_cost," +
"((SELECT COALESCE(SUM(amount),0) FROM fin_fee_source f WHERE f.customer_id=c.customer_id AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED'))+(SELECT COALESCE(SUM(cost_amount),0) FROM fin_cost x WHERE x.tenant_id=c.customer_id AND x.cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID') AND x.status='CONFIRMED')) total_cost," +
"((SELECT COALESCE(SUM(cost_amount),0) FROM fin_cost x WHERE x.tenant_id=c.customer_id AND x.cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID') AND x.status='CONFIRMED')+(SELECT COALESCE(SUM(paid_amount),0) FROM fin_settlement x WHERE x.tenant_id=c.customer_id AND x.direction='PAYABLE' AND x.status<>'VOIDED')) paid_cost," +
"((SELECT COALESCE(SUM(outstanding_amount),0) FROM fin_settlement x WHERE x.tenant_id=c.customer_id AND x.direction='PAYABLE' AND x.status<>'VOIDED')+(SELECT COALESCE(SUM(f.amount),0) FROM fin_fee_source f WHERE f.customer_id=c.customer_id AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED') AND NOT EXISTS(SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.direction='PAYABLE' AND s.status<>'VOIDED'))) outstanding_payable," +
"GREATEST(COALESCE((SELECT MAX(updated_at) FROM fin_bill b WHERE b.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_receipt r WHERE r.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_cost x WHERE x.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_fee_source f WHERE f.customer_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_settlement x WHERE x.tenant_id=c.customer_id),'1970-01-01')) last_changed_at" + base + " ORDER BY last_changed_at DESC,c.customer_id DESC LIMIT ? OFFSET ?";
Object[] args = java.util.Arrays.copyOf(filterArgs, filterArgs.length + 2);
@@ -89,7 +90,7 @@ public class FinancePhase1OperationService {
List<RawLedger> rows = new ArrayList<>();
rows.addAll(jdbc.query("SELECT id,bill_no,created_at,total_amount,created_by,created_by_name,remark FROM fin_bill WHERE tenant_id=? AND status<>'VOIDED'", (rs, n) -> raw(rs, "SETTLEMENT_ISSUED", "BILL", "bill_no", "total_amount", null), customerId));
rows.addAll(jdbc.query("SELECT id,receipt_no,received_at received_time,received_amount,created_by,created_by_name,remark FROM fin_receipt WHERE tenant_id=? AND status='CONFIRMED'", (rs, n) -> raw(rs, "RECEIPT", "RECEIPT", "receipt_no", null, "received_amount"), customerId));
rows.addAll(jdbc.query("SELECT id,cost_no,created_at,cost_amount,created_by,created_by_name,remark FROM fin_cost WHERE tenant_id=? AND cost_origin='FIRSTLEG_DIRECT_PAID' AND status='CONFIRMED'", (rs, n) -> raw(rs, "SUPPLIER_DIRECT_PAID", "DIRECT_PAID_COST", "cost_no", null, null), customerId));
rows.addAll(jdbc.query("SELECT id,cost_no,created_at,cost_amount,created_by,created_by_name,remark FROM fin_cost WHERE tenant_id=? AND cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID') AND status='CONFIRMED'", (rs, n) -> raw(rs, "SUPPLIER_DIRECT_PAID", "DIRECT_PAID_COST", "cost_no", null, null), customerId));
rows.addAll(jdbc.query("SELECT p.id,CONCAT(s.settlement_no,'-',p.id) payment_no,p.created_at,p.amount cost_amount,p.created_by,p.created_by_name,p.remark FROM fin_settlement_payment p JOIN fin_settlement s ON s.id=p.settlement_id AND s.tenant_id=p.tenant_id WHERE p.tenant_id=? AND p.transaction_type='PAYMENT' AND s.direction='PAYABLE' AND s.status<>'VOIDED'", (rs, n) -> raw(rs, "SUPPLIER_ON_ACCOUNT_PAYMENT", "AP_PAYMENT", "payment_no", null, null), customerId));
rows.sort(Comparator.comparing(RawLedger::occurredAt).thenComparing(RawLedger::id));
BigDecimal outstanding = BigDecimal.ZERO;
@@ -185,8 +186,8 @@ public class FinancePhase1OperationService {
throw error("FIN-REQ-400", adjustment ? "费用调整请求无效" : "费用编辑请求无效");
}
FeeSourceDetail before = getFinanceFeeSource(feeSourceId);
if ("FIRSTLEG".equals(before.sourceSystem())) {
throw error("FIN-FEE-409", "Firstleg费用必须在入库或柜次费用明细中调整,Finance不可直接修改业务金额");
if (Set.of("FIRSTLEG", "LASTLEG").contains(before.sourceSystem())) {
throw error("FIN-FEE-409", "业务费用必须回到对应业务费用明细调整,Finance不可直接修改来源金额");
}
String operation = adjustment ? "FEE_SOURCE_ADJUST" : "FEE_SOURCE_EDIT";
String path = "/api/platform/finance/v1/fee-sources/" + feeSourceId + (adjustment ? "/adjust" : "");
@@ -222,6 +223,9 @@ public class FinancePhase1OperationService {
throw error("FIN-REQ-400", "费用操作请求无效");
}
FeeSourceDetail before = getFinanceFeeSource(feeSourceId);
if ("LASTLEG".equals(before.sourceSystem())) {
throw error("FIN-FEE-409", "海外业务费用必须在海外费用来源中作废,Finance只消费作废事件");
}
if (!"FIRSTLEG".equals(before.sourceSystem())) {
return transitionFeeSource(feeSourceId, operatorId, key, request, "FEE_SOURCE_VOID",
"VOIDED", List.of("DRAFT", "PENDING", "REJECTED", "APPROVED"), true);
@@ -74,8 +74,8 @@ public class FinanceQueryService {
"AND NOT EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id " +
"WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND b.status<>'VOIDED')) pending_billing," +
"((SELECT COALESCE(SUM(f.amount),0) FROM fin_fee_source f WHERE f.customer_id=? AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED'))+" +
"(SELECT COALESCE(SUM(c.cost_amount),0) FROM fin_cost c WHERE c.tenant_id=? AND c.cost_origin='FIRSTLEG_DIRECT_PAID' AND c.status='CONFIRMED')) total_cost," +
"((SELECT COALESCE(SUM(c.cost_amount),0) FROM fin_cost c WHERE c.tenant_id=? AND c.cost_origin='FIRSTLEG_DIRECT_PAID' AND c.status='CONFIRMED')+" +
"(SELECT COALESCE(SUM(c.cost_amount),0) FROM fin_cost c WHERE c.tenant_id=? AND c.cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID') AND c.status='CONFIRMED')) total_cost," +
"((SELECT COALESCE(SUM(c.cost_amount),0) FROM fin_cost c WHERE c.tenant_id=? AND c.cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID') AND c.status='CONFIRMED')+" +
"(SELECT COALESCE(SUM(ps.paid_amount),0) FROM fin_settlement ps WHERE ps.tenant_id=? AND ps.direction='PAYABLE' AND ps.status<>'VOIDED')) paid_cost," +
"((SELECT COALESCE(SUM(os.outstanding_amount),0) FROM fin_settlement os WHERE os.tenant_id=? AND os.direction='PAYABLE' AND os.status<>'VOIDED')+" +
"(SELECT COALESCE(SUM(f.amount),0) FROM fin_fee_source f WHERE f.customer_id=? AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED') " +
@@ -23,10 +23,24 @@ CALL finance_fee_source_add_column('fin_fee_source', 'source_type',
"VARCHAR(32) NULL COMMENT '来源费用类型:INBOUND_FEE入库单费用、CONTAINER_FEE柜子费用' AFTER customer_id");
CALL finance_fee_source_add_column('fin_fee_source', 'source_no',
"VARCHAR(64) NULL COMMENT '来源入库单号或柜号快照,可为空' AFTER source_type");
CALL finance_fee_source_add_column('fin_fee_source', 'business_type',
"VARCHAR(32) NULL COMMENT '来源业务类型,如海外入仓、采购、卡车或柜级分摊' AFTER source_no");
CALL finance_fee_source_add_column('fin_fee_source', 'business_id',
"BIGINT UNSIGNED NULL COMMENT '来源业务记录ID,可为空' AFTER business_type");
CALL finance_fee_source_add_column('fin_fee_source', 'business_no',
"VARCHAR(128) NULL COMMENT '来源业务编号快照,可为空' AFTER business_id");
CALL finance_fee_source_add_column('fin_fee_source', 'inbound_code',
"VARCHAR(64) NULL COMMENT '来源入仓码快照;并非所有费用必填' AFTER business_no");
CALL finance_fee_source_add_column('fin_fee_source', 'warehouse_id',
"BIGINT UNSIGNED NULL COMMENT '来源海外仓或业务仓ID,可为空' AFTER inbound_code");
CALL finance_fee_source_add_column('fin_fee_source', 'direction',
"VARCHAR(16) NULL COMMENT '费用方向:RECEIVABLE应收、PAYABLE应付' AFTER source_no");
CALL finance_fee_source_add_column('fin_fee_source', 'container_id',
"BIGINT UNSIGNED NULL COMMENT '来源Firstleg装柜计划ID,可为空' AFTER inbound_id");
CALL finance_fee_source_add_column('fin_fee_source', 'container_booking_id',
"BIGINT UNSIGNED NULL COMMENT '来源订舱柜ID,可为空' AFTER container_id");
CALL finance_fee_source_add_column('fin_fee_source', 'container_no',
"VARCHAR(64) NULL COMMENT '真实集装箱号快照,可为空' AFTER container_booking_id");
CALL finance_fee_source_add_column('fin_fee_source', 'fee_item_code',
"VARCHAR(64) NULL COMMENT '来源费用项目编码快照' AFTER container_id");
CALL finance_fee_source_add_column('fin_fee_source', 'source_hash',
@@ -77,6 +91,8 @@ CALL finance_fee_source_add_column('fin_cost', 'source_fee_version',
"INT UNSIGNED NULL COMMENT '来源费用版本' AFTER source_fee_id");
CALL finance_fee_source_add_column('fin_cost', 'source_hash',
"CHAR(64) NULL COMMENT '来源不可变快照SHA-256' AFTER source_fee_version");
CALL finance_fee_source_add_column('fin_cost', 'warehouse_id',
"BIGINT UNSIGNED NULL COMMENT '业务直接已付成本关联仓库ID,可为空' AFTER container_id");
CALL finance_fee_source_add_column('fin_cost', 'paid_at',
"DATETIME(3) NULL COMMENT '直接已付时间' AFTER voucher_file_ids");
CALL finance_fee_source_add_column('fin_cost', 'payment_method',
@@ -86,6 +102,22 @@ CALL finance_fee_source_add_column('fin_cost', 'payment_reference_no',
DROP PROCEDURE IF EXISTS finance_fee_source_add_column;
SET @finance_fee_source_index_ddl = (
SELECT IF(
(SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema=DATABASE() AND table_name='fin_fee_source'
AND index_name='uk_fin_fee_source')=4,
'SELECT 1',
IF(EXISTS(SELECT 1 FROM information_schema.statistics
WHERE table_schema=DATABASE() AND table_name='fin_fee_source' AND index_name='uk_fin_fee_source'),
'ALTER TABLE fin_fee_source DROP INDEX uk_fin_fee_source, ADD UNIQUE KEY uk_fin_fee_source(source_system,source_type,source_fee_id,source_fee_version)',
'ALTER TABLE fin_fee_source ADD UNIQUE KEY uk_fin_fee_source(source_system,source_type,source_fee_id,source_fee_version)')
)
);
PREPARE finance_fee_source_index_stmt FROM @finance_fee_source_index_ddl;
EXECUTE finance_fee_source_index_stmt;
DEALLOCATE PREPARE finance_fee_source_index_stmt;
DROP PROCEDURE IF EXISTS finance_fee_source_add_index;
DELIMITER $$
CREATE PROCEDURE finance_fee_source_add_index()
@@ -104,7 +136,9 @@ CALL finance_fee_source_add_index();
DROP PROCEDURE IF EXISTS finance_fee_source_add_index;
ALTER TABLE fin_cost
MODIFY COLUMN cost_type VARCHAR(24) NOT NULL COMMENT '成本归属类型:SINGLE_ORDER单票、CONTAINER_SHARED整柜公共、SOURCE_DIRECT_PAID来源直接已付';
MODIFY COLUMN cost_type VARCHAR(24) NOT NULL COMMENT '成本归属类型:SINGLE_ORDER单票、CONTAINER_SHARED整柜公共、SOURCE_DIRECT_PAID来源直接已付',
MODIFY COLUMN cost_origin VARCHAR(24) NOT NULL DEFAULT 'MANUAL' COMMENT '成本来源:MANUAL人工补充、FIRSTLEG_DIRECT_PAID头程直接已付、BUSINESS_DIRECT_PAID其他业务直接已付',
MODIFY COLUMN source_hash VARCHAR(71) NULL COMMENT '来源不可变快照SHA-256,兼容sha256前缀格式';
DROP PROCEDURE IF EXISTS finance_fee_source_replace_cost_checks;
DELIMITER $$
CREATE PROCEDURE finance_fee_source_replace_cost_checks()
@@ -121,7 +155,7 @@ BEGIN
ADD CONSTRAINT chk_fin_cost_target CHECK (
(cost_type='SINGLE_ORDER' AND order_id IS NOT NULL AND container_id IS NULL)
OR (cost_type='CONTAINER_SHARED' AND container_id IS NOT NULL)
OR (cost_type='SOURCE_DIRECT_PAID' AND (order_id IS NOT NULL OR inbound_id IS NOT NULL OR container_id IS NOT NULL))
OR (cost_type='SOURCE_DIRECT_PAID' AND (order_id IS NOT NULL OR inbound_id IS NOT NULL OR container_id IS NOT NULL OR warehouse_id IS NOT NULL))
),
ADD CONSTRAINT chk_fin_cost_origin CHECK (
(cost_origin='MANUAL' AND source_system IS NULL AND source_type IS NULL AND source_fee_id IS NULL AND source_fee_version IS NULL AND source_hash IS NULL)
@@ -129,6 +163,10 @@ BEGIN
AND source_fee_id IS NOT NULL AND source_fee_version IS NOT NULL AND source_hash IS NOT NULL
AND status IN ('CONFIRMED','VOIDED') AND paid_at IS NOT NULL
AND payment_method IN ('ONLINE','BANK_TRANSFER','CASH','OTHER'))
OR (cost_origin='BUSINESS_DIRECT_PAID' AND source_system NOT IN ('', 'FIRSTLEG') AND source_type IS NOT NULL
AND source_fee_id IS NOT NULL AND source_fee_version IS NOT NULL AND source_hash IS NOT NULL
AND status IN ('CONFIRMED','VOIDED') AND paid_at IS NOT NULL
AND payment_method IN ('ONLINE','BANK_TRANSFER','CASH','OTHER'))
);
END$$
DELIMITER ;
@@ -152,12 +190,12 @@ ALTER TABLE fin_fee_source
MODIFY COLUMN source_type VARCHAR(32) NOT NULL COMMENT '来源费用类型:INBOUND_FEE入库单费用、CONTAINER_FEE柜子费用',
MODIFY COLUMN direction VARCHAR(16) NOT NULL COMMENT '费用方向:RECEIVABLE应收、PAYABLE应付',
MODIFY COLUMN fee_item_code VARCHAR(64) NOT NULL COMMENT '来源费用项目编码快照',
MODIFY COLUMN source_hash CHAR(64) NOT NULL COMMENT 'Firstleg费用来源完整快照SHA-256哈希',
MODIFY COLUMN source_snapshot JSON NOT NULL COMMENT 'Firstleg费用来源完整不可变JSON快照',
MODIFY COLUMN source_hash VARCHAR(71) NOT NULL COMMENT '业务费用来源完整快照SHA-256哈希,兼容sha256前缀格式',
MODIFY COLUMN source_snapshot JSON NOT NULL COMMENT '业务费用来源完整不可变JSON快照',
MODIFY COLUMN status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE' COMMENT '费用来源状态:AVAILABLE可结算、SUPERSEDED已被新版本替代、VOIDED已作废',
MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '来源状态最后更新时间,数据修改时自动刷新';
ALTER TABLE fin_inbox_event
MODIFY COLUMN payload_hash CHAR(64) NOT NULL COMMENT '规范化事件内容SHA-256哈希,用于同事件ID冲突检测',
MODIFY COLUMN payload_hash VARCHAR(71) NOT NULL COMMENT '规范化事件内容SHA-256哈希,兼容sha256前缀格式',
MODIFY COLUMN status VARCHAR(16) NOT NULL DEFAULT 'RECEIVED' COMMENT '事件处理状态:RECEIVED已接收、PROCESSED已完成、FAILED处理失败';
DROP PROCEDURE IF EXISTS finance_fee_source_assert_schema;
@@ -187,7 +225,7 @@ BEGIN
WHERE table_schema = DATABASE() AND table_name = 'fin_cost'
AND column_name IN ('cost_origin','source_system','source_type','source_fee_id',
'source_fee_version','source_hash','paid_at','payment_method',
'payment_reference_no')) <> 9 THEN
'payment_reference_no','warehouse_id')) <> 10 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance直接已付成本字段不完整';
END IF;
IF (SELECT COUNT(*) FROM information_schema.statistics
+13 -8
View File
@@ -30,7 +30,7 @@ CREATE TABLE IF NOT EXISTS fin_fee_source (
payment_method VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式:ONLINE、BANK_TRANSFER、CASH、OTHER',
payment_reference_no VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号',
payment_voucher_file_ids JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组',
source_hash CHAR(64) NOT NULL COMMENT 'Firstleg费用来源完整快照SHA-256哈希',
source_hash VARCHAR(71) NOT NULL COMMENT '业务费用来源完整快照SHA-256哈希,兼容sha256前缀格式',
source_snapshot JSON NOT NULL COMMENT 'Firstleg费用来源完整不可变JSON快照',
review_status VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT 'Finance费用状态:DRAFT草稿、PENDING待审核、APPROVED已通过、REJECTED已驳回',
review_reason VARCHAR(500) NULL COMMENT 'Finance费用审核说明或驳回原因',
@@ -61,7 +61,7 @@ CREATE TABLE IF NOT EXISTS fin_inbox_event (
source_system VARCHAR(32) NOT NULL COMMENT '事件来源系统标识',
event_type VARCHAR(64) NOT NULL COMMENT '来源事件类型',
payload JSON NOT NULL COMMENT '来源事件原始JSON载荷快照',
payload_hash CHAR(64) NOT NULL COMMENT '规范化事件内容SHA-256哈希,用于同事件ID冲突检测',
payload_hash VARCHAR(71) NOT NULL COMMENT '规范化事件内容SHA-256哈希,兼容sha256前缀格式',
result_resource_id BIGINT UNSIGNED NULL COMMENT '成功处理生成或定位的费用来源快照ID',
error_code VARCHAR(64) NULL COMMENT '处理失败时的稳定FIN错误码',
status VARCHAR(16) NOT NULL DEFAULT 'RECEIVED' COMMENT '事件处理状态:RECEIVED已接收、PROCESSED已完成、FAILED处理失败',
@@ -221,21 +221,22 @@ CREATE TABLE IF NOT EXISTS fin_cost (
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
cost_no VARCHAR(64) NOT NULL COMMENT '客户范围内唯一成本编号',
cost_type VARCHAR(24) NOT NULL COMMENT '成本归属类型:SINGLE_ORDER单票、CONTAINER_SHARED整柜公共、SOURCE_DIRECT_PAID来源直接已付',
cost_origin VARCHAR(24) NOT NULL DEFAULT 'MANUAL' COMMENT '成本来源:MANUAL人工补充、FIRSTLEG_DIRECT_PAID头程直接已付',
source_system VARCHAR(32) NULL COMMENT '来源系统,头程直接已付固定FIRSTLEG',
cost_origin VARCHAR(24) NOT NULL DEFAULT 'MANUAL' COMMENT '成本来源:MANUAL人工补充、FIRSTLEG_DIRECT_PAID头程直接已付、BUSINESS_DIRECT_PAID其他业务直接已付',
source_system VARCHAR(32) NULL COMMENT '直接已付来源系统,如FIRSTLEG或LASTLEG',
source_type VARCHAR(32) NULL COMMENT '来源费用类型快照',
source_fee_id BIGINT UNSIGNED NULL COMMENT '来源费用ID',
source_fee_version INT UNSIGNED NULL COMMENT '来源费用版本',
source_hash CHAR(64) NULL COMMENT '来源不可变快照SHA-256',
source_hash VARCHAR(71) NULL COMMENT '来源不可变快照SHA-256,兼容sha256前缀格式',
order_id BIGINT UNSIGNED NULL COMMENT '成本归属的头程订单ID,单票成本必填',
inbound_id BIGINT UNSIGNED NULL COMMENT '成本关联的入库单ID,可为空',
container_id BIGINT UNSIGNED NULL COMMENT 'Firstleg装柜计划ID,整柜公共成本必填',
warehouse_id BIGINT UNSIGNED NULL COMMENT '业务直接已付成本关联仓库ID,可为空',
supplier_id BIGINT UNSIGNED NULL COMMENT 'Warehouse公共往来单位或供应商引用ID',
supplier_name_snapshot VARCHAR(200) NULL COMMENT '登记成本时供应商或成本对象名称快照',
fee_item_code VARCHAR(64) NOT NULL COMMENT '成本费用项目编码快照',
fee_item_name VARCHAR(200) NOT NULL COMMENT '成本费用项目名称快照',
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
cost_amount DECIMAL(20,6) NOT NULL COMMENT '成本总金额;仅FIRSTLEG_DIRECT_PAID且CONFIRMED计入已线下支付供应商款',
cost_amount DECIMAL(20,6) NOT NULL COMMENT '成本总金额;来源直接已付且CONFIRMED计入已线下支付供应商款',
voucher_file_ids JSON NOT NULL COMMENT 'File服务财务凭证文件ID字符串数组JSON',
paid_at DATETIME(3) NULL COMMENT '直接已付时间;人工补充成本可为空',
payment_method VARCHAR(32) NULL COMMENT '直接已付方式:ONLINE、BANK_TRANSFER、CASH、OTHER',
@@ -265,7 +266,7 @@ CREATE TABLE IF NOT EXISTS fin_cost (
CONSTRAINT chk_fin_cost_target CHECK (
(cost_type = 'SINGLE_ORDER' AND order_id IS NOT NULL AND container_id IS NULL)
OR (cost_type = 'CONTAINER_SHARED' AND container_id IS NOT NULL)
OR (cost_type = 'SOURCE_DIRECT_PAID' AND (order_id IS NOT NULL OR inbound_id IS NOT NULL OR container_id IS NOT NULL))
OR (cost_type = 'SOURCE_DIRECT_PAID' AND (order_id IS NOT NULL OR inbound_id IS NOT NULL OR container_id IS NOT NULL OR warehouse_id IS NOT NULL))
),
CONSTRAINT chk_fin_cost_origin CHECK (
(cost_origin='MANUAL' AND source_system IS NULL AND source_type IS NULL AND source_fee_id IS NULL AND source_fee_version IS NULL AND source_hash IS NULL)
@@ -273,8 +274,12 @@ CREATE TABLE IF NOT EXISTS fin_cost (
AND source_fee_id IS NOT NULL AND source_fee_version IS NOT NULL AND source_hash IS NOT NULL
AND status IN ('CONFIRMED','VOIDED') AND paid_at IS NOT NULL
AND payment_method IN ('ONLINE','BANK_TRANSFER','CASH','OTHER'))
OR (cost_origin='BUSINESS_DIRECT_PAID' AND source_system NOT IN ('', 'FIRSTLEG') AND source_type IS NOT NULL
AND source_fee_id IS NOT NULL AND source_fee_version IS NOT NULL AND source_hash IS NOT NULL
AND status IN ('CONFIRMED','VOIDED') AND paid_at IS NOT NULL
AND payment_method IN ('ONLINE','BANK_TRANSFER','CASH','OTHER'))
)
) ENGINE=InnoDB COMMENT='成本事实;人工补充成本与头程直接已付来源成本统一留痕';
) ENGINE=InnoDB COMMENT='成本事实;人工补充成本与业务直接已付来源成本统一留痕';
CREATE TABLE IF NOT EXISTS fin_cost_allocation (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
@@ -0,0 +1,83 @@
package com.baiyue.finance;
import com.baiyue.common.event.BusinessFeeSourceChangedV1;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.RegionCode;
import com.baiyue.finance.service.BusinessFeeSourceEventService;
import com.baiyue.finance.service.FinanceSupplierValidator;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.jdbc.core.JdbcTemplate;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.startsWith;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class BusinessFeeSourceEventServiceTest {
private final JdbcTemplate jdbc = mock(JdbcTemplate.class);
private final ObjectMapper json = new ObjectMapper().findAndRegisterModules();
private final FinanceSupplierValidator suppliers = mock(FinanceSupplierValidator.class);
private final BusinessFeeSourceEventService service = new BusinessFeeSourceEventService(jdbc, json, suppliers);
@Test
void importsOverseasFeeWithoutInventingInboundReference() {
EventEnvelope event = eventWithoutInbound();
when(jdbc.queryForList(startsWith("SELECT payload_hash,status,result_resource_id"),
eq("LASTLEG"), eq(event.eventId()))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT id,source_hash,status FROM fin_fee_source"),
eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L), eq(1))).thenReturn(List.of());
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source f WHERE f.source_system=?"),
eq(Integer.class), eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L))).thenReturn(0);
when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class),
eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L), eq(1))).thenReturn(901L);
assertThat(service.consume(event)).isEqualTo(901L);
ArgumentCaptor<Object[]> values = ArgumentCaptor.forClass(Object[].class);
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), values.capture());
assertThat(values.getValue()[3]).isEqualTo(10001L);
assertThat(values.getValue()[5]).isEqualTo("PO-US-001");
assertThat(values.getValue()[9]).isNull();
assertThat(values.getValue()[13]).isNull();
}
private EventEnvelope eventWithoutInbound() {
BusinessFeeSourceChangedV1 unsigned = new BusinessFeeSourceChangedV1(null, "LASTLEG",
"OVERSEAS_PROCUREMENT_FEE", "701", 1, "10001", "客户A", "PO-US-001",
"OVERSEAS_PROCUREMENT", "801", "PO-US-001", null, null, null, null, null,
null, "91", "PROCUREMENT_SERVICE", "采购服务费", "RECEIVABLE", null, null,
null, null, null, null, null, List.of(), LocalDate.of(2026, 9, 2), "CNY",
new BigDecimal("120.00"), "APPROVED", "PUBLISHED", "ACTIVE", null);
String sourceHash = EventPayloadHash.sha256(
EventPayloadCanonicalizer.canonicalize(json.valueToTree(unsigned)));
BusinessFeeSourceChangedV1 payload = new BusinessFeeSourceChangedV1("PUBLISHED", "LASTLEG",
"OVERSEAS_PROCUREMENT_FEE", "701", 1, "10001", "客户A", "PO-US-001",
"OVERSEAS_PROCUREMENT", "801", "PO-US-001", null, null, null, null, null,
null, "91", "PROCUREMENT_SERVICE", "采购服务费", "RECEIVABLE", null, null,
null, null, null, null, null, List.of(), LocalDate.of(2026, 9, 2), "CNY",
new BigDecimal("120.00"), "APPROVED", "PUBLISHED", "ACTIVE", sourceHash);
JsonNode node = json.valueToTree(payload);
String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
return new EventEnvelope("123e4567-e89b-12d3-a456-426614174701",
EventTypes.BUSINESS_FEE_SOURCE_CHANGED, 1, Instant.parse("2026-09-02T08:00:00Z"),
RegionCode.OVERSEAS, RegionCode.CN, "10001", "BUSINESS_FEE", "701", 1,
null, "123e4567-e89b-12d3-a456-426614174702", "LASTLEG:FEE:701:1", null,
node, payloadHash);
}
}
@@ -85,7 +85,7 @@ class CustomerAccountSummaryBehaviorTest {
.contains("FROM fin_settlement s WHERE s.tenant_id=?")
.contains("s.direction='RECEIVABLE' AND s.status<>'VOIDED'")
.contains("os.direction='PAYABLE' AND os.status<>'VOIDED'")
.contains("f.settlement_mode='ON_ACCOUNT'", "c.cost_origin='FIRSTLEG_DIRECT_PAID'", "ps.paid_amount")
.contains("f.settlement_mode='ON_ACCOUNT'", "c.cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID')", "ps.paid_amount")
.contains("f.customer_id=?", "f.status='AVAILABLE'", "f.review_status='APPROVED'", "f.amount>0")
.contains("b.status<>'VOIDED'", "FROM fin_settlement_line sl", "sl.fee_source_id=f.id", "aps.status<>'VOIDED'")
.doesNotContain("JSON_EXTRACT")
@@ -59,7 +59,7 @@ class FinanceCustomerAccountServiceTest {
String all = String.join("\n", sql.getAllValues());
assertThat(all)
.contains("SUM(outstanding_amount) amount FROM fin_settlement", "direction='RECEIVABLE'")
.contains("cost_origin='FIRSTLEG_DIRECT_PAID' AND status='CONFIRMED'")
.contains("cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID') AND status='CONFIRMED'")
.contains("direction='PAYABLE' AND status<>'VOIDED'")
.contains("NOT EXISTS (SELECT 1 FROM fin_settlement_line")
.contains("sl.fee_source_id=f.id", "s.status<>'VOIDED'")
@@ -61,7 +61,7 @@ class FinanceFeeSourceEventServiceTest {
assertThat(service.consume(event)).isEqualTo(101L);
verify(firstleg).container("test-token", 41, 31, 1);
verify(jdbc).update(contains("status='SUPERSEDED'"), eq(31L));
verify(jdbc).update(contains("status='SUPERSEDED'"), eq("CONTAINER_FEE"), eq(31L));
}
@Test
@@ -143,7 +143,7 @@ class FinanceFeeSourceEventServiceTest {
when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(2))).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null)));
assertThat(service.consume(event)).isEqualTo(101L);
verify(jdbc).update(contains("status='SUPERSEDED'"), eq(31L));
verify(jdbc).update(contains("status='SUPERSEDED'"), eq("INBOUND_FEE"), eq(31L));
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
}
@@ -154,7 +154,7 @@ class FinanceFeeSourceEventServiceTest {
when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(2)))
.thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null)));
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source f WHERE f.source_system='FIRSTLEG'"),
eq(Integer.class), eq(31L))).thenReturn(1);
eq(Integer.class), anyString(), eq(31L))).thenReturn(1);
assertThatThrownBy(() -> service.consume(event))
.isInstanceOf(FinanceEventException.class)
@@ -168,7 +168,7 @@ class FinanceFeeSourceEventServiceTest {
FinanceFeeSourceEvent event = inboundEvent("voided-2", "FIRSTLEG_FEE_VOIDED", 2, 7);
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("voided-2"))).thenReturn(List.of());
when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(2))).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null)));
when(jdbc.queryForList(startsWith("SELECT id,source_hash,status"), eq(31L), eq(2)))
when(jdbc.queryForList(startsWith("SELECT id,source_hash,status"), anyString(), eq(31L), eq(2)))
.thenReturn(List.of(Map.of("id", 88L, "source_hash", "source-hash", "status", "AVAILABLE")));
assertThat(service.consume(event)).isEqualTo(88L);
@@ -183,7 +183,7 @@ class FinanceFeeSourceEventServiceTest {
.thenReturn(List.of());
when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(2)))
.thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null)));
when(jdbc.queryForList(startsWith("SELECT id,source_hash,status"), eq(31L), eq(2)))
when(jdbc.queryForList(startsWith("SELECT id,source_hash,status"), anyString(), eq(31L), eq(2)))
.thenReturn(List.of(Map.of("id", 88L, "source_hash", "source-hash", "status", "AVAILABLE")));
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source f WHERE f.id=?"),
eq(Integer.class), eq(88L))).thenReturn(1);
@@ -253,8 +253,8 @@ class FinanceFeeSourceEventServiceTest {
private void givenNewEvent() {
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), anyString())).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT id,source_hash,status"), eq(31L), anyInt())).thenReturn(List.of());
when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class), eq(31L), anyInt())).thenReturn(101L);
when(jdbc.queryForList(startsWith("SELECT id,source_hash,status"), anyString(), eq(31L), anyInt())).thenReturn(List.of());
when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class), anyString(), eq(31L), anyInt())).thenReturn(101L);
}
private FinanceFeeSourceEvent inboundEvent(String id, String type, int version, long tenant) {
@@ -16,7 +16,7 @@ class FinanceFeeSourceMigrationContractTest {
"source_hash", "source_snapshot", "payload_hash", "result_resource_id", "error_code");
assertThat(sql).contains("settlement_mode", "paid_at", "payment_method",
"payment_reference_no", "payment_voucher_file_ids");
assertThat(sql).contains("cost_origin", "FIRSTLEG_DIRECT_PAID", "source_fee_version",
assertThat(sql).contains("cost_origin", "FIRSTLEG_DIRECT_PAID", "BUSINESS_DIRECT_PAID", "warehouse_id", "source_fee_version",
"uk_fin_cost_source_version", "Finance直接已付成本字段不完整");
assertThat(sql).contains("AVAILABLE可结算", "SUPERSEDED已被新版本替代", "VOIDED已作废");
assertThat(sql).contains("finance_fee_source_assert_schema");
@@ -107,7 +107,7 @@ class FinancePhase1OperationBehaviorTest {
assertThat(result.traceId()).isNotBlank();
assertThat(jdbc.accountListSql).contains("p.customer_name_snapshot");
assertThat(jdbc.accountListSql)
.contains("ar.direction='RECEIVABLE'", "cost_origin='FIRSTLEG_DIRECT_PAID'", "FROM fin_settlement_line sl")
.contains("ar.direction='RECEIVABLE'", "cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID')", "FROM fin_settlement_line sl")
.contains("sl.fee_source_id=f.id", "s.status<>'VOIDED'");
assertThat(jdbc.accountListSql).doesNotContain("CAST(c.customer_id AS CHAR)");
}
@@ -123,7 +123,7 @@ class FinancePhase1OperationBehaviorTest {
assertThat(result.data()).isEmpty();
assertThat(jdbc.ledgerCustomerIds).containsExactly(9L, 9L, 9L, 9L);
assertThat(jdbc.ledgerSql).anySatisfy(sql -> assertThat(sql)
.contains("cost_origin='FIRSTLEG_DIRECT_PAID'", "status='CONFIRMED'"));
.contains("cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID')", "status='CONFIRMED'"));
assertThat(jdbc.ledgerSql).anySatisfy(sql -> assertThat(sql)
.contains("fin_settlement_payment", "transaction_type='PAYMENT'", "s.direction='PAYABLE'"));
}
@@ -240,9 +240,9 @@ class FinancePhase1OperationBehaviorTest {
var request = new FinancePhase1Dtos.FeeSourceEditRequest(
new BigDecimal("120.00"), "更正金额", 1);
assertThatThrownBy(() -> service.editFinanceFeeSource(88L, 71L, "edit-key", request, false))
.isInstanceOf(FinanceEventException.class).hasMessageContaining("Firstleg费用必须");
.isInstanceOf(FinanceEventException.class).hasMessageContaining("业务费用必须回到对应业务费用明细调整");
assertThatThrownBy(() -> service.editFinanceFeeSource(88L, 71L, "adjust-key", request, true))
.isInstanceOf(FinanceEventException.class).hasMessageContaining("Firstleg费用必须");
.isInstanceOf(FinanceEventException.class).hasMessageContaining("业务费用必须回到对应业务费用明细调整");
verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source SET amount="),
(Object[]) any());
}
@@ -44,7 +44,7 @@ class FinanceSqlCommentContractTest {
}
}
assertThat(tableCount).isEqualTo(17);
assertThat(columnCount).isEqualTo(257);
assertThat(columnCount).isEqualTo(265);
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
}
@@ -56,7 +56,7 @@ class FinanceSqlCommentContractTest {
assertThat(sql).contains("RECEIVED已接收、PROCESSED已完成、FAILED处理失败");
assertThat(sql).contains("Firstleg费用来源完整不可变JSON快照");
assertThat(sql).contains("ON_ACCOUNT账期应付、DIRECT_PAID业务已付");
assertThat(sql).contains("仅FIRSTLEG_DIRECT_PAID且CONFIRMED计入已线下支付供应商款");
assertThat(sql).contains("来源直接已付且CONFIRMED计入已线下支付供应商款");
assertThat(sql).contains("RECEIVABLE计待收,PAYABLE与未制单ON_ACCOUNT来源共同计待付");
assertThat(sql).contains("规范化完整请求的SHA-256十六进制哈希");
}
+1 -1
View File
@@ -28,7 +28,7 @@
| 费用项目与价格簿 | `controller/PlatformFeeItemController.java`、`PlatformPriceBookController.java`、`MerchantPriceBookController.java` |
| 价格簿查询内部切片 | `pricebookquery/application/port/in/PriceBookQueryUseCase.java`、`application/PriceBookQueryService.java`、`application/port/out/PriceBookQueryReader.java`、`adapter/out/persistence/PriceBookQueryMapper.java`;查询行模型位于 `application/model/PriceBookQueryRows.java` |
| 财务费用来源快照 | `controller/InternalFinanceFeeSourceController.java`;入库费用与柜次费用分别按来源 ID、版本向 Finance 提供不可变快照 |
| 柜次费用明细 | `controller/PlatformContainerFeeDetailController.java`、`service/ContainerFeeDetailService.java`、`dto/ContainerFeeDetailDtos.java`;Admin 消费端见 `../../apps/admin-web/src/modules/loading-lists/api/containerFees.ts` |
| 柜次费用明细 | `controller/PlatformContainerFeeDetailController.java`、`service/ContainerFeeDetailService.java`、`dto/ContainerFeeDetailDtos.java`;跨客户公共柜费使用 `allocation-groups` 按真实客户和订单原子分摊,入仓ID/入仓码同时填写或同时留空;契约 `src/main/resources/openapi/container-fees-v1.yaml`;Admin 消费端见 `../../apps/admin-web/src/modules/loading-lists/api/containerFees.ts` |
| 财务整柜成本分摊快照 | `loading/controller/InternalFinanceCostSnapshotController.java`、`loading/service/FinanceCostSnapshotService.java` |
| 分柜、预装、真实柜号后补与正式装柜清单生命周期 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java`、`service/LoadingListService.java`;契约 `src/main/resources/openapi/loading-lists-v1.yaml`,详情阶段先查 `/loading-plans/{planId}/lifecycle`。创建柜次时原子生成唯一预装计划,加入入仓码明细时同步创建入库预装聚合与关联,不设推送步骤;同一 `planId` 在 `EDITING/LOADING` 返回 `PRELOAD`,`COMPLETED` 返回 `LOADING_LIST`。订舱关联、预装确认和 PDA task 创建不依赖真实集装箱号;可在 `EDITING/LOADING` 首次补录,最终 PDA 完成前必须存在;已有值纠正仅限 `EDITING` 且任务未领取、无物理作业事实 |
| 同柜不可拆组 | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`;契约 `src/main/resources/openapi/coload-groups-v1.yaml`。请求按订单 ID 建组,服务端冻结每票全部 `WAIT_SPLIT` 可用入仓片段与满箱数;仅要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均保持整组原子 |
@@ -51,6 +51,7 @@ public class PlatformContainerFeeDetailController {
public ApiResponse<List<ContainerFeeDetailEntity>> createAllocationGroup(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long loadingPlanId,
@Valid @RequestBody AllocationGroupRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.createAllocationGroup(loadingPlanId, user.userId(), request));
}
@@ -59,6 +60,7 @@ public class PlatformContainerFeeDetailController {
public ApiResponse<List<ContainerFeeDetailEntity>> approveAllocationGroup(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long loadingPlanId,
@PathVariable long groupId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.approveAllocationGroup(loadingPlanId, groupId, user.userId(), user.username()));
}
@@ -67,6 +69,7 @@ public class PlatformContainerFeeDetailController {
public ApiResponse<List<ContainerFeeDetailEntity>> rejectAllocationGroup(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long loadingPlanId,
@PathVariable long groupId, @Valid @RequestBody RejectRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.rejectAllocationGroup(loadingPlanId, groupId, user.userId(), user.username(), request.reason()));
}
@@ -75,6 +78,7 @@ public class PlatformContainerFeeDetailController {
public ApiResponse<List<ContainerFeeDetailEntity>> adjustAllocationGroup(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long loadingPlanId,
@PathVariable long groupId, @Valid @RequestBody AllocationGroupAdjustRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.adjustAllocationGroup(loadingPlanId, groupId, user.userId(), request));
}
@@ -83,6 +87,7 @@ public class PlatformContainerFeeDetailController {
public ApiResponse<Void> voidAllocationGroup(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long loadingPlanId,
@PathVariable long groupId, @Valid @RequestBody VersionRequest request) {
FirstlegScopeGuard.platform(user);
service.voidAllocationGroup(loadingPlanId, groupId, request.expectedSourceVersion(), user.userId());
return ApiResponse.success(null);
}
@@ -47,8 +47,10 @@ public final class ContainerFeeDetailDtos {
public record Allocation(
@NotNull @Min(1) Long customerId,
@NotNull @Min(1) Long inboundId,
@NotBlank @Size(max = 64) String inboundCode,
@NotNull @Min(1) Long orderId,
@NotBlank @Size(max = 64) String orderNo,
@Min(1) Long inboundId,
@Size(max = 64) String inboundCode,
@NotNull @DecimalMin(value = "0.00", inclusive = false) BigDecimal amount
) { }
@@ -22,6 +22,8 @@ public class ContainerFeeDetailEntity {
private Long id;
private Long tenantId;
private Long allocationGroupId;
private Long allocationOrderId;
private String allocationOrderNo;
private Long allocationInboundId;
private String allocationInboundCode;
private BigDecimal allocationGroupAmount;
@@ -24,6 +24,8 @@ public class ContainerFeeSourceSnapshotEntity {
private Integer sourceVersion;
private Long tenantId;
private Long allocationGroupId;
private Long allocationOrderId;
private String allocationOrderNo;
private Long allocationInboundId;
private String allocationInboundCode;
private BigDecimal allocationGroupAmount;
@@ -20,6 +20,8 @@ public class ContainerFeeSourceSnapshotRepository {
value.setSourceVersion(source.getSourceVersion());
value.setTenantId(source.getTenantId());
value.setAllocationGroupId(source.getAllocationGroupId());
value.setAllocationOrderId(source.getAllocationOrderId());
value.setAllocationOrderNo(source.getAllocationOrderNo());
value.setAllocationInboundId(source.getAllocationInboundId());
value.setAllocationInboundCode(source.getAllocationInboundCode());
value.setAllocationGroupAmount(source.getAllocationGroupAmount());
@@ -52,12 +52,15 @@ public class ContainerFeeDetailService {
PlanContext plan = requireOwnedPlan(tenantId, loadingPlanId);
List<InboundAllocationKey> owned = loading.items(loadingPlanId).stream()
.filter(item -> Long.valueOf(tenantId).equals(item.getAccountId()))
.map(item -> new InboundAllocationKey(item.getInboundId(), item.getInboundCode()))
.map(item -> new InboundAllocationKey(item.getOrderId(), text(item.getOrderNo()),
item.getInboundId(), text(item.getInboundCode())))
.distinct().toList();
if (owned.size() != 1) throw conflict("柜次包含多个入仓码,必须使用柜级费用分摊录入");
if (owned.size() != 1) throw conflict("柜次包含多个业务归属,必须使用柜级费用分摊录入");
FeeItemEntity item = requireItem(request.feeItemId(), request.direction());
ContainerFeeDetailEntity value = new ContainerFeeDetailEntity();
value.setTenantId(tenantId);
value.setAllocationOrderId(owned.get(0).orderId());
value.setAllocationOrderNo(owned.get(0).orderNo());
value.setAllocationInboundId(owned.get(0).inboundId());
value.setAllocationInboundCode(owned.get(0).inboundCode());
value.setAllocationGroupAmount(request.amount().setScale(2, RoundingMode.HALF_UP));
@@ -86,19 +89,25 @@ public class ContainerFeeDetailService {
if (allocated.compareTo(total) != 0) throw conflict("柜级费用分摊合计必须等于费用总额");
Map<InboundAllocationKey, Long> ownership = new HashMap<>();
loading.items(loadingPlanId).forEach(value -> ownership.put(
new InboundAllocationKey(value.getInboundId(), value.getInboundCode()), value.getAccountId()));
new InboundAllocationKey(value.getOrderId(), text(value.getOrderNo()), value.getInboundId(),
text(value.getInboundCode())), value.getAccountId()));
Set<InboundAllocationKey> unique = new HashSet<>();
long groupId = IdWorker.getId();
List<ContainerFeeDetailEntity> rows = new java.util.ArrayList<>();
for (Allocation allocation : request.allocations()) {
InboundAllocationKey key = new InboundAllocationKey(allocation.inboundId(), allocation.inboundCode().trim());
if (!unique.add(key)) throw conflict("同一入仓码不得重复分摊");
String inboundCode = text(allocation.inboundCode());
if ((allocation.inboundId() == null) != (inboundCode == null))
throw new BizException(ResultCode.VALIDATION_FAILED, "入仓ID与入仓码必须同时填写或同时留空");
InboundAllocationKey key = new InboundAllocationKey(allocation.orderId(), allocation.orderNo().trim(),
allocation.inboundId(), inboundCode);
if (!unique.add(key)) throw conflict("同一业务归属不得重复分摊");
if (!allocation.customerId().equals(ownership.get(key)))
throw conflict("分摊客户、入仓码与柜内真实归属不一致");
throw conflict("分摊客户、订单及入仓信息与柜内真实归属不一致");
ContainerFeeDetailEntity value = new ContainerFeeDetailEntity();
value.setId(IdWorker.getId()); value.setTenantId(allocation.customerId());
value.setAllocationGroupId(groupId); value.setAllocationInboundId(allocation.inboundId());
value.setAllocationInboundCode(allocation.inboundCode().trim()); value.setAllocationGroupAmount(total);
value.setAllocationGroupId(groupId); value.setAllocationOrderId(allocation.orderId());
value.setAllocationOrderNo(allocation.orderNo().trim()); value.setAllocationInboundId(allocation.inboundId());
value.setAllocationInboundCode(inboundCode); value.setAllocationGroupAmount(total);
value.setLoadingPlanId(loadingPlanId); value.setContainerBookingId(plan.plan().getContainerBookingId());
value.setContainerNo(plan.booking() == null ? null : plan.booking().getContainerNo());
value.setSourceVersion(1); value.setSourceStatus("ACTIVE"); value.setReviewStatus("PENDING");
@@ -359,5 +368,7 @@ public class ContainerFeeDetailService {
private record PlanContext(LoadingPlanEntity plan, ContainerBookingEntity booking) {
}
private record InboundAllocationKey(Long inboundId, String inboundCode) { }
private static String text(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private record InboundAllocationKey(Long orderId, String orderNo, Long inboundId, String inboundCode) { }
}
@@ -70,12 +70,13 @@ public class FinanceFeeSourceSnapshotService {
nullSafe(detail.getSettlementMode()), nullSafe(detail.getPaidAt()),
nullSafe(detail.getPaymentMethod()), nullSafe(detail.getPaymentReferenceNo()),
nullSafe(detail.getPaymentVoucherFileIds()),
nullSafe(detail.getAllocationGroupId()), nullSafe(detail.getAllocationInboundId()),
nullSafe(detail.getAllocationGroupId()), nullSafe(detail.getAllocationOrderId()),
nullSafe(detail.getAllocationOrderNo()), nullSafe(detail.getAllocationInboundId()),
nullSafe(detail.getAllocationInboundCode()), nullSafe(detail.getAllocationGroupAmount()),
detail.getAmount().toPlainString(), nullSafe(detail.getCurrency()),
nullSafe(detail.getReviewStatus()));
return new FinanceFeeSourceSnapshot(detail.getTenantId(), "CONTAINER_FEE", feeId,
version, detail.getAllocationInboundId(), detail.getAllocationInboundCode(), null, containerId, detail.getContainerBookingId(),
version, detail.getAllocationInboundId(), detail.getAllocationInboundCode(), detail.getAllocationOrderId(), containerId, detail.getContainerBookingId(),
detail.getContainerNo(), detail.getTenantId(), null, detail.getSupplierId(),
detail.getSupplierName(), detail.getSupplierCategory(), detail.getSettlementMode(),
detail.getPaidAt(), detail.getPaymentMethod(), detail.getPaymentReferenceNo(),
@@ -65,6 +65,8 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_detail (
id BIGINT UNSIGNED NOT NULL COMMENT '柜次费用来源ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
allocation_group_id BIGINT UNSIGNED NULL COMMENT '柜级公共费用分摊组ID;单入仓直接费用为空',
allocation_order_id BIGINT UNSIGNED NULL COMMENT '本分摊行业务订单ID',
allocation_order_no VARCHAR(64) NULL COMMENT '本分摊行业务订单号',
allocation_inbound_id BIGINT UNSIGNED NULL COMMENT '本分摊行对应入仓单ID',
allocation_inbound_code VARCHAR(64) NULL COMMENT '本分摊行对应入仓码',
allocation_group_amount DECIMAL(18,2) NULL COMMENT '柜级原始费用总额快照',
@@ -123,6 +125,8 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_source_snapshot (
source_version INT UNSIGNED NOT NULL COMMENT '费用来源版本',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
allocation_group_id BIGINT UNSIGNED NULL COMMENT '柜级公共费用分摊组ID',
allocation_order_id BIGINT UNSIGNED NULL COMMENT '分摊业务订单ID快照',
allocation_order_no VARCHAR(64) NULL COMMENT '分摊业务订单号快照',
allocation_inbound_id BIGINT UNSIGNED NULL COMMENT '分摊入仓单ID快照',
allocation_inbound_code VARCHAR(64) NULL COMMENT '分摊入仓码快照',
allocation_group_amount DECIMAL(18,2) NULL COMMENT '柜级原始费用总额快照',
@@ -196,8 +200,12 @@ CALL firstleg_finance_add_column('fl_container_fee_detail', 'payment_voucher_fil
"JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组' AFTER payment_reference_no");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'allocation_group_id',
"BIGINT UNSIGNED NULL COMMENT '柜级公共费用分摊组ID;单入仓直接费用为空' AFTER tenant_id");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'allocation_order_id',
"BIGINT UNSIGNED NULL COMMENT '本分摊行业务订单ID' AFTER allocation_group_id");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'allocation_order_no',
"VARCHAR(64) NULL COMMENT '本分摊行业务订单号' AFTER allocation_order_id");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'allocation_inbound_id',
"BIGINT UNSIGNED NULL COMMENT '本分摊行对应入仓单ID' AFTER allocation_group_id");
"BIGINT UNSIGNED NULL COMMENT '本分摊行对应入仓单ID' AFTER allocation_order_no");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'allocation_inbound_code',
"VARCHAR(64) NULL COMMENT '本分摊行对应入仓码' AFTER allocation_inbound_id");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'allocation_group_amount',
@@ -214,8 +222,12 @@ CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'payment_vo
"JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组' AFTER payment_reference_no");
CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'allocation_group_id',
"BIGINT UNSIGNED NULL COMMENT '柜级公共费用分摊组ID' AFTER tenant_id");
CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'allocation_order_id',
"BIGINT UNSIGNED NULL COMMENT '分摊业务订单ID快照' AFTER allocation_group_id");
CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'allocation_order_no',
"VARCHAR(64) NULL COMMENT '分摊业务订单号快照' AFTER allocation_order_id");
CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'allocation_inbound_id',
"BIGINT UNSIGNED NULL COMMENT '分摊入仓单ID快照' AFTER allocation_group_id");
"BIGINT UNSIGNED NULL COMMENT '分摊入仓单ID快照' AFTER allocation_order_no");
CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'allocation_inbound_code',
"VARCHAR(64) NULL COMMENT '分摊入仓码快照' AFTER allocation_inbound_id");
CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'allocation_group_amount',
@@ -834,8 +834,8 @@ CREATE TABLE IF NOT EXISTS fl_order_declaration_snapshot (
cargoes_json JSON NOT NULL COMMENT '客户申报货物行完整快照,不覆盖实测事实',
changes_json JSON NULL COMMENT '本版本申报字段变化摘要',
occurred_at DATETIME(3) NOT NULL COMMENT 'Order事件发生时间',
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),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '快照创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '快照更新时间',
PRIMARY KEY (tenant_id, order_id),
UNIQUE KEY uk_order_declaration_event (tenant_id, event_id),
KEY idx_order_declaration_version (tenant_id, order_id, order_version)
@@ -847,7 +847,7 @@ CREATE TABLE IF NOT EXISTS fl_order_declaration_event (
order_id BIGINT UNSIGNED NOT NULL COMMENT 'Order订单ID',
order_version INT NOT NULL COMMENT '事件携带的Order聚合版本',
request_hash CHAR(64) NOT NULL COMMENT '完整事件SHA-256,用于异参重放拒绝',
consumed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
consumed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事件消费完成时间',
PRIMARY KEY (tenant_id, event_id),
KEY idx_order_declaration_event_order (tenant_id, order_id, order_version)
) ENGINE=InnoDB COMMENT='Order客户申报事件永久幂等账本';
@@ -5,16 +5,16 @@ CREATE TABLE IF NOT EXISTS fl_customs_container (
id BIGINT UNSIGNED NOT NULL COMMENT '整柜报关聚合ID',
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '已完成装柜计划ID;报关柜级聚合唯一键',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '物理货柜ID',
status VARCHAR(40) NOT NULL COMMENT '报关状态:WAITING_CUSTOMER_DOCUMENTS待客户资料、READY_TO_START待开始、DECLARING报关中、COMPLETED已完成',
status VARCHAR(40) NOT NULL COMMENT '报关状态:WAITING_CUSTOMER_DOCUMENTS待资料、READY_TO_START可开始、DECLARING申报中、COMPLETED已完成',
started_by BIGINT UNSIGNED NULL COMMENT '开始报关操作人ID',
started_by_name VARCHAR(64) NULL COMMENT '开始报关操作人姓名快照',
started_by_name VARCHAR(64) NULL COMMENT '开始报关操作人名称快照',
started_at DATETIME(3) NULL COMMENT '开始报关时间',
completed_by BIGINT UNSIGNED NULL COMMENT '完成报关操作人ID',
completed_by_name VARCHAR(64) NULL COMMENT '完成报关操作人姓名快照',
completed_at DATETIME(3) NULL COMMENT '完成报关时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '报关聚合创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '报关聚合更新时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '报关聚合乐观锁版本',
completed_by_name VARCHAR(64) NULL COMMENT '完成报关操作人名称快照',
completed_at DATETIME(3) NULL COMMENT '报关完成时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '聚合创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '聚合更新时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY(id),
UNIQUE KEY uk_customs_container_plan(loading_plan_id),
UNIQUE KEY uk_customs_container_booking(container_booking_id),
@@ -24,24 +24,24 @@ CREATE TABLE IF NOT EXISTS fl_customs_container (
) ENGINE=InnoDB COMMENT='装柜完成后的整柜出口报关聚合';
CREATE TABLE IF NOT EXISTS fl_customs_customer_document (
id BIGINT UNSIGNED NOT NULL COMMENT '客户报关资料ID',
id BIGINT UNSIGNED NOT NULL COMMENT '客户报送资料ID',
customs_container_id BIGINT UNSIGNED NOT NULL COMMENT '所属整柜报关聚合ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键;只允许关联同客户退税票',
file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID',
file_name VARCHAR(255) NOT NULL COMMENT '客户资料文件名快照',
file_hash CHAR(64) NOT NULL COMMENT '客户资料文件SHA-256摘要',
content_type VARCHAR(128) NULL COMMENT '客户资料文件内容类型',
size_bytes BIGINT UNSIGNED NOT NULL COMMENT '客户资料文件大小(字节)',
file_name VARCHAR(255) NOT NULL COMMENT '文件名称快照',
file_hash CHAR(64) NOT NULL COMMENT '文件SHA-256摘要',
content_type VARCHAR(128) NULL COMMENT '文件媒体类型',
size_bytes BIGINT UNSIGNED NOT NULL COMMENT '文件大小(字节)',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '资料状态:ACTIVE有效、DELETED已删除',
uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID',
uploaded_at DATETIME(3) NOT NULL COMMENT '上传时间',
frozen_version INT UNSIGNED NULL COMMENT '开始报关时冻结的资料版本',
frozen_at DATETIME(3) NULL COMMENT '资料冻结时间',
deleted_by BIGINT UNSIGNED NULL COMMENT '删除操作人ID',
deleted_at DATETIME(3) NULL COMMENT '逻辑删除时间',
version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '客户资料当前版本',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '客户资料创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '客户资料更新时间',
deleted_at DATETIME(3) NULL COMMENT '删除时间',
version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '资料版本号',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '资料创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '资料更新时间',
PRIMARY KEY(id),
KEY idx_customs_customer_document_container(customs_container_id,status,tenant_id),
CONSTRAINT fk_customs_customer_document_container FOREIGN KEY(customs_container_id) REFERENCES fl_customs_container(id),
@@ -51,30 +51,30 @@ CREATE TABLE IF NOT EXISTS fl_customs_customer_document (
) ENGINE=InnoDB COMMENT='客户整份退税报送资料逻辑根';
CREATE TABLE IF NOT EXISTS fl_customs_customer_document_inbound (
document_id BIGINT UNSIGNED NOT NULL COMMENT '客户报关资料ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '覆盖的入仓单ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '入仓单客户隔离键',
order_id BIGINT UNSIGNED NOT NULL COMMENT '入仓单所属订单ID',
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '资料覆盖关系创建时间',
document_id BIGINT UNSIGNED NOT NULL COMMENT '客户报送资料ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '覆盖入仓单ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '覆盖入仓单客户ID',
order_id BIGINT UNSIGNED NOT NULL COMMENT '覆盖订单ID',
inbound_code VARCHAR(40) NOT NULL COMMENT '覆盖入仓码快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '关联创建时间',
PRIMARY KEY(document_id,inbound_id),
KEY idx_customs_document_inbound(inbound_id,tenant_id),
CONSTRAINT fk_customs_document_inbound_document FOREIGN KEY(document_id) REFERENCES fl_customs_customer_document(id)
) ENGINE=InnoDB COMMENT='客户报送资料与当前柜退税入仓码多对多关系';
CREATE TABLE IF NOT EXISTS fl_customs_customer_document_version (
id BIGINT UNSIGNED NOT NULL COMMENT '客户资料版本记录ID',
document_id BIGINT UNSIGNED NOT NULL COMMENT '客户报关资料ID',
document_version INT UNSIGNED NOT NULL COMMENT '客户资料版本号',
file_id BIGINT UNSIGNED NOT NULL COMMENT '该版本File服务文件ID',
file_name VARCHAR(255) NOT NULL COMMENT '该版本文件名快照',
file_hash CHAR(64) NOT NULL COMMENT '该版本文件SHA-256摘要',
content_type VARCHAR(128) NULL COMMENT '该版本文件内容类型',
size_bytes BIGINT UNSIGNED NOT NULL COMMENT '该版本文件大小(字节)',
scope_snapshot_json JSON NOT NULL COMMENT '该版本覆盖入仓码范围快照',
action VARCHAR(24) NOT NULL COMMENT '版本动作:UPLOAD上传、REPLACE替换、SCOPE_CHANGE调整范围、DELETE删除',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '版本操作人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '版本记录创建时间',
id BIGINT UNSIGNED NOT NULL COMMENT '资料版本记录ID',
document_id BIGINT UNSIGNED NOT NULL COMMENT '客户报送资料ID',
document_version INT UNSIGNED NOT NULL COMMENT '资料版本号',
file_id BIGINT UNSIGNED NOT NULL COMMENT '本版本File服务文件ID',
file_name VARCHAR(255) NOT NULL COMMENT '本版本文件名称快照',
file_hash CHAR(64) NOT NULL COMMENT '本版本文件SHA-256摘要',
content_type VARCHAR(128) NULL COMMENT '本版本文件媒体类型',
size_bytes BIGINT UNSIGNED NOT NULL COMMENT '本版本文件大小(字节)',
scope_snapshot_json JSON NOT NULL COMMENT '本版本覆盖入仓码范围快照',
action VARCHAR(24) NOT NULL COMMENT '版本动作:UPLOAD上传、REPLACE替换、SCOPE_CHANGE范围变化、DELETE删除',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '版本创建时间',
PRIMARY KEY(id),
UNIQUE KEY uk_customs_customer_document_version(document_id,document_version),
CONSTRAINT fk_customs_customer_document_version FOREIGN KEY(document_id) REFERENCES fl_customs_customer_document(id),
@@ -82,12 +82,12 @@ CREATE TABLE IF NOT EXISTS fl_customs_customer_document_version (
) ENGINE=InnoDB COMMENT='客户报送资料文件与覆盖范围不可变版本';
CREATE TABLE IF NOT EXISTS fl_customs_frozen_document_inbound (
document_id BIGINT UNSIGNED NOT NULL COMMENT '冻结的客户报关资料ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '冻结覆盖的入仓单ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '冻结入仓单客户隔离键',
order_id BIGINT UNSIGNED NOT NULL COMMENT '冻结入仓单所属订单ID',
inbound_code VARCHAR(40) NOT NULL COMMENT '冻结入仓码快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '覆盖范围冻结时间',
document_id BIGINT UNSIGNED NOT NULL COMMENT '冻结的客户报送资料ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '冻结覆盖入仓单ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '冻结覆盖客户ID',
order_id BIGINT UNSIGNED NOT NULL COMMENT '冻结覆盖订单ID',
inbound_code VARCHAR(40) NOT NULL COMMENT '冻结覆盖入仓码快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '冻结关联创建时间',
PRIMARY KEY(document_id,inbound_id),
CONSTRAINT fk_customs_frozen_document FOREIGN KEY(document_id) REFERENCES fl_customs_customer_document(id)
) ENGINE=InnoDB COMMENT='开始报关时冻结的客户资料覆盖范围';
@@ -95,15 +95,15 @@ CREATE TABLE IF NOT EXISTS fl_customs_frozen_document_inbound (
CREATE TABLE IF NOT EXISTS fl_customs_completion_document (
id BIGINT UNSIGNED NOT NULL COMMENT '报关完成文件ID',
source_document_id BIGINT UNSIGNED NOT NULL COMMENT '一份冻结客户资料严格对应一份完成文件逻辑根',
file_id BIGINT UNSIGNED NOT NULL COMMENT '完成文件的File服务文件ID',
file_name VARCHAR(255) NOT NULL COMMENT '完成文件名快照',
file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID',
file_name VARCHAR(255) NOT NULL COMMENT '完成文件名称快照',
file_hash CHAR(64) NOT NULL COMMENT '完成文件SHA-256摘要',
content_type VARCHAR(128) NULL COMMENT '完成文件内容类型',
content_type VARCHAR(128) NULL COMMENT '完成文件媒体类型',
size_bytes BIGINT UNSIGNED NOT NULL COMMENT '完成文件大小(字节)',
uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '完成文件上传操作人ID',
uploaded_by_name VARCHAR(64) NULL COMMENT '完成文件上传操作人姓名快照',
uploaded_at DATETIME(3) NOT NULL COMMENT '完成文件上传时间',
version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '完成文件当前版本',
uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID',
uploaded_by_name VARCHAR(64) NULL COMMENT '上传操作人名称快照',
uploaded_at DATETIME(3) NOT NULL COMMENT '上传时间',
version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '完成文件版本号',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '完成文件创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '完成文件更新时间',
PRIMARY KEY(id),
@@ -115,44 +115,44 @@ CREATE TABLE IF NOT EXISTS fl_customs_completion_document_version (
id BIGINT UNSIGNED NOT NULL COMMENT '完成文件版本记录ID',
completion_document_id BIGINT UNSIGNED NOT NULL COMMENT '报关完成文件ID',
document_version INT UNSIGNED NOT NULL COMMENT '完成文件版本号',
file_id BIGINT UNSIGNED NOT NULL COMMENT '该版本File服务文件ID',
file_name VARCHAR(255) NOT NULL COMMENT '该版本文件名快照',
file_hash CHAR(64) NOT NULL COMMENT '该版本文件SHA-256摘要',
content_type VARCHAR(128) NULL COMMENT '该版本文件内容类型',
size_bytes BIGINT UNSIGNED NOT NULL COMMENT '该版本文件大小(字节)',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '版本上传操作人ID',
operator_name VARCHAR(64) NULL COMMENT '版本上传操作人姓名快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '完成文件版本创建时间',
file_id BIGINT UNSIGNED NOT NULL COMMENT '本版本File服务文件ID',
file_name VARCHAR(255) NOT NULL COMMENT '本版本文件名称快照',
file_hash CHAR(64) NOT NULL COMMENT '本版本文件SHA-256摘要',
content_type VARCHAR(128) NULL COMMENT '本版本文件媒体类型',
size_bytes BIGINT UNSIGNED NOT NULL COMMENT '本版本文件大小(字节)',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '版本创建时间',
PRIMARY KEY(id),
UNIQUE KEY uk_customs_completion_version(completion_document_id,document_version),
CONSTRAINT fk_customs_completion_version FOREIGN KEY(completion_document_id) REFERENCES fl_customs_completion_document(id)
) ENGINE=InnoDB COMMENT='报关完成文件不可变覆盖版本';
CREATE TABLE IF NOT EXISTS fl_customs_container_history (
id BIGINT UNSIGNED NOT NULL COMMENT '整柜报关历史ID',
id BIGINT UNSIGNED NOT NULL COMMENT '报关历史记录ID',
customs_container_id BIGINT UNSIGNED NOT NULL COMMENT '所属整柜报关聚合ID',
action VARCHAR(40) NOT NULL COMMENT '报关状态或系统触发动作',
from_status VARCHAR(40) NULL COMMENT '动作前报关状态',
to_status VARCHAR(40) NULL COMMENT '动作后报关状态',
remark VARCHAR(500) NULL COMMENT '动作说明或备注',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '动作操作人ID',
operator_name VARCHAR(64) NULL COMMENT '动作操作人姓名快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '报关历史创建时间',
action VARCHAR(40) NOT NULL COMMENT '报关操作类型',
from_status VARCHAR(40) NULL COMMENT '变更前报关状态',
to_status VARCHAR(40) NULL COMMENT '变更后报关状态',
remark VARCHAR(500) NULL COMMENT '操作备注',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '历史创建时间',
PRIMARY KEY(id),
KEY idx_customs_container_history(customs_container_id,created_at,id),
CONSTRAINT fk_customs_container_history FOREIGN KEY(customs_container_id) REFERENCES fl_customs_container(id)
) ENGINE=InnoDB COMMENT='整柜报关状态与系统触发历史';
CREATE TABLE IF NOT EXISTS fl_customs_command (
id BIGINT UNSIGNED NOT NULL COMMENT '报关写命令ID',
actor_id BIGINT UNSIGNED NOT NULL COMMENT '命令发起操作人ID',
action VARCHAR(80) NOT NULL COMMENT '报关写命令动作',
idempotency_key VARCHAR(128) NOT NULL COMMENT '调用方幂等键',
id BIGINT UNSIGNED NOT NULL COMMENT '报关幂等命令ID',
actor_id BIGINT UNSIGNED NOT NULL COMMENT '命令操作人ID',
action VARCHAR(80) NOT NULL COMMENT '命令动作类型',
idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键',
request_hash CHAR(64) NOT NULL COMMENT '规范化请求SHA-256摘要',
status VARCHAR(16) NOT NULL COMMENT '命令状态:PROCESSING处理中、SUCCESS成功',
result_id BIGINT UNSIGNED NULL COMMENT '命令成功生成的业务结果ID',
result_id BIGINT UNSIGNED NULL COMMENT '命令结果业务记录ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '命令创建时间',
completed_at DATETIME(3) NULL COMMENT '命令成功完成时间',
completed_at DATETIME(3) NULL COMMENT '命令完成时间',
PRIMARY KEY(id),
UNIQUE KEY uk_customs_command(actor_id,action,idempotency_key),
CONSTRAINT chk_customs_command_status CHECK(status IN('PROCESSING','SUCCESS'))
@@ -1,10 +1,10 @@
USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_container_exception_history (
id BIGINT UNSIGNED NOT NULL, container_booking_id BIGINT UNSIGNED NOT NULL,
action VARCHAR(16) NOT NULL COMMENT 'MARK/CANCEL', reason VARCHAR(500) NOT NULL,
operator_id BIGINT UNSIGNED NOT NULL, operator_name VARCHAR(64),
id BIGINT UNSIGNED NOT NULL COMMENT '货柜异常历史ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '物理货柜ID',
action VARCHAR(16) NOT NULL COMMENT '操作类型:MARK标记异常、CANCEL取消异常', reason VARCHAR(500) NOT NULL COMMENT '异常原因',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', operator_name VARCHAR(64) COMMENT '操作人名称快照',
operation_id VARCHAR(128) NOT NULL COMMENT '同一操作人幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求摘要',
occurred_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
occurred_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作发生时间',
PRIMARY KEY(id), UNIQUE KEY uk_container_exception_operation(container_booking_id,operator_id,operation_id),
KEY idx_container_exception_history(container_booking_id,occurred_at,id),
CONSTRAINT fk_container_exception_booking FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id),
@@ -344,11 +344,11 @@ CREATE TABLE IF NOT EXISTS fl_container_document (
) ENGINE=InnoDB COMMENT='平台货柜清关、DO与PU文件关联';
CREATE TABLE IF NOT EXISTS fl_container_exception_history (
id BIGINT UNSIGNED NOT NULL, container_booking_id BIGINT UNSIGNED NOT NULL,
action VARCHAR(16) NOT NULL, reason VARCHAR(500) NOT NULL,
operator_id BIGINT UNSIGNED NOT NULL, operator_name VARCHAR(64) NULL,
operation_id VARCHAR(128) NOT NULL, request_hash CHAR(64) NOT NULL,
occurred_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id),
id BIGINT UNSIGNED NOT NULL COMMENT '货柜异常历史ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '物理货柜ID',
action VARCHAR(16) NOT NULL COMMENT '操作类型:MARK标记异常、CANCEL取消异常', reason VARCHAR(500) NOT NULL COMMENT '异常原因',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
operation_id VARCHAR(128) NOT NULL COMMENT '同一操作人幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求摘要',
occurred_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作发生时间', PRIMARY KEY(id),
UNIQUE KEY uk_container_exception_operation(container_booking_id,operator_id,operation_id),
KEY idx_container_exception_history(container_booking_id,occurred_at,id),
CONSTRAINT fk_container_exception_booking FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id),
@@ -587,7 +587,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_completion_command (
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA任务ID',
operation_id VARCHAR(128) NOT NULL COMMENT '客户端全局幂等操作ID',
request_hash CHAR(64) NOT NULL COMMENT '规范化请求摘要',
command_status VARCHAR(16) NOT NULL COMMENT 'PROCESSING、RETRYABLE、FAILED、COMPLETED',
command_status VARCHAR(16) NOT NULL COMMENT '命令状态:PROCESSING处理中、RETRYABLE可重试、FAILED失败、COMPLETED已完成',
expected_task_version INT UNSIGNED NOT NULL COMMENT '受理时客户端任务版本',
remark VARCHAR(500) NULL COMMENT '完成备注',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
@@ -600,9 +600,9 @@ CREATE TABLE IF NOT EXISTS fl_loading_completion_command (
failure_code VARCHAR(32) NULL COMMENT '下游或业务错误码',
failure_request_id VARCHAR(64) NULL COMMENT '下游请求ID',
failure_trace_id VARCHAR(64) NULL COMMENT '下游链路ID',
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),
completed_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '命令创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '命令更新时间',
completed_at DATETIME(3) NULL COMMENT '命令完成时间',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_completion_operation (operation_id),
KEY idx_loading_completion_dispatch (command_status,next_retry_at,lease_until),
@@ -611,13 +611,13 @@ CREATE TABLE IF NOT EXISTS fl_loading_completion_command (
) ENGINE=InnoDB COMMENT='PDA装柜完成可靠异步命令';
CREATE TABLE IF NOT EXISTS fl_loading_completion_checkpoint (
command_id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
inbound_id BIGINT UNSIGNED NOT NULL,
owner_sequence INT UNSIGNED NOT NULL,
checkpoint_stage VARCHAR(24) NOT NULL COMMENT 'READY、SETTLED、INBOUND_COMPLETED',
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),
command_id BIGINT UNSIGNED NOT NULL COMMENT '可靠装柜完成命令ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '入仓单客户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入仓单ID',
owner_sequence INT UNSIGNED NOT NULL COMMENT '稳定处理顺序',
checkpoint_stage VARCHAR(24) NOT NULL COMMENT '处理阶段:READY待处理、SETTLED资源已结算、INBOUND_COMPLETED入仓已完成',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '检查点创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '检查点更新时间',
PRIMARY KEY (command_id,tenant_id,inbound_id),
KEY idx_loading_completion_owner (command_id,owner_sequence),
CONSTRAINT fk_loading_completion_checkpoint_command FOREIGN KEY (command_id)
@@ -0,0 +1,45 @@
openapi: 3.0.3
info: { title: Firstleg Container Fee Allocation API, version: 1.0.0 }
paths:
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/allocation-groups:
post:
summary: 原子创建柜级公共费用分摊组
description: 客户、订单必填;入仓ID与入仓码同时填写或同时留空。各分摊金额必须大于0且合计等于费用总额。
security: [{ bearerAuth: [] }]
parameters: [{ $ref: '#/components/parameters/LoadingPlanId' }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/AllocationGroupRequest' } } } }
responses: { '200': { description: 按客户和业务归属生成的待审核费用行 }, '409': { description: 分摊合计或柜内真实归属不一致 } }
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/allocation-groups/{groupId}/{action}:
post:
summary: 整组审核、驳回、调整或作废柜级分摊费用
description: action 仅为 approve、reject、adjust、void;单行接口拒绝处理分摊组,避免部分成功。
security: [{ bearerAuth: [] }]
parameters:
- { $ref: '#/components/parameters/LoadingPlanId' }
- { in: path, name: groupId, required: true, schema: { type: string, pattern: '^[1-9][0-9]{0,19}$' } }
- { in: path, name: action, required: true, schema: { type: string, enum: [approve, reject, adjust, void] } }
responses: { '200': { description: 整组处理成功 }, '409': { description: 版本、状态或完整性冲突 } }
components:
parameters:
LoadingPlanId: { in: path, name: loadingPlanId, required: true, schema: { type: string, pattern: '^[1-9][0-9]{0,19}$' } }
schemas:
AllocationGroupRequest:
type: object
required: [fee, allocations]
properties:
fee: { type: object, description: 公共费用字段;amount为原始柜级总额 }
allocations:
type: array
minItems: 1
maxItems: 200
items:
type: object
required: [customerId, orderId, orderNo, amount]
properties:
customerId: { type: string }
orderId: { type: string }
orderNo: { type: string, maxLength: 64 }
inboundId: { type: string, nullable: true }
inboundCode: { type: string, maxLength: 64, nullable: true }
amount: { type: number, format: decimal, exclusiveMinimum: 0 }
securitySchemes: { bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } }
@@ -3,6 +3,8 @@ package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AdjustRequest;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.Allocation;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AllocationGroupRequest;
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
import com.baiyue.firstleg.entity.FeeItemEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
@@ -233,15 +235,71 @@ class ContainerFeeDetailServiceTest {
assertThat(captor.getValue().getPaymentVoucherFileIds()).containsExactly(901L);
}
@Test
void createsAtomicCrossCustomerAllocationAndAllowsMissingInboundCode() {
LoadingPlanEntity plan = new LoadingPlanEntity();
plan.setId(41L);
when(loading.plan(41L)).thenReturn(plan);
LoadingPlanItemEntity first = loadingItem(10001L, 501L, "FL-501", null, null);
LoadingPlanItemEntity second = loadingItem(10002L, 502L, "FL-502", 602L, "IB-602");
when(loading.items(41L)).thenReturn(List.of(first, second));
when(feeItems.findItem(11L)).thenReturn(item());
var fee = new ContainerFeeDetailDtos.SaveRequest(11L, "RECEIVABLE",
LocalDate.of(2026, 8, 20), new BigDecimal("300.00"), "CNY", null, "柜级操作费");
var request = new AllocationGroupRequest(fee, List.of(
new Allocation(10001L, 501L, "FL-501", null, null, new BigDecimal("120.00")),
new Allocation(10002L, 502L, "FL-502", 602L, "IB-602", new BigDecimal("180.00"))));
List<ContainerFeeDetailEntity> result = service.createAllocationGroup(41L, 9L, request);
assertThat(result).hasSize(2);
ArgumentCaptor<ContainerFeeDetailEntity> captor = ArgumentCaptor.forClass(ContainerFeeDetailEntity.class);
verify(repository, org.mockito.Mockito.times(2)).insert(captor.capture());
assertThat(captor.getAllValues()).extracting(ContainerFeeDetailEntity::getTenantId)
.containsExactly(10001L, 10002L);
assertThat(captor.getAllValues().get(0).getAllocationOrderNo()).isEqualTo("FL-501");
assertThat(captor.getAllValues().get(0).getAllocationInboundId()).isNull();
assertThat(captor.getAllValues().get(0).getAllocationInboundCode()).isNull();
assertThat(captor.getAllValues()).extracting(ContainerFeeDetailEntity::getAllocationGroupId)
.doesNotContainNull().allMatch(captor.getAllValues().get(0).getAllocationGroupId()::equals);
}
@Test
void rejectsAllocationWhenInboundIdAndCodeAreOnlyPartiallyProvided() {
LoadingPlanEntity plan = new LoadingPlanEntity();
plan.setId(41L);
when(loading.plan(41L)).thenReturn(plan);
when(loading.items(41L)).thenReturn(List.of(loadingItem(10001L, 501L, "FL-501", null, null)));
when(feeItems.findItem(11L)).thenReturn(item());
var fee = new ContainerFeeDetailDtos.SaveRequest(11L, "RECEIVABLE",
LocalDate.of(2026, 8, 20), new BigDecimal("100.00"), "CNY", null, null);
var request = new AllocationGroupRequest(fee, List.of(
new Allocation(10001L, 501L, "FL-501", 601L, null, new BigDecimal("100.00"))));
assertThatThrownBy(() -> service.createAllocationGroup(41L, 9L, request))
.isInstanceOf(BizException.class).hasMessageContaining("同时填写或同时留空");
verify(repository, never()).insert(any());
}
private void ownedPlan() {
LoadingPlanEntity plan = new LoadingPlanEntity();
plan.setId(41L);
when(loading.plan(41L)).thenReturn(plan);
LoadingPlanItemEntity item = new LoadingPlanItemEntity();
item.setAccountId(10001L);
item.setOrderId(501L);
item.setOrderNo("FL-501");
when(loading.items(41L)).thenReturn(List.of(item));
}
private static LoadingPlanItemEntity loadingItem(long accountId, long orderId, String orderNo,
Long inboundId, String inboundCode) {
LoadingPlanItemEntity value = new LoadingPlanItemEntity();
value.setAccountId(accountId); value.setOrderId(orderId); value.setOrderNo(orderNo);
value.setInboundId(inboundId); value.setInboundCode(inboundCode);
return value;
}
private static FeeItemEntity item() {
FeeItemEntity value = new FeeItemEntity();
value.setId(11L);
+1
View File
@@ -42,6 +42,7 @@
| 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java`、`customervisit/service/CustomerVisitService.java`;平台维护拜访、附件上传、派送完成/状态 |
| 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java`、`exceptionorder/service/ExceptionOrderService.java`;异常订单建档、关联解析、派送退回候选与标记 |
| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
| 海外业务费用来源 | `financefee/`;海外入仓、采购、本土卡车、本土散板和海外仓服务费审核后以 Outbox/Event Bridge 进入独立 Finance,入仓记录和入仓码按事实选填;OpenAPI `src/main/resources/openapi/overseas-finance-fees-v1.yaml` |
| 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java`、`MerchantUsTruckOrderController.java` 及同子域 Service/DTO |
| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java` 及同子域 Service/DTO/Entity/Mapper;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
| 海外仓报价、月度汇率、车辆主数据 | 报价 `masterdata/quote/`、汇率 `masterdata/exchange/`;车辆入口 `masterdata/vehicle/adapter/in/web/PlatformVehicleController.java`,应用服务 `application/VehicleApplicationService.java`,持久化端口与适配器 `application/port/out/VehicleRepository.java`、`adapter/out/persistence/`;OpenAPI `src/main/resources/openapi/overseas-master-data-v1.yaml`,车辆档案不含派送履约历史 |
@@ -16,8 +16,8 @@ public class OverseasFeeBusinessGuard {
if (fee.businessId() == null) {
if (!"WAREHOUSE_SERVICE".equals(fee.businessType()))
throw bad("业务记录ID不能为空");
if (fee.warehouseId() == null || (fee.inboundId() == null && blank(fee.inboundCode())))
throw bad("海外仓服务费必须关联仓库以及入仓记录或入仓码");
if (fee.warehouseId() == null)
throw bad("海外仓服务费必须关联海外仓;入仓记录和入仓码按实际业务选填");
return;
}
Table table = switch (fee.businessType()) {
@@ -28,7 +28,7 @@ public class OverseasFeeOutboxService {
@Resource private ObjectMapper objectMapper;
public void publish(OverseasFeeSource source, String action) {
BusinessFeeSourceChangedV1 unsigned = payload(source, action, null);
BusinessFeeSourceChangedV1 unsigned = payload(source, null, null);
String sourceHash = EventPayloadHash.sha256(
EventPayloadCanonicalizer.canonicalize(objectMapper.valueToTree(unsigned)));
BusinessFeeSourceChangedV1 payload = payload(source, action, sourceHash);
@@ -67,7 +67,7 @@ public class OverseasFeeOutboxService {
value.getSettlementMode(), instant(value.getPaidAt()), value.getPaymentMethod(),
value.getPaymentReferenceNo(), value.getPaymentVoucherFileIds() == null ? List.of() : value.getPaymentVoucherFileIds(),
value.getBusinessDate(), value.getCurrency(), value.getAmount(), value.getReviewStatus(),
value.getPublicationStatus(), value.getSourceStatus(), hash);
"PUBLISHED", action == null && hash == null ? "ACTIVE" : value.getSourceStatus(), hash);
}
private static String text(Long value) { return value == null ? null : String.valueOf(value); }
@@ -0,0 +1,50 @@
openapi: 3.0.3
info: { title: Overseas Business Fee Source API, version: 1.0.0 }
paths:
/api/platform/v1/last-leg/finance-fee-sources:
get:
summary: 查询海外业务费用来源当前版本
security: [{ bearerAuth: [] }]
parameters:
- { in: query, name: customerId, schema: { type: string } }
- { in: query, name: businessType, schema: { $ref: '#/components/schemas/BusinessType' } }
- { in: query, name: businessNo, schema: { type: string } }
responses: { '200': { description: 当前版本列表 } }
post:
summary: 创建海外业务费用来源草稿
description: 客户和业务编号必填;入仓ID、入仓码均按业务事实选填,不得为补字段伪造。
security: [{ bearerAuth: [] }]
parameters: [{ in: header, name: Idempotency-Key, required: true, schema: { type: string, maxLength: 128 } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/SaveRequest' } } } }
responses: { '200': { description: 草稿费用来源 }, '400': { description: 业务归属或付款事实无效 } }
/api/platform/v1/last-leg/finance-fee-sources/{sourceFeeId}/{action}:
post:
summary: 提交、审核、调整或作废海外业务费用来源
description: 审核通过与作废均事务性写入Outbox,经Event Bridge可靠投递至独立Finance;action为submit、review、adjust、void。
security: [{ bearerAuth: [] }]
parameters:
- { in: path, name: sourceFeeId, required: true, schema: { type: string } }
- { in: path, name: action, required: true, schema: { type: string, enum: [submit, review, adjust, void] } }
responses: { '200': { description: 当前费用来源版本 }, '409': { description: 状态、版本或幂等冲突 } }
components:
schemas:
BusinessType: { type: string, enum: [OVERSEAS_INBOUND, OVERSEAS_PROCUREMENT, US_TRUCK, LOCAL_LOOSEBOARD, WAREHOUSE_SERVICE] }
SaveRequest:
type: object
required: [customerId, sourceType, businessType, businessNo, feeItemCode, feeItemName, direction, businessDate, currency, amount]
properties:
customerId: { type: string }
sourceType: { type: string, enum: [OVERSEAS_INBOUND_FEE, OVERSEAS_PROCUREMENT_FEE, OVERSEAS_TRUCK_FEE, OVERSEAS_LOOSEBOARD_FEE, OVERSEAS_WAREHOUSE_FEE] }
businessType: { $ref: '#/components/schemas/BusinessType' }
businessId: { type: string, nullable: true }
businessNo: { type: string, maxLength: 128 }
warehouseId: { type: string, nullable: true }
inboundId: { type: string, nullable: true }
inboundCode: { type: string, maxLength: 64, nullable: true }
feeItemCode: { type: string }
feeItemName: { type: string }
direction: { type: string, enum: [RECEIVABLE, PAYABLE] }
businessDate: { type: string, format: date }
currency: { type: string, enum: [CNY] }
amount: { type: number, format: decimal, exclusiveMinimum: 0 }
securitySchemes: { bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } }
@@ -0,0 +1,56 @@
package com.baiyue.lastleg.financefee.service;
import com.baiyue.common.event.BusinessFeeSourceChangedV1;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class OverseasFeeOutboxServiceTest {
private final LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class);
private final ObjectMapper json = new ObjectMapper().findAndRegisterModules();
private final OverseasFeeOutboxService service = new OverseasFeeOutboxService();
@Test
void publishesCompleteFeeSnapshotWithOptionalInboundReference() throws Exception {
ReflectionTestUtils.setField(service, "mapper", mapper);
ReflectionTestUtils.setField(service, "objectMapper", json);
when(mapper.findByEventKey(anyString())).thenReturn(null);
OverseasFeeSource source = new OverseasFeeSource();
source.setSourceFeeId(701L); source.setSourceVersion(1); source.setCustomerId("10001");
source.setCustomerName("客户A"); source.setSourceType("OVERSEAS_PROCUREMENT_FEE");
source.setSourceNo("PO-US-001"); source.setBusinessType("OVERSEAS_PROCUREMENT");
source.setBusinessId(801L); source.setBusinessNo("PO-US-001"); source.setWarehouseId(91L);
source.setFeeItemCode("PROCUREMENT_SERVICE"); source.setFeeItemName("采购服务费");
source.setDirection("RECEIVABLE"); source.setBusinessDate(LocalDate.of(2026, 9, 2));
source.setCurrency("CNY"); source.setAmount(new BigDecimal("120.00"));
source.setReviewStatus("APPROVED"); source.setPublicationStatus("PUBLISHED");
source.setSourceStatus("ACTIVE"); source.setPaymentVoucherFileIds(List.of());
service.publish(source, "PUBLISHED");
ArgumentCaptor<LastlegEventOutbox> row = ArgumentCaptor.forClass(LastlegEventOutbox.class);
verify(mapper).insert(row.capture());
EventEnvelope envelope = json.readValue(row.getValue().getPayloadJson(), EventEnvelope.class).requireValid();
BusinessFeeSourceChangedV1 payload = json.treeToValue(envelope.payload(), BusinessFeeSourceChangedV1.class);
assertThat(payload.customerId()).isEqualTo("10001");
assertThat(payload.businessNo()).isEqualTo("PO-US-001");
assertThat(payload.inboundId()).isNull();
assertThat(payload.inboundCode()).isNull();
assertThat(payload.sourceHash()).startsWith("sha256:").hasSize(71);
}
}
+1 -1
View File
@@ -259,7 +259,7 @@ CREATE TABLE IF NOT EXISTS ord_customer_address (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记:0未删除、1已删除',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
KEY idx_customer_address_scope (tenant_id, address_type, active, updated_at)
+14 -1
View File
@@ -318,7 +318,7 @@ export function parseArguments(argv) {
const modules = normalizeModuleOrder((values.get('modules') || DEFAULT_MODULES.join(',')).split(',').map((v) => v.trim()).filter(Boolean))
const merchantUsername = values.get('merchant-username') || process.env.MERCHANT_TEST_USERNAME || ''
const merchantPassword = values.get('merchant-password') || process.env.MERCHANT_TEST_PASSWORD || ''
if (merchantUsername && merchantPassword) {
if (merchantUsername && merchantPassword && !modules.includes('phase4-overseas-business')) {
const fixtureIndex = modules.indexOf('merchant-qualification-fixture')
if (fixtureIndex >= 0) modules.splice(fixtureIndex, 1)
}
@@ -1620,6 +1620,19 @@ export class AcceptanceRun {
if (this.options.waitSplitCount > 1 || this.options.waitSplitPackageCount > 0
|| this.options.orderProfileMode === 'randomized' || this.options.routeMode === 'random') {
this.applyWaitSplitVariant(this.options.waitSplitSequence || 1)
} else if (this.options.originWarehouseName || this.options.channelName || this.options.channelCode) {
this.context.waitSplitRoute = {
originWarehouseName: this.options.originWarehouseName || '',
channelName: this.options.channelName || '',
channelCode: this.options.channelCode || '',
randomize: false,
randomIndex: 0,
destinationIndex: 0,
taxModeIndex: 0,
}
// A fixed non-default origin may not carry the Yiwu-only TP-001/YW-TP-002 fixtures.
// Reuse an existing pallet from the selected warehouse while keeping order data deterministic.
this.context.allowAnyExistingPallet = true
}
const loginResponse = await this.request('POST', '/api/auth/login', { loginId: this.options.username, password: this.options.password }, '')
const login = this.apiSuccess('authentication', '平台管理员登录', loginResponse)
@@ -210,6 +210,8 @@ test('module is context injected, blocks missing prerequisites and excludes forb
assert.doesNotMatch(source, /https?:\/\/|process\.env|fetch\(/)
assert.doesNotMatch(source, /addCoverageGap|coverageGap\(/)
assert.match(source, /deploymentBlocker: true/)
assert.match(source, /contactPhone: '\d+'/)
assert.doesNotMatch(source, /contactPhone: '\+[^']+'/)
for (const forbidden of ['/outbounds', '/receive-pallets', '/outbound', '/delivery', '/dispatch', '/export']) {
assert.equal(source.includes(forbidden), false, forbidden)
}
@@ -56,7 +56,7 @@ function partnerWarehousePayload(context) {
return {
tenantId: 0, warehouseCode: `P4B-${suffix}`.slice(0, 50), warehouseName: `${context.marker} 采购合作仓`,
warehouseType: 'OVERSEAS_PARTNER', countryCode: 'US', timezone: 'UTC-8', contactName: 'Phase4 QA',
contactPhone: '+1-213-555-0177', contactEmail: null, status: 'ACTIVE', remark: `${context.marker}-procurement-fixture`,
contactPhone: '12135550177', contactEmail: null, status: 'ACTIVE', remark: `${context.marker}-procurement-fixture`,
address: { addressType: 'WAREHOUSE', countryCode: 'US', stateProvince: 'California', city: 'Los Angeles',
district: 'Commerce', postalCode: '90040', addressLine1: '601 Procurement Partner Avenue', addressLine2: null,
latitude: null, longitude: null },
@@ -6,6 +6,16 @@ const TRUCK = '/api/platform/v1/truck-appointments'
function id(value) { return value == null ? '' : String(value) }
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
async function requestWithTransientRetry(context, method, path, body) {
let response = null
for (let attempt = 1; attempt <= 3; attempt += 1) {
response = await context.request(method, path, body)
if (response?.http !== 0) return response
if (attempt < 3) await new Promise((resolve) => setTimeout(resolve, 500 * attempt))
}
return response
}
async function actionRequest(context, path, endpoint, version) {
try {
const response = await fetch(`${context.options.gateway}${path}/${endpoint}`, {
@@ -64,7 +74,7 @@ export async function run(context) {
context.assert(MODULE, '已放舱订舱可直接创建订舱级拖车预约', oceanResponse,
id(booking?.id) === id(ocean.id) && booking?.status === 'RELEASED',
`oceanBookingId=${id(booking?.id) || 'missing'}, status=${booking?.status || 'missing'}`, OPTIONS)
const carrierResponse = await context.request('GET',
const carrierResponse = await requestWithTransientRetry(context, 'GET',
'/api/platform/v1/suppliers?tenantId=0&category=TRUCKING&status=ACTIVE&page=1&size=20')
const carrierPage = context.apiSuccess(MODULE, '读取 ACTIVE TRUCKING 承运商事实源', carrierResponse, '', OPTIONS)
const carrier = carrierPage?.records?.find?.((item) => item?.id && item?.name)
+43 -109
View File
@@ -239,9 +239,6 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan (
UNIQUE KEY uk_ll_inbound_plan_container (tenant_id,container_no)
) ENGINE=InnoDB COMMENT='尾程入库计划表:每个租户的每个集装箱生成一张入库单';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='version'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''入库计划乐观锁版本'' AFTER updated_at'));
PREPARE ll_inbound_plan_version_ddl FROM @ddl; EXECUTE ll_inbound_plan_version_ddl; DEALLOCATE PREPARE ll_inbound_plan_version_ddl;
CREATE TABLE IF NOT EXISTS ll_inbound_plan_line (
id BIGINT NOT NULL COMMENT '入库计划明细ID(雪花ID)',
plan_id BIGINT NOT NULL COMMENT '入库计划ID',
@@ -443,7 +440,6 @@ CREATE TABLE IF NOT EXISTS ll_order (
relabel_order_no VARCHAR(128) NULL COMMENT '换标单号',
relabel_status VARCHAR(32) NULL COMMENT '换标状态',
pallet_merge_flag VARCHAR(32) NULL COMMENT '合托标记',
pallet_merge_batch_id VARCHAR(64) NULL COMMENT '平台预标记合托分组号',
current_status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '当前业务状态',
private_remark VARCHAR(1000) NULL COMMENT '备注(仅自己)',
customer_remark VARCHAR(1000) NULL COMMENT '备注(客户)',
@@ -461,9 +457,6 @@ CREATE TABLE IF NOT EXISTS ll_order (
KEY idx_ll_order_list (tenant_id,current_status,created_at)
) ENGINE=InnoDB COMMENT='尾程订单主表';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='pallet_merge_batch_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN pallet_merge_batch_id VARCHAR(64) NULL COMMENT ''平台预标记合托分组号'' AFTER pallet_merge_flag'));
PREPARE ll_order_merge_batch_ddl FROM @ddl; EXECUTE ll_order_merge_batch_ddl; DEALLOCATE PREPARE ll_order_merge_batch_ddl;
CREATE TABLE IF NOT EXISTS ll_order_detail (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '订单明细主键',
order_id BIGINT NOT NULL COMMENT '尾程订单ID',
@@ -1393,7 +1386,7 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order (
product_specification VARCHAR(500) NULL COMMENT '产品规格', product_url VARCHAR(2000) NULL COMMENT '产品链接', purchase_quantity BIGINT NOT NULL COMMENT '采购数量',
budget_unit_price DECIMAL(18,2) NULL COMMENT '预算单价', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', receiving_address VARCHAR(1000) NULL COMMENT '收货地址',
contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', contact_phone VARCHAR(64) NOT NULL COMMENT '联系人电话', purchase_warehouse_id VARCHAR(128) NULL COMMENT '采购入仓仓库ID',
purchase_warehouse_name VARCHAR(255) NULL COMMENT '采购入仓仓库名称', inbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计入库数量', outbound_details JSON NULL COMMENT '出库明细', outbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计出库数量',
purchase_warehouse_name VARCHAR(255) NULL COMMENT '采购入仓仓库名称', inbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计入库数量', outbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计出库数量',
domestic_carrier VARCHAR(128) NULL COMMENT '境内承运商', domestic_tracking_no VARCHAR(128) NULL COMMENT '境内物流单号', overseas_carrier VARCHAR(128) NULL COMMENT '海外承运商',
overseas_tracking_no VARCHAR(128) NULL COMMENT '海外物流单号', estimated_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态',
remark VARCHAR(1000) NULL COMMENT '备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希',
@@ -3202,8 +3195,6 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='inbound_quantity' AND COALESCE(column_comment,'') <> '累计入库数量'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN inbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT ''累计入库数量''', 'SELECT 1'));
PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='outbound_details'), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN outbound_details JSON NULL COMMENT ''出库明细'' AFTER inbound_quantity'));
PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='outbound_quantity' AND COALESCE(column_comment,'') <> '累计出库数量'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN outbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT ''累计出库数量''', 'SELECT 1'));
PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='domestic_carrier' AND COALESCE(column_comment,'') <> '境内承运商'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN domestic_carrier VARCHAR(128) NULL COMMENT ''境内承运商''', 'SELECT 1'));
@@ -3815,106 +3806,49 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
'SELECT 1', 'ALTER TABLE ll_cargo_discard_application ADD COLUMN review_idempotency_key VARCHAR(255) NULL COMMENT ''审核幂等键'' AFTER request_hash'));
PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt; DEALLOCATE PREPARE phase4_discard_review_stmt;
-- 一件代发出库与 PDA 按托盘拣货出库。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='outbound_type'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN outbound_type VARCHAR(32) NOT NULL DEFAULT ''DISPATCH_DELIVERY'' COMMENT ''出库类型'' AFTER status'));
PREPARE dropship_outbound_stmt FROM @ddl; EXECUTE dropship_outbound_stmt; DEALLOCATE PREPARE dropship_outbound_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='recipient_info'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN recipient_info VARCHAR(1000) NULL COMMENT ''一件代发收件信息'' AFTER outbound_type'));
PREPARE dropship_outbound_stmt FROM @ddl; EXECUTE dropship_outbound_stmt; DEALLOCATE PREPARE dropship_outbound_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0 COMMENT ''已拣箱数'' AFTER package_count'));
PREPARE dropship_outbound_stmt FROM @ddl; EXECUTE dropship_outbound_stmt; DEALLOCATE PREPARE dropship_outbound_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='status'), 'SELECT 1', 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN status VARCHAR(32) NOT NULL DEFAULT ''WAIT_PICK'' COMMENT ''拣货状态'' AFTER picked_package_count'));
PREPARE dropship_outbound_stmt FROM @ddl; EXECUTE dropship_outbound_stmt; DEALLOCATE PREPARE dropship_outbound_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='sku_code'), 'SELECT 1', 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN sku_code VARCHAR(128) NULL COMMENT ''一件代发SKU'' AFTER status'));
PREPARE dropship_outbound_stmt FROM @ddl; EXECUTE dropship_outbound_stmt; DEALLOCATE PREPARE dropship_outbound_stmt;
ALTER TABLE ll_outbound_order MODIFY COLUMN dispatch_order_id BIGINT NULL COMMENT '派车单ID,一件代发为空', MODIFY COLUMN dispatch_no VARCHAR(64) NULL COMMENT '派车单号,一件代发为空';
CREATE TABLE IF NOT EXISTS ll_outbound_pda_task (id BIGINT NOT NULL COMMENT '主键ID',customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',outbound_order_id BIGINT NOT NULL COMMENT '出库单ID',task_no VARCHAR(64) NOT NULL COMMENT 'PDA任务号',status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态',pushed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '推送时间',completed_at DATETIME(3) NULL COMMENT '完成时间',created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',PRIMARY KEY(id),UNIQUE KEY uk_ll_outbound_pda_task_order(customer_id,outbound_order_id),UNIQUE KEY uk_ll_outbound_pda_task_no(customer_id,task_no)) ENGINE=InnoDB COMMENT='一件代发出库PDA拣货任务';
CREATE TABLE IF NOT EXISTS ll_outbound_pda_pick (id BIGINT NOT NULL COMMENT '主键ID',customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',task_id BIGINT NOT NULL COMMENT 'PDA任务ID',outbound_pallet_item_id BIGINT NOT NULL COMMENT '出库托盘明细ID',inventory_position_id BIGINT NOT NULL COMMENT '库存仓位ID',packages BIGINT NOT NULL COMMENT '拣货箱数',idempotency_key VARCHAR(255) NOT NULL COMMENT '拣货幂等键',picked_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '拣货时间',PRIMARY KEY(id),UNIQUE KEY uk_ll_outbound_pda_pick_key(customer_id,idempotency_key)) ENGINE=InnoDB COMMENT='一件代发出库PDA托盘拣货记录';
-- ============================================================================
-- 2026-09-02 / 031
-- 海外业务费用来源与写操作幂等审计;入仓码为可选业务引用。
-- ============================================================================
CREATE TABLE IF NOT EXISTS ll_finance_fee_source (
id BIGINT NOT NULL COMMENT '费用版本记录ID', source_fee_id BIGINT NOT NULL COMMENT '跨版本稳定费用来源ID',
source_version INT NOT NULL COMMENT '费用来源版本,从1递增', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键',
customer_name VARCHAR(200) NULL COMMENT '客户名称快照', source_type VARCHAR(64) NOT NULL COMMENT '海外费用来源类型',
source_no VARCHAR(128) NOT NULL COMMENT '来源业务编号或入仓码快照', business_type VARCHAR(32) NOT NULL COMMENT '来源业务类型',
business_id BIGINT NULL COMMENT '来源业务记录ID', business_no VARCHAR(128) NOT NULL COMMENT '来源业务编号',
order_id BIGINT NULL COMMENT '关联订单ID', inbound_id BIGINT NULL COMMENT '关联海外入仓计划或入仓记录ID',
inbound_code VARCHAR(64) NULL COMMENT '关联入仓码;并非所有费用必填', container_id BIGINT NULL COMMENT '关联柜次ID',
container_booking_id BIGINT NULL COMMENT '来源订舱柜ID', container_no VARCHAR(64) NULL COMMENT '真实集装箱号快照',
warehouse_id BIGINT NULL COMMENT '关联海外仓ID', fee_item_code VARCHAR(64) NOT NULL COMMENT '费用项目编码快照',
fee_item_name VARCHAR(200) NOT NULL COMMENT '费用项目名称快照', direction VARCHAR(16) NOT NULL COMMENT 'RECEIVABLE应收或PAYABLE应付',
supplier_id BIGINT NULL COMMENT '应付供应商ID', supplier_name VARCHAR(200) NULL COMMENT '应付供应商名称快照',
supplier_category VARCHAR(32) NULL COMMENT '应付供应商类别约束', settlement_mode VARCHAR(16) NULL COMMENT 'ON_ACCOUNT账期应付或DIRECT_PAID业务已付',
paid_at DATETIME(3) NULL COMMENT '直接已付时间', payment_method VARCHAR(32) NULL COMMENT '直接已付方式',
payment_reference_no VARCHAR(128) NULL COMMENT '直接已付流水号', payment_voucher_file_ids JSON NULL COMMENT '直接已付凭证文件ID快照',
business_date DATE NOT NULL COMMENT '费用业务日期', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '财务结算币种,当前固定CNY',
amount DECIMAL(20,6) NOT NULL COMMENT '费用金额', review_status VARCHAR(16) NOT NULL COMMENT 'DRAFT、PENDING、APPROVED或REJECTED',
publication_status VARCHAR(16) NOT NULL COMMENT 'UNPUBLISHED或PUBLISHED', source_status VARCHAR(16) NOT NULL COMMENT 'ACTIVE、SUPERSEDED或VOIDED',
review_reason VARCHAR(500) NULL COMMENT '审核或作废原因', reviewer_id VARCHAR(128) NULL COMMENT '审核人ID', reviewed_at DATETIME(3) NULL COMMENT '审核时间',
created_by VARCHAR(128) NOT NULL COMMENT '创建人ID', updated_by VARCHAR(128) NOT NULL COMMENT '最后更新人ID',
created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_finance_fee_version(source_fee_id,source_version),
KEY idx_ll_finance_fee_customer(customer_id,source_status,created_at), KEY idx_ll_finance_fee_business(business_type,business_no,created_at),
CONSTRAINT chk_ll_finance_fee_amount CHECK(amount>0), CONSTRAINT chk_ll_finance_fee_currency CHECK(currency='CNY'),
CONSTRAINT chk_ll_finance_fee_direction CHECK(direction IN('RECEIVABLE','PAYABLE')),
CONSTRAINT chk_ll_finance_fee_review CHECK(review_status IN('DRAFT','PENDING','APPROVED','REJECTED')),
CONSTRAINT chk_ll_finance_fee_publication CHECK(publication_status IN('UNPUBLISHED','PUBLISHED')),
CONSTRAINT chk_ll_finance_fee_status CHECK(source_status IN('ACTIVE','SUPERSEDED','VOIDED'))
) ENGINE=InnoDB COMMENT='海外业务不可变费用来源版本;发布后经跨区事件进入Finance';
CREATE TABLE IF NOT EXISTS ll_finance_fee_command (
id BIGINT NOT NULL COMMENT '幂等命令ID', operator_id VARCHAR(128) NOT NULL COMMENT '操作人ID',
action VARCHAR(16) NOT NULL COMMENT 'CREATE、SUBMIT、REVIEW、ADJUST或VOID', idempotency_key VARCHAR(128) NOT NULL COMMENT '操作幂等键',
request_hash CHAR(64) NOT NULL COMMENT '请求内容SHA-256', source_fee_id BIGINT NOT NULL COMMENT '稳定费用来源ID',
source_version INT NOT NULL COMMENT '命令结果版本', created_at DATETIME(3) NOT NULL COMMENT '命令创建时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_finance_fee_command(operator_id,action,idempotency_key),
KEY idx_ll_finance_fee_command_source(source_fee_id,source_version)
) ENGINE=InnoDB COMMENT='海外费用来源写操作幂等审计';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='ll_cargo_discard_application' AND column_name='review_request_hash'),
'SELECT 1', 'ALTER TABLE ll_cargo_discard_application ADD COLUMN review_request_hash CHAR(64) NULL COMMENT ''审核请求内容哈希'' AFTER review_idempotency_key'));
PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt; DEALLOCATE PREPARE phase4_discard_review_stmt;
CREATE TABLE IF NOT EXISTS ll_order_fee (
id BIGINT NOT NULL COMMENT '费用明细主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
order_id BIGINT NOT NULL COMMENT '尾程订单ID', fee_item VARCHAR(128) NOT NULL COMMENT '费用项目',
currency VARCHAR(3) NOT NULL COMMENT '币种代码', amount DECIMAL(18,2) NOT NULL COMMENT '费用金额',
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '费用状态:PENDING_REVIEW待审核、APPROVED审核通过、REJECTED已驳回',
supplier_description VARCHAR(500) NULL COMMENT '供应商或费用说明', created_by VARCHAR(128) NOT NULL COMMENT '创建操作人',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(id), KEY idx_ll_order_fee_customer_order(customer_id,order_id,created_at)
) ENGINE=InnoDB COMMENT='尾程订单费用明细';
CREATE TABLE IF NOT EXISTS ll_order_operation_log (
id BIGINT NOT NULL COMMENT '操作记录主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
order_id BIGINT NOT NULL COMMENT '尾程订单ID', operation_node VARCHAR(64) NOT NULL COMMENT '操作节点',
operator_name VARCHAR(128) NOT NULL COMMENT '操作人名称', result VARCHAR(500) NOT NULL COMMENT '处理结果',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间',
PRIMARY KEY(id), KEY idx_ll_order_operation_customer_order(customer_id,order_id,created_at)
) ENGINE=InnoDB COMMENT='尾程订单操作记录';
-- 派送单列表与详情快照、转派送费用及操作记录。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='overseas_warehouse_name'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT ''海外仓名称快照'', ADD COLUMN customer_name VARCHAR(255) NULL COMMENT ''客户名称快照'', ADD COLUMN waybill_no VARCHAR(128) NULL COMMENT ''承运运单号'', ADD COLUMN delivery_method VARCHAR(32) NULL COMMENT ''派送方式'', ADD COLUMN carrier_name VARCHAR(255) NULL COMMENT ''派送承运商'', ADD COLUMN pickup_method VARCHAR(64) NULL COMMENT ''提货方式'', ADD COLUMN loading_type VARCHAR(64) NULL COMMENT ''装车类型'', ADD COLUMN vehicle_type VARCHAR(64) NULL COMMENT ''车辆类型'', ADD COLUMN vehicle_size VARCHAR(64) NULL COMMENT ''车辆大小'', ADD COLUMN vehicle_plate VARCHAR(64) NULL COMMENT ''车牌号'', ADD COLUMN driver_name VARCHAR(128) NULL COMMENT ''司机或车队名称'', ADD COLUMN driver_phone VARCHAR(64) NULL COMMENT ''车队联系电话'', ADD COLUMN appointment_account VARCHAR(128) NULL COMMENT ''预约账号'', ADD COLUMN seller_name VARCHAR(128) NULL COMMENT ''卖家姓名'', ADD COLUMN total_weight_kg DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''总重量(千克)'', ADD COLUMN total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''总体积(立方米)'', ADD COLUMN planned_delivery_at DATETIME(3) NULL COMMENT ''预计派送时间'', ADD COLUMN dispatched_at DATETIME(3) NULL COMMENT ''实际派送时间'', ADD COLUMN expected_arrival_at DATETIME(3) NULL COMMENT ''预计到达时间'', ADD COLUMN signed_at DATETIME(3) NULL COMMENT ''签收时间'', ADD COLUMN remark VARCHAR(1000) NULL COMMENT ''派送备注'''));
PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt;
CREATE TABLE IF NOT EXISTS ll_delivery_fee (id BIGINT NOT NULL COMMENT '派送费用主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '派送单ID', fee_direction VARCHAR(16) NOT NULL DEFAULT 'PAYABLE' COMMENT '应收应付方向', fee_type VARCHAR(64) NOT NULL COMMENT '费用类型', currency VARCHAR(8) NOT NULL COMMENT '币种', amount DECIMAL(18,2) NOT NULL COMMENT '费用金额', remark VARCHAR(500) NULL COMMENT '费用备注', created_by VARCHAR(128) NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), KEY idx_ll_delivery_fee_order(customer_id,delivery_order_id,id)) ENGINE=InnoDB COMMENT='派送单费用明细';
CREATE TABLE IF NOT EXISTS ll_delivery_operation (id BIGINT NOT NULL COMMENT '派送操作主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '派送单ID', operation_node VARCHAR(64) NOT NULL COMMENT '操作节点', operator_id VARCHAR(128) NULL COMMENT '操作人ID', operator_name VARCHAR(128) NULL COMMENT '操作人名称', result VARCHAR(500) NOT NULL COMMENT '处理结果', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', PRIMARY KEY(id), KEY idx_ll_delivery_operation_order(customer_id,delivery_order_id,created_at,id)) ENGINE=InnoDB COMMENT='派送单操作记录';
CREATE TABLE IF NOT EXISTS ll_exception_order (
id BIGINT NOT NULL COMMENT '异常订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', exception_no VARCHAR(64) NOT NULL COMMENT '异常订单号',
exception_type VARCHAR(32) NOT NULL COMMENT '异常类型', status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '异常处理状态', reason VARCHAR(1000) NOT NULL COMMENT '异常原因',
last_mile_order_id BIGINT NULL COMMENT '关联尾程订单ID', last_mile_order_no VARCHAR(64) NULL COMMENT '关联尾程订单号', inbound_order_id BIGINT NULL COMMENT '关联入仓单ID', inbound_code VARCHAR(128) NULL COMMENT '关联入仓码',
delivery_order_id BIGINT NULL COMMENT '关联派送单ID', delivery_no VARCHAR(64) NULL COMMENT '关联派送单号', outbound_no VARCHAR(64) NULL COMMENT '关联出库单号',
customer_name VARCHAR(255) NULL COMMENT '客户名称快照', overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID快照', overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照',
sku_code VARCHAR(128) NULL COMMENT '异常货物SKU', package_count INT NULL COMMENT '异常箱数', return_direction VARCHAR(32) NULL COMMENT '退回方向', return_logistics_no VARCHAR(128) NULL COMMENT '退回物流单号',
handling_action VARCHAR(500) NULL COMMENT '异常处理措施', handler_id VARCHAR(128) NULL COMMENT '处理人ID', handler_name VARCHAR(128) NULL COMMENT '处理人名称',
source_type VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT '异常来源类型', created_by VARCHAR(128) NULL COMMENT '创建人ID',
exception_at DATETIME(3) NOT NULL COMMENT '异常发生时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_exception_no(customer_id,exception_no),
UNIQUE KEY uk_ll_exception_delivery_return(customer_id,delivery_order_id,exception_type),
KEY idx_ll_exception_list(customer_id,status,exception_at), KEY idx_ll_exception_tail(customer_id,last_mile_order_no),
KEY idx_ll_exception_inbound(customer_id,inbound_code)
) ENGINE=InnoDB COMMENT='海外仓异常订单';
CREATE TABLE IF NOT EXISTS ll_customer_visit (
id BIGINT NOT NULL COMMENT '客户回访记录ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
delivery_order_id BIGINT NOT NULL COMMENT '关联派送单ID',
delivery_no VARCHAR(64) NOT NULL COMMENT '关联派送单号',
customer_name VARCHAR(255) NULL COMMENT '客户名称快照',
contact_name VARCHAR(128) NULL COMMENT '客户联系人姓名快照',
contact_phone VARCHAR(64) NULL COMMENT '客户联系人电话快照',
destination VARCHAR(500) NULL COMMENT '派送目的仓编码汇总快照',
delivery_completed_at DATETIME(3) NOT NULL COMMENT '派送完成时间',
status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '回访状态:PENDING待回访或COMPLETED已回访',
selected_last_mile_order_id BIGINT NULL COMMENT '本次回访选择的尾程订单ID',
selected_last_mile_order_no VARCHAR(64) NULL COMMENT '本次回访选择的尾程订单号',
selected_inbound_order_id BIGINT NULL COMMENT '本次回访选择的入仓单ID',
selected_inbound_code VARCHAR(128) NULL COMMENT '本次回访选择的入仓码',
visit_result VARCHAR(32) NULL COMMENT '最近一次回访结果',
customer_feedback VARCHAR(2000) NULL COMMENT '最近一次客户反馈',
remark VARCHAR(1000) NULL COMMENT '最近一次回访备注',
attachment_json JSON NULL COMMENT '当前回访资料文件元数据列表',
visited_by VARCHAR(128) NULL COMMENT '最近一次回访操作人ID',
visited_at DATETIME(3) NULL COMMENT '最近一次回访时间',
visit_count INT NOT NULL DEFAULT 0 COMMENT '累计保存回访次数',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(id),
UNIQUE KEY uk_ll_customer_visit_delivery(customer_id,delivery_order_id),
KEY idx_ll_customer_visit_list(customer_id,status,delivery_completed_at),
KEY idx_ll_customer_visit_delivery_no(customer_id,delivery_no)
) ENGINE=InnoDB COMMENT='派送单维度客户回访记录';
-- ============================================================================
-- 2026-08-27 / local legacy inventory-ledger cleanup
-- ============================================================================
USE baiyue_lastleg;
-- These quantity columns are absent from the current ledger contract. Legacy
-- NOT NULL definitions without defaults block package-only ledger writes.
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='before_quantity'), 'ALTER TABLE ll_inventory_ledger DROP COLUMN before_quantity', 'SELECT 1'));
PREPARE lastleg_ledger_legacy_001 FROM @ddl; EXECUTE lastleg_ledger_legacy_001; DEALLOCATE PREPARE lastleg_ledger_legacy_001;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='delta_quantity'), 'ALTER TABLE ll_inventory_ledger DROP COLUMN delta_quantity', 'SELECT 1'));
PREPARE lastleg_ledger_legacy_002 FROM @ddl; EXECUTE lastleg_ledger_legacy_002; DEALLOCATE PREPARE lastleg_ledger_legacy_002;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='after_quantity'), 'ALTER TABLE ll_inventory_ledger DROP COLUMN after_quantity', 'SELECT 1'));
PREPARE lastleg_ledger_legacy_003 FROM @ddl; EXECUTE lastleg_ledger_legacy_003; DEALLOCATE PREPARE lastleg_ledger_legacy_003;
@@ -59,6 +59,33 @@ test('entry accepts a separate overseas Gateway without exposing token state', (
assert.match(source, /forbiddenValues = \[this\.options\.password, this\.options\.merchantPassword,[\s\S]*this\.token, this\.refreshToken, this\.overseasToken\]/)
})
test('overseas business keeps its marker-scoped customer fixture with reusable credentials configured', () => {
const previousUsername = process.env.MERCHANT_TEST_USERNAME
const previousPassword = process.env.MERCHANT_TEST_PASSWORD
process.env.MERCHANT_TEST_USERNAME = 'reusable-client'
process.env.MERCHANT_TEST_PASSWORD = 'reusable-password'
try {
const modules = parseArguments(['--modules', 'phase4-overseas-business']).modules
assert.ok(modules.includes('merchant-qualification-fixture'))
assert.ok(modules.indexOf('merchant-qualification-fixture') < modules.indexOf('phase4-overseas-business'))
} finally {
if (previousUsername === undefined) delete process.env.MERCHANT_TEST_USERNAME
else process.env.MERCHANT_TEST_USERNAME = previousUsername
if (previousPassword === undefined) delete process.env.MERCHANT_TEST_PASSWORD
else process.env.MERCHANT_TEST_PASSWORD = previousPassword
}
})
test('a fixed origin route does not require randomized order fixtures', () => {
const options = parseArguments(['--origin-warehouse-name', '东莞仓', '--modules', 'phase4-overseas-pda'])
assert.equal(options.originWarehouseName, '东莞仓')
assert.equal(options.routeMode, 'fixed')
assert.equal(options.orderProfileMode, 'fixed')
assert.match(source, /else if \(this\.options\.originWarehouseName \|\| this\.options\.channelName \|\| this\.options\.channelCode\)/)
assert.match(source, /this\.context\.waitSplitRoute = \{[\s\S]*randomize: false/)
assert.match(source, /this\.context\.allowAnyExistingPallet = true/)
})
test('dual-token refresh replaces the whole session only after a complete response', async () => {
const run = new AcceptanceRun({ gateway: 'http://localhost:9000', overseasGateway: 'http://localhost:9300',
username: 'contract-user', password: 'contract-password', modules: [], output: '' })