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_id BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空',
|
||||||
supplier_name VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空',
|
supplier_name VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空',
|
||||||
supplier_category VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商',
|
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 '费用业务日期',
|
business_date DATE NOT NULL COMMENT '费用业务日期',
|
||||||
amount DECIMAL(18,2) NOT NULL COMMENT 'CNY费用金额',
|
amount DECIMAL(18,2) NOT NULL COMMENT 'CNY费用金额',
|
||||||
currency CHAR(3) NOT NULL DEFAULT 'CNY' 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_currency CHECK (currency = 'CNY'),
|
||||||
CONSTRAINT chk_container_fee_amount CHECK (amount >= 0),
|
CONSTRAINT chk_container_fee_amount CHECK (amount >= 0),
|
||||||
CONSTRAINT chk_container_fee_review CHECK (review_status IN ('PENDING', 'APPROVED', 'REJECTED')),
|
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'))
|
CONSTRAINT chk_container_fee_source CHECK (source_status IN ('ACTIVE', 'VOIDED'))
|
||||||
) ENGINE=InnoDB COMMENT='柜次级人工应收应付费用来源;审核通过后自动同步Finance';
|
) 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_id BIGINT UNSIGNED NULL COMMENT 'Warehouse供应商ID快照',
|
||||||
supplier_name VARCHAR(200) NULL COMMENT 'Warehouse供应商名称快照',
|
supplier_name VARCHAR(200) NULL COMMENT 'Warehouse供应商名称快照',
|
||||||
supplier_category VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商',
|
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 '费用业务日期',
|
business_date DATE NOT NULL COMMENT '费用业务日期',
|
||||||
amount DECIMAL(18,2) NOT NULL COMMENT 'CNY费用金额',
|
amount DECIMAL(18,2) NOT NULL COMMENT 'CNY费用金额',
|
||||||
currency CHAR(3) NOT NULL DEFAULT 'CNY' 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");
|
"VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空' AFTER supplier_id");
|
||||||
CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_category',
|
CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_category',
|
||||||
"VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商' AFTER supplier_name");
|
"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',
|
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id',
|
||||||
"BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空' AFTER direction");
|
"BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空' AFTER direction");
|
||||||
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_name',
|
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_name',
|
||||||
"VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空' AFTER supplier_id");
|
"VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空' AFTER supplier_id");
|
||||||
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_category',
|
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_category',
|
||||||
"VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商' AFTER supplier_name");
|
"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_add_column;
|
||||||
|
|
||||||
DROP PROCEDURE IF EXISTS firstleg_finance_assert_schema;
|
DROP PROCEDURE IF EXISTS firstleg_finance_assert_schema;
|
||||||
@@ -11352,12 +11401,22 @@ BEGIN
|
|||||||
END IF;
|
END IF;
|
||||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||||
WHERE table_schema = DATABASE() AND table_name = 'fl_container_fee_detail'
|
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迁移失败:柜次费用供应商快照字段不完整';
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:柜次费用供应商快照字段不完整';
|
||||||
END IF;
|
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
|
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||||
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_fee_detail'
|
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迁移失败:入库费用供应商快照字段不完整';
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:入库费用供应商快照字段不完整';
|
||||||
END IF;
|
END IF;
|
||||||
END$$
|
END$$
|
||||||
@@ -11399,6 +11458,16 @@ CALL finance_fee_source_add_column('fin_fee_source', 'fee_item_code',
|
|||||||
"VARCHAR(64) NULL COMMENT '来源费用项目编码快照' AFTER container_id");
|
"VARCHAR(64) NULL COMMENT '来源费用项目编码快照' AFTER container_id");
|
||||||
CALL finance_fee_source_add_column('fin_fee_source', 'source_hash',
|
CALL finance_fee_source_add_column('fin_fee_source', 'source_hash',
|
||||||
"CHAR(64) NULL COMMENT 'Firstleg费用来源完整快照SHA-256哈希' AFTER currency_code");
|
"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',
|
CALL finance_fee_source_add_column('fin_fee_source', 'source_snapshot',
|
||||||
"JSON NULL COMMENT 'Firstleg费用来源完整不可变JSON快照' AFTER source_hash");
|
"JSON NULL COMMENT 'Firstleg费用来源完整不可变JSON快照' AFTER source_hash");
|
||||||
CALL finance_fee_source_add_column('fin_fee_source', 'review_status',
|
CALL finance_fee_source_add_column('fin_fee_source', 'review_status',
|
||||||
@@ -11458,8 +11527,9 @@ BEGIN
|
|||||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||||
WHERE table_schema = DATABASE() AND table_name = 'fin_fee_source'
|
WHERE table_schema = DATABASE() AND table_name = 'fin_fee_source'
|
||||||
AND column_name IN ('source_type','source_no','direction','container_id','fee_item_code',
|
AND column_name IN ('source_type','source_no','direction','container_id','fee_item_code',
|
||||||
'source_hash','source_snapshot','review_status','review_reason',
|
'settlement_mode','paid_at','payment_method','payment_reference_no',
|
||||||
'operation_version','updated_at')) <> 11 THEN
|
'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费用来源迁移失败:来源快照字段不完整';
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance费用来源迁移失败:来源快照字段不完整';
|
||||||
END IF;
|
END IF;
|
||||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import org.springframework.web.bind.annotation.RequestParam;
|
|||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@FeignClient(name = "firstleg-service", contextId = "financeFirstlegFeeSourceClient",
|
@FeignClient(name = "firstleg-service", contextId = "financeFirstlegFeeSourceClient",
|
||||||
url = "${baiyue.clients.firstleg.url:}", path = "/internal/firstleg/v1")
|
url = "${baiyue.clients.firstleg.url:}", path = "/internal/firstleg/v1")
|
||||||
@@ -45,6 +46,8 @@ public interface FirstlegFeeSourceClient {
|
|||||||
Long containerBookingId, String containerNo, long customerId,
|
Long containerBookingId, String containerNo, long customerId,
|
||||||
String customerName, Long supplierId, String supplierName,
|
String customerName, Long supplierId, String supplierName,
|
||||||
String supplierCategory,
|
String supplierCategory,
|
||||||
|
String settlementMode, LocalDateTime paidAt, String paymentMethod,
|
||||||
|
String paymentReferenceNo, List<Long> paymentVoucherFileIds,
|
||||||
Long feeItemId, String feeItemCode,
|
Long feeItemId, String feeItemCode,
|
||||||
String feeItemName, String direction, LocalDate businessDate,
|
String feeItemName, String direction, LocalDate businessDate,
|
||||||
String currency, BigDecimal totalAmount, String reviewStatus,
|
String currency, BigDecimal totalAmount, String reviewStatus,
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ public class PlatformFinanceController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping(value = "/vouchers", consumes = "multipart/form-data")
|
@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,
|
public ApiResponse<FinanceFileClient.Metadata> uploadVoucher(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@RequestParam long customerId,
|
@RequestParam long customerId,
|
||||||
@RequestPart("file") MultipartFile file) {
|
@RequestPart("file") MultipartFile file) {
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ public final class FinancePhase1Dtos {
|
|||||||
public record AccountSummary(long customerId, String customerCode, String customerName,
|
public record AccountSummary(long customerId, String customerCode, String customerName,
|
||||||
String contactName, String contactPhone,
|
String contactName, String contactPhone,
|
||||||
BigDecimal outstandingReceivable, BigDecimal pendingBilling,
|
BigDecimal outstandingReceivable, BigDecimal pendingBilling,
|
||||||
BigDecimal receivedInPeriod, BigDecimal outstandingPayable,
|
BigDecimal receivedInPeriod, BigDecimal totalCost,
|
||||||
|
BigDecimal paidCost, BigDecimal outstandingPayable,
|
||||||
String status, LocalDateTime lastChangedAt) {}
|
String status, LocalDateTime lastChangedAt) {}
|
||||||
|
|
||||||
public record LedgerEntry(long ledgerId, LocalDateTime occurredAt, String businessType,
|
public record LedgerEntry(long ledgerId, LocalDateTime occurredAt, String businessType,
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ public final class FinanceReadModels {
|
|||||||
BigDecimal receivedAmount,
|
BigDecimal receivedAmount,
|
||||||
BigDecimal outstandingReceivable,
|
BigDecimal outstandingReceivable,
|
||||||
BigDecimal pendingBilling,
|
BigDecimal pendingBilling,
|
||||||
|
BigDecimal totalCost,
|
||||||
|
BigDecimal paidCost,
|
||||||
BigDecimal outstandingPayable,
|
BigDecimal outstandingPayable,
|
||||||
Instant asOf,
|
Instant asOf,
|
||||||
LocalDateTime lastChangedAt) {}
|
LocalDateTime lastChangedAt) {}
|
||||||
@@ -53,6 +55,11 @@ public final class FinanceReadModels {
|
|||||||
String feeItemName,
|
String feeItemName,
|
||||||
BigDecimal amount,
|
BigDecimal amount,
|
||||||
String currency,
|
String currency,
|
||||||
|
String settlementMode,
|
||||||
|
LocalDateTime paidAt,
|
||||||
|
String paymentMethod,
|
||||||
|
String paymentReferenceNo,
|
||||||
|
List<String> paymentVoucherFileIds,
|
||||||
String status,
|
String status,
|
||||||
LocalDateTime createdAt) {}
|
LocalDateTime createdAt) {}
|
||||||
|
|
||||||
|
|||||||
+24
-8
@@ -50,7 +50,8 @@ public class FinanceCustomerAccountService {
|
|||||||
Metrics value = metrics.getOrDefault(customer.id(), Metrics.ZERO);
|
Metrics value = metrics.getOrDefault(customer.id(), Metrics.ZERO);
|
||||||
return new AccountSummary(customer.id(), customer.tenantCode(), customer.tenantName(),
|
return new AccountSummary(customer.id(), customer.tenantCode(), customer.tenantName(),
|
||||||
customer.contactName(), customer.contactPhone(), value.outstandingReceivable(),
|
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());
|
customer.status(), value.lastChangedAt());
|
||||||
}).toList();
|
}).toList();
|
||||||
return PageEnvelope.success(rows, (int) customers.page(), (int) customers.size(), customers.total());
|
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"));
|
.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())
|
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"));
|
.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"));
|
.forEach(row -> metric(result, row, "outstandingPayable", "amount"));
|
||||||
Object[] changeArgs = new Object[customerIds.size() * 3];
|
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))
|
||||||
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)
|
|
||||||
.forEach(row -> metric(result, row, "lastChangedAt", "last_changed_at"));
|
.forEach(row -> metric(result, row, "lastChangedAt", "last_changed_at"));
|
||||||
Map<Long, Metrics> frozen = new HashMap<>();
|
Map<Long, Metrics> frozen = new HashMap<>();
|
||||||
result.forEach((id, value) -> frozen.put(id, value.freeze()));
|
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 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 static final class MutableMetrics {
|
||||||
private BigDecimal outstandingReceivable = BigDecimal.ZERO;
|
private BigDecimal outstandingReceivable = BigDecimal.ZERO;
|
||||||
private BigDecimal pendingBilling = BigDecimal.ZERO;
|
private BigDecimal pendingBilling = BigDecimal.ZERO;
|
||||||
private BigDecimal receivedInPeriod = BigDecimal.ZERO;
|
private BigDecimal receivedInPeriod = BigDecimal.ZERO;
|
||||||
|
private BigDecimal totalCost = BigDecimal.ZERO;
|
||||||
|
private BigDecimal paidCost = BigDecimal.ZERO;
|
||||||
private BigDecimal outstandingPayable = BigDecimal.ZERO;
|
private BigDecimal outstandingPayable = BigDecimal.ZERO;
|
||||||
private LocalDateTime lastChangedAt;
|
private LocalDateTime lastChangedAt;
|
||||||
void set(String field, BigDecimal value) {
|
void set(String field, BigDecimal value) {
|
||||||
@@ -109,16 +121,20 @@ public class FinanceCustomerAccountService {
|
|||||||
case "outstandingReceivable" -> outstandingReceivable = value;
|
case "outstandingReceivable" -> outstandingReceivable = value;
|
||||||
case "pendingBilling" -> pendingBilling = value;
|
case "pendingBilling" -> pendingBilling = value;
|
||||||
case "receivedInPeriod" -> receivedInPeriod = value;
|
case "receivedInPeriod" -> receivedInPeriod = value;
|
||||||
|
case "totalCost" -> totalCost = value;
|
||||||
|
case "paidCost" -> paidCost = value;
|
||||||
case "outstandingPayable" -> outstandingPayable = value;
|
case "outstandingPayable" -> outstandingPayable = value;
|
||||||
default -> { }
|
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,
|
private record Metrics(BigDecimal outstandingReceivable, BigDecimal pendingBilling,
|
||||||
BigDecimal receivedInPeriod, BigDecimal outstandingPayable,
|
BigDecimal receivedInPeriod, BigDecimal totalCost, BigDecimal paidCost,
|
||||||
|
BigDecimal outstandingPayable,
|
||||||
LocalDateTime lastChangedAt) {
|
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("customerCode", "客户编码", ExportColumnType.STRING, 18), column("customerName", "客户", ExportColumnType.STRING, 28),
|
||||||
column("contactName", "联系人", ExportColumnType.STRING, 16), column("contactPhone", "联系电话", ExportColumnType.STRING, 18),
|
column("contactName", "联系人", ExportColumnType.STRING, 16), column("contactPhone", "联系电话", ExportColumnType.STRING, 18),
|
||||||
column("outstandingReceivable", "待收(CNY)", ExportColumnType.DECIMAL, 16), column("pendingBilling", "待开账(CNY)", ExportColumnType.DECIMAL, 16),
|
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));
|
column("status", "客户状态", ExportColumnType.STRING, 14), column("lastChangedAt", "最近变动", ExportColumnType.DATETIME, 21));
|
||||||
private static final List<ExportColumn> LEDGER_COLUMNS = List.of(
|
private static final List<ExportColumn> LEDGER_COLUMNS = List.of(
|
||||||
column("ledgerId", "流水号", ExportColumnType.STRING, 22), column("occurredAt", "发生时间", ExportColumnType.DATETIME, 21),
|
column("ledgerId", "流水号", ExportColumnType.STRING, 22), column("occurredAt", "发生时间", ExportColumnType.DATETIME, 21),
|
||||||
@@ -366,7 +367,8 @@ public class FinanceExportProviderService {
|
|||||||
private Map<String, Object> accountRow(AccountSummary value) {
|
private Map<String, Object> accountRow(AccountSummary value) {
|
||||||
return row("customerCode", value.customerCode(), "customerName", value.customerName(), "contactName", value.contactName(),
|
return row("customerCode", value.customerCode(), "customerName", value.customerName(), "contactName", value.contactName(),
|
||||||
"contactPhone", value.contactPhone(), "outstandingReceivable", value.outstandingReceivable(), "pendingBilling", value.pendingBilling(),
|
"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) {
|
private Map<String, Object> ledgerRow(LedgerEntry value) {
|
||||||
return row("ledgerId", String.valueOf(value.ledgerId()), "occurredAt", value.occurredAt(), "businessType", value.businessType(),
|
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();
|
long id = ((Number) row.get("id")).longValue();
|
||||||
if ("FIRSTLEG_FEE_VOIDED".equals(event.eventType())) {
|
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);
|
jdbc.update("UPDATE fin_fee_source SET status='VOIDED',operation_version=operation_version+1 WHERE id=? AND status<>'VOIDED'", id);
|
||||||
|
voidDirectPaidCost(snapshot);
|
||||||
}
|
}
|
||||||
return id;
|
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'",
|
jdbc.update("UPDATE fin_fee_source SET status='SUPERSEDED' WHERE source_system='FIRSTLEG' AND source_fee_id=? AND status='AVAILABLE'",
|
||||||
snapshot.feeId());
|
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.feeId(), snapshot.feeVersion(), snapshot.customerId(), snapshot.sourceType(),
|
||||||
snapshot.sourceType().equals("INBOUND_FEE") ? snapshot.inboundCode() : snapshot.containerNo(),
|
snapshot.sourceType().equals("INBOUND_FEE") ? snapshot.inboundCode() : snapshot.containerNo(),
|
||||||
snapshot.direction(), snapshot.orderId(), snapshot.inboundId(), snapshot.containerId(),
|
snapshot.direction(), snapshot.orderId(), snapshot.inboundId(), snapshot.containerId(),
|
||||||
snapshot.feeItemCode(), snapshot.feeItemName(), snapshot.totalAmount(), snapshot.currency(),
|
snapshot.feeItemCode(), snapshot.feeItemName(), snapshot.totalAmount(), snapshot.currency(),
|
||||||
|
snapshot.settlementMode(), snapshot.paidAt(), snapshot.paymentMethod(),
|
||||||
|
snapshot.paymentReferenceNo(), voucherJson(snapshot.paymentVoucherFileIds()),
|
||||||
snapshot.sourceHash(), json(snapshot));
|
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());
|
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) {
|
private FirstlegFeeSourceClient.Snapshot loadSnapshot(FinanceFeeSourceEvent event) {
|
||||||
@@ -138,14 +162,48 @@ public class FinanceFeeSourceEventService {
|
|||||||
if (!value.supplierName().equals(supplier.name())) {
|
if (!value.supplierName().equals(supplier.name())) {
|
||||||
throw error("FIN-EVT-012", "应付费用来源供应商名称快照不一致");
|
throw error("FIN-EVT-012", "应付费用来源供应商名称快照不一致");
|
||||||
}
|
}
|
||||||
|
validatePayableSettlement(value);
|
||||||
} else if (value.supplierId() != null || !blank(value.supplierName())
|
} else if (value.supplierId() != null || !blank(value.supplierName())
|
||||||
|| !blank(value.supplierCategory())) {
|
|| !blank(value.supplierCategory())) {
|
||||||
throw error("FIN-EVT-012", "应收费用不得携带供应商快照");
|
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 ("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", "柜子归属不一致");
|
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) {
|
private String json(Object value) {
|
||||||
try { return objectMapper.writeValueAsString(value); }
|
try { return objectMapper.writeValueAsString(value); }
|
||||||
catch (JsonProcessingException e) { throw error("FIN-EVT-001", "事件序列化失败"); }
|
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(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(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(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," +
|
"((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," +
|
||||||
"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(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);
|
Object[] args = java.util.Arrays.copyOf(filterArgs, filterArgs.length + 2);
|
||||||
args[args.length - 2] = pageSize; args[args.length - 1] = (page - 1) * pageSize;
|
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,
|
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);
|
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 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 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 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 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 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; }
|
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 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 " +
|
"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," +
|
"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')," +
|
"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=?";
|
"FROM fin_settlement s WHERE s.tenant_id=?";
|
||||||
try {
|
try {
|
||||||
return jdbc.queryForObject(sql, (rs, rowNum) -> new CustomerAccountSummary(customerId, CNY,
|
return jdbc.queryForObject(sql, (rs, rowNum) -> new CustomerAccountSummary(customerId, CNY,
|
||||||
money(rs, "total_receivable"), money(rs, "received_amount"), money(rs, "outstanding_receivable"),
|
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")),
|
money(rs, "pending_billing"), money(rs, "total_cost"), money(rs, "paid_cost"),
|
||||||
customerId, customerId, customerId);
|
money(rs, "outstanding_payable"), Instant.now(), time(rs, "last_changed_at")),
|
||||||
|
customerId, customerId, customerId, customerId, customerId, customerId, customerId);
|
||||||
} catch (BadSqlGrammarException ignored) {
|
} catch (BadSqlGrammarException ignored) {
|
||||||
// Empty/partially migrated Finance databases may not have a settlement projection yet.
|
// 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.
|
// The read-only account summary must still be usable as a zero CNY baseline.
|
||||||
return new CustomerAccountSummary(customerId, CNY, BigDecimal.ZERO, BigDecimal.ZERO,
|
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);
|
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");
|
if (!List.of("ALL", "RECEIVABLE", "PAYABLE").contains(normalized)) throw bad("费用方向只能是RECEIVABLE或PAYABLE");
|
||||||
List<FeePoolItem> result = new ArrayList<>();
|
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<>();
|
List<Object> sourceArgs = new ArrayList<>();
|
||||||
if (customerId != null) { sourceSql += " AND customer_id=?"; sourceArgs.add(customerId); }
|
if (customerId != null) { sourceSql += " AND customer_id=?"; sourceArgs.add(customerId); }
|
||||||
if (!"ALL".equals(normalized)) { sourceSql += " AND direction=?"; sourceArgs.add(normalized); }
|
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"),
|
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"),
|
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("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"));
|
rs.getString("status"), time(rs, "created_at"));
|
||||||
}, sourceArgs.toArray()));
|
}, 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())));
|
result.sort(Comparator.comparing(FeePoolItem::createdAt, Comparator.nullsLast(Comparator.reverseOrder())));
|
||||||
return List.copyOf(result);
|
return List.copyOf(result);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ public class FinanceSettlementService {
|
|||||||
List<Object> args = new ArrayList<>();
|
List<Object> args = new ArrayList<>();
|
||||||
args.addAll(ids);
|
args.addAll(ids);
|
||||||
List<Map<String, Object>> sources = jdbc.queryForList(
|
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," +
|
"review_status," +
|
||||||
"CAST(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierId')) AS UNSIGNED) supplier_id," +
|
"CAST(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierId')) AS UNSIGNED) supplier_id," +
|
||||||
"JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierName')) supplier_name," +
|
"JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierName')) supplier_name," +
|
||||||
@@ -152,6 +152,9 @@ public class FinanceSettlementService {
|
|||||||
for (Map<String, Object> source : sources) {
|
for (Map<String, Object> source : sources) {
|
||||||
Long supplierId = nullableNumber(source, "supplier_id");
|
Long supplierId = nullableNumber(source, "supplier_id");
|
||||||
if (supplierId == null || supplierId != request.supplierId()) throw error("FIN_409", "应付来源必须属于同一供应商");
|
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"))
|
if (!"PAYABLE".equals(text(source, "direction")) || !"AVAILABLE".equals(text(source, "status"))
|
||||||
|| !"APPROVED".equals(text(source, "review_status")) || decimal(source, "amount").signum() <= 0) {
|
|| !"APPROVED".equals(text(source, "review_status")) || decimal(source, "amount").signum() <= 0) {
|
||||||
throw error("FIN_409", "费用来源未审核、金额无效或状态不可用");
|
throw error("FIN_409", "费用来源未审核、金额无效或状态不可用");
|
||||||
@@ -462,7 +465,9 @@ public class FinanceSettlementService {
|
|||||||
BigDecimal unitPrice = quantity == null || quantity.signum() == 0 ? null
|
BigDecimal unitPrice = quantity == null || quantity.signum() == 0 ? null
|
||||||
: amount.divide(quantity, 6, java.math.RoundingMode.HALF_UP);
|
: amount.divide(quantity, 6, java.math.RoundingMode.HALF_UP);
|
||||||
Long supplierId = jsonLong(snapshot, "supplierId");
|
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"),
|
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(),
|
number(row, "source_fee_id").longValue(), number(row, "source_fee_version").intValue(),
|
||||||
text(row, "direction"), number(row, "customer_id").longValue(), jsonText(snapshot, "customerName"),
|
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) {
|
private static Long jsonLong(JsonNode node, String key) {
|
||||||
JsonNode value = node.path(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) {
|
private static LocalDate jsonDate(JsonNode node, String key) {
|
||||||
String value = jsonText(node, 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");
|
"VARCHAR(64) NULL COMMENT '来源费用项目编码快照' AFTER container_id");
|
||||||
CALL finance_fee_source_add_column('fin_fee_source', 'source_hash',
|
CALL finance_fee_source_add_column('fin_fee_source', 'source_hash',
|
||||||
"CHAR(64) NULL COMMENT 'Firstleg费用来源完整快照SHA-256哈希' AFTER currency_code");
|
"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',
|
CALL finance_fee_source_add_column('fin_fee_source', 'source_snapshot',
|
||||||
"JSON NULL COMMENT 'Firstleg费用来源完整不可变JSON快照' AFTER source_hash");
|
"JSON NULL COMMENT 'Firstleg费用来源完整不可变JSON快照' AFTER source_hash");
|
||||||
CALL finance_fee_source_add_column('fin_fee_source', 'review_status',
|
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");
|
"BIGINT UNSIGNED NULL COMMENT '成功处理生成或定位的费用来源快照ID' AFTER payload_hash");
|
||||||
CALL finance_fee_source_add_column('fin_inbox_event', 'error_code',
|
CALL finance_fee_source_add_column('fin_inbox_event', 'error_code',
|
||||||
"VARCHAR(64) NULL COMMENT '处理失败时的稳定FIN错误码' AFTER result_resource_id");
|
"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_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
|
UPDATE fin_fee_source
|
||||||
SET source_type = COALESCE(source_type, 'INBOUND_FEE'),
|
SET source_type = COALESCE(source_type, 'INBOUND_FEE'),
|
||||||
@@ -90,8 +135,9 @@ BEGIN
|
|||||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||||
WHERE table_schema = DATABASE() AND table_name = 'fin_fee_source'
|
WHERE table_schema = DATABASE() AND table_name = 'fin_fee_source'
|
||||||
AND column_name IN ('source_type','source_no','direction','container_id','fee_item_code',
|
AND column_name IN ('source_type','source_no','direction','container_id','fee_item_code',
|
||||||
'source_hash','source_snapshot','review_status','review_reason',
|
'settlement_mode','paid_at','payment_method','payment_reference_no',
|
||||||
'operation_version','updated_at')) <> 11 THEN
|
'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费用来源迁移失败:来源快照字段不完整';
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance费用来源迁移失败:来源快照字段不完整';
|
||||||
END IF;
|
END IF;
|
||||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||||
@@ -105,6 +151,18 @@ BEGIN
|
|||||||
'customer_confirmed_by_name','customer_confirmed_at')) <> 4 THEN
|
'customer_confirmed_by_name','customer_confirmed_at')) <> 4 THEN
|
||||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance客户对账单确认字段不完整';
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance客户对账单确认字段不完整';
|
||||||
END IF;
|
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$$
|
END$$
|
||||||
DELIMITER ;
|
DELIMITER ;
|
||||||
CALL finance_fee_source_assert_schema();
|
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 '来源费用项目名称快照',
|
fee_item_name VARCHAR(200) NOT NULL COMMENT '来源费用项目名称快照',
|
||||||
amount DECIMAL(20,6) NOT NULL COMMENT '来源费用金额,精度6位小数',
|
amount DECIMAL(20,6) NOT NULL COMMENT '来源费用金额,精度6位小数',
|
||||||
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
|
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_hash CHAR(64) NOT NULL COMMENT 'Firstleg费用来源完整快照SHA-256哈希',
|
||||||
source_snapshot JSON NOT NULL COMMENT 'Firstleg费用来源完整不可变JSON快照',
|
source_snapshot JSON NOT NULL COMMENT 'Firstleg费用来源完整不可变JSON快照',
|
||||||
review_status VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT 'Finance费用状态:DRAFT草稿、PENDING待审核、APPROVED已通过、REJECTED已驳回',
|
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),
|
UNIQUE KEY uk_fin_fee_source (source_system, source_fee_id, source_fee_version),
|
||||||
KEY idx_fin_fee_source_customer (customer_id, status),
|
KEY idx_fin_fee_source_customer (customer_id, status),
|
||||||
CONSTRAINT chk_fin_fee_source_currency CHECK (currency_code = 'CNY'),
|
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费用来源快照池;账单人工选择并固化明细';
|
) ENGINE=InnoDB COMMENT='Firstleg费用来源快照池;账单人工选择并固化明细';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS fin_inbox_event (
|
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',
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
|
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
|
||||||
cost_no VARCHAR(64) NOT NULL COMMENT '客户范围内唯一成本编号',
|
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,单票成本必填',
|
order_id BIGINT UNSIGNED NULL COMMENT '成本归属的头程订单ID,单票成本必填',
|
||||||
inbound_id BIGINT UNSIGNED NULL COMMENT '成本关联的入库单ID,可为空',
|
inbound_id BIGINT UNSIGNED NULL COMMENT '成本关联的入库单ID,可为空',
|
||||||
container_id BIGINT UNSIGNED NULL COMMENT 'Firstleg装柜计划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',
|
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
|
||||||
cost_amount DECIMAL(20,6) NOT NULL COMMENT '成本总金额,精度6位小数',
|
cost_amount DECIMAL(20,6) NOT NULL COMMENT '成本总金额,精度6位小数',
|
||||||
voucher_file_ids JSON NOT NULL COMMENT 'File服务财务凭证文件ID字符串数组JSON',
|
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已作废',
|
status VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT '成本状态:DRAFT草稿、CONFIRMED已确认、VOIDED已作废',
|
||||||
remark VARCHAR(500) NULL COMMENT '平台成本内部备注',
|
remark VARCHAR(500) NULL COMMENT '平台成本内部备注',
|
||||||
confirmed_at DATETIME(3) 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 '最后更新时间,数据修改时自动刷新',
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间,数据修改时自动刷新',
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_fin_cost_no (tenant_id, cost_no),
|
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_ticket (tenant_id, order_id, inbound_id),
|
||||||
KEY idx_fin_cost_container (tenant_id, container_id),
|
KEY idx_fin_cost_container (tenant_id, container_id),
|
||||||
CONSTRAINT chk_fin_cost_tenant CHECK (tenant_id > 0),
|
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 (
|
CONSTRAINT chk_fin_cost_target CHECK (
|
||||||
(cost_type = 'SINGLE_ORDER' AND order_id IS NOT NULL AND container_id IS NULL)
|
(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 = '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 (
|
CREATE TABLE IF NOT EXISTS fin_cost_allocation (
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ class CustomerAccountSummaryBehaviorTest {
|
|||||||
@Test
|
@Test
|
||||||
void aggregatesFormalSettlementsInOneCustomerScopedQuery() throws Exception {
|
void aggregatesFormalSettlementsInOneCustomerScopedQuery() throws Exception {
|
||||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||||
ResultSet rs = summaryRow("300.00", "140.00", "160.00", "25.00", "80.00");
|
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())).thenAnswer(invocation ->
|
when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any())).thenAnswer(invocation ->
|
||||||
((RowMapper<?>) invocation.getArgument(1)).mapRow(rs, 0));
|
((RowMapper<?>) invocation.getArgument(1)).mapRow(rs, 0));
|
||||||
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper());
|
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper());
|
||||||
|
|
||||||
@@ -43,15 +43,21 @@ class CustomerAccountSummaryBehaviorTest {
|
|||||||
assertThat(result.receivedAmount()).isEqualByComparingTo("140.00");
|
assertThat(result.receivedAmount()).isEqualByComparingTo("140.00");
|
||||||
assertThat(result.outstandingReceivable()).isEqualByComparingTo("160.00");
|
assertThat(result.outstandingReceivable()).isEqualByComparingTo("160.00");
|
||||||
assertThat(result.pendingBilling()).isEqualByComparingTo("25.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");
|
assertThat(result.outstandingPayable()).isEqualByComparingTo("80.00");
|
||||||
|
|
||||||
var sql = org.mockito.ArgumentCaptor.forClass(String.class);
|
var sql = org.mockito.ArgumentCaptor.forClass(String.class);
|
||||||
verify(jdbc).queryForObject(sql.capture(), any(RowMapper.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())
|
assertThat(sql.getValue())
|
||||||
.contains("FROM fin_settlement s WHERE s.tenant_id=?")
|
.contains("FROM fin_settlement s WHERE s.tenant_id=?")
|
||||||
.contains("s.direction='RECEIVABLE' AND s.status<>'VOIDED'")
|
.contains("s.direction='RECEIVABLE' AND s.status<>'VOIDED'")
|
||||||
.contains("s.direction='PAYABLE' 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("f.customer_id=?", "f.status='AVAILABLE'", "f.review_status='APPROVED'", "f.amount>0")
|
||||||
.contains("b.status<>'VOIDED'")
|
.contains("b.status<>'VOIDED'")
|
||||||
.doesNotContain("JSON_EXTRACT")
|
.doesNotContain("JSON_EXTRACT")
|
||||||
@@ -61,8 +67,8 @@ class CustomerAccountSummaryBehaviorTest {
|
|||||||
@Test
|
@Test
|
||||||
void emptyCustomerReturnsZeroAmounts() throws Exception {
|
void emptyCustomerReturnsZeroAmounts() throws Exception {
|
||||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||||
ResultSet rs = summaryRow("0", "0", "0", "0", "0");
|
ResultSet rs = summaryRow("0", "0", "0", "0", "0", "0", "0");
|
||||||
when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any())).thenAnswer(invocation ->
|
when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any())).thenAnswer(invocation ->
|
||||||
((RowMapper<?>) invocation.getArgument(1)).mapRow(rs, 0));
|
((RowMapper<?>) invocation.getArgument(1)).mapRow(rs, 0));
|
||||||
|
|
||||||
CustomerAccountSummary result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(77L);
|
CustomerAccountSummary result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(77L);
|
||||||
@@ -71,13 +77,15 @@ class CustomerAccountSummaryBehaviorTest {
|
|||||||
assertThat(result.receivedAmount()).isZero();
|
assertThat(result.receivedAmount()).isZero();
|
||||||
assertThat(result.outstandingReceivable()).isZero();
|
assertThat(result.outstandingReceivable()).isZero();
|
||||||
assertThat(result.pendingBilling()).isZero();
|
assertThat(result.pendingBilling()).isZero();
|
||||||
|
assertThat(result.totalCost()).isZero();
|
||||||
|
assertThat(result.paidCost()).isZero();
|
||||||
assertThat(result.outstandingPayable()).isZero();
|
assertThat(result.outstandingPayable()).isZero();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void missingSettlementProjectionReturnsZeroCnyBaseline() {
|
void missingSettlementProjectionReturnsZeroCnyBaseline() {
|
||||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
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));
|
.thenThrow(new BadSqlGrammarException("customer summary", "SELECT ...", null));
|
||||||
|
|
||||||
CustomerAccountSummary result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(88L);
|
CustomerAccountSummary result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(88L);
|
||||||
@@ -88,6 +96,8 @@ class CustomerAccountSummaryBehaviorTest {
|
|||||||
assertThat(result.receivedAmount()).isZero();
|
assertThat(result.receivedAmount()).isZero();
|
||||||
assertThat(result.outstandingReceivable()).isZero();
|
assertThat(result.outstandingReceivable()).isZero();
|
||||||
assertThat(result.pendingBilling()).isZero();
|
assertThat(result.pendingBilling()).isZero();
|
||||||
|
assertThat(result.totalCost()).isZero();
|
||||||
|
assertThat(result.paidCost()).isZero();
|
||||||
assertThat(result.outstandingPayable()).isZero();
|
assertThat(result.outstandingPayable()).isZero();
|
||||||
assertThat(result.lastChangedAt()).isNull();
|
assertThat(result.lastChangedAt()).isNull();
|
||||||
}
|
}
|
||||||
@@ -107,12 +117,15 @@ class CustomerAccountSummaryBehaviorTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static ResultSet summaryRow(String total, String paid, String outstanding,
|
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);
|
ResultSet rs = mock(ResultSet.class);
|
||||||
when(rs.getBigDecimal("total_receivable")).thenReturn(new BigDecimal(total));
|
when(rs.getBigDecimal("total_receivable")).thenReturn(new BigDecimal(total));
|
||||||
when(rs.getBigDecimal("received_amount")).thenReturn(new BigDecimal(paid));
|
when(rs.getBigDecimal("received_amount")).thenReturn(new BigDecimal(paid));
|
||||||
when(rs.getBigDecimal("outstanding_receivable")).thenReturn(new BigDecimal(outstanding));
|
when(rs.getBigDecimal("outstanding_receivable")).thenReturn(new BigDecimal(outstanding));
|
||||||
when(rs.getBigDecimal("pending_billing")).thenReturn(new BigDecimal(pending));
|
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.getBigDecimal("outstanding_payable")).thenReturn(new BigDecimal(payable));
|
||||||
when(rs.getTimestamp("last_changed_at")).thenReturn(Timestamp.valueOf(LocalDateTime.of(2026, 8, 13, 12, 0)));
|
when(rs.getTimestamp("last_changed_at")).thenReturn(Timestamp.valueOf(LocalDateTime.of(2026, 8, 13, 12, 0)));
|
||||||
return rs;
|
return rs;
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class FinanceFeePoolDirectionBehaviorTest {
|
|||||||
|
|
||||||
assertThat(result).isNotEmpty().allMatch(item -> "PAYABLE".equals(item.direction()));
|
assertThat(result).isNotEmpty().allMatch(item -> "PAYABLE".equals(item.direction()));
|
||||||
assertThat(jdbc.sourceDirections).containsExactly("PAYABLE");
|
assertThat(jdbc.sourceDirections).containsExactly("PAYABLE");
|
||||||
assertThat(jdbc.costQueryCount).isEqualTo(1);
|
assertThat(jdbc.costQueryCount).isZero();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final class DirectionJdbc extends JdbcTemplate {
|
private static final class DirectionJdbc extends JdbcTemplate {
|
||||||
@@ -60,7 +60,8 @@ class FinanceFeePoolDirectionBehaviorTest {
|
|||||||
|
|
||||||
private static FeePoolItem item(String direction, String sourceType, long sourceId) {
|
private static FeePoolItem item(String direction, String sourceType, long sourceId) {
|
||||||
return new FeePoolItem(direction + ":" + sourceId, 7L, direction, sourceType, sourceId, 1,
|
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));
|
LocalDateTime.of(2026, 8, 11, 1, 0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,8 @@ class FinanceFeeSourceEventServiceTest {
|
|||||||
givenNewEvent();
|
givenNewEvent();
|
||||||
var snapshot = new FirstlegFeeSourceClient.Snapshot(7, "CONTAINER_FEE", 31, 1,
|
var snapshot = new FirstlegFeeSourceClient.Snapshot(7, "CONTAINER_FEE", 31, 1,
|
||||||
null, null, null, 41L, 61L, "C-41", 7, "客户", 71L, "现有供应商",
|
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",
|
"CNY", new BigDecimal("300.00"), "APPROVED", "PUBLISHED",
|
||||||
LocalDateTime.of(2026, 8, 11, 1, 0), null, null, null, null,
|
LocalDateTime.of(2026, 8, 11, 1, 0), null, null, null, null,
|
||||||
"source-hash-payable", 1);
|
"source-hash-payable", 1);
|
||||||
@@ -79,9 +80,10 @@ class FinanceFeeSourceEventServiceTest {
|
|||||||
assertThat(service.consume(event)).isEqualTo(101L);
|
assertThat(service.consume(event)).isEqualTo(101L);
|
||||||
|
|
||||||
verify(supplierValidator).requireActive(71L, "TRUCKING");
|
verify(supplierValidator).requireActive(71L, "TRUCKING");
|
||||||
|
verify(jdbc, never()).update(startsWith("INSERT INTO fin_cost"), any(Object[].class));
|
||||||
ArgumentCaptor<Object[]> values = ArgumentCaptor.forClass(Object[].class);
|
ArgumentCaptor<Object[]> values = ArgumentCaptor.forClass(Object[].class);
|
||||||
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), values.capture());
|
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\"");
|
.contains("\"supplierId\":71", "\"supplierName\":\"现有供应商\"", "\"supplierCategory\":\"TRUCKING\"");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +93,8 @@ class FinanceFeeSourceEventServiceTest {
|
|||||||
givenNewEvent();
|
givenNewEvent();
|
||||||
var snapshot = new FirstlegFeeSourceClient.Snapshot(7, "CONTAINER_FEE", 31, 1,
|
var snapshot = new FirstlegFeeSourceClient.Snapshot(7, "CONTAINER_FEE", 31, 1,
|
||||||
null, null, null, 41L, 61L, "C-41", 7, "客户", 71L, "现有供应商",
|
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",
|
"CNY", new BigDecimal("300.00"), "APPROVED", "PUBLISHED",
|
||||||
LocalDateTime.of(2026, 8, 11, 1, 0), null, null, null, null,
|
LocalDateTime.of(2026, 8, 11, 1, 0), null, null, null, null,
|
||||||
"source-hash-unrestricted", 1);
|
"source-hash-unrestricted", 1);
|
||||||
@@ -101,6 +104,38 @@ class FinanceFeeSourceEventServiceTest {
|
|||||||
verify(supplierValidator).requireActive(71L, null);
|
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
|
@Test
|
||||||
void adjustmentCreatesNewImmutableVersionAndSupersedesAvailableVersion() {
|
void adjustmentCreatesNewImmutableVersionAndSupersedesAvailableVersion() {
|
||||||
FinanceFeeSourceEvent event = inboundEvent("adjusted-2", "FIRSTLEG_FEE_ADJUSTED", 2, 7);
|
FinanceFeeSourceEvent event = inboundEvent("adjusted-2", "FIRSTLEG_FEE_ADJUSTED", 2, 7);
|
||||||
@@ -216,7 +251,8 @@ class FinanceFeeSourceEventServiceTest {
|
|||||||
String publicationStatus, BigDecimal amount) {
|
String publicationStatus, BigDecimal amount) {
|
||||||
return new FirstlegFeeSourceClient.Snapshot(customerId, sourceType, 31, version, inboundId,
|
return new FirstlegFeeSourceClient.Snapshot(customerId, sourceType, 31, version, inboundId,
|
||||||
inboundId == null ? null : "IB-21", 51L, containerId, null,
|
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,
|
"清关费", "RECEIVABLE", LocalDate.of(2026, 8, 11), "CNY", amount,
|
||||||
"APPROVED", publicationStatus, LocalDateTime.of(2026, 8, 11, 1, 0),
|
"APPROVED", publicationStatus, LocalDateTime.of(2026, 8, 11, 1, 0),
|
||||||
BigDecimal.ONE, "SHIPMENT", BigDecimal.TEN, BigDecimal.ONE, "source-hash", version);
|
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);
|
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",
|
assertThat(sql).contains("information_schema.columns", "source_type", "direction", "container_id",
|
||||||
"source_hash", "source_snapshot", "payload_hash", "result_resource_id", "error_code");
|
"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("AVAILABLE可结算", "SUPERSEDED已被新版本替代", "VOIDED已作废");
|
||||||
assertThat(sql).contains("finance_fee_source_assert_schema");
|
assertThat(sql).contains("finance_fee_source_assert_schema");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ class FinancePhase1OperationBehaviorTest {
|
|||||||
when(rs.getString("currency_code")).thenReturn("CNY");
|
when(rs.getString("currency_code")).thenReturn("CNY");
|
||||||
when(rs.getString("review_status")).thenReturn("APPROVED");
|
when(rs.getString("review_status")).thenReturn("APPROVED");
|
||||||
when(rs.getString("effective_status")).thenReturn("AVAILABLE");
|
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.getBigDecimal("amount")).thenReturn(new BigDecimal("120.00"));
|
||||||
when(rs.getBoolean("statement_generated")).thenReturn(false);
|
when(rs.getBoolean("statement_generated")).thenReturn(false);
|
||||||
return List.of(mapper.mapRow(rs, 0));
|
return List.of(mapper.mapRow(rs, 0));
|
||||||
@@ -164,6 +164,7 @@ class FinancePhase1OperationBehaviorTest {
|
|||||||
assertThat(source.sourceBusinessId()).isEqualTo("IN-9");
|
assertThat(source.sourceBusinessId()).isEqualTo("IN-9");
|
||||||
assertThat(source.inboundCode()).isEqualTo("IN-9");
|
assertThat(source.inboundCode()).isEqualTo("IN-9");
|
||||||
assertThat(source.containerNo()).isEqualTo("CONT-9");
|
assertThat(source.containerNo()).isEqualTo("CONT-9");
|
||||||
|
assertThat(source.supplierId()).isEqualTo(79L);
|
||||||
assertThat(source.supplierName()).isEqualTo("供应商A");
|
assertThat(source.supplierName()).isEqualTo("供应商A");
|
||||||
assertThat(source.reviewStatus()).isEqualTo("APPROVED");
|
assertThat(source.reviewStatus()).isEqualTo("APPROVED");
|
||||||
assertThat(source.businessDate()).isEqualTo(LocalDate.of(2026, 8, 11));
|
assertThat(source.businessDate()).isEqualTo(LocalDate.of(2026, 8, 11));
|
||||||
@@ -364,6 +365,7 @@ class FinancePhase1OperationBehaviorTest {
|
|||||||
return (List<T>) List.of(new FinancePhase1Dtos.AccountSummary(
|
return (List<T>) List.of(new FinancePhase1Dtos.AccountSummary(
|
||||||
9L, "9", "客户9", null, null, new BigDecimal("100.00"),
|
9L, "9", "客户9", null, null, new BigDecimal("100.00"),
|
||||||
new BigDecimal("20.00"), new BigDecimal("30.00"), BigDecimal.ZERO,
|
new BigDecimal("20.00"), new BigDecimal("30.00"), BigDecimal.ZERO,
|
||||||
|
BigDecimal.ZERO, BigDecimal.ZERO,
|
||||||
"ACTIVE", LocalDateTime.of(2026, 8, 11, 10, 0)));
|
"ACTIVE", LocalDateTime.of(2026, 8, 11, 10, 0)));
|
||||||
}
|
}
|
||||||
if (sql.startsWith("SELECT id,bill_no") || sql.startsWith("SELECT id,receipt_no")
|
if (sql.startsWith("SELECT id,bill_no") || sql.startsWith("SELECT id,receipt_no")
|
||||||
|
|||||||
@@ -68,7 +68,8 @@ class FinancePlatformListContractTest {
|
|||||||
void allCustomerFeePoolResponseCarriesCustomerIdentity() {
|
void allCustomerFeePoolResponseCarriesCustomerIdentity() {
|
||||||
assertThat(Arrays.stream(com.baiyue.finance.dto.FinanceReadModels.FeePoolItem.class
|
assertThat(Arrays.stream(com.baiyue.finance.dto.FinanceReadModels.FeePoolItem.class
|
||||||
.getRecordComponents()).map(java.lang.reflect.RecordComponent::getName))
|
.getRecordComponents()).map(java.lang.reflect.RecordComponent::getName))
|
||||||
.contains("customerId");
|
.contains("customerId", "settlementMode", "paidAt", "paymentMethod",
|
||||||
|
"paymentReferenceNo", "paymentVoucherFileIds");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+2
-2
@@ -24,7 +24,7 @@ class FinancePlatformOptionalCustomerQueryTest {
|
|||||||
service.receivableSettlements(null, false);
|
service.receivableSettlements(null, false);
|
||||||
service.payableSettlements(null);
|
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.sql()).doesNotContain("customer_id=?", "tenant_id=?");
|
||||||
assertThat(call.args()).isEmpty();
|
assertThat(call.args()).isEmpty();
|
||||||
});
|
});
|
||||||
@@ -41,7 +41,7 @@ class FinancePlatformOptionalCustomerQueryTest {
|
|||||||
service.receivableSettlements(31L, false);
|
service.receivableSettlements(31L, false);
|
||||||
service.payableSettlements(31L);
|
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.sql()).containsAnyOf("customer_id=?", "tenant_id=?");
|
||||||
assertThat(call.args()).containsExactly(31L);
|
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.CreateReceivableSettlementRequest;
|
||||||
import com.baiyue.finance.dto.FinanceSettlementDtos.CreateSettlementPaymentRequest;
|
import com.baiyue.finance.dto.FinanceSettlementDtos.CreateSettlementPaymentRequest;
|
||||||
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail;
|
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.SettlementSummary;
|
||||||
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest;
|
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest;
|
||||||
import com.baiyue.finance.service.FinanceEventException;
|
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));
|
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
|
@Test
|
||||||
void receivableCreationRejectsAlreadySettledStatement() {
|
void receivableCreationRejectsAlreadySettledStatement() {
|
||||||
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
|
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
|
||||||
@@ -335,6 +396,22 @@ class FinanceSettlementBehaviorTest {
|
|||||||
verify(jdbc, never()).update(any(PreparedStatementCreator.class), any(KeyHolder.class));
|
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
|
@Test
|
||||||
void payableCreationRejectsMixedSupplierIneligibleAndOccupiedSources() {
|
void payableCreationRejectsMixedSupplierIneligibleAndOccupiedSources() {
|
||||||
for (String condition : List.of("supplier", "ineligible", "occupied")) {
|
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("id", 31L), Map.entry("customer_id", customerId), Map.entry("direction", "PAYABLE"),
|
||||||
Map.entry("currency_code", "CNY"), Map.entry("status", "AVAILABLE"),
|
Map.entry("currency_code", "CNY"), Map.entry("status", "AVAILABLE"),
|
||||||
Map.entry("amount", new BigDecimal("100.00")), Map.entry("review_status", "APPROVED"),
|
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("supplier_id", supplierId),
|
||||||
Map.entry("fee_item_code", "FBA_HEAD_FREIGHT"), Map.entry("fee_item_name", "头程运费"),
|
Map.entry("fee_item_code", "FBA_HEAD_FREIGHT"), Map.entry("fee_item_name", "头程运费"),
|
||||||
Map.entry("supplier_name", "供应商20"), Map.entry("source_snapshot",
|
Map.entry("supplier_name", "供应商20"), Map.entry("source_snapshot",
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class FinanceSqlCommentContractTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
assertThat(tableCount).isEqualTo(17);
|
assertThat(tableCount).isEqualTo(17);
|
||||||
assertThat(columnCount).isEqualTo(243);
|
assertThat(columnCount).isEqualTo(248);
|
||||||
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
|
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +55,7 @@ class FinanceSqlCommentContractTest {
|
|||||||
assertThat(sql).contains("AVAILABLE可结算、SUPERSEDED已被新版本替代、VOIDED已作废");
|
assertThat(sql).contains("AVAILABLE可结算、SUPERSEDED已被新版本替代、VOIDED已作废");
|
||||||
assertThat(sql).contains("RECEIVED已接收、PROCESSED已完成、FAILED处理失败");
|
assertThat(sql).contains("RECEIVED已接收、PROCESSED已完成、FAILED处理失败");
|
||||||
assertThat(sql).contains("Firstleg费用来源完整不可变JSON快照");
|
assertThat(sql).contains("Firstleg费用来源完整不可变JSON快照");
|
||||||
|
assertThat(sql).contains("ON_ACCOUNT账期应付、DIRECT_PAID业务已付");
|
||||||
assertThat(sql).contains("规范化完整请求的SHA-256十六进制哈希");
|
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.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public final class ContainerFeeDetailDtos {
|
public final class ContainerFeeDetailDtos {
|
||||||
private ContainerFeeDetailDtos() {
|
private ContainerFeeDetailDtos() {
|
||||||
@@ -21,8 +23,18 @@ public final class ContainerFeeDetailDtos {
|
|||||||
@NotNull @DecimalMin("0.00") BigDecimal amount,
|
@NotNull @DecimalMin("0.00") BigDecimal amount,
|
||||||
@NotBlank @Pattern(regexp = "CNY") String currency,
|
@NotBlank @Pattern(regexp = "CNY") String currency,
|
||||||
Long supplierId,
|
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
|
@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(
|
public record AdjustRequest(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.dto;
|
|||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/** Finance 回查的 Firstleg 费用来源只读快照。 */
|
/** Finance 回查的 Firstleg 费用来源只读快照。 */
|
||||||
public record FinanceFeeSourceSnapshot(
|
public record FinanceFeeSourceSnapshot(
|
||||||
@@ -21,6 +22,11 @@ public record FinanceFeeSourceSnapshot(
|
|||||||
Long supplierId,
|
Long supplierId,
|
||||||
String supplierName,
|
String supplierName,
|
||||||
String supplierCategory,
|
String supplierCategory,
|
||||||
|
String settlementMode,
|
||||||
|
LocalDateTime paidAt,
|
||||||
|
String paymentMethod,
|
||||||
|
String paymentReferenceNo,
|
||||||
|
List<Long> paymentVoucherFileIds,
|
||||||
Long feeItemId,
|
Long feeItemId,
|
||||||
String feeItemCode,
|
String feeItemCode,
|
||||||
String feeItemName,
|
String feeItemName,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import jakarta.validation.constraints.Size;
|
|||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/** 入库单费用明细请求模型。 */
|
/** 入库单费用明细请求模型。 */
|
||||||
public final class InboundFeeDetailDtos {
|
public final class InboundFeeDetailDtos {
|
||||||
@@ -22,12 +24,18 @@ public final class InboundFeeDetailDtos {
|
|||||||
@NotBlank @Pattern(regexp = "[A-Z]{3}") String currency,
|
@NotBlank @Pattern(regexp = "[A-Z]{3}") String currency,
|
||||||
@NotNull @DecimalMin("0.00") BigDecimal amount,
|
@NotNull @DecimalMin("0.00") BigDecimal amount,
|
||||||
Long supplierId,
|
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
|
@Size(max = 500) String remark
|
||||||
) {
|
) {
|
||||||
public SaveRequest(Long feeItemId, String direction, LocalDate businessDate,
|
public SaveRequest(Long feeItemId, String direction, LocalDate businessDate,
|
||||||
BigDecimal unitPrice, String currency, BigDecimal amount,
|
BigDecimal unitPrice, String currency, BigDecimal amount,
|
||||||
String remark) {
|
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.IdType;
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
import com.baomidou.mybatisplus.annotation.TableName;
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
|
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.Setter;
|
import lombok.Setter;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/** 柜次级人工应收/应付费用来源。 */
|
/** 柜次级人工应收/应付费用来源。 */
|
||||||
@Getter
|
@Getter
|
||||||
@Setter
|
@Setter
|
||||||
@TableName("fl_container_fee_detail")
|
@TableName(value = "fl_container_fee_detail", autoResultMap = true)
|
||||||
public class ContainerFeeDetailEntity {
|
public class ContainerFeeDetailEntity {
|
||||||
@TableId(type = IdType.ASSIGN_ID)
|
@TableId(type = IdType.ASSIGN_ID)
|
||||||
private Long id;
|
private Long id;
|
||||||
@@ -28,6 +31,12 @@ public class ContainerFeeDetailEntity {
|
|||||||
private Long supplierId;
|
private Long supplierId;
|
||||||
private String supplierName;
|
private String supplierName;
|
||||||
private String supplierCategory;
|
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 LocalDate businessDate;
|
||||||
private BigDecimal amount;
|
private BigDecimal amount;
|
||||||
private String currency;
|
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.IdType;
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
import com.baomidou.mybatisplus.annotation.TableName;
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
|
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.Setter;
|
import lombok.Setter;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/** 柜次费用审核发布时固化的不可变版本快照。 */
|
/** 柜次费用审核发布时固化的不可变版本快照。 */
|
||||||
@Getter
|
@Getter
|
||||||
@Setter
|
@Setter
|
||||||
@TableName("fl_container_fee_source_snapshot")
|
@TableName(value = "fl_container_fee_source_snapshot", autoResultMap = true)
|
||||||
public class ContainerFeeSourceSnapshotEntity {
|
public class ContainerFeeSourceSnapshotEntity {
|
||||||
@TableId(type = IdType.ASSIGN_ID)
|
@TableId(type = IdType.ASSIGN_ID)
|
||||||
private Long id;
|
private Long id;
|
||||||
@@ -30,6 +33,12 @@ public class ContainerFeeSourceSnapshotEntity {
|
|||||||
private Long supplierId;
|
private Long supplierId;
|
||||||
private String supplierName;
|
private String supplierName;
|
||||||
private String supplierCategory;
|
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 LocalDate businessDate;
|
||||||
private BigDecimal amount;
|
private BigDecimal amount;
|
||||||
private String currency;
|
private String currency;
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.baiyue.firstleg.entity;
|
|||||||
import com.baomidou.mybatisplus.annotation.IdType;
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
import com.baomidou.mybatisplus.annotation.TableName;
|
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 com.baiyue.common.core.model.BaseEntity;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.Setter;
|
import lombok.Setter;
|
||||||
@@ -10,11 +12,12 @@ import lombok.Setter;
|
|||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/** 入库单人工费用明细及业务快照。 */
|
/** 入库单人工费用明细及业务快照。 */
|
||||||
@Getter
|
@Getter
|
||||||
@Setter
|
@Setter
|
||||||
@TableName("fl_inbound_fee_detail")
|
@TableName(value = "fl_inbound_fee_detail", autoResultMap = true)
|
||||||
public class InboundFeeDetailEntity extends BaseEntity {
|
public class InboundFeeDetailEntity extends BaseEntity {
|
||||||
@TableId(type = IdType.ASSIGN_ID)
|
@TableId(type = IdType.ASSIGN_ID)
|
||||||
private Long id;
|
private Long id;
|
||||||
@@ -30,6 +33,12 @@ public class InboundFeeDetailEntity extends BaseEntity {
|
|||||||
private Long supplierId;
|
private Long supplierId;
|
||||||
private String supplierName;
|
private String supplierName;
|
||||||
private String supplierCategory;
|
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 LocalDate businessDate;
|
||||||
private Long customerId;
|
private Long customerId;
|
||||||
private String customerName;
|
private String customerName;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import org.apache.ibatis.annotations.Update;
|
|||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntity> {
|
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);
|
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("""
|
@Select("""
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
+5
@@ -29,6 +29,11 @@ public class ContainerFeeSourceSnapshotRepository {
|
|||||||
value.setSupplierId(source.getSupplierId());
|
value.setSupplierId(source.getSupplierId());
|
||||||
value.setSupplierName(source.getSupplierName());
|
value.setSupplierName(source.getSupplierName());
|
||||||
value.setSupplierCategory(source.getSupplierCategory());
|
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.setBusinessDate(source.getBusinessDate());
|
||||||
value.setAmount(source.getAmount());
|
value.setAmount(source.getAmount());
|
||||||
value.setCurrency(source.getCurrency());
|
value.setCurrency(source.getCurrency());
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ public class ContainerFeeDetailService {
|
|||||||
private final FinanceFeeSourceOutboxService financeOutbox;
|
private final FinanceFeeSourceOutboxService financeOutbox;
|
||||||
private final WarehouseSupplierSnapshotService suppliers;
|
private final WarehouseSupplierSnapshotService suppliers;
|
||||||
private final ContainerFeeSourceSnapshotRepository sourceSnapshots;
|
private final ContainerFeeSourceSnapshotRepository sourceSnapshots;
|
||||||
|
private final FeePaymentSnapshotValidator paymentSnapshots;
|
||||||
|
|
||||||
public List<ContainerFeeDetailEntity> list(long tenantId, long loadingPlanId) {
|
public List<ContainerFeeDetailEntity> list(long tenantId, long loadingPlanId) {
|
||||||
requireOwnedPlan(tenantId, loadingPlanId);
|
requireOwnedPlan(tenantId, loadingPlanId);
|
||||||
@@ -60,6 +61,7 @@ public class ContainerFeeDetailService {
|
|||||||
long reviewerId, String reviewerName) {
|
long reviewerId, String reviewerName) {
|
||||||
ContainerFeeDetailEntity pending = required(tenantId, loadingPlanId, feeId);
|
ContainerFeeDetailEntity pending = required(tenantId, loadingPlanId, feeId);
|
||||||
FeeItemEntity item = requireItem(pending.getFeeItemId(), pending.getDirection());
|
FeeItemEntity item = requireItem(pending.getFeeItemId(), pending.getDirection());
|
||||||
|
revalidatePaymentSnapshot(pending);
|
||||||
refreshSupplierSnapshot(pending, item, reviewerId);
|
refreshSupplierSnapshot(pending, item, reviewerId);
|
||||||
if (repository.review(tenantId, loadingPlanId, feeId, "APPROVED",
|
if (repository.review(tenantId, loadingPlanId, feeId, "APPROVED",
|
||||||
reviewerId, reviewerName, null) != 1) {
|
reviewerId, reviewerName, null) != 1) {
|
||||||
@@ -152,6 +154,8 @@ public class ContainerFeeDetailService {
|
|||||||
value.setFeeItemNameSnapshot(item.getFeeTypeName());
|
value.setFeeItemNameSnapshot(item.getFeeTypeName());
|
||||||
value.setDirection(request.direction());
|
value.setDirection(request.direction());
|
||||||
applySupplierSnapshot(value, item, request.direction(), request.supplierId());
|
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.setBusinessDate(request.businessDate());
|
||||||
value.setAmount(request.amount().setScale(2, RoundingMode.HALF_UP));
|
value.setAmount(request.amount().setScale(2, RoundingMode.HALF_UP));
|
||||||
value.setCurrency(request.currency());
|
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) {
|
private static BizException conflict(String message) {
|
||||||
return new BizException(ResultCode.CONFLICT, 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()),
|
String.valueOf(inbound.getOrderId()), nullSafe(item.getFeeCode()),
|
||||||
nullSafe(detail.getDirection()), nullSafe(detail.getSupplierId()),
|
nullSafe(detail.getDirection()), nullSafe(detail.getSupplierId()),
|
||||||
nullSafe(detail.getSupplierName()), nullSafe(detail.getSupplierCategory()),
|
nullSafe(detail.getSupplierName()), nullSafe(detail.getSupplierCategory()),
|
||||||
|
nullSafe(detail.getSettlementMode()), nullSafe(detail.getPaidAt()),
|
||||||
|
nullSafe(detail.getPaymentMethod()), nullSafe(detail.getPaymentReferenceNo()),
|
||||||
|
nullSafe(detail.getPaymentVoucherFileIds()),
|
||||||
detail.getAmount().toPlainString(),
|
detail.getAmount().toPlainString(),
|
||||||
nullSafe(detail.getCurrency()));
|
nullSafe(detail.getCurrency()));
|
||||||
return new FinanceFeeSourceSnapshot(detail.getTenantId(), "INBOUND_FEE", sourceFeeId,
|
return new FinanceFeeSourceSnapshot(detail.getTenantId(), "INBOUND_FEE", sourceFeeId,
|
||||||
version, inboundId, inbound.getInboundCode(), inbound.getOrderId(), null, null, null,
|
version, inboundId, inbound.getInboundCode(), inbound.getOrderId(), null, null, null,
|
||||||
detail.getTenantId(), inbound.getCustomerName(), detail.getSupplierId(),
|
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.getFeeTypeNameSnapshot(), detail.getDirection(), detail.getBusinessDate(),
|
||||||
detail.getCurrency(), detail.getAmount(), detail.getReviewStatus(),
|
detail.getCurrency(), detail.getAmount(), detail.getReviewStatus(),
|
||||||
detail.getPublicationStatus(),
|
detail.getPublicationStatus(),
|
||||||
@@ -62,12 +67,17 @@ public class FinanceFeeSourceSnapshotService {
|
|||||||
nullSafe(detail.getFeeItemCode()), nullSafe(detail.getDirection()),
|
nullSafe(detail.getFeeItemCode()), nullSafe(detail.getDirection()),
|
||||||
nullSafe(detail.getSupplierId()), nullSafe(detail.getSupplierName()),
|
nullSafe(detail.getSupplierId()), nullSafe(detail.getSupplierName()),
|
||||||
nullSafe(detail.getSupplierCategory()),
|
nullSafe(detail.getSupplierCategory()),
|
||||||
|
nullSafe(detail.getSettlementMode()), nullSafe(detail.getPaidAt()),
|
||||||
|
nullSafe(detail.getPaymentMethod()), nullSafe(detail.getPaymentReferenceNo()),
|
||||||
|
nullSafe(detail.getPaymentVoucherFileIds()),
|
||||||
detail.getAmount().toPlainString(), nullSafe(detail.getCurrency()),
|
detail.getAmount().toPlainString(), nullSafe(detail.getCurrency()),
|
||||||
nullSafe(detail.getReviewStatus()));
|
nullSafe(detail.getReviewStatus()));
|
||||||
return new FinanceFeeSourceSnapshot(detail.getTenantId(), "CONTAINER_FEE", feeId,
|
return new FinanceFeeSourceSnapshot(detail.getTenantId(), "CONTAINER_FEE", feeId,
|
||||||
version, null, null, null, containerId, detail.getContainerBookingId(),
|
version, null, null, null, containerId, detail.getContainerBookingId(),
|
||||||
detail.getContainerNo(), detail.getTenantId(), null, detail.getSupplierId(),
|
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.getFeeItemCode(), detail.getFeeItemName(), detail.getDirection(),
|
||||||
detail.getBusinessDate(), detail.getCurrency(), detail.getAmount(), detail.getReviewStatus(),
|
detail.getBusinessDate(), detail.getCurrency(), detail.getAmount(), detail.getReviewStatus(),
|
||||||
"PUBLISHED",
|
"PUBLISHED",
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ public class InboundFeeDetailService {
|
|||||||
private FinanceFeeSourceOutboxService financeOutbox;
|
private FinanceFeeSourceOutboxService financeOutbox;
|
||||||
@Resource
|
@Resource
|
||||||
private WarehouseSupplierSnapshotService suppliers;
|
private WarehouseSupplierSnapshotService suppliers;
|
||||||
|
@Resource
|
||||||
|
private FeePaymentSnapshotValidator paymentSnapshots;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public List<InboundFeeDetailEntity> list(long tenantId, long inboundId, boolean includeHistory) {
|
public List<InboundFeeDetailEntity> list(long tenantId, long inboundId, boolean includeHistory) {
|
||||||
@@ -255,6 +257,7 @@ public class InboundFeeDetailService {
|
|||||||
long reviewerId, String reviewerName) {
|
long reviewerId, String reviewerName) {
|
||||||
InboundFeeDetailEntity pending = requiredManual(tenantId, inboundId, detailId);
|
InboundFeeDetailEntity pending = requiredManual(tenantId, inboundId, detailId);
|
||||||
FeeItemEntity item = requiredActiveItem(pending.getFeeItemId());
|
FeeItemEntity item = requiredActiveItem(pending.getFeeItemId());
|
||||||
|
revalidatePaymentSnapshot(pending);
|
||||||
refreshSupplierSnapshot(pending, item, reviewerId);
|
refreshSupplierSnapshot(pending, item, reviewerId);
|
||||||
if (repository.review(tenantId, inboundId, detailId, "APPROVED",
|
if (repository.review(tenantId, inboundId, detailId, "APPROVED",
|
||||||
reviewerId, reviewerName, null) != 1) {
|
reviewerId, reviewerName, null) != 1) {
|
||||||
@@ -381,6 +384,8 @@ public class InboundFeeDetailService {
|
|||||||
value.setInboundCode(inbound.getInboundCode());
|
value.setInboundCode(inbound.getInboundCode());
|
||||||
value.setDirection(request.direction());
|
value.setDirection(request.direction());
|
||||||
applySupplierSnapshot(value, item, request.direction(), request.supplierId());
|
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.setBusinessDate(request.businessDate());
|
||||||
value.setFeeCategorySnapshot(item.getCategory());
|
value.setFeeCategorySnapshot(item.getCategory());
|
||||||
value.setTradeScopeSnapshot(item.getTradeScope());
|
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) {
|
private static BizException conflict(String message) {
|
||||||
return new BizException(ResultCode.CONFLICT, 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_id BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空',
|
||||||
supplier_name VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空',
|
supplier_name VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空',
|
||||||
supplier_category VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商',
|
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 '费用业务日期',
|
business_date DATE NOT NULL COMMENT '费用业务日期',
|
||||||
amount DECIMAL(18,2) NOT NULL COMMENT 'CNY费用金额',
|
amount DECIMAL(18,2) NOT NULL COMMENT 'CNY费用金额',
|
||||||
currency CHAR(3) NOT NULL DEFAULT 'CNY' 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_currency CHECK (currency = 'CNY'),
|
||||||
CONSTRAINT chk_container_fee_amount CHECK (amount >= 0),
|
CONSTRAINT chk_container_fee_amount CHECK (amount >= 0),
|
||||||
CONSTRAINT chk_container_fee_review CHECK (review_status IN ('PENDING', 'APPROVED', 'REJECTED')),
|
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'))
|
CONSTRAINT chk_container_fee_source CHECK (source_status IN ('ACTIVE', 'VOIDED'))
|
||||||
) ENGINE=InnoDB COMMENT='柜次级人工应收应付费用来源;审核通过后自动同步Finance';
|
) 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_id BIGINT UNSIGNED NULL COMMENT 'Warehouse供应商ID快照',
|
||||||
supplier_name VARCHAR(200) NULL COMMENT 'Warehouse供应商名称快照',
|
supplier_name VARCHAR(200) NULL COMMENT 'Warehouse供应商名称快照',
|
||||||
supplier_category VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商',
|
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 '费用业务日期',
|
business_date DATE NOT NULL COMMENT '费用业务日期',
|
||||||
amount DECIMAL(18,2) NOT NULL COMMENT 'CNY费用金额',
|
amount DECIMAL(18,2) NOT NULL COMMENT 'CNY费用金额',
|
||||||
currency CHAR(3) NOT NULL DEFAULT 'CNY' 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");
|
"VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空' AFTER supplier_id");
|
||||||
CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_category',
|
CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_category',
|
||||||
"VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商' AFTER supplier_name");
|
"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',
|
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id',
|
||||||
"BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空' AFTER direction");
|
"BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空' AFTER direction");
|
||||||
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_name',
|
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_name',
|
||||||
"VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空' AFTER supplier_id");
|
"VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空' AFTER supplier_id");
|
||||||
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_category',
|
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_category',
|
||||||
"VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商' AFTER supplier_name");
|
"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_add_column;
|
||||||
|
|
||||||
DROP PROCEDURE IF EXISTS firstleg_finance_assert_schema;
|
DROP PROCEDURE IF EXISTS firstleg_finance_assert_schema;
|
||||||
@@ -185,12 +234,22 @@ BEGIN
|
|||||||
END IF;
|
END IF;
|
||||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||||
WHERE table_schema = DATABASE() AND table_name = 'fl_container_fee_detail'
|
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迁移失败:柜次费用供应商快照字段不完整';
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:柜次费用供应商快照字段不完整';
|
||||||
END IF;
|
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
|
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||||
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_fee_detail'
|
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迁移失败:入库费用供应商快照字段不完整';
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:入库费用供应商快照字段不完整';
|
||||||
END IF;
|
END IF;
|
||||||
END$$
|
END$$
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee_detail (
|
|||||||
supplier_id BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空',
|
supplier_id BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空',
|
||||||
supplier_name VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空',
|
supplier_name VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空',
|
||||||
supplier_category VARCHAR(16) NULL COMMENT '费用项目供应商类别快照;应收为空',
|
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 '业务发生日',
|
business_date DATE NOT NULL COMMENT '业务发生日',
|
||||||
fee_category_snapshot VARCHAR(64) NOT NULL COMMENT '费用分类快照',
|
fee_category_snapshot VARCHAR(64) NOT NULL COMMENT '费用分类快照',
|
||||||
trade_scope_snapshot VARCHAR(20) 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_detail (tenant_id, inbound_id, business_date, deleted),
|
||||||
KEY idx_inbound_fee_review (tenant_id, review_status, 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),
|
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_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)
|
CONSTRAINT fk_inbound_fee_detail_item FOREIGN KEY (fee_item_id) REFERENCES fl_fee_item (id)
|
||||||
) ENGINE=InnoDB COMMENT='入库单应收应付费用明细';
|
) ENGINE=InnoDB COMMENT='入库单应收应付费用明细';
|
||||||
|
|||||||
@@ -304,6 +304,11 @@ components:
|
|||||||
currency: {type: string, pattern: '^[A-Z]{3}$'}
|
currency: {type: string, pattern: '^[A-Z]{3}$'}
|
||||||
amount: {type: number, minimum: 0}
|
amount: {type: number, minimum: 0}
|
||||||
supplierId: {type: string, nullable: true}
|
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}
|
remark: {type: string, maxLength: 500, nullable: true}
|
||||||
InboundFeeDetailVersionRequest:
|
InboundFeeDetailVersionRequest:
|
||||||
type: object
|
type: object
|
||||||
@@ -331,6 +336,11 @@ components:
|
|||||||
supplierId: {type: string, nullable: true}
|
supplierId: {type: string, nullable: true}
|
||||||
supplierName: {type: string, nullable: true, readOnly: true}
|
supplierName: {type: string, nullable: true, readOnly: true}
|
||||||
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true, description: 费用项目供应商类别约束快照;为空表示仅要求供应商ACTIVE}
|
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}
|
businessDate: {type: string, format: date}
|
||||||
customerId: {type: string, nullable: true}
|
customerId: {type: string, nullable: true}
|
||||||
customerName: {type: string, nullable: true}
|
customerName: {type: string, nullable: true}
|
||||||
|
|||||||
@@ -881,7 +881,7 @@ paths:
|
|||||||
summary: 调整已审核柜次费用的日期和金额
|
summary: 调整已审核柜次费用的日期和金额
|
||||||
description: >-
|
description: >-
|
||||||
仅 ACTIVE、APPROVED 且 sourceVersion 匹配的费用可调整。成功后 sourceVersion 加一并重新进入
|
仅 ACTIVE、APPROVED 且 sourceVersion 匹配的费用可调整。成功后 sourceVersion 加一并重新进入
|
||||||
PENDING;费用方向、费用项目、供应商、币种和备注保持原快照。业务字段需要变化时,
|
PENDING;费用方向、费用项目、供应商、结算方式、已付快照、币种和备注保持原快照。业务字段需要变化时,
|
||||||
必须作废原费用并新增。同一 expectedSourceVersion、businessDate、amount 的原样重试只读返回已生成的
|
必须作废原费用并新增。同一 expectedSourceVersion、businessDate、amount 的原样重试只读返回已生成的
|
||||||
PENDING 版本;旧版本异参、已作废或非已审核状态返回 A0409 且零写入。
|
PENDING 版本;旧版本异参、已作废或非已审核状态返回 A0409 且零写入。
|
||||||
parameters:
|
parameters:
|
||||||
@@ -1887,6 +1887,11 @@ components:
|
|||||||
supplierId: {type: integer, format: int64, nullable: true}
|
supplierId: {type: integer, format: int64, nullable: true}
|
||||||
supplierName: {type: string, nullable: true}
|
supplierName: {type: string, nullable: true}
|
||||||
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true, description: PAYABLE供应商类别约束;为空时仅要求供应商ACTIVE}
|
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}
|
feeItemId: {type: integer, format: int64, nullable: true}
|
||||||
feeItemCode: {type: string, nullable: true}
|
feeItemCode: {type: string, nullable: true}
|
||||||
feeItemName: {type: string}
|
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("ContainerFeeAdjustRequest:")
|
||||||
.contains("required: [expectedSourceVersion, businessDate, amount]")
|
.contains("required: [expectedSourceVersion, businessDate, amount]")
|
||||||
.contains("additionalProperties: false")
|
.contains("additionalProperties: false")
|
||||||
.contains("费用方向、费用项目、供应商、币种和备注保持原快照")
|
.contains("费用方向、费用项目、供应商、结算方式、已付快照、币种和备注保持原快照")
|
||||||
.contains("旧版本异参、已作废或非已审核状态返回 A0409 且零写入");
|
.contains("旧版本异参、已作废或非已审核状态返回 A0409 且零写入");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
@@ -65,6 +65,8 @@ class InternalFinanceFeeSourceControllerTest {
|
|||||||
.contains("/internal/firstleg/v1/containers/{containerId}/fees/{feeId}/source-snapshot:")
|
.contains("/internal/firstleg/v1/containers/{containerId}/fees/{feeId}/source-snapshot:")
|
||||||
.contains("{name: sourceVersion, in: query, required: true")
|
.contains("{name: sourceVersion, in: query, required: true")
|
||||||
.contains("supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: 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("为空时仅要求供应商ACTIVE")
|
||||||
.contains("/internal/firstleg/v1/fee-sources/commands/void:")
|
.contains("/internal/firstleg/v1/fee-sources/commands/void:")
|
||||||
.contains("FIRSTLEG_FEE_VOIDED Outbox")
|
.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.entity.ContainerBookingEntity;
|
||||||
import com.baiyue.firstleg.mapper.ContainerBookingMapper;
|
import com.baiyue.firstleg.mapper.ContainerBookingMapper;
|
||||||
import org.apache.ibatis.annotations.Select;
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
import org.apache.ibatis.annotations.Update;
|
import org.apache.ibatis.annotations.Update;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -61,6 +62,23 @@ class ContainerBookingMapperContractTest {
|
|||||||
assertThat(sql).contains("i.account_id<>#{customerId}").doesNotContain("<>");
|
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
|
@Test
|
||||||
@DisplayName("草稿完整更新和预装运输白名单都带版本与状态门禁")
|
@DisplayName("草稿完整更新和预装运输白名单都带版本与状态门禁")
|
||||||
void guardsUpdatesInSql() {
|
void guardsUpdatesInSql() {
|
||||||
|
|||||||
+9
-1
@@ -18,11 +18,15 @@ class FinanceFeeSourceMigrationContractTest {
|
|||||||
assertThat(baseline).contains(
|
assertThat(baseline).contains(
|
||||||
"supplier_id BIGINT UNSIGNED NULL",
|
"supplier_id BIGINT UNSIGNED NULL",
|
||||||
"supplier_name VARCHAR(200) 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(
|
assertThat(migration).contains(
|
||||||
"firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id'",
|
"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_name'",
|
||||||
"firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_category'",
|
"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迁移失败:入库费用供应商快照字段不完整");
|
"Firstleg Finance迁移失败:入库费用供应商快照字段不完整");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,12 +39,16 @@ class FinanceFeeSourceMigrationContractTest {
|
|||||||
"uk_inbound_outbox_event_key", "next_retry_at", "locked_at", "locked_by",
|
"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",
|
"idx_inbound_outbox_finance_relay", "CREATE TABLE IF NOT EXISTS fl_container_fee_detail",
|
||||||
"source_version", "supplier_id", "supplier_name", "supplier_category",
|
"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",
|
"CREATE TABLE IF NOT EXISTS fl_container_fee_source_snapshot",
|
||||||
"uk_container_fee_source_version",
|
"uk_container_fee_source_version",
|
||||||
"CREATE TABLE IF NOT EXISTS fl_finance_fee_source_command",
|
"CREATE TABLE IF NOT EXISTS fl_finance_fee_source_command",
|
||||||
"uk_finance_fee_command_idem",
|
"uk_finance_fee_command_idem",
|
||||||
"request_hash CHAR(64) NOT NULL",
|
"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_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_add_column('fl_inbound_fee_detail', 'supplier_id'",
|
||||||
"Firstleg Finance迁移失败:入库费用供应商快照字段不完整",
|
"Firstleg Finance迁移失败:入库费用供应商快照字段不完整",
|
||||||
"chk_container_fee_currency", "currency = 'CNY'", "firstleg_finance_assert_schema");
|
"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.FeeItemRepository;
|
||||||
import com.baiyue.firstleg.repository.SmartLoadingRepository;
|
import com.baiyue.firstleg.repository.SmartLoadingRepository;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
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.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
|
||||||
class ContainerFeeDetailServiceTest {
|
class ContainerFeeDetailServiceTest {
|
||||||
private final ContainerFeeDetailRepository repository = mock(ContainerFeeDetailRepository.class);
|
private final ContainerFeeDetailRepository repository = mock(ContainerFeeDetailRepository.class);
|
||||||
@@ -37,8 +39,18 @@ class ContainerFeeDetailServiceTest {
|
|||||||
private final FinanceFeeSourceOutboxService outbox = mock(FinanceFeeSourceOutboxService.class);
|
private final FinanceFeeSourceOutboxService outbox = mock(FinanceFeeSourceOutboxService.class);
|
||||||
private final WarehouseSupplierSnapshotService suppliers = mock(WarehouseSupplierSnapshotService.class);
|
private final WarehouseSupplierSnapshotService suppliers = mock(WarehouseSupplierSnapshotService.class);
|
||||||
private final ContainerFeeSourceSnapshotRepository sourceSnapshots = mock(ContainerFeeSourceSnapshotRepository.class);
|
private final ContainerFeeSourceSnapshotRepository sourceSnapshots = mock(ContainerFeeSourceSnapshotRepository.class);
|
||||||
|
private final FeePaymentSnapshotValidator paymentSnapshots = mock(FeePaymentSnapshotValidator.class);
|
||||||
private final ContainerFeeDetailService service =
|
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
|
@Test
|
||||||
void rejectsContainerThatDoesNotContainCustomerOrders() {
|
void rejectsContainerThatDoesNotContainCustomerOrders() {
|
||||||
@@ -199,6 +211,28 @@ class ContainerFeeDetailServiceTest {
|
|||||||
assertThat(captor.getValue().getSupplierName()).isNull();
|
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() {
|
private void ownedPlan() {
|
||||||
LoadingPlanEntity plan = new LoadingPlanEntity();
|
LoadingPlanEntity plan = new LoadingPlanEntity();
|
||||||
plan.setId(41L);
|
plan.setId(41L);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import com.baiyue.firstleg.repository.FbaPricingRepository;
|
|||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
import org.springframework.dao.DuplicateKeyException;
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
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.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.doAnswer;
|
import static org.mockito.Mockito.doAnswer;
|
||||||
import static org.mockito.Mockito.doThrow;
|
import static org.mockito.Mockito.doThrow;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
|
||||||
@DisplayName("费用项目和入库费用明细")
|
@DisplayName("费用项目和入库费用明细")
|
||||||
@SpringJUnitConfig({FeeItemService.class, InboundFeeDetailService.class})
|
@SpringJUnitConfig({FeeItemService.class, InboundFeeDetailService.class})
|
||||||
@@ -45,11 +48,21 @@ class FeeManagementServiceTest {
|
|||||||
private FinanceFeeSourceOutboxService financeOutbox;
|
private FinanceFeeSourceOutboxService financeOutbox;
|
||||||
@MockBean
|
@MockBean
|
||||||
private WarehouseSupplierSnapshotService suppliers;
|
private WarehouseSupplierSnapshotService suppliers;
|
||||||
|
@MockBean
|
||||||
|
private FeePaymentSnapshotValidator paymentSnapshots;
|
||||||
@Resource
|
@Resource
|
||||||
private FeeItemService feeItems;
|
private FeeItemService feeItems;
|
||||||
@Resource
|
@Resource
|
||||||
private InboundFeeDetailService details;
|
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
|
@Test
|
||||||
@DisplayName("客户B不能删除客户A的费用明细")
|
@DisplayName("客户B不能删除客户A的费用明细")
|
||||||
void rejectsCrossCustomerFeeDetailDelete() {
|
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.setSupplierId(71L);
|
||||||
detail.setSupplierName("现有供应商");
|
detail.setSupplierName("现有供应商");
|
||||||
detail.setSupplierCategory("TRUCKING");
|
detail.setSupplierCategory("TRUCKING");
|
||||||
|
detail.setSettlementMode("ON_ACCOUNT");
|
||||||
detail.setBusinessDate(java.time.LocalDate.of(2026, 8, 12));
|
detail.setBusinessDate(java.time.LocalDate.of(2026, 8, 12));
|
||||||
detail.setAmount(new BigDecimal("300.00"));
|
detail.setAmount(new BigDecimal("300.00"));
|
||||||
detail.setCurrency("CNY");
|
detail.setCurrency("CNY");
|
||||||
@@ -96,6 +97,8 @@ class FinanceFeeSourceSnapshotServiceTest {
|
|||||||
assertThat(snapshot.supplierId()).isEqualTo(71L);
|
assertThat(snapshot.supplierId()).isEqualTo(71L);
|
||||||
assertThat(snapshot.supplierName()).isEqualTo("现有供应商");
|
assertThat(snapshot.supplierName()).isEqualTo("现有供应商");
|
||||||
assertThat(snapshot.supplierCategory()).isEqualTo("TRUCKING");
|
assertThat(snapshot.supplierCategory()).isEqualTo("TRUCKING");
|
||||||
|
assertThat(snapshot.settlementMode()).isEqualTo("ON_ACCOUNT");
|
||||||
|
assertThat(snapshot.paidAt()).isNull();
|
||||||
assertThat(snapshot.sourceHash()).matches("[0-9a-f]{64}");
|
assertThat(snapshot.sourceHash()).matches("[0-9a-f]{64}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ test('验收只使用本轮 marker fixture,解析真实 XLSX 并覆盖门禁
|
|||||||
assert.match(source, /Provider 失败信息可见/)
|
assert.match(source, /Provider 失败信息可见/)
|
||||||
assert.match(source, /9223372036854775806/)
|
assert.match(source, /9223372036854775806/)
|
||||||
assert.doesNotMatch(source, /exportType:\s*['"]CUSTOMER_LIST/)
|
assert.doesNotMatch(source, /exportType:\s*['"]CUSTOMER_LIST/)
|
||||||
|
assert.doesNotMatch(source, /FINANCE_FEE_SOURCE_LIST:[^\n]*参与结算/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('未部署时能力探测后记录部署前置,不借历史业务数据', async () => {
|
test('未部署时能力探测后记录部署前置,不借历史业务数据', async () => {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const REQUIRED_PERMISSIONS = Object.freeze([
|
|||||||
export const EXPORT_SPECS = Object.freeze({
|
export const EXPORT_SPECS = Object.freeze({
|
||||||
FINANCE_CUSTOMER_ACCOUNT_LIST: ['客户编码', '客户', '联系人', '联系电话', '待收(CNY)', '待开账(CNY)', '本期已收(CNY)', '待付(CNY)', '客户状态', '最近变动'],
|
FINANCE_CUSTOMER_ACCOUNT_LIST: ['客户编码', '客户', '联系人', '联系电话', '待收(CNY)', '待开账(CNY)', '本期已收(CNY)', '待付(CNY)', '客户状态', '最近变动'],
|
||||||
FINANCE_CUSTOMER_ACCOUNT_LEDGER: ['流水号', '发生时间', '业务类型', '关联单据', '应收变动(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_RECONCILIATION_STATEMENT_LIST: ['对账单号', '客户', '结算起始日', '结算结束日', '账期(天)', '货币', '总应收金额(CNY)', '已收金额(CNY)', '未收金额(CNY)', '是否结清', '审核状态', '备注', '创建人', '创建时间', '更新人', '更新时间'],
|
||||||
FINANCE_RECEIVABLE_SETTLEMENT_LIST: ['结算单号', '客户', '供应商', '货币', '总金额(CNY)', '已收/付金额(CNY)', '未收/付金额(CNY)', '业务期间', '生成日期', '到期日期', '状态'],
|
FINANCE_RECEIVABLE_SETTLEMENT_LIST: ['结算单号', '客户', '供应商', '货币', '总金额(CNY)', '已收/付金额(CNY)', '未收/付金额(CNY)', '业务期间', '生成日期', '到期日期', '状态'],
|
||||||
FINANCE_RECEIVABLE_SETTLEMENT_DETAIL: ['结算单号', '客户', '供应商', '费用项目', '入仓号', '柜次号', '业务发生日', '明细金额(CNY)', '结算总额(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: 'BOOKING', feeTypeName: '操作费' }), 'BOOKING')
|
||||||
assert.equal(supplierCategoryForFeeItem({ supplierCategory: null, feeTypeName: '拖车费' }), null)
|
assert.equal(supplierCategoryForFeeItem({ supplierCategory: null, feeTypeName: '拖车费' }), null)
|
||||||
assert.equal(supplierCategoryForFeeItem({ supplierCategory: 'INVALID' }), 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', () => {
|
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
|
const invalidFixtureSources = fixtureSources.filter((source) => !source.feeSourceId || !source.customerId
|
||||||
|| !source.businessDate || !(source.waybillId || source.shipmentId || source.orderNo) || source.marker !== context.marker
|
|| !source.businessDate || !(source.waybillId || source.shipmentId || source.orderNo) || source.marker !== context.marker
|
||||||
|| (source.direction === 'PAYABLE' && (!source.supplierId || !source.supplierName
|
|| (source.direction === 'PAYABLE' && (!source.supplierId || !source.supplierName
|
||||||
|| source.supplierCategory !== payableSupplierCategory)))
|
|| (payableSupplierCategory && source.supplierCategory !== payableSupplierCategory))))
|
||||||
context.assert(MODULE, '输出完整 Finance 来源契约', null, invalidFixtureSources.length === 0,
|
context.assert(MODULE, '输出完整 Finance 来源契约', null, invalidFixtureSources.length === 0,
|
||||||
`sources=${fixtureSources.length}, invalid=${invalidFixtureSources.length};字段必须来自本轮费用来源、客户、订单/运单和真实柜次分摊`, OPTIONS)
|
`sources=${fixtureSources.length}, invalid=${invalidFixtureSources.length};字段必须来自本轮费用来源、客户、订单/运单和真实柜次分摊`, OPTIONS)
|
||||||
context.createdResources.financeFullFixtures = {
|
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/)
|
assert.match(source, /!isUnboundContainerCost\(source\.raw\) \|\| hasContainerAllocations/)
|
||||||
})
|
})
|
||||||
const financePermissions = [
|
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:create', 'platform:finance:reconciliation:review',
|
||||||
'platform:finance:reconciliation:issue', 'platform:finance:reconciliation:adjust',
|
'platform:finance:reconciliation:issue', 'platform:finance:reconciliation:adjust',
|
||||||
'platform:finance:reconciliation:void', 'platform:finance:settlement-ar:create',
|
'platform:finance:reconciliation:void', 'platform:finance:settlement-ar:create',
|
||||||
@@ -29,6 +30,8 @@ function ok(data) {
|
|||||||
|
|
||||||
function fakeContext(withFixture = true, permissions = financePermissions, denyFinanceWrites = false,
|
function fakeContext(withFixture = true, permissions = financePermissions, denyFinanceWrites = false,
|
||||||
missingSubject = false, omitPayableSource = 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 marker = 'FIN-TEST-MARKER'
|
||||||
const receivables = [
|
const receivables = [
|
||||||
{ feeSourceId: '101', orderId: '201', direction: 'RECEIVABLE', currency: 'CNY', businessDate: '2026-08-10' },
|
{ 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 calls = []
|
||||||
const steps = []
|
const steps = []
|
||||||
const coverageGaps = []
|
const coverageGaps = []
|
||||||
|
const reviewedSourceIds = new Set()
|
||||||
|
|
||||||
function responseForStatement(statement) { return structuredClone(statement) }
|
function responseForStatement(statement) { return structuredClone(statement) }
|
||||||
function applyAction(statement, body) {
|
function applyAction(statement, body) {
|
||||||
const status = { SUBMIT: 'PENDING_REVIEW', APPROVE: 'APPROVED', REJECT: 'REJECTED',
|
const status = { SUBMIT: 'PENDING_REVIEW', APPROVE: 'APPROVED', REJECT: 'REJECTED',
|
||||||
ISSUE: 'ISSUED', ADJUST: 'ISSUED', VOID: 'VOIDED', REOPEN: 'DRAFT' }[body.action]
|
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.status = status
|
||||||
statement.version += 1
|
statement.version += 1
|
||||||
if (body.action === 'ADJUST') {
|
if (body.action === 'ADJUST') {
|
||||||
@@ -73,6 +77,8 @@ function fakeContext(withFixture = true, permissions = financePermissions, denyF
|
|||||||
line.adjustmentReason = adjustment.reason
|
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)
|
return responseForStatement(statement)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +92,9 @@ function fakeContext(withFixture = true, permissions = financePermissions, denyF
|
|||||||
if (method === 'GET' && requestPath === '/api/platform/v1/iam/authorization/permissions') {
|
if (method === 'GET' && requestPath === '/api/platform/v1/iam/authorization/permissions') {
|
||||||
return ok(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') {
|
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',
|
return ok({ records: [{ customerId: '11', customerCode: 'TEN11', customerName: 'Fixture Account',
|
||||||
contactName: 'Fixture Contact', contactPhone: '13800000000', outstandingReceivable: 100,
|
contactName: 'Fixture Contact', contactPhone: '13800000000', outstandingReceivable: 100,
|
||||||
@@ -112,6 +121,20 @@ function fakeContext(withFixture = true, permissions = financePermissions, denyF
|
|||||||
reviewStatus: 'APPROVED', sourceStatus: 'AVAILABLE' }
|
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 (method === 'POST' && requestPath.endsWith('/statements')) {
|
||||||
if (body.currency !== 'CNY') return { http: 200, body: { code: 'FIN_400' } }
|
if (body.currency !== 'CNY') return { http: 200, body: { code: 'FIN_400' } }
|
||||||
if (String(body.customerId) !== '11') return { http: 200, body: { code: 'FIN_404' } }
|
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,
|
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+)$/)
|
const detailMatch = requestPath.match(/\/reconciliation-statements\/(\d+)$/)
|
||||||
if (method === 'GET' && detailMatch) return ok(responseForStatement(statements.get(detailMatch[1])))
|
if (method === 'GET' && detailMatch) return ok(responseForStatement(statements.get(detailMatch[1])))
|
||||||
throw new Error(`Unexpected request: ${method} ${requestPath}`)
|
throw new Error(`Unexpected request: ${method} ${requestPath}`)
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
const MODULE = 'finance-reconciliation-full'
|
const MODULE = 'finance-reconciliation-full'
|
||||||
const SCENARIO = 'finance-full-reconciliation'
|
const SCENARIO = 'finance-full-reconciliation'
|
||||||
const BASE = '/api/platform/finance/v1'
|
const BASE = '/api/platform/finance/v1'
|
||||||
|
const CLIENT_BASE = '/api/client/finance/v1'
|
||||||
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
||||||
const STATEMENT_LIST_PERMISSION = 'platform:finance:customer-statement:list'
|
const STATEMENT_LIST_PERMISSION = 'platform:finance:customer-statement:list'
|
||||||
const LEGACY_STATEMENT_VIEW_PERMISSION = 'platform:finance:bill:view'
|
const LEGACY_STATEMENT_VIEW_PERMISSION = 'platform:finance:bill:view'
|
||||||
const REQUIRED_PERMISSIONS = [
|
const REQUIRED_PERMISSIONS = [
|
||||||
'platform:finance:customer-account:list',
|
'platform:finance:customer-account:list',
|
||||||
|
'platform:finance:customer-arap:list',
|
||||||
|
'platform:finance:fee-source:review',
|
||||||
STATEMENT_LIST_PERMISSION,
|
STATEMENT_LIST_PERMISSION,
|
||||||
'platform:finance:reconciliation:create',
|
'platform:finance:reconciliation:create',
|
||||||
'platform:finance:reconciliation:review',
|
'platform:finance:reconciliation:review',
|
||||||
@@ -116,6 +119,76 @@ function finish(context, start) {
|
|||||||
failed: steps.filter((step) => !step.passed).length,
|
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) {
|
function diagnostic(response) {
|
||||||
return { http: response?.http ?? null,
|
return { http: response?.http ?? null,
|
||||||
code: response?.body?.code == null ? null : String(response.body.code),
|
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')
|
statement = await act(context, statement, 'ISSUE', 'chain-issue-final')
|
||||||
const actions = new Set(list(statement?.revisions).map((revision) => revision.action))
|
const actions = new Set(list(statement?.revisions).map((revision) => revision.action))
|
||||||
context.assert(MODULE, '完整链保留驳回、重开、调整、作废和再次出账 revision', null,
|
context.assert(MODULE, '完整链保留驳回、重开、调整、作废和再次出账 revision', null,
|
||||||
['RECONCILIATION_REJECT', 'RECONCILIATION_REOPEN', 'RECONCILIATION_ADJUST',
|
['REJECT', 'REOPEN', 'ADJUST', 'VOID', 'ISSUE'].every((action) => actions.has(action)),
|
||||||
'RECONCILIATION_VOID', 'RECONCILIATION_ISSUE'].every((action) => actions.has(action)),
|
|
||||||
`actions=${[...actions].join(',')}`, OPTIONS)
|
`actions=${[...actions].join(',')}`, OPTIONS)
|
||||||
return statement
|
return statement
|
||||||
}
|
}
|
||||||
@@ -445,6 +517,13 @@ export async function run(context) {
|
|||||||
const groups = [cutoffSources, ...(() => {
|
const groups = [cutoffSources, ...(() => {
|
||||||
return [remainingSources.slice(0, 1), remainingSources.slice(1)]
|
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 = []
|
const issuedStatements = []
|
||||||
for (let index = 0; index < groups.length; index += 1) {
|
for (let index = 0; index < groups.length; index += 1) {
|
||||||
const created = await createStatement(context, groups[index], `selected-${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 = []
|
const receivableSettlements = []
|
||||||
for (let index = 0; index < issuedStatements.length; index += 1) {
|
for (let index = 0; index < issuedStatements.length; index += 1) {
|
||||||
const settlement = await createReceivable(context, issuedStatements[index], 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)
|
}, { 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', () => {
|
test('approval convergence requires matching inbound state, order and customer task identity', () => {
|
||||||
const detail = { summary: { inboundId: '40001', mainStatus: 'WAIT_MEASURE' } }
|
const detail = { summary: { inboundId: '40001', mainStatus: 'WAIT_MEASURE' } }
|
||||||
assert.equal(approvedInboundReady(detail, taskResponse, tenantId, orderId), true)
|
assert.equal(approvedInboundReady(detail, taskResponse, tenantId, orderId), true)
|
||||||
|
|||||||
@@ -144,22 +144,23 @@ export async function run(context) {
|
|||||||
OPTIONS)
|
OPTIONS)
|
||||||
if (!selectedChannel) return
|
if (!selectedChannel) return
|
||||||
const channelName = selectedChannel.channelName
|
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 primaryRequirement = { containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 }
|
||||||
const realContainerNo = `TST-${suffix}-01`.slice(0, 64)
|
const realContainerNo = `TST-${suffix}-01`.slice(0, 64)
|
||||||
const wrongContainerNo = `TST-${suffix}-02`.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 = {
|
const bookingDraft = {
|
||||||
customerId: tenantId, customerName: inbound?.summary?.customerName || `验收客户-${tenantId}`,
|
customerId: tenantId, customerName: inbound?.summary?.customerName || `验收客户-${tenantId}`,
|
||||||
channelCode, warehouseId,
|
channelCode, warehouseId,
|
||||||
billOfLadingNo: `BL-${suffix}`.slice(0, 128),
|
billOfLadingNo: `BL-${suffix}`.slice(0, 128),
|
||||||
shippingCompanyId: id(supplier.id), shippingCompanyName: supplier.name,
|
shippingCompanyId: id(bookingAgent.id), shippingCompanyName: bookingAgent.name,
|
||||||
bookingAgentId: id(supplier.id), bookingAgentName: supplier.name,
|
bookingAgentId: id(bookingAgent.id), bookingAgentName: bookingAgent.name,
|
||||||
vesselName: `验收船-${suffix}`.slice(0, 128), voyageNo: `V-${suffix}`.slice(0, 64),
|
vesselName: `验收船-${suffix}`.slice(0, 128), voyageNo: `V-${suffix}`.slice(0, 64),
|
||||||
departurePortCode: 'CNSZX', departurePortName: '深圳',
|
departurePortCode: 'CNSZX', departurePortName: '深圳',
|
||||||
destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', placeOfDelivery: 'LAX',
|
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, /id\(replacedTask\?\.cabinetNo\) === stableIdentity\.cabinetNo/)
|
||||||
assert.match(value, /afterReplacementSnapshot\?\.snapshotToken === beforeReplacementSnapshot\?\.snapshotToken/)
|
assert.match(value, /afterReplacementSnapshot\?\.snapshotToken === beforeReplacementSnapshot\?\.snapshotToken/)
|
||||||
assert.match(value, /pdaIdentityStable: true/)
|
assert.match(value, /pdaIdentityStable: true/)
|
||||||
assert.match(value, /shippingCompanyId: null, shippingCompanyName: null/)
|
assert.match(value, /category=BOOKING&status=ACTIVE/)
|
||||||
assert.match(value, /bookingAgentId: null, bookingAgentName: null/)
|
assert.match(value, /shippingCompanyId: id\(bookingAgent\.id\), shippingCompanyName: bookingAgent\.name/)
|
||||||
assert.doesNotMatch(value, /shippingCompanyName: `验收船公司-/)
|
assert.match(value, /bookingAgentId: id\(bookingAgent\.id\), bookingAgentName: bookingAgent\.name/)
|
||||||
assert.doesNotMatch(value, /bookingAgentName: `验收代理-/)
|
|
||||||
assert.match(value, /const frozenTransport = Object\.fromEntries\(transportFields\.map/)
|
assert.match(value, /const frozenTransport = Object\.fromEntries\(transportFields\.map/)
|
||||||
assert.match(value, /if \(!frozenTransportMatches\) return/)
|
assert.match(value, /if \(!frozenTransportMatches\) return/)
|
||||||
assert.match(value, /expectedVersion: Number\(bookingCreatedDraft\?\.oceanBooking\?\.version\), reason: context\.marker/)
|
assert.match(value, /expectedVersion: Number\(bookingCreatedDraft\?\.oceanBooking\?\.version\), reason: context\.marker/)
|
||||||
|
|||||||
Reference in New Issue
Block a user