diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 4a585a17b..ddccfc01f 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -2369,6 +2369,10 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( expected_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', customer_id BIGINT UNSIGNED NULL COMMENT '客户逻辑ID', customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照', + channel_code VARCHAR(64) NOT NULL COMMENT '订舱渠道编码快照', + channel_name VARCHAR(128) NOT NULL COMMENT '订舱渠道名称快照', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '国内中转仓ID快照', + warehouse_name VARCHAR(128) NOT NULL COMMENT '国内中转仓名称快照', company_id BIGINT UNSIGNED NULL COMMENT '运营公司逻辑ID', company_name VARCHAR(128) NULL COMMENT '运营公司名称快照', warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '实际作业仓库ID', @@ -4546,6 +4550,10 @@ CREATE TABLE IF NOT EXISTS fl_container_booking ( booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号', container_no VARCHAR(64) NULL COMMENT '集装箱号;确认前允许修订', cabinet_no VARCHAR(64) NOT NULL COMMENT '平台唯一内部柜号', + channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照;存量回填前禁止混装', + channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照', + ocean_booking_id BIGINT UNSIGNED NULL COMMENT '来源订舱ID', + ocean_booking_requirement_id BIGINT UNSIGNED NULL COMMENT '来源订舱箱型需求ID', warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '装柜仓库ID;平台公共资源', warehouse_name VARCHAR(128) NOT NULL COMMENT '装柜仓库名称快照', bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号', @@ -4922,7 +4930,9 @@ DROP PROCEDURE migrate_legacy_loading_pick_task; CREATE TABLE IF NOT EXISTS fl_loading_pick_task ( id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID', loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID', - container_no VARCHAR(64) NOT NULL COMMENT '集装箱号快照', + container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '稳定柜次ID', + cabinet_no VARCHAR(64) NOT NULL COMMENT '稳定内部柜次号快照', + container_no VARCHAR(64) NULL COMMENT '废弃兼容字段;不得作为任务身份或随运输号变更重写', task_status VARCHAR(16) NOT NULL DEFAULT 'LOADING' COMMENT 'PDA任务状态:LOADING拣货装柜中、COMPLETED已完成', started_by BIGINT UNSIGNED NOT NULL COMMENT '开始装柜人', started_by_name VARCHAR(64) NULL COMMENT '开始装柜人名称', @@ -4933,6 +4943,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_task ( version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), UNIQUE KEY uk_loading_pick_task_plan (loading_plan_id), + UNIQUE KEY uk_loading_pick_task_booking (container_booking_id), KEY idx_loading_pick_container (container_no, task_status, started_at), CONSTRAINT fk_loading_pick_task_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id), CONSTRAINT chk_loading_pick_task_status CHECK (task_status IN ('LOADING', 'COMPLETED')) @@ -8443,6 +8454,8 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_requirement ( ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '所属海运订舱ID', container_type VARCHAR(32) NOT NULL COMMENT '所需集装箱尺寸类型编码', container_kind VARCHAR(32) NOT NULL COMMENT '所需集装箱箱种编码', + capacity_cbm DECIMAL(18,4) NOT NULL COMMENT '该需求柜型可用容量快照m3', + max_weight_kg DECIMAL(18,3) NOT NULL COMMENT '该需求柜型最大承重快照kg', quantity INT UNSIGNED NOT NULL COMMENT '该箱型箱种所需柜数', sort_no INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '订舱需求展示顺序', PRIMARY KEY (id), @@ -13969,3 +13982,50 @@ UPDATE fl_loading_plan plan -- 二、三期对外统一使用 INBOUNDED;历史 WAIT_SPLIT 仅由查询适配为 INBOUNDED,统一迁移不改写主表或历史状态。 SELECT 'PASS' AS migration_status, '统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、公共资源及可靠审计结构校验通过' AS message; +/* Phase 3 channel-owned containers and stable PDA identity. Source: firstleg phase3-channel-container-migration.sql */ +DROP PROCEDURE IF EXISTS migrate_phase3_channel_containers; +DELIMITER $$ +CREATE PROCEDURE migrate_phase3_channel_containers() +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_ocean_booking' AND column_name='channel_code') THEN + ALTER TABLE baiyue_firstleg.fl_ocean_booking ADD COLUMN channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照' AFTER customer_name, + ADD COLUMN channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照' AFTER channel_code, + ADD COLUMN warehouse_id BIGINT UNSIGNED NULL COMMENT '国内中转仓ID快照' AFTER channel_name, + ADD COLUMN warehouse_name VARCHAR(128) NULL COMMENT '国内中转仓名称快照' AFTER warehouse_id; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_ocean_booking_container_requirement' AND column_name='capacity_cbm') THEN + ALTER TABLE baiyue_firstleg.fl_ocean_booking_container_requirement ADD COLUMN capacity_cbm DECIMAL(18,4) NULL COMMENT '柜型容量快照m3' AFTER container_kind, + ADD COLUMN max_weight_kg DECIMAL(18,3) NULL COMMENT '柜型最大承重快照kg' AFTER capacity_cbm; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_booking' AND column_name='channel_code') THEN + ALTER TABLE baiyue_firstleg.fl_container_booking ADD COLUMN channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照' AFTER cabinet_no, + ADD COLUMN channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照' AFTER channel_code, + ADD COLUMN ocean_booking_id BIGINT UNSIGNED NULL COMMENT '来源订舱ID' AFTER channel_name, + ADD COLUMN ocean_booking_requirement_id BIGINT UNSIGNED NULL COMMENT '来源订舱箱型需求ID' AFTER ocean_booking_id; + END IF; + UPDATE baiyue_firstleg.fl_container_booking c JOIN baiyue_firstleg.fl_ocean_booking_container_allocation a ON a.container_booking_id=c.id + JOIN baiyue_firstleg.fl_ocean_booking b ON b.id=a.ocean_booking_id + SET c.ocean_booking_id=b.id,c.ocean_booking_requirement_id=a.requirement_id,c.channel_code=COALESCE(c.channel_code,b.channel_code),c.channel_name=COALESCE(c.channel_name,b.channel_name) + WHERE c.ocean_booking_id IS NULL OR c.ocean_booking_requirement_id IS NULL OR c.channel_code IS NULL; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_loading_pick_task' AND column_name='container_booking_id') THEN + ALTER TABLE baiyue_firstleg.fl_loading_pick_task ADD COLUMN container_booking_id BIGINT UNSIGNED NULL COMMENT '稳定柜次ID' AFTER loading_plan_id, + ADD COLUMN cabinet_no VARCHAR(64) NULL COMMENT '稳定内部柜次号快照' AFTER container_booking_id, + MODIFY COLUMN container_no VARCHAR(64) NULL COMMENT '废弃兼容字段;不得作为任务身份'; + END IF; + UPDATE baiyue_firstleg.fl_loading_pick_task t JOIN baiyue_firstleg.fl_loading_plan p ON p.id=t.loading_plan_id JOIN baiyue_firstleg.fl_container_booking c ON c.id=p.container_booking_id + SET t.container_booking_id=c.id,t.cabinet_no=c.cabinet_no WHERE t.container_booking_id IS NULL OR t.cabinet_no IS NULL; + IF EXISTS (SELECT 1 FROM baiyue_firstleg.fl_loading_pick_task WHERE container_booking_id IS NULL OR cabinet_no IS NULL) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='渠道柜迁移失败:PDA任务无法回填稳定柜次归属'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_firstleg' AND table_name='fl_loading_pick_task' AND index_name='uk_loading_pick_task_booking') THEN + ALTER TABLE baiyue_firstleg.fl_loading_pick_task ADD UNIQUE KEY uk_loading_pick_task_booking (container_booking_id); + END IF; +END$$ +DELIMITER ; +CALL migrate_phase3_channel_containers(); +DROP PROCEDURE IF EXISTS migrate_phase3_channel_containers; +CREATE TABLE IF NOT EXISTS baiyue_firstleg.fl_container_transport_history ( + id BIGINT UNSIGNED NOT NULL,container_booking_id BIGINT UNSIGNED NOT NULL,before_container_no VARCHAR(64) NULL,after_container_no VARCHAR(64) NULL, + before_voyage_no VARCHAR(64) NULL,after_voyage_no VARCHAR(64) NULL,reason VARCHAR(500) NULL,operator_id BIGINT UNSIGNED NOT NULL, + operator_name VARCHAR(64) NULL,created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),PRIMARY KEY(id),KEY idx_container_transport_history(container_booking_id,created_at,id) +) ENGINE=InnoDB COMMENT='柜次真实运输字段不可变变更审计'; diff --git a/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java index def3dbc3a..2f8ba2211 100644 --- a/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java +++ b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java @@ -8,6 +8,7 @@ import com.baiyue.finance.service.FinanceQueryService; import com.baiyue.finance.client.FinanceFileClient; import com.baiyue.finance.client.FirstlegCostSnapshotClient; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import org.springframework.security.access.prepost.PreAuthorize; @@ -53,7 +54,9 @@ public class PlatformFinanceController { @GetMapping("/fee-pool") @PreAuthorize("hasAuthority('platform:finance:bill:view')") @Operation(summary = "客户应收应付费用池") - public ApiResponse feePool(@RequestParam long customerId, + public ApiResponse feePool( + @Parameter(description = "可选客户筛选;省略时查询全部客户") + @RequestParam(required = false) Long customerId, @RequestParam(required = false) String direction) { return FinanceResponses.success(queryService.feePool(customerId, direction)); } @@ -61,21 +64,27 @@ public class PlatformFinanceController { @GetMapping("/statements") @PreAuthorize("hasAuthority('platform:finance:bill:view')") @Operation(summary = "客户对账单") - public ApiResponse statements(@RequestParam long customerId) { + public ApiResponse statements( + @Parameter(description = "可选客户筛选;省略时查询全部客户") + @RequestParam(required = false) Long customerId) { return FinanceResponses.success(queryService.statements(customerId, false)); } @GetMapping("/receivable-settlements") @PreAuthorize("hasAuthority('platform:finance:bill:view')") @Operation(summary = "结算单应收") - public ApiResponse receivableSettlements(@RequestParam long customerId) { + public ApiResponse receivableSettlements( + @Parameter(description = "可选客户筛选;省略时查询全部客户") + @RequestParam(required = false) Long customerId) { return FinanceResponses.success(queryService.receivableSettlements(customerId, false)); } @GetMapping("/payable-settlements") @PreAuthorize("hasAuthority('platform:finance:cost:view')") @Operation(summary = "结算单应付") - public ApiResponse payableSettlements(@RequestParam long customerId) { + public ApiResponse payableSettlements( + @Parameter(description = "可选客户筛选;省略时查询全部客户") + @RequestParam(required = false) Long customerId) { return FinanceResponses.success(queryService.payableSettlements(customerId)); } diff --git a/finance/src/main/java/com/baiyue/finance/dto/FinanceReadModels.java b/finance/src/main/java/com/baiyue/finance/dto/FinanceReadModels.java index cacc9ff09..660a990c1 100644 --- a/finance/src/main/java/com/baiyue/finance/dto/FinanceReadModels.java +++ b/finance/src/main/java/com/baiyue/finance/dto/FinanceReadModels.java @@ -30,6 +30,7 @@ public final class FinanceReadModels { public record FeePoolItem( String poolItemId, + long customerId, String direction, String sourceType, long sourceId, 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 251fbdf2b..03304741c 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java @@ -58,45 +58,48 @@ public class FinanceQueryService { return new CustomerAccount(customerId, CNY, "MANUAL_RECEIVABLE_LEDGER", total, received, outstanding, List.copyOf(entries)); } - public List feePool(long customerId, String direction) { - requireCustomer(customerId); + public List feePool(Long customerId, String direction) { + validateOptionalCustomer(customerId); String normalized = direction == null || direction.isBlank() ? "ALL" : direction.trim().toUpperCase(java.util.Locale.ROOT); if (!List.of("ALL", "RECEIVABLE", "PAYABLE").contains(normalized)) throw bad("费用方向只能是RECEIVABLE或PAYABLE"); List result = new ArrayList<>(); - String sourceSql = "SELECT id,direction,source_type,source_fee_id,source_fee_version,order_id,inbound_id,container_id,fee_item_name,amount,currency_code,status,created_at FROM fin_fee_source WHERE customer_id=? AND status='AVAILABLE'"; - Object[] sourceArgs; - if ("ALL".equals(normalized)) { - sourceSql += " ORDER BY created_at DESC,id DESC"; - sourceArgs = new Object[]{customerId}; - } else { - sourceSql += " AND direction=? ORDER BY created_at DESC,id DESC"; - sourceArgs = new Object[]{customerId, normalized}; - } + String sourceSql = "SELECT id,customer_id,direction,source_type,source_fee_id,source_fee_version,order_id,inbound_id,container_id,fee_item_name,amount,currency_code,status,created_at FROM fin_fee_source WHERE status='AVAILABLE'"; + List sourceArgs = new ArrayList<>(); + if (customerId != null) { sourceSql += " AND customer_id=?"; sourceArgs.add(customerId); } + if (!"ALL".equals(normalized)) { sourceSql += " AND direction=?"; sourceArgs.add(normalized); } + sourceSql += " ORDER BY created_at DESC,id DESC"; result.addAll(jdbc.query(sourceSql, (rs, rowNum) -> { String rowDirection = rs.getString("direction"); - return new FeePoolItem(rowDirection + ":" + rs.getLong("id"), rowDirection, + return new FeePoolItem(rowDirection + ":" + rs.getLong("id"), rs.getLong("customer_id"), rowDirection, rs.getString("source_type"), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"), nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), nullableLong(rs, "container_id"), rs.getString("fee_item_name"), rs.getBigDecimal("amount"), rs.getString("currency_code"), rs.getString("status"), time(rs, "created_at")); - }, sourceArgs)); + }, sourceArgs.toArray())); if (!"RECEIVABLE".equals(normalized)) { - result.addAll(jdbc.query("SELECT id,order_id,inbound_id,container_id,fee_item_name,cost_amount,currency_code,status,created_at FROM fin_cost WHERE tenant_id=? ORDER BY created_at DESC,id DESC", (rs, rowNum) -> - new FeePoolItem("PAYABLE:" + rs.getLong("id"), "PAYABLE", "MANUAL_COST", rs.getLong("id"), 1, + String costSql = "SELECT id,tenant_id,order_id,inbound_id,container_id,fee_item_name,cost_amount,currency_code,status,created_at FROM fin_cost"; + Object[] costArgs = new Object[0]; + if (customerId != null) { costSql += " WHERE tenant_id=?"; costArgs = new Object[]{customerId}; } + costSql += " ORDER BY created_at DESC,id DESC"; + result.addAll(jdbc.query(costSql, (rs, rowNum) -> + new FeePoolItem("PAYABLE:" + rs.getLong("id"), rs.getLong("tenant_id"), "PAYABLE", "MANUAL_COST", rs.getLong("id"), 1, nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), nullableLong(rs, "container_id"), - rs.getString("fee_item_name"), rs.getBigDecimal("cost_amount"), rs.getString("currency_code"), rs.getString("status"), time(rs, "created_at")), customerId)); + rs.getString("fee_item_name"), rs.getBigDecimal("cost_amount"), rs.getString("currency_code"), rs.getString("status"), time(rs, "created_at")), costArgs)); } result.sort(Comparator.comparing(FeePoolItem::createdAt, Comparator.nullsLast(Comparator.reverseOrder()))); return List.copyOf(result); } - public List statements(long customerId, boolean clientVisibleOnly) { - requireCustomer(customerId); + public List statements(Long customerId, boolean clientVisibleOnly) { + validateOptionalCustomer(customerId); String visible = clientVisibleOnly ? " AND status IN ('ISSUED','PARTIALLY_PAID','PAID')" : ""; - return jdbc.query("SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version FROM fin_bill WHERE tenant_id=?" + visible + " ORDER BY bill_date DESC,id DESC", + String sql = "SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version FROM fin_bill WHERE 1=1"; + Object[] args = new Object[0]; + if (customerId != null) { sql += " AND tenant_id=?"; args = new Object[]{customerId}; } + return jdbc.query(sql + visible + " ORDER BY bill_date DESC,id DESC", (rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), rs.getString("customer_name_snapshot"), rs.getDate("bill_date").toLocalDate(), rs.getDate("due_date").toLocalDate(), rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"), - rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version")), customerId); + rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version")), args); } public StatementDetail statement(long customerId, long billId, boolean clientVisibleOnly) { @@ -127,22 +130,25 @@ public class FinanceQueryService { rs.getString("bill_snapshot"), rs.getString("line_snapshot"), rs.getLong("created_by"), time(rs, "created_at")), customerId, billId); } - public List receivableSettlements(long customerId, boolean clientVisibleOnly) { + public List receivableSettlements(Long customerId, boolean clientVisibleOnly) { return statements(customerId, clientVisibleOnly).stream().map(row -> new ReceivableSettlement( row.id(), row.customerId(), row.billNo(), row.billDate(), row.dueDate(), row.totalAmount(), row.receivedAmount(), row.outstandingAmount(), row.currency(), row.status(), row.version())).toList(); } - public List payableSettlements(long customerId) { - requireCustomer(customerId); - return jdbc.query("SELECT id,tenant_id,cost_no,cost_type,order_id,inbound_id,container_id,supplier_name_snapshot,fee_item_name,cost_amount,currency_code,status,created_at,version FROM fin_cost WHERE tenant_id=? ORDER BY created_at DESC,id DESC", + public List payableSettlements(Long customerId) { + validateOptionalCustomer(customerId); + String sql = "SELECT id,tenant_id,cost_no,cost_type,order_id,inbound_id,container_id,supplier_name_snapshot,fee_item_name,cost_amount,currency_code,status,created_at,version FROM fin_cost WHERE 1=1"; + Object[] args = new Object[0]; + if (customerId != null) { sql += " AND tenant_id=?"; args = new Object[]{customerId}; } + return jdbc.query(sql + " ORDER BY created_at DESC,id DESC", (rs, rowNum) -> { BigDecimal amount = rs.getBigDecimal("cost_amount"); return new PayableSettlement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("cost_no"), rs.getString("cost_type"), nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), nullableLong(rs, "container_id"), rs.getString("supplier_name_snapshot"), rs.getString("fee_item_name"), amount, BigDecimal.ZERO, amount, rs.getString("currency_code"), rs.getString("status"), time(rs, "created_at"), rs.getInt("version")); - }, customerId); + }, args); } public CostDetail cost(long customerId, long costId) { @@ -206,6 +212,10 @@ public class FinanceQueryService { if (customerId <= 0) throw bad("客户范围无效"); } + private static void validateOptionalCustomer(Long customerId) { + if (customerId != null) requireCustomer(customerId); + } + private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); } diff --git a/finance/src/test/java/com/baiyue/finance/FinanceFeePoolDirectionBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceFeePoolDirectionBehaviorTest.java index 0b87371e7..5398c4abf 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceFeePoolDirectionBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceFeePoolDirectionBehaviorTest.java @@ -59,7 +59,7 @@ class FinanceFeePoolDirectionBehaviorTest { } private static FeePoolItem item(String direction, String sourceType, long sourceId) { - return new FeePoolItem(direction + ":" + sourceId, direction, sourceType, sourceId, 1, + return new FeePoolItem(direction + ":" + sourceId, 7L, direction, sourceType, sourceId, 1, 11L, 21L, null, "清关费", new BigDecimal("100.00"), "CNY", "AVAILABLE", LocalDateTime.of(2026, 8, 11, 1, 0)); } diff --git a/finance/src/test/java/com/baiyue/finance/FinancePlatformListContractTest.java b/finance/src/test/java/com/baiyue/finance/FinancePlatformListContractTest.java new file mode 100644 index 000000000..eb5399a3b --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinancePlatformListContractTest.java @@ -0,0 +1,52 @@ +package com.baiyue.finance; + +import com.baiyue.finance.controller.PlatformFinanceController; +import io.swagger.v3.oas.annotations.Parameter; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.RequestParam; + +import java.lang.reflect.Method; +import java.util.Map; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class FinancePlatformListContractTest { + @Test + void platformCustomerFiltersAreOptionalAndDocumentAllCustomerDefault() throws Exception { + for (String methodName : new String[]{"feePool", "statements", "receivableSettlements", "payableSettlements"}) { + Method method = find(methodName); + var customer = method.getParameters()[0]; + assertThat(customer.getType()).isEqualTo(Long.class); + assertThat(customer.getAnnotation(RequestParam.class).required()).isFalse(); + assertThat(customer.getAnnotation(Parameter.class).description()).contains("省略时查询全部客户"); + } + } + + @Test + void platformListPermissionsRemainSeparatedByReceivableAndPayableData() throws Exception { + Map expected = Map.of( + "feePool", "hasAuthority('platform:finance:bill:view')", + "statements", "hasAuthority('platform:finance:bill:view')", + "receivableSettlements", "hasAuthority('platform:finance:bill:view')", + "payableSettlements", "hasAuthority('platform:finance:cost:view')"); + for (var entry : expected.entrySet()) { + assertThat(find(entry.getKey()).getAnnotation(PreAuthorize.class).value()).isEqualTo(entry.getValue()); + } + } + + @Test + void allCustomerFeePoolResponseCarriesCustomerIdentity() { + assertThat(Arrays.stream(com.baiyue.finance.dto.FinanceReadModels.FeePoolItem.class + .getRecordComponents()).map(java.lang.reflect.RecordComponent::getName)) + .contains("customerId"); + } + + private static Method find(String name) throws Exception { + return switch (name) { + case "feePool" -> PlatformFinanceController.class.getMethod(name, Long.class, String.class); + default -> PlatformFinanceController.class.getMethod(name, Long.class); + }; + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinancePlatformOptionalCustomerQueryTest.java b/finance/src/test/java/com/baiyue/finance/FinancePlatformOptionalCustomerQueryTest.java new file mode 100644 index 000000000..fa89a31dc --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinancePlatformOptionalCustomerQueryTest.java @@ -0,0 +1,67 @@ +package com.baiyue.finance; + +import com.baiyue.finance.service.FinanceQueryService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class FinancePlatformOptionalCustomerQueryTest { + @Test + void allCustomerQueriesOmitCustomerPredicateAndArguments() { + RecordingJdbc jdbc = new RecordingJdbc(); + FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper()); + + service.feePool(null, null); + service.statements(null, false); + service.receivableSettlements(null, false); + service.payableSettlements(null); + + assertThat(jdbc.calls).hasSize(5).allSatisfy(call -> { + assertThat(call.sql()).doesNotContain("customer_id=?", "tenant_id=?"); + assertThat(call.args()).isEmpty(); + }); + } + + @Test + void customerFilterAddsPredicateAndRealCustomerArgument() { + RecordingJdbc jdbc = new RecordingJdbc(); + FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper()); + + service.feePool(31L, null); + service.statements(31L, false); + service.receivableSettlements(31L, false); + service.payableSettlements(31L); + + assertThat(jdbc.calls).hasSize(5).allSatisfy(call -> { + assertThat(call.sql()).containsAnyOf("customer_id=?", "tenant_id=?"); + assertThat(call.args()).containsExactly(31L); + }); + } + + @Test + void optionalCustomerNeverTreatsZeroAsAllCustomers() { + FinanceQueryService service = new FinanceQueryService(new RecordingJdbc(), new ObjectMapper()); + + assertThatThrownBy(() -> service.statements(0L, false)).hasMessageContaining("客户范围无效"); + assertThatThrownBy(() -> service.feePool(-1L, null)).hasMessageContaining("客户范围无效"); + } + + private record Call(String sql, List args) {} + + private static final class RecordingJdbc extends JdbcTemplate { + private final List calls = new ArrayList<>(); + + @Override + public List query(String sql, RowMapper mapper, Object... args) { + calls.add(new Call(sql, List.of(args))); + return List.of(); + } + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinanceReadApiBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceReadApiBehaviorTest.java index 98e87e1bd..139989c5f 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceReadApiBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceReadApiBehaviorTest.java @@ -22,6 +22,7 @@ import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -75,4 +76,35 @@ class FinanceReadApiBehaviorTest { verify(queryService).receivableSettlements(11L, false); verify(queryService).payableSettlements(11L); } + + @Test + void platformListsDefaultToAllCustomersAndKeepOptionalFilter() { + var controller = new PlatformFinanceController(service, fileClient, snapshotClient, queryService); + + controller.feePool(null, null); + controller.statements(null); + controller.receivableSettlements(null); + controller.payableSettlements(null); + controller.statements(31L); + + verify(queryService).feePool(null, null); + verify(queryService).statements(null, false); + verify(queryService).receivableSettlements(null, false); + verify(queryService).payableSettlements(null); + verify(queryService).statements(31L, false); + } + + @Test + void clientListsAlwaysUseAuthenticatedCustomerScope() { + AuthenticatedUser user = new AuthenticatedUser(7L, 23L, null, null, "client", "MERCHANT", "WEB", Set.of()); + var controller = new ClientFinanceController(service, queryService); + + controller.bills(user); + controller.statements(user); + controller.receivableSettlements(user); + + verify(queryService, org.mockito.Mockito.times(2)).statements(23L, true); + verify(queryService).receivableSettlements(23L, true); + verifyNoMoreInteractions(queryService); + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java index 44f6bd0b9..fdb07ec0c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java @@ -60,6 +60,15 @@ public class PlatformLoadingPdaController { return ApiResponse.success(service.pendingTasks(user.userId(), keyword)); } + @GetMapping("/tasks/{taskId}") + @PreAuthorize("hasAuthority('platform:firstleg:pda:view')") + public ApiResponse taskById(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long taskId, + @RequestParam @Positive long warehouseId) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.task(taskId, warehouseId)); + } + @GetMapping("/tasks/{taskId}/inventory-snapshot") @PreAuthorize("hasAuthority('platform:firstleg:pda:view')") public ApiResponse inventorySnapshot(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java index eef938937..9504318ba 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java @@ -84,6 +84,7 @@ public final class ContainerDtos { public record TransportUpdateRequest( @Size(max = 64) String containerNo, @Size(max = 64) String voyageNo, - @NotNull @PositiveOrZero Integer version + @NotNull @PositiveOrZero Integer version, + @NotBlank @Size(max = 500) String reason ) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java index 5c283b841..9e956c704 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java @@ -62,6 +62,7 @@ public final class LoadingListDtos { public record PreloadSummary( Long planId, + Long containerBookingId, String preloadNo, String containerNo, String internalContainerNo, @@ -86,6 +87,7 @@ public final class LoadingListDtos { public record ConfirmedSummary( Long planId, + Long containerBookingId, String loadingListNo, Long warehouseId, String warehouseName, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java index 9b189b5a8..b214310d5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java @@ -58,7 +58,9 @@ public final class LoadingPickDtos { /** PDA 在线草稿只在员工提交时发送一次,服务端原子处理整批明细。 */ public record BatchRequest( @NotNull @Min(1) Long warehouseId, - @NotBlank @Size(max = 64) String containerNo, + @NotNull @Min(1) Long loadingPlanId, + @NotNull @Min(1) Long containerBookingId, + @Size(max = 64) String containerNo, @NotNull @Min(0) Integer expectedVersion, @NotBlank @Size(max = 64) String inventorySnapshotToken, @NotEmpty @Size(max = 500) List<@Valid BatchItemRequest> items @@ -125,13 +127,15 @@ public final class LoadingPickDtos { Long operatorId, String operatorName, LocalDateTime createdAt) { } - public record Task(Long id, Long loadingPlanId, String containerNo, String taskStatus, + public record Task(Long id, Long loadingPlanId, Long containerBookingId, String cabinetNo, + String currentContainerNo, String taskStatus, String workflowStatus, Integer version, Assignment assignment, Review review, List items) { } /** PDA 工作台只展示当前作业员可领取或已领取的进行中任务。 */ - public record PendingTask(Long taskId, Long loadingPlanId, String containerNo, + public record PendingTask(Long taskId, Long loadingPlanId, Long containerBookingId, + String cabinetNo, String currentContainerNo, Long warehouseId, String warehouseName, String taskStatus, String workflowStatus, Integer version, Integer assignmentVersion, Integer itemCount, Integer pendingItemCount) { @@ -146,7 +150,8 @@ public final class LoadingPickDtos { List resources) { } - public record InventorySnapshot(Long taskId, Integer taskVersion, String containerNo, + public record InventorySnapshot(Long taskId, Long loadingPlanId, Long containerBookingId, + String cabinetNo, Integer taskVersion, String currentContainerNo, Long warehouseId, LocalDateTime capturedAt, String snapshotToken, List inbounds) { } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java index d2dcd723a..3d99928c0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java @@ -2,12 +2,12 @@ package com.baiyue.firstleg.dto; import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.util.List; public final class SmartLoadingDtos { private SmartLoadingDtos(){} public record Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode, - Long warehouseId, Long destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsName, Integer availablePackageCount, + Long warehouseId, String channelCode, String channelName, Long destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsName, Integer availablePackageCount, BigDecimal unitWeightKg, BigDecimal unitVolumeCbm) { public Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode, Long warehouseId, String destinationWarehouseCode, String customerName, String goodsName, Integer availablePackageCount, BigDecimal unitWeightKg, BigDecimal unitVolumeCbm) { - this(accountId,inboundId,inboundDetailId,orderId,orderNo,inboundCode,warehouseId,null,destinationWarehouseCode,null,customerName,goodsName,availablePackageCount,unitWeightKg,unitVolumeCbm); + this(accountId,inboundId,inboundDetailId,orderId,orderNo,inboundCode,warehouseId,null,null,null,destinationWarehouseCode,null,customerName,goodsName,availablePackageCount,unitWeightKg,unitVolumeCbm); } } public record CreateRequest(@NotNull @Positive Long bookingId, @Size(max=500) String remark) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java index 778735c24..85ac199e2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java @@ -20,6 +20,10 @@ public class ContainerBookingEntity { private String bookingNo; private String containerNo; private String cabinetNo; + private String channelCode; + private String channelName; + private Long oceanBookingId; + private Long oceanBookingRequirementId; private Long warehouseId; private String warehouseName; private String billOfLadingNo; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerTransportHistoryEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerTransportHistoryEntity.java new file mode 100644 index 000000000..c79779045 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerTransportHistoryEntity.java @@ -0,0 +1,21 @@ +package com.baiyue.firstleg.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +@Getter @Setter +@TableName("fl_container_transport_history") +public class ContainerTransportHistoryEntity { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private Long containerBookingId; + private String beforeContainerNo; + private String afterContainerNo; + private String beforeVoyageNo; + private String afterVoyageNo; + private String reason; + private Long operatorId; + private String operatorName; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickTaskEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickTaskEntity.java index 36f020992..cb51e7d25 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickTaskEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickTaskEntity.java @@ -17,6 +17,8 @@ public class LoadingPickTaskEntity { @TableId(type = IdType.ASSIGN_ID) private Long id; private Long loadingPlanId; + private Long containerBookingId; + private String cabinetNo; private String containerNo; private String taskStatus; private Long startedBy; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java index 2371d34dd..9f72ff393 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java @@ -96,4 +96,15 @@ public interface ContainerBookingMapper extends BaseMapper {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java index cbf7aee43..5cfc40fab 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java @@ -24,7 +24,7 @@ public interface LoadingPickTaskMapper extends BaseMapper JOIN fl_loading_pick_item pick_item ON pick_item.pick_task_id = task.id JOIN fl_loading_plan plan ON plan.id = task.loading_plan_id JOIN fl_container_booking booking ON booking.id = plan.container_booking_id - WHERE task.container_no = #{containerNo} AND task.task_status = 'LOADING' + WHERE booking.container_no = #{containerNo} AND task.task_status = 'LOADING' AND booking.warehouse_id = #{warehouseId} LIMIT 1 """) @@ -34,7 +34,8 @@ public interface LoadingPickTaskMapper extends BaseMapper @Select("""