diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 11860de19..54761b447 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -8,6 +8,8 @@ `finance-receivable-active-uniqueness-repair.sql` 为 TEST 应收作废后重新建单的定向约束修复,仅以一条原子 ALTER 将 `baiyue_finance.fin_settlement` 的全历史唯一键替换为有效应收唯一键;作废历史仍保留原对账单关联,同一客户、同一对账单最多一张未作废应收。须先具备 Finance 重开校验版本 `fe89b8a2b875`,再单独授权执行 SQL;不修改业务金额、不清理或回填数据、不重放空库基线。执行前后核对表结构与记录总数,遇错停止,结果不明确先回查;脚本存在不表示已执行。 +`finance-reconciliation-line-uniqueness-repair.sql` 修正 TEST 作废客户对账单后原费用无法重新生成对账单的问题:以一条原子 ALTER 将 `baiyue_finance.fin_bill_line` 的费用版本全历史唯一键改为单内唯一键,并保留费用来源普通索引。作废明细和金额快照完整保留;有效费用占用继续由 Finance 生成时的行锁、AVAILABLE → LOCKED 状态校验及作废单重开校验保证。模块基线与统一空库基线同步。先核对上述服务校验及目标表结构,经单独授权执行,再核对结构和记录总数;遇错停止,结果不明确先回查,不盲目重放,不更新业务数据或重放空库基线。脚本存在不表示已执行。 + `overseas-procurement-workflow.sql` 新增简化采购的分批收货、分批出库与幂等三表,目标 `baiyue_lastleg`。新采购审核后直接分批收货,客户确认后创建出库,签收才扣采购库存;配套发布 Lastleg、Admin 和 Client。源基线与海外空库基线同步,不回填旧采购事实;结构脚本尚未执行。 `overseas-procurement-table-cleanup-review.sql` 为海外采购 27 表的只读核查,元数据行数为估算,另输出可手工执行的精确 COUNT。2026-10-27 已解除 21 张旧采购专属表的生产代码依赖,当前基线只保留六表。`overseas-procurement-legacy-tables-retire.sql` 为单独审阅执行的 MySQL 8.0+ 精确删表清单:仅 `baiyue_lastleg` 的 21 张旧采购表,不修改共用财务、资金、文件或仓储表。须先备份、停止旧版本写入、部署分离版本并验证,在维护窗口同连接设置脚本指定发布确认变量;六表缺失、旧状态采购或旧资金记录存在时拒绝。前置检查在业务 DROP 前完成,不关闭外键校验,使用一条 DROP TABLE IF EXISTS,可在完整成功后重跑;DDL 隐式提交,失败立即停止,禁止 --force。凭据及实例由执行者选择,不内置或自动连接。本轮未执行数据库或部署;[范围、清单及顺序](../../lastleg-service/docs/procurement-table-cleanup.md)。V14—V21 旧采购迁移仅供追溯,退役后不得重放。 diff --git a/deploy/sql/finance-reconciliation-line-uniqueness-repair.sql b/deploy/sql/finance-reconciliation-line-uniqueness-repair.sql new file mode 100644 index 000000000..73f6110ff --- /dev/null +++ b/deploy/sql/finance-reconciliation-line-uniqueness-repair.sql @@ -0,0 +1,10 @@ +-- 仅用于单独授权的 TEST:修正作废对账单历史明细阻止原费用重新生成对账单的问题。 +-- 固定目标 MySQL 8 / InnoDB;保留全部账单、明细、金额及修订,不更新、删除或回填业务记录。 +-- 单内同一费用版本保持唯一;跨单有效占用由 Finance 生成时的费用行锁和状态校验保证。 +-- 前置:Finance 已具备生成时 AVAILABLE -> LOCKED 校验,以及作废单重开时的来源重新占用校验。 +-- 执行前后核对 SHOW CREATE TABLE 与记录总数;本文件存在不表示已经执行。 +-- 使用独立连接并开启遇错停止;一条原子 ALTER 替换索引,结果不明确先回查,不盲目重放。 +ALTER TABLE baiyue_finance.fin_bill_line + DROP INDEX uk_fin_bill_line_source, + ADD UNIQUE KEY uk_fin_bill_line_bill_source (bill_id, source_type, source_id, source_version), + ADD KEY idx_fin_bill_line_source (source_type, source_id, source_version); diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index a94dcbe1b..610c824f0 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -4765,7 +4765,8 @@ CREATE TABLE fin_bill_line ( currency_code char(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '账单明细创建时间', PRIMARY KEY (id), - UNIQUE KEY uk_fin_bill_line_source (source_type,source_id,source_version), + UNIQUE KEY uk_fin_bill_line_bill_source (bill_id,source_type,source_id,source_version), + KEY idx_fin_bill_line_source (source_type,source_id,source_version), KEY idx_fin_bill_line_bill (tenant_id,bill_id), CONSTRAINT chk_fin_bill_line_amount CHECK ((amount >= 0)), CONSTRAINT chk_fin_bill_line_currency CHECK ((currency_code = _utf8mb4'CNY')), diff --git a/finance/src/main/resources/db/finance.sql b/finance/src/main/resources/db/finance.sql index 7fa15681a..d34f37d4f 100644 --- a/finance/src/main/resources/db/finance.sql +++ b/finance/src/main/resources/db/finance.sql @@ -225,7 +225,8 @@ CREATE TABLE IF NOT EXISTS fin_bill_line ( currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '账单明细创建时间', PRIMARY KEY (id), - UNIQUE KEY uk_fin_bill_line_source (source_type, source_id, source_version), + UNIQUE KEY uk_fin_bill_line_bill_source (bill_id, source_type, source_id, source_version), + KEY idx_fin_bill_line_source (source_type, source_id, source_version), KEY idx_fin_bill_line_bill (tenant_id, bill_id), CONSTRAINT chk_fin_bill_line_tenant CHECK (tenant_id > 0), CONSTRAINT chk_fin_bill_line_currency CHECK (currency_code = 'CNY'), diff --git a/finance/src/test/java/com/baiyue/finance/FinanceReconciliationSchemaContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceReconciliationSchemaContractTest.java new file mode 100644 index 000000000..e23379fa8 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceReconciliationSchemaContractTest.java @@ -0,0 +1,66 @@ +package com.baiyue.finance; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class FinanceReconciliationSchemaContractTest { + + /** 执行真实明细表约束,复现两笔费用作废后重新出单,保留原单快照且单内不能重复。 */ + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void voidedStatementKeepsHistoryWhileItsTwoSourcesCanEnterANewStatement(boolean unified) throws Exception { + JdbcTemplate jdbc = createSchema(unified); + jdbc.execute("CREATE TABLE fin_bill(id BIGINT PRIMARY KEY,tenant_id BIGINT NOT NULL,status VARCHAR(32) NOT NULL)"); + jdbc.update("INSERT INTO fin_bill VALUES(501,9,'ISSUED'),(502,9,'DRAFT')"); + insertLine(jdbc, 1, 501, 88); + insertLine(jdbc, 2, 501, 89); + jdbc.update("UPDATE fin_bill SET status='VOIDED' WHERE id=501"); + + insertLine(jdbc, 3, 502, 88); + insertLine(jdbc, 4, 502, 89); + + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_bill_line WHERE bill_id=501", Integer.class)).isEqualTo(2); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_bill_line WHERE bill_id=502", Integer.class)).isEqualTo(2); + assertThat(jdbc.queryForObject("SELECT status FROM fin_bill WHERE id=501", String.class)).isEqualTo("VOIDED"); + assertThat(jdbc.queryForObject("SELECT SUM(l.amount) FROM fin_bill_line l JOIN fin_bill b ON b.id=l.bill_id AND b.tenant_id=l.tenant_id WHERE b.status<>'VOIDED'", BigDecimal.class)) + .isEqualByComparingTo("200.00"); + assertThatThrownBy(() -> insertLine(jdbc, 5, 502, 88)).isInstanceOf(DuplicateKeyException.class); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_bill_line", Integer.class)).isEqualTo(4); + } + + /** 从模块或统一空库入口读取真实建表语句,仅移除 H2 不支持的 MySQL 存储语法。 */ + private static JdbcTemplate createSchema(boolean unified) throws Exception { + Path unifiedPath = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + if (!Files.exists(unifiedPath)) unifiedPath = Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + String sql = Files.readString(unified ? unifiedPath : Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8); + var table = Pattern.compile("CREATE TABLE (?:IF NOT EXISTS )?fin_bill_line \\(.*?\\) ENGINE=[^;]*;", Pattern.DOTALL).matcher(sql); + assertThat(table.find()).isTrue(); + String ddl = table.group().replaceFirst("\\) ENGINE=[^;]*;", ");") + .replaceAll("(?i) COMMENT '(?:[^']|'')*'", "") + .replaceAll("(?i)\\bUNSIGNED\\b|_utf8mb4", "") + .replaceAll("(?m)^\\s*KEY [^\\r\\n]+\\r?\\n", "") + .replaceAll("UNIQUE KEY (\\w+)", "CONSTRAINT $1 UNIQUE"); + var jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1")); + jdbc.execute(ddl); + return jdbc; + } + + /** 写入完整合成明细,仅改变本测试关注的单据和费用来源身份。 */ + private static void insertLine(JdbcTemplate jdbc, long id, long billId, long sourceId) { + jdbc.update("INSERT INTO fin_bill_line(id,tenant_id,bill_id,source_type,source_id,source_version,fee_item_code,fee_item_name,amount,currency_code) VALUES(?,9,?,'FIRSTLEG_FEE',?,1,'SYNTHETIC','合成测试费用',100,'CNY')", + id, billId, sourceId); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java index e86ba0e0d..6f59129d3 100644 --- a/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java @@ -126,6 +126,22 @@ class ReconciliationStatementBehaviorTest { verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source SET status='LOCKED'"), eq(88L), eq(9L)); } + /** 已被占用的费用不能完成第二张有效对账单的生成,事务必须拒绝提交。 */ + @Test + void createRejectsSourceWhoseAvailableStatusCannotBeLocked() throws Exception { + JdbcTemplate jdbc = createJdbc(); + when(jdbc.update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source SET status='LOCKED'"), eq(88L), eq(9L))) + .thenReturn(0); + CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "SELECTED", List.of(88L), + LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), null); + + assertThatThrownBy(() -> serviceWithNumber(jdbc).create(71L, "create-key", request)) + .isInstanceOf(FinanceEventException.class) + .hasMessage("费用来源已被其他对账单占用") + .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409"); + verify(jdbc, never()).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_idempotency_record SET status='SUCCEEDED'"), (Object[]) any()); + } + @Test void selectedCrossCustomerSourceIsHiddenAsNotFoundBeforeBillInsert() throws Exception { JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation -> { @@ -428,6 +444,24 @@ class ReconciliationStatementBehaviorTest { verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), eq(9L), eq(501L), eq(9L)); } + /** 作废单的两笔原费用有任一已进入新单时,旧单不得重开为另一张有效单。 */ + @ParameterizedTest + @ValueSource(ints = {0, 1}) + void reopenRejectsVoidedStatementWhenReplacementOccupiesAnySource(int reacquired) throws Exception { + JdbcTemplate jdbc = actionJdbc("VOIDED", "DRAFT", "reopen-conflict-key"); + when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_bill_line"), eq(Integer.class), eq(9L), eq(501L))) + .thenReturn(2); + when(jdbc.update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), eq(9L), eq(501L), eq(9L))) + .thenReturn(reacquired); + + assertThatThrownBy(() -> serviceWithNumber(jdbc).action(71L, 501L, "reopen-conflict-key", + new ReconciliationActionRequest("REOPEN", "重新核对", 4, List.of()))) + .isInstanceOf(FinanceEventException.class) + .hasMessage("原费用来源已被占用,无法重开对账单") + .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409"); + verify(jdbc, never()).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET status="), (Object[]) any()); + } + @Test void reconciliationIdempotencyRecoversFailedAndStaleProcessingRecords() { ReconciliationActionRequest body = new ReconciliationActionRequest("SUBMIT", null, 0, List.of()); diff --git a/lastleg-service/docs/procurement-workflow.md b/lastleg-service/docs/procurement-workflow.md index a311aaac8..aa5302685 100644 --- a/lastleg-service/docs/procurement-workflow.md +++ b/lastleg-service/docs/procurement-workflow.md @@ -16,7 +16,7 @@ 拒绝、取消等异常结果独立展示,不增加正常流程节点。采购方向只影响起运仓候选与方向展示,不再增加国内、海外两套作业步骤。 -收货批次保存实际数量和绑定本采购单的附件。采购完成后冻结收货结果,客户确认前不得出库。客户可确认或填写1~1000字原因驳回;驳回回到收货中,平台展示原因并允许按需重新上传照片,也可保留原照片直接再次提交采购完成,不要求逐批重新上传。选择替换照片时不能把旧附件当作本次新上传结果;仅替换照片,不重复累加数量。再次提交采购完成成功后清除上一轮驳回原因。平台不得代客户调用确认接口;客户身份从统一认证上下文取得,不接受请求指定其他客户。 +收货批次保存实际数量和绑定本采购单的附件。采购完成后冻结收货结果,客户确认前不得出库。客户可确认或填写1~1000字原因驳回;驳回回到收货中,平台展示原因并允许按需重新上传照片,也可保留原照片直接再次提交采购完成,不要求逐批重新上传。选择替换照片时不能把旧附件当作本次新上传结果;仅替换照片,不重复累加数量。重新上传成功后清除当前驳回原因,本轮其他批次仍可继续换图;`photosNeedUpdate` 只标识仍绑定本轮驳回照片,不作为重新提交门禁。再次提交采购完成成功后清除上一轮驳回原因和照片基线,待客户确认及确认后的页面均不展示旧驳回提示。平台不得代客户调用确认接口;客户身份从统一认证上下文取得,不接受请求指定其他客户。 出库按批次保存数量、物流跟踪号、派送和签收资料。创建批次时预留可用数量,避免多个批次超出采购库存;签收完成时才扣减该批采购库存。签收请求原键重放不得再次扣减。存在剩余库存时仍可继续创建批次,不能因前一批签收而提前结束整单。采购库存属于采购台账,不写普通尾程 WMS 库存。 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 c2ed5bace..39a85ce2a 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 @@ -378,6 +378,15 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA if("LOCAL_TRUCK".equals(row.getTaskType()) && !"BEFORE_LOADING".equals(row.getStage()))throw conflict("装车中及后续阶段不能取消排车"); if(trim(request.reason())==null)throw bad("取消原因不能为空"); if(repository.realPicked(row.getId())!=0 || repository.outbounds(row.getId()).stream().anyMatch(o->!"CANCELLED".equals(o.status())))throw conflict("本车已有真实执行或关联出库,不能取消"); + if(Set.of("PICKUP","LOCAL_TRUCK").contains(row.getTaskType())) { + // 运输任务沿原车次取消并同步释放原单;公开排车版本与车次版本独立,不能套用仓库派送关联。 + var trip=transportTasks.transport(number,true); + transportTasks.cancel(number,actor.operatorId(),new com.baiyue.lastleg.fleet.trip.FleetTripModels.Cancel(trip.getRecordVersion(),request.reason())); + // 同事务运输投影已经保存主单版本和历史,仅补记公开命令结果,避免重复递增或覆盖投影。 + row=required(actor,number,true); + var result=result(row,identity,new DispatchResults.Saved(number,List.of())); + commands.append(row.getId(),actor,identity,result);return result; + } // 撤销的是本车运输占用,不重新要求货物已实收或满足装车条件;真实执行仍由前面的零扫码及出库门禁保护。 var items=repository.cargo(row.getId());sourceRules.lockCancellationSources(row.getOverseasWarehouseId(),items);appointments.lock(repository.batches(row.getId()),List.of());appointments.remove(row.getId(),items);sourceRules.releaseWholeRelabel(items); releaseSources(row,actor,items,now()); @@ -472,8 +481,9 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA requireEditing(row,request.recordVersion()); if(!canSubmitReview(row.getReviewState()))throw conflict("当前审核已提交,请刷新核对"); verifyReviewResources(row); - // 本土卡车货物来自原卡车订单,不建立 dispatch_item;按排车摘要件数即可进入两级审核。 - if(n(row.getPackageCount())<=0 || (!"PICKUP".equals(row.getTaskType()) && !"LOCAL_TRUCK".equals(row.getTaskType()) && repository.cargo(row.getId()).isEmpty()))throw bad("空排车不能提交审核"); + // 本土卡车货物来自原订单,不建立 dispatch_item;托数与箱数独立,有托无箱不能误判为空车。 + boolean hasQuantity=n(row.getPackageCount())>0 || ("LOCAL_TRUCK".equals(row.getTaskType()) && n(row.getPalletCount())>0); + if(!hasQuantity || (!"PICKUP".equals(row.getTaskType()) && !"LOCAL_TRUCK".equals(row.getTaskType()) && repository.cargo(row.getId()).isEmpty()))throw bad("空排车不能提交审核"); long round=row.getReviewRound()+1;String basis=basis(row); row.setReviewRound(round);row.setReviewBasisHash(basis);row.setReviewState(ReviewState.DISPATCH_PENDING.name()); row.setDispatchReviewJson(write(new Review(ReviewStatus.PENDING,null,null,null,round,basis,null,null))); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/controller/PlatformOverseasFeeSourceController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/controller/PlatformOverseasFeeSourceController.java index f17251776..69821d4a7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/controller/PlatformOverseasFeeSourceController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/controller/PlatformOverseasFeeSourceController.java @@ -88,7 +88,7 @@ public class PlatformOverseasFeeSourceController { requirePlatform(user); return ApiResponse.success(manual.review(sourceFeeId, operator(user), operatorName(user), request)); } - /** 提交账单费用的调整版本。 */ + /** 调整审核前的人工费用,释放原冻结并以独立新费用重新送审。 */ @PostMapping("/{sourceFeeId}/adjust") @PreAuthorize("hasAuthority('platform:finance:fee-source:edit')") public ApiResponse adjust(@AuthenticationPrincipal AuthenticatedUser user, @@ -96,7 +96,7 @@ public class PlatformOverseasFeeSourceController { requirePlatform(user); return ApiResponse.success(manual.adjust(sourceFeeId, operator(user), request)); } - /** 申请作废已推送账单费用。 */ + /** 作废审核前的人工费用,存在冻结时申请释放。 */ @PostMapping("/{sourceFeeId}/void") @PreAuthorize("hasAuthority('platform:finance:fee-source:review')") public ApiResponse voidSource(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasManualFeeService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasManualFeeService.java index 648808cfa..c641b8759 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasManualFeeService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasManualFeeService.java @@ -332,7 +332,7 @@ public class OverseasManualFeeService implements WalletResultHandler { case "FREEZE" -> { if (!"FROZEN".equals(result.holdStatus())) throw conflict("冻结回执状态无效"); state.setWalletStatus("FROZEN"); - if ("VOIDED".equals(fee.getSourceStatus()) || "REJECTED".equals(fee.getReviewStatus())) send(fee, state, "RELEASE"); + if (Set.of("VOIDED", "SUPERSEDED").contains(fee.getSourceStatus()) || "REJECTED".equals(fee.getReviewStatus())) send(fee, state, "RELEASE"); } case "SETTLE" -> { if (!"SETTLED".equals(result.holdStatus()) || text(result.walletLedgerId()) == null) @@ -352,36 +352,66 @@ public class OverseasManualFeeService implements WalletResultHandler { state.setUpdatedAt(now()); repository.updateFunding(state); } - /** 已扣款及自动仓租不允许从通用调价入口篡改。 */ + /** 审核前以独立费用替换原记录,原冻结释放,新费用重新冻结并等待审核。 */ @Transactional public OverseasFeeSource adjust(long id, String operator, AdjustRequest request) { OverseasFeeSource fee = required(repository.lockFee(id)); requireManual(fee); - requireNotRejected(fee); OverseasFeeFunding state = repository.lockFunding(id); - if (fee.getWalletSettlement() != null || state != null && "WALLET".equals(state.getCollectionMode())) - throw conflict("余额费用请取消重建;已扣费用请通过退款处理"); - if (!bill(fee)) throw conflict("历史海外费用请先完成原账单作废,再重新创建余额费用"); + boolean replay = replayed(operator, "MANUAL_ADJUST", request.idempotencyKey(), id, request); + String createKey = "manual-adjust:" + hash(List.of(id, request.idempotencyKey().trim())); + if (replay) + return create(operator, createKey, request.fee()); + requireBeforeReview(fee, state, request.expectedVersion()); requireManualItem(request.fee().feeItemCode()); catalog.requireActive(request.fee().feeItemCode(), request.fee().feeItemName(), request.fee().direction()); if (!Objects.equals(fee.getCustomerId(), request.fee().customerId()) || !Objects.equals(fee.getBusinessType(), request.fee().businessType()) - || !Objects.equals(fee.getBusinessId(), request.fee().businessId()) || !Objects.equals(fee.getDirection(), request.fee().direction())) + || !Objects.equals(fee.getBusinessId(), request.fee().businessId()) || !Objects.equals(fee.getDirection(), request.fee().direction()) + || !Objects.equals(fee.getSourceType(), request.fee().sourceType()) || !Objects.equals(fee.getBusinessNo(), request.fee().businessNo())) throw conflict("调整不能更换客户、业务订单或收支方向"); - requireLegacyCommandOwner(operator, "ADJUST", request.idempotencyKey(), id); - return described(sources.adjust(id, operator, request), state); + closeBeforeReview(fee, state, "SUPERSEDED", operator); + OverseasFeeSource next = create(operator, createKey, request.fee()); + record(operator, "MANUAL_ADJUST", request.idempotencyKey(), id, request, fee.getSourceVersion()); + return next; } - /** 已扣费用不可作废,账单费用仍等待 Finance 确认作废。 */ + /** 仅审核前允许作废;尚未发布的费用不向 Finance 发送账单作废事件。 */ @Transactional public OverseasFeeSource voidSource(long id, String operator, VoidRequest request) { OverseasFeeSource fee = required(repository.lockFee(id)); requireManual(fee); - requireNotRejected(fee); OverseasFeeFunding state = repository.lockFunding(id); - if (fee.getWalletSettlement() != null || state != null && "WALLET".equals(state.getCollectionMode())) - throw conflict("已扣费用请通过退款处理,未扣费用请取消"); - requireLegacyCommandOwner(operator, "VOID", request.idempotencyKey(), id); - return described(sources.voidSource(id, operator, request), state); + if (replayed(operator, "MANUAL_VOID", request.idempotencyKey(), id, request)) return described(fee, state); + requireBeforeReview(fee, state, request.expectedVersion()); + if (text(request.reason()) == null) throw bad("请填写作废原因"); + fee.setReviewReason(request.reason().trim()); + closeBeforeReview(fee, state, "VOIDED", operator); + record(operator, "MANUAL_VOID", request.idempotencyKey(), id, request, fee.getSourceVersion()); + return described(fee, state); + } + + /** 写入和动作投影共用审核前资格,阻止已审核、已收款及过期版本变更。 */ + private void requireBeforeReview(OverseasFeeSource fee, OverseasFeeFunding state, int version) { + if (!beforeReview(fee, state) || fee.getSourceVersion() != version) + throw conflict("只有当前审核前的有效费用可以调整或作废"); + } + + /** 冻结在途或失败仍属审核前;扣款和资金释放在途不得更换金额快照。 */ + private boolean beforeReview(OverseasFeeSource fee, OverseasFeeFunding state) { + return "ACTIVE".equals(fee.getSourceStatus()) && fee.getWalletSettlement() == null + && Set.of("DRAFT", "PENDING").contains(Objects.toString(fee.getReviewStatus(), "")) + && "UNPUBLISHED".equals(fee.getPublicationStatus()) && !procurement(fee) + && !Set.of("PENDING_FINANCE", "FINANCE_ATTENTION").contains(Objects.toString(fee.getVoidStatus(), "")) + && !StorageRentFeeWorkflow.isRent(fee) + && (state == null || Set.of("NOT_REQUIRED", "FREEZE_PENDING", "FROZEN", "FREEZE_FAILED", "RELEASED") + .contains(Objects.toString(state.getWalletStatus(), ""))); + } + + /** 关闭原费用,已冻结立即申请释放,冻结在途由成功回执继续释放。 */ + private void closeBeforeReview(OverseasFeeSource fee, OverseasFeeFunding state, String status, String operator) { + fee.setSourceStatus(status); + touch(fee, operator); + if (state != null && "FROZEN".equals(state.getWalletStatus())) send(fee, state, "RELEASE"); } /** 资金指令与费用变更同事务写 Outbox,冻结与业务操作互不抢用身份。 */ @@ -420,9 +450,7 @@ public class OverseasManualFeeService implements WalletResultHandler { if (state != null && "SETTLE_FAILED".equals(state.getWalletStatus())) actions.add("RETRY_SETTLE"); if ("DRAFT".equals(fee.getReviewStatus()) && (state == null || "BILL".equals(state.getCollectionMode()) || Set.of("RELEASED", "FREEZE_FAILED").contains(state.getWalletStatus()))) actions.add("SUBMIT"); - if ("APPROVED".equals(fee.getReviewStatus()) && (state == null || "BILL".equals(state.getCollectionMode()))) { - if (bill(fee)) actions.add("ADJUST"); actions.add("VOID"); - } + if (beforeReview(fee, state)) actions.addAll(List.of("ADJUST", "VOID")); } if (state != null && "RELEASE_FAILED".equals(state.getWalletStatus())) actions.add("RETRY_RELEASE"); // 驳回不能重开收费,但资金释放失败仍须向操作端提供补偿入口。 @@ -495,7 +523,7 @@ public class OverseasManualFeeService implements WalletResultHandler { /** 为单条变更结果补充与列表一致的操作资格。 */ private OverseasFeeSource described(OverseasFeeSource fee, OverseasFeeFunding state) { applyDescription(fee, state); return fee; } - /** 兼容旧载荷哈希,同时禁止跨费用复用旧审核、调整或作废操作标识。 */ + /** 兼容旧载荷哈希,同时禁止跨费用复用旧审核操作标识。 */ private void requireLegacyCommandOwner(String operator, String action, String key, long id) { if (text(key) == null || key.length() > 128) throw bad("操作标识无效"); var existing = repository.command(operator, action, key.trim()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementWorkflowService.java index e904076a6..52d91bb89 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementWorkflowService.java @@ -81,17 +81,19 @@ public class ProcurementWorkflowService { finish(order,operator,request.idempotencyKey(),"CUSTOMER_REJECT",request);return snapshot(order); } - /** 平台逐批重新上传图片,禁止复用本轮被驳回照片或追加虚假收货量。 */ + /** 平台逐批重新上传图片,成功后清除驳回提示,继续支持本轮其他批次换图。 */ @Transactional public Workflow replacePhotos(String customer,String no,String operator,ReceiptPhotos request) { var order=order(customer,no,true); if(replay(order,request.idempotencyKey(),"REPLACE_PHOTOS",request)) return snapshot(order); version(order,request.version());state(order,"RECEIVING"); - if(order.getCustomerRejectionReason()==null) throw conflict("只有客户驳回后可以重新上传收货照片"); + if(order.getRejectedPhotoIds()==null) throw conflict("只有客户驳回后可以重新上传收货照片"); files(order,request.fileIds(),true); var rejected=ids(order.getRejectedPhotoIds()); if(request.fileIds().stream().anyMatch(rejected::contains)) throw bad("请重新上传照片,不能复用被驳回的图片"); if(repository.replacePhotos(request.receiptId(),order.getId(),serialize(request.fileIds()))!=1) throw bad("收货批次不属于本采购单"); + // 清除当前提示但保留本轮照片基线,避免其他批次因原因已清空而无法继续整改。 + order.setCustomerRejectionReason(null); finish(order,operator,request.idempotencyKey(),"REPLACE_PHOTOS",request);return snapshot(order); } @@ -182,8 +184,10 @@ public class ProcurementWorkflowService { long reserved=outbounds.stream().filter(b->!Set.of("COMPLETED","CANCELLED").contains(b.status())).mapToLong(OutboundRow::quantity).sum(); Set visible=new HashSet<>();receipts.forEach(r->visible.addAll(r.fileIds()));outbounds.forEach(b->visible.addAll(b.fileIds())); var files=visible.isEmpty()?List.of():repository.files(order).stream().filter(f->visible.contains(f.getFileId())).map(f->new FileInfo(f.getFileId(),f.getFileName(),f.getContentType())).toList(); - // 驳回后的照片允许按需替换,不作为重新提交采购完成的门禁。 - return new Workflow(order.getOrderNo(),order.getVersion(),order.getStatus(),received,received-completed,reserved,received-completed-reserved,receipts,outbounds,files,order.getCustomerRejectionReason(),false); + // 仅标识仍绑定本轮驳回照片的批次;照片按需替换,不作为重新提交采购完成的门禁。 + Set rejected=order.getRejectedPhotoIds()==null?Set.of():new HashSet<>(ids(order.getRejectedPhotoIds())); + boolean photosNeedUpdate=receipts.stream().anyMatch(receipt->receipt.fileIds().stream().anyMatch(rejected::contains)); + return new Workflow(order.getOrderNo(),order.getVersion(),order.getStatus(),received,received-completed,reserved,received-completed-reserved,receipts,outbounds,files,order.getCustomerRejectionReason(),photosNeedUpdate); } /** 文件数量、客户归属、用途和照片类型均由服务端验证。 */ 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 index 0b22dc215..501b8980a 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml @@ -17,7 +17,7 @@ paths: - { in: query, name: businessType, schema: { $ref: '#/components/schemas/BusinessType' } } - { in: query, name: businessNo, schema: { type: string } } - { in: query, name: containerBookingId, description: 精确查询真实来源柜下全部客户的费用;可再传customerId收窄到单个客户, schema: { type: string, pattern: '^[1-9][0-9]*$' } } - responses: { '200': { description: '当前版本列表;voidStatus为NOT_REQUESTED、PENDING_FINANCE、FINANCE_ATTENTION或CONFIRMED,批量查询作废确认状态,不逐行读取。等待或拒绝时sourceStatus保留ACTIVE,只有Finance持久化确认后为VOIDED。' } } + responses: { '200': { description: '当前版本列表;voidStatus为NOT_REQUESTED、PENDING_FINANCE、FINANCE_ATTENTION或CONFIRMED,批量查询作废确认状态,不逐行读取。业务流程发起的已发布账单作废,等待或拒绝时sourceStatus保留ACTIVE,只有Finance持久化确认后为VOIDED;人工费用审核前作废直接为VOIDED,无需账单作废确认。' } } post: summary: 创建人工费用并锁定资金 description: 需platform:finance:fee-source:edit。创建即PENDING。应收仅当前LASTLEG_ORDER且orderType=FIRST_LEG采用BILL,其余业务(含ONE_CLICK_DROPSHIP、退货、转运、柜级费用)为WALLET,发送异步FREEZE,本土卡车、本土散板应收同采购一样使用客户余额及授信扣款;余额及授信不足显示FREEZE_FAILED,不可审核通过。原USD按业务月有效汇率固化为CNY后冻结;原币与汇率快照保留。采购主单OVERSEAS_PROCUREMENT复用本入口,未传结算方式默认WALLET;旧PROCUREMENT_FEE子费用仍只由原采购节点处理。业务ID、客户、单号与补充订单/入仓关联由服务端核对;真实柜要求businessId=containerBookingId且businessNo=containerNo。调用重放不重复冻结。 @@ -86,7 +86,7 @@ paths: post: summary: 提交、审核、调整、作废或处理资金 x-headleg-delivery-review: 首版头程派送费由审核员核对原报价是否已包含;当前未实现自动识别。已含费用不应再建单或通过审核,另收费用人工添加后审核进入头程账单。 - description: 编辑动作需platform:finance:fee-source:edit,review与void需platform:finance:fee-source:review。WALLET通过审核只发送SETTLE,实际匹配成功回执含冻结与账本引用后才PUBLISHED,此时仅本地费用完成、不推送财务账单;失败为SETTLE_FAILED,保持未收。驳回释放冻结,冻结在途时回执成功后自动释放。驳回后的费用只读,不能继续操作;如需重新收费应在业务单据中新增费用。头程BILL审核通过推送结算;已发布BILL作废等待Finance持久化确认才VOIDED。历史无资金记录的海外费用必须重新提交完成冻结,不允许审核绕过。采购主单费用沿此审核扣款链,旧PROCUREMENT_FEE子费用仍由原采购节点处理。 + description: 编辑动作需platform:finance:fee-source:edit,review与void需platform:finance:fee-source:review。WALLET通过审核只发送SETTLE,实际匹配成功回执含冻结与账本引用后才PUBLISHED,此时仅本地费用完成、不推送财务账单;失败为SETTLE_FAILED,保持未收。驳回释放冻结,冻结在途时回执成功后自动释放。驳回后的费用只读,不能继续操作;如需重新收费应在业务单据中新增费用。头程BILL审核通过推送结算。人工费用仅ACTIVE、UNPUBLISHED且DRAFT或PENDING时允许ADJUST、VOID;已审核通过、已扣款或已发布均不允许这两个动作。调整关闭原费用并使用独立资金身份创建待审核费用,原冻结立即或收到迟到成功回执后释放;审核前作废直接关闭并释放冻结,不向Finance发送账单作废事件。冻结或扣款快照不可覆盖,版本、权限和幂等键仍须校验。历史无资金记录的海外费用必须重新提交完成冻结,不允许审核绕过。采购主单费用沿此审核扣款链,旧PROCUREMENT_FEE子费用仍由原采购节点处理。 security: [{ bearerAuth: [] }] parameters: - { in: path, name: sourceFeeId, required: true, schema: { type: string } } @@ -204,7 +204,7 @@ components: deliveryKind: { type: string, nullable: true, enum: [FEE, FUNDING] } deliveryEventId: { type: string, nullable: true, description: 当前原事件ID,重试时必须原样提交 } deliveryErrorMessage: { type: string, nullable: true } - allowedActions: { type: array, items: { type: string, enum: [SUBMIT, APPROVE, REJECT, ADJUST, ADJUST_RENT, VOID, RETRY_FREEZE, RETRY_SETTLE, RETRY_RELEASE, RETRY_PUBLISH, RETRY_FUNDING_DELIVERY] } } + allowedActions: { description: '人工费用ADJUST和VOID仅在审核前返回;已通过或已收款不返回,实际操作仍校验状态、版本与权限。', type: array, items: { type: string, enum: [SUBMIT, APPROVE, REJECT, ADJUST, ADJUST_RENT, VOID, RETRY_FREEZE, RETRY_SETTLE, RETRY_RELEASE, RETRY_PUBLISH, RETRY_FUNDING_DELIVERY] } } reviewerId: { type: string, nullable: true, description: 审核人ID } reviewerName: { type: string, nullable: true, description: 审核人名称快照 } reviewedAt: { type: string, format: date-time, nullable: true, description: 审核时间,UTC 且携带 Z 时区 } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java index 3248667e5..625fe8d9e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java @@ -2,20 +2,38 @@ package com.baiyue.lastleg.dispatch.service; import com.baiyue.common.core.exception.BizException; import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.transport.TransportTaskModels; +import com.baiyue.lastleg.dispatch.transport.TransportTaskProjection; +import com.baiyue.lastleg.dispatch.transport.TransportTaskRepository; +import com.baiyue.lastleg.dispatch.transport.TransportTaskService; +import com.baiyue.lastleg.fleet.trip.FleetTripEntity; +import com.baiyue.lastleg.fleet.trip.TransportTripChanged; +import com.baiyue.lastleg.trucking.entity.UsTruckOrder; +import com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.springframework.security.authentication.TestingAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import java.util.List; +import java.time.LocalDateTime; +import java.time.ZoneOffset; import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; /** 审核两端共用真实持久主单,驳回回客服且不能复用上一轮批准。 */ class DispatchReviewWorkflowPersistenceTest { - /** 本土卡车不建立仓库整车绑定,仍须完成同轮调度及理货审核。 */ - @Test void localTruckReviewsUseTransportTripWithoutWarehouseBinding() throws Exception { + /** 本土卡车托数和箱数独立,真实托量不依赖箱数或仓库货物明细即可进入同轮两级审核。 */ + @ParameterizedTest + @CsvSource({"5,0", "0,6", "5,6"}) + void localTruckReviewsUseTransportTripWithoutWarehouseBinding(int pallets,int packages) throws Exception { var f=new DispatchAggregatePersistenceTest.Fixture(); - var created=f.tx.execute(s->f.service.create(f.actor,f.configured("truck-review",List.of(f.source(1,"101",501,"ONT8",3))))); - f.db.update("UPDATE ll_dispatch_order SET task_type='LOCAL_TRUCK' WHERE dispatch_no=?",created.dispatchNo()); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("truck-review",List.of()))); + // 初次建单与取消后补建均从原单保存真实摘要,不生成仓内 dispatch_item。 + f.db.update("UPDATE ll_dispatch_order SET task_type='LOCAL_TRUCK',item_count=1,pallet_count=?,package_count=? WHERE dispatch_no=?",pallets,packages,created.dispatchNo()); + assertThat(f.repository.cargo(f.repository.find(created.dispatchNo()).getId())).isEmpty(); var transport=org.mockito.Mockito.mock(com.baiyue.lastleg.dispatch.transport.TransportTaskService.class); var trip=new com.baiyue.lastleg.fleet.trip.FleetTripEntity();trip.setStopsJson("[]"); org.mockito.Mockito.when(transport.transport(org.mockito.ArgumentMatchers.eq(created.dispatchNo()),org.mockito.ArgumentMatchers.anyBoolean())).thenReturn(trip); @@ -32,6 +50,70 @@ class DispatchReviewWorkflowPersistenceTest { org.mockito.Mockito.verify(transport,org.mockito.Mockito.times(3)).verifyLocalTruckReview(created.dispatchNo()); org.mockito.Mockito.verifyNoInteractions(fleet,master); } + + /** 取消投影释放原单后重新登记五托零箱,旧单保留取消历史,新单可独立完成两级审核。 */ + @Test void cancelledLocalTruckCanRebuildAndReviewPalletOnlyCargo() throws Exception { + var f=new DispatchAggregatePersistenceTest.Fixture(); + var links=mock(TransportTaskRepository.class); + var trucks=mock(UsTruckOrderRepository.class); + var numbers=mock(DispatchNumberGenerator.class); + when(numbers.next()).thenReturn("PC-TRUCK-OLD","PC-TRUCK-NEW"); + var projection=new TransportTaskProjection(f.repository,links,trucks,numbers,f.json); + var trip=new FleetTripEntity(); + trip.setId(701L);trip.setTripNo("TRIP-OLD");trip.setTripPurpose("LOCAL_TRUCK");trip.setStatus("PLANNED"); + trip.setWarehouseId(9L);trip.setWarehouseCode("WH9");trip.setWarehouseName("测试起运仓"); + trip.setRecordVersion(0L);trip.setRequestHash("a".repeat(64));trip.setStopsJson("[]"); + var old=f.tx.execute(s->projection.register(trip,1,5,0,f.actor.operatorId())); + when(links.byTrip(701L)).thenAnswer(call->f.repository.find(old.getDispatchNo())); + when(links.truck(old.getDispatchNo())).thenReturn(new TransportTaskModels.TruckLink(old.getDispatchNo(),"TRIP-OLD","101","TR-TEST")); + var order=new UsTruckOrder();order.setId(801L);order.setCustomerId("101");order.setOrderNo("TR-TEST"); + order.setStatus("DISPATCHED");order.setVersion(0L);order.setPalletCount(5);order.setPackageCount(0); + when(trucks.lockNo("101","TR-TEST")).thenReturn(order); + + trip.setStatus("CANCELLED");trip.setRecordVersion(1L);trip.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC)); + f.tx.executeWithoutResult(s->projection.changed(new TransportTripChanged(trip,"CANCEL","重新排车",f.actor.operatorId()))); + assertThat(f.repository.find(old.getDispatchNo()).getStatus()).isEqualTo("CANCELLED"); + assertThat(order.getStatus()).isEqualTo("PENDING_DISPATCH"); + verify(links).cancel(old.getDispatchNo()); + + trip.setId(702L);trip.setTripNo("TRIP-NEW");trip.setStatus("PLANNED");trip.setRecordVersion(0L); + var rebuilt=f.tx.execute(s->projection.register(trip,1,order.getPalletCount(),order.getPackageCount(),f.actor.operatorId())); + f.tx.executeWithoutResult(s->projection.transition("101","TR-TEST","DISPATCHED","TRANSPORT_ASSIGN",f.actor.operatorId(),"补建排车单","rebuild")); + assertThat(rebuilt.getDispatchNo()).isNotEqualTo(old.getDispatchNo()); + assertThat(f.repository.cargo(rebuilt.getId())).isEmpty(); + var transport=mock(TransportTaskService.class); + when(transport.transport(eq(rebuilt.getDispatchNo()),anyBoolean())).thenReturn(trip); + org.springframework.test.util.ReflectionTestUtils.setField(f.service,"transportTasks",transport); + var submitted=f.tx.execute(s->f.service.submitReview(f.actor,rebuilt.getDispatchNo(),new DispatchCommands.SubmitReview(rebuilt.getRecordVersion(),"rebuild-submit",null))); + var dispatched=review(f,f.actor,submitted,false,"rebuild-dispatch",Decision.APPROVED,null); + var loaded=review(f,f.actor,dispatched,true,"rebuild-loading",Decision.APPROVED,null); + + assertThat(loaded.stage()).isEqualTo(Stage.LOADING); + assertThat(f.repository.find(rebuilt.getDispatchNo()).getReviewState()).isEqualTo("APPROVED"); + assertThat(f.repository.find(old.getDispatchNo()).getStatus()).isEqualTo("CANCELLED"); + assertThat(order.getStatus()).isEqualTo("DISPATCHED"); + verify(transport,times(3)).verifyLocalTruckReview(rebuilt.getDispatchNo()); + } + + /** 本土卡车仍拒绝零托零箱;仓库派送不能只靠摘要托数或箱数绕过真实货物检查。 */ + @ParameterizedTest + @CsvSource({"LOCAL_TRUCK,0,0", "DELIVERY,0,0", "DELIVERY,5,0", "DELIVERY,5,6"}) + void emptyDispatchCannotSubmitAndDoesNotWriteReviewState(String taskType,int pallets,int packages) throws Exception { + var f=new DispatchAggregatePersistenceTest.Fixture(); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("empty-review",List.of()))); + f.db.update("UPDATE ll_dispatch_order SET task_type=?,pallet_count=?,package_count=? WHERE dispatch_no=?",taskType,pallets,packages,created.dispatchNo()); + if("LOCAL_TRUCK".equals(taskType))org.springframework.test.util.ReflectionTestUtils.setField(f.service,"transportTasks", + org.mockito.Mockito.mock(com.baiyue.lastleg.dispatch.transport.TransportTaskService.class)); + + assertThatThrownBy(()->submit(f,created,"empty-submit")) + .isInstanceOf(BizException.class).hasMessage("空排车不能提交审核"); + + var row=f.repository.find(created.dispatchNo()); + assertThat(row.getReviewState()).isEqualTo("DRAFT"); + assertThat(row.getReviewRound()).isZero(); + assertThat(row.getRecordVersion()).isEqualTo(created.recordVersion()); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE action='SUBMIT_REVIEW'",Long.class)).isZero(); + } @Test void approvedLoadingReviewImportsOnceAndCopyFailureRollsBackApproval() throws Exception { var f=new DispatchAggregatePersistenceTest.Fixture(); var created=f.tx.execute(s->f.service.create(f.actor,f.configured("copy-create",List.of(f.source(1,"101",501,"ONT8",3))))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchTransportCancellationPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchTransportCancellationPersistenceTest.java new file mode 100644 index 000000000..e9b9e5304 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchTransportCancellationPersistenceTest.java @@ -0,0 +1,174 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baiyue.lastleg.dispatch.port.DispatchCommands; +import com.baiyue.lastleg.dispatch.transport.*; +import com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries; +import com.baiyue.lastleg.fleet.driver.DriverService; +import com.baiyue.lastleg.fleet.pickup.FleetPickupRepository; +import com.baiyue.lastleg.fleet.pickup.FleetPickupService; +import com.baiyue.lastleg.fleet.reporting.TripOperationsEvidenceGateway; +import com.baiyue.lastleg.fleet.trip.*; +import com.baiyue.lastleg.trucking.entity.UsTruckOrder; +import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory; +import com.baiyue.lastleg.trucking.mapper.*; +import com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.ArrayList; + +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 从真实本土卡车建单进入列表取消,覆盖两套版本及事务内资源、来源和命令账本。 */ +class DispatchTransportCancellationPersistenceTest { + /** 未配车和已配车均取消原运输任务;原请求重试只恢复一次成功结果。 */ + @ParameterizedTest + @ValueSource(booleans={false,true}) + void cancellationReleasesTransportAndSourceWithoutWarehouseBinding(boolean assigned) throws Exception { + var e=new Fixture();var number=e.create(); + if(assigned)e.assign(number); + // 审核写主单版本,车次版本不会同步递增,不能把公开版本当成车次版本。 + e.f.db.update("UPDATE ll_dispatch_order SET record_version=7 WHERE dispatch_no=?",number); + var request=new DispatchCommands.ReasonCommand(7L,"cancel-truck-original","取消本次运输安排"); + assertThat(e.f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_dispatch",Long.class)).isZero(); + var result=e.f.tx.execute(t->e.f.service.cancel(e.f.actor,number,request)); + assertThat(result.status()).isEqualTo(Status.CANCELLED); + assertThat(result.recordVersion()).isEqualTo(8L); + assertThat(e.status(number)).isEqualTo("CANCELLED"); + assertThat(e.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("CANCELLED"); + assertThat(e.f.db.queryForObject("SELECT status FROM ll_us_truck_order",String.class)).isEqualTo("PENDING_DISPATCH"); + assertThat(e.f.db.queryForObject("SELECT active_order FROM ll_dispatch_truck_task",String.class)).isNull(); + assertThat(e.f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment",Long.class)).isZero(); + assertThat(e.f.db.queryForObject("SELECT COUNT(*) FROM ll_us_truck_order_status_history WHERE to_status='PENDING_DISPATCH'",Long.class)).isOne(); + assertThat(e.f.tx.execute(t->e.f.service.cancel(e.f.actor,number,request)).replayed()).isTrue(); + assertThat(e.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE action='CANCEL'",Long.class)).isOne(); + assertThat(e.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_history WHERE action='CANCEL'",Long.class)).isOne(); + assertThat(e.f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_history WHERE action='CANCEL'",Long.class)).isOne(); + assertThatThrownBy(()->e.f.tx.execute(t->e.f.service.cancel(e.f.actor,number, + new DispatchCommands.ReasonCommand(7L,"cancel-truck-original","同键改原因")))).hasMessageContaining("原请求标识不能更换"); + assertThat(e.f.tx.execute(t->e.tasks.createTruck(e.f.actor.operatorId(),e.request("truck-create-again"))).dispatchNo()).isNotEqualTo(number); + } + + /** 公开旧版本和空原因均不得释放运输资源或写入成功账本。 */ + @Test void staleVersionAndMissingReasonCannotCancel() throws Exception { + var e=new Fixture();var number=e.create();e.assign(number); + e.f.db.update("UPDATE ll_dispatch_order SET record_version=7 WHERE dispatch_no=?",number); + assertThatThrownBy(()->e.f.tx.execute(t->e.f.service.cancel(e.f.actor,number, + new DispatchCommands.ReasonCommand(0L,"cancel-stale","取消")))).hasMessageContaining("版本"); + assertThatThrownBy(()->e.f.tx.execute(t->e.f.service.cancel(e.f.actor,number, + new DispatchCommands.ReasonCommand(7L,"cancel-blank"," ")))).hasMessageContaining("原因"); + e.assertUnchanged(number); + } + + /** 理货通过和装车后的任务仍禁止取消,不因修复分流放松执行门禁。 */ + @ParameterizedTest + @ValueSource(strings={"APPROVED","LOADING","DELIVERING"}) + void executingTransportCannotCancel(String state) throws Exception { + var e=new Fixture();var number=e.create();e.assign(number); + e.f.db.update("UPDATE ll_dispatch_order SET stage=?,review_state=? WHERE dispatch_no=?", + "APPROVED".equals(state)?"BEFORE_LOADING":state,"APPROVED".equals(state)?state:"DRAFT",number); + assertThatThrownBy(()->e.f.tx.execute(t->e.f.service.cancel(e.f.actor,number, + new DispatchCommands.ReasonCommand(0L,"cancel-executing","取消")))).hasMessageContaining("排车"); + e.assertUnchanged(number); + } + + /** 原单事实不允许回退时,已释放资源及车次、主单变更必须整笔回滚。 */ + @Test void sourceConflictRollsBackTransportAndResourceRelease() throws Exception { + var e=new Fixture();var number=e.create();e.assign(number); + e.f.db.update("UPDATE ll_us_truck_order SET status='IN_TRANSIT'"); + assertThatThrownBy(()->e.f.tx.execute(t->e.f.service.cancel(e.f.actor,number, + new DispatchCommands.ReasonCommand(0L,"cancel-source-conflict","取消")))).hasMessageContaining("原单状态"); + assertThat(e.status(number)).isEqualTo("ACTIVE"); + assertThat(e.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED"); + assertThat(e.f.db.queryForObject("SELECT status FROM ll_us_truck_order",String.class)).isEqualTo("IN_TRANSIT"); + assertThat(e.f.db.queryForObject("SELECT active_order FROM ll_dispatch_truck_task",String.class)).isNotNull(); + assertThat(e.f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment",Long.class)).isEqualTo(2); + assertThat(e.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE action='CANCEL'",Long.class)).isZero(); + } + + /** 复用真实排车持久化夹具,补齐运输关联与本土卡车持久化,外部主档仅作受控提供者。 */ + private static final class Fixture { + final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture(); + final TransportTaskService tasks; + + /** 接通真实建单、车次取消和同步投影,不用返回成功的取消替身。 */ + Fixture() throws Exception { + var config=f.session.getConfiguration(); + config.addMapper(TransportTaskMapper.class);config.addMapper(UsTruckOrderMapper.class);config.addMapper(UsTruckOrderStatusHistoryMapper.class); + table(UsTruckOrder.class);table(UsTruckOrderStatusHistory.class); + f.db.execute("ALTER TABLE ll_dispatch_truck_task ADD trip_no VARCHAR(64)"); + f.db.execute("ALTER TABLE ll_dispatch_truck_task ADD order_no VARCHAR(64)"); + f.db.execute("ALTER TABLE ll_dispatch_truck_task ADD active_order VARCHAR(128) UNIQUE"); + f.db.execute("CREATE TABLE ll_dispatch_transport_create(trip_no VARCHAR(64) PRIMARY KEY,request_hash VARCHAR(64))"); + f.db.execute("CREATE TABLE ll_dispatch_transport_command(dispatch_no VARCHAR(64),request_key VARCHAR(128),request_hash VARCHAR(64),evidence_json CLOB,created_at TIMESTAMP)"); + var links=new TransportTaskRepository(f.session.getMapper(TransportTaskMapper.class)); + var trucks=new UsTruckOrderRepository(f.session.getMapper(UsTruckOrderMapper.class),mock(UsTruckOrderPalletMapper.class), + f.session.getMapper(UsTruckOrderStatusHistoryMapper.class),mock(UsTruckOrderOperationMapper.class)); + var numbers=mock(DispatchNumberGenerator.class);var next=new java.util.concurrent.atomic.AtomicInteger(); + when(numbers.next()).thenAnswer(call->"PC-TRUCK-"+next.incrementAndGet()); + var projection=new TransportTaskProjection(f.repository,links,trucks,numbers,f.json); + ReflectionTestUtils.setField(f.nativeFleetService,"transportEvents",(ApplicationEventPublisher)event->projection.changed((TransportTripChanged)event)); + ReflectionTestUtils.setField(f.nativeFleetService,"drivers",mock(DriverService.class,RETURNS_DEEP_STUBS)); + var trips=new FleetTripRepository(f.session.getMapper(FleetTripMapper.class),f.session.getMapper(FleetResourceMapper.class), + f.session.getMapper(FleetTripHistoryMapper.class),f.session.getMapper(FleetTripDispatchMapper.class)); + tasks=new TransportTaskService(f.repository,links,projection,trips,f.nativeFleetService,mock(FleetPickupService.class), + mock(FleetPickupRepository.class),trucks,mock(FleetPoolOrderQueries.class),mock(DriverService.class), + mock(FleetTransportVehicleGuard.class),mock(TripOperationsEvidenceGateway.class),f.json); + ReflectionTestUtils.setField(tasks,"reviewPhotos",mock(TransportReviewPhotos.class)); + ReflectionTestUtils.setField(f.service,"transportTasks",tasks); + var order=new UsTruckOrder();order.setId(91L);order.setCustomerId("C1");order.setOrderNo("US-TRUCK-1"); + order.setOrigin("洛杉矶");order.setOriginAddress("起运地址");order.setDestination("安大略");order.setDestinationAddress("目的地址"); + order.setStatus("PENDING_DISPATCH");order.setVersion(0L);order.setPackageCount(22);order.setPalletCount(2); + order.setCreatedAt(LocalDateTime.now());order.setUpdatedAt(order.getCreatedAt());trucks.insertOrder(order); + } + + /** 按实体字段建立隔离内存表,SQL通过真实Mapper运行。 */ + private void table(Class type) { + var info=TableInfoHelper.getTableInfo(type);var columns=new ArrayList();columns.add(info.getKeyColumn()+" BIGINT PRIMARY KEY"); + for(var field:info.getFieldList()) { + var javaType=field.getPropertyType();String sqlType=javaType==LocalDateTime.class?"TIMESTAMP(3)": + javaType==BigDecimal.class?"DECIMAL(18,6)":Number.class.isAssignableFrom(javaType)?"BIGINT":"VARCHAR(4000)"; + columns.add(field.getColumn()+" "+sqlType); + } + f.db.execute("CREATE TABLE "+info.getTableName()+"("+String.join(",",columns)+")"); + } + + /** 从本土卡车正式安排入口创建排车。 */ + String create() { return f.tx.execute(t->tasks.createTruck(f.actor.operatorId(),request("truck-create-original"))).dispatchNo(); } + + /** 每次安排使用新请求键,来源保留同一客户和业务单号。 */ + TransportTaskModels.TruckCreate request(String key) { + var departure=Instant.now().plusSeconds(3600); + return new TransportTaskModels.TruckCreate("C1","US-TRUCK-1",9L,departure,departure.plusSeconds(3600),key); + } + + /** 模拟已持有本车资源的事实,取消不能删除车辆或司机主档。 */ + void assign(String number) { + f.db.update("UPDATE ll_fleet_trip SET vehicle_id=71,driver_id=81 WHERE id=(SELECT trip_id FROM ll_dispatch_order WHERE dispatch_no=?)",number); + f.db.update("INSERT INTO ll_fleet_resource_assignment SELECT 'VEHICLE',71,id,trip_no FROM ll_fleet_trip"); + f.db.update("INSERT INTO ll_fleet_resource_assignment SELECT 'DRIVER',81,id,trip_no FROM ll_fleet_trip"); + } + + /** 直接核对主单持久化状态。 */ + String status(String number) { return f.db.queryForObject("SELECT status FROM ll_dispatch_order WHERE dispatch_no=?",String.class,number); } + + /** 失败命令不能改变来源安排、资源占用及成功账本。 */ + void assertUnchanged(String number) { + assertThat(status(number)).isEqualTo("ACTIVE"); + assertThat(f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED"); + assertThat(f.db.queryForObject("SELECT status FROM ll_us_truck_order",String.class)).isEqualTo("DISPATCHED"); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment",Long.class)).isEqualTo(2); + assertThat(f.db.queryForObject("SELECT active_order FROM ll_dispatch_truck_task",String.class)).isNotNull(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE action='CANCEL'",Long.class)).isZero(); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasManualFeeServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasManualFeeServiceTest.java index 528b93eb1..1100aba64 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasManualFeeServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasManualFeeServiceTest.java @@ -511,9 +511,11 @@ class OverseasManualFeeServiceTest { verify(repository).insertFunding(state.capture()); funding = state.getValue(); when(repository.lockFunding(701L)).thenReturn(funding); + assertThat(service.detail(701L).getAllowedActions()).contains("ADJUST", "VOID"); funding.setWalletStatus("FROZEN"); funding.setWalletHoldId("9001"); var reviewed = service.review(701L, "reviewer", new ReviewRequest("APPROVE", null, "purchase-review")); assertThat(reviewed.getWalletStatus()).isEqualTo("SETTLE_PENDING"); + assertThat(reviewed.getAllowedActions()).doesNotContain("ADJUST", "VOID"); assertThat(reviewed.getWalletSettlement()).isNull(); verify(wallet, times(1)).publish(eq("FREEZE"), eq(10001L), anyString(), anyString(), eq(new BigDecimal("88.00")), anyString(), eq(1L)); verify(wallet, times(1)).publish(eq("SETTLE"), eq(10001L), anyString(), anyString(), isNull(), anyString(), eq(2L)); @@ -553,4 +555,115 @@ class OverseasManualFeeServiceTest { result, holdStatus, new BigDecimal(amount), "FAILED".equals(result) ? "INSUFFICIENT" : null, "FAILED".equals(result) ? "余额不足" : null, Instant.parse("2026-10-22T00:00:00Z"), "9001", "9002"); } + /** 人工费用审核前可调整、作废,审核通过后不论结算方式均关闭这两个入口。 */ + @Test void manualActionsAreAvailableOnlyBeforeApproval() { + for (String mode : List.of("WALLET", "BILL")) { + funding.setCollectionMode(mode); funding.setWalletStatus("BILL".equals(mode) ? "NOT_REQUIRED" : "FROZEN"); + fee.setReviewStatus("PENDING"); fee.setPublicationStatus("UNPUBLISHED"); + assertThat(service.detail(701L).getAllowedActions()).contains("ADJUST", "VOID"); + fee.setReviewStatus("APPROVED"); + assertThat(service.detail(701L).getAllowedActions()).doesNotContain("ADJUST", "VOID"); + assertThatThrownBy(() -> service.adjust(701L, "operator", new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.AdjustRequest(1, + OverseasFeeBusinessGuardTest.containerFee("10001", 701L, "MSCU1234567"), "approved-adjust"))).hasMessageContaining("审核前"); + assertThatThrownBy(() -> service.voidSource(701L, "operator", new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.VoidRequest(1, "重复费用", "approved-void"))).hasMessageContaining("审核前"); + } + verifyNoInteractions(wallet, outbox); + verify(repository, never()).updateFee(any()); + } + + /** 审核前作废释放真实冻结,重复请求不重复发出释放命令。 */ + @Test void voidBeforeReviewReleasesOnceAndPreservesReason() { + var command = new java.util.concurrent.atomic.AtomicReference(); + when(repository.command("operator", "MANUAL_VOID", "void-before")).thenAnswer(invocation -> command.get()); + doAnswer(invocation -> { command.set(invocation.getArgument(0)); return null; }).when(repository).insertCommand(any()); + var request = new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.VoidRequest(1, "重复录费", "void-before"); + service.voidSource(701L, "operator", request); + service.voidSource(701L, "operator", request); + assertThat(fee.getSourceStatus()).isEqualTo("VOIDED"); + assertThat(fee.getReviewReason()).isEqualTo("重复录费"); + assertThat(funding.getWalletStatus()).isEqualTo("RELEASE_PENDING"); + verify(wallet, times(1)).publish(eq("RELEASE"), eq(10001L), anyString(), eq("701-1"), isNull(), anyString(), eq(2L)); + verifyNoInteractions(outbox); + assertThatThrownBy(() -> service.voidSource(701L, "operator", new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.VoidRequest(1, "变更原因", "void-before"))) + .hasMessageContaining("其他请求"); + } + + /** 未发布账单作废无需请求 Finance;过期版本必须先拒绝。 */ + @Test void voidUnpublishedBillChecksVersionWithoutPublishingCancellation() { + funding.setCollectionMode("BILL"); funding.setWalletStatus("NOT_REQUIRED"); + assertThatThrownBy(() -> service.voidSource(701L, "operator", new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.VoidRequest(2, "重复", "stale-void"))) + .hasMessageContaining("审核前"); + verify(repository, never()).updateFee(any()); + service.voidSource(701L, "operator", new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.VoidRequest(1, "重复", "bill-void")); + assertThat(fee.getSourceStatus()).isEqualTo("VOIDED"); + verifyNoInteractions(wallet, outbox); + } + + /** 已发布或资金/作废流程在途时不能通过伪造审核状态开放修改。 */ + @Test void pendingReviewStillRequiresUnpublishedAndSafeFundingState() { + fee.setPublicationStatus("PUBLISHED"); + assertThat(service.detail(701L).getAllowedActions()).doesNotContain("ADJUST", "VOID"); + assertThatThrownBy(() -> service.voidSource(701L, "operator", new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.VoidRequest(1, "重复", "published-void"))) + .hasMessageContaining("审核前"); + fee.setPublicationStatus("UNPUBLISHED"); + for (String status : List.of("SETTLE_PENDING", "SETTLED", "RELEASE_PENDING")) { + funding.setWalletStatus(status); + assertThat(service.detail(701L).getAllowedActions()).doesNotContain("ADJUST", "VOID"); + assertThatThrownBy(() -> service.voidSource(701L, "operator", new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.VoidRequest(1, "重复", "funding-void"))) + .hasMessageContaining("审核前"); + } + funding.setWalletStatus("FROZEN"); fee.setVoidStatus("PENDING_FINANCE"); + assertThat(service.detail(701L).getAllowedActions()).doesNotContain("ADJUST", "VOID"); + assertThatThrownBy(() -> service.voidSource(701L, "operator", new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.VoidRequest(1, "重复", "pending-void"))) + .hasMessageContaining("审核前"); + verifyNoInteractions(wallet, outbox); + verify(repository, never()).updateFee(any()); + } + + /** 调整使用独立资金身份,原冻结回执继续核对旧金额,新费用只锁定一次。 */ + @Test void adjustmentReplacesPendingFeeWithIndependentFundingAndReplaysOnce() { + var request = prepareAdjustedFee(); + var command = new java.util.concurrent.atomic.AtomicReference(); + when(repository.command("operator", "MANUAL_ADJUST", "adjust-before")).thenAnswer(invocation -> command.get()); + doAnswer(invocation -> { command.set(invocation.getArgument(0)); return null; }).when(repository).insertCommand(any()); + var first = service.adjust(701L, "operator", request); + var replay = service.adjust(701L, "operator", request); + assertThat(first.getSourceFeeId()).isEqualTo(801L); + assertThat(replay.getSourceFeeId()).isEqualTo(801L); + assertThat(first.getReviewStatus()).isEqualTo("PENDING"); + assertThat(first.getWalletStatus()).isEqualTo("FREEZE_PENDING"); + assertThat(fee.getSourceStatus()).isEqualTo("SUPERSEDED"); + assertThat(fee.getAmount()).isEqualByComparingTo("70.00"); + verify(wallet, times(1)).publish(eq("RELEASE"), eq(10001L), anyString(), eq("701-1"), isNull(), anyString(), eq(2L)); + verify(wallet, times(1)).publish(eq("FREEZE"), eq(10001L), anyString(), eq("801-1"), eq(new BigDecimal("88.00")), anyString(), eq(1L)); + verifyNoInteractions(outbox); + } + + /** 原锁款尚在途时也能调整,迟到的原成功回执只能释放原冻结,不能收费。 */ + @Test void supersededFeeReleasesItsLateFreezeResult() { + var request = prepareAdjustedFee(); + funding.setWalletStatus("FREEZE_PENDING"); + service.adjust(701L, "operator", request); + service.handle(completed("freeze-event", "FREEZE", "SUCCEEDED", "FROZEN", "10001", "701-1", "70.00")); + assertThat(funding.getWalletStatus()).isEqualTo("RELEASE_PENDING"); + assertThat(fee.getWalletSettlement()).isNull(); + verify(wallet, times(1)).publish(eq("RELEASE"), eq(10001L), anyString(), eq("701-1"), isNull(), anyString(), eq(2L)); + verifyNoInteractions(outbox); + } + + /** 构造同业务调整与独立新费用;模拟仓储层按费用身份保存资金状态。 */ + private com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.AdjustRequest prepareAdjustedFee() { + var request = OverseasFeeBusinessGuardTest.containerFee("10001", 701L, "MSCU1234567"); + fee.setSourceType(request.sourceType()); fee.setBusinessType(request.businessType()); + fee.setBusinessId(request.businessId()); fee.setBusinessNo(request.businessNo()); + fee.setFeeItemCode(request.feeItemCode()); fee.setAmount(new BigDecimal("70.00")); fee.setOriginalAmount(new BigDecimal("70.00")); + var next = new OverseasFeeSource(); org.springframework.beans.BeanUtils.copyProperties(fee, next); + next.setId(101L); next.setSourceFeeId(801L); next.setAmount(request.amount()); next.setOriginalAmount(request.amount()); + when(sources.create(eq("operator"), startsWith("manual-adjust:"), eq(request))).thenReturn(next); + when(repository.lockFee(801L)).thenReturn(next); + var nextFunding = new java.util.concurrent.atomic.AtomicReference(); + when(repository.lockFunding(801L)).thenAnswer(invocation -> nextFunding.get()); + doAnswer(invocation -> { nextFunding.set(invocation.getArgument(0)); return null; }).when(repository).insertFunding(any()); + return new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.AdjustRequest(1, request, "adjust-before"); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementWorkflowPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementWorkflowPersistenceTest.java index d8f7c754a..574635ffe 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementWorkflowPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementWorkflowPersistenceTest.java @@ -59,7 +59,7 @@ class ProcurementWorkflowPersistenceTest { assertThatThrownBy(()->tx.execute(t->service.reject("10","CG-1","8",new Rejection(2,"other","破损")))).hasMessageContaining("不属于"); var request=new Rejection(2,"reject","照片不清晰"); var rejected=tx.execute(t->service.reject("9","CG-1","8",request)); - assertThat(rejected.status()).isEqualTo("RECEIVING");assertThat(rejected.rejectionReason()).isEqualTo("照片不清晰");assertThat(rejected.photosNeedUpdate()).isFalse(); + assertThat(rejected.status()).isEqualTo("RECEIVING");assertThat(rejected.rejectionReason()).isEqualTo("照片不清晰");assertThat(rejected.photosNeedUpdate()).isTrue(); assertThat(tx.execute(t->service.reject("9","CG-1","8",request)).version()).isEqualTo(3); long receipt=rejected.receipts().get(0).id(); assertThatThrownBy(()->tx.execute(t->service.replacePhotos("9","CG-1","7",new ReceiptPhotos(3,"old",receipt,List.of(101L))))).hasMessageContaining("不能复用"); @@ -67,6 +67,9 @@ class ProcurementWorkflowPersistenceTest { file(105L,11L,"9","image/png"); var current=tx.execute(t->service.replacePhotos("9","CG-1","7",new ReceiptPhotos(3,"new",receipt,List.of(105L)))); assertThat(current.photosNeedUpdate()).isFalse();assertThat(current.stockQuantity()).isEqualTo(10);assertThat(current.receipts()).hasSize(1); + assertThat(current.rejectionReason()).isNull(); + assertThat(orders.findNo("9","CG-1").getCustomerRejectionReason()).isNull(); + assertThat(tx.execute(t->service.read("9","CG-1")).rejectionReason()).isNull(); var resubmitted=tx.execute(t->service.requestConfirmation("9","CG-1","7",new Command(4,"again"))); assertThat(resubmitted.rejectionReason()).isNull(); assertThat(resubmitted.photosNeedUpdate()).isFalse(); @@ -106,11 +109,12 @@ class ProcurementWorkflowPersistenceTest { tx.execute(s -> service.receive("9", "CG-1", "7", new Receipt(1, "multi-r2", 6, List.of(104L)))); tx.execute(s -> service.requestConfirmation("9", "CG-1", "7", new Command(2, "multi-ask"))); var rejected = tx.execute(s -> service.reject("9", "CG-1", "8", new Rejection(3, "multi-reject", "两批照片均不清晰"))); - assertThat(rejected.photosNeedUpdate()).isFalse(); + assertThat(rejected.photosNeedUpdate()).isTrue(); long first = rejected.receipts().get(0).id(); file(106L, 11L, "9", "image/png"); var firstReplaced = tx.execute(s -> service.replacePhotos("9", "CG-1", "7", new ReceiptPhotos(4, "multi-r1-new", first, List.of(106L)))); - assertThat(firstReplaced.photosNeedUpdate()).isFalse(); + assertThat(firstReplaced.photosNeedUpdate()).isTrue(); + assertThat(firstReplaced.rejectionReason()).isNull(); assertThat(firstReplaced.receipts().get(1).fileIds()).containsExactly(104L); assertThat(firstReplaced.receivedQuantity()).isEqualTo(10); var resubmitted = tx.execute(s -> service.requestConfirmation("9", "CG-1", "7", new Command(5, "multi-again"))); @@ -119,6 +123,27 @@ class ProcurementWorkflowPersistenceTest { assertThat(resubmitted.photosNeedUpdate()).isFalse(); } + /** 首批换图清除原因后,其他批次仍可继续换图且不得重复增加库存。 */ + @Test void replacingPhotosContinuesAcrossBatchesAfterRejectionReasonIsCleared() { + tx.execute(s -> service.receive("9", "CG-1", "7", new Receipt(0, "photos-r1", 4, List.of(101L)))); + tx.execute(s -> service.receive("9", "CG-1", "7", new Receipt(1, "photos-r2", 6, List.of(104L)))); + tx.execute(s -> service.requestConfirmation("9", "CG-1", "7", new Command(2, "photos-ask"))); + var rejected = tx.execute(s -> service.reject("9", "CG-1", "8", new Rejection(3, "photos-reject", "照片不清晰"))); + file(106L, 11L, "9", "image/png"); + file(107L, 11L, "9", "image/png"); + var firstRequest = new ReceiptPhotos(4, "photos-first", rejected.receipts().get(0).id(), List.of(106L)); + var first = tx.execute(s -> service.replacePhotos("9", "CG-1", "7", firstRequest)); + assertThat(first.rejectionReason()).isNull(); + assertThat(first.photosNeedUpdate()).isTrue(); + var second = tx.execute(s -> service.replacePhotos("9", "CG-1", "7", new ReceiptPhotos(5, "photos-second", rejected.receipts().get(1).id(), List.of(107L)))); + assertThat(second.rejectionReason()).isNull(); + assertThat(second.photosNeedUpdate()).isFalse(); + assertThat(second.receivedQuantity()).isEqualTo(10); + assertThat(second.stockQuantity()).isEqualTo(10); + assertThat(orders.findNo("9", "CG-1").getCustomerRejectionReason()).isNull(); + assertThat(tx.execute(s -> service.replacePhotos("9", "CG-1", "7", firstRequest)).version()).isEqualTo(second.version()); + } + /** 多批次均保留原照片也可重新提交,成功后清除上一轮原因,重放不改变库存。 */ @Test void rejectedPurchaseCanResubmitWithoutUploadingPhotos() { tx.execute(s -> service.receive("9", "CG-1", "7", new Receipt(0, "keep-r1", 4, List.of(101L)))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetServiceTest.java index 1f7e68dff..1d85347e8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetServiceTest.java @@ -87,7 +87,8 @@ class OrderBudgetServiceTest { assertThatThrownBy(()->service.submit("9","procurement","key",new BigDecimal("100"),"USD",()->"CG-OTHER",v->v)).hasMessageContaining("其他订单"); assertThatThrownBy(()->service.submit("9","procurement","key",new BigDecimal("200"),"USD",()->"CG-1",v->v)).hasMessageContaining("变化"); assertThatThrownBy(()->service.submit("9","procurement","key",new BigDecimal("100"),"CNY",()->"CG-1",v->v)).hasMessageContaining("变化"); - assertThatThrownBy(()->service.submit("10","procurement","key",new BigDecimal("100"),"USD",()->"CG-1",v->v)).hasMessageContaining("校验"); + // 新客户的下单会建立自身预占;隔离边界是不能按相同请求键读取其他客户已绑定的原单。 + assertThatThrownBy(()->service.read("10","procurement","key")).hasMessageContaining("未找到"); verifyNoInteractions(wallet); } private WalletCommandCompletedV1 result(String event,String operation,String outcome,String status,String error) {