From 96ffc0925ab672b8fe85e54caab6f561375cd595 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Wed, 28 Oct 2026 11:29:38 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B4=B9=E7=94=A8=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/README.md | 2 + ...verseas-fee-menu-finance-parent-repair.sql | 83 +++++++++++++++++++ deploy/sql/头程数据库统一增量-MySQL8.sql | 2 +- .../resources/db/overseas-fee-permissions.sql | 4 +- .../service/DispatchAggregateService.java | 4 +- .../transport/TransportTaskService.java | 7 +- .../lastleg/dto/LastlegOrderRequests.java | 2 +- ...DispatchListAndResourceRegressionTest.java | 20 +++++ .../transport/TransportTaskServiceTest.java | 20 +++++ .../lastleg/dto/LastlegOrderRequestsTest.java | 45 ++++++++++ 10 files changed, 180 insertions(+), 9 deletions(-) create mode 100644 deploy/sql/overseas-fee-menu-finance-parent-repair.sql create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dto/LastlegOrderRequestsTest.java diff --git a/deploy/sql/README.md b/deploy/sql/README.md index f4f31a11a..762fab2a8 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -1,5 +1,7 @@ # 单测试环境数据库空库初始化 +`overseas-fee-menu-finance-parent-repair.sql` 为已有 `baiyue_iam` 的定向权限修复:将菜单 `platform:overseas-fee:list` 改名为“海外费用明细”,父菜单设为 `platform:finance`(财务管理),保留原路由和 PLATFORM_ADMIN 授权;可重复执行,目标菜单缺失或权限身份冲突时停止。 + `overseas-procurement-test-data-reset.sql` 为用户已确认可丢弃采购造数后的测试专用重置脚本:仅清空六张当前采购专属表,删除二十一张旧采购表;不会修改共用费用、余额、文件对象、库存或其他业务表,也不处理共用表的历史采购关联记录或退款。先停止采购写入并更新分离后的后端,完整执行文件、遇错停止,禁止 `--force`;DELETE 使用事务,后续 DROP 为隐式提交,不能整体回滚。无需存储过程、DELIMITER 或确认变量;成功返回 `procurement_test_reset_complete`,两个数量均为 0,成功后可重复执行。2026-10-27 已在本地隔离 MySQL 8.0.40 使用真实 27 表定义、三张 APPROVED 单及收货/出库/旧费用记录验证两次执行成功,共用表对照数据不变;未连接用户目标数据库,不表示部署完成。 `finance-receivable-active-uniqueness-repair.sql` 为 TEST 应收作废后重新建单的定向约束修复,仅以一条原子 ALTER 将 `baiyue_finance.fin_settlement` 的全历史唯一键替换为有效应收唯一键;作废历史仍保留原对账单关联,同一客户、同一对账单最多一张未作废应收。须先具备 Finance 重开校验版本 `fe89b8a2b875`,再单独授权执行 SQL;不修改业务金额、不清理或回填数据、不重放空库基线。执行前后核对表结构与记录总数,遇错停止,结果不明确先回查;脚本存在不表示已执行。 diff --git a/deploy/sql/overseas-fee-menu-finance-parent-repair.sql b/deploy/sql/overseas-fee-menu-finance-parent-repair.sql new file mode 100644 index 000000000..b7d4acf9b --- /dev/null +++ b/deploy/sql/overseas-fee-menu-finance-parent-repair.sql @@ -0,0 +1,83 @@ +-- 海外费用菜单定向修复,MySQL 8.0+,目标只能 baiyue_iam。 +-- 将“海外费用管理”改为“海外费用明细”,并挂到“财务管理”下。 +-- 权限码、路由和角色授权保持不变;可重复执行。 +USE baiyue_iam; +SET NAMES utf8mb4; + +DROP PROCEDURE IF EXISTS repair_overseas_fee_menu_parent; +DELIMITER $$ +CREATE PROCEDURE repair_overseas_fee_menu_parent() +BEGIN + DECLARE v_menu_changed INT DEFAULT 0; + DECLARE EXIT HANDLER FOR SQLEXCEPTION + BEGIN + ROLLBACK; + RESIGNAL; + END; + + IF (SELECT COUNT(*) FROM iam_permission + WHERE id=5105000 AND tenant_id=0 AND app_code='PLATFORM' + AND permission_type='MENU' AND permission_code='platform:finance' + AND status='ACTIVE' AND deleted=0)<>1 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT='Finance parent menu platform:finance is missing or inactive'; + END IF; + + IF EXISTS (SELECT 1 FROM iam_permission + WHERE (id=5117200 AND (permission_code<>'platform:overseas-fee:list' + OR tenant_id<>0 OR app_code<>'PLATFORM' OR permission_type<>'MENU')) + OR (permission_code='platform:overseas-fee:list' + AND (id<>5117200 OR tenant_id<>0 OR app_code<>'PLATFORM' + OR permission_type<>'MENU'))) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT='Overseas fee menu permission identity conflict'; + END IF; + + IF (SELECT COUNT(*) FROM iam_permission + WHERE id=5117200 AND tenant_id=0 AND app_code='PLATFORM' + AND permission_type='MENU' AND permission_code='platform:overseas-fee:list')<>1 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT='Overseas fee menu platform:overseas-fee:list is missing'; + END IF; + + START TRANSACTION; + UPDATE iam_permission + SET parent_id=5105000, + permission_name='海外费用明细', + route_path='/overseas-business/fees', + http_method='GET', + sort_no=60, + status='ACTIVE', + deleted=0, + updated_at=CURRENT_TIMESTAMP(3) + WHERE id=5117200 AND tenant_id=0 AND app_code='PLATFORM' + AND permission_type='MENU' AND permission_code='platform:overseas-fee:list' + AND (parent_id<>5105000 OR permission_name<>'海外费用明细' + OR route_path<>'/overseas-business/fees' OR http_method<>'GET' + OR sort_no<>60 OR status<>'ACTIVE' OR deleted<>0); + SET v_menu_changed=ROW_COUNT(); + + -- 菜单实际变化时递增已获该菜单权限用户的安全版本,刷新旧授权快照。 + IF v_menu_changed>0 THEN + INSERT INTO iam_security_subject_version(auth_user_id,permission_version) + SELECT DISTINCT u.auth_user_id,1 + FROM iam_user u + JOIN iam_membership m ON m.user_id=u.id AND m.tenant_id=0 + JOIN iam_membership_role mr ON mr.membership_id=m.id AND mr.tenant_id=0 + JOIN iam_role r ON r.id=mr.role_id AND r.tenant_id=0 + JOIN iam_role_permission rp ON rp.role_id=r.id + AND rp.permission_id=5117200 AND rp.tenant_id=0 + WHERE u.auth_user_id IS NOT NULL AND u.status='ACTIVE' AND u.deleted=0 + AND m.status='ACTIVE' AND m.deleted=0 + AND r.app_code='PLATFORM' AND r.status='ACTIVE' AND r.deleted=0 + ON DUPLICATE KEY UPDATE permission_version=permission_version+1; + END IF; + + COMMIT; +END$$ +DELIMITER ; +CALL repair_overseas_fee_menu_parent(); +DROP PROCEDURE repair_overseas_fee_menu_parent; + +SELECT id,parent_id,permission_code,permission_name,route_path,sort_no +FROM iam_permission WHERE id IN (5105000,5117200) ORDER BY id; diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 244e6525d..2a65087ad 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -8462,6 +8462,6 @@ CREATE TABLE wh_document_sequence ( -- 海外费用查询菜单;编辑、审核继续使用独立财务按钮授权。 USE baiyue_iam; -INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES (5117200,0,'PLATFORM','MENU',0,'platform:overseas-fee:list','海外费用管理','/overseas-business/fees','GET',890,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES (5117200,0,'PLATFORM','MENU',5105000,'platform:overseas-fee:list','海外费用明细','/overseas-business/fees','GET',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES (4000001,5117200,0,CURRENT_TIMESTAMP(3)); SELECT 'PASS' AS initialization_status, 'CN' AS region, 229 AS initialized_tables; diff --git a/iam/src/main/resources/db/overseas-fee-permissions.sql b/iam/src/main/resources/db/overseas-fee-permissions.sql index 79d7184d5..9eb63682a 100644 --- a/iam/src/main/resources/db/overseas-fee-permissions.sql +++ b/iam/src/main/resources/db/overseas-fee-permissions.sql @@ -30,8 +30,8 @@ DROP PROCEDURE check_overseas_fee_permission_identity; START TRANSACTION; INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted) -VALUES (5117200,0,'PLATFORM','MENU',0,'platform:overseas-fee:list','海外费用管理','/overseas-business/fees','GET',890,'ACTIVE',1,0) -ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),route_path=VALUES(route_path),parent_id=0,status='ACTIVE',deleted=0; +VALUES (5117200,0,'PLATFORM','MENU',5105000,'platform:overseas-fee:list','海外费用明细','/overseas-business/fees','GET',60,'ACTIVE',1,0) +ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),route_path=VALUES(route_path),parent_id=5105000,sort_no=60,status='ACTIVE',deleted=0; INSERT IGNORE INTO iam_role_permission(tenant_id,role_id,permission_id,created_at) SELECT 0,r.id,5117200,CURRENT_TIMESTAMP(3) FROM iam_role r diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index 6759387d2..e16a4f870 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -1055,8 +1055,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA boolean localTruck="LOCAL_TRUCK".equals(row.getTaskType()) && "ACTIVE".equals(row.getStatus()); for(var action:Action.values())if(action!=Action.ARRANGE_DELIVERY && action!=Action.DEPART) { boolean state=(pickup || localTruck) && switch(action) { - case EDIT -> Set.of("BEFORE_LOADING","LOADING").contains(row.getStage()); - case CANCEL -> localTruck ? "BEFORE_LOADING".equals(row.getStage()) : Set.of("BEFORE_LOADING","LOADING").contains(row.getStage()); + case EDIT -> pickup ? "BEFORE_LOADING".equals(row.getStage()) && !"APPROVED".equals(row.getReviewState()) : Set.of("BEFORE_LOADING","LOADING").contains(row.getStage()); + case CANCEL -> "BEFORE_LOADING".equals(row.getStage()) && !"APPROVED".equals(row.getReviewState()); case SUBMIT_REVIEW -> "BEFORE_LOADING".equals(row.getStage()) && canSubmitReview(row.getReviewState()); case DISPATCH_REVIEW -> "DISPATCH_PENDING".equals(row.getReviewState()); case LOADING_REVIEW -> "LOADING_PENDING".equals(row.getReviewState()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskService.java index 6aa0bb236..2be0ab5d2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskService.java @@ -131,6 +131,7 @@ public class TransportTaskService { var row=roots.lock(number); if(!Objects.equals(trip.getRecordVersion(),request.recordVersion()) || request.dispatchRecordVersion()==null || !Objects.equals(row.getRecordVersion(),request.dispatchRecordVersion()))throw conflict("排车或审核信息已变化,请刷新后重试"); + if("PICKUP".equals(trip.getTripPurpose()) && (!"BEFORE_LOADING".equals(row.getStage()) || "APPROVED".equals(row.getReviewState())))throw conflict("理货审核通过后不能维护车辆"); if(!"PLANNED".equals(trip.getStatus()) || "PICKUP".equals(trip.getTripPurpose()) && pickups.tasks(trip.getTripNo()).stream().anyMatch(task->!"ASSIGNED".equals(task.status())))throw conflict("已开始执行的任务不能更换资源照片"); // 主档照片缺省带入本车;显式本趟照片仍由原归属校验复核。 var supplied=request.reviewPhotos(); @@ -179,10 +180,10 @@ public class TransportTaskService { @Transactional public TransportTaskModels.Detail cancel(String number,String operator,FleetTripModels.Cancel request) { var trip=transport(number,true); - // 本土卡车理货通过即进入装车,后续阶段禁止撤销运输安排。 - if("LOCAL_TRUCK".equals(trip.getTripPurpose())) { + // 运输任务理货通过即进入装车,后续阶段禁止撤销运输安排。 + if(Set.of("PICKUP","LOCAL_TRUCK").contains(trip.getTripPurpose())) { var row=roots.lock(number); - if(row==null || !"BEFORE_LOADING".equals(row.getStage()))throw conflict("装车中及后续阶段不能取消排车"); + if(row==null || !"BEFORE_LOADING".equals(row.getStage()) || "APPROVED".equals(row.getReviewState()))throw conflict("理货审核通过及后续阶段不能取消排车"); } trips.cancel(trip.getTripNo(),operator,request);return detail(number); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java index 6a31356f1..867b92323 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java @@ -109,7 +109,7 @@ public final class LastlegOrderRequests { @Min(1) Long supplierId, @Size(max = 200) String supplierName, @Size(max = 32) String supplierCategory, - @Pattern(regexp = "ON_ACCOUNT|DIRECT_PAID") String settlementMode, + @Pattern(regexp = "BILL|WALLET|ON_ACCOUNT|DIRECT_PAID") String settlementMode, LocalDateTime paidAt, @Pattern(regexp = "ONLINE|BANK_TRANSFER|CASH|OTHER") String paymentMethod, @Size(max = 128) String paymentReferenceNo, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchListAndResourceRegressionTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchListAndResourceRegressionTest.java index 371b2fa74..0888a53d1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchListAndResourceRegressionTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchListAndResourceRegressionTest.java @@ -14,6 +14,26 @@ import static org.mockito.ArgumentMatchers.*; /** 创建时间公共投影、实际货件来源及司机换证保存的直接回归。 */ class DispatchListAndResourceRegressionTest { + /** 取货理货通过后动作快照关闭资源维护和取消,不受尚未上传装货照片影响。 */ + @Test void pickupActionsLockResourcesAndCancellationAfterApproval() throws Exception { + var f=new DispatchAggregatePersistenceTest.Fixture(); + var row=new com.baiyue.lastleg.dispatch.entity.DispatchAggregate(); + row.setTaskType("PICKUP");row.setStatus("ACTIVE");row.setStage("BEFORE_LOADING");row.setReviewState("DRAFT"); + var security=org.springframework.security.core.context.SecurityContextHolder.getContext();var previous=security.getAuthentication(); + security.setAuthentication(new org.springframework.security.authentication.UsernamePasswordAuthenticationToken("operator-test","",List.of( + new org.springframework.security.core.authority.SimpleGrantedAuthority("platform:overseas-warehouse:dispatch:edit"), + new org.springframework.security.core.authority.SimpleGrantedAuthority("platform:overseas-warehouse:dispatch:cancel")))); + try { + assertThat(f.service.actions(f.actor,row).get(Action.EDIT).allowed()).isTrue(); + assertThat(f.service.actions(f.actor,row).get(Action.CANCEL).allowed()).isTrue(); + row.setReviewState("APPROVED"); + for(var stage:Stage.values()) { + row.setStage(stage.name()); + assertThat(f.service.actions(f.actor,row).get(Action.EDIT).allowed()).isFalse(); + assertThat(f.service.actions(f.actor,row).get(Action.CANCEL).allowed()).isFalse(); + } + } finally { security.setAuthentication(previous); } + } /** 各阶段、审核分区和任务类型均取同一主单创建时间,不借用计划或更新时间。 */ @Test void creationTimeIsPreservedAcrossAllListScopes() throws Exception { var f=new DispatchAggregatePersistenceTest.Fixture(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskServiceTest.java index 5c59a8f13..505b2fc64 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskServiceTest.java @@ -22,6 +22,26 @@ class TransportTaskServiceTest { DispatchAggregateRepository roots;TransportTaskRepository links;FleetTripRepository tripRepo;FleetTripService trips; UsTruckOrderRepository trucks;TripOperationsEvidenceGateway files;TransportTaskService service;FleetTripEntity trip;UsTruckOrder order; final Map commands=new HashMap<>(); + /** 理货通过后的旧页面不能更换资源或取消取货,尚未上传装货照片也不能例外。 */ + @Test void approvedPickupRejectsResourceChangesAndCancellation() { + var root=roots.find("PC1");root.setTaskType("PICKUP");root.setRecordVersion(9L);root.setReviewState("APPROVED"); + when(roots.lock("PC1")).thenReturn(root); + trip.setTripPurpose("PICKUP");trip.setStatus("PLANNED"); + for(var stage:List.of("LOADING","AFTER_LOADING","DELIVERING","DELIVERED")) { + root.setStage(stage); + assertThatThrownBy(()->service.assign("PC1","OP",new TransportTaskModels.Assign(3L,7L,8L,"保存",9L,null,null))).hasMessageContaining("不能维护车辆"); + assertThatThrownBy(()->service.cancel("PC1","OP",new FleetTripModels.Cancel(3L,"取消"))).hasMessageContaining("不能取消排车"); + } + verifyNoInteractions(trips,files); + } + /** 审核前仍可取消,沿用原运输取消与审计路径。 */ + @Test void pickupBeforeLoadingCanStillCancel() { + var root=roots.find("PC1");root.setTaskType("PICKUP");root.setStage("BEFORE_LOADING");root.setReviewState("DRAFT"); + when(roots.lock("PC1")).thenReturn(root);trip.setTripPurpose("PICKUP");trip.setStatus("PLANNED"); + doThrow(new IllegalStateException("已进入运输取消")).when(trips).cancel(eq("TR1"),eq("OP"),any()); + assertThatThrownBy(()->service.cancel("PC1","OP",new FleetTripModels.Cancel(3L,"取消"))).hasMessage("已进入运输取消"); + verify(trips).cancel(eq("TR1"),eq("OP"),any()); + } /** 本土卡车审核仍拦截执行中任务、无效时间和未维护车辆,不因分流而放松门禁。 */ @Test void localTruckReviewRejectsInvalidExecutionTimeAndMissingResources() { assertThatThrownBy(()->service.verifyLocalTruckReview("PC1")).hasMessageContaining("未发车"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dto/LastlegOrderRequestsTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dto/LastlegOrderRequestsTest.java new file mode 100644 index 000000000..99483b606 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dto/LastlegOrderRequestsTest.java @@ -0,0 +1,45 @@ +package com.baiyue.lastleg.dto; + +import jakarta.validation.Validation; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.math.BigDecimal; +import java.time.LocalDate; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LastlegOrderRequestsTest { + /** 入口接受应收、应付及默认结算方式,方向组合仍由费用服务校验。 */ + @ParameterizedTest + @NullSource + @ValueSource(strings = {"BILL", "WALLET", "ON_ACCOUNT", "DIRECT_PAID"}) + void acceptsSupportedSettlementModes(String settlementMode) { + try (var factory = Validation.buildDefaultValidatorFactory()) { + assertTrue(factory.getValidator().validate(fee(settlementMode)).isEmpty()); + } + } + + /** 未知或空白结算代码必须在请求入口拒绝。 */ + @ParameterizedTest + @ValueSource(strings = {"", "CASH", "wallet"}) + void rejectsUnsupportedSettlementModes(String settlementMode) { + try (var factory = Validation.buildDefaultValidatorFactory()) { + var violations = factory.getValidator().validate(fee(settlementMode)); + assertEquals(1, violations.size()); + assertEquals("settlementMode", violations.iterator().next().getPropertyPath().toString()); + } + } + + /** 构造订单详情提交的有效费用,覆盖截图中的美元余额扣费请求。 */ + private static LastlegOrderRequests.CreateFee fee(String settlementMode) { + boolean payable = "ON_ACCOUNT".equals(settlementMode) || "DIRECT_PAID".equals(settlementMode); + return new LastlegOrderRequests.CreateFee( + "FEE_TYPE_071", "装车费", null, payable ? "PAYABLE" : "RECEIVABLE", "USD", + new BigDecimal("10.00"), LocalDate.of(2026, 10, 28), + null, null, null, settlementMode, null, null, null, null, + BigDecimal.ONE, "天", null, null, null); + } +}