diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index b3ad4cdd3..62b4336a8 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -11408,6 +11408,12 @@ DROP PROCEDURE IF EXISTS firstleg_finance_add_index; CREATE TABLE IF NOT EXISTS fl_container_fee_detail ( id BIGINT UNSIGNED NOT NULL COMMENT '柜次费用来源ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', + allocation_group_id BIGINT UNSIGNED NULL COMMENT '柜级公共费用分摊组ID;单入仓直接费用为空', + allocation_order_id BIGINT UNSIGNED NULL COMMENT '本分摊行业务订单ID', + allocation_order_no VARCHAR(64) NULL COMMENT '本分摊行业务订单号', + allocation_inbound_id BIGINT UNSIGNED NULL COMMENT '本分摊行对应入仓单ID', + allocation_inbound_code VARCHAR(64) NULL COMMENT '本分摊行对应入仓码', + allocation_group_amount DECIMAL(18,2) NULL COMMENT '柜级原始费用总额快照', loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '柜次ID,对应fl_loading_plan.id', container_booking_id BIGINT UNSIGNED NULL COMMENT '订舱柜ID快照', container_no VARCHAR(64) NULL COMMENT '柜号快照', @@ -11462,6 +11468,12 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_source_snapshot ( source_fee_id BIGINT UNSIGNED NOT NULL COMMENT '柜次费用来源ID', source_version INT UNSIGNED NOT NULL COMMENT '费用来源版本', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', + allocation_group_id BIGINT UNSIGNED NULL COMMENT '柜级公共费用分摊组ID', + allocation_order_id BIGINT UNSIGNED NULL COMMENT '分摊业务订单ID快照', + allocation_order_no VARCHAR(64) NULL COMMENT '分摊业务订单号快照', + allocation_inbound_id BIGINT UNSIGNED NULL COMMENT '分摊入仓单ID快照', + allocation_inbound_code VARCHAR(64) NULL COMMENT '分摊入仓码快照', + allocation_group_amount DECIMAL(18,2) NULL COMMENT '柜级原始费用总额快照', loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '柜次ID', container_booking_id BIGINT UNSIGNED NULL COMMENT '订舱柜ID快照', container_no VARCHAR(64) NULL COMMENT '柜号快照', @@ -11530,6 +11542,18 @@ CALL firstleg_finance_add_column('fl_container_fee_detail', 'payment_reference_n "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_detail', 'allocation_group_id', + "BIGINT UNSIGNED NULL COMMENT '柜级公共费用分摊组ID;单入仓直接费用为空' AFTER tenant_id"); +CALL firstleg_finance_add_column('fl_container_fee_detail', 'allocation_order_id', + "BIGINT UNSIGNED NULL COMMENT '本分摊行业务订单ID' AFTER allocation_group_id"); +CALL firstleg_finance_add_column('fl_container_fee_detail', 'allocation_order_no', + "VARCHAR(64) NULL COMMENT '本分摊行业务订单号' AFTER allocation_order_id"); +CALL firstleg_finance_add_column('fl_container_fee_detail', 'allocation_inbound_id', + "BIGINT UNSIGNED NULL COMMENT '本分摊行对应入仓单ID' AFTER allocation_order_no"); +CALL firstleg_finance_add_column('fl_container_fee_detail', 'allocation_inbound_code', + "VARCHAR(64) NULL COMMENT '本分摊行对应入仓码' AFTER allocation_inbound_id"); +CALL firstleg_finance_add_column('fl_container_fee_detail', 'allocation_group_amount', + "DECIMAL(18,2) NULL COMMENT '柜级原始费用总额快照' AFTER allocation_inbound_code"); 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', @@ -11540,6 +11564,18 @@ CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'payment_re "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_container_fee_source_snapshot', 'allocation_group_id', + "BIGINT UNSIGNED NULL COMMENT '柜级公共费用分摊组ID' AFTER tenant_id"); +CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'allocation_order_id', + "BIGINT UNSIGNED NULL COMMENT '分摊业务订单ID快照' AFTER allocation_group_id"); +CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'allocation_order_no', + "VARCHAR(64) NULL COMMENT '分摊业务订单号快照' AFTER allocation_order_id"); +CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'allocation_inbound_id', + "BIGINT UNSIGNED NULL COMMENT '分摊入仓单ID快照' AFTER allocation_order_no"); +CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'allocation_inbound_code', + "VARCHAR(64) NULL COMMENT '分摊入仓码快照' AFTER allocation_inbound_id"); +CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'allocation_group_amount', + "DECIMAL(18,2) NULL COMMENT '柜级原始费用总额快照' AFTER allocation_inbound_code"); 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', @@ -11627,10 +11663,24 @@ CALL finance_fee_source_add_column('fin_fee_source', 'source_type', "VARCHAR(32) NULL COMMENT '来源费用类型:INBOUND_FEE入库单费用、CONTAINER_FEE柜子费用' AFTER customer_id"); CALL finance_fee_source_add_column('fin_fee_source', 'source_no', "VARCHAR(64) NULL COMMENT '来源入库单号或柜号快照,可为空' AFTER source_type"); +CALL finance_fee_source_add_column('fin_fee_source', 'business_type', + "VARCHAR(32) NULL COMMENT '来源业务类型,如海外入仓、采购、卡车或柜级分摊' AFTER source_no"); +CALL finance_fee_source_add_column('fin_fee_source', 'business_id', + "BIGINT UNSIGNED NULL COMMENT '来源业务记录ID,可为空' AFTER business_type"); +CALL finance_fee_source_add_column('fin_fee_source', 'business_no', + "VARCHAR(128) NULL COMMENT '来源业务编号快照,可为空' AFTER business_id"); +CALL finance_fee_source_add_column('fin_fee_source', 'inbound_code', + "VARCHAR(64) NULL COMMENT '来源入仓码快照;并非所有费用必填' AFTER business_no"); +CALL finance_fee_source_add_column('fin_fee_source', 'warehouse_id', + "BIGINT UNSIGNED NULL COMMENT '来源海外仓或业务仓ID,可为空' AFTER inbound_code"); CALL finance_fee_source_add_column('fin_fee_source', 'direction', "VARCHAR(16) NULL COMMENT '费用方向:RECEIVABLE应收、PAYABLE应付' AFTER source_no"); CALL finance_fee_source_add_column('fin_fee_source', 'container_id', "BIGINT UNSIGNED NULL COMMENT '来源Firstleg装柜计划ID,可为空' AFTER inbound_id"); +CALL finance_fee_source_add_column('fin_fee_source', 'container_booking_id', + "BIGINT UNSIGNED NULL COMMENT '来源订舱柜ID,可为空' AFTER container_id"); +CALL finance_fee_source_add_column('fin_fee_source', 'container_no', + "VARCHAR(64) NULL COMMENT '真实集装箱号快照,可为空' AFTER container_booking_id"); 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', @@ -11681,6 +11731,8 @@ 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', 'warehouse_id', + "BIGINT UNSIGNED NULL COMMENT '业务直接已付成本关联仓库ID,可为空' AFTER container_id"); 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', @@ -11690,6 +11742,22 @@ CALL finance_fee_source_add_column('fin_cost', 'payment_reference_no', DROP PROCEDURE IF EXISTS finance_fee_source_add_column; +SET @finance_fee_source_index_ddl = ( + SELECT IF( + (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='fin_fee_source' + AND index_name='uk_fin_fee_source')=4, + 'SELECT 1', + IF(EXISTS(SELECT 1 FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='fin_fee_source' AND index_name='uk_fin_fee_source'), + 'ALTER TABLE fin_fee_source DROP INDEX uk_fin_fee_source, ADD UNIQUE KEY uk_fin_fee_source(source_system,source_type,source_fee_id,source_fee_version)', + 'ALTER TABLE fin_fee_source ADD UNIQUE KEY uk_fin_fee_source(source_system,source_type,source_fee_id,source_fee_version)') + ) +); +PREPARE finance_fee_source_index_stmt FROM @finance_fee_source_index_ddl; +EXECUTE finance_fee_source_index_stmt; +DEALLOCATE PREPARE finance_fee_source_index_stmt; + DROP PROCEDURE IF EXISTS finance_fee_source_add_index; DELIMITER $$ CREATE PROCEDURE finance_fee_source_add_index() @@ -11708,7 +11776,9 @@ CALL finance_fee_source_add_index(); DROP PROCEDURE IF EXISTS finance_fee_source_add_index; ALTER TABLE fin_cost - MODIFY COLUMN cost_type VARCHAR(24) NOT NULL COMMENT '成本归属类型:SINGLE_ORDER单票、CONTAINER_SHARED整柜公共、SOURCE_DIRECT_PAID来源直接已付'; + MODIFY COLUMN cost_type VARCHAR(24) NOT NULL COMMENT '成本归属类型:SINGLE_ORDER单票、CONTAINER_SHARED整柜公共、SOURCE_DIRECT_PAID来源直接已付', + MODIFY COLUMN cost_origin VARCHAR(24) NOT NULL DEFAULT 'MANUAL' COMMENT '成本来源:MANUAL人工补充、FIRSTLEG_DIRECT_PAID头程直接已付、BUSINESS_DIRECT_PAID其他业务直接已付', + MODIFY COLUMN source_hash VARCHAR(71) NULL COMMENT '来源不可变快照SHA-256,兼容sha256前缀格式'; DROP PROCEDURE IF EXISTS finance_fee_source_replace_cost_checks; DELIMITER $$ CREATE PROCEDURE finance_fee_source_replace_cost_checks() @@ -11725,7 +11795,7 @@ BEGIN ADD 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)) + OR (cost_type='SOURCE_DIRECT_PAID' AND (order_id IS NOT NULL OR inbound_id IS NOT NULL OR container_id IS NOT NULL OR warehouse_id IS NOT NULL)) ), ADD 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) @@ -11733,6 +11803,10 @@ BEGIN 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')) + OR (cost_origin='BUSINESS_DIRECT_PAID' AND source_system NOT IN ('', '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')) ); END$$ DELIMITER ; @@ -11756,12 +11830,12 @@ ALTER TABLE fin_fee_source MODIFY COLUMN source_type VARCHAR(32) NOT NULL COMMENT '来源费用类型:INBOUND_FEE入库单费用、CONTAINER_FEE柜子费用', MODIFY COLUMN direction VARCHAR(16) NOT NULL COMMENT '费用方向:RECEIVABLE应收、PAYABLE应付', MODIFY COLUMN fee_item_code VARCHAR(64) NOT NULL COMMENT '来源费用项目编码快照', - MODIFY COLUMN source_hash CHAR(64) NOT NULL COMMENT 'Firstleg费用来源完整快照SHA-256哈希', - MODIFY COLUMN source_snapshot JSON NOT NULL COMMENT 'Firstleg费用来源完整不可变JSON快照', + MODIFY COLUMN source_hash VARCHAR(71) NOT NULL COMMENT '业务费用来源完整快照SHA-256哈希,兼容sha256前缀格式', + MODIFY COLUMN source_snapshot JSON NOT NULL COMMENT '业务费用来源完整不可变JSON快照', MODIFY COLUMN status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE' COMMENT '费用来源状态:AVAILABLE可结算、SUPERSEDED已被新版本替代、VOIDED已作废', MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '来源状态最后更新时间,数据修改时自动刷新'; ALTER TABLE fin_inbox_event - MODIFY COLUMN payload_hash CHAR(64) NOT NULL COMMENT '规范化事件内容SHA-256哈希,用于同事件ID冲突检测', + MODIFY COLUMN payload_hash VARCHAR(71) NOT NULL COMMENT '规范化事件内容SHA-256哈希,兼容sha256前缀格式', MODIFY COLUMN status VARCHAR(16) NOT NULL DEFAULT 'RECEIVED' COMMENT '事件处理状态:RECEIVED已接收、PROCESSED已完成、FAILED处理失败'; DROP PROCEDURE IF EXISTS finance_fee_source_assert_schema; @@ -11791,7 +11865,7 @@ BEGIN 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 + 'payment_reference_no','warehouse_id')) <> 10 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance直接已付成本字段不完整'; END IF; IF (SELECT COUNT(*) FROM information_schema.statistics diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 5f9812428..bd7e1d132 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -1116,4 +1116,43 @@ END$$ DELIMITER ; CALL assert_lastleg_security_projection_ready(); DROP PROCEDURE assert_lastleg_security_projection_ready; + +CREATE TABLE IF NOT EXISTS ll_finance_fee_source ( + id BIGINT NOT NULL COMMENT '费用版本记录ID', source_fee_id BIGINT NOT NULL COMMENT '跨版本稳定费用来源ID', + source_version INT NOT NULL COMMENT '费用来源版本,从1递增', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', + customer_name VARCHAR(200) NULL COMMENT '客户名称快照', source_type VARCHAR(64) NOT NULL COMMENT '海外费用来源类型', + source_no VARCHAR(128) NOT NULL COMMENT '来源业务编号或入仓码快照', business_type VARCHAR(32) NOT NULL COMMENT '来源业务类型', + business_id BIGINT NULL COMMENT '来源业务记录ID', business_no VARCHAR(128) NOT NULL COMMENT '来源业务编号', + order_id BIGINT NULL COMMENT '关联订单ID', inbound_id BIGINT NULL COMMENT '关联海外入仓计划或入仓记录ID', + inbound_code VARCHAR(64) NULL COMMENT '关联入仓码;并非所有费用必填', container_id BIGINT NULL COMMENT '关联柜次ID', + container_booking_id BIGINT NULL COMMENT '来源订舱柜ID', container_no VARCHAR(64) NULL COMMENT '真实集装箱号快照', + warehouse_id BIGINT NULL COMMENT '关联海外仓ID', fee_item_code VARCHAR(64) NOT NULL COMMENT '费用项目编码快照', + fee_item_name VARCHAR(200) NOT NULL COMMENT '费用项目名称快照', direction VARCHAR(16) NOT NULL COMMENT 'RECEIVABLE应收或PAYABLE应付', + supplier_id BIGINT NULL COMMENT '应付供应商ID', supplier_name VARCHAR(200) NULL COMMENT '应付供应商名称快照', + supplier_category VARCHAR(32) NULL COMMENT '应付供应商类别约束', settlement_mode VARCHAR(16) NULL COMMENT 'ON_ACCOUNT账期应付或DIRECT_PAID业务已付', + paid_at DATETIME(3) NULL COMMENT '直接已付时间', payment_method VARCHAR(32) NULL COMMENT '直接已付方式', + payment_reference_no VARCHAR(128) NULL COMMENT '直接已付流水号', payment_voucher_file_ids JSON NULL COMMENT '直接已付凭证文件ID快照', + business_date DATE NOT NULL COMMENT '费用业务日期', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '财务结算币种,当前固定CNY', + amount DECIMAL(20,6) NOT NULL COMMENT '费用金额', review_status VARCHAR(16) NOT NULL COMMENT 'DRAFT、PENDING、APPROVED或REJECTED', + publication_status VARCHAR(16) NOT NULL COMMENT 'UNPUBLISHED或PUBLISHED', source_status VARCHAR(16) NOT NULL COMMENT 'ACTIVE、SUPERSEDED或VOIDED', + review_reason VARCHAR(500) NULL COMMENT '审核或作废原因', reviewer_id VARCHAR(128) NULL COMMENT '审核人ID', reviewed_at DATETIME(3) NULL COMMENT '审核时间', + created_by VARCHAR(128) NOT NULL COMMENT '创建人ID', updated_by VARCHAR(128) NOT NULL COMMENT '最后更新人ID', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_finance_fee_version(source_fee_id,source_version), + KEY idx_ll_finance_fee_customer(customer_id,source_status,created_at), KEY idx_ll_finance_fee_business(business_type,business_no,created_at), + CONSTRAINT chk_ll_finance_fee_amount CHECK(amount>0), CONSTRAINT chk_ll_finance_fee_currency CHECK(currency='CNY'), + CONSTRAINT chk_ll_finance_fee_direction CHECK(direction IN('RECEIVABLE','PAYABLE')), + CONSTRAINT chk_ll_finance_fee_review CHECK(review_status IN('DRAFT','PENDING','APPROVED','REJECTED')), + CONSTRAINT chk_ll_finance_fee_publication CHECK(publication_status IN('UNPUBLISHED','PUBLISHED')), + CONSTRAINT chk_ll_finance_fee_status CHECK(source_status IN('ACTIVE','SUPERSEDED','VOIDED')) +) ENGINE=InnoDB COMMENT='海外业务不可变费用来源版本;发布后经跨区事件进入Finance'; + +CREATE TABLE IF NOT EXISTS ll_finance_fee_command ( + id BIGINT NOT NULL COMMENT '幂等命令ID', operator_id VARCHAR(128) NOT NULL COMMENT '操作人ID', + action VARCHAR(16) NOT NULL COMMENT 'CREATE、SUBMIT、REVIEW、ADJUST或VOID', idempotency_key VARCHAR(128) NOT NULL COMMENT '操作幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求内容SHA-256', source_fee_id BIGINT NOT NULL COMMENT '稳定费用来源ID', + source_version INT NOT NULL COMMENT '命令结果版本', created_at DATETIME(3) NOT NULL COMMENT '命令创建时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_finance_fee_command(operator_id,action,idempotency_key), + KEY idx_ll_finance_fee_command_source(source_fee_id,source_version) +) ENGINE=InnoDB COMMENT='海外费用来源写操作幂等审计'; SELECT 'PASS' AS migration_status, '海外仓业务、海外仓资源与IAM安全投影结构已就绪' AS message; diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java index 5224a11e5..4262a7bc5 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java @@ -31,6 +31,7 @@ class OverseasEventDispatcherTest { mock(com.baiyue.eventbridge.service.LastlegEventClient.class); private final com.baiyue.eventbridge.service.FirstlegEventClient firstlegEventClient = mock(com.baiyue.eventbridge.service.FirstlegEventClient.class); + private final FinanceEventClient financeEventClient = mock(FinanceEventClient.class); private final SecurityProjectionEventClient securityProjectionEventClient = mock(SecurityProjectionEventClient.class); private OverseasEventDispatcher dispatcher; private BridgeInbox event; @@ -48,6 +49,7 @@ class OverseasEventDispatcherTest { ReflectionTestUtils.setField(dispatcher, "attemptMapper", attemptMapper); ReflectionTestUtils.setField(dispatcher, "eventClient", eventClient); ReflectionTestUtils.setField(dispatcher, "firstlegEventClient", firstlegEventClient); + ReflectionTestUtils.setField(dispatcher, "financeEventClient", financeEventClient); ReflectionTestUtils.setField(dispatcher, "securityProjectionEventClient", securityProjectionEventClient); ReflectionTestUtils.setField(dispatcher, "properties", properties); ReflectionTestUtils.setField(dispatcher, "metrics", new EventBridgeMetrics(registry)); @@ -150,6 +152,22 @@ class OverseasEventDispatcherTest { assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED"); } + @Test + void overseasFeeSourceIsDeliveredToFinanceAndNeverFirstleg() throws Exception { + event.setEventType(EventTypes.BUSINESS_FEE_SOURCE_CHANGED); + event.setSourceRegion("OVERSEAS"); + event.setTargetRegion("CN"); + when(financeEventClient.deliver(event)).thenReturn("200"); + when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1); + + dispatcher.dispatchOne("event-1"); + + verify(financeEventClient).deliver(event); + verifyNoInteractions(firstlegEventClient); + verifyNoInteractions(eventClient); + assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED"); + } + @Test void iamSecurityEventsAreDeliveredToSecurityProjectionAndNeverLastleg() throws Exception { for (String eventType : List.of(EventTypes.IAM_USER_STATUS_CHANGED, diff --git a/finance/AGENTS.md b/finance/AGENTS.md index eb5fbbf79..9127eb182 100644 --- a/finance/AGENTS.md +++ b/finance/AGENTS.md @@ -22,7 +22,7 @@ | 财务单据编号 | `service/FinanceDocumentNumberGenerator.java`;对账单、应收结算单、应付结算单分别使用 `REC`、`AR`、`AP` 前缀 | | Finance 导出数据 Provider | `controller/FinanceExportProviderController.java`、`service/FinanceExportProviderService.java`;由 File `ExportProviderClient` 按已登记 `exportType` 分批拉取,不从 Admin 直接生成文件 | | 五页产品 OpenAPI | `../../docs/财务管理/openapi/finance-phase1-five-pages-v1.yaml`;人工财务补充契约 `finance-f1-manual-v1.yaml` | -| Firstleg 费用来源事件入口 | `controller/InternalFinanceFeeSourceController.java`、`service/FinanceFeeSourceEventService.java` | +| 业务费用来源事件入口 | Firstleg:`controller/InternalFinanceFeeSourceController.java`、`service/FinanceFeeSourceEventService.java`;海外完整快照:`controller/InternalBusinessFeeSourceController.java`、`service/BusinessFeeSourceEventService.java` | | 查询模型与客户可见性 | `service/FinanceQueryService.java`、`dto/FinanceReadModels.java` | | 账期、账单、收款、成本及幂等写模型 | `service/FinanceService.java` | | Firstleg 费用来源与柜次成本快照 | `client/FirstlegFeeSourceClient.java`、`client/FirstlegCostSnapshotClient.java`;生产端见 `../firstleg/AGENTS.md` | diff --git a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java index ea2e32880..1b38916da 100644 --- a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java +++ b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java @@ -94,7 +94,7 @@ public class BusinessFeeSourceEventService { number(source.containerBookingId()), trim(source.containerNo()), source.feeItemCode().trim(), source.feeItemName().trim(), source.totalAmount(), source.currency(), trim(source.settlementMode()), source.paidAt(), trim(source.paymentMethod()), trim(source.paymentReferenceNo()), - json(source.paymentVoucherFileIds()), source.sourceHash(), json(source)); + voucherJson(source.paymentVoucherFileIds()), source.sourceHash(), json(source)); long id = jdbc.queryForObject("SELECT id FROM fin_fee_source WHERE source_system=? AND source_type=? AND source_fee_id=? AND source_fee_version=?", Long.class, system, source.sourceType(), feeId, source.sourceVersion()); if ("PAYABLE".equals(source.direction()) && "DIRECT_PAID".equals(source.settlementMode())) @@ -131,14 +131,14 @@ public class BusinessFeeSourceEventService { || (value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty())) { throw error("FIN-EVT-012", "应收来源不得携带供应商或付款事实"); } - BusinessFeeSourceChangedV1 unsigned = new BusinessFeeSourceChangedV1(value.action(), value.sourceSystem(), + BusinessFeeSourceChangedV1 unsigned = new BusinessFeeSourceChangedV1(null, value.sourceSystem(), value.sourceType(), value.sourceFeeId(), value.sourceVersion(), value.customerId(), value.customerName(), value.sourceNo(), value.businessType(), value.businessId(), value.businessNo(), value.orderId(), value.inboundId(), value.inboundCode(), value.containerId(), value.containerBookingId(), value.containerNo(), value.warehouseId(), value.feeItemCode(), value.feeItemName(), value.direction(), value.supplierId(), value.supplierName(), value.supplierCategory(), value.settlementMode(), value.paidAt(), value.paymentMethod(), value.paymentReferenceNo(), value.paymentVoucherFileIds(), value.businessDate(), value.currency(), - value.totalAmount(), value.reviewStatus(), value.publicationStatus(), value.sourceStatus(), null); + value.totalAmount(), value.reviewStatus(), "PUBLISHED", "ACTIVE", null); String expected = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(objectMapper.valueToTree(unsigned))); if (!expected.equals(value.sourceHash())) throw error("FIN-EVT-004", "费用来源快照哈希不一致"); } @@ -166,10 +166,10 @@ public class BusinessFeeSourceEventService { } private void materializeDirectPaidCost(String system, long feeId, BusinessFeeSourceChangedV1 value) { - 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','BUSINESS_DIRECT_PAID',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED',CURRENT_TIMESTAMP(3),0,'系统',0)", + 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,warehouse_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','BUSINESS_DIRECT_PAID',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED',CURRENT_TIMESTAMP(3),0,'系统',0)", positive(value.customerId(), "customerId"), "DIRECT-" + system + '-' + value.sourceType() + '-' + feeId + "-V" + value.sourceVersion(), system, value.sourceType(), feeId, value.sourceVersion(), value.sourceHash(), number(value.orderId()), - number(value.inboundId()), number(value.containerId()), positive(value.supplierId(), "supplierId"), + number(value.inboundId()), number(value.containerId()), number(value.warehouseId()), positive(value.supplierId(), "supplierId"), value.supplierName(), value.feeItemCode(), value.feeItemName(), value.currency(), value.totalAmount(), json(value.paymentVoucherFileIds()), value.paidAt(), value.paymentMethod(), value.paymentReferenceNo()); } @@ -187,6 +187,9 @@ public class BusinessFeeSourceEventService { try { return objectMapper.writeValueAsString(value == null ? List.of() : value); } catch (JsonProcessingException exception) { throw error("FIN-EVT-001", "费用事件序列化失败"); } } + private String voucherJson(List values) { + return values == null || values.isEmpty() ? null : json(values); + } private static Long number(String value) { return blank(value) ? null : positive(value, "business identifier"); } private static long positive(String value, String name) { try { long parsed = Long.parseLong(value); if (parsed > 0) return parsed; } 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 b13ed5196..da40b1969 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java @@ -68,9 +68,9 @@ 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 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)) + 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 IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_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 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)) + 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 IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_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 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")); 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 59a1c544c..da7761d6f 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java @@ -34,6 +34,7 @@ import java.util.Comparator; import java.util.HexFormat; import java.util.List; import java.util.Map; +import java.util.Set; @Service public class FinancePhase1OperationService { @@ -69,8 +70,8 @@ public class FinancePhase1OperationService { "(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.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(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 IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_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 IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_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); @@ -89,7 +90,7 @@ 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 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 id,cost_no,created_at,cost_amount,created_by,created_by_name,remark FROM fin_cost WHERE tenant_id=? AND cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_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; @@ -185,8 +186,8 @@ public class FinancePhase1OperationService { throw error("FIN-REQ-400", adjustment ? "费用调整请求无效" : "费用编辑请求无效"); } FeeSourceDetail before = getFinanceFeeSource(feeSourceId); - if ("FIRSTLEG".equals(before.sourceSystem())) { - throw error("FIN-FEE-409", "Firstleg费用必须在入库或柜次费用明细中调整,Finance不可直接修改业务金额"); + if (Set.of("FIRSTLEG", "LASTLEG").contains(before.sourceSystem())) { + throw error("FIN-FEE-409", "业务费用必须回到对应业务费用明细调整,Finance不可直接修改来源金额"); } String operation = adjustment ? "FEE_SOURCE_ADJUST" : "FEE_SOURCE_EDIT"; String path = "/api/platform/finance/v1/fee-sources/" + feeSourceId + (adjustment ? "/adjust" : ""); @@ -222,6 +223,9 @@ public class FinancePhase1OperationService { throw error("FIN-REQ-400", "费用操作请求无效"); } FeeSourceDetail before = getFinanceFeeSource(feeSourceId); + if ("LASTLEG".equals(before.sourceSystem())) { + throw error("FIN-FEE-409", "海外业务费用必须在海外费用来源中作废,Finance只消费作废事件"); + } if (!"FIRSTLEG".equals(before.sourceSystem())) { return transitionFeeSource(feeSourceId, operatorId, key, request, "FEE_SOURCE_VOID", "VOIDED", List.of("DRAFT", "PENDING", "REJECTED", "APPROVED"), true); 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 1ad94c228..fbf7c4278 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java @@ -74,8 +74,8 @@ public class FinanceQueryService { "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.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(c.cost_amount),0) FROM fin_cost c WHERE c.tenant_id=? AND c.cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_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 IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_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') " + 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 d922551f8..580e757d7 100644 --- a/finance/src/main/resources/db/fee-source-event-migration.sql +++ b/finance/src/main/resources/db/fee-source-event-migration.sql @@ -23,10 +23,24 @@ CALL finance_fee_source_add_column('fin_fee_source', 'source_type', "VARCHAR(32) NULL COMMENT '来源费用类型:INBOUND_FEE入库单费用、CONTAINER_FEE柜子费用' AFTER customer_id"); CALL finance_fee_source_add_column('fin_fee_source', 'source_no', "VARCHAR(64) NULL COMMENT '来源入库单号或柜号快照,可为空' AFTER source_type"); +CALL finance_fee_source_add_column('fin_fee_source', 'business_type', + "VARCHAR(32) NULL COMMENT '来源业务类型,如海外入仓、采购、卡车或柜级分摊' AFTER source_no"); +CALL finance_fee_source_add_column('fin_fee_source', 'business_id', + "BIGINT UNSIGNED NULL COMMENT '来源业务记录ID,可为空' AFTER business_type"); +CALL finance_fee_source_add_column('fin_fee_source', 'business_no', + "VARCHAR(128) NULL COMMENT '来源业务编号快照,可为空' AFTER business_id"); +CALL finance_fee_source_add_column('fin_fee_source', 'inbound_code', + "VARCHAR(64) NULL COMMENT '来源入仓码快照;并非所有费用必填' AFTER business_no"); +CALL finance_fee_source_add_column('fin_fee_source', 'warehouse_id', + "BIGINT UNSIGNED NULL COMMENT '来源海外仓或业务仓ID,可为空' AFTER inbound_code"); CALL finance_fee_source_add_column('fin_fee_source', 'direction', "VARCHAR(16) NULL COMMENT '费用方向:RECEIVABLE应收、PAYABLE应付' AFTER source_no"); CALL finance_fee_source_add_column('fin_fee_source', 'container_id', "BIGINT UNSIGNED NULL COMMENT '来源Firstleg装柜计划ID,可为空' AFTER inbound_id"); +CALL finance_fee_source_add_column('fin_fee_source', 'container_booking_id', + "BIGINT UNSIGNED NULL COMMENT '来源订舱柜ID,可为空' AFTER container_id"); +CALL finance_fee_source_add_column('fin_fee_source', 'container_no', + "VARCHAR(64) NULL COMMENT '真实集装箱号快照,可为空' AFTER container_booking_id"); 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', @@ -77,6 +91,8 @@ 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', 'warehouse_id', + "BIGINT UNSIGNED NULL COMMENT '业务直接已付成本关联仓库ID,可为空' AFTER container_id"); 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', @@ -86,6 +102,22 @@ CALL finance_fee_source_add_column('fin_cost', 'payment_reference_no', DROP PROCEDURE IF EXISTS finance_fee_source_add_column; +SET @finance_fee_source_index_ddl = ( + SELECT IF( + (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='fin_fee_source' + AND index_name='uk_fin_fee_source')=4, + 'SELECT 1', + IF(EXISTS(SELECT 1 FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='fin_fee_source' AND index_name='uk_fin_fee_source'), + 'ALTER TABLE fin_fee_source DROP INDEX uk_fin_fee_source, ADD UNIQUE KEY uk_fin_fee_source(source_system,source_type,source_fee_id,source_fee_version)', + 'ALTER TABLE fin_fee_source ADD UNIQUE KEY uk_fin_fee_source(source_system,source_type,source_fee_id,source_fee_version)') + ) +); +PREPARE finance_fee_source_index_stmt FROM @finance_fee_source_index_ddl; +EXECUTE finance_fee_source_index_stmt; +DEALLOCATE PREPARE finance_fee_source_index_stmt; + DROP PROCEDURE IF EXISTS finance_fee_source_add_index; DELIMITER $$ CREATE PROCEDURE finance_fee_source_add_index() @@ -104,7 +136,9 @@ CALL finance_fee_source_add_index(); DROP PROCEDURE IF EXISTS finance_fee_source_add_index; ALTER TABLE fin_cost - MODIFY COLUMN cost_type VARCHAR(24) NOT NULL COMMENT '成本归属类型:SINGLE_ORDER单票、CONTAINER_SHARED整柜公共、SOURCE_DIRECT_PAID来源直接已付'; + MODIFY COLUMN cost_type VARCHAR(24) NOT NULL COMMENT '成本归属类型:SINGLE_ORDER单票、CONTAINER_SHARED整柜公共、SOURCE_DIRECT_PAID来源直接已付', + MODIFY COLUMN cost_origin VARCHAR(24) NOT NULL DEFAULT 'MANUAL' COMMENT '成本来源:MANUAL人工补充、FIRSTLEG_DIRECT_PAID头程直接已付、BUSINESS_DIRECT_PAID其他业务直接已付', + MODIFY COLUMN source_hash VARCHAR(71) NULL COMMENT '来源不可变快照SHA-256,兼容sha256前缀格式'; DROP PROCEDURE IF EXISTS finance_fee_source_replace_cost_checks; DELIMITER $$ CREATE PROCEDURE finance_fee_source_replace_cost_checks() @@ -121,7 +155,7 @@ BEGIN ADD 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)) + OR (cost_type='SOURCE_DIRECT_PAID' AND (order_id IS NOT NULL OR inbound_id IS NOT NULL OR container_id IS NOT NULL OR warehouse_id IS NOT NULL)) ), ADD 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) @@ -129,6 +163,10 @@ BEGIN 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')) + OR (cost_origin='BUSINESS_DIRECT_PAID' AND source_system NOT IN ('', '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')) ); END$$ DELIMITER ; @@ -152,12 +190,12 @@ ALTER TABLE fin_fee_source MODIFY COLUMN source_type VARCHAR(32) NOT NULL COMMENT '来源费用类型:INBOUND_FEE入库单费用、CONTAINER_FEE柜子费用', MODIFY COLUMN direction VARCHAR(16) NOT NULL COMMENT '费用方向:RECEIVABLE应收、PAYABLE应付', MODIFY COLUMN fee_item_code VARCHAR(64) NOT NULL COMMENT '来源费用项目编码快照', - MODIFY COLUMN source_hash CHAR(64) NOT NULL COMMENT 'Firstleg费用来源完整快照SHA-256哈希', - MODIFY COLUMN source_snapshot JSON NOT NULL COMMENT 'Firstleg费用来源完整不可变JSON快照', + MODIFY COLUMN source_hash VARCHAR(71) NOT NULL COMMENT '业务费用来源完整快照SHA-256哈希,兼容sha256前缀格式', + MODIFY COLUMN source_snapshot JSON NOT NULL COMMENT '业务费用来源完整不可变JSON快照', MODIFY COLUMN status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE' COMMENT '费用来源状态:AVAILABLE可结算、SUPERSEDED已被新版本替代、VOIDED已作废', MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '来源状态最后更新时间,数据修改时自动刷新'; ALTER TABLE fin_inbox_event - MODIFY COLUMN payload_hash CHAR(64) NOT NULL COMMENT '规范化事件内容SHA-256哈希,用于同事件ID冲突检测', + MODIFY COLUMN payload_hash VARCHAR(71) NOT NULL COMMENT '规范化事件内容SHA-256哈希,兼容sha256前缀格式', MODIFY COLUMN status VARCHAR(16) NOT NULL DEFAULT 'RECEIVED' COMMENT '事件处理状态:RECEIVED已接收、PROCESSED已完成、FAILED处理失败'; DROP PROCEDURE IF EXISTS finance_fee_source_assert_schema; @@ -187,7 +225,7 @@ BEGIN 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 + 'payment_reference_no','warehouse_id')) <> 10 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance直接已付成本字段不完整'; END IF; IF (SELECT COUNT(*) FROM information_schema.statistics diff --git a/finance/src/main/resources/db/finance.sql b/finance/src/main/resources/db/finance.sql index bb4a41591..ea9b2d17a 100644 --- a/finance/src/main/resources/db/finance.sql +++ b/finance/src/main/resources/db/finance.sql @@ -30,7 +30,7 @@ CREATE TABLE IF NOT EXISTS fin_fee_source ( payment_method VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式:ONLINE、BANK_TRANSFER、CASH、OTHER', payment_reference_no VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号', payment_voucher_file_ids JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组', - source_hash CHAR(64) NOT NULL COMMENT 'Firstleg费用来源完整快照SHA-256哈希', + source_hash VARCHAR(71) NOT NULL COMMENT '业务费用来源完整快照SHA-256哈希,兼容sha256前缀格式', source_snapshot JSON NOT NULL COMMENT 'Firstleg费用来源完整不可变JSON快照', review_status VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT 'Finance费用状态:DRAFT草稿、PENDING待审核、APPROVED已通过、REJECTED已驳回', review_reason VARCHAR(500) NULL COMMENT 'Finance费用审核说明或驳回原因', @@ -61,7 +61,7 @@ CREATE TABLE IF NOT EXISTS fin_inbox_event ( source_system VARCHAR(32) NOT NULL COMMENT '事件来源系统标识', event_type VARCHAR(64) NOT NULL COMMENT '来源事件类型', payload JSON NOT NULL COMMENT '来源事件原始JSON载荷快照', - payload_hash CHAR(64) NOT NULL COMMENT '规范化事件内容SHA-256哈希,用于同事件ID冲突检测', + payload_hash VARCHAR(71) NOT NULL COMMENT '规范化事件内容SHA-256哈希,兼容sha256前缀格式', result_resource_id BIGINT UNSIGNED NULL COMMENT '成功处理生成或定位的费用来源快照ID', error_code VARCHAR(64) NULL COMMENT '处理失败时的稳定FIN错误码', status VARCHAR(16) NOT NULL DEFAULT 'RECEIVED' COMMENT '事件处理状态:RECEIVED已接收、PROCESSED已完成、FAILED处理失败', @@ -221,21 +221,22 @@ CREATE TABLE IF NOT EXISTS fin_cost ( 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整柜公共、SOURCE_DIRECT_PAID来源直接已付', - cost_origin VARCHAR(24) NOT NULL DEFAULT 'MANUAL' COMMENT '成本来源:MANUAL人工补充、FIRSTLEG_DIRECT_PAID头程直接已付', - source_system VARCHAR(32) NULL COMMENT '来源系统,头程直接已付固定FIRSTLEG', + cost_origin VARCHAR(24) NOT NULL DEFAULT 'MANUAL' COMMENT '成本来源:MANUAL人工补充、FIRSTLEG_DIRECT_PAID头程直接已付、BUSINESS_DIRECT_PAID其他业务直接已付', + source_system VARCHAR(32) NULL COMMENT '直接已付来源系统,如FIRSTLEG或LASTLEG', 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', + source_hash VARCHAR(71) NULL COMMENT '来源不可变快照SHA-256,兼容sha256前缀格式', order_id BIGINT UNSIGNED NULL COMMENT '成本归属的头程订单ID,单票成本必填', inbound_id BIGINT UNSIGNED NULL COMMENT '成本关联的入库单ID,可为空', container_id BIGINT UNSIGNED NULL COMMENT 'Firstleg装柜计划ID,整柜公共成本必填', + warehouse_id BIGINT UNSIGNED NULL COMMENT '业务直接已付成本关联仓库ID,可为空', supplier_id BIGINT UNSIGNED NULL COMMENT 'Warehouse公共往来单位或供应商引用ID', supplier_name_snapshot VARCHAR(200) NULL COMMENT '登记成本时供应商或成本对象名称快照', 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 '成本总金额;仅FIRSTLEG_DIRECT_PAID且CONFIRMED计入已线下支付供应商款', + cost_amount DECIMAL(20,6) NOT NULL COMMENT '成本总金额;来源直接已付且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', @@ -265,7 +266,7 @@ 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)) + OR (cost_type = 'SOURCE_DIRECT_PAID' AND (order_id IS NOT NULL OR inbound_id IS NOT NULL OR container_id IS NOT NULL OR warehouse_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) @@ -273,8 +274,12 @@ CREATE TABLE IF NOT EXISTS fin_cost ( 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')) + OR (cost_origin='BUSINESS_DIRECT_PAID' AND source_system NOT IN ('', '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/BusinessFeeSourceEventServiceTest.java b/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java new file mode 100644 index 000000000..817e6b48e --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java @@ -0,0 +1,83 @@ +package com.baiyue.finance; + +import com.baiyue.common.event.BusinessFeeSourceChangedV1; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventPayloadCanonicalizer; +import com.baiyue.common.event.EventPayloadHash; +import com.baiyue.common.event.EventTypes; +import com.baiyue.common.event.RegionCode; +import com.baiyue.finance.service.BusinessFeeSourceEventService; +import com.baiyue.finance.service.FinanceSupplierValidator; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.startsWith; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class BusinessFeeSourceEventServiceTest { + private final JdbcTemplate jdbc = mock(JdbcTemplate.class); + private final ObjectMapper json = new ObjectMapper().findAndRegisterModules(); + private final FinanceSupplierValidator suppliers = mock(FinanceSupplierValidator.class); + private final BusinessFeeSourceEventService service = new BusinessFeeSourceEventService(jdbc, json, suppliers); + + @Test + void importsOverseasFeeWithoutInventingInboundReference() { + EventEnvelope event = eventWithoutInbound(); + when(jdbc.queryForList(startsWith("SELECT payload_hash,status,result_resource_id"), + eq("LASTLEG"), eq(event.eventId()))).thenReturn(List.of()); + when(jdbc.queryForList(startsWith("SELECT id,source_hash,status FROM fin_fee_source"), + eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L), eq(1))).thenReturn(List.of()); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source f WHERE f.source_system=?"), + eq(Integer.class), eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L))).thenReturn(0); + when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class), + eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L), eq(1))).thenReturn(901L); + + assertThat(service.consume(event)).isEqualTo(901L); + + ArgumentCaptor values = ArgumentCaptor.forClass(Object[].class); + verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), values.capture()); + assertThat(values.getValue()[3]).isEqualTo(10001L); + assertThat(values.getValue()[5]).isEqualTo("PO-US-001"); + assertThat(values.getValue()[9]).isNull(); + assertThat(values.getValue()[13]).isNull(); + } + + private EventEnvelope eventWithoutInbound() { + BusinessFeeSourceChangedV1 unsigned = new BusinessFeeSourceChangedV1(null, "LASTLEG", + "OVERSEAS_PROCUREMENT_FEE", "701", 1, "10001", "客户A", "PO-US-001", + "OVERSEAS_PROCUREMENT", "801", "PO-US-001", null, null, null, null, null, + null, "91", "PROCUREMENT_SERVICE", "采购服务费", "RECEIVABLE", null, null, + null, null, null, null, null, List.of(), LocalDate.of(2026, 9, 2), "CNY", + new BigDecimal("120.00"), "APPROVED", "PUBLISHED", "ACTIVE", null); + String sourceHash = EventPayloadHash.sha256( + EventPayloadCanonicalizer.canonicalize(json.valueToTree(unsigned))); + BusinessFeeSourceChangedV1 payload = new BusinessFeeSourceChangedV1("PUBLISHED", "LASTLEG", + "OVERSEAS_PROCUREMENT_FEE", "701", 1, "10001", "客户A", "PO-US-001", + "OVERSEAS_PROCUREMENT", "801", "PO-US-001", null, null, null, null, null, + null, "91", "PROCUREMENT_SERVICE", "采购服务费", "RECEIVABLE", null, null, + null, null, null, null, null, List.of(), LocalDate.of(2026, 9, 2), "CNY", + new BigDecimal("120.00"), "APPROVED", "PUBLISHED", "ACTIVE", sourceHash); + JsonNode node = json.valueToTree(payload); + String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); + return new EventEnvelope("123e4567-e89b-12d3-a456-426614174701", + EventTypes.BUSINESS_FEE_SOURCE_CHANGED, 1, Instant.parse("2026-09-02T08:00:00Z"), + RegionCode.OVERSEAS, RegionCode.CN, "10001", "BUSINESS_FEE", "701", 1, + null, "123e4567-e89b-12d3-a456-426614174702", "LASTLEG:FEE:701:1", null, + node, payloadHash); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java index 109765346..7e4737ea7 100644 --- a/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java @@ -85,7 +85,7 @@ class CustomerAccountSummaryBehaviorTest { .contains("FROM fin_settlement s WHERE s.tenant_id=?") .contains("s.direction='RECEIVABLE' AND s.status<>'VOIDED'") .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.settlement_mode='ON_ACCOUNT'", "c.cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID')", "ps.paid_amount") .contains("f.customer_id=?", "f.status='AVAILABLE'", "f.review_status='APPROVED'", "f.amount>0") .contains("b.status<>'VOIDED'", "FROM fin_settlement_line sl", "sl.fee_source_id=f.id", "aps.status<>'VOIDED'") .doesNotContain("JSON_EXTRACT") diff --git a/finance/src/test/java/com/baiyue/finance/FinanceCustomerAccountServiceTest.java b/finance/src/test/java/com/baiyue/finance/FinanceCustomerAccountServiceTest.java index 62b556735..4628bb385 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceCustomerAccountServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceCustomerAccountServiceTest.java @@ -59,7 +59,7 @@ class FinanceCustomerAccountServiceTest { 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("cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_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'") diff --git a/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java b/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java index 72c3be7ef..3f44aba12 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java @@ -61,7 +61,7 @@ class FinanceFeeSourceEventServiceTest { assertThat(service.consume(event)).isEqualTo(101L); verify(firstleg).container("test-token", 41, 31, 1); - verify(jdbc).update(contains("status='SUPERSEDED'"), eq(31L)); + verify(jdbc).update(contains("status='SUPERSEDED'"), eq("CONTAINER_FEE"), eq(31L)); } @Test @@ -143,7 +143,7 @@ class FinanceFeeSourceEventServiceTest { when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(2))).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null))); assertThat(service.consume(event)).isEqualTo(101L); - verify(jdbc).update(contains("status='SUPERSEDED'"), eq(31L)); + verify(jdbc).update(contains("status='SUPERSEDED'"), eq("INBOUND_FEE"), eq(31L)); verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class)); } @@ -154,7 +154,7 @@ class FinanceFeeSourceEventServiceTest { 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); + eq(Integer.class), anyString(), eq(31L))).thenReturn(1); assertThatThrownBy(() -> service.consume(event)) .isInstanceOf(FinanceEventException.class) @@ -168,7 +168,7 @@ class FinanceFeeSourceEventServiceTest { FinanceFeeSourceEvent event = inboundEvent("voided-2", "FIRSTLEG_FEE_VOIDED", 2, 7); when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("voided-2"))).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))) + when(jdbc.queryForList(startsWith("SELECT id,source_hash,status"), anyString(), eq(31L), eq(2))) .thenReturn(List.of(Map.of("id", 88L, "source_hash", "source-hash", "status", "AVAILABLE"))); assertThat(service.consume(event)).isEqualTo(88L); @@ -183,7 +183,7 @@ class FinanceFeeSourceEventServiceTest { .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))) + when(jdbc.queryForList(startsWith("SELECT id,source_hash,status"), anyString(), 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); @@ -253,8 +253,8 @@ class FinanceFeeSourceEventServiceTest { private void givenNewEvent() { when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), anyString())).thenReturn(List.of()); - when(jdbc.queryForList(startsWith("SELECT id,source_hash,status"), eq(31L), anyInt())).thenReturn(List.of()); - when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class), eq(31L), anyInt())).thenReturn(101L); + when(jdbc.queryForList(startsWith("SELECT id,source_hash,status"), anyString(), eq(31L), anyInt())).thenReturn(List.of()); + when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class), anyString(), eq(31L), anyInt())).thenReturn(101L); } private FinanceFeeSourceEvent inboundEvent(String id, String type, int version, long tenant) { diff --git a/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceMigrationContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceMigrationContractTest.java index 8329eae7d..5eb6a2f2b 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceMigrationContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceMigrationContractTest.java @@ -16,7 +16,7 @@ class FinanceFeeSourceMigrationContractTest { "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("cost_origin", "FIRSTLEG_DIRECT_PAID", "source_fee_version", + assertThat(sql).contains("cost_origin", "FIRSTLEG_DIRECT_PAID", "BUSINESS_DIRECT_PAID", "warehouse_id", "source_fee_version", "uk_fin_cost_source_version", "Finance直接已付成本字段不完整"); 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 3f422a86a..d78234a15 100644 --- a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java @@ -107,7 +107,7 @@ class FinancePhase1OperationBehaviorTest { 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("ar.direction='RECEIVABLE'", "cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_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)"); } @@ -123,7 +123,7 @@ class FinancePhase1OperationBehaviorTest { assertThat(result.data()).isEmpty(); assertThat(jdbc.ledgerCustomerIds).containsExactly(9L, 9L, 9L, 9L); assertThat(jdbc.ledgerSql).anySatisfy(sql -> assertThat(sql) - .contains("cost_origin='FIRSTLEG_DIRECT_PAID'", "status='CONFIRMED'")); + .contains("cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID')", "status='CONFIRMED'")); assertThat(jdbc.ledgerSql).anySatisfy(sql -> assertThat(sql) .contains("fin_settlement_payment", "transaction_type='PAYMENT'", "s.direction='PAYABLE'")); } @@ -240,9 +240,9 @@ class FinancePhase1OperationBehaviorTest { var request = new FinancePhase1Dtos.FeeSourceEditRequest( new BigDecimal("120.00"), "更正金额", 1); assertThatThrownBy(() -> service.editFinanceFeeSource(88L, 71L, "edit-key", request, false)) - .isInstanceOf(FinanceEventException.class).hasMessageContaining("Firstleg费用必须"); + .isInstanceOf(FinanceEventException.class).hasMessageContaining("业务费用必须回到对应业务费用明细调整"); assertThatThrownBy(() -> service.editFinanceFeeSource(88L, 71L, "adjust-key", request, true)) - .isInstanceOf(FinanceEventException.class).hasMessageContaining("Firstleg费用必须"); + .isInstanceOf(FinanceEventException.class).hasMessageContaining("业务费用必须回到对应业务费用明细调整"); verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source SET amount="), (Object[]) any()); } diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java index 45e7d486b..952f007de 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(257); + assertThat(columnCount).isEqualTo(265); assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty(); } @@ -56,7 +56,7 @@ 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("来源直接已付且CONFIRMED计入已线下支付供应商款"); assertThat(sql).contains("RECEIVABLE计待收,PAYABLE与未制单ON_ACCOUNT来源共同计待付"); assertThat(sql).contains("规范化完整请求的SHA-256十六进制哈希"); } diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 86bc3622a..9e94fa7a8 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -27,7 +27,7 @@ | 费用项目与价格簿 | `controller/PlatformFeeItemController.java`、`PlatformPriceBookController.java`、`MerchantPriceBookController.java` | | 价格簿查询内部切片 | `pricebookquery/application/port/in/PriceBookQueryUseCase.java`、`application/PriceBookQueryService.java`、`application/port/out/PriceBookQueryReader.java`、`adapter/out/persistence/PriceBookQueryMapper.java`;查询行模型位于 `application/model/PriceBookQueryRows.java` | | 财务费用来源快照 | `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` | +| 柜次费用明细 | `controller/PlatformContainerFeeDetailController.java`、`service/ContainerFeeDetailService.java`、`dto/ContainerFeeDetailDtos.java`;跨客户公共柜费使用 `allocation-groups` 按真实客户和订单原子分摊,入仓ID/入仓码同时填写或同时留空;契约 `src/main/resources/openapi/container-fees-v1.yaml`;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`、`service/LoadingListService.java`;契约 `src/main/resources/openapi/loading-lists-v1.yaml`,详情阶段先查 `/loading-plans/{planId}/lifecycle`。创建柜次时原子生成唯一预装计划,加入入仓码明细时同步创建入库预装聚合与关联,不设推送步骤;同一 `planId` 在 `EDITING/LOADING` 返回 `PRELOAD`,`COMPLETED` 返回 `LOADING_LIST`。订舱关联、预装确认和 PDA task 创建不依赖真实集装箱号;可在 `EDITING/LOADING` 首次补录,最终 PDA 完成前必须存在;已有值纠正仅限 `EDITING` 且任务未领取、无物理作业事实 | | 同柜不可拆组 | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`;契约 `src/main/resources/openapi/coload-groups-v1.yaml`。请求按订单 ID 建组,服务端冻结每票全部 `WAIT_SPLIT` 可用入仓片段与满箱数;仅要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均保持整组原子 | diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerFeeDetailController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerFeeDetailController.java index 7fb433976..47181a39e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerFeeDetailController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerFeeDetailController.java @@ -51,6 +51,7 @@ public class PlatformContainerFeeDetailController { public ApiResponse> createAllocationGroup( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long loadingPlanId, @Valid @RequestBody AllocationGroupRequest request) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.createAllocationGroup(loadingPlanId, user.userId(), request)); } @@ -59,6 +60,7 @@ public class PlatformContainerFeeDetailController { public ApiResponse> approveAllocationGroup( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long loadingPlanId, @PathVariable long groupId) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.approveAllocationGroup(loadingPlanId, groupId, user.userId(), user.username())); } @@ -67,6 +69,7 @@ public class PlatformContainerFeeDetailController { public ApiResponse> rejectAllocationGroup( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long loadingPlanId, @PathVariable long groupId, @Valid @RequestBody RejectRequest request) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.rejectAllocationGroup(loadingPlanId, groupId, user.userId(), user.username(), request.reason())); } @@ -75,6 +78,7 @@ public class PlatformContainerFeeDetailController { public ApiResponse> adjustAllocationGroup( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long loadingPlanId, @PathVariable long groupId, @Valid @RequestBody AllocationGroupAdjustRequest request) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.adjustAllocationGroup(loadingPlanId, groupId, user.userId(), request)); } @@ -83,6 +87,7 @@ public class PlatformContainerFeeDetailController { public ApiResponse voidAllocationGroup( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long loadingPlanId, @PathVariable long groupId, @Valid @RequestBody VersionRequest request) { + FirstlegScopeGuard.platform(user); service.voidAllocationGroup(loadingPlanId, groupId, request.expectedSourceVersion(), user.userId()); return ApiResponse.success(null); } 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 1906e10d8..dabc1a8d4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerFeeDetailDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerFeeDetailDtos.java @@ -47,8 +47,10 @@ public final class ContainerFeeDetailDtos { public record Allocation( @NotNull @Min(1) Long customerId, - @NotNull @Min(1) Long inboundId, - @NotBlank @Size(max = 64) String inboundCode, + @NotNull @Min(1) Long orderId, + @NotBlank @Size(max = 64) String orderNo, + @Min(1) Long inboundId, + @Size(max = 64) String inboundCode, @NotNull @DecimalMin(value = "0.00", inclusive = false) BigDecimal amount ) { } 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 2ee0e9e3e..ca2a1e128 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerFeeDetailEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerFeeDetailEntity.java @@ -22,6 +22,8 @@ public class ContainerFeeDetailEntity { private Long id; private Long tenantId; private Long allocationGroupId; + private Long allocationOrderId; + private String allocationOrderNo; private Long allocationInboundId; private String allocationInboundCode; private BigDecimal allocationGroupAmount; 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 f249fd369..3d43f3d1a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerFeeSourceSnapshotEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerFeeSourceSnapshotEntity.java @@ -24,6 +24,8 @@ public class ContainerFeeSourceSnapshotEntity { private Integer sourceVersion; private Long tenantId; private Long allocationGroupId; + private Long allocationOrderId; + private String allocationOrderNo; private Long allocationInboundId; private String allocationInboundCode; private BigDecimal allocationGroupAmount; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerFeeSourceSnapshotRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerFeeSourceSnapshotRepository.java index 051dccc75..62aa82d97 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerFeeSourceSnapshotRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerFeeSourceSnapshotRepository.java @@ -20,6 +20,8 @@ public class ContainerFeeSourceSnapshotRepository { value.setSourceVersion(source.getSourceVersion()); value.setTenantId(source.getTenantId()); value.setAllocationGroupId(source.getAllocationGroupId()); + value.setAllocationOrderId(source.getAllocationOrderId()); + value.setAllocationOrderNo(source.getAllocationOrderNo()); value.setAllocationInboundId(source.getAllocationInboundId()); value.setAllocationInboundCode(source.getAllocationInboundCode()); value.setAllocationGroupAmount(source.getAllocationGroupAmount()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerFeeDetailService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerFeeDetailService.java index 2e597df2e..f03077b3f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerFeeDetailService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerFeeDetailService.java @@ -52,12 +52,15 @@ public class ContainerFeeDetailService { PlanContext plan = requireOwnedPlan(tenantId, loadingPlanId); List owned = loading.items(loadingPlanId).stream() .filter(item -> Long.valueOf(tenantId).equals(item.getAccountId())) - .map(item -> new InboundAllocationKey(item.getInboundId(), item.getInboundCode())) + .map(item -> new InboundAllocationKey(item.getOrderId(), text(item.getOrderNo()), + item.getInboundId(), text(item.getInboundCode()))) .distinct().toList(); - if (owned.size() != 1) throw conflict("柜次包含多个入仓码,必须使用柜级费用分摊录入"); + if (owned.size() != 1) throw conflict("柜次包含多个业务归属,必须使用柜级费用分摊录入"); FeeItemEntity item = requireItem(request.feeItemId(), request.direction()); ContainerFeeDetailEntity value = new ContainerFeeDetailEntity(); value.setTenantId(tenantId); + value.setAllocationOrderId(owned.get(0).orderId()); + value.setAllocationOrderNo(owned.get(0).orderNo()); value.setAllocationInboundId(owned.get(0).inboundId()); value.setAllocationInboundCode(owned.get(0).inboundCode()); value.setAllocationGroupAmount(request.amount().setScale(2, RoundingMode.HALF_UP)); @@ -86,19 +89,25 @@ public class ContainerFeeDetailService { if (allocated.compareTo(total) != 0) throw conflict("柜级费用分摊合计必须等于费用总额"); Map ownership = new HashMap<>(); loading.items(loadingPlanId).forEach(value -> ownership.put( - new InboundAllocationKey(value.getInboundId(), value.getInboundCode()), value.getAccountId())); + new InboundAllocationKey(value.getOrderId(), text(value.getOrderNo()), value.getInboundId(), + text(value.getInboundCode())), value.getAccountId())); Set unique = new HashSet<>(); long groupId = IdWorker.getId(); List rows = new java.util.ArrayList<>(); for (Allocation allocation : request.allocations()) { - InboundAllocationKey key = new InboundAllocationKey(allocation.inboundId(), allocation.inboundCode().trim()); - if (!unique.add(key)) throw conflict("同一入仓码不得重复分摊"); + String inboundCode = text(allocation.inboundCode()); + if ((allocation.inboundId() == null) != (inboundCode == null)) + throw new BizException(ResultCode.VALIDATION_FAILED, "入仓ID与入仓码必须同时填写或同时留空"); + InboundAllocationKey key = new InboundAllocationKey(allocation.orderId(), allocation.orderNo().trim(), + allocation.inboundId(), inboundCode); + if (!unique.add(key)) throw conflict("同一业务归属不得重复分摊"); if (!allocation.customerId().equals(ownership.get(key))) - throw conflict("分摊客户、入仓码与柜内真实归属不一致"); + throw conflict("分摊客户、订单及入仓信息与柜内真实归属不一致"); ContainerFeeDetailEntity value = new ContainerFeeDetailEntity(); value.setId(IdWorker.getId()); value.setTenantId(allocation.customerId()); - value.setAllocationGroupId(groupId); value.setAllocationInboundId(allocation.inboundId()); - value.setAllocationInboundCode(allocation.inboundCode().trim()); value.setAllocationGroupAmount(total); + value.setAllocationGroupId(groupId); value.setAllocationOrderId(allocation.orderId()); + value.setAllocationOrderNo(allocation.orderNo().trim()); value.setAllocationInboundId(allocation.inboundId()); + value.setAllocationInboundCode(inboundCode); value.setAllocationGroupAmount(total); value.setLoadingPlanId(loadingPlanId); value.setContainerBookingId(plan.plan().getContainerBookingId()); value.setContainerNo(plan.booking() == null ? null : plan.booking().getContainerNo()); value.setSourceVersion(1); value.setSourceStatus("ACTIVE"); value.setReviewStatus("PENDING"); @@ -359,5 +368,7 @@ public class ContainerFeeDetailService { private record PlanContext(LoadingPlanEntity plan, ContainerBookingEntity booking) { } - private record InboundAllocationKey(Long inboundId, String inboundCode) { } + private static String text(String value) { return value == null || value.isBlank() ? null : value.trim(); } + + private record InboundAllocationKey(Long orderId, String orderNo, Long inboundId, String inboundCode) { } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FinanceFeeSourceSnapshotService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FinanceFeeSourceSnapshotService.java index c08c08cea..1ee012cb0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FinanceFeeSourceSnapshotService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FinanceFeeSourceSnapshotService.java @@ -70,12 +70,13 @@ public class FinanceFeeSourceSnapshotService { nullSafe(detail.getSettlementMode()), nullSafe(detail.getPaidAt()), nullSafe(detail.getPaymentMethod()), nullSafe(detail.getPaymentReferenceNo()), nullSafe(detail.getPaymentVoucherFileIds()), - nullSafe(detail.getAllocationGroupId()), nullSafe(detail.getAllocationInboundId()), + nullSafe(detail.getAllocationGroupId()), nullSafe(detail.getAllocationOrderId()), + nullSafe(detail.getAllocationOrderNo()), nullSafe(detail.getAllocationInboundId()), nullSafe(detail.getAllocationInboundCode()), nullSafe(detail.getAllocationGroupAmount()), detail.getAmount().toPlainString(), nullSafe(detail.getCurrency()), nullSafe(detail.getReviewStatus())); return new FinanceFeeSourceSnapshot(detail.getTenantId(), "CONTAINER_FEE", feeId, - version, detail.getAllocationInboundId(), detail.getAllocationInboundCode(), null, containerId, detail.getContainerBookingId(), + version, detail.getAllocationInboundId(), detail.getAllocationInboundCode(), detail.getAllocationOrderId(), containerId, detail.getContainerBookingId(), detail.getContainerNo(), detail.getTenantId(), null, detail.getSupplierId(), detail.getSupplierName(), detail.getSupplierCategory(), detail.getSettlementMode(), detail.getPaidAt(), detail.getPaymentMethod(), detail.getPaymentReferenceNo(), diff --git a/firstleg/src/main/resources/db/finance-fee-source-migration.sql b/firstleg/src/main/resources/db/finance-fee-source-migration.sql index 9340eee23..d61769950 100644 --- a/firstleg/src/main/resources/db/finance-fee-source-migration.sql +++ b/firstleg/src/main/resources/db/finance-fee-source-migration.sql @@ -65,6 +65,8 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_detail ( id BIGINT UNSIGNED NOT NULL COMMENT '柜次费用来源ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', allocation_group_id BIGINT UNSIGNED NULL COMMENT '柜级公共费用分摊组ID;单入仓直接费用为空', + allocation_order_id BIGINT UNSIGNED NULL COMMENT '本分摊行业务订单ID', + allocation_order_no VARCHAR(64) NULL COMMENT '本分摊行业务订单号', allocation_inbound_id BIGINT UNSIGNED NULL COMMENT '本分摊行对应入仓单ID', allocation_inbound_code VARCHAR(64) NULL COMMENT '本分摊行对应入仓码', allocation_group_amount DECIMAL(18,2) NULL COMMENT '柜级原始费用总额快照', @@ -123,6 +125,8 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_source_snapshot ( source_version INT UNSIGNED NOT NULL COMMENT '费用来源版本', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', allocation_group_id BIGINT UNSIGNED NULL COMMENT '柜级公共费用分摊组ID', + allocation_order_id BIGINT UNSIGNED NULL COMMENT '分摊业务订单ID快照', + allocation_order_no VARCHAR(64) NULL COMMENT '分摊业务订单号快照', allocation_inbound_id BIGINT UNSIGNED NULL COMMENT '分摊入仓单ID快照', allocation_inbound_code VARCHAR(64) NULL COMMENT '分摊入仓码快照', allocation_group_amount DECIMAL(18,2) NULL COMMENT '柜级原始费用总额快照', @@ -196,8 +200,12 @@ CALL firstleg_finance_add_column('fl_container_fee_detail', 'payment_voucher_fil "JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组' AFTER payment_reference_no"); CALL firstleg_finance_add_column('fl_container_fee_detail', 'allocation_group_id', "BIGINT UNSIGNED NULL COMMENT '柜级公共费用分摊组ID;单入仓直接费用为空' AFTER tenant_id"); +CALL firstleg_finance_add_column('fl_container_fee_detail', 'allocation_order_id', + "BIGINT UNSIGNED NULL COMMENT '本分摊行业务订单ID' AFTER allocation_group_id"); +CALL firstleg_finance_add_column('fl_container_fee_detail', 'allocation_order_no', + "VARCHAR(64) NULL COMMENT '本分摊行业务订单号' AFTER allocation_order_id"); CALL firstleg_finance_add_column('fl_container_fee_detail', 'allocation_inbound_id', - "BIGINT UNSIGNED NULL COMMENT '本分摊行对应入仓单ID' AFTER allocation_group_id"); + "BIGINT UNSIGNED NULL COMMENT '本分摊行对应入仓单ID' AFTER allocation_order_no"); CALL firstleg_finance_add_column('fl_container_fee_detail', 'allocation_inbound_code', "VARCHAR(64) NULL COMMENT '本分摊行对应入仓码' AFTER allocation_inbound_id"); CALL firstleg_finance_add_column('fl_container_fee_detail', 'allocation_group_amount', @@ -214,8 +222,12 @@ CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'payment_vo "JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组' AFTER payment_reference_no"); CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'allocation_group_id', "BIGINT UNSIGNED NULL COMMENT '柜级公共费用分摊组ID' AFTER tenant_id"); +CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'allocation_order_id', + "BIGINT UNSIGNED NULL COMMENT '分摊业务订单ID快照' AFTER allocation_group_id"); +CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'allocation_order_no', + "VARCHAR(64) NULL COMMENT '分摊业务订单号快照' AFTER allocation_order_id"); CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'allocation_inbound_id', - "BIGINT UNSIGNED NULL COMMENT '分摊入仓单ID快照' AFTER allocation_group_id"); + "BIGINT UNSIGNED NULL COMMENT '分摊入仓单ID快照' AFTER allocation_order_no"); CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'allocation_inbound_code', "VARCHAR(64) NULL COMMENT '分摊入仓码快照' AFTER allocation_inbound_id"); CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'allocation_group_amount', diff --git a/firstleg/src/main/resources/openapi/container-fees-v1.yaml b/firstleg/src/main/resources/openapi/container-fees-v1.yaml new file mode 100644 index 000000000..cf36252e9 --- /dev/null +++ b/firstleg/src/main/resources/openapi/container-fees-v1.yaml @@ -0,0 +1,45 @@ +openapi: 3.0.3 +info: { title: Firstleg Container Fee Allocation API, version: 1.0.0 } +paths: + /api/platform/v1/loading-plans/{loadingPlanId}/fee-details/allocation-groups: + post: + summary: 原子创建柜级公共费用分摊组 + description: 客户、订单必填;入仓ID与入仓码同时填写或同时留空。各分摊金额必须大于0且合计等于费用总额。 + security: [{ bearerAuth: [] }] + parameters: [{ $ref: '#/components/parameters/LoadingPlanId' }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/AllocationGroupRequest' } } } } + responses: { '200': { description: 按客户和业务归属生成的待审核费用行 }, '409': { description: 分摊合计或柜内真实归属不一致 } } + /api/platform/v1/loading-plans/{loadingPlanId}/fee-details/allocation-groups/{groupId}/{action}: + post: + summary: 整组审核、驳回、调整或作废柜级分摊费用 + description: action 仅为 approve、reject、adjust、void;单行接口拒绝处理分摊组,避免部分成功。 + security: [{ bearerAuth: [] }] + parameters: + - { $ref: '#/components/parameters/LoadingPlanId' } + - { in: path, name: groupId, required: true, schema: { type: string, pattern: '^[1-9][0-9]{0,19}$' } } + - { in: path, name: action, required: true, schema: { type: string, enum: [approve, reject, adjust, void] } } + responses: { '200': { description: 整组处理成功 }, '409': { description: 版本、状态或完整性冲突 } } +components: + parameters: + LoadingPlanId: { in: path, name: loadingPlanId, required: true, schema: { type: string, pattern: '^[1-9][0-9]{0,19}$' } } + schemas: + AllocationGroupRequest: + type: object + required: [fee, allocations] + properties: + fee: { type: object, description: 公共费用字段;amount为原始柜级总额 } + allocations: + type: array + minItems: 1 + maxItems: 200 + items: + type: object + required: [customerId, orderId, orderNo, amount] + properties: + customerId: { type: string } + orderId: { type: string } + orderNo: { type: string, maxLength: 64 } + inboundId: { type: string, nullable: true } + inboundCode: { type: string, maxLength: 64, nullable: true } + amount: { type: number, format: decimal, exclusiveMinimum: 0 } + securitySchemes: { bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerFeeDetailServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerFeeDetailServiceTest.java index ba6923554..8600e21d9 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerFeeDetailServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerFeeDetailServiceTest.java @@ -3,6 +3,8 @@ package com.baiyue.firstleg.service; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.ContainerFeeDetailDtos; import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AdjustRequest; +import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.Allocation; +import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AllocationGroupRequest; import com.baiyue.firstleg.entity.ContainerFeeDetailEntity; import com.baiyue.firstleg.entity.FeeItemEntity; import com.baiyue.firstleg.entity.LoadingPlanEntity; @@ -233,15 +235,71 @@ class ContainerFeeDetailServiceTest { assertThat(captor.getValue().getPaymentVoucherFileIds()).containsExactly(901L); } + @Test + void createsAtomicCrossCustomerAllocationAndAllowsMissingInboundCode() { + LoadingPlanEntity plan = new LoadingPlanEntity(); + plan.setId(41L); + when(loading.plan(41L)).thenReturn(plan); + LoadingPlanItemEntity first = loadingItem(10001L, 501L, "FL-501", null, null); + LoadingPlanItemEntity second = loadingItem(10002L, 502L, "FL-502", 602L, "IB-602"); + when(loading.items(41L)).thenReturn(List.of(first, second)); + when(feeItems.findItem(11L)).thenReturn(item()); + var fee = new ContainerFeeDetailDtos.SaveRequest(11L, "RECEIVABLE", + LocalDate.of(2026, 8, 20), new BigDecimal("300.00"), "CNY", null, "柜级操作费"); + var request = new AllocationGroupRequest(fee, List.of( + new Allocation(10001L, 501L, "FL-501", null, null, new BigDecimal("120.00")), + new Allocation(10002L, 502L, "FL-502", 602L, "IB-602", new BigDecimal("180.00")))); + + List result = service.createAllocationGroup(41L, 9L, request); + + assertThat(result).hasSize(2); + ArgumentCaptor captor = ArgumentCaptor.forClass(ContainerFeeDetailEntity.class); + verify(repository, org.mockito.Mockito.times(2)).insert(captor.capture()); + assertThat(captor.getAllValues()).extracting(ContainerFeeDetailEntity::getTenantId) + .containsExactly(10001L, 10002L); + assertThat(captor.getAllValues().get(0).getAllocationOrderNo()).isEqualTo("FL-501"); + assertThat(captor.getAllValues().get(0).getAllocationInboundId()).isNull(); + assertThat(captor.getAllValues().get(0).getAllocationInboundCode()).isNull(); + assertThat(captor.getAllValues()).extracting(ContainerFeeDetailEntity::getAllocationGroupId) + .doesNotContainNull().allMatch(captor.getAllValues().get(0).getAllocationGroupId()::equals); + } + + @Test + void rejectsAllocationWhenInboundIdAndCodeAreOnlyPartiallyProvided() { + LoadingPlanEntity plan = new LoadingPlanEntity(); + plan.setId(41L); + when(loading.plan(41L)).thenReturn(plan); + when(loading.items(41L)).thenReturn(List.of(loadingItem(10001L, 501L, "FL-501", null, null))); + when(feeItems.findItem(11L)).thenReturn(item()); + var fee = new ContainerFeeDetailDtos.SaveRequest(11L, "RECEIVABLE", + LocalDate.of(2026, 8, 20), new BigDecimal("100.00"), "CNY", null, null); + var request = new AllocationGroupRequest(fee, List.of( + new Allocation(10001L, 501L, "FL-501", 601L, null, new BigDecimal("100.00")))); + + assertThatThrownBy(() -> service.createAllocationGroup(41L, 9L, request)) + .isInstanceOf(BizException.class).hasMessageContaining("同时填写或同时留空"); + verify(repository, never()).insert(any()); + } + private void ownedPlan() { LoadingPlanEntity plan = new LoadingPlanEntity(); plan.setId(41L); when(loading.plan(41L)).thenReturn(plan); LoadingPlanItemEntity item = new LoadingPlanItemEntity(); item.setAccountId(10001L); + item.setOrderId(501L); + item.setOrderNo("FL-501"); when(loading.items(41L)).thenReturn(List.of(item)); } + private static LoadingPlanItemEntity loadingItem(long accountId, long orderId, String orderNo, + Long inboundId, String inboundCode) { + LoadingPlanItemEntity value = new LoadingPlanItemEntity(); + value.setAccountId(accountId); value.setOrderId(orderId); value.setOrderNo(orderNo); + value.setInboundId(inboundId); value.setInboundCode(inboundCode); + return value; + } + private static FeeItemEntity item() { FeeItemEntity value = new FeeItemEntity(); value.setId(11L); diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 47cc02fb1..57e81372f 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -37,6 +37,7 @@ | 一件代发接收、SKU/箱唛库存 | `dropship/service/DropshipOrderService.java`、`dropship/entity/`、`dropship/mapper/` | | 货物废弃、待审锁箱、审批扣减 | `discard/controller/CargoDiscardController.java`、`discard/service/CargoDiscardService.java` | | 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` | +| 海外业务费用来源 | `financefee/`;海外入仓、采购、本土卡车、本土散板和海外仓服务费审核后以 Outbox/Event Bridge 进入独立 Finance,入仓记录和入仓码按事实选填;OpenAPI `src/main/resources/openapi/overseas-finance-fees-v1.yaml` | | 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java`、`MerchantUsTruckOrderController.java` 及同子域 Service/DTO | | 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java` 及同子域 Service/DTO/Entity/Mapper;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` | | 海外仓报价、月度汇率、车辆主数据 | 报价 `masterdata/quote/`、汇率 `masterdata/exchange/`;车辆入口 `masterdata/vehicle/adapter/in/web/PlatformVehicleController.java`,应用服务 `application/VehicleApplicationService.java`,持久化端口与适配器 `application/port/out/VehicleRepository.java`、`adapter/out/persistence/`;OpenAPI `src/main/resources/openapi/overseas-master-data-v1.yaml`,车辆档案不含派送履约历史 | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuard.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuard.java index 8ec07e596..b6b6823ee 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuard.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuard.java @@ -16,8 +16,8 @@ public class OverseasFeeBusinessGuard { if (fee.businessId() == null) { if (!"WAREHOUSE_SERVICE".equals(fee.businessType())) throw bad("业务记录ID不能为空"); - if (fee.warehouseId() == null || (fee.inboundId() == null && blank(fee.inboundCode()))) - throw bad("海外仓服务费必须关联仓库以及入仓记录或入仓码"); + if (fee.warehouseId() == null) + throw bad("海外仓服务费必须关联海外仓;入仓记录和入仓码按实际业务选填"); return; } Table table = switch (fee.businessType()) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java index feef547df..ed5410c7e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java @@ -28,7 +28,7 @@ public class OverseasFeeOutboxService { @Resource private ObjectMapper objectMapper; public void publish(OverseasFeeSource source, String action) { - BusinessFeeSourceChangedV1 unsigned = payload(source, action, null); + BusinessFeeSourceChangedV1 unsigned = payload(source, null, null); String sourceHash = EventPayloadHash.sha256( EventPayloadCanonicalizer.canonicalize(objectMapper.valueToTree(unsigned))); BusinessFeeSourceChangedV1 payload = payload(source, action, sourceHash); @@ -67,7 +67,7 @@ public class OverseasFeeOutboxService { value.getSettlementMode(), instant(value.getPaidAt()), value.getPaymentMethod(), value.getPaymentReferenceNo(), value.getPaymentVoucherFileIds() == null ? List.of() : value.getPaymentVoucherFileIds(), value.getBusinessDate(), value.getCurrency(), value.getAmount(), value.getReviewStatus(), - value.getPublicationStatus(), value.getSourceStatus(), hash); + "PUBLISHED", action == null && hash == null ? "ACTIVE" : value.getSourceStatus(), hash); } private static String text(Long value) { return value == null ? null : String.valueOf(value); } diff --git a/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml new file mode 100644 index 000000000..d7895d875 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml @@ -0,0 +1,50 @@ +openapi: 3.0.3 +info: { title: Overseas Business Fee Source API, version: 1.0.0 } +paths: + /api/platform/v1/last-leg/finance-fee-sources: + get: + summary: 查询海外业务费用来源当前版本 + security: [{ bearerAuth: [] }] + parameters: + - { in: query, name: customerId, schema: { type: string } } + - { in: query, name: businessType, schema: { $ref: '#/components/schemas/BusinessType' } } + - { in: query, name: businessNo, schema: { type: string } } + responses: { '200': { description: 当前版本列表 } } + post: + summary: 创建海外业务费用来源草稿 + description: 客户和业务编号必填;入仓ID、入仓码均按业务事实选填,不得为补字段伪造。 + security: [{ bearerAuth: [] }] + parameters: [{ in: header, name: Idempotency-Key, required: true, schema: { type: string, maxLength: 128 } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/SaveRequest' } } } } + responses: { '200': { description: 草稿费用来源 }, '400': { description: 业务归属或付款事实无效 } } + /api/platform/v1/last-leg/finance-fee-sources/{sourceFeeId}/{action}: + post: + summary: 提交、审核、调整或作废海外业务费用来源 + description: 审核通过与作废均事务性写入Outbox,经Event Bridge可靠投递至独立Finance;action为submit、review、adjust、void。 + security: [{ bearerAuth: [] }] + parameters: + - { in: path, name: sourceFeeId, required: true, schema: { type: string } } + - { in: path, name: action, required: true, schema: { type: string, enum: [submit, review, adjust, void] } } + responses: { '200': { description: 当前费用来源版本 }, '409': { description: 状态、版本或幂等冲突 } } +components: + schemas: + BusinessType: { type: string, enum: [OVERSEAS_INBOUND, OVERSEAS_PROCUREMENT, US_TRUCK, LOCAL_LOOSEBOARD, WAREHOUSE_SERVICE] } + SaveRequest: + type: object + required: [customerId, sourceType, businessType, businessNo, feeItemCode, feeItemName, direction, businessDate, currency, amount] + properties: + customerId: { type: string } + sourceType: { type: string, enum: [OVERSEAS_INBOUND_FEE, OVERSEAS_PROCUREMENT_FEE, OVERSEAS_TRUCK_FEE, OVERSEAS_LOOSEBOARD_FEE, OVERSEAS_WAREHOUSE_FEE] } + businessType: { $ref: '#/components/schemas/BusinessType' } + businessId: { type: string, nullable: true } + businessNo: { type: string, maxLength: 128 } + warehouseId: { type: string, nullable: true } + inboundId: { type: string, nullable: true } + inboundCode: { type: string, maxLength: 64, nullable: true } + feeItemCode: { type: string } + feeItemName: { type: string } + direction: { type: string, enum: [RECEIVABLE, PAYABLE] } + businessDate: { type: string, format: date } + currency: { type: string, enum: [CNY] } + amount: { type: number, format: decimal, exclusiveMinimum: 0 } + securitySchemes: { bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxServiceTest.java new file mode 100644 index 000000000..e76b49736 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxServiceTest.java @@ -0,0 +1,56 @@ +package com.baiyue.lastleg.financefee.service; + +import com.baiyue.common.event.BusinessFeeSourceChangedV1; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class OverseasFeeOutboxServiceTest { + private final LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class); + private final ObjectMapper json = new ObjectMapper().findAndRegisterModules(); + private final OverseasFeeOutboxService service = new OverseasFeeOutboxService(); + + @Test + void publishesCompleteFeeSnapshotWithOptionalInboundReference() throws Exception { + ReflectionTestUtils.setField(service, "mapper", mapper); + ReflectionTestUtils.setField(service, "objectMapper", json); + when(mapper.findByEventKey(anyString())).thenReturn(null); + OverseasFeeSource source = new OverseasFeeSource(); + source.setSourceFeeId(701L); source.setSourceVersion(1); source.setCustomerId("10001"); + source.setCustomerName("客户A"); source.setSourceType("OVERSEAS_PROCUREMENT_FEE"); + source.setSourceNo("PO-US-001"); source.setBusinessType("OVERSEAS_PROCUREMENT"); + source.setBusinessId(801L); source.setBusinessNo("PO-US-001"); source.setWarehouseId(91L); + source.setFeeItemCode("PROCUREMENT_SERVICE"); source.setFeeItemName("采购服务费"); + source.setDirection("RECEIVABLE"); source.setBusinessDate(LocalDate.of(2026, 9, 2)); + source.setCurrency("CNY"); source.setAmount(new BigDecimal("120.00")); + source.setReviewStatus("APPROVED"); source.setPublicationStatus("PUBLISHED"); + source.setSourceStatus("ACTIVE"); source.setPaymentVoucherFileIds(List.of()); + + service.publish(source, "PUBLISHED"); + + ArgumentCaptor row = ArgumentCaptor.forClass(LastlegEventOutbox.class); + verify(mapper).insert(row.capture()); + EventEnvelope envelope = json.readValue(row.getValue().getPayloadJson(), EventEnvelope.class).requireValid(); + BusinessFeeSourceChangedV1 payload = json.treeToValue(envelope.payload(), BusinessFeeSourceChangedV1.class); + assertThat(payload.customerId()).isEqualTo("10001"); + assertThat(payload.businessNo()).isEqualTo("PO-US-001"); + assertThat(payload.inboundId()).isNull(); + assertThat(payload.inboundCode()).isNull(); + assertThat(payload.sourceHash()).startsWith("sha256:").hasSize(71); + } +} diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index 81332bff9..05e7418c8 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -3805,6 +3805,49 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab AND table_name='ll_cargo_discard_application' AND column_name='review_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_cargo_discard_application ADD COLUMN review_idempotency_key VARCHAR(255) NULL COMMENT ''审核幂等键'' AFTER request_hash')); PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt; DEALLOCATE PREPARE phase4_discard_review_stmt; + +-- ============================================================================ +-- 2026-09-02 / 031 +-- 海外业务费用来源与写操作幂等审计;入仓码为可选业务引用。 +-- ============================================================================ +CREATE TABLE IF NOT EXISTS ll_finance_fee_source ( + id BIGINT NOT NULL COMMENT '费用版本记录ID', source_fee_id BIGINT NOT NULL COMMENT '跨版本稳定费用来源ID', + source_version INT NOT NULL COMMENT '费用来源版本,从1递增', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', + customer_name VARCHAR(200) NULL COMMENT '客户名称快照', source_type VARCHAR(64) NOT NULL COMMENT '海外费用来源类型', + source_no VARCHAR(128) NOT NULL COMMENT '来源业务编号或入仓码快照', business_type VARCHAR(32) NOT NULL COMMENT '来源业务类型', + business_id BIGINT NULL COMMENT '来源业务记录ID', business_no VARCHAR(128) NOT NULL COMMENT '来源业务编号', + order_id BIGINT NULL COMMENT '关联订单ID', inbound_id BIGINT NULL COMMENT '关联海外入仓计划或入仓记录ID', + inbound_code VARCHAR(64) NULL COMMENT '关联入仓码;并非所有费用必填', container_id BIGINT NULL COMMENT '关联柜次ID', + container_booking_id BIGINT NULL COMMENT '来源订舱柜ID', container_no VARCHAR(64) NULL COMMENT '真实集装箱号快照', + warehouse_id BIGINT NULL COMMENT '关联海外仓ID', fee_item_code VARCHAR(64) NOT NULL COMMENT '费用项目编码快照', + fee_item_name VARCHAR(200) NOT NULL COMMENT '费用项目名称快照', direction VARCHAR(16) NOT NULL COMMENT 'RECEIVABLE应收或PAYABLE应付', + supplier_id BIGINT NULL COMMENT '应付供应商ID', supplier_name VARCHAR(200) NULL COMMENT '应付供应商名称快照', + supplier_category VARCHAR(32) NULL COMMENT '应付供应商类别约束', settlement_mode VARCHAR(16) NULL COMMENT 'ON_ACCOUNT账期应付或DIRECT_PAID业务已付', + paid_at DATETIME(3) NULL COMMENT '直接已付时间', payment_method VARCHAR(32) NULL COMMENT '直接已付方式', + payment_reference_no VARCHAR(128) NULL COMMENT '直接已付流水号', payment_voucher_file_ids JSON NULL COMMENT '直接已付凭证文件ID快照', + business_date DATE NOT NULL COMMENT '费用业务日期', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '财务结算币种,当前固定CNY', + amount DECIMAL(20,6) NOT NULL COMMENT '费用金额', review_status VARCHAR(16) NOT NULL COMMENT 'DRAFT、PENDING、APPROVED或REJECTED', + publication_status VARCHAR(16) NOT NULL COMMENT 'UNPUBLISHED或PUBLISHED', source_status VARCHAR(16) NOT NULL COMMENT 'ACTIVE、SUPERSEDED或VOIDED', + review_reason VARCHAR(500) NULL COMMENT '审核或作废原因', reviewer_id VARCHAR(128) NULL COMMENT '审核人ID', reviewed_at DATETIME(3) NULL COMMENT '审核时间', + created_by VARCHAR(128) NOT NULL COMMENT '创建人ID', updated_by VARCHAR(128) NOT NULL COMMENT '最后更新人ID', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_finance_fee_version(source_fee_id,source_version), + KEY idx_ll_finance_fee_customer(customer_id,source_status,created_at), KEY idx_ll_finance_fee_business(business_type,business_no,created_at), + CONSTRAINT chk_ll_finance_fee_amount CHECK(amount>0), CONSTRAINT chk_ll_finance_fee_currency CHECK(currency='CNY'), + CONSTRAINT chk_ll_finance_fee_direction CHECK(direction IN('RECEIVABLE','PAYABLE')), + CONSTRAINT chk_ll_finance_fee_review CHECK(review_status IN('DRAFT','PENDING','APPROVED','REJECTED')), + CONSTRAINT chk_ll_finance_fee_publication CHECK(publication_status IN('UNPUBLISHED','PUBLISHED')), + CONSTRAINT chk_ll_finance_fee_status CHECK(source_status IN('ACTIVE','SUPERSEDED','VOIDED')) +) ENGINE=InnoDB COMMENT='海外业务不可变费用来源版本;发布后经跨区事件进入Finance'; + +CREATE TABLE IF NOT EXISTS ll_finance_fee_command ( + id BIGINT NOT NULL COMMENT '幂等命令ID', operator_id VARCHAR(128) NOT NULL COMMENT '操作人ID', + action VARCHAR(16) NOT NULL COMMENT 'CREATE、SUBMIT、REVIEW、ADJUST或VOID', idempotency_key VARCHAR(128) NOT NULL COMMENT '操作幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求内容SHA-256', source_fee_id BIGINT NOT NULL COMMENT '稳定费用来源ID', + source_version INT NOT NULL COMMENT '命令结果版本', created_at DATETIME(3) NOT NULL COMMENT '命令创建时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_finance_fee_command(operator_id,action,idempotency_key), + KEY idx_ll_finance_fee_command_source(source_fee_id,source_version) +) ENGINE=InnoDB COMMENT='海外费用来源写操作幂等审计'; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='review_request_hash'), 'SELECT 1', 'ALTER TABLE ll_cargo_discard_application ADD COLUMN review_request_hash CHAR(64) NULL COMMENT ''审核请求内容哈希'' AFTER review_idempotency_key'));