# Conflicts:
#	scripts/admin-test-env-modules/orders.mjs
#	scripts/admin-test-env-modules/phase3-container-loading.mjs
This commit is contained in:
2026-08-20 18:24:23 +08:00
57 changed files with 1135 additions and 95 deletions
@@ -11241,6 +11241,11 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_detail (
supplier_id BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空',
supplier_name VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空',
supplier_category VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商',
settlement_mode VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT账期应付、DIRECT_PAID业务已付;RECEIVABLE为空',
paid_at DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间',
payment_method VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式',
payment_reference_no VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号',
payment_voucher_file_ids JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组',
business_date DATE NOT NULL COMMENT '费用业务日期',
amount DECIMAL(18,2) NOT NULL COMMENT 'CNY费用金额',
currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '币种,首期固定CNY',
@@ -11263,6 +11268,15 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_detail (
CONSTRAINT chk_container_fee_currency CHECK (currency = 'CNY'),
CONSTRAINT chk_container_fee_amount CHECK (amount >= 0),
CONSTRAINT chk_container_fee_review CHECK (review_status IN ('PENDING', 'APPROVED', 'REJECTED')),
CONSTRAINT chk_container_fee_settlement CHECK (
(direction='RECEIVABLE' AND settlement_mode IS NULL AND paid_at IS NULL AND payment_method IS NULL
AND payment_reference_no IS NULL AND payment_voucher_file_ids IS NULL)
OR (direction='PAYABLE' AND settlement_mode='ON_ACCOUNT' AND paid_at IS NULL AND payment_method IS NULL
AND payment_reference_no IS NULL AND payment_voucher_file_ids IS NULL)
OR (direction='PAYABLE' AND settlement_mode='DIRECT_PAID' AND paid_at IS NOT NULL
AND payment_method IN ('ONLINE','BANK_TRANSFER','CASH','OTHER')
AND (payment_reference_no IS NOT NULL OR JSON_LENGTH(payment_voucher_file_ids)>0))
),
CONSTRAINT chk_container_fee_source CHECK (source_status IN ('ACTIVE', 'VOIDED'))
) ENGINE=InnoDB COMMENT='柜次级人工应收应付费用来源;审核通过后自动同步Finance';
@@ -11281,6 +11295,11 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_source_snapshot (
supplier_id BIGINT UNSIGNED NULL COMMENT 'Warehouse供应商ID快照',
supplier_name VARCHAR(200) NULL COMMENT 'Warehouse供应商名称快照',
supplier_category VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商',
settlement_mode VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT账期应付、DIRECT_PAID业务已付;RECEIVABLE为空',
paid_at DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间',
payment_method VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式',
payment_reference_no VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号',
payment_voucher_file_ids JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组',
business_date DATE NOT NULL COMMENT '费用业务日期',
amount DECIMAL(18,2) NOT NULL COMMENT 'CNY费用金额',
currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '币种,首期固定CNY',
@@ -11324,12 +11343,42 @@ CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_name',
"VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空' AFTER supplier_id");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_category',
"VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商' AFTER supplier_name");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'settlement_mode',
"VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT或DIRECT_PAID' AFTER supplier_category");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'paid_at',
"DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间' AFTER settlement_mode");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'payment_method',
"VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式' AFTER paid_at");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'payment_reference_no',
"VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号' AFTER payment_method");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'payment_voucher_file_ids',
"JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组' AFTER payment_reference_no");
CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'settlement_mode',
"VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT或DIRECT_PAID' AFTER supplier_category");
CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'paid_at',
"DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间' AFTER settlement_mode");
CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'payment_method',
"VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式' AFTER paid_at");
CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'payment_reference_no',
"VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号' AFTER payment_method");
CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'payment_voucher_file_ids',
"JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组' AFTER payment_reference_no");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id',
"BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空' AFTER direction");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_name',
"VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空' AFTER supplier_id");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_category',
"VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商' AFTER supplier_name");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'settlement_mode',
"VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT或DIRECT_PAID' AFTER supplier_category");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'paid_at',
"DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间' AFTER settlement_mode");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'payment_method',
"VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式' AFTER paid_at");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'payment_reference_no',
"VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号' AFTER payment_method");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'payment_voucher_file_ids',
"JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组' AFTER payment_reference_no");
DROP PROCEDURE IF EXISTS firstleg_finance_add_column;
DROP PROCEDURE IF EXISTS firstleg_finance_assert_schema;
@@ -11352,12 +11401,22 @@ BEGIN
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fl_container_fee_detail'
AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category')) <> 3 THEN
AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category', 'settlement_mode',
'paid_at', 'payment_method', 'payment_reference_no',
'payment_voucher_file_ids')) <> 8 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:柜次费用供应商快照字段不完整';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fl_container_fee_source_snapshot'
AND column_name IN ('settlement_mode', 'paid_at', 'payment_method',
'payment_reference_no', 'payment_voucher_file_ids')) <> 5 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:柜次费用已付版本快照字段不完整';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_fee_detail'
AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category')) <> 3 THEN
AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category', 'settlement_mode',
'paid_at', 'payment_method', 'payment_reference_no',
'payment_voucher_file_ids')) <> 8 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:入库费用供应商快照字段不完整';
END IF;
END$$
@@ -11399,6 +11458,16 @@ 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',
"CHAR(64) NULL COMMENT 'Firstleg费用来源完整快照SHA-256哈希' AFTER currency_code");
CALL finance_fee_source_add_column('fin_fee_source', 'settlement_mode',
"VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT账期应付、DIRECT_PAID业务已付;RECEIVABLE为空' AFTER currency_code");
CALL finance_fee_source_add_column('fin_fee_source', 'paid_at',
"DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间' AFTER settlement_mode");
CALL finance_fee_source_add_column('fin_fee_source', 'payment_method',
"VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式' AFTER paid_at");
CALL finance_fee_source_add_column('fin_fee_source', 'payment_reference_no',
"VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号' AFTER payment_method");
CALL finance_fee_source_add_column('fin_fee_source', 'payment_voucher_file_ids',
"JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组' AFTER payment_reference_no");
CALL finance_fee_source_add_column('fin_fee_source', 'source_snapshot',
"JSON NULL COMMENT 'Firstleg费用来源完整不可变JSON快照' AFTER source_hash");
CALL finance_fee_source_add_column('fin_fee_source', 'review_status',
@@ -11458,8 +11527,9 @@ BEGIN
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fin_fee_source'
AND column_name IN ('source_type','source_no','direction','container_id','fee_item_code',
'source_hash','source_snapshot','review_status','review_reason',
'operation_version','updated_at')) <> 11 THEN
'settlement_mode','paid_at','payment_method','payment_reference_no',
'payment_voucher_file_ids','source_hash','source_snapshot','review_status','review_reason',
'operation_version','updated_at')) <> 16 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance费用来源迁移失败:来源快照字段不完整';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
@@ -12,6 +12,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
@FeignClient(name = "firstleg-service", contextId = "financeFirstlegFeeSourceClient",
url = "${baiyue.clients.firstleg.url:}", path = "/internal/firstleg/v1")
@@ -45,6 +46,8 @@ public interface FirstlegFeeSourceClient {
Long containerBookingId, String containerNo, long customerId,
String customerName, Long supplierId, String supplierName,
String supplierCategory,
String settlementMode, LocalDateTime paidAt, String paymentMethod,
String paymentReferenceNo, List<Long> paymentVoucherFileIds,
Long feeItemId, String feeItemCode,
String feeItemName, String direction, LocalDate businessDate,
String currency, BigDecimal totalAmount, String reviewStatus,
@@ -78,7 +78,7 @@ public class PlatformFinanceController {
}
@PostMapping(value = "/vouchers", consumes = "multipart/form-data")
@PreAuthorize("hasAnyAuthority('platform:finance:receipt:create','platform:finance:cost:create')")
@PreAuthorize("hasAnyAuthority('platform:finance:receipt:create','platform:finance:cost:create','platform:finance:fee-source:edit')")
public ApiResponse<FinanceFileClient.Metadata> uploadVoucher(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long customerId,
@RequestPart("file") MultipartFile file) {
@@ -28,7 +28,8 @@ public final class FinancePhase1Dtos {
public record AccountSummary(long customerId, String customerCode, String customerName,
String contactName, String contactPhone,
BigDecimal outstandingReceivable, BigDecimal pendingBilling,
BigDecimal receivedInPeriod, BigDecimal outstandingPayable,
BigDecimal receivedInPeriod, BigDecimal totalCost,
BigDecimal paidCost, BigDecimal outstandingPayable,
String status, LocalDateTime lastChangedAt) {}
public record LedgerEntry(long ledgerId, LocalDateTime occurredAt, String businessType,
@@ -26,6 +26,8 @@ public final class FinanceReadModels {
BigDecimal receivedAmount,
BigDecimal outstandingReceivable,
BigDecimal pendingBilling,
BigDecimal totalCost,
BigDecimal paidCost,
BigDecimal outstandingPayable,
Instant asOf,
LocalDateTime lastChangedAt) {}
@@ -53,6 +55,11 @@ public final class FinanceReadModels {
String feeItemName,
BigDecimal amount,
String currency,
String settlementMode,
LocalDateTime paidAt,
String paymentMethod,
String paymentReferenceNo,
List<String> paymentVoucherFileIds,
String status,
LocalDateTime createdAt) {}
@@ -50,7 +50,8 @@ public class FinanceCustomerAccountService {
Metrics value = metrics.getOrDefault(customer.id(), Metrics.ZERO);
return new AccountSummary(customer.id(), customer.tenantCode(), customer.tenantName(),
customer.contactName(), customer.contactPhone(), value.outstandingReceivable(),
value.pendingBilling(), value.receivedInPeriod(), value.outstandingPayable(),
value.pendingBilling(), value.receivedInPeriod(), value.totalCost(), value.paidCost(),
value.outstandingPayable(),
customer.status(), value.lastChangedAt());
}).toList();
return PageEnvelope.success(rows, (int) customers.page(), (int) customers.size(), customers.total());
@@ -67,11 +68,13 @@ 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 tenant_id customer_id,SUM(cost_amount) amount FROM fin_cost WHERE tenant_id IN (" + placeholders + ") AND status<>'VOIDED' GROUP BY tenant_id", customerIds.toArray())
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 status NOT IN ('VOIDED','SUPERSEDED') UNION ALL SELECT tenant_id customer_id,cost_amount amount FROM fin_cost WHERE tenant_id IN (" + placeholders + ") 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 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 tenant_id customer_id,SUM(outstanding_amount) amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='PAYABLE' AND status<>'VOIDED' GROUP BY tenant_id", customerIds.toArray())
.forEach(row -> metric(result, row, "outstandingPayable", "amount"));
Object[] changeArgs = new Object[customerIds.size() * 3];
for (int i = 0; i < 3; i++) System.arraycopy(customerIds.toArray(), 0, changeArgs, i * customerIds.size(), customerIds.size());
jdbc.queryForList("SELECT customer_id,MAX(updated_at) last_changed_at FROM (SELECT tenant_id customer_id,updated_at FROM fin_bill WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_receipt WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_cost WHERE tenant_id IN (" + placeholders + ")) changes GROUP BY customer_id", changeArgs)
jdbc.queryForList("SELECT customer_id,MAX(updated_at) last_changed_at FROM (SELECT tenant_id customer_id,updated_at FROM fin_bill WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_receipt WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_cost WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT customer_id,updated_at FROM fin_fee_source WHERE customer_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_settlement WHERE tenant_id IN (" + placeholders + ")) changes GROUP BY customer_id", concat(customerIds, 5))
.forEach(row -> metric(result, row, "lastChangedAt", "last_changed_at"));
Map<Long, Metrics> frozen = new HashMap<>();
result.forEach((id, value) -> frozen.put(id, value.freeze()));
@@ -98,10 +101,19 @@ public class FinanceCustomerAccountService {
private static String blankToNull(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static Object[] concat(List<Long> values, int copies) {
Object[] result = new Object[values.size() * copies];
Object[] source = values.toArray();
for (int i = 0; i < copies; i++) System.arraycopy(source, 0, result, i * source.length, source.length);
return result;
}
private static final class MutableMetrics {
private BigDecimal outstandingReceivable = BigDecimal.ZERO;
private BigDecimal pendingBilling = BigDecimal.ZERO;
private BigDecimal receivedInPeriod = BigDecimal.ZERO;
private BigDecimal totalCost = BigDecimal.ZERO;
private BigDecimal paidCost = BigDecimal.ZERO;
private BigDecimal outstandingPayable = BigDecimal.ZERO;
private LocalDateTime lastChangedAt;
void set(String field, BigDecimal value) {
@@ -109,16 +121,20 @@ public class FinanceCustomerAccountService {
case "outstandingReceivable" -> outstandingReceivable = value;
case "pendingBilling" -> pendingBilling = value;
case "receivedInPeriod" -> receivedInPeriod = value;
case "totalCost" -> totalCost = value;
case "paidCost" -> paidCost = value;
case "outstandingPayable" -> outstandingPayable = value;
default -> { }
}
}
Metrics freeze() { return new Metrics(outstandingReceivable, pendingBilling, receivedInPeriod, outstandingPayable, lastChangedAt); }
Metrics freeze() { return new Metrics(outstandingReceivable, pendingBilling, receivedInPeriod, totalCost, paidCost, outstandingPayable, lastChangedAt); }
}
private record Metrics(BigDecimal outstandingReceivable, BigDecimal pendingBilling,
BigDecimal receivedInPeriod, BigDecimal outstandingPayable,
BigDecimal receivedInPeriod, BigDecimal totalCost, BigDecimal paidCost,
BigDecimal outstandingPayable,
LocalDateTime lastChangedAt) {
private static final Metrics ZERO = new Metrics(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, null);
private static final Metrics ZERO = new Metrics(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO,
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, null);
}
}
@@ -65,7 +65,8 @@ public class FinanceExportProviderService {
column("customerCode", "客户编码", ExportColumnType.STRING, 18), column("customerName", "客户", ExportColumnType.STRING, 28),
column("contactName", "联系人", ExportColumnType.STRING, 16), column("contactPhone", "联系电话", ExportColumnType.STRING, 18),
column("outstandingReceivable", "待收(CNY)", ExportColumnType.DECIMAL, 16), column("pendingBilling", "待开账(CNY)", ExportColumnType.DECIMAL, 16),
column("receivedInPeriod", "本期已收(CNY)", ExportColumnType.DECIMAL, 16), column("outstandingPayable", "待付(CNY)", ExportColumnType.DECIMAL, 16),
column("receivedInPeriod", "本期已收(CNY)", ExportColumnType.DECIMAL, 16), column("totalCost", "总成本(CNY)", ExportColumnType.DECIMAL, 16),
column("paidCost", "已付成本(CNY)", ExportColumnType.DECIMAL, 16), column("outstandingPayable", "待付(CNY)", ExportColumnType.DECIMAL, 16),
column("status", "客户状态", ExportColumnType.STRING, 14), column("lastChangedAt", "最近变动", ExportColumnType.DATETIME, 21));
private static final List<ExportColumn> LEDGER_COLUMNS = List.of(
column("ledgerId", "流水号", ExportColumnType.STRING, 22), column("occurredAt", "发生时间", ExportColumnType.DATETIME, 21),
@@ -366,7 +367,8 @@ public class FinanceExportProviderService {
private Map<String, Object> accountRow(AccountSummary value) {
return row("customerCode", value.customerCode(), "customerName", value.customerName(), "contactName", value.contactName(),
"contactPhone", value.contactPhone(), "outstandingReceivable", value.outstandingReceivable(), "pendingBilling", value.pendingBilling(),
"receivedInPeriod", value.receivedInPeriod(), "outstandingPayable", value.outstandingPayable(), "status", value.status(), "lastChangedAt", value.lastChangedAt());
"receivedInPeriod", value.receivedInPeriod(), "totalCost", value.totalCost(), "paidCost", value.paidCost(),
"outstandingPayable", value.outstandingPayable(), "status", value.status(), "lastChangedAt", value.lastChangedAt());
}
private Map<String, Object> ledgerRow(LedgerEntry value) {
return row("ledgerId", String.valueOf(value.ledgerId()), "occurredAt", value.occurredAt(), "businessType", value.businessType(),
@@ -73,6 +73,7 @@ public class FinanceFeeSourceEventService {
long id = ((Number) row.get("id")).longValue();
if ("FIRSTLEG_FEE_VOIDED".equals(event.eventType())) {
jdbc.update("UPDATE fin_fee_source SET status='VOIDED',operation_version=operation_version+1 WHERE id=? AND status<>'VOIDED'", id);
voidDirectPaidCost(snapshot);
}
return id;
}
@@ -81,14 +82,37 @@ public class FinanceFeeSourceEventService {
}
jdbc.update("UPDATE fin_fee_source SET status='SUPERSEDED' WHERE source_system='FIRSTLEG' AND source_fee_id=? AND status='AVAILABLE'",
snapshot.feeId());
jdbc.update("INSERT INTO fin_fee_source(source_system,source_fee_id,source_fee_version,customer_id,source_type,source_no,direction,order_id,inbound_id,container_id,fee_item_code,fee_item_name,amount,currency_code,source_hash,source_snapshot,status) VALUES ('FIRSTLEG',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'AVAILABLE')",
jdbc.update("UPDATE fin_cost SET status='VOIDED',voided_at=CURRENT_TIMESTAMP(3),void_reason='来源版本已替代',version=version+1,updated_by=0 WHERE cost_origin='FIRSTLEG_DIRECT_PAID' AND source_system='FIRSTLEG' AND source_fee_id=? AND status='CONFIRMED'",
snapshot.feeId());
jdbc.update("INSERT INTO fin_fee_source(source_system,source_fee_id,source_fee_version,customer_id,source_type,source_no,direction,order_id,inbound_id,container_id,fee_item_code,fee_item_name,amount,currency_code,settlement_mode,paid_at,payment_method,payment_reference_no,payment_voucher_file_ids,source_hash,source_snapshot,status) VALUES ('FIRSTLEG',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'AVAILABLE')",
snapshot.feeId(), snapshot.feeVersion(), snapshot.customerId(), snapshot.sourceType(),
snapshot.sourceType().equals("INBOUND_FEE") ? snapshot.inboundCode() : snapshot.containerNo(),
snapshot.direction(), snapshot.orderId(), snapshot.inboundId(), snapshot.containerId(),
snapshot.feeItemCode(), snapshot.feeItemName(), snapshot.totalAmount(), snapshot.currency(),
snapshot.settlementMode(), snapshot.paidAt(), snapshot.paymentMethod(),
snapshot.paymentReferenceNo(), voucherJson(snapshot.paymentVoucherFileIds()),
snapshot.sourceHash(), json(snapshot));
return jdbc.queryForObject("SELECT id FROM fin_fee_source WHERE source_system='FIRSTLEG' AND source_fee_id=? AND source_fee_version=?",
long sourceId = jdbc.queryForObject("SELECT id FROM fin_fee_source WHERE source_system='FIRSTLEG' AND source_fee_id=? AND source_fee_version=?",
Long.class, snapshot.feeId(), snapshot.feeVersion());
if ("PAYABLE".equals(snapshot.direction()) && "DIRECT_PAID".equals(snapshot.settlementMode())) {
materializeDirectPaidCost(snapshot);
}
return sourceId;
}
private void materializeDirectPaidCost(FirstlegFeeSourceClient.Snapshot snapshot) {
String costNo = "DIRECT-" + snapshot.sourceType() + "-" + snapshot.feeId() + "-V" + snapshot.feeVersion();
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','FIRSTLEG_DIRECT_PAID','FIRSTLEG',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED',CURRENT_TIMESTAMP(3),0,'系统',0)",
snapshot.customerId(), costNo, snapshot.sourceType(), snapshot.feeId(), snapshot.feeVersion(),
snapshot.sourceHash(), snapshot.orderId(), snapshot.inboundId(), snapshot.containerId(),
snapshot.supplierId(), snapshot.supplierName(), snapshot.feeItemCode(), snapshot.feeItemName(),
snapshot.currency(), snapshot.totalAmount(), voucherJsonOrEmpty(snapshot.paymentVoucherFileIds()),
snapshot.paidAt(), snapshot.paymentMethod(), snapshot.paymentReferenceNo());
}
private void voidDirectPaidCost(FirstlegFeeSourceClient.Snapshot snapshot) {
jdbc.update("UPDATE fin_cost SET status='VOIDED',voided_at=CURRENT_TIMESTAMP(3),void_reason='来源费用已作废',version=version+1,updated_by=0 WHERE cost_origin='FIRSTLEG_DIRECT_PAID' AND source_system='FIRSTLEG' AND source_type=? AND source_fee_id=? AND source_fee_version=? AND status='CONFIRMED'",
snapshot.sourceType(), snapshot.feeId(), snapshot.feeVersion());
}
private FirstlegFeeSourceClient.Snapshot loadSnapshot(FinanceFeeSourceEvent event) {
@@ -138,14 +162,48 @@ public class FinanceFeeSourceEventService {
if (!value.supplierName().equals(supplier.name())) {
throw error("FIN-EVT-012", "应付费用来源供应商名称快照不一致");
}
validatePayableSettlement(value);
} else if (value.supplierId() != null || !blank(value.supplierName())
|| !blank(value.supplierCategory())) {
throw error("FIN-EVT-012", "应收费用不得携带供应商快照");
} else if (!blank(value.settlementMode()) || value.paidAt() != null
|| !blank(value.paymentMethod()) || !blank(value.paymentReferenceNo())
|| (value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty())) {
throw error("FIN-EVT-012", "应收费用不得携带结算方式或已付快照");
}
if ("INBOUND_FEE".equals(value.sourceType()) && (value.inboundId() == null || !String.valueOf(value.inboundId()).equals(event.payload().inboundId()))) throw error("FIN-EVT-013", "入库单归属不一致");
if ("CONTAINER_FEE".equals(value.sourceType()) && (value.containerId() == null || !String.valueOf(value.containerId()).equals(event.payload().containerId()))) throw error("FIN-EVT-014", "柜子归属不一致");
}
private void validatePayableSettlement(FirstlegFeeSourceClient.Snapshot value) {
boolean hasVouchers = value.paymentVoucherFileIds() != null
&& !value.paymentVoucherFileIds().isEmpty();
if (hasVouchers && (value.paymentVoucherFileIds().size() > 20
|| value.paymentVoucherFileIds().stream().anyMatch(id -> id == null || id <= 0))) {
throw error("FIN-EVT-012", "付款凭证文件快照无效");
}
if ("ON_ACCOUNT".equals(value.settlementMode())) {
if (value.paidAt() != null || !blank(value.paymentMethod())
|| !blank(value.paymentReferenceNo()) || hasVouchers) {
throw error("FIN-EVT-012", "账期应付不得携带已付快照");
}
return;
}
if (!"DIRECT_PAID".equals(value.settlementMode()) || value.paidAt() == null
|| !Set.of("ONLINE", "BANK_TRANSFER", "CASH", "OTHER").contains(value.paymentMethod())
|| (blank(value.paymentReferenceNo()) && !hasVouchers)) {
throw error("FIN-EVT-012", "直接已付费用来源快照不完整");
}
}
private String voucherJson(java.util.List<Long> values) {
return values == null || values.isEmpty() ? null : json(values);
}
private String voucherJsonOrEmpty(java.util.List<Long> values) {
return values == null || values.isEmpty() ? "[]" : json(values);
}
private String json(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException e) { throw error("FIN-EVT-001", "事件序列化失败"); }
@@ -69,12 +69,16 @@ public class FinancePhase1OperationService {
"(SELECT COALESCE(SUM(outstanding_amount),0) FROM fin_bill b WHERE b.tenant_id=c.customer_id AND b.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.status='AVAILABLE' AND NOT EXISTS(SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version)) 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(cost_amount),0) FROM fin_cost x WHERE x.tenant_id=c.customer_id AND x.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')) last_changed_at" + base + " ORDER BY last_changed_at DESC,c.customer_id DESC LIMIT ? OFFSET ?";
"((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.status NOT IN ('VOIDED','SUPERSEDED'))+(SELECT COALESCE(SUM(cost_amount),0) FROM fin_cost x WHERE x.tenant_id=c.customer_id 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.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') 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);
args[args.length - 2] = pageSize; args[args.length - 1] = (page - 1) * pageSize;
List<AccountSummary> rows = jdbc.query(select, (rs, row) -> new AccountSummary(rs.getLong("customer_id"), String.valueOf(rs.getLong("customer_id")), rs.getString("customer_name"), null, null,
money(rs, "outstanding_receivable"), money(rs, "pending_billing"), money(rs, "received_period"), money(rs, "outstanding_payable"), rs.getString("account_status"), time(rs, "last_changed_at")), args);
money(rs, "outstanding_receivable"), money(rs, "pending_billing"), money(rs, "received_period"),
money(rs, "total_cost"), money(rs, "paid_cost"), money(rs, "outstanding_payable"),
rs.getString("account_status"), time(rs, "last_changed_at")), args);
return PageEnvelope.success(rows, page, pageSize, total == null ? 0 : total);
}
@@ -366,7 +370,20 @@ public class FinancePhase1OperationService {
private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw error("FIN-DATA-500", "财务数据序列化失败"); } }
private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw error("FIN-DATA-500", "幂等请求哈希失败"); } }
private static String text(JsonNode node, String field) { JsonNode value = node.path(field); return value.isMissingNode() || value.isNull() ? null : value.asText(); }
private static Long jsonLong(JsonNode node, String field) { JsonNode value = node.path(field); return value.isIntegralNumber() && value.asLong() > 0 ? value.asLong() : null; }
private static Long jsonLong(JsonNode node, String field) {
JsonNode value = node.path(field);
if (value.isIntegralNumber() && value.canConvertToLong()) {
long parsed = value.longValue();
return parsed > 0 ? parsed : null;
}
if (!value.isTextual()) return null;
try {
long parsed = Long.parseLong(value.textValue());
return parsed > 0 ? parsed : null;
} catch (NumberFormatException ignored) {
return null;
}
}
private static Long firstLong(Long first, Long second) { return first != null ? first : second; }
private static String firstText(String first, String second) { return StringUtils.hasText(first) ? first : second; }
private static String defaultText(JsonNode node, String field, String fallback) { String value = text(node, field); return value == null || value.isBlank() ? fallback : value; }
@@ -75,19 +75,26 @@ public class FinanceQueryService {
"AND f.review_status='APPROVED' AND f.amount>0 " +
"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.status NOT IN ('VOIDED','SUPERSEDED'))+" +
"(SELECT COALESCE(SUM(c.cost_amount),0) FROM fin_cost c WHERE c.tenant_id=? AND c.status='CONFIRMED')) total_cost," +
"((SELECT COALESCE(SUM(c.cost_amount),0) FROM fin_cost c WHERE c.tenant_id=? AND c.status='CONFIRMED')+" +
"COALESCE(SUM(CASE WHEN s.direction='PAYABLE' AND s.status<>'VOIDED' THEN s.paid_amount ELSE 0 END),0)) paid_cost," +
"GREATEST(COALESCE(MAX(CASE WHEN s.status<>'VOIDED' THEN s.updated_at END),'1970-01-01')," +
"COALESCE((SELECT MAX(f.updated_at) FROM fin_fee_source f WHERE f.customer_id=?),'1970-01-01')) last_changed_at " +
"COALESCE((SELECT MAX(f.updated_at) FROM fin_fee_source f WHERE f.customer_id=?),'1970-01-01')," +
"COALESCE((SELECT MAX(c.updated_at) FROM fin_cost c WHERE c.tenant_id=?),'1970-01-01')) last_changed_at " +
"FROM fin_settlement s WHERE s.tenant_id=?";
try {
return jdbc.queryForObject(sql, (rs, rowNum) -> new CustomerAccountSummary(customerId, CNY,
money(rs, "total_receivable"), money(rs, "received_amount"), money(rs, "outstanding_receivable"),
money(rs, "pending_billing"), money(rs, "outstanding_payable"), Instant.now(), time(rs, "last_changed_at")),
customerId, customerId, customerId);
money(rs, "pending_billing"), money(rs, "total_cost"), money(rs, "paid_cost"),
money(rs, "outstanding_payable"), Instant.now(), time(rs, "last_changed_at")),
customerId, customerId, customerId, customerId, customerId, customerId, customerId);
} catch (BadSqlGrammarException ignored) {
// Empty/partially migrated Finance databases may not have a settlement projection yet.
// The read-only account summary must still be usable as a zero CNY baseline.
return new CustomerAccountSummary(customerId, CNY, BigDecimal.ZERO, BigDecimal.ZERO,
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, Instant.now(), null);
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO,
Instant.now(), null);
}
}
@@ -96,7 +103,7 @@ public class FinanceQueryService {
String normalized = direction == null || direction.isBlank() ? "ALL" : direction.trim().toUpperCase(java.util.Locale.ROOT);
if (!List.of("ALL", "RECEIVABLE", "PAYABLE").contains(normalized)) throw bad("费用方向只能是RECEIVABLE或PAYABLE");
List<FeePoolItem> result = new ArrayList<>();
String sourceSql = "SELECT id,customer_id,direction,source_type,source_fee_id,source_fee_version,order_id,inbound_id,container_id,fee_item_name,amount,currency_code,status,created_at FROM fin_fee_source WHERE status='AVAILABLE'";
String sourceSql = "SELECT id,customer_id,direction,source_type,source_fee_id,source_fee_version,order_id,inbound_id,container_id,fee_item_name,amount,currency_code,settlement_mode,paid_at,payment_method,payment_reference_no,payment_voucher_file_ids,status,created_at FROM fin_fee_source WHERE status='AVAILABLE'";
List<Object> sourceArgs = new ArrayList<>();
if (customerId != null) { sourceSql += " AND customer_id=?"; sourceArgs.add(customerId); }
if (!"ALL".equals(normalized)) { sourceSql += " AND direction=?"; sourceArgs.add(normalized); }
@@ -107,18 +114,10 @@ public class FinanceQueryService {
rs.getString("source_type"), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"),
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), nullableLong(rs, "container_id"),
rs.getString("fee_item_name"), rs.getBigDecimal("amount"), rs.getString("currency_code"),
rs.getString("settlement_mode"), time(rs, "paid_at"), rs.getString("payment_method"),
rs.getString("payment_reference_no"), fileIds(rs.getString("payment_voucher_file_ids")),
rs.getString("status"), time(rs, "created_at"));
}, sourceArgs.toArray()));
if (!"RECEIVABLE".equals(normalized)) {
String costSql = "SELECT id,tenant_id,order_id,inbound_id,container_id,fee_item_name,cost_amount,currency_code,status,created_at FROM fin_cost";
Object[] costArgs = new Object[0];
if (customerId != null) { costSql += " WHERE tenant_id=?"; costArgs = new Object[]{customerId}; }
costSql += " ORDER BY created_at DESC,id DESC";
result.addAll(jdbc.query(costSql, (rs, rowNum) ->
new FeePoolItem("PAYABLE:" + rs.getLong("id"), rs.getLong("tenant_id"), "PAYABLE", "MANUAL_COST", rs.getLong("id"), 1,
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), nullableLong(rs, "container_id"),
rs.getString("fee_item_name"), rs.getBigDecimal("cost_amount"), rs.getString("currency_code"), rs.getString("status"), time(rs, "created_at")), costArgs));
}
result.sort(Comparator.comparing(FeePoolItem::createdAt, Comparator.nullsLast(Comparator.reverseOrder())));
return List.copyOf(result);
}
@@ -140,7 +140,7 @@ public class FinanceSettlementService {
List<Object> args = new ArrayList<>();
args.addAll(ids);
List<Map<String, Object>> sources = jdbc.queryForList(
"SELECT id,customer_id,direction,currency_code,status,amount,fee_item_code,fee_item_name,source_snapshot," +
"SELECT id,customer_id,direction,currency_code,status,amount,fee_item_code,fee_item_name,settlement_mode,source_snapshot," +
"review_status," +
"CAST(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierId')) AS UNSIGNED) supplier_id," +
"JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierName')) supplier_name," +
@@ -152,6 +152,9 @@ public class FinanceSettlementService {
for (Map<String, Object> source : sources) {
Long supplierId = nullableNumber(source, "supplier_id");
if (supplierId == null || supplierId != request.supplierId()) throw error("FIN_409", "应付来源必须属于同一供应商");
if (!"ON_ACCOUNT".equals(text(source, "settlement_mode"))) {
throw error("FIN_409", "只有ON_ACCOUNT账期应付费用可以生成应付结算单");
}
if (!"PAYABLE".equals(text(source, "direction")) || !"AVAILABLE".equals(text(source, "status"))
|| !"APPROVED".equals(text(source, "review_status")) || decimal(source, "amount").signum() <= 0) {
throw error("FIN_409", "费用来源未审核、金额无效或状态不可用");
@@ -462,7 +465,9 @@ public class FinanceSettlementService {
BigDecimal unitPrice = quantity == null || quantity.signum() == 0 ? null
: amount.divide(quantity, 6, java.math.RoundingMode.HALF_UP);
Long supplierId = jsonLong(snapshot, "supplierId");
if (supplierId == null) throw error("FIN_409", "应付结算缺少供应商快照");
if ("PAYABLE".equals(text(row, "direction")) && supplierId == null) {
throw error("FIN_409", "应付结算缺少供应商快照");
}
return new SettlementLine(number(row, "fee_source_id").longValue(), text(row, "source_type"),
number(row, "source_fee_id").longValue(), number(row, "source_fee_version").intValue(),
text(row, "direction"), number(row, "customer_id").longValue(), jsonText(snapshot, "customerName"),
@@ -594,7 +599,17 @@ public class FinanceSettlementService {
}
private static Long jsonLong(JsonNode node, String key) {
JsonNode value = node.path(key);
return value.isIntegralNumber() ? value.longValue() : null;
if (value.isIntegralNumber() && value.canConvertToLong()) {
long parsed = value.longValue();
return parsed > 0 ? parsed : null;
}
if (!value.isTextual()) return null;
try {
long parsed = Long.parseLong(value.textValue());
return parsed > 0 ? parsed : null;
} catch (NumberFormatException ignored) {
return null;
}
}
private static LocalDate jsonDate(JsonNode node, String key) {
String value = jsonText(node, key);
@@ -31,6 +31,16 @@ 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',
"CHAR(64) NULL COMMENT 'Firstleg费用来源完整快照SHA-256哈希' AFTER currency_code");
CALL finance_fee_source_add_column('fin_fee_source', 'settlement_mode',
"VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT账期应付、DIRECT_PAID业务已付;RECEIVABLE为空' AFTER currency_code");
CALL finance_fee_source_add_column('fin_fee_source', 'paid_at',
"DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间' AFTER settlement_mode");
CALL finance_fee_source_add_column('fin_fee_source', 'payment_method',
"VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式' AFTER paid_at");
CALL finance_fee_source_add_column('fin_fee_source', 'payment_reference_no',
"VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号' AFTER payment_method");
CALL finance_fee_source_add_column('fin_fee_source', 'payment_voucher_file_ids',
"JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组' AFTER payment_reference_no");
CALL finance_fee_source_add_column('fin_fee_source', 'source_snapshot',
"JSON NULL COMMENT 'Firstleg费用来源完整不可变JSON快照' AFTER source_hash");
CALL finance_fee_source_add_column('fin_fee_source', 'review_status',
@@ -55,9 +65,44 @@ CALL finance_fee_source_add_column('fin_inbox_event', 'result_resource_id',
"BIGINT UNSIGNED NULL COMMENT '成功处理生成或定位的费用来源快照ID' AFTER payload_hash");
CALL finance_fee_source_add_column('fin_inbox_event', 'error_code',
"VARCHAR(64) NULL COMMENT '处理失败时的稳定FIN错误码' AFTER result_resource_id");
CALL finance_fee_source_add_column('fin_cost', 'cost_origin',
"VARCHAR(24) NOT NULL DEFAULT 'MANUAL' COMMENT '成本来源:MANUAL人工补充、FIRSTLEG_DIRECT_PAID头程直接已付' AFTER cost_type");
CALL finance_fee_source_add_column('fin_cost', 'source_system',
"VARCHAR(32) NULL COMMENT '来源系统,头程直接已付固定FIRSTLEG' AFTER cost_origin");
CALL finance_fee_source_add_column('fin_cost', 'source_type',
"VARCHAR(32) NULL COMMENT '来源费用类型快照' AFTER source_system");
CALL finance_fee_source_add_column('fin_cost', 'source_fee_id',
"BIGINT UNSIGNED NULL COMMENT '来源费用ID' AFTER source_type");
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', 'paid_at',
"DATETIME(3) NULL COMMENT '直接已付时间' AFTER voucher_file_ids");
CALL finance_fee_source_add_column('fin_cost', 'payment_method',
"VARCHAR(32) NULL COMMENT '直接已付方式' AFTER paid_at");
CALL finance_fee_source_add_column('fin_cost', 'payment_reference_no',
"VARCHAR(128) NULL COMMENT '直接已付流水号' AFTER payment_method");
DROP PROCEDURE IF EXISTS finance_fee_source_add_column;
DROP PROCEDURE IF EXISTS finance_fee_source_add_index;
DELIMITER $$
CREATE PROCEDURE finance_fee_source_add_index()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.statistics
WHERE table_schema=DATABASE() AND table_name='fin_cost'
AND index_name='uk_fin_cost_source_version'
) THEN
ALTER TABLE fin_cost ADD UNIQUE KEY uk_fin_cost_source_version
(source_system, source_type, source_fee_id, source_fee_version);
END IF;
END$$
DELIMITER ;
CALL finance_fee_source_add_index();
DROP PROCEDURE IF EXISTS finance_fee_source_add_index;
-- 产品确认无历史财务数据;以下兼容值仅用于空环境重复迁移时保证结构收敛。
UPDATE fin_fee_source
SET source_type = COALESCE(source_type, 'INBOUND_FEE'),
@@ -90,8 +135,9 @@ BEGIN
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fin_fee_source'
AND column_name IN ('source_type','source_no','direction','container_id','fee_item_code',
'source_hash','source_snapshot','review_status','review_reason',
'operation_version','updated_at')) <> 11 THEN
'settlement_mode','paid_at','payment_method','payment_reference_no',
'payment_voucher_file_ids','source_hash','source_snapshot','review_status','review_reason',
'operation_version','updated_at')) <> 16 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance费用来源迁移失败:来源快照字段不完整';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
@@ -105,6 +151,18 @@ BEGIN
'customer_confirmed_by_name','customer_confirmed_at')) <> 4 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance客户对账单确认字段不完整';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
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
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance直接已付成本字段不完整';
END IF;
IF (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema=DATABASE() AND table_name='fin_cost'
AND index_name='uk_fin_cost_source_version') <> 4 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance直接已付成本唯一索引不完整';
END IF;
END$$
DELIMITER ;
CALL finance_fee_source_assert_schema();
+35 -3
View File
@@ -18,6 +18,11 @@ CREATE TABLE IF NOT EXISTS fin_fee_source (
fee_item_name VARCHAR(200) NOT NULL COMMENT '来源费用项目名称快照',
amount DECIMAL(20,6) NOT NULL COMMENT '来源费用金额,精度6位小数',
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
settlement_mode VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT账期应付、DIRECT_PAID业务已付;RECEIVABLE为空',
paid_at DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间',
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_snapshot JSON NOT NULL COMMENT 'Firstleg费用来源完整不可变JSON快照',
review_status VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT 'Finance费用状态:DRAFT草稿、PENDING待审核、APPROVED已通过、REJECTED已驳回',
@@ -30,7 +35,16 @@ CREATE TABLE IF NOT EXISTS fin_fee_source (
UNIQUE KEY uk_fin_fee_source (source_system, source_fee_id, source_fee_version),
KEY idx_fin_fee_source_customer (customer_id, status),
CONSTRAINT chk_fin_fee_source_currency CHECK (currency_code = 'CNY'),
CONSTRAINT chk_fin_fee_source_amount CHECK (amount >= 0)
CONSTRAINT chk_fin_fee_source_amount CHECK (amount >= 0),
CONSTRAINT chk_fin_fee_source_settlement CHECK (
(direction='RECEIVABLE' AND settlement_mode IS NULL AND paid_at IS NULL AND payment_method IS NULL
AND payment_reference_no IS NULL AND payment_voucher_file_ids IS NULL)
OR (direction='PAYABLE' AND settlement_mode='ON_ACCOUNT' AND paid_at IS NULL AND payment_method IS NULL
AND payment_reference_no IS NULL AND payment_voucher_file_ids IS NULL)
OR (direction='PAYABLE' AND settlement_mode='DIRECT_PAID' AND paid_at IS NOT NULL
AND payment_method IN ('ONLINE','BANK_TRANSFER','CASH','OTHER')
AND (payment_reference_no IS NOT NULL OR JSON_LENGTH(payment_voucher_file_ids)>0))
)
) ENGINE=InnoDB COMMENT='Firstleg费用来源快照池;账单人工选择并固化明细';
CREATE TABLE IF NOT EXISTS fin_inbox_event (
@@ -198,7 +212,13 @@ CREATE TABLE IF NOT EXISTS fin_cost (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
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整柜公共成本',
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',
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',
order_id BIGINT UNSIGNED NULL COMMENT '成本归属的头程订单ID,单票成本必填',
inbound_id BIGINT UNSIGNED NULL COMMENT '成本关联的入库单ID,可为空',
container_id BIGINT UNSIGNED NULL COMMENT 'Firstleg装柜计划ID,整柜公共成本必填',
@@ -209,6 +229,9 @@ CREATE TABLE IF NOT EXISTS fin_cost (
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
cost_amount DECIMAL(20,6) NOT NULL COMMENT '成本总金额,精度6位小数',
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',
payment_reference_no VARCHAR(128) NULL COMMENT '直接已付流水号',
status VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT '成本状态:DRAFT草稿、CONFIRMED已确认、VOIDED已作废',
remark VARCHAR(500) NULL COMMENT '平台成本内部备注',
confirmed_at DATETIME(3) NULL COMMENT '成本确认时间',
@@ -224,6 +247,7 @@ CREATE TABLE IF NOT EXISTS fin_cost (
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间,数据修改时自动刷新',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_cost_no (tenant_id, cost_no),
UNIQUE KEY uk_fin_cost_source_version (source_system, source_type, source_fee_id, source_fee_version),
KEY idx_fin_cost_ticket (tenant_id, order_id, inbound_id),
KEY idx_fin_cost_container (tenant_id, container_id),
CONSTRAINT chk_fin_cost_tenant CHECK (tenant_id > 0),
@@ -232,8 +256,16 @@ 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))
),
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)
OR (cost_origin='FIRSTLEG_DIRECT_PAID' AND source_system='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',
@@ -30,8 +30,8 @@ class CustomerAccountSummaryBehaviorTest {
@Test
void aggregatesFormalSettlementsInOneCustomerScopedQuery() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
ResultSet rs = summaryRow("300.00", "140.00", "160.00", "25.00", "80.00");
when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any())).thenAnswer(invocation ->
ResultSet rs = summaryRow("300.00", "140.00", "160.00", "25.00", "260.00", "180.00", "80.00");
when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any())).thenAnswer(invocation ->
((RowMapper<?>) invocation.getArgument(1)).mapRow(rs, 0));
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper());
@@ -43,15 +43,21 @@ class CustomerAccountSummaryBehaviorTest {
assertThat(result.receivedAmount()).isEqualByComparingTo("140.00");
assertThat(result.outstandingReceivable()).isEqualByComparingTo("160.00");
assertThat(result.pendingBilling()).isEqualByComparingTo("25.00");
assertThat(result.totalCost()).isEqualByComparingTo("260.00");
assertThat(result.paidCost()).isEqualByComparingTo("180.00");
assertThat(result.outstandingPayable()).isEqualByComparingTo("80.00");
var sql = org.mockito.ArgumentCaptor.forClass(String.class);
verify(jdbc).queryForObject(sql.capture(), any(RowMapper.class),
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(9L));
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(9L),
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(9L),
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(9L),
org.mockito.ArgumentMatchers.eq(9L));
assertThat(sql.getValue())
.contains("FROM fin_settlement s WHERE s.tenant_id=?")
.contains("s.direction='RECEIVABLE' AND s.status<>'VOIDED'")
.contains("s.direction='PAYABLE' AND s.status<>'VOIDED'")
.contains("f.settlement_mode='ON_ACCOUNT'", "c.status='CONFIRMED'", "s.paid_amount")
.contains("f.customer_id=?", "f.status='AVAILABLE'", "f.review_status='APPROVED'", "f.amount>0")
.contains("b.status<>'VOIDED'")
.doesNotContain("JSON_EXTRACT")
@@ -61,8 +67,8 @@ class CustomerAccountSummaryBehaviorTest {
@Test
void emptyCustomerReturnsZeroAmounts() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
ResultSet rs = summaryRow("0", "0", "0", "0", "0");
when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any())).thenAnswer(invocation ->
ResultSet rs = summaryRow("0", "0", "0", "0", "0", "0", "0");
when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any())).thenAnswer(invocation ->
((RowMapper<?>) invocation.getArgument(1)).mapRow(rs, 0));
CustomerAccountSummary result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(77L);
@@ -71,13 +77,15 @@ class CustomerAccountSummaryBehaviorTest {
assertThat(result.receivedAmount()).isZero();
assertThat(result.outstandingReceivable()).isZero();
assertThat(result.pendingBilling()).isZero();
assertThat(result.totalCost()).isZero();
assertThat(result.paidCost()).isZero();
assertThat(result.outstandingPayable()).isZero();
}
@Test
void missingSettlementProjectionReturnsZeroCnyBaseline() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any()))
when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any()))
.thenThrow(new BadSqlGrammarException("customer summary", "SELECT ...", null));
CustomerAccountSummary result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(88L);
@@ -88,6 +96,8 @@ class CustomerAccountSummaryBehaviorTest {
assertThat(result.receivedAmount()).isZero();
assertThat(result.outstandingReceivable()).isZero();
assertThat(result.pendingBilling()).isZero();
assertThat(result.totalCost()).isZero();
assertThat(result.paidCost()).isZero();
assertThat(result.outstandingPayable()).isZero();
assertThat(result.lastChangedAt()).isNull();
}
@@ -107,12 +117,15 @@ class CustomerAccountSummaryBehaviorTest {
}
private static ResultSet summaryRow(String total, String paid, String outstanding,
String pending, String payable) throws Exception {
String pending, String totalCost, String paidCost,
String payable) throws Exception {
ResultSet rs = mock(ResultSet.class);
when(rs.getBigDecimal("total_receivable")).thenReturn(new BigDecimal(total));
when(rs.getBigDecimal("received_amount")).thenReturn(new BigDecimal(paid));
when(rs.getBigDecimal("outstanding_receivable")).thenReturn(new BigDecimal(outstanding));
when(rs.getBigDecimal("pending_billing")).thenReturn(new BigDecimal(pending));
when(rs.getBigDecimal("total_cost")).thenReturn(new BigDecimal(totalCost));
when(rs.getBigDecimal("paid_cost")).thenReturn(new BigDecimal(paidCost));
when(rs.getBigDecimal("outstanding_payable")).thenReturn(new BigDecimal(payable));
when(rs.getTimestamp("last_changed_at")).thenReturn(Timestamp.valueOf(LocalDateTime.of(2026, 8, 13, 12, 0)));
return rs;
@@ -36,7 +36,7 @@ class FinanceFeePoolDirectionBehaviorTest {
assertThat(result).isNotEmpty().allMatch(item -> "PAYABLE".equals(item.direction()));
assertThat(jdbc.sourceDirections).containsExactly("PAYABLE");
assertThat(jdbc.costQueryCount).isEqualTo(1);
assertThat(jdbc.costQueryCount).isZero();
}
private static final class DirectionJdbc extends JdbcTemplate {
@@ -60,7 +60,8 @@ class FinanceFeePoolDirectionBehaviorTest {
private static FeePoolItem item(String direction, String sourceType, long sourceId) {
return new FeePoolItem(direction + ":" + sourceId, 7L, direction, sourceType, sourceId, 1,
11L, 21L, null, "清关费", new BigDecimal("100.00"), "CNY", "AVAILABLE",
11L, 21L, null, "清关费", new BigDecimal("100.00"), "CNY",
"PAYABLE".equals(direction) ? "ON_ACCOUNT" : null, null, null, null, List.of(), "AVAILABLE",
LocalDateTime.of(2026, 8, 11, 1, 0));
}
}
@@ -70,7 +70,8 @@ class FinanceFeeSourceEventServiceTest {
givenNewEvent();
var snapshot = new FirstlegFeeSourceClient.Snapshot(7, "CONTAINER_FEE", 31, 1,
null, null, null, 41L, 61L, "C-41", 7, "客户", 71L, "现有供应商",
"TRUCKING", 61L, "TRUCKING", "拖车费", "PAYABLE", LocalDate.of(2026, 8, 11),
"TRUCKING", "ON_ACCOUNT", null, null, null, List.of(),
61L, "TRUCKING", "拖车费", "PAYABLE", LocalDate.of(2026, 8, 11),
"CNY", new BigDecimal("300.00"), "APPROVED", "PUBLISHED",
LocalDateTime.of(2026, 8, 11, 1, 0), null, null, null, null,
"source-hash-payable", 1);
@@ -79,9 +80,10 @@ class FinanceFeeSourceEventServiceTest {
assertThat(service.consume(event)).isEqualTo(101L);
verify(supplierValidator).requireActive(71L, "TRUCKING");
verify(jdbc, never()).update(startsWith("INSERT INTO fin_cost"), any(Object[].class));
ArgumentCaptor<Object[]> values = ArgumentCaptor.forClass(Object[].class);
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), values.capture());
assertThat(values.getValue()[14].toString())
assertThat(values.getValue()[19].toString())
.contains("\"supplierId\":71", "\"supplierName\":\"现有供应商\"", "\"supplierCategory\":\"TRUCKING\"");
}
@@ -91,7 +93,8 @@ class FinanceFeeSourceEventServiceTest {
givenNewEvent();
var snapshot = new FirstlegFeeSourceClient.Snapshot(7, "CONTAINER_FEE", 31, 1,
null, null, null, 41L, 61L, "C-41", 7, "客户", 71L, "现有供应商",
null, 61L, "STORAGE", "仓储费", "PAYABLE", LocalDate.of(2026, 8, 11),
null, "ON_ACCOUNT", null, null, null, List.of(),
61L, "STORAGE", "仓储费", "PAYABLE", LocalDate.of(2026, 8, 11),
"CNY", new BigDecimal("300.00"), "APPROVED", "PUBLISHED",
LocalDateTime.of(2026, 8, 11, 1, 0), null, null, null, null,
"source-hash-unrestricted", 1);
@@ -101,6 +104,38 @@ class FinanceFeeSourceEventServiceTest {
verify(supplierValidator).requireActive(71L, null);
}
@Test
void importsDirectPaidSnapshotWithoutTreatingItAsOnAccount() {
FinanceFeeSourceEvent event = containerEvent("container-direct-paid-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
givenNewEvent();
var snapshot = new FirstlegFeeSourceClient.Snapshot(7, "CONTAINER_FEE", 31, 1,
null, null, null, 41L, 61L, "C-41", 7, "客户", 71L, "现有供应商",
"TRUCKING", "DIRECT_PAID", LocalDateTime.of(2026, 8, 11, 10, 0),
"BANK_TRANSFER", "BANK-001", List.of(901L), 61L, "TRUCKING", "拖车费",
"PAYABLE", LocalDate.of(2026, 8, 11), "CNY", new BigDecimal("300.00"),
"APPROVED", "PUBLISHED", LocalDateTime.of(2026, 8, 11, 11, 0),
null, null, null, null, "source-hash-direct", 1);
when(firstleg.container("test-token", 41, 31, 1)).thenReturn(ApiResponse.success(snapshot));
assertThat(service.consume(event)).isEqualTo(101L);
ArgumentCaptor<Object[]> values = ArgumentCaptor.forClass(Object[].class);
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), values.capture());
assertThat(values.getValue()[13]).isEqualTo("DIRECT_PAID");
assertThat(values.getValue()[15]).isEqualTo("BANK_TRANSFER");
assertThat(values.getValue()[17]).isEqualTo("[901]");
ArgumentCaptor<Object[]> costValues = ArgumentCaptor.forClass(Object[].class);
verify(jdbc).update(startsWith("INSERT INTO fin_cost"), costValues.capture());
assertThat(costValues.getValue()[0]).isEqualTo(7L);
assertThat(costValues.getValue()[1]).isEqualTo("DIRECT-CONTAINER_FEE-31-V1");
assertThat(costValues.getValue()[2]).isEqualTo("CONTAINER_FEE");
assertThat(costValues.getValue()[3]).isEqualTo(31L);
assertThat(costValues.getValue()[4]).isEqualTo(1);
assertThat(costValues.getValue()[15]).isEqualTo("[901]");
assertThat(costValues.getValue()[17]).isEqualTo("BANK_TRANSFER");
assertThat(costValues.getValue()[18]).isEqualTo("BANK-001");
}
@Test
void adjustmentCreatesNewImmutableVersionAndSupersedesAvailableVersion() {
FinanceFeeSourceEvent event = inboundEvent("adjusted-2", "FIRSTLEG_FEE_ADJUSTED", 2, 7);
@@ -216,7 +251,8 @@ class FinanceFeeSourceEventServiceTest {
String publicationStatus, BigDecimal amount) {
return new FirstlegFeeSourceClient.Snapshot(customerId, sourceType, 31, version, inboundId,
inboundId == null ? null : "IB-21", 51L, containerId, null,
containerId == null ? null : "C-41", customerId, "客户", null, null, null, 61L, "CUSTOMS",
containerId == null ? null : "C-41", customerId, "客户", null, null, null,
null, null, null, null, List.of(), 61L, "CUSTOMS",
"清关费", "RECEIVABLE", LocalDate.of(2026, 8, 11), "CNY", amount,
"APPROVED", publicationStatus, LocalDateTime.of(2026, 8, 11, 1, 0),
BigDecimal.ONE, "SHIPMENT", BigDecimal.TEN, BigDecimal.ONE, "source-hash", version);
@@ -14,6 +14,8 @@ class FinanceFeeSourceMigrationContractTest {
String sql = Files.readString(Path.of("src/main/resources/db/fee-source-event-migration.sql"), StandardCharsets.UTF_8);
assertThat(sql).contains("information_schema.columns", "source_type", "direction", "container_id",
"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("AVAILABLE可结算", "SUPERSEDED已被新版本替代", "VOIDED已作废");
assertThat(sql).contains("finance_fee_source_assert_schema");
}
@@ -144,7 +144,7 @@ class FinancePhase1OperationBehaviorTest {
when(rs.getString("currency_code")).thenReturn("CNY");
when(rs.getString("review_status")).thenReturn("APPROVED");
when(rs.getString("effective_status")).thenReturn("AVAILABLE");
when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"supplierId\":79,\"supplierName\":\"供应商A\",\"inboundCode\":\"IN-9\",\"containerNo\":\"CONT-9\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\"}");
when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"supplierId\":\"79\",\"supplierName\":\"供应商A\",\"inboundCode\":\"IN-9\",\"containerNo\":\"CONT-9\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\"}");
when(rs.getBigDecimal("amount")).thenReturn(new BigDecimal("120.00"));
when(rs.getBoolean("statement_generated")).thenReturn(false);
return List.of(mapper.mapRow(rs, 0));
@@ -164,6 +164,7 @@ class FinancePhase1OperationBehaviorTest {
assertThat(source.sourceBusinessId()).isEqualTo("IN-9");
assertThat(source.inboundCode()).isEqualTo("IN-9");
assertThat(source.containerNo()).isEqualTo("CONT-9");
assertThat(source.supplierId()).isEqualTo(79L);
assertThat(source.supplierName()).isEqualTo("供应商A");
assertThat(source.reviewStatus()).isEqualTo("APPROVED");
assertThat(source.businessDate()).isEqualTo(LocalDate.of(2026, 8, 11));
@@ -364,6 +365,7 @@ class FinancePhase1OperationBehaviorTest {
return (List<T>) List.of(new FinancePhase1Dtos.AccountSummary(
9L, "9", "客户9", null, null, new BigDecimal("100.00"),
new BigDecimal("20.00"), new BigDecimal("30.00"), BigDecimal.ZERO,
BigDecimal.ZERO, BigDecimal.ZERO,
"ACTIVE", LocalDateTime.of(2026, 8, 11, 10, 0)));
}
if (sql.startsWith("SELECT id,bill_no") || sql.startsWith("SELECT id,receipt_no")
@@ -68,7 +68,8 @@ class FinancePlatformListContractTest {
void allCustomerFeePoolResponseCarriesCustomerIdentity() {
assertThat(Arrays.stream(com.baiyue.finance.dto.FinanceReadModels.FeePoolItem.class
.getRecordComponents()).map(java.lang.reflect.RecordComponent::getName))
.contains("customerId");
.contains("customerId", "settlementMode", "paidAt", "paymentMethod",
"paymentReferenceNo", "paymentVoucherFileIds");
}
@Test
@@ -24,7 +24,7 @@ class FinancePlatformOptionalCustomerQueryTest {
service.receivableSettlements(null, false);
service.payableSettlements(null);
assertThat(jdbc.calls).hasSize(6).allSatisfy(call -> {
assertThat(jdbc.calls).hasSize(5).allSatisfy(call -> {
assertThat(call.sql()).doesNotContain("customer_id=?", "tenant_id=?");
assertThat(call.args()).isEmpty();
});
@@ -41,7 +41,7 @@ class FinancePlatformOptionalCustomerQueryTest {
service.receivableSettlements(31L, false);
service.payableSettlements(31L);
assertThat(jdbc.calls).hasSize(6).allSatisfy(call -> {
assertThat(jdbc.calls).hasSize(5).allSatisfy(call -> {
assertThat(call.sql()).containsAnyOf("customer_id=?", "tenant_id=?");
assertThat(call.args()).containsExactly(31L);
});
@@ -10,6 +10,7 @@ import com.baiyue.finance.dto.FinanceSettlementDtos.CreatePayableSettlementReque
import com.baiyue.finance.dto.FinanceSettlementDtos.CreateReceivableSettlementRequest;
import com.baiyue.finance.dto.FinanceSettlementDtos.CreateSettlementPaymentRequest;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementLine;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest;
import com.baiyue.finance.service.FinanceEventException;
@@ -286,6 +287,66 @@ class FinanceSettlementBehaviorTest {
verify(jdbc).update(startsWith("INSERT INTO fin_settlement_line"), eq(88L), eq(10L), eq(501L));
}
@Test
void receivableSettlementLineDoesNotRequireSupplierSnapshot() {
Map<String, Object> row = new java.util.HashMap<>(Map.ofEntries(
Map.entry("fee_source_id", 31L), Map.entry("source_type", "FBA_PRICING"),
Map.entry("source_fee_id", 41L), Map.entry("source_fee_version", 1),
Map.entry("direction", "RECEIVABLE"), Map.entry("customer_id", 10L),
Map.entry("source_snapshot", "{\"customerName\":\"客户10\",\"businessDate\":\"2026-08-11\"}"),
Map.entry("fee_item_name", "头程运费"), Map.entry("amount", new BigDecimal("100.00")),
Map.entry("currency_code", "CNY"), Map.entry("review_status", "APPROVED"),
Map.entry("status", "GENERATED")));
row.put("order_id", null);
row.put("inbound_id", null);
row.put("container_id", null);
SettlementLine line = ReflectionTestUtils.invokeMethod(
service(org.mockito.Mockito.mock(JdbcTemplate.class), org.mockito.Mockito.mock(FinanceFileClient.class)),
"line", row, detail("RECEIVABLE", "ISSUED", BigDecimal.ZERO,
new BigDecimal("100.00"), 0).summary());
assertThat(line.direction()).isEqualTo("RECEIVABLE");
assertThat(line.supplierId()).isNull();
assertThat(line.customerName()).isEqualTo("客户10");
}
@Test
void payableSettlementLineStillRequiresSupplierSnapshot() {
Map<String, Object> row = new java.util.HashMap<>(Map.ofEntries(
Map.entry("direction", "PAYABLE"),
Map.entry("source_snapshot", "{\"customerName\":\"客户10\",\"businessDate\":\"2026-08-11\"}"),
Map.entry("amount", new BigDecimal("100.00"))));
assertCode(() -> ReflectionTestUtils.invokeMethod(
service(org.mockito.Mockito.mock(JdbcTemplate.class), org.mockito.Mockito.mock(FinanceFileClient.class)),
"line", row, detail("PAYABLE", "ISSUED", BigDecimal.ZERO,
new BigDecimal("100.00"), 0).summary()), "FIN_409");
}
@Test
void payableSettlementLineAcceptsStringEncodedSupplierIdSnapshot() {
Map<String, Object> row = new java.util.HashMap<>(Map.ofEntries(
Map.entry("fee_source_id", 31L), Map.entry("source_type", "INBOUND_FEE"),
Map.entry("source_fee_id", 41L), Map.entry("source_fee_version", 1),
Map.entry("direction", "PAYABLE"), Map.entry("customer_id", 10L),
Map.entry("source_snapshot", "{\"customerName\":\"客户10\",\"supplierId\":\"2090349552523644929\",\"supplierName\":\"供应商A\",\"businessDate\":\"2026-08-11\"}"),
Map.entry("fee_item_name", "装车费"), Map.entry("amount", new BigDecimal("80.00")),
Map.entry("currency_code", "CNY"), Map.entry("review_status", "APPROVED"),
Map.entry("status", "AVAILABLE")));
row.put("order_id", null);
row.put("inbound_id", null);
row.put("container_id", null);
SettlementLine line = ReflectionTestUtils.invokeMethod(
service(org.mockito.Mockito.mock(JdbcTemplate.class), org.mockito.Mockito.mock(FinanceFileClient.class)),
"line", row, detail("PAYABLE", "ISSUED", BigDecimal.ZERO,
new BigDecimal("80.00"), 0).summary());
assertThat(line.supplierId()).isEqualTo(2090349552523644929L);
assertThat(line.supplierName()).isEqualTo("供应商A");
}
@Test
void receivableCreationRejectsAlreadySettledStatement() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
@@ -335,6 +396,22 @@ class FinanceSettlementBehaviorTest {
verify(jdbc, never()).update(any(PreparedStatementCreator.class), any(KeyHolder.class));
}
@Test
void directPaidSourceCannotEnterPayableSettlement() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L),
eq("SETTLEMENT_AP_CREATE"), eq("ap-direct"))).thenReturn(List.of());
Map<String, Object> source = payableSource(10L, 20L);
source.put("settlement_mode", "DIRECT_PAID");
when(jdbc.queryForList(startsWith("SELECT id,customer_id"), eq(31L))).thenReturn(List.of(source));
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
assertCode(() -> service.createPayableSettlement(7L, "ap-direct",
new CreatePayableSettlementRequest(10L, 20L, List.of(31L),
LocalDate.of(2026, 9, 30), "CNY")), "FIN_409");
verify(jdbc, never()).update(startsWith("INSERT INTO fin_settlement"), any(Object[].class));
}
@Test
void payableCreationRejectsMixedSupplierIneligibleAndOccupiedSources() {
for (String condition : List.of("supplier", "ineligible", "occupied")) {
@@ -588,6 +665,7 @@ class FinanceSettlementBehaviorTest {
Map.entry("id", 31L), Map.entry("customer_id", customerId), Map.entry("direction", "PAYABLE"),
Map.entry("currency_code", "CNY"), Map.entry("status", "AVAILABLE"),
Map.entry("amount", new BigDecimal("100.00")), Map.entry("review_status", "APPROVED"),
Map.entry("settlement_mode", "ON_ACCOUNT"),
Map.entry("supplier_id", supplierId),
Map.entry("fee_item_code", "FBA_HEAD_FREIGHT"), Map.entry("fee_item_name", "头程运费"),
Map.entry("supplier_name", "供应商20"), Map.entry("source_snapshot",
@@ -44,7 +44,7 @@ class FinanceSqlCommentContractTest {
}
}
assertThat(tableCount).isEqualTo(17);
assertThat(columnCount).isEqualTo(243);
assertThat(columnCount).isEqualTo(248);
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
}
@@ -55,6 +55,7 @@ class FinanceSqlCommentContractTest {
assertThat(sql).contains("AVAILABLE可结算、SUPERSEDED已被新版本替代、VOIDED已作废");
assertThat(sql).contains("RECEIVED已接收、PROCESSED已完成、FAILED处理失败");
assertThat(sql).contains("Firstleg费用来源完整不可变JSON快照");
assertThat(sql).contains("ON_ACCOUNT账期应付、DIRECT_PAID业务已付");
assertThat(sql).contains("规范化完整请求的SHA-256十六进制哈希");
}
@@ -0,0 +1,20 @@
package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
@FeignClient(name = "file-service", contextId = "firstlegFeePaymentVoucherFileClient",
url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/business-files")
public interface FeePaymentVoucherFileClient {
@GetMapping("/{tenantId}/FINANCE_VOUCHER/{customerId}/{fileId}")
ApiResponse<Metadata> metadata(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId,
@PathVariable long customerId,
@PathVariable long fileId);
record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
String fileName, String sha256, String contentType, Long sizeBytes) {}
}
@@ -9,6 +9,8 @@ import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
public final class ContainerFeeDetailDtos {
private ContainerFeeDetailDtos() {
@@ -21,8 +23,18 @@ public final class ContainerFeeDetailDtos {
@NotNull @DecimalMin("0.00") BigDecimal amount,
@NotBlank @Pattern(regexp = "CNY") String currency,
Long supplierId,
@Pattern(regexp = "ON_ACCOUNT|DIRECT_PAID") String settlementMode,
LocalDateTime paidAt,
@Pattern(regexp = "ONLINE|BANK_TRANSFER|CASH|OTHER") String paymentMethod,
@Size(max = 128) String paymentReferenceNo,
@Size(max = 20) List<@Min(1) Long> paymentVoucherFileIds,
@Size(max = 500) String remark
) {
public SaveRequest(Long feeItemId, String direction, LocalDate businessDate,
BigDecimal amount, String currency, Long supplierId, String remark) {
this(feeItemId, direction, businessDate, amount, currency, supplierId,
null, null, null, null, null, remark);
}
}
public record AdjustRequest(
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.dto;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** Finance 回查的 Firstleg 费用来源只读快照。 */
public record FinanceFeeSourceSnapshot(
@@ -21,6 +22,11 @@ public record FinanceFeeSourceSnapshot(
Long supplierId,
String supplierName,
String supplierCategory,
String settlementMode,
LocalDateTime paidAt,
String paymentMethod,
String paymentReferenceNo,
List<Long> paymentVoucherFileIds,
Long feeItemId,
String feeItemCode,
String feeItemName,
@@ -8,6 +8,8 @@ import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** 入库单费用明细请求模型。 */
public final class InboundFeeDetailDtos {
@@ -22,12 +24,18 @@ public final class InboundFeeDetailDtos {
@NotBlank @Pattern(regexp = "[A-Z]{3}") String currency,
@NotNull @DecimalMin("0.00") BigDecimal amount,
Long supplierId,
@Pattern(regexp = "ON_ACCOUNT|DIRECT_PAID") String settlementMode,
LocalDateTime paidAt,
@Pattern(regexp = "ONLINE|BANK_TRANSFER|CASH|OTHER") String paymentMethod,
@Size(max = 128) String paymentReferenceNo,
@Size(max = 20) List<@jakarta.validation.constraints.Min(1) Long> paymentVoucherFileIds,
@Size(max = 500) String remark
) {
public SaveRequest(Long feeItemId, String direction, LocalDate businessDate,
BigDecimal unitPrice, String currency, BigDecimal amount,
String remark) {
this(feeItemId, direction, businessDate, unitPrice, currency, amount, null, remark);
this(feeItemId, direction, businessDate, unitPrice, currency, amount,
null, null, null, null, null, null, remark);
}
}
@@ -3,17 +3,20 @@ package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** 柜次级人工应收/应付费用来源。 */
@Getter
@Setter
@TableName("fl_container_fee_detail")
@TableName(value = "fl_container_fee_detail", autoResultMap = true)
public class ContainerFeeDetailEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
@@ -28,6 +31,12 @@ public class ContainerFeeDetailEntity {
private Long supplierId;
private String supplierName;
private String supplierCategory;
private String settlementMode;
private LocalDateTime paidAt;
private String paymentMethod;
private String paymentReferenceNo;
@TableField(typeHandler = JacksonTypeHandler.class)
private List<Long> paymentVoucherFileIds;
private LocalDate businessDate;
private BigDecimal amount;
private String currency;
@@ -3,17 +3,20 @@ package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** 柜次费用审核发布时固化的不可变版本快照。 */
@Getter
@Setter
@TableName("fl_container_fee_source_snapshot")
@TableName(value = "fl_container_fee_source_snapshot", autoResultMap = true)
public class ContainerFeeSourceSnapshotEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
@@ -30,6 +33,12 @@ public class ContainerFeeSourceSnapshotEntity {
private Long supplierId;
private String supplierName;
private String supplierCategory;
private String settlementMode;
private LocalDateTime paidAt;
private String paymentMethod;
private String paymentReferenceNo;
@TableField(typeHandler = JacksonTypeHandler.class)
private List<Long> paymentVoucherFileIds;
private LocalDate businessDate;
private BigDecimal amount;
private String currency;
@@ -3,6 +3,8 @@ package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import com.baiyue.common.core.model.BaseEntity;
import lombok.Getter;
import lombok.Setter;
@@ -10,11 +12,12 @@ import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** 入库单人工费用明细及业务快照。 */
@Getter
@Setter
@TableName("fl_inbound_fee_detail")
@TableName(value = "fl_inbound_fee_detail", autoResultMap = true)
public class InboundFeeDetailEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
@@ -30,6 +33,12 @@ public class InboundFeeDetailEntity extends BaseEntity {
private Long supplierId;
private String supplierName;
private String supplierCategory;
private String settlementMode;
private LocalDateTime paidAt;
private String paymentMethod;
private String paymentReferenceNo;
@TableField(typeHandler = JacksonTypeHandler.class)
private List<Long> paymentVoucherFileIds;
private LocalDate businessDate;
private Long customerId;
private String customerName;
@@ -11,7 +11,7 @@ import org.apache.ibatis.annotations.Update;
@Mapper
public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntity> {
@Update("UPDATE fl_container_booking SET container_no=#{containerNo},ocean_booking_id=#{oceanBookingId},ocean_booking_requirement_id=#{requirementId},record_status='ACTIVE',bill_of_lading_no=#{billOfLadingNo},vessel_name=#{vesselName},voyage_no=#{voyageNo},shipping_company_id=#{shippingCompanyId},shipping_company_name=#{shippingCompanyName},departure_port_code=#{departurePortCode},departure_port_name=#{departurePortName},destination_port_code=#{destinationPortCode},destination_port_name=#{destinationPortName},channel_code=#{channelCode},channel_name=#{channelName},customer_id=#{customerId},customer_name=#{customerName},estimated_sailing_at=#{estimatedSailingAt},estimated_port_arrival_at=#{estimatedArrivalAt},updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=NOW(),version=version+1 WHERE id=#{id} AND version=#{version} AND source_type='SIMULATION' AND record_status='ACTIVE' AND ocean_booking_id IS NULL")
@Update("UPDATE fl_container_booking SET container_no=#{containerNo},ocean_booking_id=#{oceanBookingId},ocean_booking_requirement_id=#{requirementId},record_status='ACTIVE',bill_of_lading_no=#{billOfLadingNo},vessel_name=#{vesselName},voyage_no=#{voyageNo},shipping_company_id=#{shippingCompanyId},shipping_company_name=#{shippingCompanyName},departure_port_code=#{departurePortCode},departure_port_name=#{departurePortName},destination_port_code=#{destinationPortCode},destination_port_name=#{destinationPortName},channel_code=#{channelCode},channel_name=#{channelName},customer_id=#{customerId},customer_name=#{customerName},estimated_sailing_at=#{estimatedSailingAt},estimated_port_arrival_at=#{estimatedPortArrivalAt},updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=NOW(),version=version+1 WHERE id=#{id} AND version=#{version} AND source_type='SIMULATION' AND record_status='ACTIVE' AND ocean_booking_id IS NULL")
int linkReleasedBooking(@Param("id") long id, @Param("oceanBookingId") long oceanBookingId, @Param("requirementId") long requirementId, @Param("containerNo") String containerNo, @Param("billOfLadingNo") String billOfLadingNo, @Param("vesselName") String vesselName, @Param("voyageNo") String voyageNo, @Param("shippingCompanyId") Long shippingCompanyId, @Param("shippingCompanyName") String shippingCompanyName, @Param("departurePortCode") String departurePortCode, @Param("departurePortName") String departurePortName, @Param("destinationPortCode") String destinationPortCode, @Param("destinationPortName") String destinationPortName, @Param("channelCode") String channelCode, @Param("channelName") String channelName, @Param("customerId") Long customerId, @Param("customerName") String customerName, @Param("estimatedSailingAt") java.time.LocalDateTime estimatedSailingAt, @Param("estimatedPortArrivalAt") java.time.LocalDateTime estimatedPortArrivalAt, @Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Select("""
<script>
@@ -29,6 +29,11 @@ public class ContainerFeeSourceSnapshotRepository {
value.setSupplierId(source.getSupplierId());
value.setSupplierName(source.getSupplierName());
value.setSupplierCategory(source.getSupplierCategory());
value.setSettlementMode(source.getSettlementMode());
value.setPaidAt(source.getPaidAt());
value.setPaymentMethod(source.getPaymentMethod());
value.setPaymentReferenceNo(source.getPaymentReferenceNo());
value.setPaymentVoucherFileIds(source.getPaymentVoucherFileIds());
value.setBusinessDate(source.getBusinessDate());
value.setAmount(source.getAmount());
value.setCurrency(source.getCurrency());
@@ -29,6 +29,7 @@ public class ContainerFeeDetailService {
private final FinanceFeeSourceOutboxService financeOutbox;
private final WarehouseSupplierSnapshotService suppliers;
private final ContainerFeeSourceSnapshotRepository sourceSnapshots;
private final FeePaymentSnapshotValidator paymentSnapshots;
public List<ContainerFeeDetailEntity> list(long tenantId, long loadingPlanId) {
requireOwnedPlan(tenantId, loadingPlanId);
@@ -60,6 +61,7 @@ public class ContainerFeeDetailService {
long reviewerId, String reviewerName) {
ContainerFeeDetailEntity pending = required(tenantId, loadingPlanId, feeId);
FeeItemEntity item = requireItem(pending.getFeeItemId(), pending.getDirection());
revalidatePaymentSnapshot(pending);
refreshSupplierSnapshot(pending, item, reviewerId);
if (repository.review(tenantId, loadingPlanId, feeId, "APPROVED",
reviewerId, reviewerName, null) != 1) {
@@ -152,6 +154,8 @@ public class ContainerFeeDetailService {
value.setFeeItemNameSnapshot(item.getFeeTypeName());
value.setDirection(request.direction());
applySupplierSnapshot(value, item, request.direction(), request.supplierId());
applyPaymentSnapshot(value, request.direction(), request.settlementMode(), request.paidAt(),
request.paymentMethod(), request.paymentReferenceNo(), request.paymentVoucherFileIds());
value.setBusinessDate(request.businessDate());
value.setAmount(request.amount().setScale(2, RoundingMode.HALF_UP));
value.setCurrency(request.currency());
@@ -188,6 +192,25 @@ public class ContainerFeeDetailService {
}
}
private void revalidatePaymentSnapshot(ContainerFeeDetailEntity value) {
applyPaymentSnapshot(value, value.getDirection(), value.getSettlementMode(), value.getPaidAt(),
value.getPaymentMethod(), value.getPaymentReferenceNo(), value.getPaymentVoucherFileIds());
}
private void applyPaymentSnapshot(ContainerFeeDetailEntity value, String direction,
String settlementMode, LocalDateTime paidAt,
String paymentMethod, String paymentReferenceNo,
List<Long> paymentVoucherFileIds) {
var snapshot = paymentSnapshots.validate(value.getTenantId(), direction, settlementMode,
paidAt, paymentMethod, paymentReferenceNo, paymentVoucherFileIds);
value.setSettlementMode(snapshot.settlementMode());
value.setPaidAt(snapshot.paidAt());
value.setPaymentMethod(snapshot.paymentMethod());
value.setPaymentReferenceNo(snapshot.paymentReferenceNo());
value.setPaymentVoucherFileIds(snapshot.paymentVoucherFileIds().isEmpty()
? null : snapshot.paymentVoucherFileIds());
}
private static BizException conflict(String message) {
return new BizException(ResultCode.CONFLICT, message);
}
@@ -0,0 +1,101 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.FeePaymentVoucherFileClient;
import feign.FeignException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
@Service
public class FeePaymentSnapshotValidator {
private static final Set<String> MODES = Set.of("ON_ACCOUNT", "DIRECT_PAID");
private static final Set<String> METHODS = Set.of("ONLINE", "BANK_TRANSFER", "CASH", "OTHER");
private static final String VOUCHER_TYPE = "FINANCE_VOUCHER";
private final FeePaymentVoucherFileClient files;
private final String internalToken;
public FeePaymentSnapshotValidator(FeePaymentVoucherFileClient files,
@Value("${baiyue.security.internal-service-token}") String internalToken) {
this.files = files;
this.internalToken = internalToken;
}
public Snapshot validate(long customerId, String direction, String settlementMode,
LocalDateTime paidAt, String paymentMethod,
String paymentReferenceNo, List<Long> paymentVoucherFileIds) {
String mode = trim(settlementMode);
String method = trim(paymentMethod);
String reference = trim(paymentReferenceNo);
List<Long> vouchers = normalizeVouchers(paymentVoucherFileIds);
if ("RECEIVABLE".equals(direction)) {
if (mode != null || paidAt != null || method != null || reference != null || !vouchers.isEmpty()) {
throw bad("应收费用不得携带结算方式或已付信息");
}
return Snapshot.empty();
}
if (!"PAYABLE".equals(direction) || !MODES.contains(mode)) {
throw bad("应付费用必须选择ON_ACCOUNT或DIRECT_PAID");
}
if ("ON_ACCOUNT".equals(mode)) {
if (paidAt != null || method != null || reference != null || !vouchers.isEmpty()) {
throw bad("账期应付不得携带已付时间、方式、流水号或凭证");
}
return new Snapshot(mode, null, null, null, List.of());
}
if (paidAt == null || !METHODS.contains(method)) {
throw bad("直接已付必须填写已付时间和有效付款方式");
}
if (reference == null && vouchers.isEmpty()) {
throw bad("直接已付必须填写付款流水号或至少一个付款凭证");
}
vouchers.forEach(fileId -> validateVoucher(customerId, fileId));
return new Snapshot(mode, paidAt, method, reference, vouchers);
}
private void validateVoucher(long customerId, long fileId) {
try {
var response = files.metadata(internalToken, customerId, customerId, fileId);
var metadata = response == null ? null : response.data();
if (response == null || !response.successful() || metadata == null
|| !Long.valueOf(fileId).equals(metadata.fileId())
|| !Long.valueOf(customerId).equals(metadata.tenantId())
|| !VOUCHER_TYPE.equals(metadata.businessType())
|| !String.valueOf(customerId).equals(metadata.businessId())) {
throw bad("付款凭证不存在或不属于当前客户");
}
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用,无法校验付款凭证");
}
}
private static List<Long> normalizeVouchers(List<Long> values) {
if (values == null || values.isEmpty()) return List.of();
if (values.size() > 20 || values.stream().anyMatch(value -> value == null || value <= 0)) {
throw bad("付款凭证文件ID无效或超过20个");
}
return List.copyOf(new LinkedHashSet<>(values));
}
private static String trim(String value) {
return StringUtils.hasText(value) ? value.trim() : null;
}
private static BizException bad(String message) {
return new BizException(ResultCode.BAD_REQUEST, message);
}
public record Snapshot(String settlementMode, LocalDateTime paidAt, String paymentMethod,
String paymentReferenceNo, List<Long> paymentVoucherFileIds) {
static Snapshot empty() {
return new Snapshot(null, null, null, null, List.of());
}
}
}
@@ -40,12 +40,17 @@ public class FinanceFeeSourceSnapshotService {
String.valueOf(inbound.getOrderId()), nullSafe(item.getFeeCode()),
nullSafe(detail.getDirection()), nullSafe(detail.getSupplierId()),
nullSafe(detail.getSupplierName()), nullSafe(detail.getSupplierCategory()),
nullSafe(detail.getSettlementMode()), nullSafe(detail.getPaidAt()),
nullSafe(detail.getPaymentMethod()), nullSafe(detail.getPaymentReferenceNo()),
nullSafe(detail.getPaymentVoucherFileIds()),
detail.getAmount().toPlainString(),
nullSafe(detail.getCurrency()));
return new FinanceFeeSourceSnapshot(detail.getTenantId(), "INBOUND_FEE", sourceFeeId,
version, inboundId, inbound.getInboundCode(), inbound.getOrderId(), null, null, null,
detail.getTenantId(), inbound.getCustomerName(), detail.getSupplierId(),
detail.getSupplierName(), detail.getSupplierCategory(), item.getId(), item.getFeeCode(),
detail.getSupplierName(), detail.getSupplierCategory(), detail.getSettlementMode(),
detail.getPaidAt(), detail.getPaymentMethod(), detail.getPaymentReferenceNo(),
detail.getPaymentVoucherFileIds(), item.getId(), item.getFeeCode(),
detail.getFeeTypeNameSnapshot(), detail.getDirection(), detail.getBusinessDate(),
detail.getCurrency(), detail.getAmount(), detail.getReviewStatus(),
detail.getPublicationStatus(),
@@ -62,12 +67,17 @@ public class FinanceFeeSourceSnapshotService {
nullSafe(detail.getFeeItemCode()), nullSafe(detail.getDirection()),
nullSafe(detail.getSupplierId()), nullSafe(detail.getSupplierName()),
nullSafe(detail.getSupplierCategory()),
nullSafe(detail.getSettlementMode()), nullSafe(detail.getPaidAt()),
nullSafe(detail.getPaymentMethod()), nullSafe(detail.getPaymentReferenceNo()),
nullSafe(detail.getPaymentVoucherFileIds()),
detail.getAmount().toPlainString(), nullSafe(detail.getCurrency()),
nullSafe(detail.getReviewStatus()));
return new FinanceFeeSourceSnapshot(detail.getTenantId(), "CONTAINER_FEE", feeId,
version, null, null, null, containerId, detail.getContainerBookingId(),
detail.getContainerNo(), detail.getTenantId(), null, detail.getSupplierId(),
detail.getSupplierName(), detail.getSupplierCategory(), detail.getFeeItemId(),
detail.getSupplierName(), detail.getSupplierCategory(), detail.getSettlementMode(),
detail.getPaidAt(), detail.getPaymentMethod(), detail.getPaymentReferenceNo(),
detail.getPaymentVoucherFileIds(), detail.getFeeItemId(),
detail.getFeeItemCode(), detail.getFeeItemName(), detail.getDirection(),
detail.getBusinessDate(), detail.getCurrency(), detail.getAmount(), detail.getReviewStatus(),
"PUBLISHED",
@@ -31,6 +31,8 @@ public class InboundFeeDetailService {
private FinanceFeeSourceOutboxService financeOutbox;
@Resource
private WarehouseSupplierSnapshotService suppliers;
@Resource
private FeePaymentSnapshotValidator paymentSnapshots;
@Transactional
public List<InboundFeeDetailEntity> list(long tenantId, long inboundId, boolean includeHistory) {
@@ -255,6 +257,7 @@ public class InboundFeeDetailService {
long reviewerId, String reviewerName) {
InboundFeeDetailEntity pending = requiredManual(tenantId, inboundId, detailId);
FeeItemEntity item = requiredActiveItem(pending.getFeeItemId());
revalidatePaymentSnapshot(pending);
refreshSupplierSnapshot(pending, item, reviewerId);
if (repository.review(tenantId, inboundId, detailId, "APPROVED",
reviewerId, reviewerName, null) != 1) {
@@ -381,6 +384,8 @@ public class InboundFeeDetailService {
value.setInboundCode(inbound.getInboundCode());
value.setDirection(request.direction());
applySupplierSnapshot(value, item, request.direction(), request.supplierId());
applyPaymentSnapshot(value, request.direction(), request.settlementMode(), request.paidAt(),
request.paymentMethod(), request.paymentReferenceNo(), request.paymentVoucherFileIds());
value.setBusinessDate(request.businessDate());
value.setFeeCategorySnapshot(item.getCategory());
value.setTradeScopeSnapshot(item.getTradeScope());
@@ -430,6 +435,25 @@ public class InboundFeeDetailService {
}
}
private void revalidatePaymentSnapshot(InboundFeeDetailEntity value) {
applyPaymentSnapshot(value, value.getDirection(), value.getSettlementMode(), value.getPaidAt(),
value.getPaymentMethod(), value.getPaymentReferenceNo(), value.getPaymentVoucherFileIds());
}
private void applyPaymentSnapshot(InboundFeeDetailEntity value, String direction,
String settlementMode, LocalDateTime paidAt,
String paymentMethod, String paymentReferenceNo,
List<Long> paymentVoucherFileIds) {
var snapshot = paymentSnapshots.validate(value.getTenantId(), direction, settlementMode,
paidAt, paymentMethod, paymentReferenceNo, paymentVoucherFileIds);
value.setSettlementMode(snapshot.settlementMode());
value.setPaidAt(snapshot.paidAt());
value.setPaymentMethod(snapshot.paymentMethod());
value.setPaymentReferenceNo(snapshot.paymentReferenceNo());
value.setPaymentVoucherFileIds(snapshot.paymentVoucherFileIds().isEmpty()
? null : snapshot.paymentVoucherFileIds());
}
private static BizException conflict(String message) {
return new BizException(ResultCode.CONFLICT, message);
}
@@ -74,6 +74,11 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_detail (
supplier_id BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空',
supplier_name VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空',
supplier_category VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商',
settlement_mode VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT账期应付、DIRECT_PAID业务已付;RECEIVABLE为空',
paid_at DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间',
payment_method VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式',
payment_reference_no VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号',
payment_voucher_file_ids JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组',
business_date DATE NOT NULL COMMENT '费用业务日期',
amount DECIMAL(18,2) NOT NULL COMMENT 'CNY费用金额',
currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '币种,首期固定CNY',
@@ -96,6 +101,15 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_detail (
CONSTRAINT chk_container_fee_currency CHECK (currency = 'CNY'),
CONSTRAINT chk_container_fee_amount CHECK (amount >= 0),
CONSTRAINT chk_container_fee_review CHECK (review_status IN ('PENDING', 'APPROVED', 'REJECTED')),
CONSTRAINT chk_container_fee_settlement CHECK (
(direction='RECEIVABLE' AND settlement_mode IS NULL AND paid_at IS NULL AND payment_method IS NULL
AND payment_reference_no IS NULL AND payment_voucher_file_ids IS NULL)
OR (direction='PAYABLE' AND settlement_mode='ON_ACCOUNT' AND paid_at IS NULL AND payment_method IS NULL
AND payment_reference_no IS NULL AND payment_voucher_file_ids IS NULL)
OR (direction='PAYABLE' AND settlement_mode='DIRECT_PAID' AND paid_at IS NOT NULL
AND payment_method IN ('ONLINE','BANK_TRANSFER','CASH','OTHER')
AND (payment_reference_no IS NOT NULL OR JSON_LENGTH(payment_voucher_file_ids)>0))
),
CONSTRAINT chk_container_fee_source CHECK (source_status IN ('ACTIVE', 'VOIDED'))
) ENGINE=InnoDB COMMENT='柜次级人工应收应付费用来源;审核通过后自动同步Finance';
@@ -114,6 +128,11 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_source_snapshot (
supplier_id BIGINT UNSIGNED NULL COMMENT 'Warehouse供应商ID快照',
supplier_name VARCHAR(200) NULL COMMENT 'Warehouse供应商名称快照',
supplier_category VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商',
settlement_mode VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT账期应付、DIRECT_PAID业务已付;RECEIVABLE为空',
paid_at DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间',
payment_method VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式',
payment_reference_no VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号',
payment_voucher_file_ids JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组',
business_date DATE NOT NULL COMMENT '费用业务日期',
amount DECIMAL(18,2) NOT NULL COMMENT 'CNY费用金额',
currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '币种,首期固定CNY',
@@ -157,12 +176,42 @@ CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_name',
"VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空' AFTER supplier_id");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_category',
"VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商' AFTER supplier_name");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'settlement_mode',
"VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT或DIRECT_PAID' AFTER supplier_category");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'paid_at',
"DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间' AFTER settlement_mode");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'payment_method',
"VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式' AFTER paid_at");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'payment_reference_no',
"VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号' AFTER payment_method");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'payment_voucher_file_ids',
"JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组' AFTER payment_reference_no");
CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'settlement_mode',
"VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT或DIRECT_PAID' AFTER supplier_category");
CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'paid_at',
"DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间' AFTER settlement_mode");
CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'payment_method',
"VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式' AFTER paid_at");
CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'payment_reference_no',
"VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号' AFTER payment_method");
CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'payment_voucher_file_ids',
"JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组' AFTER payment_reference_no");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id',
"BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空' AFTER direction");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_name',
"VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空' AFTER supplier_id");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_category',
"VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商' AFTER supplier_name");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'settlement_mode',
"VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT或DIRECT_PAID' AFTER supplier_category");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'paid_at',
"DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间' AFTER settlement_mode");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'payment_method',
"VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式' AFTER paid_at");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'payment_reference_no',
"VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号' AFTER payment_method");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'payment_voucher_file_ids',
"JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组' AFTER payment_reference_no");
DROP PROCEDURE IF EXISTS firstleg_finance_add_column;
DROP PROCEDURE IF EXISTS firstleg_finance_assert_schema;
@@ -185,12 +234,22 @@ BEGIN
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fl_container_fee_detail'
AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category')) <> 3 THEN
AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category', 'settlement_mode',
'paid_at', 'payment_method', 'payment_reference_no',
'payment_voucher_file_ids')) <> 8 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:柜次费用供应商快照字段不完整';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fl_container_fee_source_snapshot'
AND column_name IN ('settlement_mode', 'paid_at', 'payment_method',
'payment_reference_no', 'payment_voucher_file_ids')) <> 5 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:柜次费用已付版本快照字段不完整';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_fee_detail'
AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category')) <> 3 THEN
AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category', 'settlement_mode',
'paid_at', 'payment_method', 'payment_reference_no',
'payment_voucher_file_ids')) <> 8 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:入库费用供应商快照字段不完整';
END IF;
END$$
@@ -40,6 +40,11 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee_detail (
supplier_id BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空',
supplier_name VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空',
supplier_category VARCHAR(16) NULL COMMENT '费用项目供应商类别快照;应收为空',
settlement_mode VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT账期应付、DIRECT_PAID业务已付;RECEIVABLE为空',
paid_at DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间',
payment_method VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式',
payment_reference_no VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号',
payment_voucher_file_ids JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组',
business_date DATE NOT NULL COMMENT '业务发生日',
fee_category_snapshot VARCHAR(64) NOT NULL COMMENT '费用分类快照',
trade_scope_snapshot VARCHAR(20) NOT NULL COMMENT '国内国际快照',
@@ -73,6 +78,15 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee_detail (
KEY idx_inbound_fee_detail (tenant_id, inbound_id, business_date, deleted),
KEY idx_inbound_fee_review (tenant_id, review_status, business_date, deleted),
KEY idx_inbound_fee_item (tenant_id, fee_item_id, deleted),
CONSTRAINT chk_inbound_fee_settlement CHECK (
(direction='RECEIVABLE' AND settlement_mode IS NULL AND paid_at IS NULL AND payment_method IS NULL
AND payment_reference_no IS NULL AND payment_voucher_file_ids IS NULL)
OR (direction='PAYABLE' AND settlement_mode='ON_ACCOUNT' AND paid_at IS NULL AND payment_method IS NULL
AND payment_reference_no IS NULL AND payment_voucher_file_ids IS NULL)
OR (direction='PAYABLE' AND settlement_mode='DIRECT_PAID' AND paid_at IS NOT NULL
AND payment_method IN ('ONLINE','BANK_TRANSFER','CASH','OTHER')
AND (payment_reference_no IS NOT NULL OR JSON_LENGTH(payment_voucher_file_ids)>0))
),
CONSTRAINT fk_inbound_fee_detail_order FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id),
CONSTRAINT fk_inbound_fee_detail_item FOREIGN KEY (fee_item_id) REFERENCES fl_fee_item (id)
) ENGINE=InnoDB COMMENT='入库单应收应付费用明细';
@@ -304,6 +304,11 @@ components:
currency: {type: string, pattern: '^[A-Z]{3}$'}
amount: {type: number, minimum: 0}
supplierId: {type: string, nullable: true}
settlementMode: {type: string, enum: [ON_ACCOUNT, DIRECT_PAID], nullable: true, description: 仅PAYABLE必填;ON_ACCOUNT账期应付,DIRECT_PAID业务已付}
paidAt: {type: string, format: date-time, nullable: true, description: DIRECT_PAID必填;ON_ACCOUNT与RECEIVABLE为空}
paymentMethod: {type: string, enum: [ONLINE, BANK_TRANSFER, CASH, OTHER], nullable: true, description: DIRECT_PAID必填}
paymentReferenceNo: {type: string, maxLength: 128, nullable: true}
paymentVoucherFileIds: {type: array, maxItems: 20, nullable: true, items: {type: string, pattern: '^[1-9][0-9]*$'}, description: DIRECT_PAID时与paymentReferenceNo至少一项;File按当前客户失败关闭校验}
remark: {type: string, maxLength: 500, nullable: true}
InboundFeeDetailVersionRequest:
type: object
@@ -331,6 +336,11 @@ components:
supplierId: {type: string, nullable: true}
supplierName: {type: string, nullable: true, readOnly: true}
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true, description: 费用项目供应商类别约束快照;为空表示仅要求供应商ACTIVE}
settlementMode: {type: string, enum: [ON_ACCOUNT, DIRECT_PAID], nullable: true}
paidAt: {type: string, format: date-time, nullable: true}
paymentMethod: {type: string, enum: [ONLINE, BANK_TRANSFER, CASH, OTHER], nullable: true}
paymentReferenceNo: {type: string, nullable: true}
paymentVoucherFileIds: {type: array, nullable: true, items: {type: string}}
businessDate: {type: string, format: date}
customerId: {type: string, nullable: true}
customerName: {type: string, nullable: true}
@@ -881,7 +881,7 @@ paths:
summary: 调整已审核柜次费用的日期和金额
description: >-
仅 ACTIVE、APPROVED 且 sourceVersion 匹配的费用可调整。成功后 sourceVersion 加一并重新进入
PENDING;费用方向、费用项目、供应商、币种和备注保持原快照。业务字段需要变化时,
PENDING;费用方向、费用项目、供应商、结算方式、已付快照、币种和备注保持原快照。业务字段需要变化时,
必须作废原费用并新增。同一 expectedSourceVersion、businessDate、amount 的原样重试只读返回已生成的
PENDING 版本;旧版本异参、已作废或非已审核状态返回 A0409 且零写入。
parameters:
@@ -1887,6 +1887,11 @@ components:
supplierId: {type: integer, format: int64, nullable: true}
supplierName: {type: string, nullable: true}
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true, description: PAYABLE供应商类别约束;为空时仅要求供应商ACTIVE}
settlementMode: {type: string, enum: [ON_ACCOUNT, DIRECT_PAID], nullable: true, description: 仅PAYABLE;ON_ACCOUNT可进入AP,DIRECT_PAID仅计成本且禁止进入AP}
paidAt: {type: string, format: date-time, nullable: true}
paymentMethod: {type: string, enum: [ONLINE, BANK_TRANSFER, CASH, OTHER], nullable: true}
paymentReferenceNo: {type: string, nullable: true}
paymentVoucherFileIds: {type: array, nullable: true, items: {type: integer, format: int64}}
feeItemId: {type: integer, format: int64, nullable: true}
feeItemCode: {type: string, nullable: true}
feeItemName: {type: string}
@@ -0,0 +1,20 @@
package com.baiyue.firstleg.client;
import org.junit.jupiter.api.Test;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import static org.assertj.core.api.Assertions.assertThat;
class FeePaymentVoucherFileClientContractTest {
@Test
void validatesFinanceVoucherWithinExactCustomerScope() throws Exception {
var method = FeePaymentVoucherFileClient.class.getMethod("metadata",
String.class, long.class, long.class, long.class);
assertThat(method.getAnnotation(GetMapping.class).value())
.containsExactly("/{tenantId}/FINANCE_VOUCHER/{customerId}/{fileId}");
assertThat(method.getParameters()[0].getAnnotation(RequestHeader.class).value())
.isEqualTo("X-Internal-Service-Token");
}
}
@@ -58,7 +58,7 @@ class FeeManagementContractTest {
.contains("ContainerFeeAdjustRequest:")
.contains("required: [expectedSourceVersion, businessDate, amount]")
.contains("additionalProperties: false")
.contains("费用方向、费用项目、供应商、币种和备注保持原快照")
.contains("费用方向、费用项目、供应商、结算方式、已付快照、币种和备注保持原快照")
.contains("旧版本异参、已作废或非已审核状态返回 A0409 且零写入");
}
@@ -65,6 +65,8 @@ class InternalFinanceFeeSourceControllerTest {
.contains("/internal/firstleg/v1/containers/{containerId}/fees/{feeId}/source-snapshot:")
.contains("{name: sourceVersion, in: query, required: true")
.contains("supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true")
.contains("settlementMode: {type: string, enum: [ON_ACCOUNT, DIRECT_PAID], nullable: true")
.contains("paymentVoucherFileIds:")
.contains("为空时仅要求供应商ACTIVE")
.contains("/internal/firstleg/v1/fee-sources/commands/void:")
.contains("FIRSTLEG_FEE_VOIDED Outbox")
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.annotation.TableName;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.mapper.ContainerBookingMapper;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -61,6 +62,23 @@ class ContainerBookingMapperContractTest {
assertThat(sql).contains("i.account_id<>#{customerId}").doesNotContain("&lt;&gt;");
}
@Test
@DisplayName("关联订舱 SQL 的预计到港参数与 Mapper 参数名一致")
void linkReleasedBookingUsesDeclaredEstimatedArrivalParameter() {
var method = Arrays.stream(ContainerBookingMapper.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals("linkReleasedBooking"))
.findFirst().orElseThrow();
String sql = String.join("\n", method.getAnnotation(Update.class).value());
var parameterNames = Arrays.stream(method.getParameters())
.map(parameter -> parameter.getAnnotation(Param.class).value())
.toList();
assertThat(parameterNames).contains("estimatedPortArrivalAt");
assertThat(sql)
.contains("estimated_port_arrival_at=#{estimatedPortArrivalAt}")
.doesNotContain("#{estimatedArrivalAt}");
}
@Test
@DisplayName("草稿完整更新和预装运输白名单都带版本与状态门禁")
void guardsUpdatesInSql() {
@@ -18,11 +18,15 @@ class FinanceFeeSourceMigrationContractTest {
assertThat(baseline).contains(
"supplier_id BIGINT UNSIGNED NULL",
"supplier_name VARCHAR(200) NULL",
"supplier_category VARCHAR(16) NULL");
"supplier_category VARCHAR(16) NULL",
"settlement_mode VARCHAR(16) NULL",
"payment_voucher_file_ids JSON NULL");
assertThat(migration).contains(
"firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id'",
"firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_name'",
"firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_category'",
"firstleg_finance_add_column('fl_inbound_fee_detail', 'settlement_mode'",
"firstleg_finance_add_column('fl_inbound_fee_detail', 'payment_voucher_file_ids'",
"Firstleg Finance迁移失败:入库费用供应商快照字段不完整");
}
@@ -35,12 +39,16 @@ class FinanceFeeSourceMigrationContractTest {
"uk_inbound_outbox_event_key", "next_retry_at", "locked_at", "locked_by",
"idx_inbound_outbox_finance_relay", "CREATE TABLE IF NOT EXISTS fl_container_fee_detail",
"source_version", "supplier_id", "supplier_name", "supplier_category",
"settlement_mode", "paid_at", "payment_method", "payment_reference_no",
"payment_voucher_file_ids",
"CREATE TABLE IF NOT EXISTS fl_container_fee_source_snapshot",
"uk_container_fee_source_version",
"CREATE TABLE IF NOT EXISTS fl_finance_fee_source_command",
"uk_finance_fee_command_idem",
"request_hash CHAR(64) NOT NULL",
"firstleg_finance_add_column('fl_container_fee_detail', 'supplier_id'", "RECEIVABLE", "PAYABLE",
"firstleg_finance_add_column('fl_container_fee_source_snapshot', 'settlement_mode'",
"柜次费用已付版本快照字段不完整",
"firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id'",
"Firstleg Finance迁移失败:入库费用供应商快照字段不完整",
"chk_container_fee_currency", "currency = 'CNY'", "firstleg_finance_assert_schema");
@@ -12,6 +12,7 @@ import com.baiyue.firstleg.repository.ContainerFeeSourceSnapshotRepository;
import com.baiyue.firstleg.repository.FeeItemRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.mockito.ArgumentCaptor;
import java.math.BigDecimal;
@@ -29,6 +30,7 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.lenient;
class ContainerFeeDetailServiceTest {
private final ContainerFeeDetailRepository repository = mock(ContainerFeeDetailRepository.class);
@@ -37,8 +39,18 @@ class ContainerFeeDetailServiceTest {
private final FinanceFeeSourceOutboxService outbox = mock(FinanceFeeSourceOutboxService.class);
private final WarehouseSupplierSnapshotService suppliers = mock(WarehouseSupplierSnapshotService.class);
private final ContainerFeeSourceSnapshotRepository sourceSnapshots = mock(ContainerFeeSourceSnapshotRepository.class);
private final FeePaymentSnapshotValidator paymentSnapshots = mock(FeePaymentSnapshotValidator.class);
private final ContainerFeeDetailService service =
new ContainerFeeDetailService(repository, loading, feeItems, outbox, suppliers, sourceSnapshots);
new ContainerFeeDetailService(repository, loading, feeItems, outbox, suppliers, sourceSnapshots,
paymentSnapshots);
@BeforeEach
void paymentSnapshot() {
lenient().when(paymentSnapshots.validate(anyLong(), any(), any(), any(), any(), any(), any()))
.thenAnswer(invocation -> new FeePaymentSnapshotValidator.Snapshot(
invocation.getArgument(2), invocation.getArgument(3), invocation.getArgument(4),
invocation.getArgument(5), invocation.getArgument(6) == null ? List.of() : invocation.getArgument(6)));
}
@Test
void rejectsContainerThatDoesNotContainCustomerOrders() {
@@ -199,6 +211,28 @@ class ContainerFeeDetailServiceTest {
assertThat(captor.getValue().getSupplierName()).isNull();
}
@Test
void directPaidFieldsAreStoredAsBusinessSnapshot() {
ownedPlan();
when(feeItems.findItem(11L)).thenReturn(item());
when(suppliers.fee(71L, null)).thenReturn(
new com.baiyue.firstleg.client.WarehouseSupplierClient.Snapshot(
71L, "现有供应商", "TRUCKING", "ACTIVE"));
LocalDateTime paidAt = LocalDateTime.of(2026, 8, 20, 10, 0);
var request = new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE",
LocalDate.of(2026, 8, 20), new BigDecimal("300.00"), "CNY", 71L,
"DIRECT_PAID", paidAt, "BANK_TRANSFER", "BANK-001", List.of(901L), "已付拖车费");
service.create(10001L, 41L, 9L, request);
ArgumentCaptor<ContainerFeeDetailEntity> captor = ArgumentCaptor.forClass(ContainerFeeDetailEntity.class);
verify(repository).insert(captor.capture());
assertThat(captor.getValue().getSettlementMode()).isEqualTo("DIRECT_PAID");
assertThat(captor.getValue().getPaidAt()).isEqualTo(paidAt);
assertThat(captor.getValue().getPaymentMethod()).isEqualTo("BANK_TRANSFER");
assertThat(captor.getValue().getPaymentVoucherFileIds()).containsExactly(901L);
}
private void ownedPlan() {
LoadingPlanEntity plan = new LoadingPlanEntity();
plan.setId(41L);
@@ -14,6 +14,7 @@ import com.baiyue.firstleg.repository.FbaPricingRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.mockito.ArgumentCaptor;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.boot.test.mock.mockito.MockBean;
@@ -27,12 +28,14 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.lenient;
@DisplayName("费用项目和入库费用明细")
@SpringJUnitConfig({FeeItemService.class, InboundFeeDetailService.class})
@@ -45,11 +48,21 @@ class FeeManagementServiceTest {
private FinanceFeeSourceOutboxService financeOutbox;
@MockBean
private WarehouseSupplierSnapshotService suppliers;
@MockBean
private FeePaymentSnapshotValidator paymentSnapshots;
@Resource
private FeeItemService feeItems;
@Resource
private InboundFeeDetailService details;
@BeforeEach
void paymentSnapshot() {
lenient().when(paymentSnapshots.validate(anyLong(), any(), any(), any(), any(), any(), any()))
.thenAnswer(invocation -> new FeePaymentSnapshotValidator.Snapshot(
invocation.getArgument(2), invocation.getArgument(3), invocation.getArgument(4),
invocation.getArgument(5), invocation.getArgument(6) == null ? List.of() : invocation.getArgument(6)));
}
@Test
@DisplayName("客户B不能删除客户A的费用明细")
void rejectsCrossCustomerFeeDetailDelete() {
@@ -0,0 +1,77 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.FeePaymentVoucherFileClient;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class FeePaymentSnapshotValidatorTest {
private final FeePaymentVoucherFileClient files = mock(FeePaymentVoucherFileClient.class);
private final FeePaymentSnapshotValidator validator =
new FeePaymentSnapshotValidator(files, "file-token");
@Test
void receivableClearsSettlementAndRejectsPaidFields() {
assertThat(validator.validate(9L, "RECEIVABLE", null, null, null, null, null))
.isEqualTo(new FeePaymentSnapshotValidator.Snapshot(null, null, null, null, List.of()));
assertThatThrownBy(() -> validator.validate(9L, "RECEIVABLE", "ON_ACCOUNT",
null, null, null, null)).isInstanceOf(BizException.class)
.hasMessageContaining("应收费用不得");
}
@Test
void onAccountRejectsEveryPaidField() {
assertThat(validator.validate(9L, "PAYABLE", "ON_ACCOUNT",
null, null, null, null).settlementMode()).isEqualTo("ON_ACCOUNT");
assertThatThrownBy(() -> validator.validate(9L, "PAYABLE", "ON_ACCOUNT",
LocalDateTime.now(), "CASH", "CASH-1", List.of(1L)))
.isInstanceOf(BizException.class).hasMessageContaining("账期应付不得");
}
@Test
void directPaidRequiresTimeMethodAndReferenceOrVoucher() {
LocalDateTime paidAt = LocalDateTime.of(2026, 8, 20, 10, 0);
assertThatThrownBy(() -> validator.validate(9L, "PAYABLE", "DIRECT_PAID",
paidAt, "BANK_TRANSFER", null, null))
.isInstanceOf(BizException.class).hasMessageContaining("流水号或至少一个付款凭证");
assertThat(validator.validate(9L, "PAYABLE", "DIRECT_PAID",
paidAt, "BANK_TRANSFER", "BANK-001", null))
.extracting(FeePaymentSnapshotValidator.Snapshot::paymentReferenceNo)
.isEqualTo("BANK-001");
}
@Test
void directPaidVoucherIsValidatedAgainstCurrentCustomerAndFileType() {
LocalDateTime paidAt = LocalDateTime.of(2026, 8, 20, 10, 0);
when(files.metadata("file-token", 9L, 9L, 901L)).thenReturn(ApiResponse.success(
new FeePaymentVoucherFileClient.Metadata(901L, 9L, "FINANCE_VOUCHER", "9",
"voucher.pdf", "a".repeat(64), "application/pdf", 100L)));
var result = validator.validate(9L, "PAYABLE", "DIRECT_PAID",
paidAt, "ONLINE", null, List.of(901L, 901L));
assertThat(result.paymentVoucherFileIds()).containsExactly(901L);
verify(files).metadata("file-token", 9L, 9L, 901L);
}
@Test
void fileMismatchFailsClosed() {
when(files.metadata("file-token", 9L, 9L, 901L)).thenReturn(ApiResponse.success(
new FeePaymentVoucherFileClient.Metadata(901L, 10L, "FINANCE_VOUCHER", "10",
"voucher.pdf", "a".repeat(64), "application/pdf", 100L)));
assertThatThrownBy(() -> validator.validate(9L, "PAYABLE", "DIRECT_PAID",
LocalDateTime.now(), "CASH", null, List.of(901L)))
.isInstanceOf(BizException.class).hasMessageContaining("不属于当前客户");
}
}
@@ -84,6 +84,7 @@ class FinanceFeeSourceSnapshotServiceTest {
detail.setSupplierId(71L);
detail.setSupplierName("现有供应商");
detail.setSupplierCategory("TRUCKING");
detail.setSettlementMode("ON_ACCOUNT");
detail.setBusinessDate(java.time.LocalDate.of(2026, 8, 12));
detail.setAmount(new BigDecimal("300.00"));
detail.setCurrency("CNY");
@@ -96,6 +97,8 @@ class FinanceFeeSourceSnapshotServiceTest {
assertThat(snapshot.supplierId()).isEqualTo(71L);
assertThat(snapshot.supplierName()).isEqualTo("现有供应商");
assertThat(snapshot.supplierCategory()).isEqualTo("TRUCKING");
assertThat(snapshot.settlementMode()).isEqualTo("ON_ACCOUNT");
assertThat(snapshot.paidAt()).isNull();
assertThat(snapshot.sourceHash()).matches("[0-9a-f]{64}");
}
}
@@ -38,6 +38,7 @@ test('验收只使用本轮 marker fixture,解析真实 XLSX 并覆盖门禁
assert.match(source, /Provider 失败信息可见/)
assert.match(source, /9223372036854775806/)
assert.doesNotMatch(source, /exportType:\s*['"]CUSTOMER_LIST/)
assert.doesNotMatch(source, /FINANCE_FEE_SOURCE_LIST:[^\n]*参与结算/)
})
test('未部署时能力探测后记录部署前置,不借历史业务数据', async () => {
@@ -15,7 +15,7 @@ export const REQUIRED_PERMISSIONS = Object.freeze([
export const EXPORT_SPECS = Object.freeze({
FINANCE_CUSTOMER_ACCOUNT_LIST: ['客户编码', '客户', '联系人', '联系电话', '待收(CNY)', '待开账(CNY)', '本期已收(CNY)', '待付(CNY)', '客户状态', '最近变动'],
FINANCE_CUSTOMER_ACCOUNT_LEDGER: ['流水号', '发生时间', '业务类型', '关联单据', '应收变动(CNY)', '应付变动(CNY)', '变动后未收(CNY)', '操作人', '备注'],
FINANCE_FEE_SOURCE_LIST: ['客户', '收付方向', '来源类型', '来源单据ID', '来源版本', '入仓号', '柜次号', '业务发生日', '费用项目', '计费量', '单价(CNY)', '金额(CNY)', '审核状态', '参与结算', '来源状态', '已生成对账单'],
FINANCE_FEE_SOURCE_LIST: ['客户', '收付方向', '来源类型', '来源单据ID', '来源版本', '入仓号', '柜次号', '业务发生日', '费用项目', '计费量', '单价(CNY)', '金额(CNY)', '审核状态', '来源状态', '已生成对账单'],
FINANCE_RECONCILIATION_STATEMENT_LIST: ['对账单号', '客户', '结算起始日', '结算结束日', '账期(天)', '货币', '总应收金额(CNY)', '已收金额(CNY)', '未收金额(CNY)', '是否结清', '审核状态', '备注', '创建人', '创建时间', '更新人', '更新时间'],
FINANCE_RECEIVABLE_SETTLEMENT_LIST: ['结算单号', '客户', '供应商', '货币', '总金额(CNY)', '已收/付金额(CNY)', '未收/付金额(CNY)', '业务期间', '生成日期', '到期日期', '状态'],
FINANCE_RECEIVABLE_SETTLEMENT_DETAIL: ['结算单号', '客户', '供应商', '费用项目', '入仓号', '柜次号', '业务发生日', '明细金额(CNY)', '结算总额(CNY)', '已收/付金额(CNY)', '未收/付金额(CNY)', '状态'],
@@ -38,6 +38,7 @@ test('PAYABLE fee selection uses only the explicit nullable supplierCategory fie
assert.equal(supplierCategoryForFeeItem({ supplierCategory: 'BOOKING', feeTypeName: '操作费' }), 'BOOKING')
assert.equal(supplierCategoryForFeeItem({ supplierCategory: null, feeTypeName: '拖车费' }), null)
assert.equal(supplierCategoryForFeeItem({ supplierCategory: 'INVALID' }), null)
assert.match(source, /payableSupplierCategory && source\.supplierCategory !== payableSupplierCategory/)
})
test('Finance PAYABLE fixture creates an isolated ACTIVE supplier through the formal API', () => {
@@ -498,7 +498,7 @@ export async function run(context) {
const invalidFixtureSources = fixtureSources.filter((source) => !source.feeSourceId || !source.customerId
|| !source.businessDate || !(source.waybillId || source.shipmentId || source.orderNo) || source.marker !== context.marker
|| (source.direction === 'PAYABLE' && (!source.supplierId || !source.supplierName
|| source.supplierCategory !== payableSupplierCategory)))
|| (payableSupplierCategory && source.supplierCategory !== payableSupplierCategory))))
context.assert(MODULE, '输出完整 Finance 来源契约', null, invalidFixtureSources.length === 0,
`sources=${fixtureSources.length}, invalid=${invalidFixtureSources.length};字段必须来自本轮费用来源、客户、订单/运单和真实柜次分摊`, OPTIONS)
context.createdResources.financeFullFixtures = {
@@ -15,7 +15,8 @@ test('Finance AP consumes only published fin_fee_source rows and never substitut
assert.match(source, /!isUnboundContainerCost\(source\.raw\) \|\| hasContainerAllocations/)
})
const financePermissions = [
'platform:finance:customer-account:list', 'platform:finance:customer-statement:list',
'platform:finance:customer-account:list', 'platform:finance:customer-arap:list',
'platform:finance:fee-source:review', 'platform:finance:customer-statement:list',
'platform:finance:reconciliation:create', 'platform:finance:reconciliation:review',
'platform:finance:reconciliation:issue', 'platform:finance:reconciliation:adjust',
'platform:finance:reconciliation:void', 'platform:finance:settlement-ar:create',
@@ -29,6 +30,8 @@ function ok(data) {
function fakeContext(withFixture = true, permissions = financePermissions, denyFinanceWrites = false,
missingSubject = false, omitPayableSource = false) {
process.env.MERCHANT_TEST_USERNAME = 'fixture-client'
process.env.MERCHANT_TEST_PASSWORD = 'fixture-password'
const marker = 'FIN-TEST-MARKER'
const receivables = [
{ feeSourceId: '101', orderId: '201', direction: 'RECEIVABLE', currency: 'CNY', businessDate: '2026-08-10' },
@@ -58,12 +61,13 @@ function fakeContext(withFixture = true, permissions = financePermissions, denyF
const calls = []
const steps = []
const coverageGaps = []
const reviewedSourceIds = new Set()
function responseForStatement(statement) { return structuredClone(statement) }
function applyAction(statement, body) {
const status = { SUBMIT: 'PENDING_REVIEW', APPROVE: 'APPROVED', REJECT: 'REJECTED',
ISSUE: 'ISSUED', ADJUST: 'ISSUED', VOID: 'VOIDED', REOPEN: 'DRAFT' }[body.action]
statement.revisions.push({ action: `RECONCILIATION_${body.action}` })
statement.revisions.push({ action: body.action })
statement.status = status
statement.version += 1
if (body.action === 'ADJUST') {
@@ -73,6 +77,8 @@ function fakeContext(withFixture = true, permissions = financePermissions, denyF
line.adjustmentReason = adjustment.reason
}
}
if (body.action === 'ISSUE') statement.customerConfirmationStatus = 'PENDING_CONFIRMATION'
if (body.action === 'VOID' || body.action === 'REOPEN') statement.customerConfirmationStatus = 'NOT_REQUIRED'
return responseForStatement(statement)
}
@@ -86,6 +92,9 @@ function fakeContext(withFixture = true, permissions = financePermissions, denyF
if (method === 'GET' && requestPath === '/api/platform/v1/iam/authorization/permissions') {
return ok(permissions)
}
if (method === 'POST' && requestPath === '/api/auth/login') {
return ok({ accessToken: 'client-token' })
}
if (method === 'GET' && requestPath === '/api/platform/finance/v1/accounts?page=1&pageSize=20&customerId=11') {
return ok({ records: [{ customerId: '11', customerCode: 'TEN11', customerName: 'Fixture Account',
contactName: 'Fixture Contact', contactPhone: '13800000000', outstandingReceivable: 100,
@@ -112,6 +121,20 @@ function fakeContext(withFixture = true, permissions = financePermissions, denyF
reviewStatus: 'APPROVED', sourceStatus: 'AVAILABLE' }
}))
}
const feeSourceDetailMatch = requestPath.match(/\/fee-sources\/(\d+)$/)
if (method === 'GET' && feeSourceDetailMatch) {
const localId = feeSourceDetailMatch[1]
return ok({ feeSourceId: localId, reviewStatus: reviewedSourceIds.has(localId) ? 'APPROVED' : 'PENDING',
sourceStatus: 'AVAILABLE', statementGenerated: false, operationVersion: reviewedSourceIds.has(localId) ? 1 : 0 })
}
const feeSourceReviewMatch = requestPath.match(/\/fee-sources\/(\d+)\/review$/)
if (method === 'POST' && feeSourceReviewMatch) {
reviewedSourceIds.add(feeSourceReviewMatch[1])
const reviewed = { feeSourceId: feeSourceReviewMatch[1], reviewStatus: 'APPROVED',
sourceStatus: 'AVAILABLE', statementGenerated: false, operationVersion: 1 }
if (idem) idempotency.set(idem, reviewed)
return ok(reviewed)
}
if (method === 'POST' && requestPath.endsWith('/statements')) {
if (body.currency !== 'CNY') return { http: 200, body: { code: 'FIN_400' } }
if (String(body.customerId) !== '11') return { http: 200, body: { code: 'FIN_404' } }
@@ -166,6 +189,23 @@ function fakeContext(withFixture = true, permissions = financePermissions, denyF
createdBy: item.createdBy, createdAt: item.createdAt, updatedBy: item.updatedBy, updatedAt: item.updatedAt,
})))
}
if (method === 'GET' && requestPath === '/api/client/finance/v1/statements') {
return ok([...statements.values()].map((item) => ({
statementId: item.statementId, customerId: item.customerId, status: item.status,
version: item.version, customerConfirmationStatus: item.customerConfirmationStatus,
})))
}
const clientConfirmMatch = requestPath.match(/\/api\/client\/finance\/v1\/statements\/(\d+)\/confirm$/)
if (method === 'POST' && clientConfirmMatch) {
const statement = statements.get(clientConfirmMatch[1])
statement.customerConfirmationStatus = 'CONFIRMED'
statement.version += 1
const result = { statementId: statement.statementId, confirmationStatus: 'CONFIRMED',
confirmedBy: '7002', confirmedByName: 'fixture-client', confirmedAt: '2026-08-10T10:00:00',
version: statement.version }
if (idem) idempotency.set(idem, result)
return ok(structuredClone(result))
}
const detailMatch = requestPath.match(/\/reconciliation-statements\/(\d+)$/)
if (method === 'GET' && detailMatch) return ok(responseForStatement(statements.get(detailMatch[1])))
throw new Error(`Unexpected request: ${method} ${requestPath}`)
@@ -1,11 +1,14 @@
const MODULE = 'finance-reconciliation-full'
const SCENARIO = 'finance-full-reconciliation'
const BASE = '/api/platform/finance/v1'
const CLIENT_BASE = '/api/client/finance/v1'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
const STATEMENT_LIST_PERMISSION = 'platform:finance:customer-statement:list'
const LEGACY_STATEMENT_VIEW_PERMISSION = 'platform:finance:bill:view'
const REQUIRED_PERMISSIONS = [
'platform:finance:customer-account:list',
'platform:finance:customer-arap:list',
'platform:finance:fee-source:review',
STATEMENT_LIST_PERMISSION,
'platform:finance:reconciliation:create',
'platform:finance:reconciliation:review',
@@ -116,6 +119,76 @@ function finish(context, start) {
failed: steps.filter((step) => !step.passed).length,
})
}
async function confirmIssuedStatementAsClient(context, fixture, statement) {
const username = process.env.MERCHANT_TEST_USERNAME
const password = process.env.MERCHANT_TEST_PASSWORD
const targetId = statementId(statement)
if (!username || !password || !targetId) {
context.assert(MODULE, '客户确认对账单凭据与单据门禁', null, false,
`username=${username ? 'present' : 'missing'}, password=${password ? 'present' : 'missing'}, statementId=${targetId || 'missing'}`, OPTIONS)
return null
}
const loginResponse = await context.request('POST', '/api/auth/login', { username, password }, '')
const login = context.apiSuccess(MODULE, 'Client 客户登录用于确认对账单', loginResponse,
`username=${username}`, OPTIONS)
const clientToken = login?.accessToken
if (!clientToken) return null
const listResponse = await context.request('GET', `${CLIENT_BASE}/statements`, undefined, clientToken)
const statements = context.apiSuccess(MODULE, 'Client 查询本企业对账单', listResponse,
`customerId=${fixture.customerId}, statementId=${targetId}`, OPTIONS)
const before = rows(statements).find((item) => statementId(item) === targetId)
context.assert(MODULE, 'Client 仅取得本企业待确认已出账对账单', listResponse,
id(before?.customerId) === fixture.customerId && before?.status === 'ISSUED'
&& before?.customerConfirmationStatus === 'PENDING_CONFIRMATION',
`statementId=${statementId(before) || 'missing'}, status=${before?.status ?? 'missing'}, confirmation=${before?.customerConfirmationStatus ?? 'missing'}`, OPTIONS)
if (!before) return null
const headers = { 'Idempotency-Key': key(context, `client-confirm-${targetId}`) }
const body = { expectedVersion: Number(before.version) }
const confirmResponse = await context.request('POST',
`${CLIENT_BASE}/statements/${encodeURIComponent(targetId)}/confirm`, body, clientToken, headers)
const confirmed = context.apiSuccess(MODULE, 'Client 确认本人已出账对账单', confirmResponse,
`statementId=${targetId}`, OPTIONS)
context.assert(MODULE, '客户确认状态、确认人和版本已落地', confirmResponse,
statementId(confirmed) === targetId && confirmed?.confirmationStatus === 'CONFIRMED'
&& present(confirmed?.confirmedBy) && present(confirmed?.confirmedByName)
&& present(confirmed?.confirmedAt) && Number(confirmed?.version) === Number(before.version) + 1,
`statementId=${statementId(confirmed) || 'missing'}, confirmation=${confirmed?.confirmationStatus ?? 'missing'}, version=${confirmed?.version ?? 'missing'}`, OPTIONS)
const replayResponse = await context.request('POST',
`${CLIENT_BASE}/statements/${encodeURIComponent(targetId)}/confirm`, body, clientToken, headers)
const replay = context.apiSuccess(MODULE, 'Client 客户确认幂等重放', replayResponse,
`statementId=${targetId}`, OPTIONS)
context.assert(MODULE, '客户确认幂等不重复递增版本', replayResponse,
statementId(replay) === targetId && Number(replay?.version) === Number(confirmed?.version),
`firstVersion=${confirmed?.version ?? 'missing'}, replayVersion=${replay?.version ?? 'missing'}`, OPTIONS)
fixture.root.clientConfirmation = confirmed
return confirmed
}
async function approveFixtureSources(context, fixture) {
const approved = []
for (const source of fixture.sources) {
const detailResponse = await context.request('GET', `${BASE}/fee-sources/${encodeURIComponent(source.feeSourceId)}`)
let detail = context.apiSuccess(MODULE, '回读本轮 Finance 费用来源审核状态', detailResponse,
`feeSourceId=${source.feeSourceId}`, OPTIONS)
if (!detail) return false
if (detail.reviewStatus === 'PENDING') {
const reviewResponse = await write(context, 'POST', `${BASE}/fee-sources/${encodeURIComponent(source.feeSourceId)}/review`, {
action: 'APPROVE', reason: context.marker, expectedVersion: Number(detail.operationVersion),
}, `fee-source-review-${source.feeSourceId}`)
detail = context.apiSuccess(MODULE, '审核本轮 Finance 费用来源', reviewResponse,
`feeSourceId=${source.feeSourceId}`, OPTIONS)
context.assert(MODULE, '费用来源审核后成为结算候选', reviewResponse,
detail?.reviewStatus === 'APPROVED' && detail?.sourceStatus === 'AVAILABLE'
&& detail?.statementGenerated === false,
`feeSourceId=${source.feeSourceId}, reviewStatus=${detail?.reviewStatus ?? 'missing'}, sourceStatus=${detail?.sourceStatus ?? 'missing'}`, OPTIONS)
}
if (detail?.reviewStatus !== 'APPROVED') return false
approved.push(source.feeSourceId)
}
fixture.root.approvedFinanceFeeSourceIds = approved
return approved.length === fixture.sources.length
}
function diagnostic(response) {
return { http: response?.http ?? null,
code: response?.body?.code == null ? null : String(response.body.code),
@@ -306,8 +379,7 @@ async function issueCompleteChain(context, statement) {
statement = await act(context, statement, 'ISSUE', 'chain-issue-final')
const actions = new Set(list(statement?.revisions).map((revision) => revision.action))
context.assert(MODULE, '完整链保留驳回、重开、调整、作废和再次出账 revision', null,
['RECONCILIATION_REJECT', 'RECONCILIATION_REOPEN', 'RECONCILIATION_ADJUST',
'RECONCILIATION_VOID', 'RECONCILIATION_ISSUE'].every((action) => actions.has(action)),
['REJECT', 'REOPEN', 'ADJUST', 'VOID', 'ISSUE'].every((action) => actions.has(action)),
`actions=${[...actions].join(',')}`, OPTIONS)
return statement
}
@@ -445,6 +517,13 @@ export async function run(context) {
const groups = [cutoffSources, ...(() => {
return [remainingSources.slice(0, 1), remainingSources.slice(1)]
})()]
if (!await approveFixtureSources(context, fixture)) {
blocker(context, '本轮 Finance 费用来源全部审核通过',
'费用来源未完成 PENDING -> APPROVED,禁止跳过审核直接生成对账单',
'finance-fee-source-review-failed')
finish(context, start)
return
}
const issuedStatements = []
for (let index = 0; index < groups.length; index += 1) {
const created = await createStatement(context, groups[index], `selected-${index + 1}`,
@@ -506,6 +585,8 @@ export async function run(context) {
}
}
await confirmIssuedStatementAsClient(context, fixture, issuedStatements[0])
const receivableSettlements = []
for (let index = 0; index < issuedStatements.length; index += 1) {
const settlement = await createReceivable(context, issuedStatements[index], index + 1)
@@ -23,6 +23,12 @@ test('measurable inbound without fulfillment task remains pending', () => {
}, { http: 200, body: { code: 'A0404' } }, tenantId, orderId), false)
})
test('approval polling remains pending before the first fulfillment task response', () => {
assert.equal(approvedInboundReady({
summary: { inboundId: null, mainStatus: 'PENDING_REVIEW' },
}, null, tenantId, orderId), false)
})
test('approval convergence requires matching inbound state, order and customer task identity', () => {
const detail = { summary: { inboundId: '40001', mainStatus: 'WAIT_MEASURE' } }
assert.equal(approvedInboundReady(detail, taskResponse, tenantId, orderId), true)
@@ -144,22 +144,23 @@ export async function run(context) {
OPTIONS)
if (!selectedChannel) return
const channelName = selectedChannel.channelName
const supplierResponse = await context.request('GET',
'/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&page=1&size=20')
const supplierPage = context.apiSuccess(MODULE, '读取 ACTIVE 订舱代理作为运输事实源', supplierResponse, '', OPTIONS)
const supplier = supplierPage?.records?.find?.((value) => value?.id && value?.name)
context.assert(MODULE, '存在可用于发布订舱的 ACTIVE 订舱代理', supplierResponse,
Boolean(supplier?.id && supplier?.name), `supplierId=${id(supplier?.id) || 'missing'}`, OPTIONS)
if (!supplier?.id) return
const primaryRequirement = { containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 }
const realContainerNo = `TST-${suffix}-01`.slice(0, 64)
const wrongContainerNo = `TST-${suffix}-02`.slice(0, 64)
const agentResponse = await context.request('GET',
'/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&page=1&size=20')
const agentPage = context.apiSuccess(MODULE, '查询放舱所需 ACTIVE 订舱代理', agentResponse, '', OPTIONS)
const bookingAgent = records(agentPage).find((item) => item?.category === 'BOOKING' && item?.status === 'ACTIVE')
context.assert(MODULE, '放舱订舱使用真实启用供应商', agentResponse,
Boolean(bookingAgent?.id && bookingAgent?.name),
`supplierId=${id(bookingAgent?.id) || 'missing'}`, OPTIONS)
if (!bookingAgent?.id || !bookingAgent?.name) return
const bookingDraft = {
customerId: tenantId, customerName: inbound?.summary?.customerName || `验收客户-${tenantId}`,
channelCode, warehouseId,
billOfLadingNo: `BL-${suffix}`.slice(0, 128),
shippingCompanyId: id(supplier.id), shippingCompanyName: supplier.name,
bookingAgentId: id(supplier.id), bookingAgentName: supplier.name,
shippingCompanyId: id(bookingAgent.id), shippingCompanyName: bookingAgent.name,
bookingAgentId: id(bookingAgent.id), bookingAgentName: bookingAgent.name,
vesselName: `验收船-${suffix}`.slice(0, 128), voyageNo: `V-${suffix}`.slice(0, 64),
departurePortCode: 'CNSZX', departurePortName: '深圳',
destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', placeOfDelivery: 'LAX',
@@ -372,10 +372,9 @@ test('container loading enforces channel isolation and stable PDA identity acros
assert.match(value, /id\(replacedTask\?\.cabinetNo\) === stableIdentity\.cabinetNo/)
assert.match(value, /afterReplacementSnapshot\?\.snapshotToken === beforeReplacementSnapshot\?\.snapshotToken/)
assert.match(value, /pdaIdentityStable: true/)
assert.match(value, /shippingCompanyId: null, shippingCompanyName: null/)
assert.match(value, /bookingAgentId: null, bookingAgentName: null/)
assert.doesNotMatch(value, /shippingCompanyName: `验收船公司-/)
assert.doesNotMatch(value, /bookingAgentName: `验收代理-/)
assert.match(value, /category=BOOKING&status=ACTIVE/)
assert.match(value, /shippingCompanyId: id\(bookingAgent\.id\), shippingCompanyName: bookingAgent\.name/)
assert.match(value, /bookingAgentId: id\(bookingAgent\.id\), bookingAgentName: bookingAgent\.name/)
assert.match(value, /const frozenTransport = Object\.fromEntries\(transportFields\.map/)
assert.match(value, /if \(!frozenTransportMatches\) return/)
assert.match(value, /expectedVersion: Number\(bookingCreatedDraft\?\.oceanBooking\?\.version\), reason: context\.marker/)