Compare commits

..
4 changed files with 127 additions and 6 deletions
@@ -524,7 +524,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
var identity=identity(actor,number,Action.LOADING_COMPLETE,request.recordVersion(),request.idempotencyKey(),request);
var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get();
if(row.getRecordVersion()!=request.recordVersion().longValue() || !Status.ACTIVE.name().equals(row.getStatus()) || !Stage.LOADING.name().equals(row.getStage()) || !ReviewState.APPROVED.name().equals(row.getReviewState()))throw conflict("当前版本或审核阶段不能确认装车");
lockExecutionSources(row,repository.cargo(row.getId()));
var items=repository.cargo(row.getId());
// 未实收/未首拣的PLAN尚未物化实托,先按未核平拒绝,不能将空实托集合传给执行锁。
if(items.isEmpty() || items.stream().anyMatch(i->i.getWarehousePalletId()==null))throw conflict("逐项真实拣货尚未核平");
lockExecutionSources(row,items);
var preview=loading(actor,number);if(!preview.pickingReconciled())throw conflict("逐项真实拣货尚未核平");
if(!Objects.equals(preview.manifestHash(),request.manifestHash()))throw conflict("真实装载清单已变化,请重新核对");
if(request.measurements()==null || request.measurements().size()!=preview.pallets().size())throw bad("请确认本车全部实托测量");
@@ -179,15 +179,14 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
.header("X-Internal-Service-Token", internalToken).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> { throw palletLookupFailure(result.getStatusCode()); })
.body(WarehousePalletResponse.class);
if (response == null || response.data() == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"Warehouse pallet service returned no usable response");
requirePalletResponse(response);
return new WarehousePalletReference(response.data().id(), response.data().palletCode(), response.data().warehouseId(),
response.data().locationId(), response.data().status(), response.data().fbaWarehouseCode(),
response.data().unitPrice(), response.data().version());
}
private ResponseStatusException palletLookupFailure(HttpStatusCode status) {
return new ResponseStatusException(status.value() == 404 ? HttpStatus.NOT_FOUND
return new ResponseStatusException(status.value() == 409 ? HttpStatus.CONFLICT : status.value() == 404 ? HttpStatus.NOT_FOUND
: (status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE),
status.value() == 404 ? "Warehouse pallet was not found" : "Warehouse pallet lookup failed");
}
@@ -199,14 +198,23 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
.retrieve().onStatus(HttpStatusCode::isError, (request, result) -> {
throw palletLookupFailure(result.getStatusCode());
}).body(WarehousePalletResponse.class);
requirePalletResponse(response);
return response;
}
/** 托盘状态冲突是业务拒绝,不能把HTTP 200内的A0409误报为仓储服务不可用。 */
private void requirePalletResponse(WarehousePalletResponse response) {
if (response != null && "A0409".equals(response.code())) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
response.message() == null || response.message().isBlank() ? "Warehouse pallet state conflicts with this operation" : response.message());
}
if (response != null && "A0404".equals(response.code())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Warehouse pallet was not found");
}
if (response == null || response.data() == null) {
if (response == null || !"200".equals(response.code()) || response.data() == null) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"Warehouse pallet service returned no usable response");
}
return response;
}
private WarehousePalletReference reference(WarehousePalletData pallet) {
@@ -0,0 +1,70 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.dispatch.port.DispatchCommands;
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
import com.baiyue.lastleg.dispatch.port.DispatchTypes.ActorMode;
import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository;
import com.baiyue.lastleg.relabel.repository.RelabelDispatchSourceRepository;
import com.baiyue.lastleg.relabel.service.RelabelDispatchSourceService;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 真实排车、车次、命令和来源入口回归;未物化PLAN不得进入仅接受实托的执行锁。 */
class DispatchLoadingEligibilityPersistenceTest {
/** 全车在途和部分实托已拣的混合车均应业务拒绝,原单/命令/占用/扫码事实完全不变。 */
@ParameterizedTest
@ValueSource(booleans={false,true})
void unmaterializedPlanRejectsLoadingBeforePhysicalSourceLock(boolean mixed) throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var plan=f.source(1,"101",501,"ONT8",3);
var selections=mixed?List.of(plan,f.source(2,"202",502,"LAX9",2)):List.of(plan);
var created=f.tx.execute(t->f.service.create(f.actor,f.configured("unreceived-loading",selections)));
var approved=f.approve(created);
f.db.update("UPDATE ll_dispatch_item SET warehouse_pallet_id=NULL,source_stage='PLAN' WHERE plan_pallet_id=1");
if(mixed) {
f.picked(2,"202",502,2);
f.db.update("UPDATE ll_dispatch_order SET picked_package_count=2");
}
var providerRows=mock(RelabelDispatchSourceRepository.class);
var provider=spy(new RelabelDispatchSourceService(providerRows,f.json));
var sources=mock(DispatchSourceRepository.class);
var rules=spy(new DispatchService());
ReflectionTestUtils.setField(rules,"relabelSources",provider);
ReflectionTestUtils.setField(rules,"wholeSources",sources);
ReflectionTestUtils.setField(f.service,"sourceRules",rules);
var actor=new DispatchContracts.Actor("picker",ActorMode.PDA,"9");
var preview=f.tx.execute(t->f.service.loading(actor,created.dispatchNo()));
assertThat(preview.pickingReconciled()).isFalse();
assertThat(preview.loadingComplete().allowed()).isFalse();
assertThat(preview.loadingComplete().blockCode()).isEqualTo("PICKING_INCOMPLETE");
assertThat(preview.pallets()).hasSize(mixed?1:0);
var tables=List.of("ll_dispatch_order","ll_dispatch_item","ll_dispatch_command","ll_dispatch_history",
"ll_dispatch_pda_pick","ll_dispatch_pda_pick_order","ll_dispatch_pallet_lock","ll_dispatch_physical_pallet_lock",
"ll_fleet_trip","ll_fleet_trip_history");
var before=snapshot(f,tables);
var request=new DispatchCommands.LoadingComplete(approved.recordVersion(),"unreceived-negative",preview.manifestHash(),List.of(),"未实收负例");
assertThatThrownBy(()->f.tx.execute(t->f.service.loadingComplete(actor,created.dispatchNo(),request)))
.isInstanceOfSatisfying(BizException.class,error->assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT))
.hasMessage("逐项真实拣货尚未核平");
assertThat(snapshot(f,tables)).isEqualTo(before);
verify(rules,never()).lockExecutionSources(anyString(),anyList());
verifyNoInteractions(provider,providerRows,sources,f.ownedBusinessOwnership);
}
/** 按实际列值读取CLOB及时间,避免比较JDBC临时对象身份。 */
private Object snapshot(DispatchAggregatePersistenceTest.Fixture f,List<String> tables) {
return tables.stream().map(table->f.db.query("SELECT * FROM "+table+" ORDER BY id",(row,index)->{
var values=new java.util.ArrayList<String>();
for(int column=1;column<=row.getMetaData().getColumnCount();column++)values.add(row.getString(column));
return values;
})).toList();
}
}
@@ -164,6 +164,46 @@ class HttpWarehouseResourceGatewayTest {
server.verify();
}
/** 实际SHIP后报废的原托扫码仍拒绝;保留提供者业务码/原因,不伪装为服务故障或按ID重试。 */
@Test
void inspectPreservesScrappedPalletBusinessConflictWithoutFallback() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/D1747-1-001"))
.andRespond(withSuccess("""
{"code":"A0409","message":"托盘已报废","data":null}
""", MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> gateway.inspectPallet("D1747-1-001"))
.isInstanceOfSatisfying(ResponseStatusException.class, error -> {
assertThat(error.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
assertThat(error.getReason()).isEqualTo("托盘已报废");
});
server.verify();
}
/** 按ID读取使用同一响应边界,错误码即使带data也不能被当作合法实托。 */
@Test
void idLookupRejectsBusinessConflictEvenWithPayload() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/id/31"))
.andRespond(withSuccess("""
{"code":"A0409","message":"托盘状态冲突","data":{"palletId":31,"palletCode":"PAL-1",
"warehouseId":88,"locationId":40,"status":"IDLE","version":1}}
""", MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> gateway.resolvePallet(31L))
.isInstanceOfSatisfying(ResponseStatusException.class, error ->
assertThat(error.getStatusCode()).isEqualTo(HttpStatus.CONFLICT));
server.verify();
}
/** 同一个状态冲突无论通过HTTP状态还是业务信封传输,都不能降为400或503。 */
@Test
void inspectPreservesHttpConflict() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-CONFLICT"))
.andRespond(withStatus(HttpStatus.CONFLICT));
assertThatThrownBy(() -> gateway.inspectPallet("PAL-CONFLICT"))
.isInstanceOfSatisfying(ResponseStatusException.class, error ->
assertThat(error.getStatusCode()).isEqualTo(HttpStatus.CONFLICT));
server.verify();
}
@Test
void resolveReservedFallsBackToConfirmedPhysicalBindingForEarlierPlanDisplayCode() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/IP-ACY2-20260909-166786-P001"))