修复费用审核前调整和运输取消及对账明细约束

This commit is contained in:
2026-10-30 18:51:28 +08:00
parent 5825739b92
commit 035e7e06af
17 changed files with 592 additions and 41 deletions
+2
View File
@@ -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 旧采购迁移仅供追溯,退役后不得重放。
@@ -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);
@@ -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')),
+2 -1
View File
@@ -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'),
@@ -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);
}
}
@@ -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());
+1 -1
View File
@@ -16,7 +16,7 @@
拒绝、取消等异常结果独立展示,不增加正常流程节点。采购方向只影响起运仓候选与方向展示,不再增加国内、海外两套作业步骤。
收货批次保存实际数量和绑定本采购单的附件。采购完成后冻结收货结果,客户确认前不得出库。客户可确认或填写1~1000字原因驳回;驳回回到收货中,平台展示原因并允许按需重新上传照片,也可保留原照片直接再次提交采购完成,不要求逐批重新上传。选择替换照片时不能把旧附件当作本次新上传结果;仅替换照片,不重复累加数量。再次提交采购完成成功后清除上一轮驳回原因。平台不得代客户调用确认接口;客户身份从统一认证上下文取得,不接受请求指定其他客户。
收货批次保存实际数量和绑定本采购单的附件。采购完成后冻结收货结果,客户确认前不得出库。客户可确认或填写1~1000字原因驳回;驳回回到收货中,平台展示原因并允许按需重新上传照片,也可保留原照片直接再次提交采购完成,不要求逐批重新上传。选择替换照片时不能把旧附件当作本次新上传结果;仅替换照片,不重复累加数量。重新上传成功后清除当前驳回原因,本轮其他批次仍可继续换图;`photosNeedUpdate` 只标识仍绑定本轮驳回照片,不作为重新提交门禁。再次提交采购完成成功后清除上一轮驳回原因和照片基线,待客户确认及确认后的页面均不展示旧驳回提示。平台不得代客户调用确认接口;客户身份从统一认证上下文取得,不接受请求指定其他客户。
出库按批次保存数量、物流跟踪号、派送和签收资料。创建批次时预留可用数量,避免多个批次超出采购库存;签收完成时才扣减该批采购库存。签收请求原键重放不得再次扣减。存在剩余库存时仍可继续创建批次,不能因前一批签收而提前结束整单。采购库存属于采购台账,不写普通尾程 WMS 库存。
@@ -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)));
@@ -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<OverseasFeeSource> 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<OverseasFeeSource> voidSource(@AuthenticationPrincipal AuthenticatedUser user,
@@ -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());
@@ -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<Long> visible=new HashSet<>();receipts.forEach(r->visible.addAll(r.fileIds()));outbounds.forEach(b->visible.addAll(b.fileIds()));
var files=visible.isEmpty()?List.<FileInfo>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<Long> 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);
}
/** 文件数量、客户归属、用途和照片类型均由服务端验证。 */
@@ -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 时区 }
@@ -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)))));
@@ -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<String>();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();
}
}
}
@@ -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<OverseasFeeCommand>();
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<OverseasFeeCommand>();
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<OverseasFeeFunding>();
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");
}
}
@@ -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))));
@@ -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) {