修复费用审核前调整和运输取消及对账明细约束
This commit is contained in:
@@ -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')),
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
拒绝、取消等异常结果独立展示,不增加正常流程节点。采购方向只影响起运仓候选与方向展示,不再增加国内、海外两套作业步骤。
|
||||
|
||||
收货批次保存实际数量和绑定本采购单的附件。采购完成后冻结收货结果,客户确认前不得出库。客户可确认或填写1~1000字原因驳回;驳回回到收货中,平台展示原因并允许按需重新上传照片,也可保留原照片直接再次提交采购完成,不要求逐批重新上传。选择替换照片时不能把旧附件当作本次新上传结果;仅替换照片,不重复累加数量。再次提交采购完成成功后清除上一轮驳回原因。平台不得代客户调用确认接口;客户身份从统一认证上下文取得,不接受请求指定其他客户。
|
||||
收货批次保存实际数量和绑定本采购单的附件。采购完成后冻结收货结果,客户确认前不得出库。客户可确认或填写1~1000字原因驳回;驳回回到收货中,平台展示原因并允许按需重新上传照片,也可保留原照片直接再次提交采购完成,不要求逐批重新上传。选择替换照片时不能把旧附件当作本次新上传结果;仅替换照片,不重复累加数量。重新上传成功后清除当前驳回原因,本轮其他批次仍可继续换图;`photosNeedUpdate` 只标识仍绑定本轮驳回照片,不作为重新提交门禁。再次提交采购完成成功后清除上一轮驳回原因和照片基线,待客户确认及确认后的页面均不展示旧驳回提示。平台不得代客户调用确认接口;客户身份从统一认证上下文取得,不接受请求指定其他客户。
|
||||
|
||||
出库按批次保存数量、物流跟踪号、派送和签收资料。创建批次时预留可用数量,避免多个批次超出采购库存;签收完成时才扣减该批采购库存。签收请求原键重放不得再次扣减。存在剩余库存时仍可继续创建批次,不能因前一批签收而提前结束整单。采购库存属于采购台账,不写普通尾程 WMS 库存。
|
||||
|
||||
|
||||
+12
-2
@@ -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)));
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+47
-19
@@ -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());
|
||||
|
||||
+8
-4
@@ -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 时区 }
|
||||
|
||||
+86
-4
@@ -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)))));
|
||||
|
||||
+174
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+113
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+28
-3
@@ -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))));
|
||||
|
||||
+2
-1
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user