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