Compare commits

...
Author SHA1 Message Date
ouyangxq 51c3132195 fix(deploy): enforce TEST service memory budgets 2026-11-06 13:31:25 +08:00
liuyihe 9e99658bfa 提交 2026-11-06 10:41:49 +08:00
28 changed files with 743 additions and 37 deletions
+65
View File
@@ -0,0 +1,65 @@
# 仅由 TEST 部署入口叠加,固定 JVM 堆并为线程、元空间和直接内存保留余量。
x-test-limits: &test-limits
mem_limit: 1536m
memswap_limit: 1536m
environment: &test-java-environment
JAVA_OPTS: "-XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8"
JAVA_TOOL_OPTIONS: "-Xms128m -Xmx768m"
services:
gateway:
<<: *test-limits
auth:
<<: *test-limits
iam:
<<: *test-limits
order:
<<: *test-limits
firstleg:
<<: *test-limits
mem_limit: 2g
memswap_limit: 2g
environment:
<<: *test-java-environment
JAVA_TOOL_OPTIONS: "-Xms256m -Xmx1g"
finance:
<<: *test-limits
warehouse:
<<: *test-limits
warehouse-overseas-service:
<<: *test-limits
integration-service:
<<: *test-limits
audit-service:
<<: *test-limits
file:
<<: *test-limits
mem_limit: 3g
memswap_limit: 3g
environment:
<<: *test-java-environment
JAVA_TOOL_OPTIONS: "-Xms1g -Xmx2g"
print:
<<: *test-limits
environment:
<<: *test-java-environment
JAVA_TOOL_OPTIONS: "-Xms512m -Xmx1g"
lastleg-service:
<<: *test-limits
mem_limit: 3g
memswap_limit: 3g
environment:
<<: *test-java-environment
JAVA_TOOL_OPTIONS: "-Xms512m -Xmx1536m"
+8
View File
@@ -12,6 +12,7 @@ IMAGE_TAG="$3"
SERVICE="$4"
ENV_FILE="$5"
COMPOSE_FILE="deploy/docker-compose.yml"
TEST_LIMITS_FILE="deploy/docker-compose.test-limits.yml"
IMAGE="$IMAGE_REPOSITORY/$SERVICE:$IMAGE_TAG"
REGISTRY_HOST="${IMAGE_REPOSITORY%%/*}"
PROJECT_NAME="firstleg-$DEPLOY_ENV"
@@ -40,6 +41,9 @@ case "${ALLOW_SAME_CONTRACT_ROLLBACK:-false}" in true|false) ;; *) exit 2 ;; esa
if [ "$DEPLOY_ENV" = "test" ] && [ "${TARGET_IS_LOCAL:-false}" != "true" ]; then
echo "TEST deploy must run on the target host" >&2; exit 2
fi
if [ "$DEPLOY_ENV" = "test" ] && [ ! -f "$TEST_LIMITS_FILE" ]; then
echo "TEST memory limits file missing: $TEST_LIMITS_FILE" >&2; exit 2
fi
[ -f "$ENV_FILE" ] || { echo "Environment file missing" >&2; exit 2; }
env_file_value() { sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r'; }
for key in NACOS_SERVER_ADDR NACOS_NAMESPACE NACOS_USERNAME NACOS_PASSWORD REDIS_HOST REDIS_PORT REDIS_DATABASE REDIS_TOKEN_KEY_PREFIX INTERNAL_SERVICE_TOKEN JWT_RSA_JWK_SET JWT_CURRENT_KID ROCKETMQ_NAME_SERVER; do
@@ -74,7 +78,11 @@ local_registry_logout() {
if [ -n "${deploy_docker_config:-}" ]; then rm -rf -- "$deploy_docker_config"; fi
}
# TEST 的查询、部署和回滚共用同一份内存限额,缺失时由入口提前拒绝。
local_compose() {
if [ "$DEPLOY_ENV" = "test" ]; then
set -- -f "$TEST_LIMITS_FILE" "$@"
fi
docker compose \
--project-name "$PROJECT_NAME" \
--env-file "$ENV_FILE" \
+2
View File
@@ -1,5 +1,7 @@
# 单测试环境数据库空库初始化
`migrations/overseas/baiyue_lastleg/V38__allow_multi_order_delivery_returns.sql` 移除派送退回的旧全历史唯一索引,保留普通来源索引,支持同派送一次选择多张原订单各建异常。配套 Lastleg 批次行锁、总量校验及 Admin 多选;可重复执行,不改业务记录。已在本机应用并核对行数,远程 TEST 尚未执行。
`20261104-unify-utf8mb4-0900-ai-ci.sql` 是本机 MySQL 8.4.11 五个业务库的排序规则汇总脚本,覆盖 `baiyue_event_bridge`、`baiyue_event_bridge_overseas`、`baiyue_firstleg`、`baiyue_lastleg`、`baiyue_order`。脚本已在本机执行并核验 209 张表、1,644 个文本字段;其他环境执行前必须核对实际字段定义和唯一索引冲突,不能默认部署。脚本只收敛旧的 Unicode 不区分大小写排序规则,保留有意使用的 `utf8mb4_bin`。
退货仓租改为原托整托计费、余一箱仍计一托,现有阶梯不变。已有 Lastleg 库先执行 [整托计费结构增量](return-storage-rent-whole-pallet.sql) 增加 `ll_storage_rent_lot.charged_days`,再发布匹配的 Lastleg;源基线和海外空库基线已同步。实际出库事务推进已生成费用的截止库龄日,同托同计租日不重复收费。切换期间暂停相关出库、取消未消费预占,并逐单核对旧比例费用;脚本不修改既有费用、库存或资金。本次仅准备代码及脚本,未执行环境 SQL 或部署。
@@ -0,0 +1,10 @@
-- 同一派送可按多张原订单生成退回异常;活动批次由派送行锁和来源数量校验保护。
-- 只调整索引,不修改异常、签收、库存或资金事实;重复执行跳过已完成项。
USE baiyue_lastleg;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'll_exception_order' AND index_name = 'idx_ll_exception_delivery_return'),
'ALTER TABLE ll_exception_order ADD KEY idx_ll_exception_delivery_return (customer_id, delivery_order_id, exception_type)', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'll_exception_order' AND index_name = 'uk_ll_exception_delivery_return'),
'ALTER TABLE ll_exception_order DROP INDEX uk_ll_exception_delivery_return', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -54,10 +54,11 @@ public final class DispatchViews {
List<DispatchDeliveryModels.DeliveryReference> deliveries,
Instant createdAt, Instant updatedAt, String remark) { }
/** 散板按客户和商业订单汇总;来源键集合仅供只读明细关联,不替代作业来源身份。 */
public record OrderDetail(String customerId, String customerName, String orderNo, String inboundCode,
BigDecimal grossWeightKg, Integer palletCount, String destinationFbaWarehouse,
BigDecimal volumeCbm, String containerNo, String sourceKey,
String sourceBusinessType, String sourceBusinessNo) { }
String sourceBusinessType, String sourceBusinessNo, List<String> sourceKeys) { }
/** 页头元数据和四个内容对象固定,附加能力不扩展为新页签。 */
public record Detail(String dispatchNo, String taskType, Status status, Stage stage, long recordVersion,
@@ -6,11 +6,12 @@ import java.util.List;
/** 客户照片仅查询已核准客户与原单的有效承运关联。 */
@Repository
public class MerchantTransportPhotoRepository {
public class MerchantTransportPhotoRepository implements com.baiyue.lastleg.looseboard.port.LooseboardDispatches {
private final MerchantTransportPhotoMapper mapper;
/** 注入本域查询入口。 */
public MerchantTransportPhotoRepository(MerchantTransportPhotoMapper mapper){this.mapper=mapper;}
/** 返回原散板单实际关联的排车身份。 */
@Override
public List<String> looseboardDispatches(String customer,String number){return mapper.looseboardDispatches(customer,number);}
/** 返回原卡车单有效运输任务。 */
public List<String> truckDispatches(String customer,String number){return mapper.truckDispatches(customer,number);}
@@ -1229,9 +1229,9 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
reviews(row),read(row.getResourcesJson(),Resources.class),row.getOverseasWarehouseId(),row.getOverseasWarehouseCode(),row.getOverseasWarehouseName(),instant(row.getCreatedAt()),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),counts(row),reconciled(row),row.getArrangementKey()!=null,actions(actor,row),reviewAppointments(row));
}
/** 混托多订单无法真实分摊重量体积时保持未知,不能给每票复制整托总量。 */
/** 散板同客户同商业订单只展示一行,逐托来源仍保留用于原作业和运营明细关联。 */
private List<DispatchViews.OrderDetail> orderDetails(List<DispatchItem> cargo) {
var result=new ArrayList<DispatchViews.OrderDetail>();
var groups=new LinkedHashMap<List<String>,List<OrderDisplayContribution>>();
var businessSources=looseboardDisplay.sources(cargo.stream().map(i->new com.baiyue.lastleg.dispatch.mapper.LooseboardCargoMapper.Reference(i.getCustomerId(),i.getOrderNo(),i.getPlanPalletId())).toList());
for(var item:cargo) {
var source=read(item.getSourceSnapshotJson(),SourceDisplay.class);
@@ -1239,11 +1239,46 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
if(orders.isEmpty()) orders=Collections.singletonList(null);
for(var order:orders) {
var business=looseboardDisplay.find(businessSources,item.getCustomerId(),order,item.getPlanPalletId());
result.add(new DispatchViews.OrderDetail(item.getCustomerId(),item.getCustomerName(),business==null?order:business.orderNo(),business==null?item.getInboundCode():null,orders.size()==1?item.getGrossWeightKg():null,
orders.size()==1?1:null,item.getFbaWarehouseCode(),orders.size()==1?item.getVolumeCbm():null,business==null?source.containerNo():null,item.getSourceKey(),business==null?item.getSourceBusinessType():"LOCAL_LOOSEBOARD",business==null?item.getSourceBusinessNo():business.orderNo()));
var detail=new DispatchViews.OrderDetail(item.getCustomerId(),item.getCustomerName(),business==null?order:business.orderNo(),business==null?item.getInboundCode():null,orders.size()==1?item.getGrossWeightKg():null,
orders.size()==1?1:null,item.getFbaWarehouseCode(),orders.size()==1?item.getVolumeCbm():null,business==null?source.containerNo():null,item.getSourceKey(),business==null?item.getSourceBusinessType():"LOCAL_LOOSEBOARD",business==null?item.getSourceBusinessNo():business.orderNo(),item.getSourceKey()==null?List.of():List.of(item.getSourceKey()));
// 商业订单必须来自权威关联,不能按LB前缀或跨客户同号合并。
var key=business==null?List.of("SOURCE",Integer.toString(groups.size())):List.of("LOOSEBOARD",item.getCustomerId(),business.orderNo());
String pallet=item.getWarehousePalletId()!=null?"WAREHOUSE:"+item.getWarehousePalletId():item.getPlanPalletId()!=null?"PLAN:"+item.getPlanPalletId():"SOURCE:"+Objects.toString(item.getSourceKey(),Objects.toString(item.getId(),Integer.toString(cargo.indexOf(item))));
var members=groups.computeIfAbsent(key,ignored->new ArrayList<>());
if(members.stream().noneMatch(member->member.item()==item))members.add(new OrderDisplayContribution(item,detail,pallet));
}
}
return List.copyOf(result);
var palletOrders=new HashMap<String,Set<List<String>>>();
groups.forEach((key,members)->members.forEach(member->palletOrders.computeIfAbsent(member.palletKey(),ignored->new HashSet<>()).add(key)));
var sharedPallets=palletOrders.entrySet().stream().filter(entry->entry.getValue().size()>1).map(Map.Entry::getKey).collect(Collectors.toSet());
return groups.entrySet().stream().map(entry->"LOOSEBOARD".equals(entry.getKey().get(0))?looseboardOrderDetail(entry.getValue(),sharedPallets):entry.getValue().get(0).detail()).toList();
}
/** 仅在展示层保存原来源与实托身份,不改写排车货物或库存。 */
private record OrderDisplayContribution(DispatchItem item,DispatchViews.OrderDetail detail,String palletKey) { }
/** 同托只计一次;混托无法分摊、缺测量或同托测量冲突均保持未知。 */
private DispatchViews.OrderDetail looseboardOrderDetail(List<OrderDisplayContribution> members,Set<String> sharedPallets) {
var first=members.get(0).detail();
var pallets=members.stream().collect(Collectors.groupingBy(OrderDisplayContribution::palletKey,LinkedHashMap::new,Collectors.toList()));
Integer palletCount=members.stream().anyMatch(m->m.detail().palletCount()==null)?null:pallets.size();
var destinations=members.stream().map(m->m.detail().destinationFbaWarehouse()).filter(Objects::nonNull).filter(s->!s.isBlank()).distinct().toList();
return new DispatchViews.OrderDetail(first.customerId(),first.customerName(),first.orderNo(),null,
orderMeasurement(pallets.values(),sharedPallets,DispatchViews.OrderDetail::grossWeightKg),palletCount,destinations.isEmpty()?null:String.join("、",destinations),
orderMeasurement(pallets.values(),sharedPallets,DispatchViews.OrderDetail::volumeCbm),null,first.sourceKey(),first.sourceBusinessType(),first.sourceBusinessNo(),
members.stream().flatMap(m->m.detail().sourceKeys().stream()).distinct().toList());
}
/** 每个实托必须有一致测量才能汇总,不能把未知量当零或重复累加整托量。 */
private java.math.BigDecimal orderMeasurement(Collection<List<OrderDisplayContribution>> pallets,Set<String> sharedPallets,java.util.function.Function<DispatchViews.OrderDetail,java.math.BigDecimal> measurement) {
var total=java.math.BigDecimal.ZERO;
for(var members:pallets) {
if(sharedPallets.contains(members.get(0).palletKey()))return null;
var value=measurement.apply(members.get(0).detail());
if(value==null || members.stream().anyMatch(m->measurement.apply(m.detail())==null || value.compareTo(measurement.apply(m.detail()))!=0))return null;
total=total.add(value);
}
return total;
}
/** 合并Fleet权威资源、本趟照片和维护资料,不写入车辆或司机主档。 */
@@ -14,6 +14,10 @@ public interface ExceptionWorkflowMapper {
/** 本车可登记的派送单,客户跟随真实派送记录。 */
@Select("SELECT customer_id,delivery_no FROM ll_delivery_order WHERE dispatch_order_id=#{id} AND status IN ('DELIVERING','REJECTED','DELIVERED') ORDER BY id")
List<Delivery> deliveries(long id);
/** 取消本单时核对同一客户及派送是否仍有其他活动退回。 */
@Select("SELECT COUNT(*) FROM ll_exception_order WHERE customer_id=#{customer} AND delivery_order_id=#{delivery} " +
"AND exception_type='RETURN' AND id<>#{excluded} AND status IN ('PENDING','PROCESSING')")
long countOtherActiveReturns(@Param("customer") String customer, @Param("delivery") long delivery, @Param("excluded") long excluded);
/** 以异常号和冻结仓锁定现场任务。 */
@Select("SELECT * FROM ll_exception_order WHERE exception_no=#{number} AND overseas_warehouse_id=#{warehouse} LIMIT 1 FOR UPDATE")
ExceptionOrder lockWarehouse(@Param("warehouse") String warehouse, @Param("number") String number);
@@ -24,6 +24,10 @@ public class ExceptionWorkflowRepository {
}
/** 查询本车派送归属。 */
public List<ExceptionWorkflowMapper.Delivery> deliveries(long id) { return workflow.deliveries(id); }
/** 取消单据不释放同派送其他仍在处理的退回批次。 */
public boolean hasOtherActiveReturns(String customer, long delivery, long excluded) {
return workflow.countOtherActiveReturns(customer,delivery,excluded)>0;
}
/** 在客户范围锁定异常。 */
public ExceptionOrder lock(String customer, String number) { return exceptions.lockByNo(customer,number); }
/** 保存人工完结后的异常状态,保留关联单据和实际库存事实。 */
@@ -36,17 +36,24 @@ public class DispatchExceptionAdapter implements DispatchExceptionPort {
if(candidates.isEmpty())throw new ResponseStatusException(HttpStatus.CONFLICT,"本车没有可关联的派送来源");
if(warehouse) {
var seen=new HashSet<String>();
var grouped=new LinkedHashMap<Candidate,List<ExceptionOrderRequests.MarkReturn>>();
for(int index=0;index<selected.size();index++) {
var selection=selected.get(index);
if(selection==null || selection.items()==null || selection.items().isEmpty() || selection.items().size()>200 || !seen.add(selection.deliveryNo()))
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"每张派送单请选择一张来源订单及有效退回货物");
if(selection==null || selection.items()==null || selection.items().isEmpty() || selection.items().size()>200
|| !seen.add(selection.deliveryNo()+":"+selection.lastMileOrderId()))
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"请选择不重复的来源订单及有效退回货物");
var source=candidates.stream().filter(c->Objects.equals(c.deliveryNo(),selection.deliveryNo()) && Objects.equals(c.lastMileOrderId(),selection.lastMileOrderId())).findFirst()
.orElseThrow(()->new ResponseStatusException(HttpStatus.CONFLICT,"所选来源不属于当前排车"));
if(selection.items().stream().anyMatch(i->i==null || i.outboundPalletItemId()==null || i.packageCount()<1))throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"退回明细无效");
service.markReturn(source.customerId(),operator,operator,source.deliveryNo(),new ExceptionOrderRequests.MarkReturn(source.lastMileOrderId(),reason,"OVERSEAS_WAREHOUSE",null,
// 同派送的多张原订单一次锁定并整批冻结,不能让首单的退回中状态拦住后续订单。
var group=grouped.keySet().stream().filter(row->Objects.equals(row.customerId(),source.customerId())
&& Objects.equals(row.deliveryNo(),source.deliveryNo())).findFirst().orElse(source);
grouped.computeIfAbsent(group,ignored->new ArrayList<>()).add(new ExceptionOrderRequests.MarkReturn(source.lastMileOrderId(),reason,"OVERSEAS_WAREHOUSE",null,
selection.items().stream().map(i->new ExceptionOrderRequests.ReturnItem(i.outboundPalletItemId(),i.inventoryPositionId(),i.packageCount(),i.returnCartonId())).toList(),
"dispatch:"+dispatchId+":"+java.util.UUID.nameUUIDFromBytes(request.idempotencyKey().getBytes(java.nio.charset.StandardCharsets.UTF_8))+":"+index));
}
for(var entry:grouped.entrySet()) service.markReturns(entry.getKey().customerId(),operator,operator,
entry.getKey().deliveryNo(),entry.getValue());
} else for(var source:candidates) service.create(source.customerId(),operator,operator,
new ExceptionOrderRequests.Create(source.customerId(),null,source.lastMileOrderNo(),"REFUSED".equals(request.exceptionType().name())?"REJECT":"OTHER",reason,null,null));
}
@@ -216,7 +216,7 @@ public class ExceptionOrderService {
ExceptionOrderResponses.Relation relation = relation(scopedCustomerId, request.customerId(),
request.inboundCode(), request.lastMileOrderNo());
return insert(relation, null, normalizeType(request.exceptionType()), request.reason(), request.packageCount(),
request.skuCode(), null, null, "MANUAL", operatorId, operatorName);
request.skuCode(), null, null, "MANUAL", operatorId, operatorName, null);
}
/** 按原出库冻结普通仓位或原箱来源,整箱引用与数量在同一派送锁内复核。 */
@@ -227,7 +227,44 @@ public class ExceptionOrderService {
ExceptionOrderCommand replay = commands.findByKey(customerId, "MARK_RETURN", request.idempotencyKey().trim());
if (replay != null) return replayCreated(replay, requestHash);
DeliveryOrder delivery = lockForReturn(customerId, deliveryNo);
return markReturnLocked(customerId, operatorId, operatorName, delivery, request, requestHash, delivery.getStatus());
}
/** 同一派送可一次选择多张原订单;整批共用派送行锁,任一来源失败全部回滚。 */
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public List<ExceptionOrderResponses.Created> markReturns(String customerId, String operatorId, String operatorName,
String deliveryNo, List<ExceptionOrderRequests.MarkReturn> requests) {
if (requests == null || requests.isEmpty() || requests.size() > 200 || requests.stream().anyMatch(request -> request == null
|| request.lastMileOrderId() == null || request.idempotencyKey() == null || request.idempotencyKey().isBlank()
|| request.items() == null || request.items().isEmpty() || request.items().size() > 200
|| request.items().stream().anyMatch(item -> item == null || item.packageCount() < 1))
|| requests.stream().map(ExceptionOrderRequests.MarkReturn::idempotencyKey).distinct().count() != requests.size()
|| requests.stream().map(ExceptionOrderRequests.MarkReturn::lastMileOrderId).distinct().count() != requests.size())
throw badRequest("请选择不重复的来源订单及有效退回货物");
var replayed = new ArrayList<ExceptionOrderResponses.Created>();
for (var request : requests) {
var replay = commands.findByKey(customerId, "MARK_RETURN", request.idempotencyKey().trim());
if (replay != null) replayed.add(replayCreated(replay, hash(request)));
}
if (replayed.size() == requests.size()) return List.copyOf(replayed);
if (!replayed.isEmpty()) throw conflict("退回批次已有部分命令,请刷新后核对原结果");
DeliveryOrder delivery = lockForReturn(customerId, deliveryNo);
long packages = requests.stream().flatMap(request -> request.items().stream())
.mapToLong(ExceptionOrderRequests.ReturnItem::packageCount).reduce(0L, Math::addExact);
long signed = Objects.requireNonNullElse(delivery.getSignedPackages(), 0);
if (signed < 0 || delivery.getPackageCount() == null || packages > delivery.getPackageCount() - signed)
throw conflict("退回总箱数超过派送未签收数量");
var created = new ArrayList<ExceptionOrderResponses.Created>();
String originalStatus = delivery.getStatus();
for (var request : requests) created.add(markReturnLocked(customerId, operatorId, operatorName, delivery, request, hash(request), originalStatus));
return List.copyOf(created);
}
/** 调用方已锁定派送并校验批次总量,这里按原订单冻结真实来源并保存独立异常。 */
private ExceptionOrderResponses.Created markReturnLocked(String customerId, String operatorId, String operatorName,
DeliveryOrder delivery, ExceptionOrderRequests.MarkReturn request,
String requestHash, String originalDeliveryStatus) {
String deliveryNo = delivery.getDeliveryNo();
List<ExceptionOrderResponses.Relation> candidates = returnCandidates(delivery);
ExceptionOrderResponses.Relation selected = candidates.stream()
.filter(candidate -> Objects.equals(candidate.lastMileOrderId(), request.lastMileOrderId()))
@@ -248,7 +285,7 @@ public class ExceptionOrderService {
}
ExceptionOrderResponses.Created created = insert(selected, delivery, "RETURN", request.reason(),
packages, sku, returnDirection, request.returnLogisticsNo(),
"DELIVERY_RETURN", operatorId, operatorName);
"DELIVERY_RETURN", operatorId, operatorName, originalDeliveryStatus);
for (ExceptionOrderRequests.ReturnItem requestedItem : requested) {
ExceptionOrderResponses.ReturnSource source = available.stream()
@@ -284,7 +321,7 @@ public class ExceptionOrderService {
return created;
}
/** Caller keeps the delivery row locked until return creation commits. */
/** 调用方持有派送行锁,禁止活动退回批次之间重叠冻结。 */
DeliveryOrder lockForReturn(String customerId, String deliveryNo) {
DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo);
if (delivery == null) throw notFound("派送单不存在");
@@ -461,7 +498,7 @@ public class ExceptionOrderService {
private ExceptionOrderResponses.Created insert(ExceptionOrderResponses.Relation relation, DeliveryOrder delivery,
String type, String reason, Integer packages, String sku,
String direction, String logisticsNo, String sourceType,
String operatorId, String operatorName) {
String operatorId, String operatorName, String originalDeliveryStatus) {
if (packages != null && relation.availablePackages() != null && packages > relation.availablePackages()) {
throw conflict("异常箱数不能大于订单箱数 " + relation.availablePackages());
}
@@ -482,7 +519,8 @@ public class ExceptionOrderService {
value.setReturnLogisticsNo(trim(logisticsNo));
if (delivery != null) {
LastlegOrder tail = relation.lastMileOrderId() == null ? null : lastMileOrders.selectById(relation.lastMileOrderId());
value.setReturnContextJson(write(new ReturnContext(delivery.getStatus(), tail == null ? null : tail.getId(),
// 同批各单冻结批次开始前的派送状态,支持任意顺序取消并恢复。
value.setReturnContextJson(write(new ReturnContext(originalDeliveryStatus, tail == null ? null : tail.getId(),
tail == null ? null : tail.getSubOrderStatus(), tail == null ? null : tail.getAbnormal())));
}
if (StringUtils.hasText(direction)) {
@@ -695,6 +733,7 @@ public class ExceptionOrderService {
private record ReturnContext(String deliveryStatus, Long tailId, String tailStatus, Boolean abnormal) { }
/** 取消只恢复本原单;同批仍有未取消退回时继续保留派送退回中状态。 */
private void restoreCancelledReturn(ExceptionOrder order) {
if (!"RETURN".equals(order.getExceptionType()) || !StringUtils.hasText(order.getDeliveryNo())) return;
ReturnContext previous;
@@ -706,7 +745,8 @@ public class ExceptionOrderService {
if (delivery == null || !"RETURN_PENDING".equals(delivery.getStatus())) throw conflict("关联派送状态已变化,不能取消退回");
if (returnItems.findByException(order.getCustomerId(), order.getId()).stream().anyMatch(item -> ReturnReceiptPolicy.received(item) > 0))
throw conflict("已有实际退回入仓,不能取消退回事实");
delivery.setStatus(previous.deliveryStatus()); delivery.setUpdatedAt(LocalDateTime.now());
boolean otherReturns = workflow.hasOtherActiveReturns(order.getCustomerId(), delivery.getId(), order.getId());
delivery.setStatus(otherReturns ? "RETURN_PENDING" : previous.deliveryStatus()); delivery.setUpdatedAt(LocalDateTime.now());
delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(), 0L) + 1); deliveryOrders.updateById(delivery);
if (previous.tailId() != null) {
LastlegOrder tail = lastMileOrders.selectById(previous.tailId());
@@ -56,8 +56,9 @@ public final class OwnedBusinessModels {
String recordVersion,String dispatchRecordVersion,PhotoSummary photoSummary,Actions actions,List<String> sourceIssues,String taskType) {}
public record Summary(int dispatchCount,int afterLoadingCount,int deliveringCount,int deliveredCount) {}
public record ListResult(List<Row> records,int page,int size,int total,Summary summary) {}
/** 散板按商业订单展示,关联托盘与目的地完整保留,箱数只累加真实来源贡献。 */
public record Cargo(String sourceKey,String customerId,String customerName,String orderNo,
String inboundCode,String palletNo,String destinationKey,String packages) {}
String inboundCode,String palletNo,String destinationKey,String packages,List<String> destinationKeys) {}
public record Detail(Row row,DispatchViews.Detail dispatch,List<Cargo> cargo,List<Photo> photos) {}
public record History(String eventSource,String recordVersion,String dispatchRecordVersion,String action,String operatorName,String occurredAt,String reason,
Operations operations,List<Photo> photos) {}
@@ -5,6 +5,7 @@ import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Search;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.CargoRow;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import com.baiyue.lastleg.dispatch.port.DispatchDetailReader;
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
@@ -68,14 +69,37 @@ public class OwnedBusinessQueryService {
public Detail detailForWrite(Actor actor,Source source) {
return detailForWrite(actor,source,OwnedBusinessScope.OWNED);
}
/** 写回执也执行固定归属校验,调用者已核对本域独立写权限。 */
/** 写回执也执行固定归属校验;散板明细复用原排车公开的商业订单归属。 */
public Detail detailForWrite(Actor actor,Source source,OwnedBusinessScope scope) {
eligible(source,scope);var ids=List.of(source.getId());var cargo=repository.cargo(ids);
var row=projection.row(source,repository.record(source.getId()),cargo,repository.batches(ids),actor,scope);
var original=dispatch.detail(new DispatchContracts.Actor(actor.operatorId(),ActorMode.PLATFORM,null),source.getDispatchNo());
return new Detail(row,original,cargo.stream().map(c->new Cargo(c.getSourceKey(),c.getCustomerId(),c.getCustomerName(),
c.getOrderNo(),c.getInboundCode(),c.getPalletNo(),projection.destinationKey(c),String.valueOf(c.getPackageCount()))).toList(),
projection.photos(repository.record(source.getId())));
var looseboardOrders=new HashMap<List<String>,String>();
if(original!=null && original.orderDetails()!=null)for(var order:original.orderDetails()) {
if(!"LOCAL_LOOSEBOARD".equals(order.sourceBusinessType()) || order.sourceBusinessNo()==null)continue;
for(var key:order.sourceKeys())looseboardOrders.put(List.of(order.customerId(),key),order.sourceBusinessNo());
}
return new Detail(row,original,cargoDetails(cargo,looseboardOrders),projection.photos(repository.record(source.getId())));
}
/** 商业订单由排车公开契约确认,普通货物保持原行,不能按编号前缀猜测散板。 */
private List<Cargo> cargoDetails(List<CargoRow> cargo,Map<List<String>,String> looseboardOrders) {
var groups=new LinkedHashMap<List<String>,List<CargoRow>>();
for(var item:cargo) {
var order=looseboardOrders.get(List.of(item.getCustomerId(),item.getSourceKey()));
var key=order==null?List.of("SOURCE",item.getCustomerId(),item.getSourceKey()):List.of("LOOSEBOARD",item.getCustomerId(),order);
groups.computeIfAbsent(key,ignored->new ArrayList<>()).add(item);
}
return groups.entrySet().stream().map(entry->{
var rows=entry.getValue();var first=rows.get(0);boolean looseboard="LOOSEBOARD".equals(entry.getKey().get(0));
var destinations=rows.stream().map(projection::destinationKey).distinct().toList();
var pallets=rows.stream().map(CargoRow::getPalletNo).filter(Objects::nonNull).distinct().toList();
// 同一来源只贡献一次箱数,实托码展示完整集合,不复制整托箱数。
var sources=rows.stream().collect(Collectors.toMap(CargoRow::getSourceKey,c->c,(a,b)->a,LinkedHashMap::new)).values();
String packages=sources.stream().anyMatch(c->c.getPackageCount()==null)?null:Long.toString(sources.stream().mapToLong(CargoRow::getPackageCount).sum());
return new Cargo(first.getSourceKey(),first.getCustomerId(),first.getCustomerName(),looseboard?entry.getKey().get(2):first.getOrderNo(),
looseboard?null:first.getInboundCode(),pallets.isEmpty()?null:String.join("、",pallets),destinations.get(0),packages,destinations);
}).toList();
}
/** 缺少历史归属必须明确拒绝,不能按当前车辆主档降级猜测。 */
public Source requireOwned(String number) {
@@ -22,7 +22,8 @@ public final class LocalLooseboardResponses {
String operationType, String fromStatus, String toStatus, String operatorId,
String idempotencyKey, String changeDetail, String remark, LocalDateTime createdAt) { }
/** 详情独立返回全部有效排车,同车多托合并为一个单号。 */
public record Detail(
Summary summary, String originAddress, String destinationAddress, String remark,
String createdBy, List<History> histories) { }
String createdBy, List<History> histories, List<String> dispatchNos) { }
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.looseboard.port;
import java.util.List;
/** 散板原单的排车关联只读入口,不向客户暴露整车货物。 */
public interface LooseboardDispatches {
/** 按真实客户和散板原单返回去重后的有效排车单号。 */
List<String> looseboardDispatches(String customerId, String orderNo);
}
@@ -48,6 +48,7 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
history(order,"TRANSPORT_PHOTO",from,next,operatorId,null,null,"按排车取货/送达照片更新,累计送达"+deliveredPackages+"箱",now);
}
@Resource private com.baiyue.lastleg.looseboard.port.LooseboardReceiving receiving;
@Resource private com.baiyue.lastleg.looseboard.port.LooseboardDispatches dispatches;
@Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries fleetPool;
public static final String PENDING_ACCEPTANCE = "PENDING_ACCEPTANCE";
public static final String DISPATCHED = "DISPATCHED";
@@ -278,7 +279,7 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
return order;
}
/** 装配需求详情、真实承运安排与不可变历史。 */
/** 装配需求详情、真实承运安排、全部有效排车与不可变历史。 */
private LocalLooseboardResponses.Detail response(LocalLooseboardOrder order) {
return new LocalLooseboardResponses.Detail(summary(order), order.getOriginAddress(),
order.getDestinationAddress(), order.getRemark(), order.getCreatedBy(),
@@ -286,7 +287,7 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
.map(value -> new LocalLooseboardResponses.History(value.getOperationType(), value.getFromStatus(),
value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(),
value.getChangeDetail(), value.getRemark(),
value.getCreatedAt())).toList());
value.getCreatedAt())).toList(), dispatches.looseboardDispatches(order.getCustomerId(), order.getOrderNo()));
}
/** 构造同客户商业订单的承运查询身份。 */
@@ -2,7 +2,7 @@ openapi: 3.0.3
info:
title: 派送异常退回入库
version: 1.0.0
description: 排车异常冻结原出库货物,异常单生成退回尾程单后进入PDA退货入库列表。现场展示异常单号、车牌和待收箱数,点击开始作业,不需要回单号或扫码找单。建单不增加库存,分批实收调用原来源RETURN_STOCK_IN或原箱恢复,异常和退回尾程单收齐后完成。
description: 排车异常允许一次选择同派送下多张原订单,逐原单生成异常并冻结对应原出库货物,整批校验与落单原子提交。异常单生成退回尾程单后进入PDA退货入库列表。现场展示异常单号、车牌和待收箱数,点击开始作业,不需要回单号或扫码找单。建单不增加库存,分批实收调用原来源RETURN_STOCK_IN或原箱恢复,异常和退回尾程单收齐后完成。回仓扫描当前仓有效托盘及其实际库位,不要求使用已出库报废的原托;同键实收重放不重复增加库存。
paths:
/api/platform/v1/last-leg/exception-orders/return-inbound-plans:
post:
@@ -793,6 +793,7 @@ components:
Cargo:
type: "object"
x-java-record: "OwnedBusinessModels.Cargo"
description: 散板按客户和商业订单一行展示,关联托盘码去重连接,箱数按本单来源累加;其他货物保持原来源行。
required:
- "sourceKey"
- "customerId"
@@ -815,10 +816,19 @@ components:
nullable: true
palletNo:
type: "string"
nullable: true
description: 关联的完整实托码集合,以顿号连接;未提供保持未知。
destinationKey:
type: "string"
description: 首个目的地键;展示使用完整destinationKeys集合。
destinationKeys:
type: array
items:
type: string
description: 本订单全部真实目的地,不能丢失多目的地信息。
packages:
type: "string"
nullable: true
Detail:
type: "object"
x-java-record: "OwnedBusinessModels.Detail"
@@ -4904,8 +4904,8 @@ components:
fleetArrangement: { $ref: '#/components/schemas/FleetArrangement' }
LooseboardDetail:
type: object
description: 承运安排位于summary.fleetArrangement,与列表摘要使用同一真实来源;根对象不重复该字段。池来源状态只能由实际履约推进。
required: [summary, histories]
description: 承运安排位于summary.fleetArrangement;dispatchNos独立按本客户散板原单的真实来源返回全部有效排车,不依赖车队预订,同车多托去重、分车全部展示、取消排车排除;未排车返回空数组。池来源状态只能由实际履约推进。
required: [summary, histories, dispatchNos]
properties:
summary: { $ref: '#/components/schemas/LooseboardSummary' }
originAddress: { type: string, nullable: true }
@@ -4913,6 +4913,7 @@ components:
remark: { type: string, nullable: true }
createdBy: { type: string, nullable: true }
histories: { type: array, items: { $ref: '#/components/schemas/BusinessHistory' } }
dispatchNos: { type: array, uniqueItems: true, items: { type: string }, description: 本客户当前散板单关联的全部有效排车单号,不授予客户整车详情访问权限 }
FleetArrangement:
type: object
nullable: true
@@ -6771,6 +6772,7 @@ components:
DispatchOrderDetail:
type: object
x-java-record: DispatchViews.OrderDetail
description: 散板按客户和权威商业订单汇总一行,实托去重计数;重量体积仅完整可分摊时汇总,未知不补零。普通来源保持原展示语义。
properties:
customerId:
type: string
@@ -6808,6 +6810,11 @@ components:
sourceBusinessNo:
type: string
nullable: true
sourceKeys:
type: array
items:
type: string
description: 本展示行包含的全部原来源键,只用于只读关联,不能作为新增或删除货物的命令身份。
required:
- customerId
- customerName
@@ -6821,6 +6828,7 @@ components:
- sourceKey
- sourceBusinessType
- sourceBusinessNo
- sourceKeys
DispatchDetail:
type: object
x-java-record: DispatchViews.Detail
@@ -8504,7 +8512,9 @@ components:
pattern: ^[1-9][0-9]*$
returnSelections:
type: array
description: 退回海外仓必填。来源必须属于本车派送,箱数不超过原拣货及未签收余量。
description: 退回海外仓必填,可同时选择同一派送下多张原订单,每个deliveryNo和lastMileOrderId组合只允许一项,各原单分别生成异常。同一派送在行锁内核验批次总退回量不超过未签收余量;所有来源属于本车及对应原单,任一失败整批回滚,原幂等键重放不重复建单或冻结。
minItems: 1
maxItems: 200
items:
type: object
required: [deliveryNo, lastMileOrderId, items]
@@ -11,7 +11,7 @@ import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
class MerchantTransportPhotoMapperTest {
/** 同编号、混装和撤销安排均不能扩大客户读取范围,实际查询使用正式Mapper。 */
/** 同单分托去重且分车全部保留;同编号、混装和撤销安排不扩大客户读取范围。 */
@Test void queriesOnlyExactCustomerOrderAndActiveDispatches() throws Exception {
var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
var db=new JdbcTemplate(data);
@@ -24,13 +24,13 @@ class MerchantTransportPhotoMapperTest {
db.update("INSERT INTO ll_local_looseboard_order VALUES('C1','LB1',1),('C2','LB1',2)");
db.update("INSERT INTO ll_inbound_plan VALUES(11,'C1',1),(12,'C2',2)");
db.update("INSERT INTO ll_inbound_plan_pallet VALUES(21,11,'C1'),(22,12,'C2')");
db.update("INSERT INTO ll_dispatch_order VALUES(31,'PC1','ACTIVE','DELIVERY'),(32,'PC2','CANCELLED','DELIVERY'),(33,'PC3','ACTIVE','LOCAL_TRUCK')");
db.update("INSERT INTO ll_dispatch_item VALUES(21,'C1',31),(22,'C2',31),(21,'C1',32),(22,'C1',33)");
db.update("INSERT INTO ll_dispatch_order VALUES(31,'PC1','ACTIVE','DELIVERY'),(32,'PC2','CANCELLED','DELIVERY'),(33,'PC3','ACTIVE','LOCAL_TRUCK'),(34,'PC4','ACTIVE','DELIVERY')");
db.update("INSERT INTO ll_dispatch_item VALUES(21,'C1',31),(21,'C1',31),(22,'C2',31),(21,'C1',32),(22,'C1',33),(21,'C1',34)");
db.update("INSERT INTO ll_dispatch_truck_task VALUES('PC3','C1','TR1','C1:TR1'),('PC2','C1','TR1','C1:TR1'),('PC1','C2','TR1','C2:TR1')");
var config=new MybatisConfiguration();config.addMapper(MerchantTransportPhotoMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(config);factory.afterPropertiesSet();
var repository=new MerchantTransportPhotoRepository(new SqlSessionTemplate(factory.getObject()).getMapper(MerchantTransportPhotoMapper.class));
assertThat(repository.looseboardDispatches("C1","LB1")).containsExactly("PC1");
assertThat(repository.looseboardDispatches("C1","LB1")).containsExactly("PC1","PC4");
assertThat(repository.looseboardDispatches("C3","LB1")).isEmpty();
assertThat(repository.truckDispatches("C1","TR1")).containsExactly("PC3");
assertThat(repository.truckDispatches("C2","TR1")).isEmpty();
@@ -8,12 +8,64 @@ import com.baiyue.lastleg.dispatch.port.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static org.assertj.core.api.Assertions.*;
class DispatchLooseboardDisplayServiceTest {
/** 维护车辆与排车详情共用订单投影,同单两托只展示一行且未知重量不补零。 */
@Test void looseboardOrderCombinesPalletsAndRetainsAllSourceKeys() {
var repository=mock(LooseboardCargoRepository.class);
when(repository.businessSources(any())).thenReturn(List.of(new BusinessSource("A","ZY1","LB202611050003",11L),new BusinessSource("A","ZY2","LB202611050002",13L)));
var first=item("A","ZY1",11L,"ZY1");var second=item("A","ZY1",12L,"ZY1");var other=item("A","ZY2",13L,"ZY2");
first.setVolumeCbm(new BigDecimal("5"));second.setVolumeCbm(new BigDecimal("5"));other.setVolumeCbm(new BigDecimal("4"));
var result=details(repository,List.of(first,second,other));
assertThat(result).hasSize(2);assertThat(result.get(0).orderNo()).isEqualTo("LB202611050003");
assertThat(result.get(0).palletCount()).isEqualTo(2);assertThat(result.get(0).volumeCbm()).isEqualByComparingTo("10");
assertThat(result.get(0).grossWeightKg()).isNull();assertThat(result.get(0).sourceKeys()).containsExactly("PLAN:11","PLAN:12");
assertThat(result.get(1).palletCount()).isEqualTo(1);assertThat(result.get(1).volumeCbm()).isEqualByComparingTo("4");
assertThat(first.getOrderNo()).isEqualTo("ZY1");assertThat(second.getVolumeCbm()).isEqualByComparingTo("5");
}
/** 同号跨客户不能合并,普通来源不能仅因编号类似散板而变成散板。 */
@Test void sameOrderNumberKeepsCustomerAndOrdinarySourceBoundaries() {
var repository=mock(LooseboardCargoRepository.class);
when(repository.businessSources(any())).thenReturn(List.of(new BusinessSource("A","ZY1","LB1",11L),new BusinessSource("B","ZY1","LB1",12L)));
var result=details(repository,List.of(item("A","ZY1",11L,"ZY1"),item("B","ZY1",12L,"ZY1"),item("C","LB1",13L,"REAL"),item("C","LB1",14L,"REAL")));
assertThat(result).hasSize(4);assertThat(result).extracting(DispatchViews.OrderDetail::customerId).containsExactly("A","B","C","C");
assertThat(result.get(2).sourceBusinessType()).isNull();assertThat(result.get(2).containerNo()).isEqualTo("REAL");
}
/** 同一实托关联多个来源只计一托和一次整托测量;缺测量的订单总量仍未知。 */
@Test void sharedPhysicalPalletIsCountedOnceAndPartialMeasurementRemainsUnknown() {
var repository=mock(LooseboardCargoRepository.class);
when(repository.businessSources(any())).thenReturn(List.of(new BusinessSource("A","ZY1","LB1",11L)));
var first=item("A","ZY1",11L,"ZY1");var second=item("A","ZY1",12L,"ZY1");
first.setWarehousePalletId(91L);second.setWarehousePalletId(91L);
first.setGrossWeightKg(new BigDecimal("30"));second.setGrossWeightKg(new BigDecimal("30.00"));
first.setVolumeCbm(new BigDecimal("5"));second.setVolumeCbm(new BigDecimal("5.0"));
var combined=details(repository,List.of(first,second)).get(0);
assertThat(combined.palletCount()).isEqualTo(1);assertThat(combined.volumeCbm()).isEqualByComparingTo("5");assertThat(combined.grossWeightKg()).isEqualByComparingTo("30");
second.setWarehousePalletId(92L);second.setVolumeCbm(null);
var partial=details(repository,List.of(first,second)).get(0);
assertThat(partial.palletCount()).isEqualTo(2);assertThat(partial.volumeCbm()).isNull();assertThat(partial.grossWeightKg()).isEqualByComparingTo("60");
second.setWarehousePalletId(91L);second.setGrossWeightKg(new BigDecimal("31"));
assertThat(details(repository,List.of(first,second)).get(0).grossWeightKg()).isNull();
}
/** 混托多订单无法分摊测量时,不给散板复制整托重量、体积或托数。 */
@Test void sharedPalletWithMultipleOrdersDoesNotInventPerOrderMeasurements() {
var repository=mock(LooseboardCargoRepository.class);
when(repository.businessSources(any())).thenReturn(List.of(new BusinessSource("A","ZY1","LB1",11L)));
var mixed=item("A","ZY1",11L,"ZY1");mixed.setOrderRefs("ZY1,OTHER");mixed.setGrossWeightKg(new BigDecimal("30"));mixed.setVolumeCbm(new BigDecimal("5"));
var result=details(repository,List.of(mixed));
assertThat(result).hasSize(1);assertThat(result.get(0).palletCount()).isNull();assertThat(result.get(0).grossWeightKg()).isNull();assertThat(result.get(0).volumeCbm()).isNull();
}
/** 调用正式排车关联订单投影,避免为聚合规则复制一份测试实现。 */
private List<DispatchViews.OrderDetail> details(LooseboardCargoRepository repository,List<DispatchItem> cargo) {
var aggregate=new DispatchAggregateService(null,null,null,null,null,null,new ObjectMapper());
ReflectionTestUtils.setField(aggregate,"looseboardDisplay",new DispatchLooseboardDisplayService(repository));
return ReflectionTestUtils.invokeMethod(aggregate,"orderDetails",cargo);
}
/** 历史排车仍按真实关联显示LB原单;不把内部ZY号代填为入仓码或集装箱号。 */
@Test void historicalDetailsUseBusinessOrderAndNoInventedContainer() {
var repository=mock(LooseboardCargoRepository.class);
@@ -64,6 +116,7 @@ class DispatchLooseboardDisplayServiceTest {
/** 构造旧排车保存的履约快照,验证读取时纠正且不改原来源。 */
private DispatchItem item(String customer,String number,long pallet,String container) {
var item=new DispatchItem();item.setCustomerId(customer);item.setOrderNo(number);item.setOrderRefs(number);item.setPlanPalletId(pallet);item.setInboundCode(number);
item.setSourceKey("PLAN:"+pallet);
item.setSourceSnapshotJson("{\"containerNo\":\""+container+"\",\"planNo\":\"plan\"}");return item;
}
}
@@ -207,7 +207,7 @@ class ExceptionOrderLifecycleTest {
var order = order("PENDING", 1);
order.setExceptionType("RETURN"); order.setDeliveryNo("DL-1");
order.setReturnContextJson("{\"deliveryStatus\":\"DELIVERED\",\"tailId\":null,\"tailStatus\":null,\"abnormal\":null}");
var delivery = new DeliveryOrder(); delivery.setStatus("RETURN_PENDING"); delivery.setVersion(3L);
var delivery = new DeliveryOrder(); delivery.setId(71L); delivery.setStatus("RETURN_PENDING"); delivery.setVersion(3L);
var signedAt = LocalDateTime.of(2026, 9, 1, 12, 0); delivery.setSignedAt(signedAt);
when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order);
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
@@ -36,7 +36,7 @@ class ReturnCartonReturnPersistenceTest {
var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
db=new JdbcTemplate(data);tx=new TransactionTemplate(new DataSourceTransactionManager(data));
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);
var mappers=List.of(ExceptionOrderMapper.class,ExceptionOrderCommandMapper.class,com.baiyue.lastleg.mapper.LastlegOrderMapper.class,com.baiyue.lastleg.mapper.LastlegInboundOrderMapper.class,ExceptionReturnItemMapper.class,ReturnCartonStockReceiptMapper.class,
var mappers=List.of(ExceptionWorkflowMapper.class,ExceptionOrderMapper.class,ExceptionOrderCommandMapper.class,com.baiyue.lastleg.mapper.LastlegOrderMapper.class,com.baiyue.lastleg.mapper.LastlegInboundOrderMapper.class,ExceptionReturnItemMapper.class,ReturnCartonStockReceiptMapper.class,
ReturnInventoryCartonMapper.class,ReturnInventoryCartonItemMapper.class,ReturnInventoryBalanceMapper.class,ReturnInventoryMovementMapper.class,
DeliveryOrderMapper.class,OutboundOrderMapper.class,OutboundPalletItemMapper.class,OutboundPdaPickMapper.class,OutboundPdaTaskMapper.class,DispatchAggregateMapper.class,DispatchReadMapper.class,DispatchItemMapper.class,DispatchFeeCargoMapper.class,
DispatchPdaTaskMapper.class,DispatchPdaPickMapper.class,DispatchPdaPickOrderMapper.class,DeliveryOperationMapper.class);
@@ -165,6 +165,75 @@ class ReturnCartonReturnPersistenceTest {
assertThatThrownBy(()->tx.execute(t->business.markReturn("C","OP","操作员","DL-1",foreign))).hasMessageContaining("主单不属于该派送单");
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_exception_order",Long.class)).isZero();
}
/** 同派送两张原单可一起退回,部分签收数量保留,同键重放不增异常。 */
@Test void multipleMastersOfOneDeliveryFreezeTogetherAndReplayWithoutDuplicateExceptions() {
var requests=prepareTwoMasterReturns();var business=business();
var created=tx.execute(t->business.markReturns("C","OP","操作员","DL-1",requests));
assertThat(created).hasSize(2);
assertThat(db.queryForList("SELECT last_mile_order_id FROM ll_exception_order ORDER BY last_mile_order_id",Long.class)).containsExactly(100L,101L);
assertThat(db.queryForObject("SELECT SUM(approved_return_packages) FROM ll_exception_return_item",Long.class)).isEqualTo(2);
assertThat(db.queryForObject("SELECT signed_packages FROM ll_delivery_order WHERE id=3",Long.class)).isEqualTo(2);
var replay=tx.execute(t->business.markReturns("C","OP","操作员","DL-1",requests));
assertThat(replay).allSatisfy(row->assertThat(row.replayed()).isTrue());
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_exception_order",Long.class)).isEqualTo(2);
}
/** 从排车适配器传入两张原单,真实服务分别建异常;各来源仍保持独立。 */
@Test void dispatchAdapterCreatesBothReturnsThroughRealService() {
var requests=prepareTwoMasterReturns();var business=business();
var repository=mock(com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository.class);
when(repository.deliveries(10L)).thenReturn(List.of(new com.baiyue.lastleg.exceptionorder.mapper.ExceptionWorkflowMapper.Delivery("C","DL-1")));
var adapter=new DispatchExceptionAdapter(repository,business);
var selections=requests.stream().map(r->new com.baiyue.lastleg.dispatch.port.DispatchExceptionPort.Selection("DL-1",r.lastMileOrderId(),
r.items().stream().map(i->new com.baiyue.lastleg.dispatch.port.DispatchExceptionPort.Item(i.outboundPalletItemId(),i.inventoryPositionId(),i.returnCartonId(),i.packageCount())).toList())).toList();
var command=new com.baiyue.lastleg.dispatch.port.DispatchCommands.MarkException(0L,"adapter-multiple",com.baiyue.lastleg.dispatch.port.DispatchTypes.ExceptionType.PARTIAL_RECEIPT,
com.baiyue.lastleg.dispatch.port.DispatchTypes.ReturnDirection.OVERSEAS_WAREHOUSE,"多单部分签收",List.of(),selections);
tx.executeWithoutResult(t->adapter.register(10L,"OP",command));
assertThat(db.queryForList("SELECT last_mile_order_id FROM ll_exception_order ORDER BY last_mile_order_id",Long.class)).containsExactly(100L,101L);
assertThat(db.queryForObject("SELECT SUM(approved_return_packages) FROM ll_exception_return_item",Long.class)).isEqualTo(2);
}
/** 多单退回可按任意顺序取消;最后一单取消才恢复批次前状态,签收历史保持不变。 */
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans={false,true})
void cancellingBothMastersRestoresOriginalStateInEitherOrder(boolean reversed) {
var requests=prepareTwoMasterReturns();var business=business();
var created=new ArrayList<>(tx.execute(t->business.markReturns("C","OP","操作员","DL-1",requests)));
if(reversed)Collections.reverse(created);
for(int index=0;index<created.size();index++) {
var row=created.get(index);
tx.executeWithoutResult(t->business.cancel("C","OP",row.exceptionNo(),new ExceptionOrderRequests.Cancel(0,"cancel-"+row.id(),"取消本单退回")));
assertThat(db.queryForObject("SELECT status FROM ll_delivery_order WHERE id=3",String.class)).isEqualTo(index==0?"RETURN_PENDING":"DELIVERING");
}
assertThat(db.queryForObject("SELECT signed_packages FROM ll_delivery_order WHERE id=3",Long.class)).isEqualTo(2);
assertThat(db.queryForList("SELECT abnormal FROM ll_order",String.class)).doesNotContain("true");
}
/** 第二单借用第一单原箱时,第一单异常和冻结数量也必须回滚。 */
@Test void invalidSecondMasterRollsBackTheWholeMultiOrderReturn() {
var requests=prepareTwoMasterReturns();var business=business();
var wrong=new ExceptionOrderRequests.MarkReturn(101L,"原因","OVERSEAS_WAREHOUSE",null,requests.get(0).items(),"batch-wrong");
assertThatThrownBy(()->tx.execute(t->business.markReturns("C","OP","操作员","DL-1",List.of(requests.get(0),wrong))))
.hasMessageContaining("不属于该派送单和尾程主单");
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_exception_order",Long.class)).isZero();
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_exception_return_item",Long.class)).isZero();
assertThat(db.queryForObject("SELECT status FROM ll_delivery_order WHERE id=3",String.class)).isEqualTo("DELIVERING");
}
/** 单笔数量均合法也不能让合计退回超过部分签收后的未签数量。 */
@Test void multiOrderTotalCannotIncludeAlreadySignedPackages() {
var requests=prepareTwoMasterReturns();var business=business();
db.update("UPDATE ll_delivery_order SET signed_packages=3 WHERE id=3");
assertThatThrownBy(()->tx.execute(t->business.markReturns("C","OP","操作员","DL-1",requests))).hasMessageContaining("退回总箱数");
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_exception_order",Long.class)).isZero();
}
/** 两张原单共享派送单,但各自只拥有一只已实际出库的原箱。 */
private List<ExceptionOrderRequests.MarkReturn> prepareTwoMasterReturns() {
prepareMarkReturn();
db.update("UPDATE ll_delivery_order SET status='DELIVERING',package_count=4,signed_packages=2 WHERE id=3");
db.update("INSERT INTO ll_order(id,customer_id,order_no,delivery_order_no,outbound_order_no,overseas_warehouse_id,package_count) VALUES(101,'C','LM-2','DL-1','OUT-1','92',1)");
db.update("INSERT INTO ll_dispatch_pda_pick(id,customer_id,task_id,dispatch_item_id,return_carton_id,packages,product_quantity) VALUES(104,'C',102,4,102,1,1)");
db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,packages) VALUES(106,'C',104,101,1)");
db.update("INSERT INTO ll_return_inventory_carton(id,customer_id,overseas_warehouse_id,carton_mark,status,total_quantity) VALUES(102,'C','92','BOX-2','OUTBOUNDED',1)");
return List.of(new ExceptionOrderRequests.MarkReturn(100L,"多单部分签收","OVERSEAS_WAREHOUSE",null,List.of(new ExceptionOrderRequests.ReturnItem(5L,null,1,101L)),"batch-master-1"),
new ExceptionOrderRequests.MarkReturn(101L,"多单部分签收","OVERSEAS_WAREHOUSE",null,List.of(new ExceptionOrderRequests.ReturnItem(5L,null,1,102L)),"batch-master-2"));
}
/** 将真实已出库换标主单置为拒签,不添加普通入仓关系。 */
private void prepareMarkReturn() {
db.update("DELETE FROM ll_exception_return_item");db.update("DELETE FROM ll_exception_order");
@@ -174,7 +243,8 @@ class ReturnCartonReturnPersistenceTest {
/** 使用真实Mapper组装异常业务服务,资源和文件网关维持隔离。 */
private ExceptionOrderService business() {
var business=new ExceptionOrderService();
org.springframework.test.util.ReflectionTestUtils.setField(business,"workflow",org.mockito.Mockito.mock(com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"workflow",new com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository(
map(ExceptionWorkflowMapper.class),map(ExceptionOrderMapper.class),map(com.baiyue.lastleg.mapper.LastlegOrderMapper.class)));
org.springframework.test.util.ReflectionTestUtils.setField(business,"exceptionOrders",map(ExceptionOrderMapper.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"returnItems",map(ExceptionReturnItemMapper.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"commands",map(ExceptionOrderCommandMapper.class));
@@ -18,6 +18,28 @@ import static org.mockito.Mockito.*;
/** 同筛选完整统计、旧单不回填和权限隔离的直接回归。 */
class OwnedBusinessQueryTest {
/** 自有、外配共享散板订单明细,跨客户同号不合并,目的地和托盘都不丢失。 */
@Test void detailsGroupLooseboardSourcesUsingDispatchOrderAuthorityInBothScopes() {
var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec();var dispatch=mock(DispatchDetailReader.class);
var original=mock(com.baiyue.lastleg.dispatch.port.DispatchViews.Detail.class);
var first=OwnedBusinessFixtures.cargo().get(0);var second=OwnedBusinessFixtures.cargo().get(1);var other=OwnedBusinessFixtures.cargo().get(0);
second.setCustomerId(first.getCustomerId());second.setWarehousePalletId(32L);second.setPalletNo("PAL-32");second.setDestinationWarehouseCode("LAX9");second.setPackageCount(8);
other.setCustomerId("102");other.setOrderNo("LB-SAME-NUMBER");
when(repo.cargo(any())).thenReturn(List.of(first,second,other));when(repo.batches(any())).thenReturn(List.of());
when(dispatch.detail(any(),any())).thenReturn(original);
when(original.orderDetails()).thenReturn(List.of(new com.baiyue.lastleg.dispatch.port.DispatchViews.OrderDetail("101","客户A","LB1",null,null,2,null,null,null,first.getSourceKey(),"LOCAL_LOOSEBOARD","LB1",List.of(first.getSourceKey(),second.getSourceKey())),
new com.baiyue.lastleg.dispatch.port.DispatchViews.OrderDetail("102","客户B","LB1",null,null,1,null,null,null,other.getSourceKey(),"LOCAL_LOOSEBOARD","LB1",List.of(other.getSourceKey()))));
var service=new OwnedBusinessQueryService(repo,new OwnedBusinessProjection(codec),codec,dispatch);
for(var scope:List.of(com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope.OWNED,com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope.OUTSOURCED)) {
var source=OwnedBusinessFixtures.source();source.setPooledCargo(scope.isPooledCargo());
var result=service.detailForWrite(OwnedBusinessFixtures.actor(),source,scope);
assertEquals(2,result.cargo().size());var order=result.cargo().get(0);
assertEquals("LB1",order.orderNo());assertNull(order.inboundCode());assertEquals("9",order.packages());
assertEquals("PAL-31、PAL-32",order.palletNo());assertEquals(List.of("FBA:ONT8","FBA:LAX9"),order.destinationKeys());
assertEquals("102",result.cargo().get(1).customerId());assertEquals("LB1",result.cargo().get(1).orderNo());
}
assertEquals("ORDER-IN-A",first.getOrderNo());verify(repo,never()).update(any(),anyLong());
}
/** 当前页只有一车时统计仍来自全量,日期边界转换为UTC且共用过滤。 */
@Test void summaryDoesNotUseCurrentPageLengthAndDateUsesPlannedDeparture() {
var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec();
@@ -47,13 +47,16 @@ class LocalLooseboardServiceTest {
@Mock LocalLooseboardOperationMapper operations;
@Mock LastlegOrderMapper lastlegOrders;
@Mock LastlegOrderStatusHistoryMapper lastlegHistories;
@Mock com.baiyue.lastleg.looseboard.port.LooseboardDispatches dispatches;
@Spy LastlegOrderNoGenerator lastlegOrderNoGenerator = com.baiyue.lastleg.service.TestDocumentNumbers.orders();
private LocalLooseboardService service;
private final Map<String, LocalLooseboardOperation> operationRows = new HashMap<>();
/** 注入真实仓储组合及客户隔离的排车查询端口。 */
@BeforeEach
void setUp() {
service = new LocalLooseboardService();
ReflectionTestUtils.setField(service, "dispatches", dispatches);
org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries.class));
var repository = new LocalLooseboardRepository();
ReflectionTestUtils.setField(service, "repository", repository);
@@ -77,6 +80,19 @@ class LocalLooseboardServiceTest {
});
}
/** 无车队预订的普通散板也返回全部排车,客户和原单必须原样限定。 */
@Test
void detailIncludesAllDispatchesWithoutFleetBooking() {
when(orders.findNo("100", "LB1")).thenReturn(order(LocalLooseboardService.IN_TRANSIT, 1L));
when(dispatches.looseboardDispatches("100", "LB1")).thenReturn(List.of("PC1", "PC2"));
var detail = service.detail("100", "LB1");
assertEquals(List.of("PC1", "PC2"), detail.dispatchNos());
org.junit.jupiter.api.Assertions.assertNull(detail.summary().fleetArrangement());
verify(dispatches).looseboardDispatches("100", "LB1");
}
@Test
void createIsTenantScopedIdempotentAndStartsPendingAcceptance() {
AtomicReference<LocalLooseboardOrder> saved = new AtomicReference<>();
+1
View File
@@ -647,3 +647,4 @@ Admin 全量中的 `phase4-cross-region-events` 使用内部 `test-event-fault-e
固定角色/账号统一配置:TEST 使用显式环境文件中的 `ADMIN_TEST_FIXED_ACTORS` JSON 对象,GRAY 通用入口使用 `ADMIN_GRAY_FIXED_ACTORS`,GRAY 拼车和页面样本使用 `CHONGCHONG_ACCEPTANCE_FIXED_ACTORS`,不跨环境借用。槽位和字段见模块索引 `fixed-test-actors.mjs`;密码仅留在私有环境文件及明确的私有页面交接文件中。GRAY PDA 账号通过 `CHONGCHONG_ACCEPTANCE_PDA_ACTORS` 传递到专用入口(通用入口为 `ADMIN_GRAY_PDA_ACTORS`);只读核验并复用已有仓绑定账号。当前 Admin HTTP 客户端拒绝 POST 新增角色、账号、权限和供应商,所有上下文均不例外。角色权限不足、账号停用或供应商缺失均停止,不自动创建、启用、重置或扩大权限。拼车 HTTP 和成本探针完成后仅退出会话;页面固定账号保留原状态,不停用。
本机 `local-dispatch-transport-acceptance.mjs`、`local-transfer-pickup-acceptance.mjs` 也只复用配置的平台账号和 `LOCAL_TRANSFER_PDA_USERNAME/PASSWORD`,不再补权限、创建操作员角色或PDA账号;权限不足即停止。Client HTTP 客户端同样拒绝新增客户角色、成员与权限。
`local-mixed-looseboard-workflow-acceptance.mjs`:仅本机统一 Gateway 的六单混排 HTTP 验收,三单送货到仓分两托实收共九箱、三单异地上门取货共六箱。使用显式开发配置的 `ADMIN_TEST_*` 与 `MIXED_PDA_USERNAME/MIXED_PDA_PASSWORD`(密码可复用显式 `LOCAL_TRANSFER_PDA_PASSWORD`),例如 `node --env-file=<开发配置> --env-file=<PDA配置> scripts/local-mixed-looseboard-workflow-acceptance.mjs`。会写客户测试原单、排车、专用测试车及司机和合成照片;默认报告 `reports/local-mixed-looseboard-workflow.json`,`MIXED_ACCEPTANCE_OUTPUT` 可覆盖,`MIXED_ACCEPTANCE_PHASE=prepare` 停在审核后。默认 `MIXED_ACCEPTANCE_SCENARIO=multi-return` 一次选择同派送两张原单各退一箱,生成退回单、计划并由PDA实收,重放核对库存仅恢复一次,剩余七箱保存签收。异常实签实退核平后调用正式车次结案入口;账号缺少结案权限时报告 `pendingAction`,不得声称结案或资源已释放。`MIXED_ACCEPTANCE_SCENARIO=normal` 配合新的独立报告路径,验证全部九箱正常签收及自动结案释放。回仓通过正式资源接口创建两张本轮托盘并绑定既有有效库位,不复用已报废的原出库托。只复用既有客户/PDA权限,不直接写数据库,不删除数据,续跑沿报告原单和幂等键恢复,未知写结果停写。HTTP 通过不代表 PDA 真机扫码、实物测量或真实凭证验收。
+14 -2
View File
@@ -69,7 +69,8 @@ case "$command" in
esac
`
async function runDeployment({ imageTag, failingTag, failingStartTag, restartingTag, allowRollback = true, environment = 'test', service = 'audit-service', missingHealth = false, topicPrefix = `${environment}-`, remote = false }) {
// 通过临时 Docker/SSH 替身验证环境选择、健康门禁及失败后的镜像恢复。
async function runDeployment({ imageTag, failingTag, failingStartTag, restartingTag, allowRollback = true, environment = 'test', service = 'audit-service', missingHealth = false, missingTestLimits = false, topicPrefix = `${environment}-`, remote = false }) {
const temporaryDirectory = await mkdtemp(join(tmpdir(), 'firstleg-rollback-'))
const mockBin = join(temporaryDirectory, 'bin')
const dockerPath = join(mockBin, 'docker')
@@ -115,7 +116,7 @@ cp "$1" "\${2#*:}"
service,
envFile.replaceAll('\\', '/')
], {
cwd: repositoryRoot,
cwd: missingTestLimits ? temporaryDirectory : repositoryRoot,
encoding: 'utf8',
env: {
...process.env,
@@ -158,6 +159,16 @@ test('successful deployment keeps the new healthy image', async (context) => {
assert.match(result.stdout, /部署前运行镜像: registry\.local\/group\/firstleg\/audit-service:oldgood/)
assert.match(result.stdout, /新镜像健康检查通过: registry\.local\/group\/firstleg\/audit-service:newgood/)
assert.equal(result.currentImage, 'registry.local/group/firstleg/audit-service:newgood')
for (const command of result.dockerLog.split(/\r?\n/).filter(line => line.startsWith('compose '))) {
assert.match(command, /-f deploy\/docker-compose\.yml -f deploy\/docker-compose\.test-limits\.yml /)
}
})
test('TEST rejects a missing memory limits file before Docker', async () => {
const result = await runDeployment({ imageTag: 'newgood', missingTestLimits: true })
assert.equal(result.status, 2)
assert.equal(result.dockerLog, '')
assert.match(result.stderr, /TEST memory limits file missing/)
})
test('unhealthy deployment restores the previous healthy image and still fails the job', async (context) => {
@@ -232,6 +243,7 @@ test('gray deploy preserves health checks and uses an isolated Compose project',
assert.equal(result.status, 0, result.stderr)
assert.match(result.dockerLog, /--project-name firstleg-production/)
assert.doesNotMatch(result.dockerLog, /--project-name firstleg-test/)
assert.doesNotMatch(result.dockerLog, /docker-compose\.test-limits\.yml/)
})
test('gray deployment rejects TEST message scope before any Docker operation', async () => {
@@ -0,0 +1,299 @@
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { createHash } from 'node:crypto'
import { prepareFleetTrip } from './admin-test-env-modules/fleet-trip-fulfillment.mjs'
const gateway = process.env.ADMIN_TEST_GATEWAY?.replace(/\/$/, '')
if (!gateway || !['localhost', '127.0.0.1'].includes(new URL(gateway).hostname)) throw Error('必须显式指定本机 Gateway')
const output = resolve(process.env.MIXED_ACCEPTANCE_OUTPUT || 'reports/local-mixed-looseboard-workflow.json')
const scenario = process.env.MIXED_ACCEPTANCE_SCENARIO || 'multi-return'
if (!['multi-return', 'normal'].includes(scenario)) throw Error('验收场景仅支持 multi-return 或 normal')
let report
try { report = JSON.parse(await readFile(output, 'utf8')) } catch (error) { if (error.code !== 'ENOENT') throw error }
report ||= { marker: `MIXLB-${Date.now()}`, environment: 'LOCAL', syntheticEvidence: true, orders: [], operations: {}, checks: [], createdResources: {} }
if (report.scenario && report.scenario !== scenario || !report.scenario && report.orders.length && scenario !== 'multi-return') throw Error('不同场景必须使用独立验收报告')
report.scenario = scenario
const root = '/api/platform/v1/last-leg', pr = '/api/pda/v1/last-leg'
const key = name => createHash('sha256').update(`${report.marker}:${name}`).digest('hex')
let admin, pda, merchant
/** 原子保存写请求和回执,失败留单续跑,不删除或重复造单。 */
async function save() { await mkdir(dirname(output), { recursive: true }); await writeFile(`${output}.tmp`, JSON.stringify(report, null, 2)); await rename(`${output}.tmp`, output) }
/** 断言保留业务含义,失败立即停止后续写入。 */
function check(value, label) { report.checks.push({ label, passed: Boolean(value) }); if (!value) throw Error(label) }
/** 统一Gateway真实HTTP;会话凭据不写入报告。 */
async function raw(method, path, body, token = admin) {
const form = body instanceof FormData
const response = await fetch(gateway + path, { method, signal: AbortSignal.timeout(45000), headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body !== undefined && !form ? { 'Content-Type': 'application/json' } : {}), ...(path.endsWith('/exception-orders/return-inbound-plans') ? { 'Idempotency-Key': key(`return-plan-${body.plan.orderId}`) } : {}) }, body: body === undefined ? undefined : form ? body : JSON.stringify(body) })
const value = await response.json()
return { http: response.status, ...value, requestId: value.requestId || response.headers.get('x-request-id'), traceId: value.traceId || response.headers.get('x-trace-id') }
}
/** 仅读显式配置的现有账号,不新建或修改权限。 */
async function login(username, password) {
if (!username || !password) throw Error('缺少显式验收账号配置')
const result = await raw('POST', '/api/auth/login', { loginId: username, password }, '')
if (String(result.code) !== '200') throw Error(`登录失败:${result.code} ${result.message}`)
return result.data.accessToken
}
/** 每条命令前读取当前服务端事实,禁止自行推算版本。 */
async function get(path, token = admin) {
const result = await raw('GET', path, undefined, token)
if (result.http !== 200 || String(result.code) !== '200') throw Error(`GET ${path}: ${result.http}/${result.code} ${result.message}`)
return result.data
}
/** 原步骤成功回执可恢复;未知写结果停写,保留原幂等键供核对。 */
async function write(name, method, path, body, token = admin) {
if (report.operations[name]?.status === 'SUCCESS') return report.operations[name].result
if (report.unknownWrite) throw Error(`上次写结果未知:${report.unknownWrite}`)
const entry = { method, path, body: body instanceof FormData ? 'SYNTHETIC_TEST_IMAGE' : body, status: 'SENDING' }
report.operations[name] = entry; await save()
let result
try { result = await raw(method, path, body, token) } catch (error) { entry.status = 'UNKNOWN'; report.unknownWrite = name; await save(); throw error }
Object.assign(entry, { http: result.http, code: result.code, requestId: result.requestId, traceId: result.traceId, message: result.message })
entry.status = result.http === 200 && String(result.code) === '200' ? 'SUCCESS' : result.http >= 500 || result.code == null ? 'UNKNOWN' : 'FAILED'
if (entry.status === 'UNKNOWN') report.unknownWrite = name
if (entry.status === 'SUCCESS') entry.result = result.data
await save()
if (entry.status !== 'SUCCESS') throw Error(`${method} ${path}: ${result.http}/${result.code} ${result.message}`)
return result.data
}
/** 合成小图仅证明文件协议和归属,不能代表现场物流证据。 */
async function photo(name, path, token = admin) {
const form = new FormData(); form.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')], { type: 'image/png' }), `${report.marker}-${name}-SYNTHETIC-TEST.png`)
return write(name, 'POST', path, form, token)
}
/** 三箱分两托实收,订单展示应汇总一行,PDA沿用真实托盘明细。 */
async function receive(order) {
const path = `${pr}/warehouse-transfers/${order.transferNo}`, label = order.orderNo
let d = await get(path, pda)
if (d.status === 'READY_FOR_DISPATCH') return
while (d.pallets.length < 2) { await write(`${label}-draft-${d.pallets.length}`, 'POST', `${path}/pallets/drafts`, { idempotencyKey: key(`${label}-draft-${d.pallets.length}`) }, pda); d = await get(path, pda) }
for (const initial of d.pallets) {
let p = d.pallets.find(row => row.id === initial.id)
if (p.status === 'DRAFT') await write(`${label}-generate-${p.id}`, 'POST', `${path}/pallets/${p.id}/generate`, { idempotencyKey: key(`${label}-generate-${p.id}`) }, pda)
d = await get(path, pda); p = d.pallets.find(row => row.id === initial.id)
if (p.status === 'GENERATED') await write(`${label}-bind-${p.id}`, 'POST', `${path}/pallets/${p.id}/bind`, { idempotencyKey: key(`${label}-bind-${p.id}`), palletCode: p.palletNo }, pda)
d = await get(path, pda)
}
if (!d.cartons.every(row => row.countedAt)) await write(`${label}-count`, 'POST', `${path}/cartons/count`, { idempotencyKey: key(`${label}-count`), forecastPackageCount: d.packageCount,
boxes: d.cartons.map((row, i) => ({ cartonMark: row.cartonMark, transferPalletId: d.pallets[i === 0 ? 0 : 1].id, length: 40, width: 30, height: 25, weight: 4, items: [{ skuCode: d.items[0].skuCode, quantity: 1 }] })) }, pda)
const f = await photo(`${label}-measurement`, `${path}/measurement-photos/files`, pda)
await write(`${label}-palletize`, 'POST', `${path}/palletize`, { idempotencyKey: key(`${label}-palletize`), photoFileIds: [f.fileId] }, pda)
d = await get(path, pda); check(d.status === 'READY_FOR_DISPATCH' && d.pallets.length === 2, `${label}三箱两托实收完成`); order.receiving = d; await save()
}
/** 六张本轮原单:三单到仓实收,三单保留异地上门取货。 */
async function prepare() {
const customers = (await get('/api/platform/v1/iam/tenants?page=1&size=200')).records
report.customerId ||= customers.find(row => row.status === 'ACTIVE' && /测试/.test(row.tenantName || row.name))?.id
report.warehouseId ||= (await get('/api/platform/v1/warehouse-admin?page=1&size=100&warehouseType=OVERSEAS&status=ACTIVE')).records.find(row => row.warehouseCode === 'US-LAX-01')?.id
check(report.customerId && report.warehouseId, '复用有效测试客户和洛杉矶仓'); await save()
const ticket = await raw('POST', `/api/platform/v1/iam/tenants/${report.customerId}/impersonation`, { reason: `${report.marker}本机混排验收` })
check(String(ticket.code) === '200', '获取本轮客户下单会话')
merchant = (await raw('POST', '/api/auth/impersonations/exchange', { code: ticket.data.code }, '')).data.accessToken
for (const mode of ['DELIVERY_TO_WAREHOUSE', 'WAREHOUSE_PICKUP']) for (let i = 0; i < 3; i++) {
const label = `${mode}-${i}`, remote = mode === 'WAREHOUSE_PICKUP'
let order = report.orders.find(row => row.label === label)
if (!order) {
const created = await write(label, 'POST', '/api/merchant/v1/last-leg/local-looseboard-orders', { operatingWarehouseId: report.warehouseId, destinationType: 'PRIVATE', destinationPostalCode: '91761', deliveryMode: mode,
deliveryContactName: '合成验收联系人', deliveryContactPhone: '0000000000', estimatedArrivalAt: '2026-11-06T09:00:00', trackingNo: `${report.marker}-${label}`,
origin: 'Ontario TEST', originAddress: `100 TEST Pickup Road ${i}`, destination: 'Ontario TEST', destinationAddress: '200 TEST Destination Road', productName: `${report.marker}-${label}`,
estimatedPalletCount: 2, packageCount: remote ? 2 : 3, totalWeightKg: remote ? 8 : 12, totalVolumeCbm: remote ? 0.06 : 0.09, budgetAmount: 25, currency: 'USD',
originContactName: '合成验收取货员', originContactPhone: '0000000000', destinationContactName: '合成验收收货员', destinationContactPhone: '0000000000',
pickupAt: '2026-11-06T09:00:00', deliveryAt: '2026-11-07T09:00:00', remark: `${report.marker} SYNTHETIC TEST`, idempotencyKey: key(label) }, merchant)
order = { label, mode, orderNo: created.summary.orderNo }; report.orders.push(order); await save()
}
const path = `${root}/local-looseboard-orders/${order.orderNo}`, query = `?customerId=${report.customerId}`
let d = await get(path + query)
if (d.summary.status === 'PENDING_ACCEPTANCE') await write(`${label}-review`, 'POST', `${path}/review${query}`, { version: d.summary.version, decision: 'APPROVE', idempotencyKey: key(`${label}-review`), remark: report.marker })
d = await get(path + query); order.transferNo = d.summary.transferNo; await save(); check(order.transferNo, `${order.orderNo}审核生成真实履约来源`)
if (!remote && !order.receiving) await receive(order)
}
if (!report.dispatchNo) { report.dispatchNo = (await write('create-dispatch', 'POST', `${root}/dispatches`, { idempotencyKey: key('create-dispatch'), warehouseId: report.warehouseId, publishLooseboardDemand: true, cargo: [], batchAllocations: [], remark: `${report.marker} 仓内九箱+异地六箱` })).dispatchNo; await save() }
const path = `${root}/dispatches/${report.dispatchNo}`, cp = `${root}/dispatches/capacity-demands/${report.dispatchNo}/cargo`
if (!report.operations['add-cargo']?.result) {
const selected = (await get(`${cp}?page=1&size=50`)).candidates.filter(row => report.orders.some(order => row.inboundCode === order.transferNo || row.orderNo === order.orderNo || row.sourceBusinessNo === order.orderNo))
check(selected.length >= 6 && selected.every(row => !row.blockReasons.length), '六单均为正式可选来源')
const sources = new Map(selected.flatMap(row => row.selectionGroup.sources).map(row => [row.sourceKey, row.selection]))
await write('add-cargo', 'POST', cp, { recordVersion: (await get(path)).recordVersion, idempotencyKey: key('add-cargo'), cargo: [...sources.values()], batchAllocations: [] })
}
report.preparedDetail = await get(path); await save()
check(Number(report.preparedDetail.counts.packageCount) === 15, '混排十五箱:仓内九箱、异地六箱')
check(report.preparedDetail.orderDetails.length === 6, '关联订单按六张散板原单汇总')
if (!report.fleet) {
const context = { marker: report.marker, options: {}, createdResources: report.createdResources, checkpoint: save,
request: async (method, url, body, token) => { const r = await raw(method, url, body, token || admin); return { http: r.http, body: r } },
requestMultipart: async (method, url, file) => { const f = new FormData(); f.append('file', new Blob([file.content], { type: file.type }), file.name); const r = await raw(method, url, f); return { http: r.http, body: r } },
apiSuccess: (module, label, response) => { check(response.http === 200 && String(response.body.code) === '200', `${label}: ${response.body.message}`); return response.body.data }, assert: (module, label, response, passed) => check(passed, label) }
report.fleet = await prepareFleetTrip(context, 'local-mixed-looseboard', report.dispatchNo, report.warehouseId); await save()
}
}
/** PDA只拣仓内九箱;异地六箱不进入拣货校验和仓内出库。 */
async function pickAndLoad() {
const path = `${pr}/dispatches/${report.dispatchNo}`
let task = await get(path, pda); report.pdaBefore ||= task; await save()
check(Number(task.counts.packageCount) === 9 && task.items.every(row => row.positions?.length), 'PDA只要求仓内九箱')
for (const initial of task.items) {
task = await get(path, pda); let item = task.items.find(row => row.dispatchItemId === initial.dispatchItemId)
for (const position of item.positions) {
const packages = Math.min(Number(position.availablePackages), Number(item.scheduledPackages) - Number(item.pickedPackages))
if (packages <= 0) continue
const name = `pick-${item.dispatchItemId}-${position.positionId}`
const prep = await write(`${name}-rent`, 'POST', `${pr}/storage-rent/preparations`, { mode: 'DISPATCH', businessNo: report.dispatchNo, itemId: item.dispatchItemId, positionId: position.positionId, packages, productQuantity: packages, idempotencyKey: key(name) }, pda)
check(prep.ready === true, '本轮新入库货物仓租预校验就绪')
await write(name, 'POST', `${path}/items/${item.dispatchItemId}/pick`, { recordVersion: task.recordVersion, positionId: position.positionId, packages, productQuantity: packages, idempotencyKey: key(name), remark: report.marker }, pda)
task = await get(path, pda); item = task.items.find(row => row.dispatchItemId === initial.dispatchItemId)
}
}
task = await get(path, pda); report.pdaPicked = task; await save(); check(task.pickingReconciled && Number(task.counts.pickedPackageCount) === 9, '九箱实际拣货核平')
if (task.stage === 'LOADING') { const loading = await get(`${path}/loading`, pda); await write('loading-complete', 'POST', `${path}/loading-complete`, { recordVersion: loading.recordVersion, idempotencyKey: key('loading-complete'), manifestHash: loading.manifestHash, remark: report.marker }, pda) }
report.afterLoading = await get(`${root}/dispatches/${report.dispatchNo}`); await save(); check(report.afterLoading.stage === 'DELIVERING', '仓内装车完成立即发运,不等待异地取货')
}
/** 上门取货三单各存一组取货/送达照片,分托不增加资料块。 */
async function remoteDocuments() {
const path = `${root}/dispatches/${report.dispatchNo}`
let docs = await get(`${path}/loading-documents`)
for (const slot of ['LOADING_SCENE', 'LOADING_LIST', 'PRE_DEPARTURE_CHECK', 'DRIVER_LICENSE', 'RECEIPT']) {
if (docs.photos.some(row => row.slot === slot)) continue
const name = `loading-photo-${slot}`, uploadKey = key(`${name}-upload`)
const f = await photo(name, `${path}/loading-documents/${slot}/uploads?reviewRound=${docs.reviewRound}&idempotencyKey=${uploadKey}`)
await write(`${name}-bind`, 'PUT', `${path}/loading-documents/${slot}`, { recordVersion: docs.recordVersion, idempotencyKey: key(`${name}-bind`), fileId: f.fileId, uploadKey })
docs = await get(`${path}/loading-documents`)
}
report.loadingDocuments = docs; check(docs.complete, '五类装车资料补齐')
let execution = await get(`${path}/looseboard-execution`)
report.looseboardBefore ||= execution; await save()
const items = execution.items.filter(row => row.pickupRequired)
check(items.length === 3 && new Set(items.map(row => row.orderNo)).size === 3, '上门取货按三张订单展示资料,六个计划托不拆六块')
for (const phase of ['PICKUP', 'ARRIVAL']) {
if (phase === 'ARRIVAL') {
execution = await get(`${path}/looseboard-execution`)
if (!execution.pickupCompleted) await write('pickup-complete', 'POST', `${path}/looseboard-execution/pickup-complete`, { recordVersion: execution.recordVersion, idempotencyKey: key('pickup-complete') })
}
for (const initial of items) {
execution = await get(`${path}/looseboard-execution`)
const row = execution.items.find(item => item.itemId === initial.itemId), photos = phase === 'PICKUP' ? row.pickupPhotos : row.arrivalPhotos
if (photos.length) continue
const name = `${initial.orderNo}-${phase}`, f = await photo(`${name}-upload`, `${path}/looseboard-execution/${initial.itemId}/${phase}/uploads`)
await write(`${name}-save`, 'PUT', `${path}/looseboard-execution/${initial.itemId}/${phase}`, { recordVersion: execution.recordVersion, idempotencyKey: key(`${name}-save`), fileIds: [f.fileId] })
}
}
execution = await get(`${path}/looseboard-execution`)
if (!execution.pickupCompleted) await write('pickup-complete', 'POST', `${path}/looseboard-execution/pickup-complete`, { recordVersion: execution.recordVersion, idempotencyKey: key('pickup-complete') })
report.looseboardAfter = await get(`${path}/looseboard-execution`); await save()
check(report.looseboardAfter.pickupCompleted && report.looseboardAfter.items.filter(row => row.pickupRequired).every(row => row.pickupPhotos.length && row.arrivalPhotos.length), '三单上门取货和送达资料全部完成')
}
/** 一次选择同派送的两张原订单,生成两张异常并沿PDA分批回仓实收。 */
async function exceptionReturns() {
const path = `${root}/dispatches/${report.dispatchNo}`, query = `?customerId=${report.customerId}`
if (!report.operations['multi-order-return']?.result) {
const candidates = await get(`${path}/exception-return-options`)
report.returnOptions = candidates; await save()
const originals = report.orders.filter(row => row.mode === 'DELIVERY_TO_WAREHOUSE').slice(0, 2)
const selected = originals.map(order => candidates.find(row => row.lastMileOrderNo === order.transferNo || row.lastMileOrderNo === order.orderNo))
check(selected.every(Boolean) && selected[0].deliveryNo === selected[1].deliveryNo, '同一派送单下两张原单均可选择')
const returns = selected.map(row => ({ deliveryNo: row.deliveryNo, lastMileOrderId: row.lastMileOrderId,
items: [{ outboundPalletItemId: row.sources[0].outboundPalletItemId, inventoryPositionId: row.sources[0].inventoryPositionId,
returnCartonId: row.sources[0].returnCartonId, packageCount: 1 }] }))
await write('multi-order-return', 'POST', `${path}/exceptions`, { recordVersion: (await get(path)).recordVersion, idempotencyKey: key('multi-order-return'),
exceptionType: 'PARTIAL_RECEIPT', returnDirection: 'OVERSEAS_WAREHOUSE', description: `${report.marker} 两单各退一箱,其余签收`, imageFileIds: [], returnSelections: returns })
}
const rows = (await get(`${root}/exception-orders?page=1&size=100&keyword=${encodeURIComponent(report.marker)}`)).records.filter(row => row.reason?.includes(report.marker))
check(rows.length === 2, '一次多选实际生成两张独立异常')
report.exceptions ||= []
for (const row of rows) {
const name = row.exceptionNo, ep = `${root}/exception-orders/${name}`, pp = `${pr}/return-receipts/exceptions/${name}`
let detail = await get(ep + query)
if (!detail.returnOrderNo) await write(`${name}-generate`, 'POST', `${ep}/generate-return-inbound${query}`, { expectedVersion: detail.order.version, idempotencyKey: key(`${name}-generate`), handlingAction: '合成测试退回海外仓' })
detail = await get(ep + query)
const tails = (await get(`${root}/orders?page=1&size=50&orderNo=${detail.returnOrderNo}`)).records.filter(item => item.orderNo === detail.returnOrderNo)
check(tails.length === 1, `${name}唯一关联退回尾程单`)
const tail = tails[0]
if (!report.operations[`${name}-plan`]?.result) await write(`${name}-plan`, 'POST', `${root}/exception-orders/return-inbound-plans`, { expectedArrivalAt: '2026-11-06T10:00:00',
plan: { customerId: report.customerId, orderId: tail.id, expectedOrderVersion: tail.version, palletCount: 1, palletType: 'STANDARD', assigned: false, mergeRequired: false, urgent: false, remark: report.marker } })
if (detail.order.status !== 'COMPLETED') {
const tasks = await get(`${pr}/return-receipts/exceptions`, pda); check(tasks.some(task => task.exceptionNo === name), `${name}出现在PDA回仓任务`)
const started = await write(`${name}-start`, 'POST', `${pp}/start`, {}, pda)
const original = report.orders.find(order => order.transferNo === row.lastMileOrderNo)
// 原出库托盘可能已报废;回仓另建实际可用托盘,沿用原仓有效虚拟库位。
const sourcePallet = original.receiving.pallets[0]
const resourcePath = `/api/platform/v1/overseas-warehouse-resources/${report.warehouseId}`
const targetId = await write(`${name}-target-pallet`, 'POST', `${resourcePath}/pallets`, {
palletCode: `${report.marker}-${name}`, palletType: 'STANDARD', capacityCbm: 2, unitPrice: 0,
status: 'IDLE', locationId: sourcePallet.warehouseLocationId, remark: `${report.marker} 异常回仓验收托盘` })
const target = await get(`${resourcePath}/pallets/${targetId}`)
check(target.status !== 'SCRAPPED' && target.locationId === sourcePallet.warehouseLocationId, `${name}使用当前仓真实有效托盘`)
const f = await photo(`${name}-evidence`, `${pp}/evidence`, pda)
await write(`${name}-receive`, 'POST', `${pp}/receive`, { expectedVersion: started.order.version, idempotencyKey: key(`${name}-receive`),
palletCode: target.palletCode, locationCode: sourcePallet.warehouseLocationCode, evidenceFileIds: [f.fileId],
items: started.returnSources.map(source => ({ outboundPalletItemId: source.outboundPalletItemId, inventoryPositionId: source.inventoryPositionId, returnCartonId: source.returnCartonId, packages: Number(source.maxReturnPackages) })) }, pda)
}
detail = await get(ep + query)
check(detail.order.status === 'COMPLETED' && detail.returnReceipts.reduce((sum, receipt) => sum + Number(receipt.receivedPackages), 0) === 1, `${name}PDA实收一箱且异常完结`)
const returnedTail = (await get(`${root}/orders?page=1&size=50&orderNo=${detail.returnOrderNo}`)).records.find(item => item.orderNo === detail.returnOrderNo)
check(returnedTail.currentStatus === 'INBOUND_COMPLETED' && Number(returnedTail.receivedPackageCount) === 1, `${name}退回尾程已入库签收`)
const receiptCommand = report.operations[`${name}-receive`]
const replay = await raw(receiptCommand.method, receiptCommand.path, receiptCommand.body, pda)
check(String(replay.code) === '200' && replay.data.replayed, `${name}实收同键重放不重复入账`)
const inventoryPath = `${root}/inventory`
const ledger = await get(`${inventoryPath}/ledger${query}&size=100&businessType=RETURN_STOCK_IN&businessOrderNo=${name}`)
const balances = await get(`${inventoryPath}/balances${query}&size=100&inboundCode=${row.inboundCode}`)
check(ledger.records.reduce((sum, entry) => sum + Number(entry.deltaPackages), 0) === 1, `${name}退回库存流水仅增加一箱`)
check(balances.records.reduce((sum, balance) => sum + Number(balance.availablePackages), 0) === 1, `${name}原单实际可用库存恢复一箱`)
report.inventoryVerification ||= {}; report.inventoryVerification[name] = { ledger, balances, receiptReplay: replay.data }
report.exceptions = [...report.exceptions.filter(item => item.order.exceptionNo !== name), detail]; await save()
}
await finishDelivery(7)
}
/** 签收只记录剩余实际交付量;异常车核平实签实退后通过正式结案入口释放资源。 */
async function finishDelivery(expectedSigned) {
const path = `${root}/dispatches/${report.dispatchNo}`, query = `?customerId=${report.customerId}`
const delivery = report.afterLoading.documents.deliveries[0], dp = `${root}/delivery-orders/${delivery.deliveryNo}`
let documents = await get(`${dp}/documents${query}`)
if (!report.operations['delivery-pod-save']?.result) {
const f = await photo('delivery-pod', `${dp}/documents/RECEIPT/files${query}`)
await write('delivery-pod-save', 'PUT', `${dp}/documents${query}`, { expectedVersion: documents.version, idempotencyKey: key('delivery-pod-save'), bolNo: `${report.marker}-TEST-BOL`,
documents: documents.categories.map(category => ({ category: category.category, fileIds: category.category === 'RECEIPT' ? [f.fileId] : category.files.map(file => file.fileId) })) })
}
report.deliveryFinal = await get(dp + query)
check(report.deliveryFinal.order.status === 'DELIVERED' && Number(report.deliveryFinal.order.signedPackages) === expectedSigned, `仓内实际签收${expectedSigned}箱凭签收文件完结`)
if (scenario === 'normal') {
for (let attempt = 0; attempt < 15; attempt++) {
report.finalDispatch = await get(path)
if (report.finalDispatch.stage === 'DELIVERED') break
await new Promise(resolve => setTimeout(resolve, 1000))
}
check(report.finalDispatch.stage === 'DELIVERED', '全正常混排自动进入派送完成')
}
report.finalDispatch = await get(path); report.finalTrip = await get(`${root}/fleet/trips/${report.fleet.tripNo}/completion`); await save()
if (scenario === 'multi-return' && report.finalTrip.canComplete) {
const previous = report.operations['exception-trip-complete']
if (previous?.code !== 'A0403') {
try {
await write('exception-trip-complete', 'POST', `${root}/fleet/trips/${report.fleet.tripNo}/complete`, {
recordVersion: report.finalTrip.recordVersion, idempotencyKey: key('exception-trip-complete'), reason: `${report.marker} 核实七箱签收两箱实际回仓后结案` })
} catch (error) { if (report.operations['exception-trip-complete']?.code !== 'A0403') throw error }
}
}
report.tripDetail = await get(`${root}/fleet/trips/${report.fleet.tripNo}`)
if (report.operations['exception-trip-complete']?.code === 'A0403') {
report.pendingAction = { action: 'EXCEPTION_TRIP_CLOSE', tripNo: report.fleet.tripNo, reason: '当前验收账号缺少platform:fleet:trip:complete,保留原权限及车次事实,待有权限账号结案。' }
check(report.finalTrip.canComplete && report.finalTrip.outcomes.every(row => row.remainingPackages === 0), '异常车实签实退核平,可由有权限账号结案')
} else check(report.tripDetail.trip.status === 'COMPLETED' && report.tripDetail.trip.vehicleRelease && report.tripDetail.trip.driverRelease, '车次结案并释放车辆及司机')
report.harnessCorrections ||= []
for (const previous of report.checks.filter(row => row.label === '仓内签收和上门取货送达后整车完结')) {
report.harnessCorrections.push({ ...previous, reason: '现有排车规则仅原实发全部正常签收推进DELIVERED;部分签收及实退通过车次正式结案,不能把实退伪作签收。' })
}
report.checks = report.checks.filter(row => row.label !== '仓内签收和上门取货送达后整车完结')
}
try {
admin = await login(process.env.ADMIN_TEST_USERNAME, process.env.ADMIN_TEST_PASSWORD)
pda = await login(process.env.MIXED_PDA_USERNAME, process.env.MIXED_PDA_PASSWORD || process.env.LOCAL_TRANSFER_PDA_PASSWORD)
await prepare()
if (process.env.MIXED_ACCEPTANCE_PHASE !== 'prepare') {
if (!report.afterLoading) await pickAndLoad()
await remoteDocuments()
if (scenario === 'multi-return') await exceptionReturns()
else await finishDelivery(9)
}
report.phase = process.env.MIXED_ACCEPTANCE_PHASE === 'prepare' ? 'PREPARED' : report.pendingAction ? 'COMPLETED_WITH_PENDING_ACTION' : 'COMPLETED'; delete report.error; await save()
console.log(JSON.stringify({ phase: report.phase, dispatchNo: report.dispatchNo, orders: report.orders.map(({ orderNo, mode }) => ({ orderNo, mode })), output }))
} catch (error) { report.error = error.message; await save(); console.error(error.message); process.exitCode = 1 }