Compare commits

..
Author SHA1 Message Date
ouyangxq 9dd34791f7 feat(dispatch): unify whole vehicle scheduling, source ownership and staged execution 2026-10-19 23:40:42 +08:00
liuyihe e0677e7ae7 提交 2026-10-19 16:41:20 +08:00
liuyihe 44c76d8953 提交 2026-10-19 15:54:19 +08:00
37 changed files with 554 additions and 415 deletions
@@ -35,6 +35,28 @@ public class PlatformDropshipPalletizationController {
@Resource private DropshipPalletizationService service;
@Resource private InboundCartonLabelPrintService printService;
/** 跨票识别箱码只读预览,清点仍按原任务领取并提交。 */
@GetMapping("/cartons/resolve")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ApiResponse<DropshipPalletizationDtos.ScanContext> resolveWorkspaceCarton(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Positive long warehouseId,
@RequestParam @jakarta.validation.constraints.NotBlank @Size(max = 80) String boxCode) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.resolveWorkspaceCarton(warehouseId, boxCode, user.userId()));
}
/** 获取当前实物托盘及客户组成,不要求托盘已与当前票绑定。 */
@GetMapping("/pallets/resolve")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ApiResponse<DropshipPalletizationDtos.PalletWorkspace> workspacePallet(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Positive long warehouseId,
@RequestParam @jakarta.validation.constraints.Pattern(regexp = "DP-[0-9]{6}-[0-9]{4}") String palletNo) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.workspacePallet(warehouseId, palletNo));
}
@GetMapping("/tasks/pending")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ApiResponse<List<DropshipPalletizationDtos.PendingTask>> pending(
@@ -18,6 +18,15 @@ public final class DropshipPalletizationDtos {
public record ClaimRequest(@Positive long warehouseId) {}
/** 跨票只读识别结果;确认清点前仍须领取对应任务。 */
public record ScanContext(Task task, Carton carton) {}
/** 当前实物托盘及逐客户、逐票组成。 */
public record PalletWorkspace(Pallet pallet, List<PalletMember> members) {}
public record PalletMember(Long taskId, Long inboundId, String inboundCode,
Long customerId, String customerName, Integer cartonCount) {}
public enum DiscrepancyAction { MATCH, ACCEPT_VARIANCE }
public record CountCartonRequest(@Positive long warehouseId,
@@ -15,6 +15,45 @@ import java.util.List;
/** 一件代发逐箱清点、待绑托与封托事实。 */
@Mapper
public interface DropshipPalletizationMapper {
/** 在指定仓库按稳定箱码识别任务,多个匹配由业务层拒绝。 */
@Select("""
SELECT DISTINCT t.id FROM fl_dropship_pallet_task t
JOIN fl_inbound_order o ON o.id=t.inbound_id AND o.tenant_id=t.tenant_id AND o.deleted=0
JOIN fl_inbound_carton_label l ON l.inbound_id=t.inbound_id AND l.tenant_id=t.tenant_id
WHERE t.warehouse_id=#{warehouseId} AND l.box_code=#{boxCode} AND l.batch_status='ACTIVE'
AND o.fulfillment_type='ONE_CLICK_DROPSHIP'
""")
List<Long> workspaceTaskIds(@Param("warehouseId") long warehouseId,@Param("boxCode") String boxCode);
/** 扫码预览为只读查询,不能把未知或已封托托盘默认成开放。 */
@Select("SELECT * FROM fl_dropship_pallet WHERE warehouse_id=#{warehouseId} AND pallet_no=#{palletNo}")
PalletRow workspacePallet(@Param("warehouseId") long warehouseId,@Param("palletNo") String palletNo);
/** 按实际成员汇总客户与票,空的预生成托盘不虚构成员。 */
@Select("""
SELECT c.task_id,c.inbound_id,o.inbound_code,c.tenant_id customer_id,o.customer_name,COUNT(*) carton_count
FROM fl_dropship_pallet_carton c
JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id
WHERE c.pallet_id=#{palletId}
GROUP BY c.task_id,c.inbound_id,o.inbound_code,c.tenant_id,o.customer_name
ORDER BY o.inbound_code,c.task_id
""")
List<com.baiyue.firstleg.dto.DropshipPalletizationDtos.PalletMember> workspaceMembers(@Param("palletId") long palletId);
/** 混托成员必须与当前票同渠道、同目的仓,客户可以不同。 */
@Select("""
SELECT COUNT(*) FROM fl_dropship_pallet_carton c
JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id
JOIN fl_dropship_pallet_task t ON t.id=#{taskId}
JOIN fl_inbound_order target ON target.id=t.inbound_id AND target.tenant_id=t.tenant_id
WHERE c.pallet_id=#{palletId} AND (
NOT (o.channel_code <=> target.channel_code)
OR NOT (o.destination_type <=> target.destination_type)
OR NOT (o.destination_warehouse_id <=> target.destination_warehouse_id)
OR NOT (o.destination_code <=> target.destination_code))
""")
int workspaceIncompatibleMembers(@Param("taskId") long taskId,@Param("palletId") long palletId);
@Insert("""
INSERT IGNORE INTO fl_dropship_pallet_task
(id,tenant_id,inbound_id,warehouse_id,task_status,total_cartons,counted_cartons,allocated_cartons,discrepancy_cartons,version,created_at,updated_at)
@@ -0,0 +1,39 @@
package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.dto.DropshipPalletizationDtos.PalletMember;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.PalletRow;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 仓内跨票扫码与实物托盘组成查询,客户归属始终从箱唛事实取得。 */
@Repository
public class DropshipWorkspaceRepository {
@Resource private DropshipPalletizationMapper mapper;
/** 按仓库和稳定箱码查找作业任务,不接受客户端指定客户归属。 */
public List<Long> taskIdsForBox(long warehouseId, String boxCode) {
return mapper.workspaceTaskIds(warehouseId, boxCode);
}
/** 只读查询托盘,不因扫描托盘产生绑定或状态变化。 */
public PalletRow pallet(long warehouseId, String palletNo) {
return mapper.workspacePallet(warehouseId, palletNo);
}
/** 保留每票客户与任务身份,供混托核对和按原任务移箱。 */
public List<PalletMember> members(long palletId) {
return mapper.workspaceMembers(palletId);
}
/** 归托前锁定实物托盘,与封托互斥。 */
public PalletRow lockPallet(String palletNo) {
return mapper.palletByNo(palletNo);
}
/** 按原始入仓快照检查运输兼容性,不接受客户端声明。 */
public boolean compatible(long taskId, long palletId) {
return mapper.workspaceIncompatibleMembers(taskId, palletId) == 0;
}
}
@@ -21,6 +21,34 @@ import java.util.*;
public class DropshipPalletizationService {
@Resource private DropshipPalletizationMapper mapper;
@Resource private FirstlegDocumentNumberGenerator documentNumbers;
@Resource private com.baiyue.firstleg.repository.DropshipWorkspaceRepository workspaceRepository;
/** 只读识别其他客户的箱码;不自动领取、清点或绑定托盘。 */
public ScanContext resolveWorkspaceCarton(long warehouseId,String boxCode,long operatorId){
String code=normalize(boxCode);
if(code==null) throw invalid("箱码不能为空");
List<Long> ids=workspaceRepository.taskIdsForBox(warehouseId,code);
if(ids.isEmpty()) throw new BizException(ResultCode.NOT_FOUND,"当前仓库未找到该一件代发箱码");
if(ids.size()!=1) throw conflict("箱码匹配多个任务,请联系后台核对");
Task target=task(ids.get(0),warehouseId,operatorId);
Carton carton=resolveCarton(target.taskId(),warehouseId,code,operatorId);
// 未领取任务仅允许预览,员工确认后由正式领取与清点接口再次检查权限和状态。
if("PENDING".equals(target.taskStatus()) && Boolean.TRUE.equals(target.palletSetupReady())
&& "请先领取任务".equals(carton.blockReason())){
carton=new Carton(carton.cartonId(),carton.boxCode(),carton.inboundId(),carton.inboundCode(),
carton.skuLines(),carton.countStatus(),carton.palletId(),carton.palletNo(),true,null);
}
return new ScanContext(target,carton);
}
/** 按仓库返回真实托盘状态及全部成员,跨客户不改变每箱的原归属。 */
public PalletWorkspace workspacePallet(long warehouseId,String palletNo){
String code=normalize(palletNo);
if(code==null) throw invalid("托盘码不能为空");
PalletRow row=workspaceRepository.pallet(warehouseId,code);
if(row==null) throw new BizException(ResultCode.NOT_FOUND,"当前仓库未找到该托盘码");
return new PalletWorkspace(palletView(row,null),workspaceRepository.members(row.getId()));
}
@Transactional
public void createTask(InboundOrderEntity inbound,long operatorId,String operatorName) {
@@ -195,11 +223,13 @@ public class DropshipPalletizationService {
.map(p->palletView(p,task.getInboundCode())).toList();
}
/** 跨客户归托仍须同仓、同渠道、同目的仓,并锁定开放托盘。 */
private PalletRow requireAssignablePallet(TaskRow task,String palletNo){
PalletRow existing=mapper.palletByNo(palletNo);
PalletRow existing=workspaceRepository.lockPallet(palletNo);
if(existing==null) throw new BizException(ResultCode.NOT_FOUND,"托盘码不存在,请扫描测量审核后生成的 DP 托盘码");
if(!Objects.equals(existing.getWarehouseId(),task.getWarehouseId())) throw conflict("托盘码不属于当前作业仓库");
if(!"OPEN".equals(existing.getPalletStatus())) throw conflict("托盘已封托,不能继续绑定箱");
if(!workspaceRepository.compatible(task.getId(),existing.getId())) throw conflict("托内货物与当前票的渠道或目的仓不同,不能混托");
return existing;
}
@@ -4,6 +4,26 @@ info:
version: 3.0.0
description: 测量环节录入预计托数,审核通过后预生成全部 DP 托盘码并可合并打印。PDA 支持先扫箱或先扫托,同一国内作业仓可跨票共用开放托盘,实物装满后再封托。
paths:
/api/platform/v1/first-leg/pda-dropship/cartons/resolve:
get:
summary: 同仓跨客户识别箱码并预览原任务
description: 只读,不领取或清点;返回 task 与 carton,确认时仍须领取原任务后按原 taskId 清点。拒绝其他作业员已领取任务及箱码歧义。
x-required-authority: platform:firstleg:pda:view
parameters:
- {$ref: '#/components/parameters/WarehouseId'}
- {name: boxCode, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 80}}
responses:
'200': {description: 原任务和箱内 SKU 预览}
/api/platform/v1/first-leg/pda-dropship/pallets/resolve:
get:
summary: 查询当前仓库真实托盘状态与逐客户逐票组成
description: 扫描不产生归托;返回 pallet 与 members,成员保留 taskId、inboundId、inboundCode、customerId、customerName、cartonCount。不存在或跨仓的托盘拒绝,已封托返回真实状态。
x-required-authority: platform:firstleg:pda:view
parameters:
- {$ref: '#/components/parameters/WarehouseId'}
- {name: palletNo, in: query, required: true, schema: {type: string, pattern: 'DP-[0-9]{6}-[0-9]{4}'}}
responses:
'200': {description: 实物托盘状态和成员汇总}
/api/platform/v1/first-leg/pda-dropship/tasks/pending:
get:
summary: 查询待领取或本人已领取任务
@@ -24,7 +24,56 @@ class DropshipPalletizationServiceTest {
private final FirstlegDocumentNumberGenerator numbers=mock(FirstlegDocumentNumberGenerator.class);
private final DropshipPalletizationMapper mapper=mock(DropshipPalletizationMapper.class);
private final DropshipPalletizationService service=new DropshipPalletizationService();
DropshipPalletizationServiceTest(){ ReflectionTestUtils.setField(service,"mapper",mapper); ReflectionTestUtils.setField(service,"documentNumbers",numbers); when(mapper.labelSkus(anyLong(),anyLong())).thenAnswer(call -> java.util.List.of(labelSku())); when(mapper.cartonSkus(anyLong())).thenAnswer(call -> java.util.List.of(cartonSku())); }
private final com.baiyue.firstleg.repository.DropshipWorkspaceRepository workspace=mock(com.baiyue.firstleg.repository.DropshipWorkspaceRepository.class);
/** 使用正式仓储入口的等价锁定查询,保留既有扫码测试的事实准备。 */
DropshipPalletizationServiceTest(){ ReflectionTestUtils.setField(service,"mapper",mapper); ReflectionTestUtils.setField(service,"documentNumbers",numbers); ReflectionTestUtils.setField(service,"workspaceRepository",workspace); when(workspace.lockPallet(anyString())).thenAnswer(call -> mapper.palletByNo(call.getArgument(0))); when(workspace.compatible(anyLong(),anyLong())).thenReturn(true); when(mapper.labelSkus(anyLong(),anyLong())).thenAnswer(call -> java.util.List.of(labelSku())); when(mapper.cartonSkus(anyLong())).thenAnswer(call -> java.util.List.of(cartonSku())); }
/** 跨客户预览不领取任务,不写入清点事实。 */
@Test void workspaceResolvesPendingCustomerWithoutMutating(){
ReflectionTestUtils.setField(service,"workspaceRepository",workspace);
TaskRow other=task(); other.setTenantId(2L); other.setTaskStatus("PENDING"); other.setAssigneeId(null);
when(workspace.taskIdsForBox(100L,"BOX-1")).thenReturn(java.util.List.of(10L));
when(mapper.task(10L,100L)).thenReturn(other);
when(mapper.label(10L,"BOX-1")).thenReturn(label());
when(mapper.palletCountByInbound(2L,30L)).thenReturn(other.getExpectedPalletCount());
var result=service.resolveWorkspaceCarton(100L," BOX-1 ",7L);
assertThat(result.task().tenantId()).isEqualTo(2L);
assertThat(result.carton().canCount()).isTrue();
verify(mapper,never()).claim(anyLong(),anyLong(),anyLong(),anyString());
verify(mapper,never()).addCounted(anyLong(),anyLong(),anyInt(),anyInt());
}
/** 不允许从跨票扫码接管其他作业员的任务。 */
@Test void workspaceRejectsOtherOperatorsTaskAndAmbiguousBox(){
ReflectionTestUtils.setField(service,"workspaceRepository",workspace);
when(workspace.taskIdsForBox(100L,"BOX-1")).thenReturn(java.util.List.of(10L));
TaskRow other=task(); other.setAssigneeId(9L); when(mapper.task(10L,100L)).thenReturn(other);
assertThatThrownBy(()->service.resolveWorkspaceCarton(100L,"BOX-1",7L)).isInstanceOf(BizException.class).hasMessageContaining("其他人员");
when(workspace.taskIdsForBox(100L,"BOX-1")).thenReturn(java.util.List.of(10L,11L));
assertThatThrownBy(()->service.resolveWorkspaceCarton(100L,"BOX-1",7L)).isInstanceOf(BizException.class).hasMessageContaining("多个任务");
}
/** 托盘预览必须返回已封托的真实状态,跨仓不存在时拒绝。 */
@Test void workspacePalletNeverInventsOpenState(){
ReflectionTestUtils.setField(service,"workspaceRepository",workspace);
when(workspace.pallet(100L,"DP-260911-0001")).thenReturn(sealedPallet());
when(workspace.members(20L)).thenReturn(java.util.List.of());
when(mapper.cartons(20L)).thenReturn(java.util.List.of());
assertThat(service.workspacePallet(100L,"DP-260911-0001").pallet().status()).isEqualTo("SEALED");
assertThatThrownBy(()->service.workspacePallet(101L,"DP-260911-0001")).isInstanceOf(BizException.class).hasMessageContaining("未找到");
}
/** 即使不同客户允许混托,运输渠道或目的仓不同仍须拒绝。 */
@Test void incompatibleDestinationCannotBeMixed(){
when(mapper.task(10L,100L)).thenReturn(task());
when(mapper.palletCountByInbound(1L,30L)).thenReturn(2);
when(mapper.label(10L,"BOX-1")).thenReturn(label());
when(mapper.palletByNo("DP-260911-0001")).thenReturn(pallet());
when(workspace.compatible(10L,20L)).thenReturn(false);
assertThatThrownBy(()->service.countCarton(10L,new CountCartonRequest(100L,"BOX-1",java.util.List.of(new CountSkuLine(40L,"SKU-1",12,DiscrepancyAction.MATCH,null)),"DP-260911-0001","wrong-destination"),7L,"张三"))
.isInstanceOf(BizException.class).hasMessageContaining("渠道或目的仓不同");
verify(mapper,never()).addCounted(anyLong(),anyLong(),anyInt(),anyInt());
}
@Test void sharedPalletCountsDistinctSkuCodesWithoutMergingInboundDetails(){
when(mapper.inboundCode(1L,30L)).thenReturn("IN-1");
+1 -1
View File
@@ -34,7 +34,7 @@
<executions>
<execution>
<id>default-test</id>
<configuration><reuseForks>false</reuseForks><excludes><exclude>**/*SqlSemanticTest.java</exclude></excludes></configuration>
<configuration><excludes><exclude>**/*SqlSemanticTest.java</exclude></excludes></configuration>
</execution>
<!-- H2复杂查询规划与普通单测分进程,仍沿用父级1GB上限并执行全部断言。 -->
<execution>
@@ -35,11 +35,6 @@ public class AppointmentCargoService {
return new PageResult<>(rows, page, size, total, (total + size - 1) / size);
}
/** 查询下拉框与货物分页共用权威目录范围,不从当前页猜测目的仓。 */
public List<String> destinations() {
return scope().destinations().stream().distinct().sorted().toList();
}
private record Scope(List<String> origins, List<String> destinations) { }
private Scope scope() {
final List<AppointmentWarehouseClient.Warehouse> catalog;
@@ -53,14 +53,6 @@ public class AppointmentPlanningController {
return ApiResponse.success(new Resolved(source,result.get(0)));
}
/** 目的仓选项沿用货物查询权限,只返回同范围的美国FBA仓码。 */
@GetMapping("/cargo/destinations")
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')")
public ApiResponse<java.util.List<String>> destinations(@AuthenticationPrincipal AuthenticatedUser user) {
requirePlatform(user);
return ApiResponse.success(service.destinations());
}
@GetMapping("/cargo")
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')")
public ApiResponse<PageResult<AppointmentCargo>> cargo(@AuthenticationPrincipal AuthenticatedUser user,
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.delivery.repository;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.HashSet;
/** 出库查询按实物箱汇总,退货混装 SKU 不重复计箱。 */
@Repository
public class OutboundQuantityRepository {
@Resource private OutboundPalletItemMapper pallets;
/** 废弃退货来源依据已冻结箱唛计数,读取已有单据也不会把件数当箱数。 */
public Integer boxes(OutboundOrder outbound) {
if (!"DISCARD_OUTBOUND".equals(outbound.getOutboundType())) return outbound.getPackageCount();
var lines = pallets.findByOutbound(outbound.getCustomerId(), outbound.getId());
if (lines.isEmpty()) return outbound.getPackageCount();
var returnBoxes = new HashSet<String>();
int count = 0;
for (var line : lines) {
if (line.getReturnSkuQuantity() != null && line.getCartonMarks() != null && !line.getCartonMarks().isBlank()) {
returnBoxes.add(line.getInboundCode() + ":" + line.getCartonMarks());
} else {
count = Math.addExact(count, java.util.Objects.requireNonNullElse(line.getPackageCount(), 0));
}
}
return Math.addExact(count, returnBoxes.size());
}
}
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.discard.model;
import com.baiyue.lastleg.discard.entity.CargoDiscardItem;
import java.util.HashSet;
import java.util.List;
/** 废弃单汇总使用物理箱数,退货明细的数量仍保留为 SKU 件数。 */
public final class CargoDiscardQuantities {
/** 数量规则不需要实例化。 */
private CargoDiscardQuantities() { }
/** 退货混装箱按箱 ID 去重,其余来源累加已选择的物理箱数。 */
public static int boxes(List<CargoDiscardItem> lines, boolean actual) {
var returnBoxes = new HashSet<Long>();
int total = 0;
for (var line : lines) {
int quantity = actual ? java.util.Objects.requireNonNullElse(line.getActualDiscardPackages(), 0) : line.getDiscardPackages();
if (line.getReturnCartonId() != null) {
if (quantity > 0) returnBoxes.add(line.getReturnCartonId());
} else {
total = Math.addExact(total, quantity);
}
}
return Math.addExact(total, returnBoxes.size());
}
}
@@ -19,8 +19,9 @@ public final class PdaCargoDiscardDtos {
List<@NotBlank String> scannedCartonMarks, Boolean confirmAllCartons) {
public Confirm(List<ActualLine> lines, List<Long> photoFileIds) { this(lines, photoFileIds, null, false); }
}
/** 明细数量按来源标识单位:退货库存为件,其余箱级库存为箱。 */
public record Sku(Long itemId, String skuCode, String productName, int plannedPackages,
Integer actualPackages, String status) { }
Integer actualPackages, String status, String quantityUnit) { }
public record PalletTask(String discardNo, String customerId, String overseasWarehouseId,
String palletNo, Long profileId, String status, List<Sku> skus, String outboundNo,
String scanMode, List<String> cartonMarks) { }
@@ -117,8 +117,8 @@ public class PdaCargoDiscardService {
.forEach(cartonId -> adjustments.finishReturnCarton(application.getCustomerId(), cartonId));
if (items.listApplication(application.getCustomerId(), application.getId()).stream()
.allMatch(i -> "COMPLETED".equals(i.getPdaStatus()))) {
int actualTotal = items.listApplication(application.getCustomerId(), application.getId()).stream()
.mapToInt(i -> i.getActualDiscardPackages() == null ? 0 : i.getActualDiscardPackages()).sum();
int actualTotal = com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(
items.listApplication(application.getCustomerId(), application.getId()), true);
application.setTotalPackages(actualTotal);
application.setTotalFee(application.getFeePerPackage().multiply(java.math.BigDecimal.valueOf(actualTotal)));
discardService.updateLinkedStatus(application.getCustomerId(), application, CargoDiscardService.APPROVED,
@@ -160,16 +160,20 @@ public class PdaCargoDiscardService {
}
private static final String LOOSE = "散箱";
/** 按实托汇总本单所选箱唛,退货件数与物理箱数分别呈现。 */
private List<PdaCargoDiscardDtos.PalletTask> palletTasks(CargoDiscardApplication application) {
var allItems = items.listApplication(application.getCustomerId(), application.getId());
var grouped = allItems.stream().collect(Collectors.groupingBy(i -> physicalCode(application, i), LinkedHashMap::new, Collectors.toList()));
return grouped.entrySet().stream().map(entry -> {
CargoDiscardItem first = entry.getValue().get(0);
var skus = entry.getValue().stream().map(i -> new PdaCargoDiscardDtos.Sku(i.getId(), i.getSkuCode(), i.getProductName(),
i.getDiscardPackages(), i.getActualDiscardPackages(), i.getPdaStatus() == null ? "PENDING" : i.getPdaStatus())).toList();
i.getDiscardPackages(), i.getActualDiscardPackages(), i.getPdaStatus() == null ? "PENDING" : i.getPdaStatus(),
isReturnItem(i) ? "件" : "箱")).toList();
String status = skus.stream().allMatch(i -> "COMPLETED".equals(i.status())) ? "COMPLETED" : "PENDING";
var marks = entry.getValue().stream().flatMap(i -> cartonMarks(application, i).stream()).distinct().sorted().toList();
boolean cartonScan = LOOSE.equals(entry.getKey()) || entry.getValue().stream().anyMatch(i -> Boolean.TRUE.equals(i.getCartonScanRequired()));
// 退货箱只代表本单选择的箱子,不能因为有托盘号就当成整托废弃。
boolean cartonScan = LOOSE.equals(entry.getKey()) || entry.getValue().stream()
.anyMatch(i -> isReturnItem(i) || Boolean.TRUE.equals(i.getCartonScanRequired()));
if (cartonScan && marks.isEmpty()) throw conflict("散箱废弃任务缺少箱唛");
return new PdaCargoDiscardDtos.PalletTask(application.getDiscardNo(), application.getCustomerId(), application.getOverseasWarehouseId(),
entry.getKey(), first.getInventoryPositionId() != null ? first.getInventoryPositionId() : isReturnItem(first) ? first.getReturnCartonId() : first.getProfileId(),
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.discard.repository;
import com.baiyue.lastleg.discard.entity.CargoDiscardApplication;
import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper;
import com.baiyue.lastleg.discard.model.CargoDiscardQuantities;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
/** 从申请明细读取物理箱汇总,不修改历史库存或件数。 */
@Repository
public class CargoDiscardQuantityRepository {
@Resource private CargoDiscardItemMapper items;
/** 列表和详情均按所选实物箱展示;空明细保留申请自身快照。 */
public int boxes(CargoDiscardApplication application) {
var lines = items.listApplication(application.getCustomerId(), application.getId());
return lines.isEmpty() ? application.getTotalPackages() : CargoDiscardQuantities.boxes(lines, false);
}
}
@@ -39,7 +39,7 @@ public class CargoDiscardOutboundService {
return outbound == null ? null : outbound.getOutboundNo();
}
// The caller holds the application lock and owns the approval transaction.
/** 审核事务持有申请锁时创建出库投影,汇总箱数与退货件数分开保存。 */
public void create(CargoDiscardApplication application, String operatorId) {
if (outbounds.findByIdempotency(application.getCustomerId(), key(application)) != null) return;
var lines = items.listApplication(application.getCustomerId(), application.getId());
@@ -51,12 +51,13 @@ public class CargoDiscardOutboundService {
outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", application.getOverseasWarehouseId(), outbound.getId()));
outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType(TYPE);
outbound.setLxCodes(null); outbound.setRecipientInfo("废弃单 " + application.getDiscardNo());
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(application.getTotalPackages());
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, false));
outbound.setIdempotencyKey(key(application)); outbound.setRequestHash(key(application));
outbound.setVolumeWeightKg(BigDecimal.ZERO); outbound.setVolumeCbm(BigDecimal.ZERO);
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(at); outbound.setUpdatedAt(at);
var snapshots = new java.util.ArrayList<OutboundPalletItem>();
var palletCodes = new java.util.HashSet<String>();
var returnBoxIds = new java.util.HashSet<Long>();
for (var line : lines) {
boolean returned = line.getReturnCartonId() != null || "RETURN_CARTON".equals(line.getSourceType());
String palletNo;
@@ -83,11 +84,12 @@ public class CargoDiscardOutboundService {
}
if (palletNo != null && !palletNo.isBlank()) palletCodes.add(palletNo);
OutboundPalletItem snapshot = new OutboundPalletItem();
// Stable one-to-one projection key in a separate table, never used to deduct stock.
// 出库明细与申请明细一一对应,仅作投影,不通过此表扣减库存。
snapshot.setId(line.getId()); snapshot.setCustomerId(application.getCustomerId()); snapshot.setOutboundOrderId(outbound.getId());
snapshot.setPalletNo(palletNo); snapshot.setSkuCode(line.getSkuCode()); snapshot.setInboundCode(line.getInboundCode());
snapshot.setOrderRefs(application.getDiscardNo()); snapshot.setCartonMarks(cartonMarks);
snapshot.setPackageCount(line.getDiscardPackages()); snapshot.setPickedPackageCount(0); snapshot.setStatus("WAIT_PICK");
snapshot.setPackageCount(returned ? (returnBoxIds.add(line.getReturnCartonId()) ? 1 : 0) : line.getDiscardPackages());
snapshot.setPickedPackageCount(0); snapshot.setStatus("WAIT_PICK");
if (returned) snapshot.setReturnSkuQuantity((long) line.getDiscardPackages());
snapshot.setVolumeWeightKg(BigDecimal.ZERO); snapshot.setVolumeCbm(BigDecimal.ZERO); snapshot.setCreatedAt(at);
snapshots.add(snapshot);
@@ -96,15 +98,21 @@ public class CargoDiscardOutboundService {
snapshots.forEach(pallets::insert);
}
/** 同步实物箱的拣货进度,退货 SKU 件数不进入箱数汇总。 */
public void sync(CargoDiscardApplication application) {
OutboundOrder outbound = outbounds.lockByIdempotency(application.getCustomerId(), key(application));
if (outbound == null) throw conflict("废弃出库单不存在,请联系平台核对审核结果");
var lines = items.listApplication(application.getCustomerId(), application.getId());
var returnBoxIds = new java.util.HashSet<Long>();
for (var snapshot : pallets.findByOutbound(application.getCustomerId(), outbound.getId())) {
var line = lines.stream().filter(i -> Objects.equals(i.getId(), snapshot.getId())).findFirst()
.orElseThrow(() -> conflict("废弃出库明细关联失效"));
boolean returned = line.getReturnCartonId() != null;
int boxes = returned ? (returnBoxIds.add(line.getReturnCartonId()) ? 1 : 0) : line.getDiscardPackages();
snapshot.setPackageCount(boxes);
if ("COMPLETED".equals(line.getPdaStatus())) {
snapshot.setPickedPackageCount(Objects.requireNonNullElse(line.getActualDiscardPackages(), 0));
snapshot.setPickedPackageCount(returned ? (Objects.requireNonNullElse(line.getActualDiscardPackages(), 0) > 0 ? boxes : 0)
: Objects.requireNonNullElse(line.getActualDiscardPackages(), 0));
snapshot.setStatus("PICKED");
}
if (CargoDiscardService.CANCELLED.equals(application.getStatus())) snapshot.setStatus("CANCELLED");
@@ -117,7 +125,7 @@ public class CargoDiscardOutboundService {
};
outbound.setStatus(status);
outbound.setLxCodes(null);
if ("OUTBOUNDED".equals(status)) outbound.setPackageCount(application.getTotalPackages());
outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, "OUTBOUNDED".equals(status)));
outbound.setUpdatedAt(LocalDateTime.now()); outbounds.updateById(outbound);
}
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
@@ -50,6 +50,7 @@ import java.util.Set;
@Service
public class CargoDiscardService {
@Resource private com.baiyue.lastleg.discard.repository.CargoDiscardQuantityRepository quantities;
@Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator;
public static final String APPROVAL_PERMISSION = "platform:overseas-warehouse:discard:approve";
public static final String PENDING_REVIEW = "PENDING_REVIEW";
@@ -138,6 +139,7 @@ public class CargoDiscardService {
supported ? null : "当前库存缺少完整箱唛,可按全部库存废弃");
}
/** 校验并预留所选库存,物理箱汇总与退货 SKU 件数分别保留。 */
@Transactional
public CargoDiscardResponses.Detail create(String customerId, String operatorId,
CargoDiscardRequests.Create request) {
@@ -198,7 +200,8 @@ public class CargoDiscardService {
} else if (!warehouseId.equals(itemWarehouseId)) {
throw conflict("One cargo discard application cannot span operating warehouses");
}
total = Math.addExact(total, input.discardPackages());
// 退货明细为件数,整箱只在全部 SKU 校验完成后按箱 ID 计一次。
if (!returnSource) total = Math.addExact(total, input.discardPackages());
}
for (Long cartonId : returnCartonCache.keySet()) {
@@ -209,6 +212,7 @@ public class CargoDiscardService {
.map(i -> i.skuCode().trim()).collect(java.util.stream.Collectors.toSet());
if (!availableSkus.equals(requestedSkus)) throw conflict("目前不支持部分废弃,必须选择退货箱内全部 SKU 库存");
}
total = Math.addExact(total, returnCartonCache.size());
LocalDateTime now = LocalDateTime.now();
if (!StringUtils.hasText(warehouseName)) warehouseName = warehouseNames().get(warehouseId);
CargoDiscardApplication application = new CargoDiscardApplication();
@@ -506,10 +510,12 @@ public class CargoDiscardService {
return names;
}
/** 废弃列表与详情按物理箱汇总,并按每箱单价计算应展示费用。 */
private CargoDiscardResponses.Summary summary(CargoDiscardApplication value, Map<String, String> names) {
int boxes = quantities.boxes(value);
return new CargoDiscardResponses.Summary(value.getId(), value.getCustomerId(), value.getDiscardNo(), value.getInboundCodes(),
value.getOverseasWarehouseId(), StringUtils.hasText(value.getOverseasWarehouseName()) ? value.getOverseasWarehouseName() : names.get(value.getOverseasWarehouseId()), value.getTotalPackages(),
value.getTotalFee(), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(),
value.getOverseasWarehouseId(), StringUtils.hasText(value.getOverseasWarehouseName()) ? value.getOverseasWarehouseName() : names.get(value.getOverseasWarehouseId()), boxes,
value.getFeePerPackage() == null ? value.getTotalFee() : value.getFeePerPackage().multiply(BigDecimal.valueOf(boxes)), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(),
value.getReviewedAt(), outboundService.outboundNo(value), value.getCustomerConfirmedAt());
}
@@ -52,12 +52,11 @@ 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," +
// 仓ID文本统一保留128字符,避免混合CASE的NONE排序规则及无长度CHAR截断;来源优先级与身份等值不变。
"CAST(CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " +
"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 &gt; 0) ELSE COALESCE(NULLIF(CAST(ip.overseas_warehouse_id AS CHAR(128)),'')," +
"AND pos.available_packages &gt; 0) ELSE COALESCE(NULLIF(CAST(ip.overseas_warehouse_id AS CHAR),'')," +
"(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 CHAR(128)) AS overseasWarehouseId," +
"WHERE source_order.id=ip.source_order_id AND source_order.customer_id=ip.customer_id LIMIT 1)) END 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 &gt; 0) " +
@@ -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 CAST(s.overseas_warehouse_id AS CHAR(128))=CAST(ip.overseas_warehouse_id AS CHAR(128)) "
+ "AND s.overseas_warehouse_id=CAST(ip.overseas_warehouse_id AS CHAR) "
+ "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);
@@ -337,12 +337,6 @@ 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()));
@@ -190,7 +190,9 @@ public class WarehouseTransferService {
private List<WarehouseTransferResponses.SourceCarton> unreservedSourceCartons(String customerId,
String warehouseId, String source, List<WarehouseTransferResponses.SourceCarton> cartons) {
if (inventorySources == null) return cartons;
Set<String> occupied = inventorySources.occupiedMarks(customerId, warehouseId, source).stream()
var occupiedMarks = inventorySources.occupiedMarks(customerId, warehouseId, source);
if (occupiedMarks == null || occupiedMarks.isEmpty()) return cartons;
Set<String> occupied = occupiedMarks.stream()
.filter(StringUtils::hasText).map(mark -> mark.trim().toUpperCase(java.util.Locale.ROOT))
.collect(Collectors.toSet());
return cartons.stream().filter(box -> !occupied.contains(box.cartonMark().trim()
@@ -483,7 +485,6 @@ public class WarehouseTransferService {
// 所有同源提交锁定同一入仓记录;取锁后按最新可用箱校验并保存逐箱占用快照。
if (inventorySources != null && inventorySources.lockSource(customerId, transfer.getInboundOrderId()) == null)
throw conflict("库存来源已失效,请重新选择来源单");
if (inventorySources != null) reserveSourceCartons(customerId, transfer);
}
if (Boolean.TRUE.equals(transfer.getNeedRelabel())) {
@@ -499,6 +500,11 @@ public class WarehouseTransferService {
}
}
// 来源箱归属校验通过后再写入占用快照,保证错误箱号返回明确提示。
if (!transfer.getTransferNo().equals(transfer.getInboundCode()) && inventorySources != null) {
reserveSourceCartons(customerId, transfer);
}
LastlegOrder order = requireTailOrder(customerId, transfer);
LocalDateTime now = LocalDateTime.now();
transfer.setStatus(PENDING_REVIEW);
@@ -6,26 +6,6 @@ info:
security:
- bearerAuth: []
paths:
/api/platform/v1/last-leg/appointment-planning/cargo/destinations:
get:
operationId: listAppointmentCargoDestinations
summary: 读取预约货物查询的目的FBA仓选项
description: >-
仅平台;同时要求platform:appointment:list与platform:overseas-warehouse:last-mile-order:list。
与货物分页共用权威启用美国FBA目录,返回去重、按仓码排序的完整选项,不受货物当前分页或关键词限制。
不增加仓库主档读取权限;目录失败返回503,不当作空选项。
responses:
'200':
description: 业务响应;data为目的FBA仓码数组
content:
application/json:
schema:
type: object
properties:
code: {type: string}
data: {type: array, items: {type: string}, example: [ABE4, ONT8]}
'403': {description: 非平台或缺少任一读取权限}
'503': {description: 权威目录不可用}
/api/platform/v1/last-leg/appointment-planning/sources/resolve-order:
get:
operationId: resolveOrderAppointmentSource
@@ -8,21 +8,6 @@ import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class AppointmentCargoServiceTest {
/** 选项取完整权威美国FBA目录,不依赖当前页货物或新增仓库主档权限。 */
@Test void destinationOptionsUseTheSameAmericanFbaScopeWithoutQueryingCargoRows() {
var mapper = mock(AppointmentCargoMapper.class);
var warehouses = mock(AppointmentWarehouseClient.class);
when(warehouses.catalog("token")).thenReturn(ApiResponse.success(List.of(
new AppointmentWarehouseClient.Warehouse(1L,"US-WH","美国仓","OVERSEAS","US"),
new AppointmentWarehouseClient.Warehouse(2L,"ONT8","ONT8","FBA","US"),
new AppointmentWarehouseClient.Warehouse(3L,"ABE4","ABE4","FBA","US"),
new AppointmentWarehouseClient.Warehouse(4L,"YYZ1","YYZ1","FBA","CA"),
new AppointmentWarehouseClient.Warehouse(5L,"ONT8","ONT8","FBA","US"))));
assertEquals(List.of("ABE4","ONT8"),new AppointmentCargoService(mapper,warehouses,"token").destinations());
verifyNoInteractions(mapper);
when(warehouses.catalog("token")).thenThrow(new IllegalStateException("offline"));
assertEquals(503,assertThrows(ResponseStatusException.class,()->new AppointmentCargoService(mapper,warehouses,"token").destinations()).getStatusCode().value());
}
@Test void authenticUsDirectoryScopesTheDatabaseQueryAndPreservesServerPagination() {
var mapper = mock(AppointmentCargoMapper.class);
var warehouses = mock(AppointmentWarehouseClient.class);
@@ -17,20 +17,6 @@ import static org.mockito.Mockito.*;
class AppointmentPlanningAuthorizationTest {
private static final String LIST="platform:appointment:list", CARGO="platform:overseas-warehouse:last-mile-order:list", WRITE="platform:appointment:followup";
/** 目的仓选项不要求仓库管理权限,但保留原两项读取权限和平台身份边界。 */
@Test void destinationOptionsRequireTheExistingCargoReadPermissionsAndPlatformIdentity() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var controller=context.getBean(AppointmentPlanningController.class);
var service=context.getBean(AppointmentCargoService.class);
login(LIST);assertThrows(AccessDeniedException.class,()->controller.destinations(user(0L,"PLATFORM")));
login(CARGO);assertThrows(AccessDeniedException.class,()->controller.destinations(user(0L,"PLATFORM")));
login(LIST,CARGO);
assertThrows(ResponseStatusException.class,()->controller.destinations(user(1L,"CLIENT")));
verifyNoInteractions(service);
when(service.destinations()).thenReturn(List.of("ABE4","ONT8"));
assertEquals(List.of("ABE4","ONT8"),controller.destinations(user(0L,"PLATFORM")).data());
} finally { SecurityContextHolder.clearContext(); }
}
@Test void orderLookupUsesCanonicalBookingSourceAndEnforcesCustomerAndReadPermissions() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var controller=context.getBean(AppointmentPlanningController.class);
@@ -189,6 +189,28 @@ class PdaCargoDiscardServiceTest {
verify(photoService).confirm(same(application), eq("散箱"), eq(List.of(101L)), eq(false));
}
/** 退货一箱五件按指定箱作业,不得按五箱或整托处理。 */
@Test void returnCartonUsesPieceUnitAndRequiresSelectedCartonEvenWhenBoundToPallet() {
item.setSourceType("RETURN_CARTON"); item.setReturnCartonId(31L); item.setDiscardPackages(5);
var carton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton();
carton.setCustomerId("CUSTOMER"); carton.setPalletCode("PALLET-1"); carton.setCartonMark("RETURN-BOX-1");
when(returnCartons.selectById(31L)).thenReturn(carton);
when(applications.selectList(any())).thenReturn(List.of(application));
var task = service.resolve(identity, "PALLET-1", "CD-1");
assertEquals("CARTON", task.scanMode());
assertEquals(List.of("RETURN-BOX-1"), task.cartonMarks());
assertEquals(5, task.skus().get(0).plannedPackages());
assertEquals("件", task.skus().get(0).quantityUnit());
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(5)));
verifyNoInteractions(adjustments);
var confirm = new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, 5)),
List.of(101L), List.of("RETURN-BOX-1"), false);
service.confirm(identity, "CD-1", "PALLET-1", confirm);
assertEquals(1, application.getTotalPackages());
assertEquals(new BigDecimal("5.00"), application.getTotalFee());
verify(adjustments).discardPalletItem(eq("CUSTOMER"), same(application), same(item), anyList(), eq(5), eq("OP"), any());
}
@Test void partialBoundPalletOnlyExposesSelectedBoxesAndRequiresTheirScan() {
looseTask(); item.setCartonScanRequired(true);
var stockService = (com.baiyue.lastleg.discard.service.CargoDiscardStockService) ReflectionTestUtils.getField(service, "stockService");
@@ -104,4 +104,23 @@ class CargoDiscardOutboundServiceTest {
assertThrows(ResponseStatusException.class, () -> service.create(application, "OP"));
verify(outbounds, never()).insert(any(OutboundOrder.class)); verify(pallets, never()).insert(any(OutboundPalletItem.class));
}
/** 同一退货箱含多个 SKU 时箱数只记一次,件数仍逐 SKU 保留。 */
@Test void returnCartonProjectionSeparatesOneBoxFromFivePiecesAndSyncsCompletion() {
first.setReturnCartonId(31L); first.setSourceType("RETURN_CARTON"); first.setDiscardPackages(2);
second.setReturnCartonId(31L); second.setSourceType("RETURN_CARTON"); second.setDiscardPackages(3);
var carton = new ReturnInventoryCarton(); carton.setCustomerId("C1"); carton.setOverseasWarehouseId("99");
carton.setCartonMark("BOX-31"); carton.setPalletCode("PALLET-1");
when(returnCartons.selectById(31L)).thenReturn(carton);
service.create(application, "OP");
assertEquals(1, document.getPackageCount());
assertEquals(1, snapshots.stream().mapToInt(OutboundPalletItem::getPackageCount).sum());
assertEquals(5, snapshots.stream().mapToLong(OutboundPalletItem::getReturnSkuQuantity).sum());
first.setPdaStatus("COMPLETED"); first.setActualDiscardPackages(2);
second.setPdaStatus("COMPLETED"); second.setActualDiscardPackages(3);
application.setStatus("PDA_COMPLETED");
service.sync(application);
assertEquals(1, document.getPackageCount());
assertEquals(1, snapshots.stream().mapToInt(OutboundPalletItem::getPickedPackageCount).sum());
}
}
@@ -99,6 +99,9 @@ class CargoDiscardServiceTest {
@BeforeEach
void setUp() {
service = new CargoDiscardService();
var quantities = new com.baiyue.lastleg.discard.repository.CargoDiscardQuantityRepository();
ReflectionTestUtils.setField(quantities, "items", items);
ReflectionTestUtils.setField(service, "quantities", quantities);
ReflectionTestUtils.setField(service, "warehouseDirectory", warehouseDirectory);
lenient().when(warehouseDirectory.receivingWarehouses(any())).thenReturn(com.baiyue.common.core.api.ApiResponse.success(List.of(
new com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient.ReceivingWarehouse(99L, "LAX", "洛杉矶仓", "US", "ACTIVE"))));
@@ -227,6 +230,32 @@ class CargoDiscardServiceTest {
assertEquals("SKU-RETURN", result.get(0).skus().get(0).skuCode());
}
/** 创建和已有单据查询均按退货箱去重,五件不可计为五箱。 */
@Test void returnMixedSkuCartonCreatesOneBoxAndExistingSummaryReadsOneBox() {
var carton = new ReturnInventoryCarton(); carton.setId(901L); carton.setCustomerId("100");
carton.setOverseasWarehouseId("99"); carton.setSourceReturnNo("RT-1"); carton.setCartonMark("BOX-1");
carton.setStatus("AVAILABLE");
var skuA = new ReturnInventoryCartonItem(); skuA.setSkuCode("A"); skuA.setQuantity(2L);
var skuB = new ReturnInventoryCartonItem(); skuB.setSkuCode("B"); skuB.setQuantity(3L);
when(returnCartons.lockId(901L)).thenReturn(carton);
when(returnCartons.selectById(901L)).thenReturn(carton);
when(returnCartonItems.lockSku(901L, "A")).thenReturn(skuA);
when(returnCartonItems.lockSku(901L, "B")).thenReturn(skuB);
when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(skuA, skuB));
when(returnCartons.markDiscardPending(901L, "100")).thenReturn(1);
var detail = service.create("100", "OP", new CargoDiscardRequests.Create(List.of(
new CargoDiscardRequests.Item(null, 901L, "A", 2),
new CargoDiscardRequests.Item(null, 901L, "B", 3)), "return-five-pieces", null));
assertEquals(1, storedApplication.get().getTotalPackages());
assertEquals(new BigDecimal("5.00"), storedApplication.get().getTotalFee());
assertEquals(1, detail.summary().totalPackages());
assertEquals(5, storedItems.stream().mapToInt(CargoDiscardItem::getDiscardPackages).sum());
storedApplication.get().setTotalPackages(5); storedApplication.get().setTotalFee(new BigDecimal("25.00"));
var existing = service.detail("100", storedApplication.get().getDiscardNo());
assertEquals(1, existing.summary().totalPackages());
assertEquals(new BigDecimal("5.00"), existing.summary().totalFee());
}
@Test
void approvalPushesToPdaWithoutDeductingInventory() {
CargoDiscardApplication application = pendingApplication();
@@ -16,57 +16,6 @@ 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("&gt;", ">").replace("&lt;", "<");
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")) {
@@ -251,9 +200,7 @@ 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(method.getName().equals("available")
? "NULLIF(CAST(ip.overseas_warehouse_id AS CHAR(128)),'')"
: "NULLIF(CAST(ip.overseas_warehouse_id AS CHAR),'')"));
assertTrue(sql.contains("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"));
@@ -1,110 +0,0 @@
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);
}
}
@@ -35,44 +35,6 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
/** 主单/资源/占用/账本真实SQL回归;外部目录与选货权威提供者使用受控输入。 */
public class DispatchAggregatePersistenceTest {
/** 空单零版本取消使用真实来源规则,成功及原键重放均不制造来源、库存或重复命令。 */
@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);
@@ -18,44 +18,15 @@ 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);
@@ -513,7 +513,7 @@ class WarehouseTransferServiceTest {
verifyNoInteractions(transfers);
}
/** 来源箱快照先通过占用校验,再单独拒绝草稿中的外部换标箱。 */
/** 先按原订单来源快照拒绝外部换标箱,不进入后续选箱占用。 */
@Test
void submittingOldDraftRechecksRelabelCartonOwnership() {
var transfer = transfer(WarehouseTransferService.DRAFT);
@@ -521,9 +521,6 @@ class WarehouseTransferServiceTest {
transfer.setInboundCode("IN-1");
transfer.setNeedRelabel(true);
transfer.setPackageCount(1);
var selected = new WarehouseTransferCarton();
selected.setId(51L); selected.setCustomerCartonMark("CURRENT-BOX");
when(transferCartons.listOrder("TENANT-A", transfer.getId())).thenReturn(List.of(selected));
when(inboundSnapshots.sourceCartons("TENANT-A", "WH-1", "IN-1"))
.thenReturn(List.of(new InboundSnapshotMapper.SourceCarton("CURRENT-BOX", "SKU-1", null, null, null, null, null)));
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
+2 -1
View File
@@ -11,7 +11,7 @@ const OPTIONAL_MODULES = ['phase5-root-fulfillment', 'phase4-overseas-return-fol
'manual-pda-ready-fixture', 'manual-pda-ready-verify',
'manual-pda-batch-fixture', 'manual-pda-batch-verify',
'manual-loading-complete-fixture', 'manual-loading-complete-verify',
'coload-groups', 'dropship-pallet-matrix', 'dropship-physical-carton-chain', 'dropship-browser-fixture', 'mixed-container-receiving', 'pda-loading-link-regression', 'fleet-vehicle-care', 'fleet-driver-qualification']
'coload-groups', 'dropship-pallet-matrix', 'dropship-mixed-palletization', 'dropship-physical-carton-chain', 'dropship-browser-fixture', 'mixed-container-receiving', 'pda-loading-link-regression', 'fleet-vehicle-care', 'fleet-driver-qualification']
const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES])
const A0_LOCATION_PAGE_SIZE = 200
const A0_MAX_LOCATION_MUTATIONS = 8
@@ -61,6 +61,7 @@ const MODULE_DEPENDENCIES = new Map([
['pda-loading-link-regression', ['merchant-qualification-fixture']],
['phase4-overseas-return-followups', ['phase4-overseas-return-receipt']],
['dropship-pallet-matrix', ['merchant-qualification-fixture']],
['dropship-mixed-palletization', ['merchant-qualification-fixture']],
['dropship-physical-carton-chain', ['merchant-qualification-fixture']],
['dropship-browser-fixture', ['merchant-qualification-fixture']],
['mixed-container-receiving', ['merchant-qualification-fixture']],
+2
View File
@@ -172,6 +172,8 @@ export async function run(context) {
`dropship-browser-fixture.mjs` 通过总入口 `--modules dropship-browser-fixture` 执行,自动依赖客户资质准备;复用矩阵生产者创建一票四箱两托,完成国内装柜、船节点和按柜生成海外入库计划,明确停在 `READY_FOR_BROWSER_RECEIVING`。只准备新测试数据,不调用海外 PDA 收货或出库、不创建海外操作员;后续由浏览器页面验收。报告仍保存于 `createdResources.dropshipPalletMatrix`,准备通过不代表全流程通过,不自动删除数据。
`dropship-mixed-palletization.mjs` 通过总入口 `--modules dropship-mixed-palletization` 执行,自动依赖客户资质准备。经正式 HTTP 创建两客户三票一件代发(2/3/1 个物理箱,每箱两个 SKU),完成下单审核和测量,交替调用跨客户箱码预览、领取、清点、先箱后托批绑、移回/归回、六箱混托封托、解封和重新封托。核对客户/入仓单/SKU/数量守恒、只读识别无写入、同键重放、未知码/跨仓拒绝及逐票待分柜;结果保存在 `createdResources.dropshipMixedPalletization`。使用显式目标环境文件,开发机获授权时 Gateway 必须显式指向本机。会创建并保留客户、订单、测量与托盘数据,不自动清理;失败必须检查报告,重跑创建新批次。终点为 `MIXED_PALLET_SEALED_VERIFIED`,不代表装柜、海外履约或 Android 真机验收通过。
`dropship-pallet-matrix.mjs` 验证下述四场景整托手工加入、整托移除、智能推荐、独立订舱与文件放舱、真实柜号关联、预装确认、PDA 整托拣货和五类证据上传;异步装柜完成必须等到任务 `COMPLETED` 并逐票回查 `LOADED`。智能装柜的托盘汇总与装柜详情逐票组成分别核对,不能把共托汇总行的首票编号当作全托归属。随后经正式报关、国内离仓及人工船节点接口推进至 `PICKUP_READY`,再按柜生成逐票海外入库计划,检查来源托盘完整绑定、共享托盘身份一致和同键重放。海外收货使用本轮创建并绑定接柜仓的 PDA 操作员,上传三类照片、签收、逐实物托盘收货、逐票箱数核平和完成入库,同键重放及同托新键扫码均不得重复入账。随后从本轮逐票库存候选创建一件代发专用出库,携带原始箱唛由 PDA 拣货、完成,核对终态、幂等和可用库存扣减;finally 停用本轮操作员。出库重放按业务身份和数据库毫秒时间精度核对。离线合同为 `dropship-pallet-matrix-contract.test.mjs`;脚本覆盖不等于真实 HTTP 已通过,须检查当轮报告和库存审计。
`dropship-physical-carton-chain.mjs` 复用同一条正式链路,专门覆盖多 SKU 共用物理箱:20 箱×2 SKU/2 托、5 箱×3 SKU/1 托、7 箱×2 SKU/预计 2 托后合为 1 托。每个场景从新订单、测量、箱唛冻结和 PDA 清点开始,按去重箱唛核对预装、装柜、尾程计划、海外整托收货、库存候选和一件代发出库;每箱必须保留全部 SKU 明细,但任何汇总不得按 SKU 行重复累计箱数。使用 `--modules dropship-physical-carton-chain`,会保留本轮业务数据和 JSON 报告,不自动清理。
@@ -0,0 +1,134 @@
import { run as createOrder } from './orders.mjs'
import { run as measure } from './inbound-measurement.mjs'
import { run as createCustomer } from './merchant-qualification-fixture.mjs'
const MODULE = 'dropship-mixed-palletization'
const BASE = '/api/platform/v1/first-leg/pda-dropship'
const OPTIONS = { scenario: MODULE, deploymentBlocker: true }
// 正式下单和测量产生两客户三票,再用 PDA 公网接口逐箱混托并保留全部证据。
export async function run(context) {
const originalMarker = context.marker
const savedContext = { ...context.context }
const originalManualCount = context.options.prepareDropshipManualCount
const records = []
const result = { marker: originalMarker, stage: 'PREPARING', records, fullFlowVerified: false }
context.createdResources.dropshipMixedPalletization = result
function check(name, response, passed, details = '') {
context.assert(MODULE, name, response, passed, details, OPTIONS)
if (!passed) throw new Error(name)
}
async function command(name, method, path, body, headers = {}) {
const response = await context.request(method, path, body, undefined, headers)
const value = context.apiSuccess(MODULE, name, response, '', OPTIONS)
if (String(response?.body?.code) !== '200') throw new Error(`${name}: ${response?.body?.code}: ${response?.body?.message}`)
return value
}
async function rejected(name, method, path, body) {
const response = await context.request(method, path, body)
check(name, response, response.http === 200 && ['A0404', 'A0409'].includes(String(response.body?.code)), response.body?.message)
}
async function readTask(record) {
return command(`${record.label}回查任务`, 'GET', `${BASE}/tasks/${record.source.taskId}?warehouseId=${record.source.warehouseId}`)
}
try {
check('存在本轮已审核客户', null, Boolean(context.context.merchantQualificationFixture?.tenantId))
for (const [index, count] of [2, 3, 1].entries()) {
const label = ['A-2BOX', 'B-3BOX', 'B-1BOX'][index]
context.marker = `${originalMarker}-${label}`
if (index === 1) await createCustomer(context, { publishEnvironment: false })
for (const key of ['orders', 'inboundMeasurement', 'dropshipManualCount']) delete context.createdResources[key]
for (const key of ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']) delete context.context[key]
context.context.orderDestinationType = 'OVERSEAS'
context.context.orderFulfillmentType = 'ONE_CLICK_DROPSHIP'
context.context.orderProfile = { taxMode: 'BUY_DECLARATION', pickupMethod: 'SELF_EXPRESS', containerMode: 'LCL' }
context.context.waitSplitVariant = { skuCount: 2, packageCountPerSku: count, packageCounts: [count, count],
totalPackageCount: count, palletCount: 1, skuCodes: [`MIX-SAME-${originalMarker}`.slice(-60), `MIX-SECOND-${originalMarker}`.slice(-60)] }
if (records.length) context.context.waitSplitRoute = { originWarehouseName: records[0].order.receivingWarehouseName,
channelCode: records[0].order.channelCode, destinationIndex: 0 }
context.options.prepareDropshipManualCount = true
const start = context.steps.length
await createOrder(context)
await measure(context)
const source = context.createdResources.dropshipManualCount
check(`${label}真实下单审核测量全部通过`, null, Boolean(source?.taskId)
&& !context.steps.slice(start).some((step) => !step.passed))
records.push({ label, order: { ...context.createdResources.orders }, measurement: { ...context.createdResources.inboundMeasurement }, source: { ...source } })
check(`${label}实测物理箱数正确`, null, source.totalCartons === count)
}
check('两客户三票保持独立归属', null, records.length === 3 && new Set(records.map((r) => r.order.tenantId)).size === 2)
check('三票同仓同渠道同目的仓', null, records.every((r) => r.source.warehouseId === records[0].source.warehouseId
&& r.order.channelCode === records[0].order.channelCode && r.order.destinationWarehouseId === records[0].order.destinationWarehouseId))
context.marker = originalMarker
const warehouseId = records[0].source.warehouseId
const query = `warehouseId=${warehouseId}`
const generated = await command('读取第一票预生成托盘', 'GET', `${BASE}/inbound-orders/${records[0].source.inboundId}/pallets?accountId=${records[0].order.tenantId}`)
const palletNo = generated?.[0]?.palletNo
check('取得空的预生成DP托盘', null, Boolean(palletNo) && generated[0].status === 'OPEN' && generated[0].cartonCount === 0)
result.palletNo = palletNo; result.warehouseId = warehouseId
const palletPath = `${BASE}/pallets/resolve?${query}&palletNo=${encodeURIComponent(palletNo)}`
const initial = await command('先扫空托盘只读识别', 'GET', palletPath)
check('空托盘不会虚构客户组成', null, initial.pallet.cartonCount === 0 && initial.members.length === 0)
await rejected('无效托盘码被拒绝', 'GET', `${BASE}/pallets/resolve?${query}&palletNo=DP-000000-0000`)
await rejected('跨仓托盘码被拒绝', 'GET', `${BASE}/pallets/resolve?warehouseId=1&palletNo=${encodeURIComponent(palletNo)}`)
await rejected('无效箱码被拒绝', 'GET', `${BASE}/cartons/resolve?${query}&boxCode=NO-SUCH-${originalMarker}`)
result.stage = 'COUNTING'
const counted = []
// 交替扫描客户 A/B,证明页面无需先手工切换入仓单。
const scans = records.flatMap((record) => Array.from({ length: record.source.totalCartons }, (_, index) => ({ record, boxCode: `${record.source.inboundCode}#${record.source.totalCartons}-${index + 1}` })))
scans.sort((a, b) => Number(a.boxCode.split('-').at(-1)) - Number(b.boxCode.split('-').at(-1)))
for (const [index, { record, boxCode }] of scans.entries()) {
const resolved = await command('跨客户只读扫码预览', 'GET', `${BASE}/cartons/resolve?${query}&boxCode=${encodeURIComponent(boxCode)}`)
check('箱码返回原客户和原入仓任务', null, String(resolved.task.taskId) === record.source.taskId
&& String(resolved.task.tenantId) === record.order.tenantId && resolved.carton.skuLines.length === 2)
const before = await readTask(record)
check('读取箱码未改变清点和归托事实', null, before.countedCartons === counted.filter((x) => x.taskId === record.source.taskId).length)
if (before.taskStatus === 'PENDING') await command('确认时领取原任务', 'POST', `${BASE}/tasks/${record.source.taskId}/claim`, { warehouseId })
const body = { warehouseId, boxCode, palletNo: index === 0 ? null : palletNo,
skuLines: resolved.carton.skuLines.map((line) => ({ inboundDetailId: line.inboundDetailId, skuCode: line.skuCode,
actualQuantity: line.expectedQuantity, discrepancyAction: 'MATCH' })), operationId: `${originalMarker}-count-${index}` }
const submitted = await command('按原任务确认逐箱实收', 'POST', `${BASE}/tasks/${record.source.taskId}/cartons/count`, body)
const replayed = await command('清点同键重放不重复入账', 'POST', `${BASE}/tasks/${record.source.taskId}/cartons/count`, body)
check('重放保持箱身份和清点数量', null, submitted.carton.cartonId === replayed.carton.cartonId && submitted.countedCartons === replayed.countedCartons)
counted.push({ taskId: record.source.taskId, boxCode, cartonId: submitted.carton.cartonId, inboundId: record.source.inboundId, skuLines: body.skuLines })
if (index === 0) await command('先扫箱再扫托盘批量归托', 'POST', `${BASE}/tasks/${record.source.taskId}/pallets/bind-pending`, { warehouseId, palletNo, operationId: `${originalMarker}-bind` })
}
const beforeSeal = await command('封托前逐客户汇总核对', 'GET', palletPath)
check('六个实物箱两客户三票汇总守恒', null, beforeSeal.pallet.cartonCount === 6 && beforeSeal.members.length === 3
&& new Set(beforeSeal.members.map((m) => String(m.customerId))).size === 2
&& beforeSeal.members.every((m) => m.cartonCount === records.find((r) => r.source.taskId === String(m.taskId))?.source.totalCartons))
const moved = counted.find((item) => item.taskId === records[1].source.taskId)
await command('按客户B原任务移回待绑托', 'POST', `${BASE}/tasks/${moved.taskId}/cartons/${moved.cartonId}/move`, { warehouseId, targetPalletNo: null, operationId: `${originalMarker}-move-out` })
const movedTask = await readTask(records[1])
check('只改变原票归托数量', null, movedTask.unboundCartons === 1 && movedTask.countedCartons === 3)
await command('客户B已点箱归回当前混托', 'POST', `${BASE}/tasks/${moved.taskId}/cartons/${moved.cartonId}/move`, { warehouseId, targetPalletNo: palletNo, operationId: `${originalMarker}-move-back` })
const open = await command('归回后读取权威托盘', 'GET', palletPath)
const sealPath = `${BASE}/tasks/${records[0].source.taskId}/pallets/${open.pallet.palletId}/seal`
await command('混托整托封托', 'POST', sealPath, { warehouseId, expectedVersion: open.pallet.version, operationId: `${originalMarker}-seal` })
let sealed = await command('回查已封托状态', 'GET', palletPath)
check('托盘已封托且六箱完整', null, sealed.pallet.status === 'SEALED' && sealed.pallet.cartonCount === 6)
for (const record of records) {
record.task = await readTask(record)
const inbound = await command('封托后回查原票待分柜', 'GET', `/api/platform/inbounds/${record.source.inboundId}?tenantId=${record.order.tenantId}`)
check('参与票全部完成且进入待分柜', null, record.task.taskStatus === 'COMPLETED' && inbound.summary.processStatus === 'WAIT_SPLIT')
}
const actual = sealed.pallet.cartons
check('每箱客户归属和双SKU实收未合并', null, actual.length === counted.length && actual.every((carton) => {
const source = counted.find((item) => item.boxCode === carton.boxCode)
return source && String(carton.inboundId) === source.inboundId && carton.skuLines.length === 2
&& carton.skuLines.every((line) => source.skuLines.some((expected) => String(line.inboundDetailId) === String(expected.inboundDetailId) && line.actualQuantity === expected.actualQuantity))
}))
await rejected('已封托禁止移箱', 'POST', `${BASE}/tasks/${moved.taskId}/cartons/${moved.cartonId}/move`, { warehouseId, targetPalletNo: null, operationId: `${originalMarker}-sealed-move` })
await command('未入装柜计划允许解除封托', 'POST', `${BASE}/tasks/${records[0].source.taskId}/pallets/${sealed.pallet.palletId}/unseal`, { warehouseId, expectedVersion: sealed.pallet.version, operationId: `${originalMarker}-unseal` })
for (const record of records) check('解封同步恢复所有参与任务', null, (await readTask(record)).taskStatus === 'CLAIMED')
const reopened = await command('回查解封后开放状态', 'GET', palletPath)
await command('核对后重新封托', 'POST', sealPath, { warehouseId, expectedVersion: reopened.pallet.version, operationId: `${originalMarker}-reseal` })
sealed = await command('最终托盘事实', 'GET', palletPath)
result.pallet = sealed.pallet; result.members = sealed.members; result.counted = counted
result.stage = 'MIXED_PALLET_SEALED_VERIFIED'
for (const record of records) record.task = await readTask(record)
} finally {
context.marker = originalMarker; context.context = savedContext
context.options.prepareDropshipManualCount = originalManualCount
}
}
@@ -76,14 +76,10 @@ 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.items[0].quantity, 1)
assert.equal(body.needRelabel, false); assert.equal(body.items.length, 1); assert.equal(body.items[0].packageCount, 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', 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 }] }
status: 'DRAFT', pallets: [] }
transfers.push(transfer); return response(transfer)
}
if (path.includes('/warehouse-transfers/')) {
@@ -92,39 +88,10 @@ 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')) {
// 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('/pallets/drafts')) transfer.pallets = [{ id: `DRAFT${n}`, status: 'DRAFT', cartonCount: body.cartonCount }]
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('/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('/palletize')) { 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)
@@ -215,26 +182,13 @@ 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', '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']) {
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']) {
test(`fail closed, retain partial resources and clean owned sessions: ${fault}`, async () => {
const m = model(fault)
await assert.rejects(createFleetCrossCustomerFixture(m.context, m.options))
@@ -244,8 +198,6 @@ 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, quantity: 1 }] }
items: [{ skuCode: `CROSS-${key(suffix).slice(0, 16)}`, productNameZh: '受控测试一箱货物', packageCount: 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,14 +167,8 @@ 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 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)
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)
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)
@@ -182,28 +176,8 @@ 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 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)
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)
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')
@@ -215,7 +189,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, countedCarton,
member.measurementReceipt = { sourceBusinessNo: member.incomingNo, syntheticTestMaterials: true,
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')