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:
+311
-793
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;
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
+18
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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` 可用入仓片段与满箱数;仅要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均保持整组原子 |
|
||||
|
||||
+5
@@ -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;
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+2
@@ -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) { }
|
||||
}
|
||||
|
||||
+3
-2
@@ -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);
|
||||
|
||||
@@ -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`,车辆档案不含派送履约历史 |
|
||||
|
||||
+2
-2
@@ -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()) {
|
||||
|
||||
+2
-2
@@ -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 } }
|
||||
+56
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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: '' })
|
||||
|
||||
Reference in New Issue
Block a user