diff --git a/AGENTS.md b/AGENTS.md index c139e9e48..e22d2aabc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,8 +26,8 @@ | 履约分配、入库、测量、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md` | | Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 | | Mapper 新增、移动、Bean 缺失与扫描遗漏 | 目标持久化服务 | 所有 Mapper 显式 `@Mapper`,启动类按服务根包注解过滤扫描;先运行 `node scripts/mapper-scan-contract.mjs`,再按目标模块执行 `test-compile/test`,不得等部署启动发现 | -| 分柜、柜次、智能排柜、预装/装柜清单、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位 | -| 同柜不可拆组、跨客户合柜、整组分配/移除 | `firstleg/` | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`、`src/main/resources/openapi/coload-groups-v1.yaml`;按订单建组并冻结全部可用入仓片段,仅要求同国内仓、同渠道 | +| 分柜、柜次、智能排柜、预装/装柜清单、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位;预装/正式装柜详情阶段由 `/loading-plans/{planId}/lifecycle` 按稳定 planId 解析,不从 404 或文案推断 | +| 合柜报关 / 同柜不可拆组、跨客户合柜、整组分配/移除 | `firstleg/` | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`、`src/main/resources/openapi/coload-groups-v1.yaml`;按订单建组并冻结全部可用入仓片段,仅要求同国内仓、同渠道 | | 智能装柜入仓码候选与箱数分配 | `firstleg/` | `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/plans/{planId}/inbound-assignments`,业务编排 `service/SmartLoadingService.java` | | 订舱、船期、拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`outbound/dispatch/`、`customs/` | | 客户账期、费用池、账单、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` | diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 62c1e574a..948e216fe 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -11068,7 +11068,7 @@ CREATE TABLE IF NOT EXISTS fin_cost ( fee_item_code VARCHAR(64) NOT NULL COMMENT '成本费用项目编码快照', fee_item_name VARCHAR(200) NOT NULL COMMENT '成本费用项目名称快照', currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY', - cost_amount DECIMAL(20,6) NOT NULL COMMENT '成本总金额,精度6位小数', + cost_amount DECIMAL(20,6) NOT NULL COMMENT '成本总金额;仅FIRSTLEG_DIRECT_PAID且CONFIRMED计入已线下支付供应商款', voucher_file_ids JSON NOT NULL COMMENT 'File服务财务凭证文件ID字符串数组JSON', status VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT '成本状态:DRAFT草稿、CONFIRMED已确认、VOIDED已作废', remark VARCHAR(500) NULL COMMENT '平台成本内部备注', @@ -11185,8 +11185,8 @@ CREATE TABLE IF NOT EXISTS fin_settlement ( business_period VARCHAR(32) NULL COMMENT '结算业务期间快照', currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY', total_amount DECIMAL(20,6) NOT NULL COMMENT '正式结算总金额,精度6位小数', - paid_amount DECIMAL(20,6) NOT NULL DEFAULT 0 COMMENT '累计已收或已付金额,精度6位小数', - outstanding_amount DECIMAL(20,6) NOT NULL COMMENT '未结清金额,精度6位小数', + paid_amount DECIMAL(20,6) NOT NULL DEFAULT 0 COMMENT '累计已收或已付金额;PAYABLE已付计入已线下支付供应商款', + outstanding_amount DECIMAL(20,6) NOT NULL COMMENT '未结清金额;RECEIVABLE计待收,PAYABLE与未制单ON_ACCOUNT来源共同计待付', generated_date DATE NOT NULL COMMENT '结算单生成日期', due_date DATE NOT NULL COMMENT '结算到期日期', payment_status VARCHAR(24) NOT NULL DEFAULT 'UNPAID' COMMENT '收付款状态:UNPAID未收付、PARTIALLY_PAID部分收付、PAID已结清', @@ -12427,7 +12427,7 @@ CALL queue_column_comment('baiyue_finance', 'fin_bill_revision', 'version', '调 CALL queue_column_comment('baiyue_finance', 'fin_cost', 'confirmed_at', '成本确认时间'); CALL queue_column_comment('baiyue_finance', 'fin_cost', 'confirmed_by', '成本确认操作人ID'); CALL queue_column_comment('baiyue_finance', 'fin_cost', 'container_id', 'Firstleg装柜计划ID,整柜公共成本必填'); -CALL queue_column_comment('baiyue_finance', 'fin_cost', 'cost_amount', '成本总金额,精度6位小数'); +CALL queue_column_comment('baiyue_finance', 'fin_cost', 'cost_amount', '成本总金额;仅FIRSTLEG_DIRECT_PAID且CONFIRMED计入已线下支付供应商款'); CALL queue_column_comment('baiyue_finance', 'fin_cost', 'cost_no', '客户范围内唯一成本编号'); CALL queue_column_comment('baiyue_finance', 'fin_cost', 'cost_type', '成本归属类型:SINGLE_ORDER单票成本、CONTAINER_SHARED整柜公共成本'); CALL queue_column_comment('baiyue_finance', 'fin_cost', 'created_at', '记录创建时间'); 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 0c0184061..6cead789a 100644 --- a/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java +++ b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java @@ -46,7 +46,7 @@ public class PlatformFinanceController { @GetMapping("/customer-accounts/{customerId}") @PreAuthorize("hasAuthority('platform:finance:customer-account:list')") - @Operation(summary = "客户人工应收账户及流水") + @Operation(summary = "客户正式应收账户及收款流水") public ApiResponse customerAccount(@PathVariable long customerId) { return FinanceResponses.success(queryService.customerAccount(customerId)); } 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 94c2cf4b2..b13ed5196 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java @@ -62,17 +62,17 @@ public class FinanceCustomerAccountService { String placeholders = String.join(",", customerIds.stream().map(id -> "?").toList()); Map result = new HashMap<>(); customerIds.forEach(id -> result.put(id, new MutableMetrics())); - jdbc.queryForList("SELECT tenant_id customer_id,SUM(outstanding_amount) amount FROM fin_bill 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='RECEIVABLE' AND status<>'VOIDED' GROUP BY tenant_id", customerIds.toArray()) .forEach(row -> metric(result, row, "outstandingReceivable", "amount")); - jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM fin_fee_source f WHERE customer_id IN (" + placeholders + ") AND direction='RECEIVABLE' AND 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) GROUP BY customer_id", customerIds.toArray()) + jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM fin_fee_source f WHERE customer_id IN (" + placeholders + ") AND direction='RECEIVABLE' AND review_status='APPROVED' AND currency_code='CNY' AND amount>0 AND status='AVAILABLE' 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 bl.source_version=f.source_fee_version AND b.status<>'VOIDED') GROUP BY customer_id", customerIds.toArray()) .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 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)) + 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 currency_code='CNY' AND amount>0 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 cost_origin='FIRSTLEG_DIRECT_PAID' 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)) + 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 cost_origin='FIRSTLEG_DIRECT_PAID' 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()) + jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM (SELECT tenant_id customer_id,outstanding_amount amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='PAYABLE' AND status<>'VOIDED' UNION ALL SELECT f.customer_id,f.amount FROM fin_fee_source f WHERE f.customer_id IN (" + placeholders + ") AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED') AND NOT EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.direction='PAYABLE' AND s.status<>'VOIDED')) payable_obligations GROUP BY customer_id", concat(customerIds, 2)) .forEach(row -> metric(result, row, "outstandingPayable", "amount")); 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")); 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 ab12c6bc4..989f86b1d 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceFeeSourceEventService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceFeeSourceEventService.java @@ -72,6 +72,7 @@ public class FinanceFeeSourceEventService { } long id = ((Number) row.get("id")).longValue(); if ("FIRSTLEG_FEE_VOIDED".equals(event.eventType())) { + requireNotFrozen(id); jdbc.update("UPDATE fin_fee_source SET status='VOIDED',operation_version=operation_version+1 WHERE id=? AND status<>'VOIDED'", id); voidDirectPaidCost(snapshot); } @@ -80,6 +81,7 @@ public class FinanceFeeSourceEventService { if ("FIRSTLEG_FEE_VOIDED".equals(event.eventType())) { throw error("FIN-EVT-005", "作废事件对应的费用来源版本不存在"); } + requireSourceNotFrozen(snapshot.feeId()); jdbc.update("UPDATE fin_fee_source SET status='SUPERSEDED' WHERE source_system='FIRSTLEG' AND source_fee_id=? AND status='AVAILABLE'", snapshot.feeId()); 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'", @@ -100,6 +102,16 @@ public class FinanceFeeSourceEventService { return sourceId; } + private void requireSourceNotFrozen(long sourceFeeId) { + Integer frozen = jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source f WHERE f.source_system='FIRSTLEG' AND f.source_fee_id=? AND (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 bl.source_version=f.source_fee_version AND b.status<>'VOIDED') OR EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.status<>'VOIDED'))", Integer.class, sourceFeeId); + if (frozen != null && frozen > 0) throw error("FIN-EVT-016", "费用来源已进入有效单据,禁止调整或替代"); + } + + private void requireNotFrozen(long feeSourceId) { + Integer frozen = jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source f WHERE f.id=? AND (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 bl.source_version=f.source_fee_version AND b.status<>'VOIDED') OR EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.status<>'VOIDED'))", Integer.class, feeSourceId); + if (frozen != null && frozen > 0) throw error("FIN-EVT-016", "费用来源已进入有效单据,禁止作废"); + } + 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)", 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 807dff4d9..59a1c544c 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java @@ -66,12 +66,12 @@ public class FinancePhase1OperationService { String base = " FROM (" + CUSTOMER_IDS + ") c LEFT JOIN fin_customer_settlement_profile p ON p.tenant_id=c.customer_id" + filter; Long total = jdbc.queryForObject("SELECT COUNT(*)" + base, Long.class, filterArgs); String select = "SELECT c.customer_id,COALESCE(p.customer_name_snapshot,CONCAT('客户',c.customer_id)) customer_name,COALESCE(p.status,'ACTIVE') account_status," + - "(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(outstanding_amount),0) FROM fin_settlement ar WHERE ar.tenant_id=c.customer_id AND ar.direction='RECEIVABLE' AND ar.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.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status='AVAILABLE' 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 bl.source_version=f.source_fee_version AND b.status<>'VOIDED')) 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(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," + + "((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.currency_code='CNY' AND f.amount>0 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.cost_origin='FIRSTLEG_DIRECT_PAID' 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.cost_origin='FIRSTLEG_DIRECT_PAID' 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')+(SELECT COALESCE(SUM(f.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.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED') AND NOT EXISTS(SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.direction='PAYABLE' AND s.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; @@ -89,14 +89,15 @@ public class FinancePhase1OperationService { List rows = new ArrayList<>(); rows.addAll(jdbc.query("SELECT id,bill_no,created_at,total_amount,created_by,created_by_name,remark FROM fin_bill WHERE tenant_id=? AND status<>'VOIDED'", (rs, n) -> raw(rs, "SETTLEMENT_ISSUED", "BILL", "bill_no", "total_amount", null), customerId)); rows.addAll(jdbc.query("SELECT id,receipt_no,received_at received_time,received_amount,created_by,created_by_name,remark FROM fin_receipt WHERE tenant_id=? AND status='CONFIRMED'", (rs, n) -> raw(rs, "RECEIPT", "RECEIPT", "receipt_no", null, "received_amount"), customerId)); - rows.addAll(jdbc.query("SELECT id,cost_no,created_at,cost_amount,created_by,created_by_name,remark FROM fin_cost WHERE tenant_id=? AND status<>'VOIDED'", (rs, n) -> raw(rs, "PAYMENT", "COST", "cost_no", null, null), customerId)); + rows.addAll(jdbc.query("SELECT id,cost_no,created_at,cost_amount,created_by,created_by_name,remark FROM fin_cost WHERE tenant_id=? AND cost_origin='FIRSTLEG_DIRECT_PAID' AND status='CONFIRMED'", (rs, n) -> raw(rs, "SUPPLIER_DIRECT_PAID", "DIRECT_PAID_COST", "cost_no", null, null), customerId)); + rows.addAll(jdbc.query("SELECT p.id,CONCAT(s.settlement_no,'-',p.id) payment_no,p.created_at,p.amount cost_amount,p.created_by,p.created_by_name,p.remark FROM fin_settlement_payment p JOIN fin_settlement s ON s.id=p.settlement_id AND s.tenant_id=p.tenant_id WHERE p.tenant_id=? AND p.transaction_type='PAYMENT' AND s.direction='PAYABLE' AND s.status<>'VOIDED'", (rs, n) -> raw(rs, "SUPPLIER_ON_ACCOUNT_PAYMENT", "AP_PAYMENT", "payment_no", null, null), customerId)); rows.sort(Comparator.comparing(RawLedger::occurredAt).thenComparing(RawLedger::id)); BigDecimal outstanding = BigDecimal.ZERO; List projected = new ArrayList<>(); for (RawLedger row : rows) { BigDecimal receivableChange = row.receivable(); outstanding = outstanding.add(receivableChange); - BigDecimal payable = "COST".equals(row.documentType()) ? row.amount() : BigDecimal.ZERO; + BigDecimal payable = List.of("DIRECT_PAID_COST", "AP_PAYMENT").contains(row.documentType()) ? row.amount() : BigDecimal.ZERO; LedgerEntry entry = new LedgerEntry(row.id(), row.occurredAt(), row.businessType(), row.documentType(), row.id(), row.documentNo(), receivableChange, payable, outstanding, row.operatorName(), row.remark()); if ((businessType == null || businessType.isBlank() || businessType.equals(entry.businessType())) && inRange(entry.occurredAt(), startDate, endDate)) projected.add(entry); } @@ -343,7 +344,8 @@ public class FinancePhase1OperationService { private static RawLedger raw(ResultSet rs, String businessType, String documentType, String noColumn, String debitColumn, String creditColumn) throws SQLException { LocalDateTime occurred = time(rs, documentType.equals("RECEIPT") ? "received_time" : "created_at"); - BigDecimal amount = documentType.equals("COST") ? money(rs, "cost_amount") : debitColumn != null ? money(rs, debitColumn) : money(rs, creditColumn); + BigDecimal amount = List.of("DIRECT_PAID_COST", "AP_PAYMENT").contains(documentType) + ? money(rs, "cost_amount") : debitColumn != null ? money(rs, debitColumn) : money(rs, creditColumn); BigDecimal receivable = debitColumn != null ? amount : creditColumn != null ? amount.negate() : BigDecimal.ZERO; return new RawLedger(rs.getLong("id"), occurred, businessType, documentType, rs.getString(noColumn), amount, receivable, rs.getLong("created_by"), rs.getString("created_by_name"), rs.getString("remark")); 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 1997a8924..1ad94c228 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java @@ -19,7 +19,6 @@ import com.baiyue.finance.dto.FinanceReadModels.ReconciliationSummary; import com.baiyue.finance.dto.FinanceReadModels.StatementDetail; import com.baiyue.finance.dto.FinanceReadModels.StatementLine; import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.BadSqlGrammarException; import org.springframework.stereotype.Service; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; @@ -49,12 +48,12 @@ public class FinanceQueryService { public CustomerAccount customerAccount(long customerId) { requireCustomer(customerId); - BigDecimal total = amount("SELECT COALESCE(SUM(total_amount),0) FROM fin_bill WHERE tenant_id=? AND status<>'VOIDED'", customerId); - BigDecimal received = amount("SELECT COALESCE(SUM(received_amount),0) FROM fin_bill WHERE tenant_id=? AND status<>'VOIDED'", customerId); - BigDecimal outstanding = amount("SELECT COALESCE(SUM(outstanding_amount),0) FROM fin_bill WHERE tenant_id=? AND status<>'VOIDED'", customerId); + BigDecimal total = amount("SELECT COALESCE(SUM(total_amount),0) FROM fin_settlement WHERE tenant_id=? AND direction='RECEIVABLE' AND status<>'VOIDED'", customerId); + BigDecimal received = amount("SELECT COALESCE(SUM(paid_amount),0) FROM fin_settlement WHERE tenant_id=? AND direction='RECEIVABLE' AND status<>'VOIDED'", customerId); + BigDecimal outstanding = amount("SELECT COALESCE(SUM(outstanding_amount),0) FROM fin_settlement WHERE tenant_id=? AND direction='RECEIVABLE' AND status<>'VOIDED'", customerId); List entries = new ArrayList<>(); - entries.addAll(jdbc.query("SELECT id,bill_no,created_at,total_amount,currency_code,status FROM fin_bill WHERE tenant_id=? AND status<>'VOIDED'", (rs, rowNum) -> - new AccountEntry("RECEIVABLE", rs.getLong("id"), rs.getString("bill_no"), time(rs, "created_at"), + entries.addAll(jdbc.query("SELECT id,settlement_no,created_at,total_amount,currency_code,status FROM fin_settlement WHERE tenant_id=? AND direction='RECEIVABLE' AND status<>'VOIDED'", (rs, rowNum) -> + new AccountEntry("RECEIVABLE", rs.getLong("id"), rs.getString("settlement_no"), time(rs, "created_at"), rs.getBigDecimal("total_amount"), BigDecimal.ZERO, rs.getString("currency_code"), rs.getString("status")), customerId)); entries.addAll(jdbc.query("SELECT id,receipt_no,received_at,received_amount,currency_code,status FROM fin_receipt WHERE tenant_id=? AND status<>'VOIDED'", (rs, rowNum) -> new AccountEntry("RECEIPT", rs.getLong("id"), rs.getString("receipt_no"), time(rs, "received_at"), @@ -69,33 +68,28 @@ public class FinanceQueryService { "COALESCE(SUM(CASE WHEN s.direction='RECEIVABLE' AND s.status<>'VOIDED' THEN s.total_amount ELSE 0 END),0) total_receivable," + "COALESCE(SUM(CASE WHEN s.direction='RECEIVABLE' AND s.status<>'VOIDED' THEN s.paid_amount ELSE 0 END),0) received_amount," + "COALESCE(SUM(CASE WHEN s.direction='RECEIVABLE' AND s.status<>'VOIDED' THEN s.outstanding_amount ELSE 0 END),0) outstanding_receivable," + - "COALESCE(SUM(CASE WHEN s.direction='PAYABLE' AND s.status<>'VOIDED' THEN s.outstanding_amount ELSE 0 END),0) outstanding_payable," + "(SELECT COALESCE(SUM(f.amount),0) FROM fin_fee_source f WHERE f.customer_id=? AND f.direction='RECEIVABLE' " + "AND f.currency_code='CNY' AND f.status='AVAILABLE' " + "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," + + "((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.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED'))+" + + "(SELECT COALESCE(SUM(c.cost_amount),0) FROM fin_cost c WHERE c.tenant_id=? AND c.cost_origin='FIRSTLEG_DIRECT_PAID' AND c.status='CONFIRMED')) total_cost," + + "((SELECT COALESCE(SUM(c.cost_amount),0) FROM fin_cost c WHERE c.tenant_id=? AND c.cost_origin='FIRSTLEG_DIRECT_PAID' AND c.status='CONFIRMED')+" + + "(SELECT COALESCE(SUM(ps.paid_amount),0) FROM fin_settlement ps WHERE ps.tenant_id=? AND ps.direction='PAYABLE' AND ps.status<>'VOIDED')) paid_cost," + + "((SELECT COALESCE(SUM(os.outstanding_amount),0) FROM fin_settlement os WHERE os.tenant_id=? AND os.direction='PAYABLE' AND os.status<>'VOIDED')+" + + "(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.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED') " + + "AND NOT EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement aps ON aps.id=sl.settlement_id AND aps.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND aps.direction='PAYABLE' AND aps.status<>'VOIDED'))) outstanding_payable," + "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')," + "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, "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, BigDecimal.ZERO, BigDecimal.ZERO, - Instant.now(), null); - } + 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, "total_cost"), money(rs, "paid_cost"), + money(rs, "outstanding_payable"), Instant.now(), time(rs, "last_changed_at")), + customerId, customerId, customerId, customerId, customerId, customerId, customerId, + customerId, customerId, customerId); } public List feePool(Long customerId, String direction) { diff --git a/finance/src/main/resources/db/finance.sql b/finance/src/main/resources/db/finance.sql index dd583fe13..cf2fd567c 100644 --- a/finance/src/main/resources/db/finance.sql +++ b/finance/src/main/resources/db/finance.sql @@ -34,6 +34,7 @@ CREATE TABLE IF NOT EXISTS fin_fee_source ( PRIMARY KEY (id), UNIQUE KEY uk_fin_fee_source (source_system, source_fee_id, source_fee_version), KEY idx_fin_fee_source_customer (customer_id, status), + KEY idx_fin_fee_source_customer_fund (customer_id, direction, settlement_mode, review_status, status, currency_code, id), CONSTRAINT chk_fin_fee_source_currency CHECK (currency_code = 'CNY'), CONSTRAINT chk_fin_fee_source_amount CHECK (amount >= 0), CONSTRAINT chk_fin_fee_source_settlement CHECK ( @@ -227,7 +228,7 @@ CREATE TABLE IF NOT EXISTS fin_cost ( fee_item_code VARCHAR(64) NOT NULL COMMENT '成本费用项目编码快照', fee_item_name VARCHAR(200) NOT NULL COMMENT '成本费用项目名称快照', currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY', - cost_amount DECIMAL(20,6) NOT NULL COMMENT '成本总金额,精度6位小数', + cost_amount DECIMAL(20,6) NOT NULL COMMENT '成本总金额;仅FIRSTLEG_DIRECT_PAID且CONFIRMED计入已线下支付供应商款', 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', @@ -250,6 +251,7 @@ CREATE TABLE IF NOT EXISTS fin_cost ( 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), + KEY idx_fin_cost_customer_origin_status (tenant_id, cost_origin, status), CONSTRAINT chk_fin_cost_tenant CHECK (tenant_id > 0), CONSTRAINT chk_fin_cost_currency CHECK (currency_code = 'CNY'), CONSTRAINT chk_fin_cost_amount CHECK (cost_amount > 0), @@ -356,8 +358,8 @@ CREATE TABLE IF NOT EXISTS fin_settlement ( business_period VARCHAR(32) NULL COMMENT '结算业务期间快照', currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY', total_amount DECIMAL(20,6) NOT NULL COMMENT '正式结算总金额,精度6位小数', - paid_amount DECIMAL(20,6) NOT NULL DEFAULT 0 COMMENT '累计已收或已付金额,精度6位小数', - outstanding_amount DECIMAL(20,6) NOT NULL COMMENT '未结清金额,精度6位小数', + paid_amount DECIMAL(20,6) NOT NULL DEFAULT 0 COMMENT '累计已收或已付金额;PAYABLE已付计入已线下支付供应商款', + outstanding_amount DECIMAL(20,6) NOT NULL COMMENT '未结清金额;RECEIVABLE计待收,PAYABLE与未制单ON_ACCOUNT来源共同计待付', generated_date DATE NOT NULL COMMENT '结算单生成日期', due_date DATE NOT NULL COMMENT '结算到期日期', payment_status VARCHAR(24) NOT NULL DEFAULT 'UNPAID' COMMENT '收付款状态:UNPAID未收付、PARTIALLY_PAID部分收付、PAID已结清', @@ -415,6 +417,7 @@ CREATE TABLE IF NOT EXISTS fin_settlement_payment ( PRIMARY KEY (id), UNIQUE KEY uk_fin_settlement_payment_no (tenant_id, transaction_no), KEY idx_fin_settlement_payment_settlement (tenant_id, settlement_id, created_at), + KEY idx_fin_settlement_payment_customer_type_time (tenant_id, transaction_type, created_at, settlement_id), CONSTRAINT chk_fin_settlement_payment_tenant CHECK (tenant_id > 0), CONSTRAINT chk_fin_settlement_payment_currency CHECK (currency_code = 'CNY'), CONSTRAINT chk_fin_settlement_payment_amount CHECK (amount > 0) diff --git a/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java index f99d622ce..109765346 100644 --- a/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java @@ -16,8 +16,10 @@ import java.math.BigDecimal; import java.sql.ResultSet; import java.sql.Timestamp; 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.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; @@ -27,11 +29,36 @@ import org.springframework.jdbc.BadSqlGrammarException; class CustomerAccountSummaryBehaviorTest { + @Test + void accountReadUsesFormalArSettlementsAndKeepsReceiptHistorySeparate() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForObject(anyString(), org.mockito.ArgumentMatchers.eq(BigDecimal.class), + org.mockito.ArgumentMatchers.eq(9L))).thenReturn(new BigDecimal("100.00")); + when(jdbc.query(anyString(), any(RowMapper.class), org.mockito.ArgumentMatchers.eq(9L))) + .thenReturn(List.of()); + + var result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccount(9L); + + assertThat(result.accountMode()).isEqualTo("MANUAL_RECEIVABLE_LEDGER"); + var amountSql = org.mockito.ArgumentCaptor.forClass(String.class); + verify(jdbc, org.mockito.Mockito.times(3)).queryForObject(amountSql.capture(), + org.mockito.ArgumentMatchers.eq(BigDecimal.class), org.mockito.ArgumentMatchers.eq(9L)); + assertThat(amountSql.getAllValues()).allSatisfy(sql -> assertThat(sql) + .contains("FROM fin_settlement", "direction='RECEIVABLE'", "status<>'VOIDED'")); + var entrySql = org.mockito.ArgumentCaptor.forClass(String.class); + verify(jdbc, org.mockito.Mockito.times(2)).query(entrySql.capture(), any(RowMapper.class), + org.mockito.ArgumentMatchers.eq(9L)); + assertThat(entrySql.getAllValues()).anySatisfy(sql -> assertThat(sql) + .contains("settlement_no", "FROM fin_settlement", "direction='RECEIVABLE'")); + assertThat(entrySql.getAllValues()).anySatisfy(sql -> assertThat(sql) + .contains("FROM fin_receipt", "status<>'VOIDED'")); + } + @Test void aggregatesFormalSettlementsInOneCustomerScopedQuery() throws Exception { JdbcTemplate jdbc = mock(JdbcTemplate.class); 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 -> + when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())).thenAnswer(invocation -> ((RowMapper) invocation.getArgument(1)).mapRow(rs, 0)); FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper()); @@ -52,14 +79,15 @@ class CustomerAccountSummaryBehaviorTest { 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), 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("os.direction='PAYABLE' AND os.status<>'VOIDED'") + .contains("f.settlement_mode='ON_ACCOUNT'", "c.cost_origin='FIRSTLEG_DIRECT_PAID'", "ps.paid_amount") .contains("f.customer_id=?", "f.status='AVAILABLE'", "f.review_status='APPROVED'", "f.amount>0") - .contains("b.status<>'VOIDED'") + .contains("b.status<>'VOIDED'", "FROM fin_settlement_line sl", "sl.fee_source_id=f.id", "aps.status<>'VOIDED'") .doesNotContain("JSON_EXTRACT") .doesNotContain("SUM(received_amount)", "FROM fin_receipt r"); } @@ -68,7 +96,7 @@ class CustomerAccountSummaryBehaviorTest { void emptyCustomerReturnsZeroAmounts() throws Exception { JdbcTemplate jdbc = mock(JdbcTemplate.class); 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 -> + when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), 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); @@ -83,23 +111,13 @@ class CustomerAccountSummaryBehaviorTest { } @Test - void missingSettlementProjectionReturnsZeroCnyBaseline() { + void missingSettlementProjectionFailsClosedInsteadOfReturningFakeZeroAmounts() { JdbcTemplate jdbc = mock(JdbcTemplate.class); - when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any())) + when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) .thenThrow(new BadSqlGrammarException("customer summary", "SELECT ...", null)); - CustomerAccountSummary result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(88L); - - assertThat(result.customerId()).isEqualTo(88L); - assertThat(result.currency()).isEqualTo("CNY"); - assertThat(result.totalReceivable()).isZero(); - 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(); + assertThatThrownBy(() -> new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(88L)) + .isInstanceOf(BadSqlGrammarException.class); } @Test diff --git a/finance/src/test/java/com/baiyue/finance/FinanceCustomerAccountServiceTest.java b/finance/src/test/java/com/baiyue/finance/FinanceCustomerAccountServiceTest.java index 60e14fd13..62b556735 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceCustomerAccountServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceCustomerAccountServiceTest.java @@ -16,6 +16,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -42,6 +43,29 @@ class FinanceCustomerAccountServiceTest { verify(iam).page("internal-token", 1, 20, "Alice", "ACTIVE", 900000000000000001L); } + @Test + void operatingFundQueriesUseFormalArUnbilledApDedupAndSupplierOnlyPaidAmounts() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + IamCustomerClient iam = mock(IamCustomerClient.class); + when(jdbc.queryForList(anyString(), any(Object[].class))).thenReturn(List.of()); + when(iam.page("internal-token", 1, 20, null, null, 9L)).thenReturn(ApiResponse.success( + new PageResult<>(List.of(new IamCustomerClient.Customer(9L, "C-9", "客户9", "ACTIVE", null, null)), + 1, 20, 1, 1))); + + new FinanceCustomerAccountService(jdbc, iam, "internal-token").list(1, 20, null, null, 9L); + + var sql = org.mockito.ArgumentCaptor.forClass(String.class); + verify(jdbc, atLeastOnce()).queryForList(sql.capture(), any(Object[].class)); + String all = String.join("\n", sql.getAllValues()); + assertThat(all) + .contains("SUM(outstanding_amount) amount FROM fin_settlement", "direction='RECEIVABLE'") + .contains("cost_origin='FIRSTLEG_DIRECT_PAID' AND status='CONFIRMED'") + .contains("direction='PAYABLE' AND status<>'VOIDED'") + .contains("NOT EXISTS (SELECT 1 FROM fin_settlement_line") + .contains("sl.fee_source_id=f.id", "s.status<>'VOIDED'") + .contains("review_status='APPROVED'", "currency_code='CNY'", "amount>0"); + } + @Test void rejectsUnsupportedFinanceAccountStatus() { var service = new FinanceCustomerAccountService(mock(JdbcTemplate.class), mock(IamCustomerClient.class), "token"); diff --git a/finance/src/test/java/com/baiyue/finance/FinanceExportContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceExportContractTest.java index d7cfa0777..5da3b2749 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceExportContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceExportContractTest.java @@ -49,6 +49,11 @@ class FinanceExportContractTest { assertThat(openApi).contains("/internal/exports/v1/{exportType}/batches:") .contains("customerId?, status?, settlementNo?") .contains("所有 DETAIL 仅 settlementId") + .contains("仅三类运营资金:待收客户款、待付供应商款、已线下支付供应商款") + .contains("operatingCards: [outstandingReceivable, outstandingPayable, paidCost]") + .contains("辅助字段只允许用于列表、明细或历史说明,不得生成第四类运营资金卡片") + .contains("AP outstanding加未制单有效ON_ACCOUNT来源并去重") + .contains("已线下支付供应商款;不存在客户线下支付含义") .doesNotContain("DETAIL: settlementId, direction"); for (String type : TYPES) { assertThat(openApi).contains("- " + type); diff --git a/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java b/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java index b57a6b50e..72c3be7ef 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java @@ -147,6 +147,22 @@ class FinanceFeeSourceEventServiceTest { verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class)); } + @Test + void adjustmentRejectsSourceFrozenByActiveBillOrAp() { + FinanceFeeSourceEvent event = inboundEvent("adjusted-frozen", "FIRSTLEG_FEE_ADJUSTED", 2, 7); + givenNewEvent(); + when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(2))) + .thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null))); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source f WHERE f.source_system='FIRSTLEG'"), + eq(Integer.class), eq(31L))).thenReturn(1); + + assertThatThrownBy(() -> service.consume(event)) + .isInstanceOf(FinanceEventException.class) + .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-016"); + verify(jdbc, never()).update(contains("status='SUPERSEDED'"), anyLong()); + verify(jdbc, never()).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class)); + } + @Test void voidMarksExistingVersionWithoutDeletingHistory() { FinanceFeeSourceEvent event = inboundEvent("voided-2", "FIRSTLEG_FEE_VOIDED", 2, 7); @@ -160,6 +176,24 @@ class FinanceFeeSourceEventServiceTest { verify(jdbc, never()).update(startsWith("DELETE"), any(Object[].class)); } + @Test + void voidRejectsSourceFrozenByActiveBillOrAp() { + FinanceFeeSourceEvent event = inboundEvent("voided-frozen", "FIRSTLEG_FEE_VOIDED", 2, 7); + when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("voided-frozen"))) + .thenReturn(List.of()); + when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(2))) + .thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null))); + when(jdbc.queryForList(startsWith("SELECT id,source_hash,status"), eq(31L), eq(2))) + .thenReturn(List.of(Map.of("id", 88L, "source_hash", "source-hash", "status", "AVAILABLE"))); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source f WHERE f.id=?"), + eq(Integer.class), eq(88L))).thenReturn(1); + + assertThatThrownBy(() -> service.consume(event)) + .isInstanceOf(FinanceEventException.class) + .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-016"); + verify(jdbc, never()).update(contains("SET status='VOIDED'"), eq(88L)); + } + @Test void sameEventAndContentReturnsFirstResourceWithoutCallingFirstlegAgain() throws Exception { FinanceFeeSourceEvent event = inboundEvent("same-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7); diff --git a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java index 55d71920b..3f422a86a 100644 --- a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java @@ -106,6 +106,9 @@ class FinancePhase1OperationBehaviorTest { assertThat(result.requestId()).isNotBlank(); assertThat(result.traceId()).isNotBlank(); assertThat(jdbc.accountListSql).contains("p.customer_name_snapshot"); + assertThat(jdbc.accountListSql) + .contains("ar.direction='RECEIVABLE'", "cost_origin='FIRSTLEG_DIRECT_PAID'", "FROM fin_settlement_line sl") + .contains("sl.fee_source_id=f.id", "s.status<>'VOIDED'"); assertThat(jdbc.accountListSql).doesNotContain("CAST(c.customer_id AS CHAR)"); } @@ -118,7 +121,11 @@ class FinancePhase1OperationBehaviorTest { service.listDerivedCustomerAccountLedger(9L, 1, 20, null, null, null); assertThat(result.data()).isEmpty(); - assertThat(jdbc.ledgerCustomerIds).containsExactly(9L, 9L, 9L); + assertThat(jdbc.ledgerCustomerIds).containsExactly(9L, 9L, 9L, 9L); + assertThat(jdbc.ledgerSql).anySatisfy(sql -> assertThat(sql) + .contains("cost_origin='FIRSTLEG_DIRECT_PAID'", "status='CONFIRMED'")); + assertThat(jdbc.ledgerSql).anySatisfy(sql -> assertThat(sql) + .contains("fin_settlement_payment", "transaction_type='PAYMENT'", "s.direction='PAYABLE'")); } @Test @@ -349,6 +356,7 @@ class FinancePhase1OperationBehaviorTest { private static final class AccountJdbc extends JdbcTemplate { private final List ledgerCustomerIds = new ArrayList<>(); + private final List ledgerSql = new ArrayList<>(); private String accountListSql = ""; @Override @@ -369,8 +377,9 @@ class FinancePhase1OperationBehaviorTest { "ACTIVE", LocalDateTime.of(2026, 8, 11, 10, 0))); } if (sql.startsWith("SELECT id,bill_no") || sql.startsWith("SELECT id,receipt_no") - || sql.startsWith("SELECT id,cost_no")) { + || sql.startsWith("SELECT id,cost_no") || sql.startsWith("SELECT p.id")) { ledgerCustomerIds.add(((Number) Arrays.asList(args).get(0)).longValue()); + ledgerSql.add(sql); return List.of(); } return List.of(); diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java index 38b19749b..45e7d486b 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java @@ -56,6 +56,8 @@ class FinanceSqlCommentContractTest { assertThat(sql).contains("RECEIVED已接收、PROCESSED已完成、FAILED处理失败"); assertThat(sql).contains("Firstleg费用来源完整不可变JSON快照"); assertThat(sql).contains("ON_ACCOUNT账期应付、DIRECT_PAID业务已付"); + assertThat(sql).contains("仅FIRSTLEG_DIRECT_PAID且CONFIRMED计入已线下支付供应商款"); + assertThat(sql).contains("RECEIVABLE计待收,PAYABLE与未制单ON_ACCOUNT来源共同计待付"); assertThat(sql).contains("规范化完整请求的SHA-256十六进制哈希"); } @@ -72,6 +74,17 @@ class FinanceSqlCommentContractTest { .contains("queue_column_comment('baiyue_finance', 'fin_bill_line', 'adjustment_reason'"); } + @Test + void operatingFundAggregatesHaveDedicatedBaselineIndexes() throws Exception { + String sql = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8); + assertThat(sql) + .contains("KEY idx_fin_fee_source_customer_fund (customer_id, direction, settlement_mode, review_status, status, currency_code, id)") + .contains("KEY idx_fin_cost_customer_origin_status (tenant_id, cost_origin, status)") + .contains("KEY idx_fin_settlement_customer_direction_status (tenant_id, direction, status)") + .contains("KEY idx_fin_settlement_line_source (fee_source_id)") + .contains("KEY idx_fin_settlement_payment_customer_type_time (tenant_id, transaction_type, created_at, settlement_id)"); + } + @Test void financeLedgerSourcesPersistOperatorNameSnapshotsInBaselineAndUnifiedSql() throws Exception { String baseline = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8); diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 3fe5c446d..3ceb946fd 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -28,7 +28,7 @@ | 财务费用来源快照 | `controller/InternalFinanceFeeSourceController.java`;入库费用与柜次费用分别按来源 ID、版本向 Finance 提供不可变快照 | | 柜次费用明细 | `controller/PlatformContainerFeeDetailController.java`、`service/ContainerFeeDetailService.java`、`dto/ContainerFeeDetailDtos.java`;Admin 消费端见 `../../apps/admin-web/src/modules/loading-lists/api/containerFees.ts` | | 财务整柜成本分摊快照 | `loading/controller/InternalFinanceCostSnapshotController.java`、`loading/service/FinanceCostSnapshotService.java` | -| 分柜与预装 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java` | +| 分柜、预装与正式装柜清单生命周期 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java`、`service/LoadingListService.java`;契约 `src/main/resources/openapi/loading-lists-v1.yaml`。同一 `planId` 在 `EDITING/LOADING` 返回 `PRELOAD`,`COMPLETED` 返回 `LOADING_LIST` 与独立读取权限;调用方先查 `/loading-plans/{planId}/lifecycle`,不得用 A0404 或错误文案猜阶段 | | 同柜不可拆组 | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`;契约 `src/main/resources/openapi/coload-groups-v1.yaml`。请求按订单 ID 建组,服务端冻结每票全部 `WAIT_SPLIT` 可用入仓片段与满箱数;仅要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均保持整组原子 | | 货柜与智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`loading/`;按入仓码候选与箱数分配分别从 `/inbound-candidates`、`/plans/{planId}/inbound-assignments` 进入,明细规格拆分留在服务端 | | 货柜资料、PDA 证据与业务总览 | `controller/PlatformContainerController.java` 的 `/{id}/overview`、`/{id}/documents/{documentType}`,聚合 `mapper/ContainerOverviewMapper.java`;契约 `src/main/resources/openapi/phase3-container-overview-v1.yaml`,四类资料固定为目的港清关、国内出口报关、DO、PU 的当前唯一版本 | diff --git a/order/AGENTS.md b/order/AGENTS.md index 1955634b8..897fd98c9 100644 --- a/order/AGENTS.md +++ b/order/AGENTS.md @@ -12,6 +12,8 @@ | 目标 | 入口 | |---|---| | 平台订单 API | `controller/PlatformFirstLegOrderController.java` | +| 统一订单查询与稳定渠道编码 | `service/UnifiedOrderQueryService.java`、`dto/UnifiedOrderResponse.java`、`src/main/resources/openapi/order-v1.yaml`;`channelCode` 直接来自 Order/Firstleg 权威快照,只用于业务校验,不得由 `channelName` 推导 | +| 入仓码业务关系摘要 | `service/InboundCodeAssociationService.java`、`dto/InboundCodeAssociationSummary.java`、平台/客户 `FirstLegOrderController` 的 `/inbound-code-associations/{inboundCode}`;客户入口必须按认证客户隔离 | | 订单编排/状态/幂等 | `service/OrderApplicationService.java`、`entity/OrderStatus.java` | | 商户订单列表与批量草稿 | `service/MerchantInboundOrderListService.java`、`service/MerchantBatchDraftService.java`、`service/MerchantBatchDraftItemService.java` | | 入库号生成 | `config/InboundCodeModeProvider.java`、`mapper/InboundCodeSequenceMapper.java`、`repository/OrderRepository.java`、`service/OrderApplicationService.java` | diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index dc1ee96b4..89089c664 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -115,6 +115,16 @@ Assert-Contains $backendAgents 'loading/coload/controller/PlatformColoadGroupCon Assert-Contains $firstlegAgents '服务端冻结每票全部 `WAIT_SPLIT` 可用入仓片段与满箱数' 'Firstleg 同柜建组边界' Assert-Contains $coloadOpenApi '允许跨客户及不同目的仓港成组,仅要求国内仓和渠道一致' '同柜资格 OpenAPI' Assert-Contains $coloadOpenApi '服务端解析每个订单全部 WAIT_SPLIT' '同柜订单展开 OpenAPI' +Assert-Contains $rootAgents '用户可见页面、提示和错误不得展示或以内部数据库 ID 作为业务编号回退' '根业务编号展示边界' +Assert-Contains $adminAgents '/loading-plans/{planId}/lifecycle' 'Admin 装柜清单生命周期入口' +Assert-Contains $adminAgents '合柜报关 / 同柜不可拆组' 'Admin 合柜报关别名入口' +Assert-Contains $clientAgents 'api/inboundCodeAssociations.ts' 'Client 入仓码业务关系入口' +Assert-Contains $pdaAgents 'utils/user-facing.js' '国内 PDA 业务编号入口' +Assert-Contains $overseasPdaAgents 'tests/business-identifiers-ui.test.mjs' '海外 PDA 内部 ID 隔离合同' +$orderAgents = Read-RequiredText (Join-Path $backendRoot 'order/AGENTS.md') +Assert-Contains $orderAgents '`channelCode` 直接来自 Order/Firstleg 权威快照' 'Order 稳定渠道编码' +Assert-Contains $orderAgents 'InboundCodeAssociationService.java' 'Order 入仓码业务关系入口' +Assert-Contains $firstlegAgents '/loading-plans/{planId}/lifecycle' 'Firstleg 装柜生命周期入口' $mapperModuleAgents = @( @{ Module = 'audit-service'; Application = 'BaiyueAuditApplication.java' }, @{ Module = 'auth'; Application = 'BaiyueAuthApplication.java' }, @@ -416,6 +426,7 @@ $criticalPaths = @( 'apps/admin-web/src/modules/container-loading/api/platformContainerCustoms.ts', 'apps/admin-web/src/modules/container-loading/api/platformContainerCustoms.test.ts', 'apps/admin-web/src/modules/container-loading/api/platformContainerLoading.ts', + 'apps/admin-web/src/modules/loading-lists/pages/PreloadPlanDetailView.lifecycle.test.ts', 'apps/admin-web/src/modules/unified-orders/api/coLoadGroups.ts', 'apps/admin-web/src/modules/unified-orders/pages/UnifiedOrdersView.vue', 'apps/admin-web/src/modules/ocean-bookings/api/platformOceanBookings.ts', @@ -441,6 +452,8 @@ $criticalPaths = @( 'apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue', 'apps/client-web/src/modules/first-leg/api/customs.ts', 'apps/client-web/src/modules/first-leg/api/customs.test.mjs', + 'apps/client-web/src/modules/first-leg/api/inboundCodeAssociations.ts', + 'apps/client-web/src/modules/first-leg/components/InboundCodeAssociationDialog.vue', 'apps/admin-web/src/modules/overseas-business/api/platformOverseasBusiness.ts', 'apps/admin-web/src/modules/overseas-master-data/api/overseasMasterData.ts', 'apps/pda-uniapp/pages/tasks/tasks.vue', @@ -451,6 +464,8 @@ $criticalPaths = @( 'apps/pda-uniapp/tests/app-update.test.mjs', 'apps/pda-uniapp/tests/partial-layout.test.mjs', 'apps/pda-uniapp/tests/picking-location-display.test.mjs', + 'apps/pda-uniapp/utils/user-facing.js', + 'apps/pda-uniapp/tests/user-facing-identifiers.test.mjs', 'apps/pda-uniapp/utils/evidence-upload-queue.js', 'apps/pda-overseas-uniapp/package.json', 'apps/pda-overseas-uniapp/src/pages.json', @@ -468,6 +483,7 @@ $criticalPaths = @( 'apps/pda-overseas-uniapp/tests/app-icons.test.mjs', 'apps/pda-overseas-uniapp/src/utils/app-update.js', 'apps/pda-overseas-uniapp/tests/app-update.test.mjs', + 'apps/pda-overseas-uniapp/tests/business-identifiers-ui.test.mjs', 'apps/pda-overseas-uniapp/src/i18n/index.js', 'apps/pda-overseas-uniapp/src/i18n/locales/zh-CN.js', 'apps/pda-overseas-uniapp/src/i18n/locales/en.js', @@ -494,9 +510,13 @@ $criticalPaths = @( 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java', 'baiyue-firstleg/firstleg/src/main/resources/openapi/customs-v1.yaml', 'baiyue-firstleg/firstleg/src/main/resources/openapi/coload-groups-v1.yaml', + 'baiyue-firstleg/firstleg/src/main/resources/openapi/loading-lists-v1.yaml', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/controller/PlatformColoadGroupController.java', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/service/ColoadGroupService.java', 'baiyue-firstleg/file/src/main/resources/openapi/customs-file-and-export-v1.yaml', + 'baiyue-firstleg/order/src/main/java/com/baiyue/order/service/InboundCodeAssociationService.java', + 'baiyue-firstleg/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java', + 'baiyue-firstleg/order/src/main/resources/openapi/order-v1.yaml', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/controller/PlatformOutboundDispatchController.java', 'baiyue-firstleg/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml', 'baiyue-firstleg/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml', @@ -673,5 +693,5 @@ if ($failures.Count -gt 0) { exit 1 } -Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、四期范围漂移、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口和排除目录。" -ForegroundColor Green +Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、业务编号与内部 ID 隔离、装柜清单生命周期、四期范围漂移、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口和排除目录。" -ForegroundColor Green exit 0