This commit is contained in:
2026-08-20 18:02:20 +08:00
parent 1e8577dc4e
commit b1655a1841
39 changed files with 626 additions and 38 deletions
@@ -12,6 +12,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
@FeignClient(name = "firstleg-service", contextId = "financeFirstlegFeeSourceClient",
url = "${baiyue.clients.firstleg.url:}", path = "/internal/firstleg/v1")
@@ -45,6 +46,8 @@ public interface FirstlegFeeSourceClient {
Long containerBookingId, String containerNo, long customerId,
String customerName, Long supplierId, String supplierName,
String supplierCategory,
String settlementMode, LocalDateTime paidAt, String paymentMethod,
String paymentReferenceNo, List<Long> paymentVoucherFileIds,
Long feeItemId, String feeItemCode,
String feeItemName, String direction, LocalDate businessDate,
String currency, BigDecimal totalAmount, String reviewStatus,
@@ -53,6 +53,11 @@ public final class FinanceReadModels {
String feeItemName,
BigDecimal amount,
String currency,
String settlementMode,
LocalDateTime paidAt,
String paymentMethod,
String paymentReferenceNo,
List<String> paymentVoucherFileIds,
String status,
LocalDateTime createdAt) {}
@@ -67,7 +67,7 @@ public class FinanceCustomerAccountService {
.forEach(row -> metric(result, row, "pendingBilling", "amount"));
jdbc.queryForList("SELECT tenant_id customer_id,SUM(received_amount) amount FROM fin_receipt WHERE tenant_id IN (" + placeholders + ") AND status='CONFIRMED' GROUP BY tenant_id", customerIds.toArray())
.forEach(row -> metric(result, row, "receivedInPeriod", "amount"));
jdbc.queryForList("SELECT tenant_id customer_id,SUM(cost_amount) amount FROM fin_cost WHERE tenant_id IN (" + placeholders + ") AND status<>'VOIDED' GROUP BY tenant_id", customerIds.toArray())
jdbc.queryForList("SELECT tenant_id customer_id,SUM(outstanding_amount) amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='PAYABLE' AND status<>'VOIDED' GROUP BY tenant_id", customerIds.toArray())
.forEach(row -> metric(result, row, "outstandingPayable", "amount"));
Object[] changeArgs = new Object[customerIds.size() * 3];
for (int i = 0; i < 3; i++) System.arraycopy(customerIds.toArray(), 0, changeArgs, i * customerIds.size(), customerIds.size());
@@ -81,11 +81,13 @@ public class FinanceFeeSourceEventService {
}
jdbc.update("UPDATE fin_fee_source SET status='SUPERSEDED' WHERE source_system='FIRSTLEG' AND source_fee_id=? AND status='AVAILABLE'",
snapshot.feeId());
jdbc.update("INSERT INTO fin_fee_source(source_system,source_fee_id,source_fee_version,customer_id,source_type,source_no,direction,order_id,inbound_id,container_id,fee_item_code,fee_item_name,amount,currency_code,source_hash,source_snapshot,status) VALUES ('FIRSTLEG',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'AVAILABLE')",
jdbc.update("INSERT INTO fin_fee_source(source_system,source_fee_id,source_fee_version,customer_id,source_type,source_no,direction,order_id,inbound_id,container_id,fee_item_code,fee_item_name,amount,currency_code,settlement_mode,paid_at,payment_method,payment_reference_no,payment_voucher_file_ids,source_hash,source_snapshot,status) VALUES ('FIRSTLEG',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'AVAILABLE')",
snapshot.feeId(), snapshot.feeVersion(), snapshot.customerId(), snapshot.sourceType(),
snapshot.sourceType().equals("INBOUND_FEE") ? snapshot.inboundCode() : snapshot.containerNo(),
snapshot.direction(), snapshot.orderId(), snapshot.inboundId(), snapshot.containerId(),
snapshot.feeItemCode(), snapshot.feeItemName(), snapshot.totalAmount(), snapshot.currency(),
snapshot.settlementMode(), snapshot.paidAt(), snapshot.paymentMethod(),
snapshot.paymentReferenceNo(), voucherJson(snapshot.paymentVoucherFileIds()),
snapshot.sourceHash(), json(snapshot));
return jdbc.queryForObject("SELECT id FROM fin_fee_source WHERE source_system='FIRSTLEG' AND source_fee_id=? AND source_fee_version=?",
Long.class, snapshot.feeId(), snapshot.feeVersion());
@@ -138,14 +140,44 @@ public class FinanceFeeSourceEventService {
if (!value.supplierName().equals(supplier.name())) {
throw error("FIN-EVT-012", "应付费用来源供应商名称快照不一致");
}
validatePayableSettlement(value);
} else if (value.supplierId() != null || !blank(value.supplierName())
|| !blank(value.supplierCategory())) {
throw error("FIN-EVT-012", "应收费用不得携带供应商快照");
} else if (!blank(value.settlementMode()) || value.paidAt() != null
|| !blank(value.paymentMethod()) || !blank(value.paymentReferenceNo())
|| (value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty())) {
throw error("FIN-EVT-012", "应收费用不得携带结算方式或已付快照");
}
if ("INBOUND_FEE".equals(value.sourceType()) && (value.inboundId() == null || !String.valueOf(value.inboundId()).equals(event.payload().inboundId()))) throw error("FIN-EVT-013", "入库单归属不一致");
if ("CONTAINER_FEE".equals(value.sourceType()) && (value.containerId() == null || !String.valueOf(value.containerId()).equals(event.payload().containerId()))) throw error("FIN-EVT-014", "柜子归属不一致");
}
private void validatePayableSettlement(FirstlegFeeSourceClient.Snapshot value) {
boolean hasVouchers = value.paymentVoucherFileIds() != null
&& !value.paymentVoucherFileIds().isEmpty();
if (hasVouchers && (value.paymentVoucherFileIds().size() > 20
|| value.paymentVoucherFileIds().stream().anyMatch(id -> id == null || id <= 0))) {
throw error("FIN-EVT-012", "付款凭证文件快照无效");
}
if ("ON_ACCOUNT".equals(value.settlementMode())) {
if (value.paidAt() != null || !blank(value.paymentMethod())
|| !blank(value.paymentReferenceNo()) || hasVouchers) {
throw error("FIN-EVT-012", "账期应付不得携带已付快照");
}
return;
}
if (!"DIRECT_PAID".equals(value.settlementMode()) || value.paidAt() == null
|| !Set.of("ONLINE", "BANK_TRANSFER", "CASH", "OTHER").contains(value.paymentMethod())
|| (blank(value.paymentReferenceNo()) && !hasVouchers)) {
throw error("FIN-EVT-012", "直接已付费用来源快照不完整");
}
}
private String voucherJson(java.util.List<Long> values) {
return values == null || values.isEmpty() ? null : json(values);
}
private String json(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException e) { throw error("FIN-EVT-001", "事件序列化失败"); }
@@ -69,7 +69,7 @@ public class FinancePhase1OperationService {
"(SELECT COALESCE(SUM(outstanding_amount),0) FROM fin_bill b WHERE b.tenant_id=c.customer_id AND b.status<>'VOIDED') outstanding_receivable," +
"(SELECT COALESCE(SUM(amount),0) FROM fin_fee_source f WHERE f.customer_id=c.customer_id AND f.direction='RECEIVABLE' AND f.status='AVAILABLE' AND NOT EXISTS(SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version)) pending_billing," +
"(SELECT COALESCE(SUM(received_amount),0) FROM fin_receipt r WHERE r.tenant_id=c.customer_id AND r.status='CONFIRMED') received_period," +
"(SELECT COALESCE(SUM(cost_amount),0) FROM fin_cost x WHERE x.tenant_id=c.customer_id AND x.status<>'VOIDED') outstanding_payable," +
"(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')) last_changed_at" + base + " ORDER BY last_changed_at DESC,c.customer_id DESC LIMIT ? OFFSET ?";
Object[] args = java.util.Arrays.copyOf(filterArgs, filterArgs.length + 2);
args[args.length - 2] = pageSize; args[args.length - 1] = (page - 1) * pageSize;
@@ -96,7 +96,7 @@ public class FinanceQueryService {
String normalized = direction == null || direction.isBlank() ? "ALL" : direction.trim().toUpperCase(java.util.Locale.ROOT);
if (!List.of("ALL", "RECEIVABLE", "PAYABLE").contains(normalized)) throw bad("费用方向只能是RECEIVABLE或PAYABLE");
List<FeePoolItem> result = new ArrayList<>();
String sourceSql = "SELECT id,customer_id,direction,source_type,source_fee_id,source_fee_version,order_id,inbound_id,container_id,fee_item_name,amount,currency_code,status,created_at FROM fin_fee_source WHERE status='AVAILABLE'";
String sourceSql = "SELECT id,customer_id,direction,source_type,source_fee_id,source_fee_version,order_id,inbound_id,container_id,fee_item_name,amount,currency_code,settlement_mode,paid_at,payment_method,payment_reference_no,payment_voucher_file_ids,status,created_at FROM fin_fee_source WHERE status='AVAILABLE'";
List<Object> sourceArgs = new ArrayList<>();
if (customerId != null) { sourceSql += " AND customer_id=?"; sourceArgs.add(customerId); }
if (!"ALL".equals(normalized)) { sourceSql += " AND direction=?"; sourceArgs.add(normalized); }
@@ -107,18 +107,10 @@ public class FinanceQueryService {
rs.getString("source_type"), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"),
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), nullableLong(rs, "container_id"),
rs.getString("fee_item_name"), rs.getBigDecimal("amount"), rs.getString("currency_code"),
rs.getString("settlement_mode"), time(rs, "paid_at"), rs.getString("payment_method"),
rs.getString("payment_reference_no"), fileIds(rs.getString("payment_voucher_file_ids")),
rs.getString("status"), time(rs, "created_at"));
}, sourceArgs.toArray()));
if (!"RECEIVABLE".equals(normalized)) {
String costSql = "SELECT id,tenant_id,order_id,inbound_id,container_id,fee_item_name,cost_amount,currency_code,status,created_at FROM fin_cost";
Object[] costArgs = new Object[0];
if (customerId != null) { costSql += " WHERE tenant_id=?"; costArgs = new Object[]{customerId}; }
costSql += " ORDER BY created_at DESC,id DESC";
result.addAll(jdbc.query(costSql, (rs, rowNum) ->
new FeePoolItem("PAYABLE:" + rs.getLong("id"), rs.getLong("tenant_id"), "PAYABLE", "MANUAL_COST", rs.getLong("id"), 1,
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), nullableLong(rs, "container_id"),
rs.getString("fee_item_name"), rs.getBigDecimal("cost_amount"), rs.getString("currency_code"), rs.getString("status"), time(rs, "created_at")), costArgs));
}
result.sort(Comparator.comparing(FeePoolItem::createdAt, Comparator.nullsLast(Comparator.reverseOrder())));
return List.copyOf(result);
}
@@ -140,7 +140,7 @@ public class FinanceSettlementService {
List<Object> args = new ArrayList<>();
args.addAll(ids);
List<Map<String, Object>> sources = jdbc.queryForList(
"SELECT id,customer_id,direction,currency_code,status,amount,fee_item_code,fee_item_name,source_snapshot," +
"SELECT id,customer_id,direction,currency_code,status,amount,fee_item_code,fee_item_name,settlement_mode,source_snapshot," +
"review_status," +
"CAST(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierId')) AS UNSIGNED) supplier_id," +
"JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierName')) supplier_name," +
@@ -152,6 +152,9 @@ public class FinanceSettlementService {
for (Map<String, Object> source : sources) {
Long supplierId = nullableNumber(source, "supplier_id");
if (supplierId == null || supplierId != request.supplierId()) throw error("FIN_409", "应付来源必须属于同一供应商");
if (!"ON_ACCOUNT".equals(text(source, "settlement_mode"))) {
throw error("FIN_409", "只有ON_ACCOUNT账期应付费用可以生成应付结算单");
}
if (!"PAYABLE".equals(text(source, "direction")) || !"AVAILABLE".equals(text(source, "status"))
|| !"APPROVED".equals(text(source, "review_status")) || decimal(source, "amount").signum() <= 0) {
throw error("FIN_409", "费用来源未审核、金额无效或状态不可用");
@@ -31,6 +31,16 @@ CALL finance_fee_source_add_column('fin_fee_source', 'fee_item_code',
"VARCHAR(64) NULL COMMENT '来源费用项目编码快照' AFTER container_id");
CALL finance_fee_source_add_column('fin_fee_source', 'source_hash',
"CHAR(64) NULL COMMENT 'Firstleg费用来源完整快照SHA-256哈希' AFTER currency_code");
CALL finance_fee_source_add_column('fin_fee_source', 'settlement_mode',
"VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT账期应付、DIRECT_PAID业务已付;RECEIVABLE为空' AFTER currency_code");
CALL finance_fee_source_add_column('fin_fee_source', 'paid_at',
"DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间' AFTER settlement_mode");
CALL finance_fee_source_add_column('fin_fee_source', 'payment_method',
"VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式' AFTER paid_at");
CALL finance_fee_source_add_column('fin_fee_source', 'payment_reference_no',
"VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号' AFTER payment_method");
CALL finance_fee_source_add_column('fin_fee_source', 'payment_voucher_file_ids',
"JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组' AFTER payment_reference_no");
CALL finance_fee_source_add_column('fin_fee_source', 'source_snapshot',
"JSON NULL COMMENT 'Firstleg费用来源完整不可变JSON快照' AFTER source_hash");
CALL finance_fee_source_add_column('fin_fee_source', 'review_status',
@@ -90,8 +100,9 @@ BEGIN
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fin_fee_source'
AND column_name IN ('source_type','source_no','direction','container_id','fee_item_code',
'source_hash','source_snapshot','review_status','review_reason',
'operation_version','updated_at')) <> 11 THEN
'settlement_mode','paid_at','payment_method','payment_reference_no',
'payment_voucher_file_ids','source_hash','source_snapshot','review_status','review_reason',
'operation_version','updated_at')) <> 16 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance费用来源迁移失败:来源快照字段不完整';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
+15 -1
View File
@@ -18,6 +18,11 @@ CREATE TABLE IF NOT EXISTS fin_fee_source (
fee_item_name VARCHAR(200) NOT NULL COMMENT '来源费用项目名称快照',
amount DECIMAL(20,6) NOT NULL COMMENT '来源费用金额,精度6位小数',
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
settlement_mode VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT账期应付、DIRECT_PAID业务已付;RECEIVABLE为空',
paid_at DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间',
payment_method VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式:ONLINE、BANK_TRANSFER、CASH、OTHER',
payment_reference_no VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号',
payment_voucher_file_ids JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组',
source_hash CHAR(64) NOT NULL COMMENT 'Firstleg费用来源完整快照SHA-256哈希',
source_snapshot JSON NOT NULL COMMENT 'Firstleg费用来源完整不可变JSON快照',
review_status VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT 'Finance费用状态:DRAFT草稿、PENDING待审核、APPROVED已通过、REJECTED已驳回',
@@ -30,7 +35,16 @@ CREATE TABLE IF NOT EXISTS fin_fee_source (
UNIQUE KEY uk_fin_fee_source (source_system, source_fee_id, source_fee_version),
KEY idx_fin_fee_source_customer (customer_id, status),
CONSTRAINT chk_fin_fee_source_currency CHECK (currency_code = 'CNY'),
CONSTRAINT chk_fin_fee_source_amount CHECK (amount >= 0)
CONSTRAINT chk_fin_fee_source_amount CHECK (amount >= 0),
CONSTRAINT chk_fin_fee_source_settlement CHECK (
(direction='RECEIVABLE' AND settlement_mode IS NULL AND paid_at IS NULL AND payment_method IS NULL
AND payment_reference_no IS NULL AND payment_voucher_file_ids IS NULL)
OR (direction='PAYABLE' AND settlement_mode='ON_ACCOUNT' AND paid_at IS NULL AND payment_method IS NULL
AND payment_reference_no IS NULL AND payment_voucher_file_ids IS NULL)
OR (direction='PAYABLE' AND settlement_mode='DIRECT_PAID' AND paid_at IS NOT NULL
AND payment_method IN ('ONLINE','BANK_TRANSFER','CASH','OTHER')
AND (payment_reference_no IS NOT NULL OR JSON_LENGTH(payment_voucher_file_ids)>0))
)
) ENGINE=InnoDB COMMENT='Firstleg费用来源快照池;账单人工选择并固化明细';
CREATE TABLE IF NOT EXISTS fin_inbox_event (
@@ -36,7 +36,7 @@ class FinanceFeePoolDirectionBehaviorTest {
assertThat(result).isNotEmpty().allMatch(item -> "PAYABLE".equals(item.direction()));
assertThat(jdbc.sourceDirections).containsExactly("PAYABLE");
assertThat(jdbc.costQueryCount).isEqualTo(1);
assertThat(jdbc.costQueryCount).isZero();
}
private static final class DirectionJdbc extends JdbcTemplate {
@@ -60,7 +60,8 @@ class FinanceFeePoolDirectionBehaviorTest {
private static FeePoolItem item(String direction, String sourceType, long sourceId) {
return new FeePoolItem(direction + ":" + sourceId, 7L, direction, sourceType, sourceId, 1,
11L, 21L, null, "清关费", new BigDecimal("100.00"), "CNY", "AVAILABLE",
11L, 21L, null, "清关费", new BigDecimal("100.00"), "CNY",
"PAYABLE".equals(direction) ? "ON_ACCOUNT" : null, null, null, null, List.of(), "AVAILABLE",
LocalDateTime.of(2026, 8, 11, 1, 0));
}
}
@@ -70,7 +70,8 @@ class FinanceFeeSourceEventServiceTest {
givenNewEvent();
var snapshot = new FirstlegFeeSourceClient.Snapshot(7, "CONTAINER_FEE", 31, 1,
null, null, null, 41L, 61L, "C-41", 7, "客户", 71L, "现有供应商",
"TRUCKING", 61L, "TRUCKING", "拖车费", "PAYABLE", LocalDate.of(2026, 8, 11),
"TRUCKING", "ON_ACCOUNT", null, null, null, List.of(),
61L, "TRUCKING", "拖车费", "PAYABLE", LocalDate.of(2026, 8, 11),
"CNY", new BigDecimal("300.00"), "APPROVED", "PUBLISHED",
LocalDateTime.of(2026, 8, 11, 1, 0), null, null, null, null,
"source-hash-payable", 1);
@@ -81,7 +82,7 @@ class FinanceFeeSourceEventServiceTest {
verify(supplierValidator).requireActive(71L, "TRUCKING");
ArgumentCaptor<Object[]> values = ArgumentCaptor.forClass(Object[].class);
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), values.capture());
assertThat(values.getValue()[14].toString())
assertThat(values.getValue()[19].toString())
.contains("\"supplierId\":71", "\"supplierName\":\"现有供应商\"", "\"supplierCategory\":\"TRUCKING\"");
}
@@ -91,7 +92,8 @@ class FinanceFeeSourceEventServiceTest {
givenNewEvent();
var snapshot = new FirstlegFeeSourceClient.Snapshot(7, "CONTAINER_FEE", 31, 1,
null, null, null, 41L, 61L, "C-41", 7, "客户", 71L, "现有供应商",
null, 61L, "STORAGE", "仓储费", "PAYABLE", LocalDate.of(2026, 8, 11),
null, "ON_ACCOUNT", null, null, null, List.of(),
61L, "STORAGE", "仓储费", "PAYABLE", LocalDate.of(2026, 8, 11),
"CNY", new BigDecimal("300.00"), "APPROVED", "PUBLISHED",
LocalDateTime.of(2026, 8, 11, 1, 0), null, null, null, null,
"source-hash-unrestricted", 1);
@@ -101,6 +103,28 @@ class FinanceFeeSourceEventServiceTest {
verify(supplierValidator).requireActive(71L, null);
}
@Test
void importsDirectPaidSnapshotWithoutTreatingItAsOnAccount() {
FinanceFeeSourceEvent event = containerEvent("container-direct-paid-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
givenNewEvent();
var snapshot = new FirstlegFeeSourceClient.Snapshot(7, "CONTAINER_FEE", 31, 1,
null, null, null, 41L, 61L, "C-41", 7, "客户", 71L, "现有供应商",
"TRUCKING", "DIRECT_PAID", LocalDateTime.of(2026, 8, 11, 10, 0),
"BANK_TRANSFER", "BANK-001", List.of(901L), 61L, "TRUCKING", "拖车费",
"PAYABLE", LocalDate.of(2026, 8, 11), "CNY", new BigDecimal("300.00"),
"APPROVED", "PUBLISHED", LocalDateTime.of(2026, 8, 11, 11, 0),
null, null, null, null, "source-hash-direct", 1);
when(firstleg.container("test-token", 41, 31, 1)).thenReturn(ApiResponse.success(snapshot));
assertThat(service.consume(event)).isEqualTo(101L);
ArgumentCaptor<Object[]> values = ArgumentCaptor.forClass(Object[].class);
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), values.capture());
assertThat(values.getValue()[13]).isEqualTo("DIRECT_PAID");
assertThat(values.getValue()[15]).isEqualTo("BANK_TRANSFER");
assertThat(values.getValue()[17]).isEqualTo("[901]");
}
@Test
void adjustmentCreatesNewImmutableVersionAndSupersedesAvailableVersion() {
FinanceFeeSourceEvent event = inboundEvent("adjusted-2", "FIRSTLEG_FEE_ADJUSTED", 2, 7);
@@ -216,7 +240,8 @@ class FinanceFeeSourceEventServiceTest {
String publicationStatus, BigDecimal amount) {
return new FirstlegFeeSourceClient.Snapshot(customerId, sourceType, 31, version, inboundId,
inboundId == null ? null : "IB-21", 51L, containerId, null,
containerId == null ? null : "C-41", customerId, "客户", null, null, null, 61L, "CUSTOMS",
containerId == null ? null : "C-41", customerId, "客户", null, null, null,
null, null, null, null, List.of(), 61L, "CUSTOMS",
"清关费", "RECEIVABLE", LocalDate.of(2026, 8, 11), "CNY", amount,
"APPROVED", publicationStatus, LocalDateTime.of(2026, 8, 11, 1, 0),
BigDecimal.ONE, "SHIPMENT", BigDecimal.TEN, BigDecimal.ONE, "source-hash", version);
@@ -14,6 +14,8 @@ class FinanceFeeSourceMigrationContractTest {
String sql = Files.readString(Path.of("src/main/resources/db/fee-source-event-migration.sql"), StandardCharsets.UTF_8);
assertThat(sql).contains("information_schema.columns", "source_type", "direction", "container_id",
"source_hash", "source_snapshot", "payload_hash", "result_resource_id", "error_code");
assertThat(sql).contains("settlement_mode", "paid_at", "payment_method",
"payment_reference_no", "payment_voucher_file_ids");
assertThat(sql).contains("AVAILABLE可结算", "SUPERSEDED已被新版本替代", "VOIDED已作废");
assertThat(sql).contains("finance_fee_source_assert_schema");
}
@@ -68,7 +68,8 @@ class FinancePlatformListContractTest {
void allCustomerFeePoolResponseCarriesCustomerIdentity() {
assertThat(Arrays.stream(com.baiyue.finance.dto.FinanceReadModels.FeePoolItem.class
.getRecordComponents()).map(java.lang.reflect.RecordComponent::getName))
.contains("customerId");
.contains("customerId", "settlementMode", "paidAt", "paymentMethod",
"paymentReferenceNo", "paymentVoucherFileIds");
}
@Test
@@ -396,6 +396,22 @@ class FinanceSettlementBehaviorTest {
verify(jdbc, never()).update(any(PreparedStatementCreator.class), any(KeyHolder.class));
}
@Test
void directPaidSourceCannotEnterPayableSettlement() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L),
eq("SETTLEMENT_AP_CREATE"), eq("ap-direct"))).thenReturn(List.of());
Map<String, Object> source = payableSource(10L, 20L);
source.put("settlement_mode", "DIRECT_PAID");
when(jdbc.queryForList(startsWith("SELECT id,customer_id"), eq(31L))).thenReturn(List.of(source));
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
assertCode(() -> service.createPayableSettlement(7L, "ap-direct",
new CreatePayableSettlementRequest(10L, 20L, List.of(31L),
LocalDate.of(2026, 9, 30), "CNY")), "FIN_409");
verify(jdbc, never()).update(startsWith("INSERT INTO fin_settlement"), any(Object[].class));
}
@Test
void payableCreationRejectsMixedSupplierIneligibleAndOccupiedSources() {
for (String condition : List.of("supplier", "ineligible", "occupied")) {
@@ -649,6 +665,7 @@ class FinanceSettlementBehaviorTest {
Map.entry("id", 31L), Map.entry("customer_id", customerId), Map.entry("direction", "PAYABLE"),
Map.entry("currency_code", "CNY"), Map.entry("status", "AVAILABLE"),
Map.entry("amount", new BigDecimal("100.00")), Map.entry("review_status", "APPROVED"),
Map.entry("settlement_mode", "ON_ACCOUNT"),
Map.entry("supplier_id", supplierId),
Map.entry("fee_item_code", "FBA_HEAD_FREIGHT"), Map.entry("fee_item_name", "头程运费"),
Map.entry("supplier_name", "供应商20"), Map.entry("source_snapshot",
@@ -44,7 +44,7 @@ class FinanceSqlCommentContractTest {
}
}
assertThat(tableCount).isEqualTo(17);
assertThat(columnCount).isEqualTo(243);
assertThat(columnCount).isEqualTo(248);
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
}
@@ -55,6 +55,7 @@ class FinanceSqlCommentContractTest {
assertThat(sql).contains("AVAILABLE可结算、SUPERSEDED已被新版本替代、VOIDED已作废");
assertThat(sql).contains("RECEIVED已接收、PROCESSED已完成、FAILED处理失败");
assertThat(sql).contains("Firstleg费用来源完整不可变JSON快照");
assertThat(sql).contains("ON_ACCOUNT账期应付、DIRECT_PAID业务已付");
assertThat(sql).contains("规范化完整请求的SHA-256十六进制哈希");
}
@@ -0,0 +1,20 @@
package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
@FeignClient(name = "file-service", contextId = "firstlegFeePaymentVoucherFileClient",
url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/business-files")
public interface FeePaymentVoucherFileClient {
@GetMapping("/{tenantId}/FINANCE_VOUCHER/{customerId}/{fileId}")
ApiResponse<Metadata> metadata(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId,
@PathVariable long customerId,
@PathVariable long fileId);
record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
String fileName, String sha256, String contentType, Long sizeBytes) {}
}
@@ -9,6 +9,8 @@ import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
public final class ContainerFeeDetailDtos {
private ContainerFeeDetailDtos() {
@@ -21,8 +23,18 @@ public final class ContainerFeeDetailDtos {
@NotNull @DecimalMin("0.00") BigDecimal amount,
@NotBlank @Pattern(regexp = "CNY") String currency,
Long supplierId,
@Pattern(regexp = "ON_ACCOUNT|DIRECT_PAID") String settlementMode,
LocalDateTime paidAt,
@Pattern(regexp = "ONLINE|BANK_TRANSFER|CASH|OTHER") String paymentMethod,
@Size(max = 128) String paymentReferenceNo,
@Size(max = 20) List<@Min(1) Long> paymentVoucherFileIds,
@Size(max = 500) String remark
) {
public SaveRequest(Long feeItemId, String direction, LocalDate businessDate,
BigDecimal amount, String currency, Long supplierId, String remark) {
this(feeItemId, direction, businessDate, amount, currency, supplierId,
null, null, null, null, null, remark);
}
}
public record AdjustRequest(
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.dto;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** Finance 回查的 Firstleg 费用来源只读快照。 */
public record FinanceFeeSourceSnapshot(
@@ -21,6 +22,11 @@ public record FinanceFeeSourceSnapshot(
Long supplierId,
String supplierName,
String supplierCategory,
String settlementMode,
LocalDateTime paidAt,
String paymentMethod,
String paymentReferenceNo,
List<Long> paymentVoucherFileIds,
Long feeItemId,
String feeItemCode,
String feeItemName,
@@ -8,6 +8,8 @@ import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** 入库单费用明细请求模型。 */
public final class InboundFeeDetailDtos {
@@ -22,12 +24,18 @@ public final class InboundFeeDetailDtos {
@NotBlank @Pattern(regexp = "[A-Z]{3}") String currency,
@NotNull @DecimalMin("0.00") BigDecimal amount,
Long supplierId,
@Pattern(regexp = "ON_ACCOUNT|DIRECT_PAID") String settlementMode,
LocalDateTime paidAt,
@Pattern(regexp = "ONLINE|BANK_TRANSFER|CASH|OTHER") String paymentMethod,
@Size(max = 128) String paymentReferenceNo,
@Size(max = 20) List<@jakarta.validation.constraints.Min(1) Long> paymentVoucherFileIds,
@Size(max = 500) String remark
) {
public SaveRequest(Long feeItemId, String direction, LocalDate businessDate,
BigDecimal unitPrice, String currency, BigDecimal amount,
String remark) {
this(feeItemId, direction, businessDate, unitPrice, currency, amount, null, remark);
this(feeItemId, direction, businessDate, unitPrice, currency, amount,
null, null, null, null, null, null, remark);
}
}
@@ -3,17 +3,20 @@ package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** 柜次级人工应收/应付费用来源。 */
@Getter
@Setter
@TableName("fl_container_fee_detail")
@TableName(value = "fl_container_fee_detail", autoResultMap = true)
public class ContainerFeeDetailEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
@@ -28,6 +31,12 @@ public class ContainerFeeDetailEntity {
private Long supplierId;
private String supplierName;
private String supplierCategory;
private String settlementMode;
private LocalDateTime paidAt;
private String paymentMethod;
private String paymentReferenceNo;
@TableField(typeHandler = JacksonTypeHandler.class)
private List<Long> paymentVoucherFileIds;
private LocalDate businessDate;
private BigDecimal amount;
private String currency;
@@ -3,17 +3,20 @@ package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** 柜次费用审核发布时固化的不可变版本快照。 */
@Getter
@Setter
@TableName("fl_container_fee_source_snapshot")
@TableName(value = "fl_container_fee_source_snapshot", autoResultMap = true)
public class ContainerFeeSourceSnapshotEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
@@ -30,6 +33,12 @@ public class ContainerFeeSourceSnapshotEntity {
private Long supplierId;
private String supplierName;
private String supplierCategory;
private String settlementMode;
private LocalDateTime paidAt;
private String paymentMethod;
private String paymentReferenceNo;
@TableField(typeHandler = JacksonTypeHandler.class)
private List<Long> paymentVoucherFileIds;
private LocalDate businessDate;
private BigDecimal amount;
private String currency;
@@ -3,6 +3,8 @@ package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import com.baiyue.common.core.model.BaseEntity;
import lombok.Getter;
import lombok.Setter;
@@ -10,11 +12,12 @@ import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** 入库单人工费用明细及业务快照。 */
@Getter
@Setter
@TableName("fl_inbound_fee_detail")
@TableName(value = "fl_inbound_fee_detail", autoResultMap = true)
public class InboundFeeDetailEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
@@ -30,6 +33,12 @@ public class InboundFeeDetailEntity extends BaseEntity {
private Long supplierId;
private String supplierName;
private String supplierCategory;
private String settlementMode;
private LocalDateTime paidAt;
private String paymentMethod;
private String paymentReferenceNo;
@TableField(typeHandler = JacksonTypeHandler.class)
private List<Long> paymentVoucherFileIds;
private LocalDate businessDate;
private Long customerId;
private String customerName;
@@ -29,6 +29,11 @@ public class ContainerFeeSourceSnapshotRepository {
value.setSupplierId(source.getSupplierId());
value.setSupplierName(source.getSupplierName());
value.setSupplierCategory(source.getSupplierCategory());
value.setSettlementMode(source.getSettlementMode());
value.setPaidAt(source.getPaidAt());
value.setPaymentMethod(source.getPaymentMethod());
value.setPaymentReferenceNo(source.getPaymentReferenceNo());
value.setPaymentVoucherFileIds(source.getPaymentVoucherFileIds());
value.setBusinessDate(source.getBusinessDate());
value.setAmount(source.getAmount());
value.setCurrency(source.getCurrency());
@@ -29,6 +29,7 @@ public class ContainerFeeDetailService {
private final FinanceFeeSourceOutboxService financeOutbox;
private final WarehouseSupplierSnapshotService suppliers;
private final ContainerFeeSourceSnapshotRepository sourceSnapshots;
private final FeePaymentSnapshotValidator paymentSnapshots;
public List<ContainerFeeDetailEntity> list(long tenantId, long loadingPlanId) {
requireOwnedPlan(tenantId, loadingPlanId);
@@ -60,6 +61,7 @@ public class ContainerFeeDetailService {
long reviewerId, String reviewerName) {
ContainerFeeDetailEntity pending = required(tenantId, loadingPlanId, feeId);
FeeItemEntity item = requireItem(pending.getFeeItemId(), pending.getDirection());
revalidatePaymentSnapshot(pending);
refreshSupplierSnapshot(pending, item, reviewerId);
if (repository.review(tenantId, loadingPlanId, feeId, "APPROVED",
reviewerId, reviewerName, null) != 1) {
@@ -152,6 +154,8 @@ public class ContainerFeeDetailService {
value.setFeeItemNameSnapshot(item.getFeeTypeName());
value.setDirection(request.direction());
applySupplierSnapshot(value, item, request.direction(), request.supplierId());
applyPaymentSnapshot(value, request.direction(), request.settlementMode(), request.paidAt(),
request.paymentMethod(), request.paymentReferenceNo(), request.paymentVoucherFileIds());
value.setBusinessDate(request.businessDate());
value.setAmount(request.amount().setScale(2, RoundingMode.HALF_UP));
value.setCurrency(request.currency());
@@ -188,6 +192,25 @@ public class ContainerFeeDetailService {
}
}
private void revalidatePaymentSnapshot(ContainerFeeDetailEntity value) {
applyPaymentSnapshot(value, value.getDirection(), value.getSettlementMode(), value.getPaidAt(),
value.getPaymentMethod(), value.getPaymentReferenceNo(), value.getPaymentVoucherFileIds());
}
private void applyPaymentSnapshot(ContainerFeeDetailEntity value, String direction,
String settlementMode, LocalDateTime paidAt,
String paymentMethod, String paymentReferenceNo,
List<Long> paymentVoucherFileIds) {
var snapshot = paymentSnapshots.validate(value.getTenantId(), direction, settlementMode,
paidAt, paymentMethod, paymentReferenceNo, paymentVoucherFileIds);
value.setSettlementMode(snapshot.settlementMode());
value.setPaidAt(snapshot.paidAt());
value.setPaymentMethod(snapshot.paymentMethod());
value.setPaymentReferenceNo(snapshot.paymentReferenceNo());
value.setPaymentVoucherFileIds(snapshot.paymentVoucherFileIds().isEmpty()
? null : snapshot.paymentVoucherFileIds());
}
private static BizException conflict(String message) {
return new BizException(ResultCode.CONFLICT, message);
}
@@ -0,0 +1,101 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.FeePaymentVoucherFileClient;
import feign.FeignException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
@Service
public class FeePaymentSnapshotValidator {
private static final Set<String> MODES = Set.of("ON_ACCOUNT", "DIRECT_PAID");
private static final Set<String> METHODS = Set.of("ONLINE", "BANK_TRANSFER", "CASH", "OTHER");
private static final String VOUCHER_TYPE = "FINANCE_VOUCHER";
private final FeePaymentVoucherFileClient files;
private final String internalToken;
public FeePaymentSnapshotValidator(FeePaymentVoucherFileClient files,
@Value("${baiyue.security.internal-service-token}") String internalToken) {
this.files = files;
this.internalToken = internalToken;
}
public Snapshot validate(long customerId, String direction, String settlementMode,
LocalDateTime paidAt, String paymentMethod,
String paymentReferenceNo, List<Long> paymentVoucherFileIds) {
String mode = trim(settlementMode);
String method = trim(paymentMethod);
String reference = trim(paymentReferenceNo);
List<Long> vouchers = normalizeVouchers(paymentVoucherFileIds);
if ("RECEIVABLE".equals(direction)) {
if (mode != null || paidAt != null || method != null || reference != null || !vouchers.isEmpty()) {
throw bad("应收费用不得携带结算方式或已付信息");
}
return Snapshot.empty();
}
if (!"PAYABLE".equals(direction) || !MODES.contains(mode)) {
throw bad("应付费用必须选择ON_ACCOUNT或DIRECT_PAID");
}
if ("ON_ACCOUNT".equals(mode)) {
if (paidAt != null || method != null || reference != null || !vouchers.isEmpty()) {
throw bad("账期应付不得携带已付时间、方式、流水号或凭证");
}
return new Snapshot(mode, null, null, null, List.of());
}
if (paidAt == null || !METHODS.contains(method)) {
throw bad("直接已付必须填写已付时间和有效付款方式");
}
if (reference == null && vouchers.isEmpty()) {
throw bad("直接已付必须填写付款流水号或至少一个付款凭证");
}
vouchers.forEach(fileId -> validateVoucher(customerId, fileId));
return new Snapshot(mode, paidAt, method, reference, vouchers);
}
private void validateVoucher(long customerId, long fileId) {
try {
var response = files.metadata(internalToken, customerId, customerId, fileId);
var metadata = response == null ? null : response.data();
if (response == null || !response.successful() || metadata == null
|| !Long.valueOf(fileId).equals(metadata.fileId())
|| !Long.valueOf(customerId).equals(metadata.tenantId())
|| !VOUCHER_TYPE.equals(metadata.businessType())
|| !String.valueOf(customerId).equals(metadata.businessId())) {
throw bad("付款凭证不存在或不属于当前客户");
}
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用,无法校验付款凭证");
}
}
private static List<Long> normalizeVouchers(List<Long> values) {
if (values == null || values.isEmpty()) return List.of();
if (values.size() > 20 || values.stream().anyMatch(value -> value == null || value <= 0)) {
throw bad("付款凭证文件ID无效或超过20个");
}
return List.copyOf(new LinkedHashSet<>(values));
}
private static String trim(String value) {
return StringUtils.hasText(value) ? value.trim() : null;
}
private static BizException bad(String message) {
return new BizException(ResultCode.BAD_REQUEST, message);
}
public record Snapshot(String settlementMode, LocalDateTime paidAt, String paymentMethod,
String paymentReferenceNo, List<Long> paymentVoucherFileIds) {
static Snapshot empty() {
return new Snapshot(null, null, null, null, List.of());
}
}
}
@@ -40,12 +40,17 @@ public class FinanceFeeSourceSnapshotService {
String.valueOf(inbound.getOrderId()), nullSafe(item.getFeeCode()),
nullSafe(detail.getDirection()), nullSafe(detail.getSupplierId()),
nullSafe(detail.getSupplierName()), nullSafe(detail.getSupplierCategory()),
nullSafe(detail.getSettlementMode()), nullSafe(detail.getPaidAt()),
nullSafe(detail.getPaymentMethod()), nullSafe(detail.getPaymentReferenceNo()),
nullSafe(detail.getPaymentVoucherFileIds()),
detail.getAmount().toPlainString(),
nullSafe(detail.getCurrency()));
return new FinanceFeeSourceSnapshot(detail.getTenantId(), "INBOUND_FEE", sourceFeeId,
version, inboundId, inbound.getInboundCode(), inbound.getOrderId(), null, null, null,
detail.getTenantId(), inbound.getCustomerName(), detail.getSupplierId(),
detail.getSupplierName(), detail.getSupplierCategory(), item.getId(), item.getFeeCode(),
detail.getSupplierName(), detail.getSupplierCategory(), detail.getSettlementMode(),
detail.getPaidAt(), detail.getPaymentMethod(), detail.getPaymentReferenceNo(),
detail.getPaymentVoucherFileIds(), item.getId(), item.getFeeCode(),
detail.getFeeTypeNameSnapshot(), detail.getDirection(), detail.getBusinessDate(),
detail.getCurrency(), detail.getAmount(), detail.getReviewStatus(),
detail.getPublicationStatus(),
@@ -62,12 +67,17 @@ public class FinanceFeeSourceSnapshotService {
nullSafe(detail.getFeeItemCode()), nullSafe(detail.getDirection()),
nullSafe(detail.getSupplierId()), nullSafe(detail.getSupplierName()),
nullSafe(detail.getSupplierCategory()),
nullSafe(detail.getSettlementMode()), nullSafe(detail.getPaidAt()),
nullSafe(detail.getPaymentMethod()), nullSafe(detail.getPaymentReferenceNo()),
nullSafe(detail.getPaymentVoucherFileIds()),
detail.getAmount().toPlainString(), nullSafe(detail.getCurrency()),
nullSafe(detail.getReviewStatus()));
return new FinanceFeeSourceSnapshot(detail.getTenantId(), "CONTAINER_FEE", feeId,
version, null, null, null, containerId, detail.getContainerBookingId(),
detail.getContainerNo(), detail.getTenantId(), null, detail.getSupplierId(),
detail.getSupplierName(), detail.getSupplierCategory(), detail.getFeeItemId(),
detail.getSupplierName(), detail.getSupplierCategory(), detail.getSettlementMode(),
detail.getPaidAt(), detail.getPaymentMethod(), detail.getPaymentReferenceNo(),
detail.getPaymentVoucherFileIds(), detail.getFeeItemId(),
detail.getFeeItemCode(), detail.getFeeItemName(), detail.getDirection(),
detail.getBusinessDate(), detail.getCurrency(), detail.getAmount(), detail.getReviewStatus(),
"PUBLISHED",
@@ -31,6 +31,8 @@ public class InboundFeeDetailService {
private FinanceFeeSourceOutboxService financeOutbox;
@Resource
private WarehouseSupplierSnapshotService suppliers;
@Resource
private FeePaymentSnapshotValidator paymentSnapshots;
@Transactional
public List<InboundFeeDetailEntity> list(long tenantId, long inboundId, boolean includeHistory) {
@@ -255,6 +257,7 @@ public class InboundFeeDetailService {
long reviewerId, String reviewerName) {
InboundFeeDetailEntity pending = requiredManual(tenantId, inboundId, detailId);
FeeItemEntity item = requiredActiveItem(pending.getFeeItemId());
revalidatePaymentSnapshot(pending);
refreshSupplierSnapshot(pending, item, reviewerId);
if (repository.review(tenantId, inboundId, detailId, "APPROVED",
reviewerId, reviewerName, null) != 1) {
@@ -381,6 +384,8 @@ public class InboundFeeDetailService {
value.setInboundCode(inbound.getInboundCode());
value.setDirection(request.direction());
applySupplierSnapshot(value, item, request.direction(), request.supplierId());
applyPaymentSnapshot(value, request.direction(), request.settlementMode(), request.paidAt(),
request.paymentMethod(), request.paymentReferenceNo(), request.paymentVoucherFileIds());
value.setBusinessDate(request.businessDate());
value.setFeeCategorySnapshot(item.getCategory());
value.setTradeScopeSnapshot(item.getTradeScope());
@@ -430,6 +435,25 @@ public class InboundFeeDetailService {
}
}
private void revalidatePaymentSnapshot(InboundFeeDetailEntity value) {
applyPaymentSnapshot(value, value.getDirection(), value.getSettlementMode(), value.getPaidAt(),
value.getPaymentMethod(), value.getPaymentReferenceNo(), value.getPaymentVoucherFileIds());
}
private void applyPaymentSnapshot(InboundFeeDetailEntity value, String direction,
String settlementMode, LocalDateTime paidAt,
String paymentMethod, String paymentReferenceNo,
List<Long> paymentVoucherFileIds) {
var snapshot = paymentSnapshots.validate(value.getTenantId(), direction, settlementMode,
paidAt, paymentMethod, paymentReferenceNo, paymentVoucherFileIds);
value.setSettlementMode(snapshot.settlementMode());
value.setPaidAt(snapshot.paidAt());
value.setPaymentMethod(snapshot.paymentMethod());
value.setPaymentReferenceNo(snapshot.paymentReferenceNo());
value.setPaymentVoucherFileIds(snapshot.paymentVoucherFileIds().isEmpty()
? null : snapshot.paymentVoucherFileIds());
}
private static BizException conflict(String message) {
return new BizException(ResultCode.CONFLICT, message);
}
@@ -74,6 +74,11 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_detail (
supplier_id BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空',
supplier_name VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空',
supplier_category VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商',
settlement_mode VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT账期应付、DIRECT_PAID业务已付;RECEIVABLE为空',
paid_at DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间',
payment_method VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式',
payment_reference_no VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号',
payment_voucher_file_ids JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组',
business_date DATE NOT NULL COMMENT '费用业务日期',
amount DECIMAL(18,2) NOT NULL COMMENT 'CNY费用金额',
currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '币种,首期固定CNY',
@@ -96,6 +101,15 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_detail (
CONSTRAINT chk_container_fee_currency CHECK (currency = 'CNY'),
CONSTRAINT chk_container_fee_amount CHECK (amount >= 0),
CONSTRAINT chk_container_fee_review CHECK (review_status IN ('PENDING', 'APPROVED', 'REJECTED')),
CONSTRAINT chk_container_fee_settlement CHECK (
(direction='RECEIVABLE' AND settlement_mode IS NULL AND paid_at IS NULL AND payment_method IS NULL
AND payment_reference_no IS NULL AND payment_voucher_file_ids IS NULL)
OR (direction='PAYABLE' AND settlement_mode='ON_ACCOUNT' AND paid_at IS NULL AND payment_method IS NULL
AND payment_reference_no IS NULL AND payment_voucher_file_ids IS NULL)
OR (direction='PAYABLE' AND settlement_mode='DIRECT_PAID' AND paid_at IS NOT NULL
AND payment_method IN ('ONLINE','BANK_TRANSFER','CASH','OTHER')
AND (payment_reference_no IS NOT NULL OR JSON_LENGTH(payment_voucher_file_ids)>0))
),
CONSTRAINT chk_container_fee_source CHECK (source_status IN ('ACTIVE', 'VOIDED'))
) ENGINE=InnoDB COMMENT='柜次级人工应收应付费用来源;审核通过后自动同步Finance';
@@ -114,6 +128,11 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_source_snapshot (
supplier_id BIGINT UNSIGNED NULL COMMENT 'Warehouse供应商ID快照',
supplier_name VARCHAR(200) NULL COMMENT 'Warehouse供应商名称快照',
supplier_category VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商',
settlement_mode VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT账期应付、DIRECT_PAID业务已付;RECEIVABLE为空',
paid_at DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间',
payment_method VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式',
payment_reference_no VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号',
payment_voucher_file_ids JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组',
business_date DATE NOT NULL COMMENT '费用业务日期',
amount DECIMAL(18,2) NOT NULL COMMENT 'CNY费用金额',
currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '币种,首期固定CNY',
@@ -157,12 +176,42 @@ CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_name',
"VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空' AFTER supplier_id");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_category',
"VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商' AFTER supplier_name");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'settlement_mode',
"VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT或DIRECT_PAID' AFTER supplier_category");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'paid_at',
"DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间' AFTER settlement_mode");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'payment_method',
"VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式' AFTER paid_at");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'payment_reference_no',
"VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号' AFTER payment_method");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'payment_voucher_file_ids',
"JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组' AFTER payment_reference_no");
CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'settlement_mode',
"VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT或DIRECT_PAID' AFTER supplier_category");
CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'paid_at',
"DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间' AFTER settlement_mode");
CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'payment_method',
"VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式' AFTER paid_at");
CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'payment_reference_no',
"VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号' AFTER payment_method");
CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'payment_voucher_file_ids',
"JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组' AFTER payment_reference_no");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id',
"BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空' AFTER direction");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_name',
"VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空' AFTER supplier_id");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_category',
"VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商' AFTER supplier_name");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'settlement_mode',
"VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT或DIRECT_PAID' AFTER supplier_category");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'paid_at',
"DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间' AFTER settlement_mode");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'payment_method',
"VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式' AFTER paid_at");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'payment_reference_no',
"VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号' AFTER payment_method");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'payment_voucher_file_ids',
"JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组' AFTER payment_reference_no");
DROP PROCEDURE IF EXISTS firstleg_finance_add_column;
DROP PROCEDURE IF EXISTS firstleg_finance_assert_schema;
@@ -185,12 +234,22 @@ BEGIN
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fl_container_fee_detail'
AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category')) <> 3 THEN
AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category', 'settlement_mode',
'paid_at', 'payment_method', 'payment_reference_no',
'payment_voucher_file_ids')) <> 8 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:柜次费用供应商快照字段不完整';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fl_container_fee_source_snapshot'
AND column_name IN ('settlement_mode', 'paid_at', 'payment_method',
'payment_reference_no', 'payment_voucher_file_ids')) <> 5 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:柜次费用已付版本快照字段不完整';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_fee_detail'
AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category')) <> 3 THEN
AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category', 'settlement_mode',
'paid_at', 'payment_method', 'payment_reference_no',
'payment_voucher_file_ids')) <> 8 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:入库费用供应商快照字段不完整';
END IF;
END$$
@@ -40,6 +40,11 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee_detail (
supplier_id BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空',
supplier_name VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空',
supplier_category VARCHAR(16) NULL COMMENT '费用项目供应商类别快照;应收为空',
settlement_mode VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT账期应付、DIRECT_PAID业务已付;RECEIVABLE为空',
paid_at DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间',
payment_method VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式',
payment_reference_no VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号',
payment_voucher_file_ids JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组',
business_date DATE NOT NULL COMMENT '业务发生日',
fee_category_snapshot VARCHAR(64) NOT NULL COMMENT '费用分类快照',
trade_scope_snapshot VARCHAR(20) NOT NULL COMMENT '国内国际快照',
@@ -73,6 +78,15 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee_detail (
KEY idx_inbound_fee_detail (tenant_id, inbound_id, business_date, deleted),
KEY idx_inbound_fee_review (tenant_id, review_status, business_date, deleted),
KEY idx_inbound_fee_item (tenant_id, fee_item_id, deleted),
CONSTRAINT chk_inbound_fee_settlement CHECK (
(direction='RECEIVABLE' AND settlement_mode IS NULL AND paid_at IS NULL AND payment_method IS NULL
AND payment_reference_no IS NULL AND payment_voucher_file_ids IS NULL)
OR (direction='PAYABLE' AND settlement_mode='ON_ACCOUNT' AND paid_at IS NULL AND payment_method IS NULL
AND payment_reference_no IS NULL AND payment_voucher_file_ids IS NULL)
OR (direction='PAYABLE' AND settlement_mode='DIRECT_PAID' AND paid_at IS NOT NULL
AND payment_method IN ('ONLINE','BANK_TRANSFER','CASH','OTHER')
AND (payment_reference_no IS NOT NULL OR JSON_LENGTH(payment_voucher_file_ids)>0))
),
CONSTRAINT fk_inbound_fee_detail_order FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id),
CONSTRAINT fk_inbound_fee_detail_item FOREIGN KEY (fee_item_id) REFERENCES fl_fee_item (id)
) ENGINE=InnoDB COMMENT='入库单应收应付费用明细';
@@ -304,6 +304,11 @@ components:
currency: {type: string, pattern: '^[A-Z]{3}$'}
amount: {type: number, minimum: 0}
supplierId: {type: string, nullable: true}
settlementMode: {type: string, enum: [ON_ACCOUNT, DIRECT_PAID], nullable: true, description: 仅PAYABLE必填;ON_ACCOUNT账期应付,DIRECT_PAID业务已付}
paidAt: {type: string, format: date-time, nullable: true, description: DIRECT_PAID必填;ON_ACCOUNT与RECEIVABLE为空}
paymentMethod: {type: string, enum: [ONLINE, BANK_TRANSFER, CASH, OTHER], nullable: true, description: DIRECT_PAID必填}
paymentReferenceNo: {type: string, maxLength: 128, nullable: true}
paymentVoucherFileIds: {type: array, maxItems: 20, nullable: true, items: {type: string, pattern: '^[1-9][0-9]*$'}, description: DIRECT_PAID时与paymentReferenceNo至少一项;File按当前客户失败关闭校验}
remark: {type: string, maxLength: 500, nullable: true}
InboundFeeDetailVersionRequest:
type: object
@@ -331,6 +336,11 @@ components:
supplierId: {type: string, nullable: true}
supplierName: {type: string, nullable: true, readOnly: true}
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true, description: 费用项目供应商类别约束快照;为空表示仅要求供应商ACTIVE}
settlementMode: {type: string, enum: [ON_ACCOUNT, DIRECT_PAID], nullable: true}
paidAt: {type: string, format: date-time, nullable: true}
paymentMethod: {type: string, enum: [ONLINE, BANK_TRANSFER, CASH, OTHER], nullable: true}
paymentReferenceNo: {type: string, nullable: true}
paymentVoucherFileIds: {type: array, nullable: true, items: {type: string}}
businessDate: {type: string, format: date}
customerId: {type: string, nullable: true}
customerName: {type: string, nullable: true}
@@ -881,7 +881,7 @@ paths:
summary: 调整已审核柜次费用的日期和金额
description: >-
仅 ACTIVE、APPROVED 且 sourceVersion 匹配的费用可调整。成功后 sourceVersion 加一并重新进入
PENDING;费用方向、费用项目、供应商、币种和备注保持原快照。业务字段需要变化时,
PENDING;费用方向、费用项目、供应商、结算方式、已付快照、币种和备注保持原快照。业务字段需要变化时,
必须作废原费用并新增。同一 expectedSourceVersion、businessDate、amount 的原样重试只读返回已生成的
PENDING 版本;旧版本异参、已作废或非已审核状态返回 A0409 且零写入。
parameters:
@@ -1882,6 +1882,11 @@ components:
supplierId: {type: integer, format: int64, nullable: true}
supplierName: {type: string, nullable: true}
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true, description: PAYABLE供应商类别约束;为空时仅要求供应商ACTIVE}
settlementMode: {type: string, enum: [ON_ACCOUNT, DIRECT_PAID], nullable: true, description: 仅PAYABLE;ON_ACCOUNT可进入AP,DIRECT_PAID仅计成本且禁止进入AP}
paidAt: {type: string, format: date-time, nullable: true}
paymentMethod: {type: string, enum: [ONLINE, BANK_TRANSFER, CASH, OTHER], nullable: true}
paymentReferenceNo: {type: string, nullable: true}
paymentVoucherFileIds: {type: array, nullable: true, items: {type: integer, format: int64}}
feeItemId: {type: integer, format: int64, nullable: true}
feeItemCode: {type: string, nullable: true}
feeItemName: {type: string}
@@ -0,0 +1,20 @@
package com.baiyue.firstleg.client;
import org.junit.jupiter.api.Test;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import static org.assertj.core.api.Assertions.assertThat;
class FeePaymentVoucherFileClientContractTest {
@Test
void validatesFinanceVoucherWithinExactCustomerScope() throws Exception {
var method = FeePaymentVoucherFileClient.class.getMethod("metadata",
String.class, long.class, long.class, long.class);
assertThat(method.getAnnotation(GetMapping.class).value())
.containsExactly("/{tenantId}/FINANCE_VOUCHER/{customerId}/{fileId}");
assertThat(method.getParameters()[0].getAnnotation(RequestHeader.class).value())
.isEqualTo("X-Internal-Service-Token");
}
}
@@ -58,7 +58,7 @@ class FeeManagementContractTest {
.contains("ContainerFeeAdjustRequest:")
.contains("required: [expectedSourceVersion, businessDate, amount]")
.contains("additionalProperties: false")
.contains("费用方向、费用项目、供应商、币种和备注保持原快照")
.contains("费用方向、费用项目、供应商、结算方式、已付快照、币种和备注保持原快照")
.contains("旧版本异参、已作废或非已审核状态返回 A0409 且零写入");
}
@@ -65,6 +65,8 @@ class InternalFinanceFeeSourceControllerTest {
.contains("/internal/firstleg/v1/containers/{containerId}/fees/{feeId}/source-snapshot:")
.contains("{name: sourceVersion, in: query, required: true")
.contains("supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true")
.contains("settlementMode: {type: string, enum: [ON_ACCOUNT, DIRECT_PAID], nullable: true")
.contains("paymentVoucherFileIds:")
.contains("为空时仅要求供应商ACTIVE")
.contains("/internal/firstleg/v1/fee-sources/commands/void:")
.contains("FIRSTLEG_FEE_VOIDED Outbox")
@@ -18,11 +18,15 @@ class FinanceFeeSourceMigrationContractTest {
assertThat(baseline).contains(
"supplier_id BIGINT UNSIGNED NULL",
"supplier_name VARCHAR(200) NULL",
"supplier_category VARCHAR(16) NULL");
"supplier_category VARCHAR(16) NULL",
"settlement_mode VARCHAR(16) NULL",
"payment_voucher_file_ids JSON NULL");
assertThat(migration).contains(
"firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id'",
"firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_name'",
"firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_category'",
"firstleg_finance_add_column('fl_inbound_fee_detail', 'settlement_mode'",
"firstleg_finance_add_column('fl_inbound_fee_detail', 'payment_voucher_file_ids'",
"Firstleg Finance迁移失败:入库费用供应商快照字段不完整");
}
@@ -35,12 +39,16 @@ class FinanceFeeSourceMigrationContractTest {
"uk_inbound_outbox_event_key", "next_retry_at", "locked_at", "locked_by",
"idx_inbound_outbox_finance_relay", "CREATE TABLE IF NOT EXISTS fl_container_fee_detail",
"source_version", "supplier_id", "supplier_name", "supplier_category",
"settlement_mode", "paid_at", "payment_method", "payment_reference_no",
"payment_voucher_file_ids",
"CREATE TABLE IF NOT EXISTS fl_container_fee_source_snapshot",
"uk_container_fee_source_version",
"CREATE TABLE IF NOT EXISTS fl_finance_fee_source_command",
"uk_finance_fee_command_idem",
"request_hash CHAR(64) NOT NULL",
"firstleg_finance_add_column('fl_container_fee_detail', 'supplier_id'", "RECEIVABLE", "PAYABLE",
"firstleg_finance_add_column('fl_container_fee_source_snapshot', 'settlement_mode'",
"柜次费用已付版本快照字段不完整",
"firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id'",
"Firstleg Finance迁移失败:入库费用供应商快照字段不完整",
"chk_container_fee_currency", "currency = 'CNY'", "firstleg_finance_assert_schema");
@@ -12,6 +12,7 @@ import com.baiyue.firstleg.repository.ContainerFeeSourceSnapshotRepository;
import com.baiyue.firstleg.repository.FeeItemRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.mockito.ArgumentCaptor;
import java.math.BigDecimal;
@@ -29,6 +30,7 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.lenient;
class ContainerFeeDetailServiceTest {
private final ContainerFeeDetailRepository repository = mock(ContainerFeeDetailRepository.class);
@@ -37,8 +39,18 @@ class ContainerFeeDetailServiceTest {
private final FinanceFeeSourceOutboxService outbox = mock(FinanceFeeSourceOutboxService.class);
private final WarehouseSupplierSnapshotService suppliers = mock(WarehouseSupplierSnapshotService.class);
private final ContainerFeeSourceSnapshotRepository sourceSnapshots = mock(ContainerFeeSourceSnapshotRepository.class);
private final FeePaymentSnapshotValidator paymentSnapshots = mock(FeePaymentSnapshotValidator.class);
private final ContainerFeeDetailService service =
new ContainerFeeDetailService(repository, loading, feeItems, outbox, suppliers, sourceSnapshots);
new ContainerFeeDetailService(repository, loading, feeItems, outbox, suppliers, sourceSnapshots,
paymentSnapshots);
@BeforeEach
void paymentSnapshot() {
lenient().when(paymentSnapshots.validate(anyLong(), any(), any(), any(), any(), any(), any()))
.thenAnswer(invocation -> new FeePaymentSnapshotValidator.Snapshot(
invocation.getArgument(2), invocation.getArgument(3), invocation.getArgument(4),
invocation.getArgument(5), invocation.getArgument(6) == null ? List.of() : invocation.getArgument(6)));
}
@Test
void rejectsContainerThatDoesNotContainCustomerOrders() {
@@ -199,6 +211,28 @@ class ContainerFeeDetailServiceTest {
assertThat(captor.getValue().getSupplierName()).isNull();
}
@Test
void directPaidFieldsAreStoredAsBusinessSnapshot() {
ownedPlan();
when(feeItems.findItem(11L)).thenReturn(item());
when(suppliers.fee(71L, null)).thenReturn(
new com.baiyue.firstleg.client.WarehouseSupplierClient.Snapshot(
71L, "现有供应商", "TRUCKING", "ACTIVE"));
LocalDateTime paidAt = LocalDateTime.of(2026, 8, 20, 10, 0);
var request = new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE",
LocalDate.of(2026, 8, 20), new BigDecimal("300.00"), "CNY", 71L,
"DIRECT_PAID", paidAt, "BANK_TRANSFER", "BANK-001", List.of(901L), "已付拖车费");
service.create(10001L, 41L, 9L, request);
ArgumentCaptor<ContainerFeeDetailEntity> captor = ArgumentCaptor.forClass(ContainerFeeDetailEntity.class);
verify(repository).insert(captor.capture());
assertThat(captor.getValue().getSettlementMode()).isEqualTo("DIRECT_PAID");
assertThat(captor.getValue().getPaidAt()).isEqualTo(paidAt);
assertThat(captor.getValue().getPaymentMethod()).isEqualTo("BANK_TRANSFER");
assertThat(captor.getValue().getPaymentVoucherFileIds()).containsExactly(901L);
}
private void ownedPlan() {
LoadingPlanEntity plan = new LoadingPlanEntity();
plan.setId(41L);
@@ -14,6 +14,7 @@ import com.baiyue.firstleg.repository.FbaPricingRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.mockito.ArgumentCaptor;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.boot.test.mock.mockito.MockBean;
@@ -27,12 +28,14 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.lenient;
@DisplayName("费用项目和入库费用明细")
@SpringJUnitConfig({FeeItemService.class, InboundFeeDetailService.class})
@@ -45,11 +48,21 @@ class FeeManagementServiceTest {
private FinanceFeeSourceOutboxService financeOutbox;
@MockBean
private WarehouseSupplierSnapshotService suppliers;
@MockBean
private FeePaymentSnapshotValidator paymentSnapshots;
@Resource
private FeeItemService feeItems;
@Resource
private InboundFeeDetailService details;
@BeforeEach
void paymentSnapshot() {
lenient().when(paymentSnapshots.validate(anyLong(), any(), any(), any(), any(), any(), any()))
.thenAnswer(invocation -> new FeePaymentSnapshotValidator.Snapshot(
invocation.getArgument(2), invocation.getArgument(3), invocation.getArgument(4),
invocation.getArgument(5), invocation.getArgument(6) == null ? List.of() : invocation.getArgument(6)));
}
@Test
@DisplayName("客户B不能删除客户A的费用明细")
void rejectsCrossCustomerFeeDetailDelete() {
@@ -0,0 +1,77 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.FeePaymentVoucherFileClient;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class FeePaymentSnapshotValidatorTest {
private final FeePaymentVoucherFileClient files = mock(FeePaymentVoucherFileClient.class);
private final FeePaymentSnapshotValidator validator =
new FeePaymentSnapshotValidator(files, "file-token");
@Test
void receivableClearsSettlementAndRejectsPaidFields() {
assertThat(validator.validate(9L, "RECEIVABLE", null, null, null, null, null))
.isEqualTo(new FeePaymentSnapshotValidator.Snapshot(null, null, null, null, List.of()));
assertThatThrownBy(() -> validator.validate(9L, "RECEIVABLE", "ON_ACCOUNT",
null, null, null, null)).isInstanceOf(BizException.class)
.hasMessageContaining("应收费用不得");
}
@Test
void onAccountRejectsEveryPaidField() {
assertThat(validator.validate(9L, "PAYABLE", "ON_ACCOUNT",
null, null, null, null).settlementMode()).isEqualTo("ON_ACCOUNT");
assertThatThrownBy(() -> validator.validate(9L, "PAYABLE", "ON_ACCOUNT",
LocalDateTime.now(), "CASH", "CASH-1", List.of(1L)))
.isInstanceOf(BizException.class).hasMessageContaining("账期应付不得");
}
@Test
void directPaidRequiresTimeMethodAndReferenceOrVoucher() {
LocalDateTime paidAt = LocalDateTime.of(2026, 8, 20, 10, 0);
assertThatThrownBy(() -> validator.validate(9L, "PAYABLE", "DIRECT_PAID",
paidAt, "BANK_TRANSFER", null, null))
.isInstanceOf(BizException.class).hasMessageContaining("流水号或至少一个付款凭证");
assertThat(validator.validate(9L, "PAYABLE", "DIRECT_PAID",
paidAt, "BANK_TRANSFER", "BANK-001", null))
.extracting(FeePaymentSnapshotValidator.Snapshot::paymentReferenceNo)
.isEqualTo("BANK-001");
}
@Test
void directPaidVoucherIsValidatedAgainstCurrentCustomerAndFileType() {
LocalDateTime paidAt = LocalDateTime.of(2026, 8, 20, 10, 0);
when(files.metadata("file-token", 9L, 9L, 901L)).thenReturn(ApiResponse.success(
new FeePaymentVoucherFileClient.Metadata(901L, 9L, "FINANCE_VOUCHER", "9",
"voucher.pdf", "a".repeat(64), "application/pdf", 100L)));
var result = validator.validate(9L, "PAYABLE", "DIRECT_PAID",
paidAt, "ONLINE", null, List.of(901L, 901L));
assertThat(result.paymentVoucherFileIds()).containsExactly(901L);
verify(files).metadata("file-token", 9L, 9L, 901L);
}
@Test
void fileMismatchFailsClosed() {
when(files.metadata("file-token", 9L, 9L, 901L)).thenReturn(ApiResponse.success(
new FeePaymentVoucherFileClient.Metadata(901L, 10L, "FINANCE_VOUCHER", "10",
"voucher.pdf", "a".repeat(64), "application/pdf", 100L)));
assertThatThrownBy(() -> validator.validate(9L, "PAYABLE", "DIRECT_PAID",
LocalDateTime.now(), "CASH", null, List.of(901L)))
.isInstanceOf(BizException.class).hasMessageContaining("不属于当前客户");
}
}
@@ -84,6 +84,7 @@ class FinanceFeeSourceSnapshotServiceTest {
detail.setSupplierId(71L);
detail.setSupplierName("现有供应商");
detail.setSupplierCategory("TRUCKING");
detail.setSettlementMode("ON_ACCOUNT");
detail.setBusinessDate(java.time.LocalDate.of(2026, 8, 12));
detail.setAmount(new BigDecimal("300.00"));
detail.setCurrency("CNY");
@@ -96,6 +97,8 @@ class FinanceFeeSourceSnapshotServiceTest {
assertThat(snapshot.supplierId()).isEqualTo(71L);
assertThat(snapshot.supplierName()).isEqualTo("现有供应商");
assertThat(snapshot.supplierCategory()).isEqualTo("TRUCKING");
assertThat(snapshot.settlementMode()).isEqualTo("ON_ACCOUNT");
assertThat(snapshot.paidAt()).isNull();
assertThat(snapshot.sourceHash()).matches("[0-9a-f]{64}");
}
}