Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c59525dec | ||
|
|
c76b9888f5 | ||
|
|
135ffa662d | ||
|
|
a8037fd97d |
+4
-3
@@ -52,11 +52,12 @@ public interface DispatchCandidateMapper {
|
||||
" / NULLIF((SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a2 WHERE a2.line_id=a.line_id),0) END),0) ELSE NULL END AS grossWeightKg," +
|
||||
"MIN(ip.container_no) AS containerNo," +
|
||||
CONFIRMED_APPOINTMENT_AT + " AS appointmentAt,NULL AS expectedLoadingAt," +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " +
|
||||
// 仓ID文本统一保留128字符,避免混合CASE的NONE排序规则及无长度CHAR截断;来源优先级与身份等值不变。
|
||||
"CAST(CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " +
|
||||
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " +
|
||||
"AND pos.available_packages > 0) ELSE COALESCE(NULLIF(CAST(ip.overseas_warehouse_id AS CHAR),'')," +
|
||||
"AND pos.available_packages > 0) ELSE COALESCE(NULLIF(CAST(ip.overseas_warehouse_id AS CHAR(128)),'')," +
|
||||
"(SELECT NULLIF(TRIM(source_order.overseas_warehouse_id),'') FROM ll_order source_order " +
|
||||
"WHERE source_order.id=ip.source_order_id AND source_order.customer_id=ip.customer_id LIMIT 1)) END AS overseasWarehouseId," +
|
||||
"WHERE source_order.id=ip.source_order_id AND source_order.customer_id=ip.customer_id LIMIT 1)) END AS CHAR(128)) AS overseasWarehouseId," +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_code) " +
|
||||
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
|
||||
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " +
|
||||
|
||||
+2
-2
@@ -25,11 +25,11 @@ public interface DispatchSourceMapper {
|
||||
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
|
||||
List<ItemPicked> pickedItems(@Param("dispatch")long dispatch);
|
||||
|
||||
/** 组成查询不按占用可选性筛掉本草稿或隐藏客户的真实成员。 */
|
||||
/** 组成查询保留完整客户成员;仓ID两侧同转有界字符,避免列与连接字符集排序规则冲突。 */
|
||||
@Select("<script>SELECT DISTINCT p.id AS planPalletId,ip.customer_id AS customerId FROM ll_inbound_plan_pallet p "
|
||||
+ "JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id "
|
||||
+ "JOIN ll_inventory_position s ON s.pallet_id=p.warehouse_pallet_id AND s.customer_id=ip.customer_id "
|
||||
+ "AND s.overseas_warehouse_id=CAST(ip.overseas_warehouse_id AS CHAR) "
|
||||
+ "AND CAST(s.overseas_warehouse_id AS CHAR(128))=CAST(ip.overseas_warehouse_id AS CHAR(128)) "
|
||||
+ "WHERE s.overseas_warehouse_id=#{warehouse} AND p.warehouse_pallet_id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> ORDER BY ip.customer_id,p.id</script>")
|
||||
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
|
||||
List<PlanReference> physicalPlans(@Param("warehouse")String warehouse,@Param("ids")List<Long> ids);
|
||||
|
||||
+36
-6
@@ -223,14 +223,37 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
requirePlatform(actor);var row=required(actor,number,false);requireEditing(row,row.getRecordVersion());
|
||||
return evidence.upload(row.getId(),kind,actor.operatorId(),file);
|
||||
}
|
||||
/** 列表权限含详情只读,但文件仍限当前整车复合归属。 */
|
||||
/** 列表权限含详情只读;仅冻结MASTER证照走司机归属,其余保持本车附件复合归属。 */
|
||||
public com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File evidenceMetadata(Actor actor,String number,
|
||||
com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,long fileId) {
|
||||
requirePlatform(actor);var row=required(actor,number,false);if(fileId<=0)throw bad("文件引用无效");return evidence.metadata(row.getId(),kind,fileId);
|
||||
requirePlatform(actor);var row=required(actor,number,false);if(fileId<=0)throw bad("文件引用无效");
|
||||
var driverId=masterEvidenceDriverId(row,kind,fileId);
|
||||
if(driverId!=null)return driverEvidenceMetadata(fleet.dispatchDriverLicense(driverId,fileId).metadata(),fileId);
|
||||
return evidence.metadata(row.getId(),kind,fileId);
|
||||
}
|
||||
/** 内容仅经服务端鉴权代理,不返回匿名存储URL。 */
|
||||
/** 内容沿同一次确定的归属代理读取,不尝试类别回退,不返回匿名存储URL。 */
|
||||
public byte[] evidenceContent(Actor actor,String number,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,long fileId) {
|
||||
requirePlatform(actor);var row=required(actor,number,false);if(fileId<=0)throw bad("文件引用无效");return evidence.download(row.getId(),kind,fileId);
|
||||
requirePlatform(actor);var row=required(actor,number,false);if(fileId<=0)throw bad("文件引用无效");
|
||||
var driverId=masterEvidenceDriverId(row,kind,fileId);
|
||||
if(driverId!=null) {
|
||||
var file=fleet.dispatchDriverLicense(driverId,fileId);driverEvidenceMetadata(file.metadata(),fileId);return file.content();
|
||||
}
|
||||
return evidence.download(row.getId(),kind,fileId);
|
||||
}
|
||||
/** 未命中冻结MASTER引用的文件仅可按原排车归属读取,保留编辑中尚未保存的本车外配上传。 */
|
||||
private Long masterEvidenceDriverId(DispatchAggregate row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,long fileId) {
|
||||
if(kind!=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER)return null;
|
||||
var resources=read(row.getResourcesJson(),Resources.class);var driver=resources==null?null:resources.driver();
|
||||
if(driver==null || driver.mode()!=ResourceMode.MASTER || driver.evidence()==null || driver.evidence().fileIds()==null
|
||||
|| !driver.evidence().fileIds().contains(fileId))return null;
|
||||
if(driver.driverId()==null || driver.driverId()<=0)throw bad("排车冻结司机档案身份无效,不能读取证照");
|
||||
return driver.driverId();
|
||||
}
|
||||
/** 司机域已执行当前/历史关联及File四元组校验,转换元数据时仍保留请求文件身份。 */
|
||||
private static com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File driverEvidenceMetadata(
|
||||
com.baiyue.lastleg.fleet.driver.DriverModels.File file,long fileId) {
|
||||
if(file==null || !Long.toString(fileId).equals(file.fileId()))throw bad("司机证照回执文件身份不一致");
|
||||
return new com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File(fileId,file.fileName(),file.contentType(),file.sizeBytes());
|
||||
}
|
||||
|
||||
/** 空单或同仓多客户选货一次建车,与资源关系、来源锁和账本同事务。 */
|
||||
@@ -346,7 +369,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
var row=lockPlatform(actor,number);var identity=identity(actor,number,Action.SUBMIT_REVIEW,request.recordVersion(),request.idempotencyKey(),request);
|
||||
var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get();
|
||||
requireEditing(row,request.recordVersion());
|
||||
if(!Set.of(ReviewState.DRAFT.name(),ReviewState.DISPATCH_REJECTED.name(),ReviewState.LOADING_REJECTED.name()).contains(row.getReviewState()))throw conflict("当前审核已提交,请刷新核对");
|
||||
if(!canSubmitReview(row.getReviewState()))throw conflict("当前审核已提交,请刷新核对");
|
||||
verifyReviewResources(row);
|
||||
if(n(row.getPackageCount())<=0 || repository.cargo(row.getId()).isEmpty())throw bad("空排车不能提交审核");
|
||||
long round=row.getReviewRound()+1;String basis=basis(row);
|
||||
@@ -716,6 +739,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
if(!Status.ACTIVE.name().equals(row.getStatus()) || !Stage.BEFORE_LOADING.name().equals(row.getStage()) || n(row.getPickedPackageCount())>0 || n(row.getShippedPackageCount())>0) throw conflict("仅装车前且未扫码的有效排车可以调整货物");
|
||||
}
|
||||
|
||||
/** 提交资格和写入共用可重提状态,已进入两级待审的整车不得再次显示提交入口。 */
|
||||
private static boolean canSubmitReview(String reviewState) {
|
||||
return ReviewState.DRAFT.name().equals(reviewState) || ReviewState.DISPATCH_REJECTED.name().equals(reviewState)
|
||||
|| ReviewState.LOADING_REJECTED.name().equals(reviewState);
|
||||
}
|
||||
|
||||
/** 动作快照只反映实际当前资格,写入仍须再次加锁校验。 */
|
||||
Map<Action,Gate> actions(Actor actor,DispatchAggregate row) {
|
||||
var gates=new EnumMap<Action,Gate>(Action.class); boolean active=Status.ACTIVE.name().equals(row.getStatus());
|
||||
@@ -723,7 +752,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
for(var action:Action.values()) {
|
||||
boolean state=switch(action) {
|
||||
case CREATE -> actor.mode()==ActorMode.PLATFORM;
|
||||
case EDIT,ADD_CARGO,REMOVE_CARGO,CANCEL,SUBMIT_REVIEW -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0;
|
||||
case EDIT,ADD_CARGO,REMOVE_CARGO,CANCEL -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0;
|
||||
case SUBMIT_REVIEW -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0 && canSubmitReview(row.getReviewState());
|
||||
case DISPATCH_REVIEW -> active && review==ReviewState.DISPATCH_PENDING;
|
||||
case LOADING_REVIEW -> active && review==ReviewState.LOADING_PENDING;
|
||||
case RETURN_TO_REVIEW -> active && stage==Stage.LOADING && n(row.getPickedPackageCount())==0;
|
||||
|
||||
@@ -337,6 +337,12 @@ public class DispatchService {
|
||||
/** 执行沿用全车申请/订单→共同实托串行顺序;库存变化不重新解释原冻结贡献。 */
|
||||
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public RelabelDispatchSourcePort.Snapshot lockExecutionSources(String warehouse,List<DispatchItem> items) {
|
||||
// 合法空车没有来源或实托可锁;非空明细即使尚未关联实托,仍必须经过原严格校验。
|
||||
if(items.isEmpty()) {
|
||||
if(warehouse==null || warehouse.isBlank())throw new IllegalArgumentException("空排车仍须明确起运仓库");
|
||||
return new RelabelDispatchSourcePort.Snapshot(List.of(),List.of(),List.of(),
|
||||
wholeHashes.source(Map.of("warehouseId",warehouse,"items",List.of())),true,List.of());
|
||||
}
|
||||
var pallets=items.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).collect(Collectors.toCollection(TreeSet::new));
|
||||
var initial=relabelSources.snapshot(warehouse,List.copyOf(pallets));var applications=new TreeSet<String>();
|
||||
initial.applications().forEach(a->applications.add(a.relabelNo()));
|
||||
|
||||
+5
@@ -34,6 +34,11 @@ public class FleetTripExecutionService {
|
||||
private final ObjectMapper json;
|
||||
private final FleetBookingReservations bookingReservations;
|
||||
|
||||
/** 排车域先核对冻结司机及附件引用;本域继续校验该司机当前或历史证照归属。 */
|
||||
public DriverModels.Download dispatchDriverLicense(long driverId,long fileId) {
|
||||
return drivers.download(driverId,fileId);
|
||||
}
|
||||
|
||||
public FleetTripExecutionService(FleetTripMapper trips, FleetTripDispatchMapper bindings, FleetResourceMapper resources,
|
||||
FleetDispatchGateway dispatches, FleetTripService history, DriverService drivers,
|
||||
VehicleRepository vehicles, ObjectMapper json, FleetBookingReservations bookingReservations) {
|
||||
|
||||
+54
-1
@@ -16,6 +16,57 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class DispatchCandidateMapperContractTest {
|
||||
/** 执行正式仓ID的完整CASE及外层过滤,保留实库存优先、客户来源和长ID文本身份。 */
|
||||
@Test
|
||||
void wholeCandidateWarehouseProjectionExecutesAsTextWithoutChangingSourceOwnership() throws Exception {
|
||||
String sql = String.join(" ", DispatchCandidateMapper.class.getMethod("wholeAvailable", String.class,
|
||||
String.class, List.class, String.class, String.class, String.class, String.class).getAnnotation(Select.class).value());
|
||||
int start = sql.indexOf("CAST(CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id)");
|
||||
int end = sql.indexOf(" AS overseasWarehouseId", start);
|
||||
assertTrue(start >= 0 && end > start, "正式whole候选必须在整个仓CASE外统一为文本投影");
|
||||
String expression = sql.substring(start, end).replace(">", ">").replace("<", "<");
|
||||
assertTrue(expression.endsWith("END AS CHAR(128))"));
|
||||
assertTrue(expression.contains("NULLIF(CAST(ip.overseas_warehouse_id AS CHAR(128)),'')"));
|
||||
String warehouseFilter = sql.substring(sql.lastIndexOf(") selected WHERE ") + 1)
|
||||
.replace("#{warehouseId}", "?").replace("</script>", "");
|
||||
String query = "SELECT selected.planPalletId FROM (SELECT p.id AS planPalletId," + expression
|
||||
+ " AS overseasWarehouseId FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id)"
|
||||
+ warehouseFilter;
|
||||
try (var connection = DriverManager.getConnection("jdbc:h2:mem:dispatch-whole-warehouse-text;MODE=MySQL")) {
|
||||
var statement = connection.createStatement();
|
||||
statement.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR(128),overseas_warehouse_id BIGINT,source_order_id BIGINT)");
|
||||
statement.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,warehouse_pallet_id BIGINT)");
|
||||
statement.execute("CREATE TABLE ll_inventory_position(customer_id VARCHAR(128),pallet_id BIGINT,available_packages BIGINT,overseas_warehouse_id VARCHAR(128))");
|
||||
statement.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(128),overseas_warehouse_id VARCHAR(128))");
|
||||
statement.execute("INSERT INTO ll_inbound_plan VALUES (1,'A',193000000000200002,NULL),"
|
||||
+ "(2,'A',193000000000100001,NULL),(3,'B',NULL,20),(4,'A',193000000000100001,NULL),"
|
||||
+ "(5,'B',193000000000200002,NULL),(6,'A',NULL,NULL),(7,'A',193000000000100001,21),"
|
||||
+ "(8,'A',193000000000100001,NULL),(9,'A',NULL,21)");
|
||||
statement.execute("INSERT INTO ll_inbound_plan_pallet VALUES (1,1,10),(2,2,NULL),(3,3,NULL),"
|
||||
+ "(4,4,40),(5,5,50),(6,6,NULL),(7,7,NULL),(8,8,80),(9,9,NULL)");
|
||||
statement.execute("INSERT INTO ll_inventory_position VALUES ('A',10,1,'193000000000100001'),"
|
||||
+ "('B',40,1,'193000000000100001'),('A',40,0,'193000000000100001'),"
|
||||
+ "('B',50,1,'193000000000100001'),('A',80,1,'193000000000200002')");
|
||||
statement.execute("INSERT INTO ll_order VALUES (20,'A','193000000000200002'),"
|
||||
+ "(20,'B','193000000000100001'),(21,'B','193000000000100001')");
|
||||
try (var prepared = connection.prepareStatement(query)) {
|
||||
assertWarehouseRows(prepared, "193000000000100001", List.of(1L, 2L, 3L, 5L, 7L));
|
||||
assertWarehouseRows(prepared, "193000000000200002", List.of(8L));
|
||||
for (String invalid : List.of("19300000000010000", "193000000000100001x", "0193000000000100001", "1.93e17", "wrong"))
|
||||
assertWarehouseRows(prepared, invalid, List.of());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 每次实际执行同一参数化仓过滤,禁止用前缀、数字强转或其他客户库存替代身份。 */
|
||||
private void assertWarehouseRows(java.sql.PreparedStatement prepared, String warehouse, List<Long> expected) throws Exception {
|
||||
prepared.setString(1, warehouse);
|
||||
var actual = new ArrayList<Long>();
|
||||
try (var rows = prepared.executeQuery()) { while (rows.next()) actual.add(rows.getLong(1)); }
|
||||
actual.sort(Long::compareTo);
|
||||
assertEquals(expected, actual, warehouse);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ordinaryAndReturnRelabelCandidatesExcludeDirectOutboundDeliveryModes() throws Exception {
|
||||
for (String method : List.of("relabelAvailable", "nativeReturnRelabelAvailable")) {
|
||||
@@ -200,7 +251,9 @@ class DispatchCandidateMapperContractTest {
|
||||
String sql = String.join(" ", method.getAnnotation(Select.class).value());
|
||||
assertTrue(sql.contains("CASE WHEN p.warehouse_pallet_id IS NOT NULL"));
|
||||
assertTrue(sql.contains("pos.overseas_warehouse_id"));
|
||||
assertTrue(sql.contains("NULLIF(CAST(ip.overseas_warehouse_id AS CHAR),'')"));
|
||||
assertTrue(sql.contains(method.getName().equals("available")
|
||||
? "NULLIF(CAST(ip.overseas_warehouse_id AS CHAR(128)),'')"
|
||||
: "NULLIF(CAST(ip.overseas_warehouse_id AS CHAR),'')"));
|
||||
assertTrue(sql.contains("source_order.id=ip.source_order_id"));
|
||||
assertTrue(sql.contains("source_order.customer_id=ip.customer_id"));
|
||||
assertTrue(sql.contains("source_order.overseas_warehouse_id"));
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package com.baiyue.lastleg.dispatch.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/** 实际Mapper查询仓/客户/实托交集;MySQL不同排序规则另由只读真实库探针验证。 */
|
||||
class DispatchSourceMapperWarehouseBindingTest {
|
||||
private static final long WAREHOUSE = 193000000000200003L;
|
||||
private static final long PHYSICAL_PALLET = 2112349761578209281L;
|
||||
private static final long PLAN = 2112349764502786049L;
|
||||
private static final long PLAN_PALLET = 2112349764502786050L;
|
||||
private static final String CUSTOMER = "2112349740878327809";
|
||||
private static final String SECOND_CUSTOMER = "2112349740878327810";
|
||||
private JdbcTemplate db;
|
||||
private DispatchSourceMapper mapper;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
var data = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID()
|
||||
+ ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
db = new JdbcTemplate(data);
|
||||
// 仅保留本查询所需列,类型/长度与lastleg.sql一致:计划仓BIGINT,库存仓VARCHAR(128)。
|
||||
db.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),overseas_warehouse_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT PRIMARY KEY,plan_id BIGINT,customer_id VARCHAR(128),warehouse_pallet_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_inventory_position(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),overseas_warehouse_id VARCHAR(128),pallet_id BIGINT)");
|
||||
plan(PLAN, PLAN_PALLET, CUSTOMER, CUSTOMER, WAREHOUSE, PHYSICAL_PALLET);
|
||||
position(1, CUSTOMER, Long.toString(WAREHOUSE), PHYSICAL_PALLET);
|
||||
var config = new MybatisConfiguration();
|
||||
config.addMapper(DispatchSourceMapper.class);
|
||||
var factory = new MybatisSqlSessionFactoryBean();
|
||||
factory.setDataSource(data);
|
||||
factory.setConfiguration(config);
|
||||
mapper = new SqlSessionTemplate(factory.getObject()).getMapper(DispatchSourceMapper.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void longStringStockWarehouseMatchesTheSameNumericPlanWarehouseWithoutLosingIdentity() {
|
||||
assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE), List.of(PHYSICAL_PALLET)))
|
||||
.containsExactly(new DispatchSourceMapper.PlanReference(PLAN_PALLET, CUSTOMER));
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestedWarehouseMustMatchTheRealStockPosition() {
|
||||
assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE + 1), List.of(PHYSICAL_PALLET))).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void matchingRequestedStockWarehouseDoesNotAuthorizeAPlanInAnotherWarehouse() {
|
||||
db.update("UPDATE ll_inbound_plan SET overseas_warehouse_id=? WHERE id=?", WAREHOUSE + 1, PLAN);
|
||||
assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE), List.of(PHYSICAL_PALLET))).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void samePhysicalPalletCannotBorrowAnotherCustomersInventoryPosition() {
|
||||
db.update("UPDATE ll_inventory_position SET customer_id=? WHERE id=1", SECOND_CUSTOMER);
|
||||
assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE), List.of(PHYSICAL_PALLET))).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void matchingHeaderAndPositionCannotBypassThePlanPalletCustomer() {
|
||||
db.update("UPDATE ll_inbound_plan_pallet SET customer_id=? WHERE id=?", SECOND_CUSTOMER, PLAN_PALLET);
|
||||
assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE), List.of(PHYSICAL_PALLET))).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void completeSharedPalletCompositionRetainsBothSeparatelyOwnedCustomerPlans() {
|
||||
plan(PLAN + 10, PLAN_PALLET + 10, SECOND_CUSTOMER, SECOND_CUSTOMER, WAREHOUSE, PHYSICAL_PALLET);
|
||||
position(2, SECOND_CUSTOMER, Long.toString(WAREHOUSE), PHYSICAL_PALLET);
|
||||
// 无本仓库存的第三份计划不能混入当前组成。
|
||||
plan(PLAN + 20, PLAN_PALLET + 20, "2112349740878327811", "2112349740878327811", WAREHOUSE, PHYSICAL_PALLET);
|
||||
assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE), List.of(PHYSICAL_PALLET)))
|
||||
.containsExactly(new DispatchSourceMapper.PlanReference(PLAN_PALLET, CUSTOMER),
|
||||
new DispatchSourceMapper.PlanReference(PLAN_PALLET + 10, SECOND_CUSTOMER));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMatchingWarehouseAndCustomerCannotBypassTheRequestedPhysicalPalletSet() {
|
||||
assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE), List.of(PHYSICAL_PALLET + 1))).isEmpty();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"0193000000000200003", "193000000000200003x", "193000000000200003.0"})
|
||||
void pseudoWarehouseIdentifiersAreNotAcceptedThroughNumericCoercion(String pseudoWarehouse) {
|
||||
db.update("UPDATE ll_inventory_position SET overseas_warehouse_id=? WHERE id=1", pseudoWarehouse);
|
||||
// 请求与库存字符串确实相同;必须仍因不等于计划仓的完整字符身份而拒绝。
|
||||
assertThat(mapper.physicalPlans(pseudoWarehouse, List.of(PHYSICAL_PALLET))).isEmpty();
|
||||
}
|
||||
|
||||
private void plan(long planId, long planPalletId, String headerCustomer, String palletCustomer,
|
||||
long warehouse, long physicalPallet) {
|
||||
db.update("INSERT INTO ll_inbound_plan VALUES(?,?,?)", planId, headerCustomer, warehouse);
|
||||
db.update("INSERT INTO ll_inbound_plan_pallet VALUES(?,?,?,?)", planPalletId, planId, palletCustomer, physicalPallet);
|
||||
}
|
||||
|
||||
private void position(long positionId, String customer, String warehouse, long pallet) {
|
||||
db.update("INSERT INTO ll_inventory_position VALUES(?,?,?,?)", positionId, customer, warehouse, pallet);
|
||||
}
|
||||
}
|
||||
+218
@@ -35,6 +35,166 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
|
||||
/** 主单/资源/占用/账本真实SQL回归;外部目录与选货权威提供者使用受控输入。 */
|
||||
public class DispatchAggregatePersistenceTest {
|
||||
/** 正式司机附件从排车冻结引用选出真实司机,再复用司机域及File的读取边界。 */
|
||||
@Test void masterDriverEvidenceUsesFrozenDriverOwnerForMetadataAndContent() throws Exception {
|
||||
var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,71L,List.of(81L));
|
||||
e.allowFile(81L);var before=e.f.db.queryForMap("SELECT resources_json,record_version FROM ll_dispatch_order WHERE dispatch_no=?",number);
|
||||
assertThat(e.f.service.evidenceMetadata(e.f.actor,number,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,81L).fileId()).isEqualTo(81L);
|
||||
assertThat(e.f.service.evidenceContent(e.f.actor,number,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,81L)).containsExactly(1,2,3);
|
||||
verify(e.files,times(2)).metadata(71L,81L);verify(e.files,times(2)).download(71L,81L);verifyNoInteractions(e.dispatchFiles);
|
||||
assertThat(e.f.db.queryForMap("SELECT resources_json,record_version FROM ll_dispatch_order WHERE dispatch_no=?",number)).isEqualTo(before);
|
||||
}
|
||||
|
||||
/** 主档后来换证不改写本车冻结证照;旧附件必须仍满足该司机的历史关联规则。 */
|
||||
@Test void frozenHistoricalDriverLicenseRemainsReadableAfterMasterReplacement() throws Exception {
|
||||
var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,71L,List.of(81L));
|
||||
e.driver.setLicenseFileId(82L);when(e.history.selectCount(any())).thenReturn(1L);e.allowFile(81L);
|
||||
assertThat(e.f.service.evidenceMetadata(e.f.actor,number,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,81L).fileId()).isEqualTo(81L);
|
||||
assertThat(e.f.service.evidenceContent(e.f.actor,number,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,81L)).containsExactly(1,2,3);
|
||||
verify(e.history,times(2)).selectCount(any());verify(e.files,times(2)).metadata(71L,81L);verifyNoInteractions(e.dispatchFiles);
|
||||
}
|
||||
|
||||
/** 外配附件仍限本车证据;不能因为共用DRIVER展示类型而改走正式司机档案。 */
|
||||
@Test void externalDriverEvidenceRetainsExactDispatchOwnerForBothReads() throws Exception {
|
||||
var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.EXTERNAL,null,List.of(81L));
|
||||
var dispatch=e.f.repository.find(number).getId();var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER;
|
||||
var metadata=new com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File(81L,"external.pdf","application/pdf",3L);
|
||||
when(e.dispatchFiles.metadata(dispatch,kind,81L)).thenReturn(metadata);when(e.dispatchFiles.download(dispatch,kind,81L)).thenReturn(new byte[]{4,5,6});
|
||||
assertThat(e.f.service.evidenceMetadata(e.f.actor,number,kind,81L)).isEqualTo(metadata);
|
||||
assertThat(e.f.service.evidenceContent(e.f.actor,number,kind,81L)).containsExactly(4,5,6);
|
||||
verify(e.dispatchFiles).metadata(dispatch,kind,81L);verify(e.dispatchFiles).download(dispatch,kind,81L);
|
||||
verifyNoInteractions(e.driverRows,e.history,e.files);
|
||||
}
|
||||
|
||||
/** 冻结引用不绕过司机当前或历史关联,提供者明确拒绝后不得尝试排车类别。 */
|
||||
@Test void unlinkedFrozenMasterFileCannotFallBackToDispatchEvidence() throws Exception {
|
||||
var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,71L,List.of(81L));
|
||||
e.driver.setLicenseFileId(82L);when(e.history.selectCount(any())).thenReturn(0L);
|
||||
var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER;
|
||||
assertThatThrownBy(()->e.f.service.evidenceMetadata(e.f.actor,number,kind,81L)).hasMessageContaining("未关联");
|
||||
assertThatThrownBy(()->e.f.service.evidenceContent(e.f.actor,number,kind,81L)).hasMessageContaining("未关联");
|
||||
verifyNoInteractions(e.files,e.dispatchFiles);
|
||||
}
|
||||
|
||||
/** File拒绝主档四元组时直接失败,不以另一业务类别或其他司机重新读取。 */
|
||||
@Test void masterFileProviderRejectionCannotFallBackToAnotherCategory() throws Exception {
|
||||
var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,71L,List.of(81L));
|
||||
when(e.files.metadata(71L,81L)).thenThrow(new BizException(com.baiyue.common.core.api.ResultCode.NOT_FOUND,"业务附件不存在或归属不匹配"));
|
||||
var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER;
|
||||
assertThatThrownBy(()->e.f.service.evidenceMetadata(e.f.actor,number,kind,81L)).hasMessageContaining("归属不匹配");
|
||||
assertThatThrownBy(()->e.f.service.evidenceContent(e.f.actor,number,kind,81L)).hasMessageContaining("归属不匹配");
|
||||
verify(e.files,never()).download(anyLong(),anyLong());verifyNoInteractions(e.dispatchFiles);
|
||||
}
|
||||
|
||||
/** MASTER改外配的编辑尚未保存时,新上传文件只走本车归属,不误借旧司机白名单。 */
|
||||
@Test void unsavedExternalUploadOnMasterDispatchRetainsOriginalDispatchFileReads() throws Exception {
|
||||
var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,71L,List.of(81L));
|
||||
var dispatch=e.f.repository.find(number).getId();var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER;
|
||||
var metadata=new com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File(91L,"new-external.pdf","application/pdf",3L);
|
||||
when(e.dispatchFiles.metadata(dispatch,kind,91L)).thenReturn(metadata);when(e.dispatchFiles.download(dispatch,kind,91L)).thenReturn(new byte[]{4,5,6});
|
||||
assertThat(e.f.service.evidenceMetadata(e.f.actor,number,kind,91L)).isEqualTo(metadata);
|
||||
assertThat(e.f.service.evidenceContent(e.f.actor,number,kind,91L)).containsExactly(4,5,6);
|
||||
verifyNoInteractions(e.driverRows,e.history,e.files);
|
||||
assertThat(e.f.db.queryForObject("SELECT resources_json FROM ll_dispatch_order WHERE dispatch_no=?",String.class,number)).contains("MASTER").doesNotContain("91");
|
||||
}
|
||||
|
||||
/** 未列入MASTER冻结集合或声明为外配的档案文件只能接受本车四元组核验,失败后不转司机域。 */
|
||||
@Test void foreignOrWrongModeMasterFilesNeverGainDriverDomainAccess() throws Exception {
|
||||
for(var mode:List.of(ResourceMode.MASTER,ResourceMode.EXTERNAL)) {
|
||||
var e=new DriverEvidenceFixture();var number=e.dispatch(mode,71L,mode==ResourceMode.MASTER?List.of(81L):List.of(91L));
|
||||
var dispatch=e.f.repository.find(number).getId();var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER;
|
||||
when(e.dispatchFiles.metadata(dispatch,kind,91L)).thenThrow(new BizException(com.baiyue.common.core.api.ResultCode.NOT_FOUND,"业务附件不存在或归属不匹配"));
|
||||
when(e.dispatchFiles.download(dispatch,kind,91L)).thenThrow(new BizException(com.baiyue.common.core.api.ResultCode.NOT_FOUND,"业务附件不存在或归属不匹配"));
|
||||
assertThatThrownBy(()->e.f.service.evidenceMetadata(e.f.actor,number,kind,91L)).hasMessageContaining("归属不匹配");
|
||||
assertThatThrownBy(()->e.f.service.evidenceContent(e.f.actor,number,kind,91L)).hasMessageContaining("归属不匹配");
|
||||
verifyNoInteractions(e.driverRows,e.history,e.files);
|
||||
}
|
||||
}
|
||||
|
||||
/** 命中冻结文件集合仍须有本车真实正数司机身份,不能拿车号或其他司机替代。 */
|
||||
@Test void masterFileWithMissingOrInvalidDriverIdentityFailsBeforeAnyFileProvider() throws Exception {
|
||||
for(Long driverId:Arrays.asList(null,0L,-1L)) {
|
||||
var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,driverId,List.of(81L));
|
||||
var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER;
|
||||
assertThatThrownBy(()->e.f.service.evidenceMetadata(e.f.actor,number,kind,81L)).hasMessageContaining("身份无效");
|
||||
assertThatThrownBy(()->e.f.service.evidenceContent(e.f.actor,number,kind,81L)).hasMessageContaining("身份无效");
|
||||
verifyNoInteractions(e.driverRows,e.history,e.files,e.dispatchFiles);
|
||||
}
|
||||
}
|
||||
|
||||
/** 无法解释的资源模式不选择任何文件域,防止异常快照放宽读取。 */
|
||||
@Test void unknownDriverModeFailsBeforeEitherFileProvider() throws Exception {
|
||||
var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,71L,List.of(81L));
|
||||
e.f.db.update("UPDATE ll_dispatch_order SET resources_json=REPLACE(resources_json,'MASTER','UNKNOWN') WHERE dispatch_no=?",number);
|
||||
var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER;
|
||||
assertThatThrownBy(()->e.f.service.evidenceMetadata(e.f.actor,number,kind,81L)).hasMessageContaining("快照读取失败");
|
||||
assertThatThrownBy(()->e.f.service.evidenceContent(e.f.actor,number,kind,81L)).hasMessageContaining("快照读取失败");
|
||||
verifyNoInteractions(e.driverRows,e.history,e.files,e.dispatchFiles);
|
||||
}
|
||||
|
||||
/** 使用真实排车Mapper/资源JSON及司机Service,只替代外部文件内容和主档持久提供者。 */
|
||||
private static final class DriverEvidenceFixture {
|
||||
final Fixture f;final DriverMapper driverRows=mock(DriverMapper.class);final DriverHistoryMapper history=mock(DriverHistoryMapper.class);
|
||||
final DriverFileGateway files=mock(DriverFileGateway.class);final DriverEntity driver=new DriverEntity();
|
||||
final com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway dispatchFiles;
|
||||
/** 真实司机Service继续执行当前/历史附件关联检查,不由排车测试替身伪装通过。 */
|
||||
DriverEvidenceFixture() throws Exception {
|
||||
f=new Fixture();driver.setId(71L);driver.setLicenseFileId(81L);when(driverRows.selectById(71L)).thenReturn(driver);
|
||||
var drivers=new DriverService(driverRows,history,files,f.json,mock(FleetWarehouseDirectory.class));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(f.fleet,"drivers",drivers);
|
||||
dispatchFiles=(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway)org.springframework.test.util.ReflectionTestUtils.getField(f.service,"evidence");
|
||||
}
|
||||
/** 在真实主单保存服务端资源快照,HTTP请求本身不能指定司机、模式或允许文件集合。 */
|
||||
String dispatch(ResourceMode mode,Long driverId,List<Long> ids) throws Exception {
|
||||
var created=f.tx.execute(t->f.service.create(f.actor,f.create("evidence-"+UUID.randomUUID(),List.of())));
|
||||
var resource=new DispatchContracts.Resources(null,new DispatchContracts.DriverSnapshot(mode,driverId,"测试司机","123456789",
|
||||
new DispatchContracts.DriverEvidence("TEST-LICENSE",LocalDate.of(2099,1,1),ids,Instant.parse("2098-01-01T00:00:00Z"))));
|
||||
f.db.update("UPDATE ll_dispatch_order SET resources_json=? WHERE dispatch_no=?",f.json.writeValueAsString(resource),created.dispatchNo());return created.dispatchNo();
|
||||
}
|
||||
/** File内容固定为离线测试字节;真实复合归属由既有两个HTTP网关合同分别覆盖。 */
|
||||
void allowFile(long fileId) {
|
||||
var metadata=new DriverModels.File(Long.toString(fileId),"driver.pdf","application/pdf",3L);
|
||||
when(files.metadata(71L,fileId)).thenReturn(metadata);when(files.download(71L,fileId)).thenReturn(new byte[]{1,2,3});
|
||||
}
|
||||
}
|
||||
/** 空单零版本取消使用真实来源规则,成功及原键重放均不制造来源、库存或重复命令。 */
|
||||
@Test void emptyVersionZeroCancellationUsesRealSourceRuleAndPreservesOriginalReplay() throws Exception {
|
||||
var f=new Fixture();var created=f.tx.execute(t->f.service.create(f.actor,f.create("empty-real-cancel",List.of())));
|
||||
var rules=new DispatchService();var facts=mock(com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.class);
|
||||
var sources=mock(DispatchSourceRepository.class);var serial=mock(com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort.class);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(rules,"wholeHashes",new DispatchCommandHasher(f.json));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(rules,"relabelSources",facts);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(rules,"wholeSources",sources);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(rules,"palletSerial",serial);
|
||||
when(facts.snapshot(anyString(),anyList())).thenThrow(new IllegalArgumentException("仓库和实托集合不能为空"));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(f.service,"sourceRules",rules);
|
||||
assertThat(created.recordVersion()).isZero();
|
||||
var request=new DispatchCommands.ReasonCommand(0L,"empty-cancel-original","取消尚未添加货物的空车");
|
||||
var cancelled=f.tx.execute(t->f.service.cancel(f.actor,created.dispatchNo(),request));
|
||||
assertThat(cancelled.status()).isEqualTo(Status.CANCELLED);assertThat(cancelled.stage()).isEqualTo(Stage.BEFORE_LOADING);
|
||||
assertThat(f.tx.execute(t->f.service.cancel(f.actor,created.dispatchNo(),request)).replayed()).isTrue();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='empty-cancel-original'",Long.class)).isOne();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item",Long.class)).isZero();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock",Long.class)).isZero();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock",Long.class)).isZero();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class)).isZero();
|
||||
assertThat(f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("CANCELLED");
|
||||
verifyNoInteractions(facts,sources,serial);
|
||||
}
|
||||
|
||||
/** 空单编辑保留零货量与版本命令,不借编辑隐式制造可装车来源。 */
|
||||
@Test void emptyShellEditPreservesZeroCargoAndVersionedReplay() throws Exception {
|
||||
var f=new Fixture();var created=f.tx.execute(t->f.service.create(f.actor,f.create("empty-edit",List.of())));
|
||||
var request=new DispatchCommands.Update(0L,"empty-edit-original",null,null,null,null,"空单补充备注");
|
||||
var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request));
|
||||
assertThat(saved.stage()).isEqualTo(Stage.BEFORE_LOADING);assertThat(saved.counts().packageCount()).isZero();
|
||||
assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion());
|
||||
assertThat(f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request)).replayed()).isTrue();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item",Long.class)).isZero();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='empty-edit-original'",Long.class)).isOne();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class)).isZero();
|
||||
}
|
||||
|
||||
/** 移货、取消只释放本车原占用;重排后旧键恢复不得释放新车的活动实托键。 */
|
||||
@Test void removalCancellationAndOldCommandRecoveryNeverReleaseNewTruckClaims() throws Exception {
|
||||
var f=new Fixture();var a=f.source(1,"101",501,"ONT8",3);var b=f.source(2,"202",502,"LAX9",5);
|
||||
@@ -127,6 +287,64 @@ public class DispatchAggregatePersistenceTest {
|
||||
assertThatThrownBy(()->f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),new DispatchCommands.LoadingComplete(approved.recordVersion(),"missing-rejected",preview.manifestHash(),List.of(),null)))).isInstanceOf(BizException.class).hasMessageContaining("核平");
|
||||
assertThat(f.db.queryForObject("SELECT stage FROM ll_dispatch_order",String.class)).isEqualTo("LOADING");
|
||||
}
|
||||
/** 已提交根的公开资格关闭;新键重复提交不改变事实,原键仍先于当前状态恢复结果。 */
|
||||
@Test void pendingSubmissionGateRejectsNewKeyWithoutMutationAndRestoresOriginalCommand() throws Exception {
|
||||
var security=org.springframework.security.core.context.SecurityContextHolder.getContext();var previous=security.getAuthentication();
|
||||
security.setAuthentication(new org.springframework.security.authentication.TestingAuthenticationToken("operator-test","",
|
||||
"platform:overseas-warehouse:dispatch:list","platform:overseas-warehouse:dispatch:edit"));
|
||||
try {
|
||||
var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3);
|
||||
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("submission-gate-create",List.of(source))));
|
||||
var request=new DispatchCommands.SubmitReview(created.recordVersion(),"submission-gate-original",null);
|
||||
var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),request));
|
||||
var detail=f.tx.execute(s->f.service.detail(f.actor,created.dispatchNo()));
|
||||
assertThat(detail.reviewState()).isEqualTo(ReviewState.DISPATCH_PENDING);
|
||||
assertThat(detail.actions().get(Action.SUBMIT_REVIEW).allowed()).isFalse();
|
||||
var before=f.json.writeValueAsString(f.repository.find(created.dispatchNo()));
|
||||
var commands=f.db.queryForList("SELECT id,idempotency_key,record_version,command_hash FROM ll_dispatch_command ORDER BY id");
|
||||
assertThatThrownBy(()->f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),
|
||||
new DispatchCommands.SubmitReview(detail.recordVersion(),"submission-gate-duplicate",null))))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("当前审核已提交");
|
||||
assertThat(f.json.writeValueAsString(f.repository.find(created.dispatchNo()))).isEqualTo(before);
|
||||
assertThat(f.db.queryForList("SELECT id,idempotency_key,record_version,command_hash FROM ll_dispatch_command ORDER BY id")).isEqualTo(commands);
|
||||
var replay=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),request));
|
||||
assertThat(replay.replayed()).isTrue();assertThat(replay.recordVersion()).isEqualTo(submitted.recordVersion());
|
||||
assertThat(replay.result()).isEqualTo(submitted.result());
|
||||
assertThat(f.json.writeValueAsString(f.repository.find(created.dispatchNo()))).isEqualTo(before);
|
||||
assertThat(f.db.queryForList("SELECT id,idempotency_key,record_version,command_hash FROM ll_dispatch_command ORDER BY id")).isEqualTo(commands);
|
||||
} finally { security.setAuthentication(previous); }
|
||||
}
|
||||
|
||||
/** 真实两级审核流证明三种可重提状态开放,而两种待审状态均关闭提交资格。 */
|
||||
@Test void submissionActionMatchesDraftRejectedAndPendingReviewStates() throws Exception {
|
||||
var security=org.springframework.security.core.context.SecurityContextHolder.getContext();var previous=security.getAuthentication();
|
||||
security.setAuthentication(new org.springframework.security.authentication.TestingAuthenticationToken("operator-test","",
|
||||
"platform:overseas-warehouse:dispatch:list","platform:overseas-warehouse:dispatch:edit",
|
||||
"platform:fleet:trip:dispatch-review","platform:fleet:trip:loading-review"));
|
||||
try {
|
||||
var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3);
|
||||
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("submission-states-create",List.of(source))));
|
||||
assertSubmissionGate(f,created.dispatchNo(),ReviewState.DRAFT,true);
|
||||
var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submission-states-1",null)));
|
||||
assertSubmissionGate(f,created.dispatchNo(),ReviewState.DISPATCH_PENDING,false);
|
||||
var rejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"submission-states-dispatch-rejected",Decision.REJECTED,"核对调度")));
|
||||
assertSubmissionGate(f,created.dispatchNo(),ReviewState.DISPATCH_REJECTED,true);
|
||||
var resubmitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(rejected.recordVersion(),"submission-states-2",null)));
|
||||
var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(resubmitted.recordVersion(),"submission-states-dispatch-approved",Decision.APPROVED,null)));
|
||||
assertSubmissionGate(f,created.dispatchNo(),ReviewState.LOADING_PENDING,false);
|
||||
var loadingRejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"submission-states-loading-rejected",Decision.REJECTED,"核对装载")));
|
||||
assertSubmissionGate(f,created.dispatchNo(),ReviewState.LOADING_REJECTED,true);
|
||||
f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(loadingRejected.recordVersion(),"submission-states-3",null)));
|
||||
assertSubmissionGate(f,created.dispatchNo(),ReviewState.DISPATCH_PENDING,false);
|
||||
} finally { security.setAuthentication(previous); }
|
||||
}
|
||||
|
||||
/** 从真实详情DTO读取动作资格,避免只测内部枚举或权限缺失导致的假阴性。 */
|
||||
private static void assertSubmissionGate(Fixture f,String number,ReviewState state,boolean allowed) {
|
||||
var detail=f.tx.execute(s->f.service.detail(f.actor,number));
|
||||
assertThat(detail.reviewState()).isEqualTo(state);assertThat(detail.actions().get(Action.SUBMIT_REVIEW).allowed()).isEqualTo(allowed);
|
||||
}
|
||||
|
||||
/** 两级顺序、驳回重提、原结果恢复和零扫码退回均实际提交到同一整车。 */
|
||||
@Test void reviewsUseActualRootRoundAndImmutableCommandsBeforeAndAfterLoading() throws Exception {
|
||||
var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3);
|
||||
|
||||
+30
-1
@@ -18,15 +18,44 @@ class DispatchServiceTest {
|
||||
final DispatchDraftMapper drafts=mock(DispatchDraftMapper.class);
|
||||
final RelabelApplicationMapper relabels=mock(RelabelApplicationMapper.class);
|
||||
final RelabelPalletMeasurementService measurements=mock(RelabelPalletMeasurementService.class);
|
||||
final com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort facts=mock(com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.class);
|
||||
final com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort palletSerial=mock(com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort.class);
|
||||
@BeforeEach void setup(){
|
||||
ReflectionTestUtils.setField(service,"wholeSources",sources);ReflectionTestUtils.setField(service,"wholeHashes",new DispatchCommandHasher(new com.fasterxml.jackson.databind.ObjectMapper()));
|
||||
ReflectionTestUtils.setField(service,"candidates",candidates);ReflectionTestUtils.setField(service,"drafts",drafts);
|
||||
ReflectionTestUtils.setField(service,"relabelApplications",relabels);ReflectionTestUtils.setField(service,"relabelMeasurements",measurements);
|
||||
ReflectionTestUtils.setField(service,"relabelBindingGuard",mock(RelabelBindingGuard.class));ReflectionTestUtils.setField(service,"returnRelabelStock",mock(ReturnRelabelDispatchStock.class));
|
||||
var facts=mock(com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.class);
|
||||
// 本用例只验证完整选择的早期拒绝;实际贡献/HOLD由持久化主链覆盖。
|
||||
when(facts.snapshot(anyString(),anyList())).thenReturn(new com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.Snapshot(List.of(),List.of(),List.of(),"CONTROLLED",true,List.of()));
|
||||
ReflectionTestUtils.setField(service,"relabelSources",facts);
|
||||
ReflectionTestUtils.setField(service,"palletSerial",palletSerial);
|
||||
}
|
||||
/** 空车返回仓范围内的确定空快照,不对不存在的申请、来源或实托取得锁。 */
|
||||
@Test void emptyExecutionSourcesHaveWarehouseScopedSnapshotWithoutAnySourceLocks(){
|
||||
var empty=service.lockExecutionSources("9",List.of());
|
||||
assertThat(empty.complete()).isTrue();assertThat(empty.applications()).isEmpty();assertThat(empty.sources()).isEmpty();
|
||||
assertThat(empty.positions()).isEmpty();assertThat(empty.blockReasons()).isEmpty();
|
||||
assertThat(empty.compositionVersion()).matches("[0-9a-f]{64}");
|
||||
assertThat(service.lockExecutionSources("9",List.of()).compositionVersion()).isEqualTo(empty.compositionVersion());
|
||||
assertThat(service.lockExecutionSources("10",List.of()).compositionVersion()).isNotEqualTo(empty.compositionVersion());
|
||||
verifyNoInteractions(facts,sources,palletSerial,relabels,measurements);
|
||||
}
|
||||
/** 空车也不能缺失起运仓,不能把无范围请求解释成合法空来源。 */
|
||||
@Test void emptyExecutionSourcesStillRequireWarehouse(){
|
||||
for(String warehouse:Arrays.asList(null,""," "))assertThatThrownBy(()->service.lockExecutionSources(warehouse,List.of()))
|
||||
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("起运仓库");
|
||||
verifyNoInteractions(facts,sources,palletSerial);
|
||||
}
|
||||
/** 非空货物缺实托或提供者拒绝时保留原错误,不能借空车分支绕过闭包。 */
|
||||
@Test void nonemptyExecutionSourcesRetainStrictProviderValidation(){
|
||||
var item=new com.baiyue.lastleg.dispatch.entity.DispatchItem();
|
||||
when(facts.snapshot(eq("9"),eq(List.of()))).thenThrow(new IllegalArgumentException("仓库和实托集合不能为空"));
|
||||
assertThatThrownBy(()->service.lockExecutionSources("9",List.of(item))).hasMessageContaining("实托集合不能为空");
|
||||
verify(facts).snapshot("9",List.of());
|
||||
item.setWarehousePalletId(501L);
|
||||
when(facts.snapshot("9",List.of(501L))).thenThrow(new IllegalStateException("完整实托来源已变化"));
|
||||
assertThatThrownBy(()->service.lockExecutionSources("9",List.of(item))).hasMessageContaining("完整实托来源已变化");
|
||||
verify(facts).snapshot("9",List.of(501L));verifyNoInteractions(sources,palletSerial);
|
||||
}
|
||||
@Test void anotherOperatorsDraftIsRejectedBeforeAnyCandidateRead(){
|
||||
assertThatThrownBy(()->service.wholeCandidates("operator",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"PLAN","foreign"))).hasMessageContaining("草稿");verifyNoInteractions(sources);
|
||||
|
||||
@@ -76,10 +76,14 @@ function model(fault = '', countPatch = {}) {
|
||||
if (path === '/api/merchant/v1/iam/tenant/profile') return response({ id: fault === 'foreign login' ? 'OTHER' : merchant.id })
|
||||
if (path === '/api/merchant/v1/last-leg/warehouse-transfers') {
|
||||
assert.ok(merchant); assert.equal(body.inboundCode, undefined); assert.deepEqual(body.sourceLastlegOrderIds, [])
|
||||
assert.equal(body.needRelabel, false); assert.equal(body.items.length, 1); assert.equal(body.items[0].packageCount, 1)
|
||||
assert.equal(body.needRelabel, false); assert.equal(body.items.length, 1); assert.equal(body.items[0].packageCount, 1); assert.equal(body.items[0].quantity, 1)
|
||||
const transferNo = `IN${transfers.length}`
|
||||
const itemId = `FORECAST-ITEM${transfers.length}`
|
||||
const transfer = { transferNo, inboundCode: transferNo, customerId: merchant.id, operatingWarehouseId: body.operatingWarehouseId,
|
||||
status: 'DRAFT', pallets: [] }
|
||||
status: 'DRAFT', packageCount: 1, pallets: [],
|
||||
items: [{ ...copy(body.items[0]), id: itemId, ...(fault === 'missing declared quantity' ? { quantity: null } : {}) }],
|
||||
cartons: fault === 'missing forecast carton' ? [] : [{ cartonMark: `BX-FORECAST${transfers.length}`, status: 'FORECAST',
|
||||
transferItemId: fault === 'foreign forecast item' ? 'OTHER-ITEM' : itemId }] }
|
||||
transfers.push(transfer); return response(transfer)
|
||||
}
|
||||
if (path.includes('/warehouse-transfers/')) {
|
||||
@@ -88,10 +92,39 @@ function model(fault = '', countPatch = {}) {
|
||||
if (path.startsWith('/api/merchant/')) assert.equal(transfer.customerId, merchant.id)
|
||||
if (path.endsWith('/submit')) transfer.status = 'PENDING_REVIEW'
|
||||
else if (path.endsWith('/review')) { assert.equal(url.searchParams.get('customerId'), transfer.customerId); assert.equal(body.decision, 'APPROVE'); transfer.status = 'WAIT_PDA_RECEIVING' }
|
||||
else if (path.endsWith('/pallets/drafts')) transfer.pallets = [{ id: `DRAFT${n}`, status: 'DRAFT', cartonCount: body.cartonCount }]
|
||||
else if (path.endsWith('/pallets/drafts')) {
|
||||
// The actual PalletDraft DTO accepts identity only; never derive measurement from ignored request fields.
|
||||
assert.deepEqual(Object.keys(body), ['idempotencyKey'])
|
||||
transfer.pallets = [{ id: `DRAFT${n}`, status: 'DRAFT', cartonCount: 0, lengthCm: 0, widthCm: 0, heightCm: 0, volumeCbm: 0, grossWeightKg: 0 }]
|
||||
}
|
||||
else if (path.endsWith('/generate')) Object.assign(transfer.pallets[0], { status: 'GENERATED', warehousePalletId: `PAL${n}`, palletNo: `PALLET${n}` })
|
||||
else if (path.endsWith('/bind')) { assert.equal(body.palletCode, transfer.pallets[0].palletNo); transfer.pallets[0].status = 'BOUND' }
|
||||
else if (path.endsWith('/palletize')) { transfer.status = 'WAIT_CUSTOMER_CONFIRMATION'; transfer.pallets[0].status = 'COMPLETED' }
|
||||
else if (path.endsWith('/cartons/count')) {
|
||||
assert.equal(method, 'POST'); assert.equal(transfer.pallets[0].status, 'BOUND')
|
||||
assert.equal(transfer.pallets[0].cartonCount, 0); assert.equal(body.forecastPackageCount, transfer.packageCount)
|
||||
assert.equal(body.boxes.length, 1)
|
||||
const box = body.boxes[0], item = transfer.items[0], carton = transfer.cartons[0], pallet = transfer.pallets[0]
|
||||
assert.equal(box.cartonMark, carton.cartonMark); assert.equal(box.transferItemId, item.id)
|
||||
assert.equal(box.transferPalletId, pallet.id); assert.notEqual(box.transferPalletId, pallet.warehousePalletId)
|
||||
assert.deepEqual([box.length, box.width, box.height, box.weight], [100, 100, 100, 100])
|
||||
assert.deepEqual(box.items, [{ skuCode: item.skuCode, quantity: item.quantity }])
|
||||
Object.assign(carton, { transferPalletId: pallet.id, status: 'COUNTED', countedAt: '2026-10-20T09:00:00',
|
||||
length: box.length, width: box.width, height: box.height, weight: box.weight, quantity: 1,
|
||||
items: [{ transferItemId: item.id, skuCode: item.skuCode, quantity: 1 }] })
|
||||
Object.assign(pallet, { cartonCount: 1, volumeCbm: 1, grossWeightKg: 100 })
|
||||
if (fault === 'uncounted carton') { carton.status = 'FORECAST'; carton.countedAt = null }
|
||||
if (fault === 'wrong counted pallet') carton.transferPalletId = 'OTHER-PALLET'
|
||||
if (fault === 'wrong counted sku') carton.items[0].skuCode = 'OTHER-SKU'
|
||||
if (fault === 'missing carton measurement') carton.weight = null
|
||||
if (fault === 'zero counted pallet') pallet.cartonCount = 0
|
||||
if (fault === 'wrong measured pallet weight') pallet.grossWeightKg = 0
|
||||
}
|
||||
else if (path.endsWith('/palletize')) {
|
||||
assert.equal(transfer.cartons.length, 1); assert.equal(transfer.cartons[0].status, 'COUNTED'); assert.ok(transfer.cartons[0].countedAt)
|
||||
assert.equal(transfer.pallets[0].cartonCount, 1); assert.equal(transfer.pallets[0].grossWeightKg, 100)
|
||||
assert.equal(transfer.pallets[0].volumeCbm, 1); assert.ok(body.photoFileIds.length)
|
||||
transfer.status = 'WAIT_CUSTOMER_CONFIRMATION'; transfer.pallets[0].status = 'COMPLETED'
|
||||
}
|
||||
else if (path.endsWith('/confirm-measurement')) transfer.status = 'READY_FOR_DISPATCH'
|
||||
else assert.equal(method, 'GET')
|
||||
return response(transfer)
|
||||
@@ -182,13 +215,26 @@ test('real helpers build independent current-run customers, receipts and one com
|
||||
assert.equal(proof.status, 'READY'); assert.deepEqual(proof.sessionCleanup.map(row => row.status), ['LOGGED_OUT', 'LOGGED_OUT'])
|
||||
assert.equal(m.trip.dispatches.length, 1); assert.equal(new Set(fixture.members.map(row => row.dispatchNo)).size, 1); assert.deepEqual(m.trip.trip.stops.map(row => row.destinationKey).filter(Boolean), ['FBA:FBA'])
|
||||
assert.equal(m.calls.filter(row => row.method === 'POST' && row.raw === '/api/platform/v1/last-leg/dispatches').length, 1)
|
||||
assert.equal(m.calls.filter(row => row.raw.endsWith('/cartons/count')).length, 2)
|
||||
for (const member of fixture.members) {
|
||||
const prefix = `/api/pda/v1/last-leg/warehouse-transfers/${member.incomingNo}`
|
||||
const draft = m.calls.findIndex(row => row.raw === `${prefix}/pallets/drafts`)
|
||||
const bind = m.calls.findIndex(row => row.raw.startsWith(`${prefix}/pallets/`) && row.raw.endsWith('/bind'))
|
||||
const count = m.calls.findIndex(row => row.raw === `${prefix}/cartons/count`)
|
||||
const photo = m.calls.findIndex(row => row.raw === `${prefix}/measurement-photos/files`)
|
||||
const receipt = m.calls.findIndex(row => row.raw === `${prefix}/palletize`)
|
||||
assert.ok(draft < bind && bind < count && count < photo && photo < receipt)
|
||||
assert.equal(member.measurementReceipt.countedCarton.cartonMark, m.calls[count].body.boxes[0].cartonMark)
|
||||
assert.equal(member.measurementReceipt.syntheticTestMaterials, true)
|
||||
assert.equal(member.measurementReceipt.finalizedPallet.cartonCount, 1)
|
||||
}
|
||||
assert.equal(m.calls.filter(row => row.raw.endsWith('/transfer-delivery')).length, 0)
|
||||
assert.ok(!m.calls.some(row => /\/pick$|\/depart$|\/bookings|\/capacity-plan/.test(row.raw)))
|
||||
const persisted = JSON.stringify(m.context.createdResources)
|
||||
for (const secret of ['caller-pda-secret', 'admin-secret', 'fresh-secret-', 'merchant-100-secret', 'refresh-100-secret']) assert.ok(!persisted.includes(secret))
|
||||
})
|
||||
|
||||
for (const fault of ['disabled receiving', 'reused customer', 'foreign login', 'foreign position', 'truncated ledger', 'wrong raw scope', 'wrong raw ledger position', 'wrong raw ledger balance', 'foreign candidate', 'missing second cargo', 'foreign delivery', 'logout failure']) {
|
||||
for (const fault of ['disabled receiving', 'reused customer', 'foreign login', 'missing forecast carton', 'missing declared quantity', 'foreign forecast item', 'uncounted carton', 'wrong counted pallet', 'wrong counted sku', 'missing carton measurement', 'zero counted pallet', 'wrong measured pallet weight', 'foreign position', 'truncated ledger', 'wrong raw scope', 'wrong raw ledger position', 'wrong raw ledger balance', 'foreign candidate', 'missing second cargo', 'foreign delivery', 'logout failure']) {
|
||||
test(`fail closed, retain partial resources and clean owned sessions: ${fault}`, async () => {
|
||||
const m = model(fault)
|
||||
await assert.rejects(createFleetCrossCustomerFixture(m.context, m.options))
|
||||
@@ -198,6 +244,8 @@ for (const fault of ['disabled receiving', 'reused customer', 'foreign login', '
|
||||
assert.equal(logouts.length, logins.length)
|
||||
assert.ok(!m.calls.some(row => row.method === 'DELETE' || row.raw.includes('/users/')))
|
||||
if (fault === 'disabled receiving') assert.equal(m.calls.filter(row => row.method !== 'GET').length, 0)
|
||||
if (['missing forecast carton', 'missing declared quantity', 'foreign forecast item', 'uncounted carton', 'wrong counted pallet', 'wrong counted sku', 'missing carton measurement', 'zero counted pallet', 'wrong measured pallet weight'].includes(fault))
|
||||
assert.equal(m.calls.some(row => row.raw.endsWith('/palletize') || row.raw === '/api/platform/v1/last-leg/dispatches'), false)
|
||||
const previousCount = m.calls.length
|
||||
await assert.rejects(createFleetCrossCustomerFixture(m.context, m.options), /already started/)
|
||||
assert.equal(m.calls.length, previousCount)
|
||||
|
||||
@@ -151,7 +151,7 @@ export async function createFleetCrossCustomerFixture(context, { execute = false
|
||||
destinationWarehouseCode: destination.warehouse.warehouseCode, destinationWarehouseName: destination.warehouse.warehouseName,
|
||||
deliveryAddress, packageCount: 1, volumeCbm: 1, deliveryMethod: 'CUSTOMER_DELIVERY', estimatedArrivalAt: localTime(1800000),
|
||||
needRelabel: false, remark: member.customerMarker, idempotencyKey: key(`CREATE-${suffix}`),
|
||||
items: [{ skuCode: `CROSS-${key(suffix).slice(0, 16)}`, productNameZh: '受控测试一箱货物', packageCount: 1 }] }
|
||||
items: [{ skuCode: `CROSS-${key(suffix).slice(0, 16)}`, productNameZh: '受控测试一箱货物', packageCount: 1, quantity: 1 }] }
|
||||
let incoming = await request('POST', MERCHANT, createBody, `Create new incoming ${suffix}`, auth.accessToken)
|
||||
member.incomingNo = incoming?.transferNo
|
||||
check('Ordinary transfer creates fresh incoming without borrowed source inventory', member.incomingNo && incoming.inboundCode === member.incomingNo
|
||||
@@ -167,8 +167,14 @@ export async function createFleetCrossCustomerFixture(context, { execute = false
|
||||
check('PDA task is untouched and belongs to the requested receiving warehouse', task?.transferNo === member.incomingNo
|
||||
&& task.inboundCode === member.incomingNo && id(task.operatingWarehouseId) === id(warehouseId)
|
||||
&& task.status === 'WAIT_PDA_RECEIVING' && task.pallets?.length === 0)
|
||||
const measured = await request('POST', `${pdaPath}/pallets/drafts`, { idempotencyKey: key(`MEASURE-${suffix}`),
|
||||
cartonCount: 1, lengthCm: 100, widthCm: 100, heightCm: 100, volumeCbm: 1, grossWeightKg: 100 }, 'Measure one real test pallet', pdaToken)
|
||||
const forecastItem = task.items?.[0], forecastCarton = task.cartons?.[0]
|
||||
check('One original forecast carton and declared SKU belong to this fresh one-box incoming', safeFixtureCount(task.packageCount) === 1
|
||||
&& task.items?.length === 1 && forecastItem?.id && forecastItem.skuCode === createBody.items[0].skuCode
|
||||
&& safeFixtureCount(forecastItem.packageCount) === 1 && safeFixtureCount(forecastItem.quantity) === 1
|
||||
&& task.cartons?.length === 1 && forecastCarton?.cartonMark
|
||||
&& (forecastCarton.transferItemId == null || id(forecastCarton.transferItemId) === id(forecastItem.id)))
|
||||
// PalletDraft creates only an empty identity; measurements belong to the real carton-count command below.
|
||||
const measured = await request('POST', `${pdaPath}/pallets/drafts`, { idempotencyKey: key(`MEASURE-${suffix}`) }, 'Create empty TEST pallet draft', pdaToken)
|
||||
check('Measurement created one pallet draft', measured?.pallets?.length === 1 && measured.pallets[0].id && measured.pallets[0].status === 'DRAFT')
|
||||
const palletPath = `${pdaPath}/pallets/${encode(measured.pallets[0].id)}`
|
||||
const generated = await request('POST', `${palletPath}/generate`, { idempotencyKey: key(`GENERATE-${suffix}`) }, 'Generate authoritative warehouse pallet', pdaToken)
|
||||
@@ -176,8 +182,28 @@ export async function createFleetCrossCustomerFixture(context, { execute = false
|
||||
check('Generated physical pallet has real references', generated?.pallets?.length === 1 && pallet.status === 'GENERATED' && pallet.warehousePalletId && pallet.palletNo)
|
||||
member.palletId = id(pallet.warehousePalletId); member.palletNo = pallet.palletNo
|
||||
const bound = await request('POST', `${palletPath}/bind`, { palletCode: member.palletNo, idempotencyKey: key(`BIND-${suffix}`) }, 'Bind scanned physical pallet', pdaToken)
|
||||
check('Bound pallet is exactly the measured one-box pallet', bound?.pallets?.length === 1 && bound.pallets[0].status === 'BOUND'
|
||||
&& id(bound.pallets[0].warehousePalletId) === member.palletId && safeFixtureCount(bound.pallets[0].cartonCount) === 1)
|
||||
check('Bound pallet is exactly the generated empty physical pallet before carton counting', bound?.pallets?.length === 1 && bound.pallets[0].status === 'BOUND'
|
||||
&& id(bound.pallets[0].id) === id(measured.pallets[0].id)
|
||||
&& id(bound.pallets[0].warehousePalletId) === member.palletId && safeFixtureCount(bound.pallets[0].cartonCount) === 0)
|
||||
const counted = await request('POST', `${pdaPath}/cartons/count`, { idempotencyKey: key(`COUNT-${suffix}`),
|
||||
forecastPackageCount: safeFixtureCount(task.packageCount), boxes: [{ cartonMark: forecastCarton.cartonMark,
|
||||
transferItemId: id(forecastItem.id), transferPalletId: id(measured.pallets[0].id),
|
||||
length: 100, width: 100, height: 100, weight: 100,
|
||||
items: [{ skuCode: forecastItem.skuCode, quantity: safeFixtureCount(forecastItem.quantity) }] }] },
|
||||
'Count original TEST carton, record dimensions/SKU and allocate it to the bound pallet', pdaToken)
|
||||
const countedCarton = counted?.cartons?.[0], countedPallet = counted?.pallets?.[0]
|
||||
check('Actual carton count proves one declared box/SKU on the original bound pallet', counted?.transferNo === member.incomingNo
|
||||
&& counted.cartons?.length === 1 && countedCarton.cartonMark === forecastCarton.cartonMark
|
||||
&& countedCarton.status === 'COUNTED' && countedCarton.countedAt
|
||||
&& id(countedCarton.transferPalletId) === id(measured.pallets[0].id)
|
||||
&& id(countedCarton.transferItemId) === id(forecastItem.id)
|
||||
&& ['length', 'width', 'height', 'weight'].every(field => Number(countedCarton[field]) === 100)
|
||||
&& safeFixtureCount(countedCarton.quantity) === 1 && countedCarton.items?.length === 1
|
||||
&& countedCarton.items[0].skuCode === forecastItem.skuCode
|
||||
&& id(countedCarton.items[0].transferItemId) === id(forecastItem.id) && safeFixtureCount(countedCarton.items[0].quantity) === 1
|
||||
&& counted.pallets?.length === 1 && countedPallet.status === 'BOUND' && id(countedPallet.id) === id(measured.pallets[0].id)
|
||||
&& id(countedPallet.warehousePalletId) === member.palletId && safeFixtureCount(countedPallet.cartonCount) === 1
|
||||
&& Number(countedPallet.grossWeightKg) === 100 && Number(countedPallet.volumeCbm) === 1)
|
||||
const photoPath = `${pdaPath}/measurement-photos/files`
|
||||
const photo = record('POST', photoPath, undefined, await context.requestMultipart('POST', photoPath,
|
||||
{ name: `${member.customerMarker}-TEST-MEASUREMENT.png`, type: 'image/png', content: testImage }, pdaToken), 'Upload TEST measurement evidence')
|
||||
@@ -189,7 +215,7 @@ export async function createFleetCrossCustomerFixture(context, { execute = false
|
||||
check('Customer confirmation makes ordinary incoming dispatchable', incoming?.transferNo === member.incomingNo && incoming.status === 'READY_FOR_DISPATCH')
|
||||
member.receipt = await stock(member)
|
||||
member.balanceId = id(member.receipt.balance.id); member.positionId = id(member.receipt.positions[0].id); member.receiptVerified = true
|
||||
member.measurementReceipt = { sourceBusinessNo: member.incomingNo, syntheticTestMaterials: true,
|
||||
member.measurementReceipt = { sourceBusinessNo: member.incomingNo, syntheticTestMaterials: true, countedCarton,
|
||||
sourceType: 'WAREHOUSE_TRANSFER_PDA', finalizedPallet: incoming.pallets?.find(row => id(row.warehousePalletId) === member.palletId) }
|
||||
const candidates = await request('GET', `${ROOT}/dispatches/candidates?${new URLSearchParams({ warehouseId: id(warehouseId), orderNo: member.incomingNo })}`,
|
||||
undefined, 'Read own native received source selection')
|
||||
|
||||
Reference in New Issue
Block a user