Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev
# Conflicts: # scripts/admin-test-env-modules/orders.mjs # scripts/admin-test-env-modules/phase3-container-loading.mjs
This commit is contained in:
@@ -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) {}
|
||||
|
||||
|
||||
+24
-8
@@ -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", "事件序列化失败"); }
|
||||
|
||||
+21
-4
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+10
-1
@@ -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>
|
||||
|
||||
+5
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-2
@@ -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}
|
||||
|
||||
+20
@@ -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");
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -58,7 +58,7 @@ class FeeManagementContractTest {
|
||||
.contains("ContainerFeeAdjustRequest:")
|
||||
.contains("required: [expectedSourceVersion, businessDate, amount]")
|
||||
.contains("additionalProperties: false")
|
||||
.contains("费用方向、费用项目、供应商、币种和备注保持原快照")
|
||||
.contains("费用方向、费用项目、供应商、结算方式、已付快照、币种和备注保持原快照")
|
||||
.contains("旧版本异参、已作废或非已审核状态返回 A0409 且零写入");
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -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")
|
||||
|
||||
+18
@@ -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("<>");
|
||||
}
|
||||
|
||||
@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() {
|
||||
|
||||
+9
-1
@@ -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");
|
||||
|
||||
+35
-1
@@ -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() {
|
||||
|
||||
+77
@@ -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("不属于当前客户");
|
||||
}
|
||||
}
|
||||
+3
@@ -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/)
|
||||
|
||||
Reference in New Issue
Block a user