diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 841aa7d1a..afd8cfe68 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -11241,6 +11241,11 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_detail ( supplier_id BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空', supplier_name VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空', supplier_category VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商', + settlement_mode VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT账期应付、DIRECT_PAID业务已付;RECEIVABLE为空', + paid_at DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间', + payment_method VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式', + payment_reference_no VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号', + payment_voucher_file_ids JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组', business_date DATE NOT NULL COMMENT '费用业务日期', amount DECIMAL(18,2) NOT NULL COMMENT 'CNY费用金额', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '币种,首期固定CNY', @@ -11263,6 +11268,15 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_detail ( CONSTRAINT chk_container_fee_currency CHECK (currency = 'CNY'), CONSTRAINT chk_container_fee_amount CHECK (amount >= 0), CONSTRAINT chk_container_fee_review CHECK (review_status IN ('PENDING', 'APPROVED', 'REJECTED')), + CONSTRAINT chk_container_fee_settlement CHECK ( + (direction='RECEIVABLE' AND settlement_mode IS NULL AND paid_at IS NULL AND payment_method IS NULL + AND payment_reference_no IS NULL AND payment_voucher_file_ids IS NULL) + OR (direction='PAYABLE' AND settlement_mode='ON_ACCOUNT' AND paid_at IS NULL AND payment_method IS NULL + AND payment_reference_no IS NULL AND payment_voucher_file_ids IS NULL) + OR (direction='PAYABLE' AND settlement_mode='DIRECT_PAID' AND paid_at IS NOT NULL + AND payment_method IN ('ONLINE','BANK_TRANSFER','CASH','OTHER') + AND (payment_reference_no IS NOT NULL OR JSON_LENGTH(payment_voucher_file_ids)>0)) + ), CONSTRAINT chk_container_fee_source CHECK (source_status IN ('ACTIVE', 'VOIDED')) ) ENGINE=InnoDB COMMENT='柜次级人工应收应付费用来源;审核通过后自动同步Finance'; @@ -11281,6 +11295,11 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_source_snapshot ( supplier_id BIGINT UNSIGNED NULL COMMENT 'Warehouse供应商ID快照', supplier_name VARCHAR(200) NULL COMMENT 'Warehouse供应商名称快照', supplier_category VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商', + settlement_mode VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT账期应付、DIRECT_PAID业务已付;RECEIVABLE为空', + paid_at DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间', + payment_method VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式', + payment_reference_no VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号', + payment_voucher_file_ids JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组', business_date DATE NOT NULL COMMENT '费用业务日期', amount DECIMAL(18,2) NOT NULL COMMENT 'CNY费用金额', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '币种,首期固定CNY', @@ -11324,12 +11343,42 @@ CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_name', "VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空' AFTER supplier_id"); CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_category', "VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商' AFTER supplier_name"); +CALL firstleg_finance_add_column('fl_container_fee_detail', 'settlement_mode', + "VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT或DIRECT_PAID' AFTER supplier_category"); +CALL firstleg_finance_add_column('fl_container_fee_detail', 'paid_at', + "DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间' AFTER settlement_mode"); +CALL firstleg_finance_add_column('fl_container_fee_detail', 'payment_method', + "VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式' AFTER paid_at"); +CALL firstleg_finance_add_column('fl_container_fee_detail', 'payment_reference_no', + "VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号' AFTER payment_method"); +CALL firstleg_finance_add_column('fl_container_fee_detail', 'payment_voucher_file_ids', + "JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组' AFTER payment_reference_no"); +CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'settlement_mode', + "VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT或DIRECT_PAID' AFTER supplier_category"); +CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'paid_at', + "DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间' AFTER settlement_mode"); +CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'payment_method', + "VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式' AFTER paid_at"); +CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'payment_reference_no', + "VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号' AFTER payment_method"); +CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'payment_voucher_file_ids', + "JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组' AFTER payment_reference_no"); CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id', "BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空' AFTER direction"); CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_name', "VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空' AFTER supplier_id"); CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_category', "VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商' AFTER supplier_name"); +CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'settlement_mode', + "VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT或DIRECT_PAID' AFTER supplier_category"); +CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'paid_at', + "DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间' AFTER settlement_mode"); +CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'payment_method', + "VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式' AFTER paid_at"); +CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'payment_reference_no', + "VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号' AFTER payment_method"); +CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'payment_voucher_file_ids', + "JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组' AFTER payment_reference_no"); DROP PROCEDURE IF EXISTS firstleg_finance_add_column; DROP PROCEDURE IF EXISTS firstleg_finance_assert_schema; @@ -11352,12 +11401,22 @@ BEGIN END IF; IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_container_fee_detail' - AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category')) <> 3 THEN + AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category', 'settlement_mode', + 'paid_at', 'payment_method', 'payment_reference_no', + 'payment_voucher_file_ids')) <> 8 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:柜次费用供应商快照字段不完整'; END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_container_fee_source_snapshot' + AND column_name IN ('settlement_mode', 'paid_at', 'payment_method', + 'payment_reference_no', 'payment_voucher_file_ids')) <> 5 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:柜次费用已付版本快照字段不完整'; + END IF; IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_fee_detail' - AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category')) <> 3 THEN + AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category', 'settlement_mode', + 'paid_at', 'payment_method', 'payment_reference_no', + 'payment_voucher_file_ids')) <> 8 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:入库费用供应商快照字段不完整'; END IF; END$$ @@ -11399,6 +11458,16 @@ CALL finance_fee_source_add_column('fin_fee_source', 'fee_item_code', "VARCHAR(64) NULL COMMENT '来源费用项目编码快照' AFTER container_id"); CALL finance_fee_source_add_column('fin_fee_source', 'source_hash', "CHAR(64) NULL COMMENT 'Firstleg费用来源完整快照SHA-256哈希' AFTER currency_code"); +CALL finance_fee_source_add_column('fin_fee_source', 'settlement_mode', + "VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT账期应付、DIRECT_PAID业务已付;RECEIVABLE为空' AFTER currency_code"); +CALL finance_fee_source_add_column('fin_fee_source', 'paid_at', + "DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间' AFTER settlement_mode"); +CALL finance_fee_source_add_column('fin_fee_source', 'payment_method', + "VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式' AFTER paid_at"); +CALL finance_fee_source_add_column('fin_fee_source', 'payment_reference_no', + "VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号' AFTER payment_method"); +CALL finance_fee_source_add_column('fin_fee_source', 'payment_voucher_file_ids', + "JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组' AFTER payment_reference_no"); CALL finance_fee_source_add_column('fin_fee_source', 'source_snapshot', "JSON NULL COMMENT 'Firstleg费用来源完整不可变JSON快照' AFTER source_hash"); CALL finance_fee_source_add_column('fin_fee_source', 'review_status', @@ -11458,8 +11527,9 @@ BEGIN IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fin_fee_source' AND column_name IN ('source_type','source_no','direction','container_id','fee_item_code', - 'source_hash','source_snapshot','review_status','review_reason', - 'operation_version','updated_at')) <> 11 THEN + 'settlement_mode','paid_at','payment_method','payment_reference_no', + 'payment_voucher_file_ids','source_hash','source_snapshot','review_status','review_reason', + 'operation_version','updated_at')) <> 16 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance费用来源迁移失败:来源快照字段不完整'; END IF; IF (SELECT COUNT(*) FROM information_schema.columns 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/controller/PlatformFinanceController.java b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java index 2d2e4862a..ddb6985c6 100644 --- a/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java +++ b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java @@ -78,7 +78,7 @@ public class PlatformFinanceController { } @PostMapping(value = "/vouchers", consumes = "multipart/form-data") - @PreAuthorize("hasAnyAuthority('platform:finance:receipt:create','platform:finance:cost:create')") + @PreAuthorize("hasAnyAuthority('platform:finance:receipt:create','platform:finance:cost:create','platform:finance:fee-source:edit')") public ApiResponse uploadVoucher(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long customerId, @RequestPart("file") MultipartFile file) { diff --git a/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java b/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java index a25c3fb6c..0d0342ec8 100644 --- a/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java +++ b/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java @@ -28,7 +28,8 @@ public final class FinancePhase1Dtos { public record AccountSummary(long customerId, String customerCode, String customerName, String contactName, String contactPhone, BigDecimal outstandingReceivable, BigDecimal pendingBilling, - BigDecimal receivedInPeriod, BigDecimal outstandingPayable, + BigDecimal receivedInPeriod, BigDecimal totalCost, + BigDecimal paidCost, BigDecimal outstandingPayable, String status, LocalDateTime lastChangedAt) {} public record LedgerEntry(long ledgerId, LocalDateTime occurredAt, String businessType, 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..1f3b3eaab 100644 --- a/finance/src/main/java/com/baiyue/finance/dto/FinanceReadModels.java +++ b/finance/src/main/java/com/baiyue/finance/dto/FinanceReadModels.java @@ -26,6 +26,8 @@ public final class FinanceReadModels { BigDecimal receivedAmount, BigDecimal outstandingReceivable, BigDecimal pendingBilling, + BigDecimal totalCost, + BigDecimal paidCost, BigDecimal outstandingPayable, Instant asOf, LocalDateTime lastChangedAt) {} @@ -53,6 +55,11 @@ public final class FinanceReadModels { String feeItemName, BigDecimal amount, String currency, + String settlementMode, + LocalDateTime paidAt, + String paymentMethod, + String paymentReferenceNo, + List 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..94c2cf4b2 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java @@ -50,7 +50,8 @@ public class FinanceCustomerAccountService { Metrics value = metrics.getOrDefault(customer.id(), Metrics.ZERO); return new AccountSummary(customer.id(), customer.tenantCode(), customer.tenantName(), customer.contactName(), customer.contactPhone(), value.outstandingReceivable(), - value.pendingBilling(), value.receivedInPeriod(), value.outstandingPayable(), + value.pendingBilling(), value.receivedInPeriod(), value.totalCost(), value.paidCost(), + value.outstandingPayable(), customer.status(), value.lastChangedAt()); }).toList(); return PageEnvelope.success(rows, (int) customers.page(), (int) customers.size(), customers.total()); @@ -67,11 +68,13 @@ public class FinanceCustomerAccountService { .forEach(row -> metric(result, row, "pendingBilling", "amount")); jdbc.queryForList("SELECT tenant_id customer_id,SUM(received_amount) amount FROM fin_receipt WHERE tenant_id IN (" + placeholders + ") AND status='CONFIRMED' GROUP BY tenant_id", customerIds.toArray()) .forEach(row -> metric(result, row, "receivedInPeriod", "amount")); - jdbc.queryForList("SELECT tenant_id customer_id,SUM(cost_amount) amount FROM fin_cost WHERE tenant_id IN (" + placeholders + ") AND status<>'VOIDED' GROUP BY tenant_id", customerIds.toArray()) + jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM (SELECT customer_id,amount FROM fin_fee_source WHERE customer_id IN (" + placeholders + ") AND direction='PAYABLE' AND settlement_mode='ON_ACCOUNT' AND review_status='APPROVED' AND status NOT IN ('VOIDED','SUPERSEDED') UNION ALL SELECT tenant_id customer_id,cost_amount amount FROM fin_cost WHERE tenant_id IN (" + placeholders + ") AND status='CONFIRMED') costs GROUP BY customer_id", concat(customerIds, 2)) + .forEach(row -> metric(result, row, "totalCost", "amount")); + jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM (SELECT tenant_id customer_id,cost_amount amount FROM fin_cost WHERE tenant_id IN (" + placeholders + ") AND status='CONFIRMED' UNION ALL SELECT tenant_id customer_id,paid_amount amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='PAYABLE' AND status<>'VOIDED') paid_costs GROUP BY customer_id", concat(customerIds, 2)) + .forEach(row -> metric(result, row, "paidCost", "amount")); + jdbc.queryForList("SELECT tenant_id customer_id,SUM(outstanding_amount) amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='PAYABLE' AND status<>'VOIDED' GROUP BY tenant_id", customerIds.toArray()) .forEach(row -> metric(result, row, "outstandingPayable", "amount")); - Object[] changeArgs = new Object[customerIds.size() * 3]; - for (int i = 0; i < 3; i++) System.arraycopy(customerIds.toArray(), 0, changeArgs, i * customerIds.size(), customerIds.size()); - jdbc.queryForList("SELECT customer_id,MAX(updated_at) last_changed_at FROM (SELECT tenant_id customer_id,updated_at FROM fin_bill WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_receipt WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_cost WHERE tenant_id IN (" + placeholders + ")) changes GROUP BY customer_id", changeArgs) + jdbc.queryForList("SELECT customer_id,MAX(updated_at) last_changed_at FROM (SELECT tenant_id customer_id,updated_at FROM fin_bill WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_receipt WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_cost WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT customer_id,updated_at FROM fin_fee_source WHERE customer_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_settlement WHERE tenant_id IN (" + placeholders + ")) changes GROUP BY customer_id", concat(customerIds, 5)) .forEach(row -> metric(result, row, "lastChangedAt", "last_changed_at")); Map frozen = new HashMap<>(); result.forEach((id, value) -> frozen.put(id, value.freeze())); @@ -98,10 +101,19 @@ public class FinanceCustomerAccountService { private static String blankToNull(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static Object[] concat(List values, int copies) { + Object[] result = new Object[values.size() * copies]; + Object[] source = values.toArray(); + for (int i = 0; i < copies; i++) System.arraycopy(source, 0, result, i * source.length, source.length); + return result; + } + private static final class MutableMetrics { private BigDecimal outstandingReceivable = BigDecimal.ZERO; private BigDecimal pendingBilling = BigDecimal.ZERO; private BigDecimal receivedInPeriod = BigDecimal.ZERO; + private BigDecimal totalCost = BigDecimal.ZERO; + private BigDecimal paidCost = BigDecimal.ZERO; private BigDecimal outstandingPayable = BigDecimal.ZERO; private LocalDateTime lastChangedAt; void set(String field, BigDecimal value) { @@ -109,16 +121,20 @@ public class FinanceCustomerAccountService { case "outstandingReceivable" -> outstandingReceivable = value; case "pendingBilling" -> pendingBilling = value; case "receivedInPeriod" -> receivedInPeriod = value; + case "totalCost" -> totalCost = value; + case "paidCost" -> paidCost = value; case "outstandingPayable" -> outstandingPayable = value; default -> { } } } - Metrics freeze() { return new Metrics(outstandingReceivable, pendingBilling, receivedInPeriod, outstandingPayable, lastChangedAt); } + Metrics freeze() { return new Metrics(outstandingReceivable, pendingBilling, receivedInPeriod, totalCost, paidCost, outstandingPayable, lastChangedAt); } } private record Metrics(BigDecimal outstandingReceivable, BigDecimal pendingBilling, - BigDecimal receivedInPeriod, BigDecimal outstandingPayable, + BigDecimal receivedInPeriod, BigDecimal totalCost, BigDecimal paidCost, + BigDecimal outstandingPayable, LocalDateTime lastChangedAt) { - private static final Metrics ZERO = new Metrics(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, null); + private static final Metrics ZERO = new Metrics(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, + BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, null); } } diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java index 9b51034cb..49895f9d6 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java @@ -65,7 +65,8 @@ public class FinanceExportProviderService { column("customerCode", "客户编码", ExportColumnType.STRING, 18), column("customerName", "客户", ExportColumnType.STRING, 28), column("contactName", "联系人", ExportColumnType.STRING, 16), column("contactPhone", "联系电话", ExportColumnType.STRING, 18), column("outstandingReceivable", "待收(CNY)", ExportColumnType.DECIMAL, 16), column("pendingBilling", "待开账(CNY)", ExportColumnType.DECIMAL, 16), - column("receivedInPeriod", "本期已收(CNY)", ExportColumnType.DECIMAL, 16), column("outstandingPayable", "待付(CNY)", ExportColumnType.DECIMAL, 16), + column("receivedInPeriod", "本期已收(CNY)", ExportColumnType.DECIMAL, 16), column("totalCost", "总成本(CNY)", ExportColumnType.DECIMAL, 16), + column("paidCost", "已付成本(CNY)", ExportColumnType.DECIMAL, 16), column("outstandingPayable", "待付(CNY)", ExportColumnType.DECIMAL, 16), column("status", "客户状态", ExportColumnType.STRING, 14), column("lastChangedAt", "最近变动", ExportColumnType.DATETIME, 21)); private static final List LEDGER_COLUMNS = List.of( column("ledgerId", "流水号", ExportColumnType.STRING, 22), column("occurredAt", "发生时间", ExportColumnType.DATETIME, 21), @@ -366,7 +367,8 @@ public class FinanceExportProviderService { private Map accountRow(AccountSummary value) { return row("customerCode", value.customerCode(), "customerName", value.customerName(), "contactName", value.contactName(), "contactPhone", value.contactPhone(), "outstandingReceivable", value.outstandingReceivable(), "pendingBilling", value.pendingBilling(), - "receivedInPeriod", value.receivedInPeriod(), "outstandingPayable", value.outstandingPayable(), "status", value.status(), "lastChangedAt", value.lastChangedAt()); + "receivedInPeriod", value.receivedInPeriod(), "totalCost", value.totalCost(), "paidCost", value.paidCost(), + "outstandingPayable", value.outstandingPayable(), "status", value.status(), "lastChangedAt", value.lastChangedAt()); } private Map ledgerRow(LedgerEntry value) { return row("ledgerId", String.valueOf(value.ledgerId()), "occurredAt", value.occurredAt(), "businessType", value.businessType(), 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..ab12c6bc4 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceFeeSourceEventService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceFeeSourceEventService.java @@ -73,6 +73,7 @@ public class FinanceFeeSourceEventService { long id = ((Number) row.get("id")).longValue(); if ("FIRSTLEG_FEE_VOIDED".equals(event.eventType())) { jdbc.update("UPDATE fin_fee_source SET status='VOIDED',operation_version=operation_version+1 WHERE id=? AND status<>'VOIDED'", id); + voidDirectPaidCost(snapshot); } return id; } @@ -81,14 +82,37 @@ public class FinanceFeeSourceEventService { } jdbc.update("UPDATE fin_fee_source SET status='SUPERSEDED' WHERE source_system='FIRSTLEG' AND source_fee_id=? AND status='AVAILABLE'", snapshot.feeId()); - jdbc.update("INSERT INTO fin_fee_source(source_system,source_fee_id,source_fee_version,customer_id,source_type,source_no,direction,order_id,inbound_id,container_id,fee_item_code,fee_item_name,amount,currency_code,source_hash,source_snapshot,status) VALUES ('FIRSTLEG',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'AVAILABLE')", + jdbc.update("UPDATE fin_cost SET status='VOIDED',voided_at=CURRENT_TIMESTAMP(3),void_reason='来源版本已替代',version=version+1,updated_by=0 WHERE cost_origin='FIRSTLEG_DIRECT_PAID' AND source_system='FIRSTLEG' AND source_fee_id=? AND status='CONFIRMED'", + snapshot.feeId()); + jdbc.update("INSERT INTO fin_fee_source(source_system,source_fee_id,source_fee_version,customer_id,source_type,source_no,direction,order_id,inbound_id,container_id,fee_item_code,fee_item_name,amount,currency_code,settlement_mode,paid_at,payment_method,payment_reference_no,payment_voucher_file_ids,source_hash,source_snapshot,status) VALUES ('FIRSTLEG',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'AVAILABLE')", snapshot.feeId(), snapshot.feeVersion(), snapshot.customerId(), snapshot.sourceType(), snapshot.sourceType().equals("INBOUND_FEE") ? snapshot.inboundCode() : snapshot.containerNo(), snapshot.direction(), snapshot.orderId(), snapshot.inboundId(), snapshot.containerId(), snapshot.feeItemCode(), snapshot.feeItemName(), snapshot.totalAmount(), snapshot.currency(), + snapshot.settlementMode(), snapshot.paidAt(), snapshot.paymentMethod(), + snapshot.paymentReferenceNo(), voucherJson(snapshot.paymentVoucherFileIds()), snapshot.sourceHash(), json(snapshot)); - return jdbc.queryForObject("SELECT id FROM fin_fee_source WHERE source_system='FIRSTLEG' AND source_fee_id=? AND source_fee_version=?", + long sourceId = jdbc.queryForObject("SELECT id FROM fin_fee_source WHERE source_system='FIRSTLEG' AND source_fee_id=? AND source_fee_version=?", Long.class, snapshot.feeId(), snapshot.feeVersion()); + if ("PAYABLE".equals(snapshot.direction()) && "DIRECT_PAID".equals(snapshot.settlementMode())) { + materializeDirectPaidCost(snapshot); + } + return sourceId; + } + + private void materializeDirectPaidCost(FirstlegFeeSourceClient.Snapshot snapshot) { + String costNo = "DIRECT-" + snapshot.sourceType() + "-" + snapshot.feeId() + "-V" + snapshot.feeVersion(); + jdbc.update("INSERT INTO fin_cost(tenant_id,cost_no,cost_type,cost_origin,source_system,source_type,source_fee_id,source_fee_version,source_hash,order_id,inbound_id,container_id,supplier_id,supplier_name_snapshot,fee_item_code,fee_item_name,currency_code,cost_amount,voucher_file_ids,paid_at,payment_method,payment_reference_no,status,confirmed_at,created_by,created_by_name,updated_by) VALUES(?,?,'SOURCE_DIRECT_PAID','FIRSTLEG_DIRECT_PAID','FIRSTLEG',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED',CURRENT_TIMESTAMP(3),0,'系统',0)", + snapshot.customerId(), costNo, snapshot.sourceType(), snapshot.feeId(), snapshot.feeVersion(), + snapshot.sourceHash(), snapshot.orderId(), snapshot.inboundId(), snapshot.containerId(), + snapshot.supplierId(), snapshot.supplierName(), snapshot.feeItemCode(), snapshot.feeItemName(), + snapshot.currency(), snapshot.totalAmount(), voucherJsonOrEmpty(snapshot.paymentVoucherFileIds()), + snapshot.paidAt(), snapshot.paymentMethod(), snapshot.paymentReferenceNo()); + } + + private void voidDirectPaidCost(FirstlegFeeSourceClient.Snapshot snapshot) { + jdbc.update("UPDATE fin_cost SET status='VOIDED',voided_at=CURRENT_TIMESTAMP(3),void_reason='来源费用已作废',version=version+1,updated_by=0 WHERE cost_origin='FIRSTLEG_DIRECT_PAID' AND source_system='FIRSTLEG' AND source_type=? AND source_fee_id=? AND source_fee_version=? AND status='CONFIRMED'", + snapshot.sourceType(), snapshot.feeId(), snapshot.feeVersion()); } private FirstlegFeeSourceClient.Snapshot loadSnapshot(FinanceFeeSourceEvent event) { @@ -138,14 +162,48 @@ public class FinanceFeeSourceEventService { if (!value.supplierName().equals(supplier.name())) { throw error("FIN-EVT-012", "应付费用来源供应商名称快照不一致"); } + validatePayableSettlement(value); } else if (value.supplierId() != null || !blank(value.supplierName()) || !blank(value.supplierCategory())) { throw error("FIN-EVT-012", "应收费用不得携带供应商快照"); + } else if (!blank(value.settlementMode()) || value.paidAt() != null + || !blank(value.paymentMethod()) || !blank(value.paymentReferenceNo()) + || (value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty())) { + throw error("FIN-EVT-012", "应收费用不得携带结算方式或已付快照"); } if ("INBOUND_FEE".equals(value.sourceType()) && (value.inboundId() == null || !String.valueOf(value.inboundId()).equals(event.payload().inboundId()))) throw error("FIN-EVT-013", "入库单归属不一致"); if ("CONTAINER_FEE".equals(value.sourceType()) && (value.containerId() == null || !String.valueOf(value.containerId()).equals(event.payload().containerId()))) throw error("FIN-EVT-014", "柜子归属不一致"); } + private void validatePayableSettlement(FirstlegFeeSourceClient.Snapshot value) { + boolean hasVouchers = value.paymentVoucherFileIds() != null + && !value.paymentVoucherFileIds().isEmpty(); + if (hasVouchers && (value.paymentVoucherFileIds().size() > 20 + || value.paymentVoucherFileIds().stream().anyMatch(id -> id == null || id <= 0))) { + throw error("FIN-EVT-012", "付款凭证文件快照无效"); + } + if ("ON_ACCOUNT".equals(value.settlementMode())) { + if (value.paidAt() != null || !blank(value.paymentMethod()) + || !blank(value.paymentReferenceNo()) || hasVouchers) { + throw error("FIN-EVT-012", "账期应付不得携带已付快照"); + } + return; + } + if (!"DIRECT_PAID".equals(value.settlementMode()) || value.paidAt() == null + || !Set.of("ONLINE", "BANK_TRANSFER", "CASH", "OTHER").contains(value.paymentMethod()) + || (blank(value.paymentReferenceNo()) && !hasVouchers)) { + throw error("FIN-EVT-012", "直接已付费用来源快照不完整"); + } + } + + private String voucherJson(java.util.List values) { + return values == null || values.isEmpty() ? null : json(values); + } + + private String voucherJsonOrEmpty(java.util.List values) { + return values == null || values.isEmpty() ? "[]" : json(values); + } + private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (JsonProcessingException e) { throw error("FIN-EVT-001", "事件序列化失败"); } 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 3ce52020d..08b345067 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java @@ -69,12 +69,16 @@ public class FinancePhase1OperationService { "(SELECT COALESCE(SUM(outstanding_amount),0) FROM fin_bill b WHERE b.tenant_id=c.customer_id AND b.status<>'VOIDED') outstanding_receivable," + "(SELECT COALESCE(SUM(amount),0) FROM fin_fee_source f WHERE f.customer_id=c.customer_id AND f.direction='RECEIVABLE' AND f.status='AVAILABLE' AND NOT EXISTS(SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version)) pending_billing," + "(SELECT COALESCE(SUM(received_amount),0) FROM fin_receipt r WHERE r.tenant_id=c.customer_id AND r.status='CONFIRMED') received_period," + - "(SELECT COALESCE(SUM(cost_amount),0) FROM fin_cost x WHERE x.tenant_id=c.customer_id AND x.status<>'VOIDED') outstanding_payable," + - "GREATEST(COALESCE((SELECT MAX(updated_at) FROM fin_bill b WHERE b.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_receipt r WHERE r.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_cost x WHERE x.tenant_id=c.customer_id),'1970-01-01')) last_changed_at" + base + " ORDER BY last_changed_at DESC,c.customer_id DESC LIMIT ? OFFSET ?"; + "((SELECT COALESCE(SUM(amount),0) FROM fin_fee_source f WHERE f.customer_id=c.customer_id AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.status NOT IN ('VOIDED','SUPERSEDED'))+(SELECT COALESCE(SUM(cost_amount),0) FROM fin_cost x WHERE x.tenant_id=c.customer_id AND x.status='CONFIRMED')) total_cost," + + "((SELECT COALESCE(SUM(cost_amount),0) FROM fin_cost x WHERE x.tenant_id=c.customer_id AND x.status='CONFIRMED')+(SELECT COALESCE(SUM(paid_amount),0) FROM fin_settlement x WHERE x.tenant_id=c.customer_id AND x.direction='PAYABLE' AND x.status<>'VOIDED')) paid_cost," + + "(SELECT COALESCE(SUM(outstanding_amount),0) FROM fin_settlement x WHERE x.tenant_id=c.customer_id AND x.direction='PAYABLE' AND x.status<>'VOIDED') outstanding_payable," + + "GREATEST(COALESCE((SELECT MAX(updated_at) FROM fin_bill b WHERE b.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_receipt r WHERE r.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_cost x WHERE x.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_fee_source f WHERE f.customer_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_settlement x WHERE x.tenant_id=c.customer_id),'1970-01-01')) last_changed_at" + base + " ORDER BY last_changed_at DESC,c.customer_id DESC LIMIT ? OFFSET ?"; Object[] args = java.util.Arrays.copyOf(filterArgs, filterArgs.length + 2); args[args.length - 2] = pageSize; args[args.length - 1] = (page - 1) * pageSize; List rows = jdbc.query(select, (rs, row) -> new AccountSummary(rs.getLong("customer_id"), String.valueOf(rs.getLong("customer_id")), rs.getString("customer_name"), null, null, - money(rs, "outstanding_receivable"), money(rs, "pending_billing"), money(rs, "received_period"), money(rs, "outstanding_payable"), rs.getString("account_status"), time(rs, "last_changed_at")), args); + money(rs, "outstanding_receivable"), money(rs, "pending_billing"), money(rs, "received_period"), + money(rs, "total_cost"), money(rs, "paid_cost"), money(rs, "outstanding_payable"), + rs.getString("account_status"), time(rs, "last_changed_at")), args); return PageEnvelope.success(rows, page, pageSize, total == null ? 0 : total); } @@ -366,7 +370,20 @@ public class FinancePhase1OperationService { private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw error("FIN-DATA-500", "财务数据序列化失败"); } } private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw error("FIN-DATA-500", "幂等请求哈希失败"); } } private static String text(JsonNode node, String field) { JsonNode value = node.path(field); return value.isMissingNode() || value.isNull() ? null : value.asText(); } - private static Long jsonLong(JsonNode node, String field) { JsonNode value = node.path(field); return value.isIntegralNumber() && value.asLong() > 0 ? value.asLong() : null; } + private static Long jsonLong(JsonNode node, String field) { + JsonNode value = node.path(field); + if (value.isIntegralNumber() && value.canConvertToLong()) { + long parsed = value.longValue(); + return parsed > 0 ? parsed : null; + } + if (!value.isTextual()) return null; + try { + long parsed = Long.parseLong(value.textValue()); + return parsed > 0 ? parsed : null; + } catch (NumberFormatException ignored) { + return null; + } + } private static Long firstLong(Long first, Long second) { return first != null ? first : second; } private static String firstText(String first, String second) { return StringUtils.hasText(first) ? first : second; } private static String defaultText(JsonNode node, String field, String fallback) { String value = text(node, field); return value == null || value.isBlank() ? fallback : value; } 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..db848ed88 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java @@ -75,19 +75,26 @@ public class FinanceQueryService { "AND f.review_status='APPROVED' AND f.amount>0 " + "AND NOT EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id " + "WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND b.status<>'VOIDED')) pending_billing," + + "((SELECT COALESCE(SUM(f.amount),0) FROM fin_fee_source f WHERE f.customer_id=? AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.status NOT IN ('VOIDED','SUPERSEDED'))+" + + "(SELECT COALESCE(SUM(c.cost_amount),0) FROM fin_cost c WHERE c.tenant_id=? AND c.status='CONFIRMED')) total_cost," + + "((SELECT COALESCE(SUM(c.cost_amount),0) FROM fin_cost c WHERE c.tenant_id=? AND c.status='CONFIRMED')+" + + "COALESCE(SUM(CASE WHEN s.direction='PAYABLE' AND s.status<>'VOIDED' THEN s.paid_amount ELSE 0 END),0)) paid_cost," + "GREATEST(COALESCE(MAX(CASE WHEN s.status<>'VOIDED' THEN s.updated_at END),'1970-01-01')," + - "COALESCE((SELECT MAX(f.updated_at) FROM fin_fee_source f WHERE f.customer_id=?),'1970-01-01')) last_changed_at " + + "COALESCE((SELECT MAX(f.updated_at) FROM fin_fee_source f WHERE f.customer_id=?),'1970-01-01')," + + "COALESCE((SELECT MAX(c.updated_at) FROM fin_cost c WHERE c.tenant_id=?),'1970-01-01')) last_changed_at " + "FROM fin_settlement s WHERE s.tenant_id=?"; try { return jdbc.queryForObject(sql, (rs, rowNum) -> new CustomerAccountSummary(customerId, CNY, money(rs, "total_receivable"), money(rs, "received_amount"), money(rs, "outstanding_receivable"), - money(rs, "pending_billing"), money(rs, "outstanding_payable"), Instant.now(), time(rs, "last_changed_at")), - customerId, customerId, customerId); + money(rs, "pending_billing"), money(rs, "total_cost"), money(rs, "paid_cost"), + money(rs, "outstanding_payable"), Instant.now(), time(rs, "last_changed_at")), + customerId, customerId, customerId, customerId, customerId, customerId, customerId); } catch (BadSqlGrammarException ignored) { // Empty/partially migrated Finance databases may not have a settlement projection yet. // The read-only account summary must still be usable as a zero CNY baseline. return new CustomerAccountSummary(customerId, CNY, BigDecimal.ZERO, BigDecimal.ZERO, - BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, Instant.now(), null); + BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, + Instant.now(), null); } } @@ -96,7 +103,7 @@ public class FinanceQueryService { String normalized = direction == null || direction.isBlank() ? "ALL" : direction.trim().toUpperCase(java.util.Locale.ROOT); if (!List.of("ALL", "RECEIVABLE", "PAYABLE").contains(normalized)) throw bad("费用方向只能是RECEIVABLE或PAYABLE"); List 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 +114,10 @@ public class FinanceQueryService { rs.getString("source_type"), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"), nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), nullableLong(rs, "container_id"), rs.getString("fee_item_name"), rs.getBigDecimal("amount"), rs.getString("currency_code"), + rs.getString("settlement_mode"), time(rs, "paid_at"), rs.getString("payment_method"), + rs.getString("payment_reference_no"), fileIds(rs.getString("payment_voucher_file_ids")), rs.getString("status"), time(rs, "created_at")); }, sourceArgs.toArray())); - if (!"RECEIVABLE".equals(normalized)) { - String costSql = "SELECT id,tenant_id,order_id,inbound_id,container_id,fee_item_name,cost_amount,currency_code,status,created_at FROM fin_cost"; - Object[] costArgs = new Object[0]; - if (customerId != null) { costSql += " WHERE tenant_id=?"; costArgs = new Object[]{customerId}; } - costSql += " ORDER BY created_at DESC,id DESC"; - result.addAll(jdbc.query(costSql, (rs, rowNum) -> - new FeePoolItem("PAYABLE:" + rs.getLong("id"), rs.getLong("tenant_id"), "PAYABLE", "MANUAL_COST", rs.getLong("id"), 1, - nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), nullableLong(rs, "container_id"), - rs.getString("fee_item_name"), rs.getBigDecimal("cost_amount"), rs.getString("currency_code"), rs.getString("status"), time(rs, "created_at")), costArgs)); - } result.sort(Comparator.comparing(FeePoolItem::createdAt, Comparator.nullsLast(Comparator.reverseOrder()))); return List.copyOf(result); } 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 97c6f1df4..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", "费用来源未审核、金额无效或状态不可用"); @@ -462,7 +465,9 @@ public class FinanceSettlementService { BigDecimal unitPrice = quantity == null || quantity.signum() == 0 ? null : amount.divide(quantity, 6, java.math.RoundingMode.HALF_UP); Long supplierId = jsonLong(snapshot, "supplierId"); - if (supplierId == null) throw error("FIN_409", "应付结算缺少供应商快照"); + if ("PAYABLE".equals(text(row, "direction")) && supplierId == null) { + throw error("FIN_409", "应付结算缺少供应商快照"); + } return new SettlementLine(number(row, "fee_source_id").longValue(), text(row, "source_type"), number(row, "source_fee_id").longValue(), number(row, "source_fee_version").intValue(), text(row, "direction"), number(row, "customer_id").longValue(), jsonText(snapshot, "customerName"), @@ -594,7 +599,17 @@ public class FinanceSettlementService { } private static Long jsonLong(JsonNode node, String key) { JsonNode value = node.path(key); - return value.isIntegralNumber() ? value.longValue() : null; + if (value.isIntegralNumber() && value.canConvertToLong()) { + long parsed = value.longValue(); + return parsed > 0 ? parsed : null; + } + if (!value.isTextual()) return null; + try { + long parsed = Long.parseLong(value.textValue()); + return parsed > 0 ? parsed : null; + } catch (NumberFormatException ignored) { + return null; + } } private static LocalDate jsonDate(JsonNode node, String key) { String value = jsonText(node, key); 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..b74ba03bd 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', @@ -55,9 +65,44 @@ CALL finance_fee_source_add_column('fin_inbox_event', 'result_resource_id', "BIGINT UNSIGNED NULL COMMENT '成功处理生成或定位的费用来源快照ID' AFTER payload_hash"); CALL finance_fee_source_add_column('fin_inbox_event', 'error_code', "VARCHAR(64) NULL COMMENT '处理失败时的稳定FIN错误码' AFTER result_resource_id"); +CALL finance_fee_source_add_column('fin_cost', 'cost_origin', + "VARCHAR(24) NOT NULL DEFAULT 'MANUAL' COMMENT '成本来源:MANUAL人工补充、FIRSTLEG_DIRECT_PAID头程直接已付' AFTER cost_type"); +CALL finance_fee_source_add_column('fin_cost', 'source_system', + "VARCHAR(32) NULL COMMENT '来源系统,头程直接已付固定FIRSTLEG' AFTER cost_origin"); +CALL finance_fee_source_add_column('fin_cost', 'source_type', + "VARCHAR(32) NULL COMMENT '来源费用类型快照' AFTER source_system"); +CALL finance_fee_source_add_column('fin_cost', 'source_fee_id', + "BIGINT UNSIGNED NULL COMMENT '来源费用ID' AFTER source_type"); +CALL finance_fee_source_add_column('fin_cost', 'source_fee_version', + "INT UNSIGNED NULL COMMENT '来源费用版本' AFTER source_fee_id"); +CALL finance_fee_source_add_column('fin_cost', 'source_hash', + "CHAR(64) NULL COMMENT '来源不可变快照SHA-256' AFTER source_fee_version"); +CALL finance_fee_source_add_column('fin_cost', 'paid_at', + "DATETIME(3) NULL COMMENT '直接已付时间' AFTER voucher_file_ids"); +CALL finance_fee_source_add_column('fin_cost', 'payment_method', + "VARCHAR(32) NULL COMMENT '直接已付方式' AFTER paid_at"); +CALL finance_fee_source_add_column('fin_cost', 'payment_reference_no', + "VARCHAR(128) NULL COMMENT '直接已付流水号' AFTER payment_method"); DROP PROCEDURE IF EXISTS finance_fee_source_add_column; +DROP PROCEDURE IF EXISTS finance_fee_source_add_index; +DELIMITER $$ +CREATE PROCEDURE finance_fee_source_add_index() +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='fin_cost' + AND index_name='uk_fin_cost_source_version' + ) THEN + ALTER TABLE fin_cost ADD UNIQUE KEY uk_fin_cost_source_version + (source_system, source_type, source_fee_id, source_fee_version); + END IF; +END$$ +DELIMITER ; +CALL finance_fee_source_add_index(); +DROP PROCEDURE IF EXISTS finance_fee_source_add_index; + -- 产品确认无历史财务数据;以下兼容值仅用于空环境重复迁移时保证结构收敛。 UPDATE fin_fee_source SET source_type = COALESCE(source_type, 'INBOUND_FEE'), @@ -90,8 +135,9 @@ BEGIN IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fin_fee_source' AND column_name IN ('source_type','source_no','direction','container_id','fee_item_code', - 'source_hash','source_snapshot','review_status','review_reason', - 'operation_version','updated_at')) <> 11 THEN + 'settlement_mode','paid_at','payment_method','payment_reference_no', + 'payment_voucher_file_ids','source_hash','source_snapshot','review_status','review_reason', + 'operation_version','updated_at')) <> 16 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance费用来源迁移失败:来源快照字段不完整'; END IF; IF (SELECT COUNT(*) FROM information_schema.columns @@ -105,6 +151,18 @@ BEGIN 'customer_confirmed_by_name','customer_confirmed_at')) <> 4 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance客户对账单确认字段不完整'; END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fin_cost' + AND column_name IN ('cost_origin','source_system','source_type','source_fee_id', + 'source_fee_version','source_hash','paid_at','payment_method', + 'payment_reference_no')) <> 9 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance直接已付成本字段不完整'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='fin_cost' + AND index_name='uk_fin_cost_source_version') <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance直接已付成本唯一索引不完整'; + END IF; END$$ DELIMITER ; CALL finance_fee_source_assert_schema(); diff --git a/finance/src/main/resources/db/finance.sql b/finance/src/main/resources/db/finance.sql index 6d58b8f06..dd583fe13 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 ( @@ -198,7 +212,13 @@ CREATE TABLE IF NOT EXISTS fin_cost ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', cost_no VARCHAR(64) NOT NULL COMMENT '客户范围内唯一成本编号', - cost_type VARCHAR(24) NOT NULL COMMENT '成本归属类型:SINGLE_ORDER单票成本、CONTAINER_SHARED整柜公共成本', + cost_type VARCHAR(24) NOT NULL COMMENT '成本归属类型:SINGLE_ORDER单票、CONTAINER_SHARED整柜公共、SOURCE_DIRECT_PAID来源直接已付', + cost_origin VARCHAR(24) NOT NULL DEFAULT 'MANUAL' COMMENT '成本来源:MANUAL人工补充、FIRSTLEG_DIRECT_PAID头程直接已付', + source_system VARCHAR(32) NULL COMMENT '来源系统,头程直接已付固定FIRSTLEG', + source_type VARCHAR(32) NULL COMMENT '来源费用类型快照', + source_fee_id BIGINT UNSIGNED NULL COMMENT '来源费用ID', + source_fee_version INT UNSIGNED NULL COMMENT '来源费用版本', + source_hash CHAR(64) NULL COMMENT '来源不可变快照SHA-256', order_id BIGINT UNSIGNED NULL COMMENT '成本归属的头程订单ID,单票成本必填', inbound_id BIGINT UNSIGNED NULL COMMENT '成本关联的入库单ID,可为空', container_id BIGINT UNSIGNED NULL COMMENT 'Firstleg装柜计划ID,整柜公共成本必填', @@ -209,6 +229,9 @@ CREATE TABLE IF NOT EXISTS fin_cost ( currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY', cost_amount DECIMAL(20,6) NOT NULL COMMENT '成本总金额,精度6位小数', voucher_file_ids JSON NOT NULL COMMENT 'File服务财务凭证文件ID字符串数组JSON', + paid_at DATETIME(3) NULL COMMENT '直接已付时间;人工补充成本可为空', + payment_method VARCHAR(32) NULL COMMENT '直接已付方式:ONLINE、BANK_TRANSFER、CASH、OTHER', + payment_reference_no VARCHAR(128) NULL COMMENT '直接已付流水号', status VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT '成本状态:DRAFT草稿、CONFIRMED已确认、VOIDED已作废', remark VARCHAR(500) NULL COMMENT '平台成本内部备注', confirmed_at DATETIME(3) NULL COMMENT '成本确认时间', @@ -224,6 +247,7 @@ CREATE TABLE IF NOT EXISTS fin_cost ( updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间,数据修改时自动刷新', PRIMARY KEY (id), UNIQUE KEY uk_fin_cost_no (tenant_id, cost_no), + UNIQUE KEY uk_fin_cost_source_version (source_system, source_type, source_fee_id, source_fee_version), KEY idx_fin_cost_ticket (tenant_id, order_id, inbound_id), KEY idx_fin_cost_container (tenant_id, container_id), CONSTRAINT chk_fin_cost_tenant CHECK (tenant_id > 0), @@ -232,8 +256,16 @@ CREATE TABLE IF NOT EXISTS fin_cost ( CONSTRAINT chk_fin_cost_target CHECK ( (cost_type = 'SINGLE_ORDER' AND order_id IS NOT NULL AND container_id IS NULL) OR (cost_type = 'CONTAINER_SHARED' AND container_id IS NOT NULL) + OR (cost_type = 'SOURCE_DIRECT_PAID' AND (order_id IS NOT NULL OR inbound_id IS NOT NULL OR container_id IS NOT NULL)) + ), + CONSTRAINT chk_fin_cost_origin CHECK ( + (cost_origin='MANUAL' AND source_system IS NULL AND source_type IS NULL AND source_fee_id IS NULL AND source_fee_version IS NULL AND source_hash IS NULL) + OR (cost_origin='FIRSTLEG_DIRECT_PAID' AND source_system='FIRSTLEG' AND source_type IS NOT NULL + AND source_fee_id IS NOT NULL AND source_fee_version IS NOT NULL AND source_hash IS NOT NULL + AND status IN ('CONFIRMED','VOIDED') AND paid_at IS NOT NULL + AND payment_method IN ('ONLINE','BANK_TRANSFER','CASH','OTHER')) ) -) ENGINE=InnoDB COMMENT='人工成本;单票直接归属,整柜按体积分摊'; +) ENGINE=InnoDB COMMENT='成本事实;人工补充成本与头程直接已付来源成本统一留痕'; CREATE TABLE IF NOT EXISTS fin_cost_allocation ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', diff --git a/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java index 573be27cd..f99d622ce 100644 --- a/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java @@ -30,8 +30,8 @@ class CustomerAccountSummaryBehaviorTest { @Test void aggregatesFormalSettlementsInOneCustomerScopedQuery() throws Exception { JdbcTemplate jdbc = mock(JdbcTemplate.class); - ResultSet rs = summaryRow("300.00", "140.00", "160.00", "25.00", "80.00"); - when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any())).thenAnswer(invocation -> + ResultSet rs = summaryRow("300.00", "140.00", "160.00", "25.00", "260.00", "180.00", "80.00"); + when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any())).thenAnswer(invocation -> ((RowMapper) invocation.getArgument(1)).mapRow(rs, 0)); FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper()); @@ -43,15 +43,21 @@ class CustomerAccountSummaryBehaviorTest { assertThat(result.receivedAmount()).isEqualByComparingTo("140.00"); assertThat(result.outstandingReceivable()).isEqualByComparingTo("160.00"); assertThat(result.pendingBilling()).isEqualByComparingTo("25.00"); + assertThat(result.totalCost()).isEqualByComparingTo("260.00"); + assertThat(result.paidCost()).isEqualByComparingTo("180.00"); assertThat(result.outstandingPayable()).isEqualByComparingTo("80.00"); var sql = org.mockito.ArgumentCaptor.forClass(String.class); verify(jdbc).queryForObject(sql.capture(), any(RowMapper.class), - org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(9L)); + org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(9L), + org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(9L), + org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(9L), + org.mockito.ArgumentMatchers.eq(9L)); assertThat(sql.getValue()) .contains("FROM fin_settlement s WHERE s.tenant_id=?") .contains("s.direction='RECEIVABLE' AND s.status<>'VOIDED'") .contains("s.direction='PAYABLE' AND s.status<>'VOIDED'") + .contains("f.settlement_mode='ON_ACCOUNT'", "c.status='CONFIRMED'", "s.paid_amount") .contains("f.customer_id=?", "f.status='AVAILABLE'", "f.review_status='APPROVED'", "f.amount>0") .contains("b.status<>'VOIDED'") .doesNotContain("JSON_EXTRACT") @@ -61,8 +67,8 @@ class CustomerAccountSummaryBehaviorTest { @Test void emptyCustomerReturnsZeroAmounts() throws Exception { JdbcTemplate jdbc = mock(JdbcTemplate.class); - ResultSet rs = summaryRow("0", "0", "0", "0", "0"); - when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any())).thenAnswer(invocation -> + ResultSet rs = summaryRow("0", "0", "0", "0", "0", "0", "0"); + when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any())).thenAnswer(invocation -> ((RowMapper) invocation.getArgument(1)).mapRow(rs, 0)); CustomerAccountSummary result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(77L); @@ -71,13 +77,15 @@ class CustomerAccountSummaryBehaviorTest { assertThat(result.receivedAmount()).isZero(); assertThat(result.outstandingReceivable()).isZero(); assertThat(result.pendingBilling()).isZero(); + assertThat(result.totalCost()).isZero(); + assertThat(result.paidCost()).isZero(); assertThat(result.outstandingPayable()).isZero(); } @Test void missingSettlementProjectionReturnsZeroCnyBaseline() { JdbcTemplate jdbc = mock(JdbcTemplate.class); - when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any())) + when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any())) .thenThrow(new BadSqlGrammarException("customer summary", "SELECT ...", null)); CustomerAccountSummary result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(88L); @@ -88,6 +96,8 @@ class CustomerAccountSummaryBehaviorTest { assertThat(result.receivedAmount()).isZero(); assertThat(result.outstandingReceivable()).isZero(); assertThat(result.pendingBilling()).isZero(); + assertThat(result.totalCost()).isZero(); + assertThat(result.paidCost()).isZero(); assertThat(result.outstandingPayable()).isZero(); assertThat(result.lastChangedAt()).isNull(); } @@ -107,12 +117,15 @@ class CustomerAccountSummaryBehaviorTest { } private static ResultSet summaryRow(String total, String paid, String outstanding, - String pending, String payable) throws Exception { + String pending, String totalCost, String paidCost, + String payable) throws Exception { ResultSet rs = mock(ResultSet.class); when(rs.getBigDecimal("total_receivable")).thenReturn(new BigDecimal(total)); when(rs.getBigDecimal("received_amount")).thenReturn(new BigDecimal(paid)); when(rs.getBigDecimal("outstanding_receivable")).thenReturn(new BigDecimal(outstanding)); when(rs.getBigDecimal("pending_billing")).thenReturn(new BigDecimal(pending)); + when(rs.getBigDecimal("total_cost")).thenReturn(new BigDecimal(totalCost)); + when(rs.getBigDecimal("paid_cost")).thenReturn(new BigDecimal(paidCost)); when(rs.getBigDecimal("outstanding_payable")).thenReturn(new BigDecimal(payable)); when(rs.getTimestamp("last_changed_at")).thenReturn(Timestamp.valueOf(LocalDateTime.of(2026, 8, 13, 12, 0))); return rs; 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..b57a6b50e 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); @@ -79,9 +80,10 @@ class FinanceFeeSourceEventServiceTest { assertThat(service.consume(event)).isEqualTo(101L); verify(supplierValidator).requireActive(71L, "TRUCKING"); + verify(jdbc, never()).update(startsWith("INSERT INTO fin_cost"), any(Object[].class)); ArgumentCaptor values = ArgumentCaptor.forClass(Object[].class); verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), values.capture()); - assertThat(values.getValue()[14].toString()) + assertThat(values.getValue()[19].toString()) .contains("\"supplierId\":71", "\"supplierName\":\"现有供应商\"", "\"supplierCategory\":\"TRUCKING\""); } @@ -91,7 +93,8 @@ class FinanceFeeSourceEventServiceTest { givenNewEvent(); var snapshot = new FirstlegFeeSourceClient.Snapshot(7, "CONTAINER_FEE", 31, 1, null, null, null, 41L, 61L, "C-41", 7, "客户", 71L, "现有供应商", - null, 61L, "STORAGE", "仓储费", "PAYABLE", LocalDate.of(2026, 8, 11), + null, "ON_ACCOUNT", null, null, null, List.of(), + 61L, "STORAGE", "仓储费", "PAYABLE", LocalDate.of(2026, 8, 11), "CNY", new BigDecimal("300.00"), "APPROVED", "PUBLISHED", LocalDateTime.of(2026, 8, 11, 1, 0), null, null, null, null, "source-hash-unrestricted", 1); @@ -101,6 +104,38 @@ class FinanceFeeSourceEventServiceTest { verify(supplierValidator).requireActive(71L, null); } + @Test + void importsDirectPaidSnapshotWithoutTreatingItAsOnAccount() { + FinanceFeeSourceEvent event = containerEvent("container-direct-paid-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7); + givenNewEvent(); + var snapshot = new FirstlegFeeSourceClient.Snapshot(7, "CONTAINER_FEE", 31, 1, + null, null, null, 41L, 61L, "C-41", 7, "客户", 71L, "现有供应商", + "TRUCKING", "DIRECT_PAID", LocalDateTime.of(2026, 8, 11, 10, 0), + "BANK_TRANSFER", "BANK-001", List.of(901L), 61L, "TRUCKING", "拖车费", + "PAYABLE", LocalDate.of(2026, 8, 11), "CNY", new BigDecimal("300.00"), + "APPROVED", "PUBLISHED", LocalDateTime.of(2026, 8, 11, 11, 0), + null, null, null, null, "source-hash-direct", 1); + when(firstleg.container("test-token", 41, 31, 1)).thenReturn(ApiResponse.success(snapshot)); + + assertThat(service.consume(event)).isEqualTo(101L); + + ArgumentCaptor values = ArgumentCaptor.forClass(Object[].class); + verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), values.capture()); + assertThat(values.getValue()[13]).isEqualTo("DIRECT_PAID"); + assertThat(values.getValue()[15]).isEqualTo("BANK_TRANSFER"); + assertThat(values.getValue()[17]).isEqualTo("[901]"); + ArgumentCaptor costValues = ArgumentCaptor.forClass(Object[].class); + verify(jdbc).update(startsWith("INSERT INTO fin_cost"), costValues.capture()); + assertThat(costValues.getValue()[0]).isEqualTo(7L); + assertThat(costValues.getValue()[1]).isEqualTo("DIRECT-CONTAINER_FEE-31-V1"); + assertThat(costValues.getValue()[2]).isEqualTo("CONTAINER_FEE"); + assertThat(costValues.getValue()[3]).isEqualTo(31L); + assertThat(costValues.getValue()[4]).isEqualTo(1); + assertThat(costValues.getValue()[15]).isEqualTo("[901]"); + assertThat(costValues.getValue()[17]).isEqualTo("BANK_TRANSFER"); + assertThat(costValues.getValue()[18]).isEqualTo("BANK-001"); + } + @Test void adjustmentCreatesNewImmutableVersionAndSupersedesAvailableVersion() { FinanceFeeSourceEvent event = inboundEvent("adjusted-2", "FIRSTLEG_FEE_ADJUSTED", 2, 7); @@ -216,7 +251,8 @@ class FinanceFeeSourceEventServiceTest { String publicationStatus, BigDecimal amount) { return new FirstlegFeeSourceClient.Snapshot(customerId, sourceType, 31, version, inboundId, inboundId == null ? null : "IB-21", 51L, containerId, null, - containerId == null ? null : "C-41", customerId, "客户", null, null, null, 61L, "CUSTOMS", + containerId == null ? null : "C-41", customerId, "客户", null, null, null, + null, null, null, null, List.of(), 61L, "CUSTOMS", "清关费", "RECEIVABLE", LocalDate.of(2026, 8, 11), "CNY", amount, "APPROVED", publicationStatus, LocalDateTime.of(2026, 8, 11, 1, 0), BigDecimal.ONE, "SHIPMENT", BigDecimal.TEN, BigDecimal.ONE, "source-hash", version); 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/FinancePhase1OperationBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java index 03e429d9c..55d71920b 100644 --- a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java @@ -144,7 +144,7 @@ class FinancePhase1OperationBehaviorTest { when(rs.getString("currency_code")).thenReturn("CNY"); when(rs.getString("review_status")).thenReturn("APPROVED"); when(rs.getString("effective_status")).thenReturn("AVAILABLE"); - when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"supplierId\":79,\"supplierName\":\"供应商A\",\"inboundCode\":\"IN-9\",\"containerNo\":\"CONT-9\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\"}"); + when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"supplierId\":\"79\",\"supplierName\":\"供应商A\",\"inboundCode\":\"IN-9\",\"containerNo\":\"CONT-9\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\"}"); when(rs.getBigDecimal("amount")).thenReturn(new BigDecimal("120.00")); when(rs.getBoolean("statement_generated")).thenReturn(false); return List.of(mapper.mapRow(rs, 0)); @@ -164,6 +164,7 @@ class FinancePhase1OperationBehaviorTest { assertThat(source.sourceBusinessId()).isEqualTo("IN-9"); assertThat(source.inboundCode()).isEqualTo("IN-9"); assertThat(source.containerNo()).isEqualTo("CONT-9"); + assertThat(source.supplierId()).isEqualTo(79L); assertThat(source.supplierName()).isEqualTo("供应商A"); assertThat(source.reviewStatus()).isEqualTo("APPROVED"); assertThat(source.businessDate()).isEqualTo(LocalDate.of(2026, 8, 11)); @@ -364,6 +365,7 @@ class FinancePhase1OperationBehaviorTest { return (List) List.of(new FinancePhase1Dtos.AccountSummary( 9L, "9", "客户9", null, null, new BigDecimal("100.00"), new BigDecimal("20.00"), new BigDecimal("30.00"), BigDecimal.ZERO, + BigDecimal.ZERO, BigDecimal.ZERO, "ACTIVE", LocalDateTime.of(2026, 8, 11, 10, 0))); } if (sql.startsWith("SELECT id,bill_no") || sql.startsWith("SELECT id,receipt_no") 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/FinancePlatformOptionalCustomerQueryTest.java b/finance/src/test/java/com/baiyue/finance/FinancePlatformOptionalCustomerQueryTest.java index 568c5e3dd..c54a04c07 100644 --- a/finance/src/test/java/com/baiyue/finance/FinancePlatformOptionalCustomerQueryTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinancePlatformOptionalCustomerQueryTest.java @@ -24,7 +24,7 @@ class FinancePlatformOptionalCustomerQueryTest { service.receivableSettlements(null, false); service.payableSettlements(null); - assertThat(jdbc.calls).hasSize(6).allSatisfy(call -> { + assertThat(jdbc.calls).hasSize(5).allSatisfy(call -> { assertThat(call.sql()).doesNotContain("customer_id=?", "tenant_id=?"); assertThat(call.args()).isEmpty(); }); @@ -41,7 +41,7 @@ class FinancePlatformOptionalCustomerQueryTest { service.receivableSettlements(31L, false); service.payableSettlements(31L); - assertThat(jdbc.calls).hasSize(6).allSatisfy(call -> { + assertThat(jdbc.calls).hasSize(5).allSatisfy(call -> { assertThat(call.sql()).containsAnyOf("customer_id=?", "tenant_id=?"); assertThat(call.args()).containsExactly(31L); }); diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java index 333cf48e4..19f41a707 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java @@ -10,6 +10,7 @@ import com.baiyue.finance.dto.FinanceSettlementDtos.CreatePayableSettlementReque import com.baiyue.finance.dto.FinanceSettlementDtos.CreateReceivableSettlementRequest; import com.baiyue.finance.dto.FinanceSettlementDtos.CreateSettlementPaymentRequest; import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail; +import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementLine; import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary; import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest; import com.baiyue.finance.service.FinanceEventException; @@ -286,6 +287,66 @@ class FinanceSettlementBehaviorTest { verify(jdbc).update(startsWith("INSERT INTO fin_settlement_line"), eq(88L), eq(10L), eq(501L)); } + @Test + void receivableSettlementLineDoesNotRequireSupplierSnapshot() { + Map row = new java.util.HashMap<>(Map.ofEntries( + Map.entry("fee_source_id", 31L), Map.entry("source_type", "FBA_PRICING"), + Map.entry("source_fee_id", 41L), Map.entry("source_fee_version", 1), + Map.entry("direction", "RECEIVABLE"), Map.entry("customer_id", 10L), + Map.entry("source_snapshot", "{\"customerName\":\"客户10\",\"businessDate\":\"2026-08-11\"}"), + Map.entry("fee_item_name", "头程运费"), Map.entry("amount", new BigDecimal("100.00")), + Map.entry("currency_code", "CNY"), Map.entry("review_status", "APPROVED"), + Map.entry("status", "GENERATED"))); + row.put("order_id", null); + row.put("inbound_id", null); + row.put("container_id", null); + + SettlementLine line = ReflectionTestUtils.invokeMethod( + service(org.mockito.Mockito.mock(JdbcTemplate.class), org.mockito.Mockito.mock(FinanceFileClient.class)), + "line", row, detail("RECEIVABLE", "ISSUED", BigDecimal.ZERO, + new BigDecimal("100.00"), 0).summary()); + + assertThat(line.direction()).isEqualTo("RECEIVABLE"); + assertThat(line.supplierId()).isNull(); + assertThat(line.customerName()).isEqualTo("客户10"); + } + + @Test + void payableSettlementLineStillRequiresSupplierSnapshot() { + Map row = new java.util.HashMap<>(Map.ofEntries( + Map.entry("direction", "PAYABLE"), + Map.entry("source_snapshot", "{\"customerName\":\"客户10\",\"businessDate\":\"2026-08-11\"}"), + Map.entry("amount", new BigDecimal("100.00")))); + + assertCode(() -> ReflectionTestUtils.invokeMethod( + service(org.mockito.Mockito.mock(JdbcTemplate.class), org.mockito.Mockito.mock(FinanceFileClient.class)), + "line", row, detail("PAYABLE", "ISSUED", BigDecimal.ZERO, + new BigDecimal("100.00"), 0).summary()), "FIN_409"); + } + + @Test + void payableSettlementLineAcceptsStringEncodedSupplierIdSnapshot() { + Map row = new java.util.HashMap<>(Map.ofEntries( + Map.entry("fee_source_id", 31L), Map.entry("source_type", "INBOUND_FEE"), + Map.entry("source_fee_id", 41L), Map.entry("source_fee_version", 1), + Map.entry("direction", "PAYABLE"), Map.entry("customer_id", 10L), + Map.entry("source_snapshot", "{\"customerName\":\"客户10\",\"supplierId\":\"2090349552523644929\",\"supplierName\":\"供应商A\",\"businessDate\":\"2026-08-11\"}"), + Map.entry("fee_item_name", "装车费"), Map.entry("amount", new BigDecimal("80.00")), + Map.entry("currency_code", "CNY"), Map.entry("review_status", "APPROVED"), + Map.entry("status", "AVAILABLE"))); + row.put("order_id", null); + row.put("inbound_id", null); + row.put("container_id", null); + + SettlementLine line = ReflectionTestUtils.invokeMethod( + service(org.mockito.Mockito.mock(JdbcTemplate.class), org.mockito.Mockito.mock(FinanceFileClient.class)), + "line", row, detail("PAYABLE", "ISSUED", BigDecimal.ZERO, + new BigDecimal("80.00"), 0).summary()); + + assertThat(line.supplierId()).isEqualTo(2090349552523644929L); + assertThat(line.supplierName()).isEqualTo("供应商A"); + } + @Test void receivableCreationRejectsAlreadySettledStatement() { JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class); @@ -335,6 +396,22 @@ class FinanceSettlementBehaviorTest { verify(jdbc, never()).update(any(PreparedStatementCreator.class), any(KeyHolder.class)); } + @Test + void directPaidSourceCannotEnterPayableSettlement() { + JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class); + when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), + eq("SETTLEMENT_AP_CREATE"), eq("ap-direct"))).thenReturn(List.of()); + Map source = payableSource(10L, 20L); + source.put("settlement_mode", "DIRECT_PAID"); + when(jdbc.queryForList(startsWith("SELECT id,customer_id"), eq(31L))).thenReturn(List.of(source)); + FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)); + + assertCode(() -> service.createPayableSettlement(7L, "ap-direct", + new CreatePayableSettlementRequest(10L, 20L, List.of(31L), + LocalDate.of(2026, 9, 30), "CNY")), "FIN_409"); + verify(jdbc, never()).update(startsWith("INSERT INTO fin_settlement"), any(Object[].class)); + } + @Test void payableCreationRejectsMixedSupplierIneligibleAndOccupiedSources() { for (String condition : List.of("supplier", "ineligible", "occupied")) { @@ -588,6 +665,7 @@ class FinanceSettlementBehaviorTest { Map.entry("id", 31L), Map.entry("customer_id", customerId), Map.entry("direction", "PAYABLE"), Map.entry("currency_code", "CNY"), Map.entry("status", "AVAILABLE"), Map.entry("amount", new BigDecimal("100.00")), Map.entry("review_status", "APPROVED"), + Map.entry("settlement_mode", "ON_ACCOUNT"), Map.entry("supplier_id", supplierId), Map.entry("fee_item_code", "FBA_HEAD_FREIGHT"), Map.entry("fee_item_name", "头程运费"), Map.entry("supplier_name", "供应商20"), Map.entry("source_snapshot", 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/mapper/ContainerBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java index 50ec10a34..f5e905ab8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java @@ -11,7 +11,7 @@ import org.apache.ibatis.annotations.Update; @Mapper public interface ContainerBookingMapper extends BaseMapper { - @Update("UPDATE fl_container_booking SET container_no=#{containerNo},ocean_booking_id=#{oceanBookingId},ocean_booking_requirement_id=#{requirementId},record_status='ACTIVE',bill_of_lading_no=#{billOfLadingNo},vessel_name=#{vesselName},voyage_no=#{voyageNo},shipping_company_id=#{shippingCompanyId},shipping_company_name=#{shippingCompanyName},departure_port_code=#{departurePortCode},departure_port_name=#{departurePortName},destination_port_code=#{destinationPortCode},destination_port_name=#{destinationPortName},channel_code=#{channelCode},channel_name=#{channelName},customer_id=#{customerId},customer_name=#{customerName},estimated_sailing_at=#{estimatedSailingAt},estimated_port_arrival_at=#{estimatedArrivalAt},updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=NOW(),version=version+1 WHERE id=#{id} AND version=#{version} AND source_type='SIMULATION' AND record_status='ACTIVE' AND ocean_booking_id IS NULL") + @Update("UPDATE fl_container_booking SET container_no=#{containerNo},ocean_booking_id=#{oceanBookingId},ocean_booking_requirement_id=#{requirementId},record_status='ACTIVE',bill_of_lading_no=#{billOfLadingNo},vessel_name=#{vesselName},voyage_no=#{voyageNo},shipping_company_id=#{shippingCompanyId},shipping_company_name=#{shippingCompanyName},departure_port_code=#{departurePortCode},departure_port_name=#{departurePortName},destination_port_code=#{destinationPortCode},destination_port_name=#{destinationPortName},channel_code=#{channelCode},channel_name=#{channelName},customer_id=#{customerId},customer_name=#{customerName},estimated_sailing_at=#{estimatedSailingAt},estimated_port_arrival_at=#{estimatedPortArrivalAt},updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=NOW(),version=version+1 WHERE id=#{id} AND version=#{version} AND source_type='SIMULATION' AND record_status='ACTIVE' AND ocean_booking_id IS NULL") int linkReleasedBooking(@Param("id") long id, @Param("oceanBookingId") long oceanBookingId, @Param("requirementId") long requirementId, @Param("containerNo") String containerNo, @Param("billOfLadingNo") String billOfLadingNo, @Param("vesselName") String vesselName, @Param("voyageNo") String voyageNo, @Param("shippingCompanyId") Long shippingCompanyId, @Param("shippingCompanyName") String shippingCompanyName, @Param("departurePortCode") String departurePortCode, @Param("departurePortName") String departurePortName, @Param("destinationPortCode") String destinationPortCode, @Param("destinationPortName") String destinationPortName, @Param("channelCode") String channelCode, @Param("channelName") String channelName, @Param("customerId") Long customerId, @Param("customerName") String customerName, @Param("estimatedSailingAt") java.time.LocalDateTime estimatedSailingAt, @Param("estimatedPortArrivalAt") java.time.LocalDateTime estimatedPortArrivalAt, @Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); @Select("""