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.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,
@@ -53,6 +53,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) {}
@@ -67,7 +67,7 @@ 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 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]; 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()); 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'", 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("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=?", 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()); Long.class, snapshot.feeId(), snapshot.feeVersion());
@@ -138,14 +140,44 @@ 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 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", "事件序列化失败"); }
@@ -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(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(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 ?"; "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); 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;
@@ -96,7 +96,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 +107,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", "费用来源未审核、金额无效或状态不可用");
@@ -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',
@@ -90,8 +100,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
+15 -1
View File
@@ -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 (
@@ -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);
@@ -81,7 +82,7 @@ class FinanceFeeSourceEventServiceTest {
verify(supplierValidator).requireActive(71L, "TRUCKING"); verify(supplierValidator).requireActive(71L, "TRUCKING");
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 +92,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 +103,28 @@ 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]");
}
@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 +240,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");
} }
@@ -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
@@ -396,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")) {
@@ -649,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;
@@ -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;
@@ -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());
}
}
}
@@ -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:
@@ -1882,6 +1882,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}
@@ -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("ContainerFeeAdjustRequest:")
.contains("required: [expectedSourceVersion, businessDate, amount]") .contains("required: [expectedSourceVersion, businessDate, amount]")
.contains("additionalProperties: false") .contains("additionalProperties: false")
.contains("费用方向、费用项目、供应商、币种和备注保持原快照") .contains("费用方向、费用项目、供应商、结算方式、已付快照、币种和备注保持原快照")
.contains("旧版本异参、已作废或非已审核状态返回 A0409 且零写入"); .contains("旧版本异参、已作废或非已审核状态返回 A0409 且零写入");
} }
@@ -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,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");
@@ -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() {
@@ -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.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}");
} }
} }