Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a13586aff0 | ||
|
|
6c59525dec | ||
|
|
c76b9888f5 | ||
|
|
135ffa662d | ||
|
|
a8037fd97d | ||
|
|
d7616899d8 | ||
|
|
a563f63857 |
-22
@@ -35,28 +35,6 @@ 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,15 +18,6 @@ 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,45 +15,6 @@ 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)
|
||||
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
+1
-31
@@ -21,34 +21,6 @@ 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) {
|
||||
@@ -223,13 +195,11 @@ public class DropshipPalletizationService {
|
||||
.map(p->palletView(p,task.getInboundCode())).toList();
|
||||
}
|
||||
|
||||
/** 跨客户归托仍须同仓、同渠道、同目的仓,并锁定开放托盘。 */
|
||||
private PalletRow requireAssignablePallet(TaskRow task,String palletNo){
|
||||
PalletRow existing=workspaceRepository.lockPallet(palletNo);
|
||||
PalletRow existing=mapper.palletByNo(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,26 +4,6 @@ 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: 查询待领取或本人已领取任务
|
||||
|
||||
+1
-50
@@ -24,56 +24,7 @@ class DropshipPalletizationServiceTest {
|
||||
private final FirstlegDocumentNumberGenerator numbers=mock(FirstlegDocumentNumberGenerator.class);
|
||||
private final DropshipPalletizationMapper mapper=mock(DropshipPalletizationMapper.class);
|
||||
private final DropshipPalletizationService service=new DropshipPalletizationService();
|
||||
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());
|
||||
}
|
||||
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())); }
|
||||
|
||||
@Test void sharedPalletCountsDistinctSkuCodesWithoutMergingInboundDetails(){
|
||||
when(mapper.inboundCode(1L,30L)).thenReturn("IN-1");
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<executions>
|
||||
<execution>
|
||||
<id>default-test</id>
|
||||
<configuration><excludes><exclude>**/*SqlSemanticTest.java</exclude></excludes></configuration>
|
||||
<configuration><reuseForks>false</reuseForks><excludes><exclude>**/*SqlSemanticTest.java</exclude></excludes></configuration>
|
||||
</execution>
|
||||
<!-- H2复杂查询规划与普通单测分进程,仍沿用父级1GB上限并执行全部断言。 -->
|
||||
<execution>
|
||||
|
||||
+5
@@ -35,6 +35,11 @@ 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;
|
||||
|
||||
+8
@@ -53,6 +53,14 @@ 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,
|
||||
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -19,9 +19,8 @@ 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, String quantityUnit) { }
|
||||
Integer actualPackages, String status) { }
|
||||
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) { }
|
||||
|
||||
+4
-8
@@ -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 = com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(
|
||||
items.listApplication(application.getCustomerId(), application.getId()), true);
|
||||
int actualTotal = items.listApplication(application.getCustomerId(), application.getId()).stream()
|
||||
.mapToInt(i -> i.getActualDiscardPackages() == null ? 0 : i.getActualDiscardPackages()).sum();
|
||||
application.setTotalPackages(actualTotal);
|
||||
application.setTotalFee(application.getFeePerPackage().multiply(java.math.BigDecimal.valueOf(actualTotal)));
|
||||
discardService.updateLinkedStatus(application.getCustomerId(), application, CargoDiscardService.APPROVED,
|
||||
@@ -160,20 +160,16 @@ 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(),
|
||||
isReturnItem(i) ? "件" : "箱")).toList();
|
||||
i.getDiscardPackages(), i.getActualDiscardPackages(), i.getPdaStatus() == null ? "PENDING" : i.getPdaStatus())).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 -> isReturnItem(i) || Boolean.TRUE.equals(i.getCartonScanRequired()));
|
||||
boolean cartonScan = LOOSE.equals(entry.getKey()) || entry.getValue().stream().anyMatch(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(),
|
||||
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
+6
-14
@@ -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,13 +51,12 @@ 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(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, false));
|
||||
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(application.getTotalPackages());
|
||||
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;
|
||||
@@ -84,12 +83,11 @@ 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(returned ? (returnBoxIds.add(line.getReturnCartonId()) ? 1 : 0) : line.getDiscardPackages());
|
||||
snapshot.setPickedPackageCount(0); snapshot.setStatus("WAIT_PICK");
|
||||
snapshot.setPackageCount(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);
|
||||
@@ -98,21 +96,15 @@ 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(returned ? (Objects.requireNonNullElse(line.getActualDiscardPackages(), 0) > 0 ? boxes : 0)
|
||||
: Objects.requireNonNullElse(line.getActualDiscardPackages(), 0));
|
||||
snapshot.setPickedPackageCount(Objects.requireNonNullElse(line.getActualDiscardPackages(), 0));
|
||||
snapshot.setStatus("PICKED");
|
||||
}
|
||||
if (CargoDiscardService.CANCELLED.equals(application.getStatus())) snapshot.setStatus("CANCELLED");
|
||||
@@ -125,7 +117,7 @@ public class CargoDiscardOutboundService {
|
||||
};
|
||||
outbound.setStatus(status);
|
||||
outbound.setLxCodes(null);
|
||||
outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, "OUTBOUNDED".equals(status)));
|
||||
if ("OUTBOUNDED".equals(status)) outbound.setPackageCount(application.getTotalPackages());
|
||||
outbound.setUpdatedAt(LocalDateTime.now()); outbounds.updateById(outbound);
|
||||
}
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
|
||||
+3
-9
@@ -50,7 +50,6 @@ 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";
|
||||
@@ -139,7 +138,6 @@ public class CargoDiscardService {
|
||||
supported ? null : "当前库存缺少完整箱唛,可按全部库存废弃");
|
||||
}
|
||||
|
||||
/** 校验并预留所选库存,物理箱汇总与退货 SKU 件数分别保留。 */
|
||||
@Transactional
|
||||
public CargoDiscardResponses.Detail create(String customerId, String operatorId,
|
||||
CargoDiscardRequests.Create request) {
|
||||
@@ -200,8 +198,7 @@ public class CargoDiscardService {
|
||||
} else if (!warehouseId.equals(itemWarehouseId)) {
|
||||
throw conflict("One cargo discard application cannot span operating warehouses");
|
||||
}
|
||||
// 退货明细为件数,整箱只在全部 SKU 校验完成后按箱 ID 计一次。
|
||||
if (!returnSource) total = Math.addExact(total, input.discardPackages());
|
||||
total = Math.addExact(total, input.discardPackages());
|
||||
}
|
||||
|
||||
for (Long cartonId : returnCartonCache.keySet()) {
|
||||
@@ -212,7 +209,6 @@ 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();
|
||||
@@ -510,12 +506,10 @@ 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()), boxes,
|
||||
value.getFeePerPackage() == null ? value.getTotalFee() : value.getFeePerPackage().multiply(BigDecimal.valueOf(boxes)), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(),
|
||||
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.getReviewedAt(), outboundService.outboundNo(value), value.getCustomerConfirmedAt());
|
||||
}
|
||||
|
||||
|
||||
+59
-24
@@ -1,30 +1,65 @@
|
||||
package com.baiyue.lastleg.dispatch.mapper;
|
||||
import com.baiyue.lastleg.dispatch.model.DispatchBolData;
|
||||
|
||||
import org.apache.ibatis.annotations.*;
|
||||
import java.util.List;
|
||||
/** BOL按真实明细客户逐行读取整车,不依赖已移除的主单客户头。 */
|
||||
|
||||
/** BOL读取原调度逐来源数量及真实预约分配,扫码进度和订单商品JOIN均不放大或缩小箱数。 */
|
||||
@Mapper
|
||||
public interface DispatchBolMapper {
|
||||
@Select("SELECT di.customer_id AS customerId,di.destination_type AS destinationType,di.delivery_address AS deliveryAddress,di.postal_code AS postalCode," +
|
||||
"COALESCE(MAX(NULLIF(o.container_no,'')),MAX(NULLIF(ip.container_no,''))) AS containerNo," +
|
||||
"MAX(NULLIF(di.pallet_no,'')) AS palletNo," +
|
||||
"COALESCE(MAX(NULLIF(l.destination_warehouse_code,'')),MAX(NULLIF(di.fba_warehouse_code,''))," +
|
||||
"MAX(NULLIF(di.destination_warehouse_code,''))) AS fbaWarehouseCode," +
|
||||
"COALESCE(MAX(NULLIF(l.destination_warehouse_name,'')),MAX(NULLIF(di.destination_warehouse_name,''))) AS destinationWarehouseName," +
|
||||
"COALESCE(MAX(NULLIF(l.inbound_code,'')),MAX(NULLIF(di.inbound_code,''))) AS warehouseNo," +
|
||||
"GROUP_CONCAT(DISTINCT NULLIF(od.shipment_no,'') ORDER BY od.shipment_no SEPARATOR ', ') AS fbaShipmentId," +
|
||||
"GROUP_CONCAT(DISTINCT NULLIF(od.fba_tracking_no,'') ORDER BY od.fba_tracking_no SEPARATOR ', ') AS poReferenceNo," +
|
||||
"CASE WHEN MAX(l.id) IS NULL THEN COALESCE(MAX(di.package_count),0) " +
|
||||
"WHEN COALESCE(MAX(a.picked_package_count),0)>0 THEN MAX(a.picked_package_count) " +
|
||||
"ELSE COALESCE(MAX(a.planned_package_count),0) END AS packageCount " +
|
||||
"FROM ll_dispatch_item di " +
|
||||
"LEFT JOIN ll_inbound_plan_pallet p ON p.id=di.plan_pallet_id " +
|
||||
"LEFT JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=di.customer_id " +
|
||||
"LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND di.return_receipt_id IS NULL AND COALESCE(di.source_business_type,'')<>'RELABEL_ORDER' " +
|
||||
"LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=di.customer_id " +
|
||||
"LEFT JOIN ll_order o ON o.id=l.order_id AND o.customer_id=di.customer_id " +
|
||||
"LEFT JOIN ll_order_detail od ON od.order_id=l.order_id AND od.customer_id=di.customer_id " +
|
||||
"WHERE di.dispatch_order_id=#{dispatchOrderId} " +
|
||||
"GROUP BY di.id,di.customer_id,di.destination_type,di.delivery_address,di.postal_code,l.id ORDER BY di.id,l.id")
|
||||
List<DispatchBolData.Line> findWholeDispatch(@Param("dispatchOrderId") Long dispatchOrderId);
|
||||
record Cargo(Long dispatchItemId,Long orderId,Long planPalletId,String customerId,String destinationType,
|
||||
String deliveryAddress,String postalCode,String containerNo,String palletNo,String fbaWarehouseCode,
|
||||
String destinationWarehouseName,String warehouseNo,String fbaShipmentId,String poReferenceNo,
|
||||
Integer packageCount,Integer itemPackageCount,boolean originalPlan) { }
|
||||
record Allocation(Long dispatchItemId,Long planPalletId,String batchKey,String customerId,String sourceKey,
|
||||
String destinationKey,Integer packageCount,Long appointmentId,Long orderId,String confirmationNo) { }
|
||||
|
||||
/** 一行对应本车一个真实计划来源行;商品明细只聚合展示字段,原调度数量不取部分已拣数。 */
|
||||
@Select("""
|
||||
SELECT di.id AS dispatchItemId,
|
||||
CASE WHEN di.plan_pallet_id IS NOT NULL AND di.return_receipt_id IS NULL
|
||||
AND COALESCE(di.source_business_type,'')<>'RELABEL_ORDER' THEN l.order_id ELSE di.order_id END AS orderId,
|
||||
di.plan_pallet_id AS planPalletId,di.customer_id AS customerId,di.destination_type AS destinationType,
|
||||
di.delivery_address AS deliveryAddress,di.postal_code AS postalCode,
|
||||
COALESCE(MAX(NULLIF(o.container_no,'')),MAX(NULLIF(ip.container_no,''))) AS containerNo,
|
||||
MAX(NULLIF(di.pallet_no,'')) AS palletNo,
|
||||
COALESCE(MAX(NULLIF(l.fba_warehouse_code,'')),MAX(NULLIF(l.destination_warehouse_code,'')),
|
||||
MAX(NULLIF(di.fba_warehouse_code,'')),MAX(NULLIF(di.destination_warehouse_code,''))) AS fbaWarehouseCode,
|
||||
COALESCE(MAX(NULLIF(l.destination_warehouse_name,'')),MAX(NULLIF(di.destination_warehouse_name,''))) AS destinationWarehouseName,
|
||||
COALESCE(MAX(NULLIF(l.inbound_code,'')),MAX(NULLIF(di.inbound_code,''))) AS warehouseNo,
|
||||
GROUP_CONCAT(DISTINCT NULLIF(od.shipment_no,'') ORDER BY od.shipment_no SEPARATOR ', ') AS fbaShipmentId,
|
||||
GROUP_CONCAT(DISTINCT NULLIF(od.fba_tracking_no,'') ORDER BY od.fba_tracking_no SEPARATOR ', ') AS poReferenceNo,
|
||||
CASE WHEN l.id IS NULL THEN MAX(di.package_count)
|
||||
WHEN di.source_stage='RECEIVED' THEN MAX(a.counted_package_count)
|
||||
ELSE MAX(a.planned_package_count) END AS packageCount,
|
||||
MAX(di.package_count) AS itemPackageCount,
|
||||
CASE WHEN di.plan_pallet_id IS NOT NULL AND di.return_receipt_id IS NULL
|
||||
AND COALESCE(di.source_business_type,'')<>'RELABEL_ORDER' THEN TRUE ELSE FALSE END AS originalPlan
|
||||
FROM ll_dispatch_item di
|
||||
LEFT JOIN ll_inbound_plan_pallet p ON p.id=di.plan_pallet_id
|
||||
LEFT JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=di.customer_id
|
||||
LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND a.customer_id=di.customer_id
|
||||
AND di.return_receipt_id IS NULL AND COALESCE(di.source_business_type,'')<>'RELABEL_ORDER'
|
||||
LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=di.customer_id AND l.plan_id=p.plan_id
|
||||
LEFT JOIN ll_order o ON o.id=l.order_id AND o.customer_id=di.customer_id
|
||||
LEFT JOIN ll_order_detail od ON od.order_id=l.order_id AND od.customer_id=l.customer_id
|
||||
WHERE di.dispatch_order_id=#{dispatchOrderId}
|
||||
GROUP BY di.id,di.order_id,di.plan_pallet_id,di.customer_id,di.destination_type,di.delivery_address,
|
||||
di.postal_code,di.source_stage,di.return_receipt_id,di.source_business_type,l.id,l.order_id,a.id
|
||||
ORDER BY di.id,l.id,a.id
|
||||
""")
|
||||
List<Cargo> findWholeDispatch(@Param("dispatchOrderId") long dispatchOrderId);
|
||||
|
||||
/** 原batch+客户+稳定来源+目的地定位预约货物;BOOKING使用持久化order_id,绝不自行拼ORDER键。 */
|
||||
@Select("""
|
||||
SELECT a.dispatch_item_id AS dispatchItemId,a.plan_pallet_id AS planPalletId,a.batch_key AS batchKey,
|
||||
a.customer_id AS customerId,a.source_key AS sourceKey,a.destination_key AS destinationKey,
|
||||
a.package_count AS packageCount,p.id AS appointmentId,i.order_id AS orderId,p.confirmation_no AS confirmationNo
|
||||
FROM ll_dispatch_appointment_allocation a
|
||||
LEFT JOIN ll_inbound_fba_appointment p ON a.batch_key=CONCAT('FBA_APPOINTMENT:',p.id)
|
||||
LEFT JOIN ll_inbound_fba_appointment_item i ON i.appointment_id=p.id AND i.customer_id=a.customer_id
|
||||
AND i.source_key=a.source_key AND CONCAT('FBA:',i.fba_warehouse_code)=a.destination_key
|
||||
WHERE a.dispatch_id=#{dispatchOrderId} AND a.batch_key LIKE 'FBA_APPOINTMENT:%'
|
||||
ORDER BY a.dispatch_item_id,a.customer_id,a.source_key,a.destination_key,a.batch_key
|
||||
""")
|
||||
List<Allocation> findAppointmentAllocations(@Param("dispatchOrderId") long dispatchOrderId);
|
||||
}
|
||||
|
||||
+4
-3
@@ -52,11 +52,12 @@ public interface DispatchCandidateMapper {
|
||||
" / NULLIF((SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a2 WHERE a2.line_id=a.line_id),0) END),0) ELSE NULL END AS grossWeightKg," +
|
||||
"MIN(ip.container_no) AS containerNo," +
|
||||
CONFIRMED_APPOINTMENT_AT + " AS appointmentAt,NULL AS expectedLoadingAt," +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " +
|
||||
// 仓ID文本统一保留128字符,避免混合CASE的NONE排序规则及无长度CHAR截断;来源优先级与身份等值不变。
|
||||
"CAST(CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " +
|
||||
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " +
|
||||
"AND pos.available_packages > 0) ELSE COALESCE(NULLIF(CAST(ip.overseas_warehouse_id AS CHAR),'')," +
|
||||
"AND pos.available_packages > 0) ELSE COALESCE(NULLIF(CAST(ip.overseas_warehouse_id AS CHAR(128)),'')," +
|
||||
"(SELECT NULLIF(TRIM(source_order.overseas_warehouse_id),'') FROM ll_order source_order " +
|
||||
"WHERE source_order.id=ip.source_order_id AND source_order.customer_id=ip.customer_id LIMIT 1)) END AS overseasWarehouseId," +
|
||||
"WHERE source_order.id=ip.source_order_id AND source_order.customer_id=ip.customer_id LIMIT 1)) END AS CHAR(128)) AS overseasWarehouseId," +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_code) " +
|
||||
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
|
||||
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " +
|
||||
|
||||
+2
-2
@@ -25,11 +25,11 @@ public interface DispatchSourceMapper {
|
||||
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
|
||||
List<ItemPicked> pickedItems(@Param("dispatch")long dispatch);
|
||||
|
||||
/** 组成查询不按占用可选性筛掉本草稿或隐藏客户的真实成员。 */
|
||||
/** 组成查询保留完整客户成员;仓ID两侧同转有界字符,避免列与连接字符集排序规则冲突。 */
|
||||
@Select("<script>SELECT DISTINCT p.id AS planPalletId,ip.customer_id AS customerId FROM ll_inbound_plan_pallet p "
|
||||
+ "JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id "
|
||||
+ "JOIN ll_inventory_position s ON s.pallet_id=p.warehouse_pallet_id AND s.customer_id=ip.customer_id "
|
||||
+ "AND s.overseas_warehouse_id=CAST(ip.overseas_warehouse_id AS CHAR) "
|
||||
+ "AND CAST(s.overseas_warehouse_id AS CHAR(128))=CAST(ip.overseas_warehouse_id AS CHAR(128)) "
|
||||
+ "WHERE s.overseas_warehouse_id=#{warehouse} AND p.warehouse_pallet_id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> ORDER BY ip.customer_id,p.id</script>")
|
||||
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
|
||||
List<PlanReference> physicalPlans(@Param("warehouse")String warehouse,@Param("ids")List<Long> ids);
|
||||
|
||||
+9
-5
@@ -3,13 +3,17 @@ import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
/** 打印服务内部一致快照,不恢复旧排车JSON协议。 */
|
||||
public record DispatchBolData(String dispatchNo,String overseasWarehouseId,String overseasWarehouseName,String carrierName,
|
||||
String vehiclePlate,LocalDateTime estimatedArrivalAt,List<Line> bolLines,Map<String,String> appointmentNumbers) {
|
||||
public record Line(String customerId,String destinationType,String deliveryAddress,String postalCode,String containerNo,
|
||||
String vehiclePlate,LocalDateTime estimatedArrivalAt,List<Line> bolLines) {
|
||||
public record Line(Long dispatchItemId,Long orderId,String customerId,String destinationType,String deliveryAddress,String postalCode,String containerNo,
|
||||
String palletNo,String fbaWarehouseCode,String destinationWarehouseName,String warehouseNo,String fbaShipmentId,
|
||||
String poReferenceNo,int packageCount) {
|
||||
String poReferenceNo,int packageCount,String appointmentNo) {
|
||||
/** 外部确认号只去首尾空白,未取得真实约号时保留空。 */
|
||||
public Line { appointmentNo=appointmentNo==null || appointmentNo.isBlank()?null:appointmentNo.strip(); }
|
||||
/** 目的地身份不使用客户作为分组条件。 */
|
||||
public String destinationKey(){return com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(destinationType,fbaWarehouseCode,null,deliveryAddress,postalCode);}
|
||||
/** 打印展示使用业务名称或真实地址。 */
|
||||
public String destinationLabel(){return "PRIVATE".equals(destinationType)?deliveryAddress:destinationWarehouseName;}
|
||||
public String groupKey(){return customerId+"\u0000"+destinationKey();}
|
||||
/** 同目的地同约号跨客户合并,无号也可组成同目的地文档。 */
|
||||
public String groupKey(){return destinationKey()+"\u0000"+Objects.requireNonNullElse(appointmentNo,"");}
|
||||
}
|
||||
public String appointmentFor(Line line){return appointmentNumbers.get(line.groupKey());}
|
||||
}
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.baiyue.lastleg.dispatch.repository;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchBolMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.List;
|
||||
|
||||
/** BOL专用只读事实入口,应用服务不直接持有Mapper。 */
|
||||
@Repository
|
||||
public class DispatchBolRepository {
|
||||
private final DispatchBolMapper mapper;
|
||||
/** 装配同一次事务使用的来源及分配读取。 */
|
||||
public DispatchBolRepository(DispatchBolMapper mapper){this.mapper=mapper;}
|
||||
/** 返回完整明细身份、原调度箱数及逐来源打印资料。 */
|
||||
public List<DispatchBolMapper.Cargo> cargo(long dispatchId){return mapper.findWholeDispatch(dispatchId);}
|
||||
/** 仅正式FBA预约分配有外部预约号;客户仓内部DISPATCH批次不伪造约号。 */
|
||||
public List<DispatchBolMapper.Allocation> allocations(long dispatchId){return mapper.findAppointmentAllocations(dispatchId);}
|
||||
}
|
||||
+36
-6
@@ -223,14 +223,37 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
requirePlatform(actor);var row=required(actor,number,false);requireEditing(row,row.getRecordVersion());
|
||||
return evidence.upload(row.getId(),kind,actor.operatorId(),file);
|
||||
}
|
||||
/** 列表权限含详情只读,但文件仍限当前整车复合归属。 */
|
||||
/** 列表权限含详情只读;仅冻结MASTER证照走司机归属,其余保持本车附件复合归属。 */
|
||||
public com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File evidenceMetadata(Actor actor,String number,
|
||||
com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,long fileId) {
|
||||
requirePlatform(actor);var row=required(actor,number,false);if(fileId<=0)throw bad("文件引用无效");return evidence.metadata(row.getId(),kind,fileId);
|
||||
requirePlatform(actor);var row=required(actor,number,false);if(fileId<=0)throw bad("文件引用无效");
|
||||
var driverId=masterEvidenceDriverId(row,kind,fileId);
|
||||
if(driverId!=null)return driverEvidenceMetadata(fleet.dispatchDriverLicense(driverId,fileId).metadata(),fileId);
|
||||
return evidence.metadata(row.getId(),kind,fileId);
|
||||
}
|
||||
/** 内容仅经服务端鉴权代理,不返回匿名存储URL。 */
|
||||
/** 内容沿同一次确定的归属代理读取,不尝试类别回退,不返回匿名存储URL。 */
|
||||
public byte[] evidenceContent(Actor actor,String number,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,long fileId) {
|
||||
requirePlatform(actor);var row=required(actor,number,false);if(fileId<=0)throw bad("文件引用无效");return evidence.download(row.getId(),kind,fileId);
|
||||
requirePlatform(actor);var row=required(actor,number,false);if(fileId<=0)throw bad("文件引用无效");
|
||||
var driverId=masterEvidenceDriverId(row,kind,fileId);
|
||||
if(driverId!=null) {
|
||||
var file=fleet.dispatchDriverLicense(driverId,fileId);driverEvidenceMetadata(file.metadata(),fileId);return file.content();
|
||||
}
|
||||
return evidence.download(row.getId(),kind,fileId);
|
||||
}
|
||||
/** 未命中冻结MASTER引用的文件仅可按原排车归属读取,保留编辑中尚未保存的本车外配上传。 */
|
||||
private Long masterEvidenceDriverId(DispatchAggregate row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,long fileId) {
|
||||
if(kind!=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER)return null;
|
||||
var resources=read(row.getResourcesJson(),Resources.class);var driver=resources==null?null:resources.driver();
|
||||
if(driver==null || driver.mode()!=ResourceMode.MASTER || driver.evidence()==null || driver.evidence().fileIds()==null
|
||||
|| !driver.evidence().fileIds().contains(fileId))return null;
|
||||
if(driver.driverId()==null || driver.driverId()<=0)throw bad("排车冻结司机档案身份无效,不能读取证照");
|
||||
return driver.driverId();
|
||||
}
|
||||
/** 司机域已执行当前/历史关联及File四元组校验,转换元数据时仍保留请求文件身份。 */
|
||||
private static com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File driverEvidenceMetadata(
|
||||
com.baiyue.lastleg.fleet.driver.DriverModels.File file,long fileId) {
|
||||
if(file==null || !Long.toString(fileId).equals(file.fileId()))throw bad("司机证照回执文件身份不一致");
|
||||
return new com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File(fileId,file.fileName(),file.contentType(),file.sizeBytes());
|
||||
}
|
||||
|
||||
/** 空单或同仓多客户选货一次建车,与资源关系、来源锁和账本同事务。 */
|
||||
@@ -346,7 +369,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
var row=lockPlatform(actor,number);var identity=identity(actor,number,Action.SUBMIT_REVIEW,request.recordVersion(),request.idempotencyKey(),request);
|
||||
var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get();
|
||||
requireEditing(row,request.recordVersion());
|
||||
if(!Set.of(ReviewState.DRAFT.name(),ReviewState.DISPATCH_REJECTED.name(),ReviewState.LOADING_REJECTED.name()).contains(row.getReviewState()))throw conflict("当前审核已提交,请刷新核对");
|
||||
if(!canSubmitReview(row.getReviewState()))throw conflict("当前审核已提交,请刷新核对");
|
||||
verifyReviewResources(row);
|
||||
if(n(row.getPackageCount())<=0 || repository.cargo(row.getId()).isEmpty())throw bad("空排车不能提交审核");
|
||||
long round=row.getReviewRound()+1;String basis=basis(row);
|
||||
@@ -716,6 +739,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
if(!Status.ACTIVE.name().equals(row.getStatus()) || !Stage.BEFORE_LOADING.name().equals(row.getStage()) || n(row.getPickedPackageCount())>0 || n(row.getShippedPackageCount())>0) throw conflict("仅装车前且未扫码的有效排车可以调整货物");
|
||||
}
|
||||
|
||||
/** 提交资格和写入共用可重提状态,已进入两级待审的整车不得再次显示提交入口。 */
|
||||
private static boolean canSubmitReview(String reviewState) {
|
||||
return ReviewState.DRAFT.name().equals(reviewState) || ReviewState.DISPATCH_REJECTED.name().equals(reviewState)
|
||||
|| ReviewState.LOADING_REJECTED.name().equals(reviewState);
|
||||
}
|
||||
|
||||
/** 动作快照只反映实际当前资格,写入仍须再次加锁校验。 */
|
||||
Map<Action,Gate> actions(Actor actor,DispatchAggregate row) {
|
||||
var gates=new EnumMap<Action,Gate>(Action.class); boolean active=Status.ACTIVE.name().equals(row.getStatus());
|
||||
@@ -723,7 +752,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
for(var action:Action.values()) {
|
||||
boolean state=switch(action) {
|
||||
case CREATE -> actor.mode()==ActorMode.PLATFORM;
|
||||
case EDIT,ADD_CARGO,REMOVE_CARGO,CANCEL,SUBMIT_REVIEW -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0;
|
||||
case EDIT,ADD_CARGO,REMOVE_CARGO,CANCEL -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0;
|
||||
case SUBMIT_REVIEW -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0 && canSubmitReview(row.getReviewState());
|
||||
case DISPATCH_REVIEW -> active && review==ReviewState.DISPATCH_PENDING;
|
||||
case LOADING_REVIEW -> active && review==ReviewState.LOADING_PENDING;
|
||||
case RETURN_TO_REVIEW -> active && stage==Stage.LOADING && n(row.getPickedPackageCount())==0;
|
||||
|
||||
+3
-1
@@ -32,6 +32,7 @@ public class DispatchBolPrintService {
|
||||
@Resource private DomesticWarehouseDirectoryClient warehouses;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||
|
||||
/** 按本车一致快照组装全部BOL,一次调用打印服务并保持一个PDF下载。 */
|
||||
public GeneratedPdf generate(String dispatchNo, String requestId) {
|
||||
DispatchBolData detail = dispatches.read(dispatchNo);
|
||||
if (detail.bolLines() == null || detail.bolLines().isEmpty()) {
|
||||
@@ -54,6 +55,7 @@ public class DispatchBolPrintService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 将同目的地同预约号的跨客户货物组装为独立分页BOL,无预约号时ISA保持空白。 */
|
||||
private List<PrintPdfClient.Document> documents(DispatchBolData detail) {
|
||||
String shipFromAddress = warehouseAddress(detail.overseasWarehouseId());
|
||||
Map<String, List<DispatchBolData.Line>> groups = new LinkedHashMap<>();
|
||||
@@ -72,7 +74,7 @@ public class DispatchBolPrintService {
|
||||
.map(line -> new PrintPdfClient.CargoRow(text(line.containerNo()), text(line.palletNo()), text(line.fbaWarehouseCode()),
|
||||
text(line.warehouseNo()), text(line.fbaShipmentId()), text(line.poReferenceNo()), line.packageCount())).toList();
|
||||
documents.add(new PrintPdfClient.Document(text(detail.overseasWarehouseName()), shipFromAddress, text(detail.carrierName()),
|
||||
text(detail.appointmentFor(lines.get(0))), dateTime(detail.estimatedArrivalAt()), text(detail.vehiclePlate()), "—",
|
||||
lines.get(0).appointmentNo() == null ? "" : lines.get(0).appointmentNo().strip(), dateTime(detail.estimatedArrivalAt()), text(detail.vehiclePlate()), "—",
|
||||
text(lines.get(0).fbaWarehouseCode()), destination, "—", text(detail.dispatchNo()), unique(lines, true), unique(lines, false),
|
||||
total, page + 1, pages, rows));
|
||||
offset = end;
|
||||
|
||||
+71
-10
@@ -1,31 +1,92 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.lastleg.dispatch.model.DispatchBolData;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchBolMapper;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchBolRepository;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.*;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
/** 先在本地一致事务读全车打印事实,事务结束后才调用远程打印。 */
|
||||
|
||||
/** 同一只读事务内核实本车逐来源数量,再按真实预约份额拆行;事务结束后才调用远程打印。 */
|
||||
@Service
|
||||
public class DispatchBolReadService {
|
||||
private final DispatchAggregateRepository roots;private final DispatchBolMapper lines;private final ObjectMapper json;
|
||||
public DispatchBolReadService(DispatchAggregateRepository roots,DispatchBolMapper lines,ObjectMapper json){this.roots=roots;this.lines=lines;this.json=json;}
|
||||
private final DispatchAggregateRepository roots;private final DispatchBolRepository facts;private final ObjectMapper json;
|
||||
private record Source(Long itemId,String customerId,Long orderId,String destinationKey) { }
|
||||
private record AllocationKey(Long itemId,String batchKey,String customerId,String sourceKey,String destinationKey) { }
|
||||
/** 打印读取只经Repository,不写预约、调度或库存事实。 */
|
||||
public DispatchBolReadService(DispatchAggregateRepository roots,DispatchBolRepository facts,ObjectMapper json){this.roots=roots;this.facts=facts;this.json=json;}
|
||||
|
||||
/** 保留原调度总量,未约部分以空约号打印,不按部分拣货进度缩量。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public DispatchBolData read(String number) {
|
||||
var row=roots.find(number);if(row==null)throw new BizException(ResultCode.NOT_FOUND,"排车单不存在");
|
||||
Resources resources;try{resources=json.readValue(row.getResourcesJson(),Resources.class);}catch(com.fasterxml.jackson.core.JsonProcessingException e){throw new IllegalStateException("本车资源快照损坏",e);}
|
||||
var cargo=lines.findWholeDispatch(row.getId());
|
||||
if(cargo.stream().anyMatch(l->l.customerId()==null || l.destinationKey()==null || l.packageCount()<=0))throw new BizException(ResultCode.VALIDATION_FAILED,"BOL货物客户、目的地或箱数尚未核实");
|
||||
var confirmations=new LinkedHashMap<String,LinkedHashSet<String>>();
|
||||
for(var batch:roots.batches(row.getId()))if(batch.confirmationNo()!=null && !batch.confirmationNo().isBlank())confirmations.computeIfAbsent(batch.customerId()+"\u0000"+batch.destinationKey(),k->new LinkedHashSet<>()).add(batch.confirmationNo());
|
||||
var numbers=confirmations.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey,e->String.join(", ",e.getValue())));
|
||||
var cargo=new LinkedHashMap<Source,DispatchBolData.Line>();var totals=new HashMap<Long,Integer>();var pallets=new HashMap<Long,Long>();
|
||||
for(var raw:facts.cargo(row.getId())) {
|
||||
int packages=nonnegative(raw.packageCount());var line=line(raw,null,packages);
|
||||
if(raw.dispatchItemId()==null || blank(raw.customerId()) || line.destinationKey()==null
|
||||
|| raw.originalPlan() && raw.orderId()==null)throw invalid();
|
||||
int total=positive(raw.itemPackageCount());var prior=totals.putIfAbsent(raw.dispatchItemId(),total);
|
||||
if(prior!=null && prior!=total)throw invalid();
|
||||
if(pallets.containsKey(raw.dispatchItemId()) && !Objects.equals(pallets.get(raw.dispatchItemId()),raw.planPalletId()))throw invalid();
|
||||
pallets.put(raw.dispatchItemId(),raw.planPalletId());
|
||||
if(packages==0)continue;
|
||||
var key=new Source(raw.dispatchItemId(),raw.customerId(),raw.orderId(),line.destinationKey());
|
||||
cargo.merge(key,line,DispatchBolReadService::merge);
|
||||
}
|
||||
var sourceTotals=new HashMap<Long,Integer>();
|
||||
cargo.forEach((key,line)->sourceTotals.merge(key.itemId(),line.packageCount(),Math::addExact));
|
||||
if(!sourceTotals.equals(totals))throw invalid();
|
||||
var remaining=new LinkedHashMap<Source,Integer>();cargo.forEach((key,line)->remaining.put(key,line.packageCount()));
|
||||
var seen=new HashSet<AllocationKey>();var result=new ArrayList<DispatchBolData.Line>();
|
||||
for(var allocation:facts.allocations(row.getId())) {
|
||||
if(allocation.appointmentId()==null || allocation.orderId()==null || blank(allocation.sourceKey())
|
||||
|| !Objects.equals(pallets.get(allocation.dispatchItemId()),allocation.planPalletId()))throw invalid();
|
||||
var key=new Source(allocation.dispatchItemId(),allocation.customerId(),allocation.orderId(),allocation.destinationKey());
|
||||
var source=cargo.get(key);int count=positive(allocation.packageCount());
|
||||
if(source==null || !seen.add(new AllocationKey(allocation.dispatchItemId(),allocation.batchKey(),allocation.customerId(),allocation.sourceKey(),allocation.destinationKey()))
|
||||
|| count>remaining.get(key))throw invalid();
|
||||
remaining.put(key,remaining.get(key)-count);result.add(copy(source,count,allocation.confirmationNo()));
|
||||
}
|
||||
// 每个来源只消费实际分配份额,余额有明确实物依据,保持空ISA而非重复整托。
|
||||
remaining.forEach((key,count)->{if(count>0)result.add(copy(cargo.get(key),count,null));});
|
||||
var printedTotals=new HashMap<Long,Integer>();result.forEach(line->printedTotals.merge(line.dispatchItemId(),line.packageCount(),Math::addExact));
|
||||
if(!printedTotals.equals(totals))throw invalid();
|
||||
return new DispatchBolData(row.getDispatchNo(),row.getOverseasWarehouseId(),row.getOverseasWarehouseName(),resources.vehicle()==null?null:resources.vehicle().carrierName(),resources.vehicle()==null?null:resources.vehicle().plate(),
|
||||
row.getEstimatedArrivalAt()==null?null:LocalDateTime.ofInstant(row.getEstimatedArrivalAt().toInstant(ZoneOffset.UTC),ZoneId.systemDefault()),List.copyOf(cargo),Map.copyOf(numbers));
|
||||
row.getEstimatedArrivalAt()==null?null:LocalDateTime.ofInstant(row.getEstimatedArrivalAt().toInstant(ZoneOffset.UTC),ZoneId.systemDefault()),List.copyOf(result));
|
||||
}
|
||||
/** 将数据库原始来源行转换为带真实身份的内部打印行。 */
|
||||
private static DispatchBolData.Line line(DispatchBolMapper.Cargo c,String appointment,int packages) {
|
||||
return new DispatchBolData.Line(c.dispatchItemId(),c.orderId(),c.customerId(),c.destinationType(),c.deliveryAddress(),c.postalCode(),c.containerNo(),c.palletNo(),
|
||||
c.fbaWarehouseCode(),c.destinationWarehouseName(),c.warehouseNo(),c.fbaShipmentId(),c.poReferenceNo(),packages,appointment);
|
||||
}
|
||||
/** 拆份只改变份额和预约号,真实客户、源订单和排车明细身份不变。 */
|
||||
private static DispatchBolData.Line copy(DispatchBolData.Line c,int packages,String appointment) {
|
||||
return new DispatchBolData.Line(c.dispatchItemId(),c.orderId(),c.customerId(),c.destinationType(),c.deliveryAddress(),c.postalCode(),c.containerNo(),c.palletNo(),
|
||||
c.fbaWarehouseCode(),c.destinationWarehouseName(),c.warehouseNo(),c.fbaShipmentId(),c.poReferenceNo(),packages,appointment);
|
||||
}
|
||||
/** 同订单多来源行先守恒合并,再让每条预约分配消费一次;展示字符串只去重合并。 */
|
||||
private static DispatchBolData.Line merge(DispatchBolData.Line a,DispatchBolData.Line b) {
|
||||
return new DispatchBolData.Line(a.dispatchItemId(),a.orderId(),a.customerId(),a.destinationType(),a.deliveryAddress(),a.postalCode(),join(a.containerNo(),b.containerNo()),a.palletNo(),
|
||||
a.fbaWarehouseCode(),a.destinationWarehouseName(),join(a.warehouseNo(),b.warehouseNo()),join(a.fbaShipmentId(),b.fbaShipmentId()),join(a.poReferenceNo(),b.poReferenceNo()),Math.addExact(a.packageCount(),b.packageCount()),null);
|
||||
}
|
||||
/** 仅打印展示字段去重,不用逗号字符串表示预约身份。 */
|
||||
private static String join(String a,String b) {
|
||||
var values=new LinkedHashSet<String>();for(var text:Arrays.asList(a,b))if(text!=null)for(var part:text.split(","))if(!part.isBlank())values.add(part.strip());
|
||||
return values.isEmpty()?null:String.join(", ",values);
|
||||
}
|
||||
/** 数量未知或非正不得伪造打印份额。 */
|
||||
private static int positive(Integer value){if(value==null || value<=0)throw invalid();return value;}
|
||||
/** 已核实为零的源行没有待打印货物,但不以零替代未知数量。 */
|
||||
private static int nonnegative(Integer value){if(value==null || value<0)throw invalid();return value;}
|
||||
/** 来源身份必须来自真实持久事实。 */
|
||||
private static boolean blank(String value){return value==null || value.isBlank();}
|
||||
/** 不完整、跨客户或超额分配不能生成看似守恒的BOL。 */
|
||||
private static BizException invalid(){return new BizException(ResultCode.VALIDATION_FAILED,"BOL货物来源、预约归属或分配箱数尚未核实");}
|
||||
}
|
||||
|
||||
@@ -337,6 +337,12 @@ public class DispatchService {
|
||||
/** 执行沿用全车申请/订单→共同实托串行顺序;库存变化不重新解释原冻结贡献。 */
|
||||
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public RelabelDispatchSourcePort.Snapshot lockExecutionSources(String warehouse,List<DispatchItem> items) {
|
||||
// 合法空车没有来源或实托可锁;非空明细即使尚未关联实托,仍必须经过原严格校验。
|
||||
if(items.isEmpty()) {
|
||||
if(warehouse==null || warehouse.isBlank())throw new IllegalArgumentException("空排车仍须明确起运仓库");
|
||||
return new RelabelDispatchSourcePort.Snapshot(List.of(),List.of(),List.of(),
|
||||
wholeHashes.source(Map.of("warehouseId",warehouse,"items",List.of())),true,List.of());
|
||||
}
|
||||
var pallets=items.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).collect(Collectors.toCollection(TreeSet::new));
|
||||
var initial=relabelSources.snapshot(warehouse,List.copyOf(pallets));var applications=new TreeSet<String>();
|
||||
initial.applications().forEach(a->applications.add(a.relabelNo()));
|
||||
|
||||
+5
@@ -34,6 +34,11 @@ public class FleetTripExecutionService {
|
||||
private final ObjectMapper json;
|
||||
private final FleetBookingReservations bookingReservations;
|
||||
|
||||
/** 排车域先核对冻结司机及附件引用;本域继续校验该司机当前或历史证照归属。 */
|
||||
public DriverModels.Download dispatchDriverLicense(long driverId,long fileId) {
|
||||
return drivers.download(driverId,fileId);
|
||||
}
|
||||
|
||||
public FleetTripExecutionService(FleetTripMapper trips, FleetTripDispatchMapper bindings, FleetResourceMapper resources,
|
||||
FleetDispatchGateway dispatches, FleetTripService history, DriverService drivers,
|
||||
VehicleRepository vehicles, ObjectMapper json, FleetBookingReservations bookingReservations) {
|
||||
|
||||
+2
-8
@@ -190,9 +190,7 @@ public class WarehouseTransferService {
|
||||
private List<WarehouseTransferResponses.SourceCarton> unreservedSourceCartons(String customerId,
|
||||
String warehouseId, String source, List<WarehouseTransferResponses.SourceCarton> cartons) {
|
||||
if (inventorySources == null) return cartons;
|
||||
var occupiedMarks = inventorySources.occupiedMarks(customerId, warehouseId, source);
|
||||
if (occupiedMarks == null || occupiedMarks.isEmpty()) return cartons;
|
||||
Set<String> occupied = occupiedMarks.stream()
|
||||
Set<String> occupied = inventorySources.occupiedMarks(customerId, warehouseId, source).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()
|
||||
@@ -485,6 +483,7 @@ 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())) {
|
||||
@@ -500,11 +499,6 @@ 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,6 +6,26 @@ 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
|
||||
|
||||
@@ -874,7 +874,7 @@ paths:
|
||||
/api/platform/v1/last-leg/dispatches/{dispatchNo}/prints/bol:
|
||||
get:
|
||||
summary: 生成排车 BOL PDF
|
||||
description: 权限 platform:overseas-warehouse:dispatch:list;服务端按目的 FBA 仓分组并分页生成固定 A4 PDF,浏览器不直接访问内部打印服务。
|
||||
description: 权限 platform:overseas-warehouse:dispatch:list;服务端按目的地与预约号归集BOL,同目的地同预约号跨客户合并,不同预约号分别生成BOL并独立分页;无预约号也能打印且预约栏留空,同目的地空号合并。本次仍返回包含各份BOL的一个固定A4 PDF,浏览器不直接访问内部打印服务,打印不改变货物客户归属。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }]
|
||||
responses:
|
||||
|
||||
+15
@@ -8,6 +8,21 @@ 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);
|
||||
|
||||
+14
@@ -17,6 +17,20 @@ 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);
|
||||
|
||||
-22
@@ -189,28 +189,6 @@ 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");
|
||||
|
||||
-19
@@ -104,23 +104,4 @@ 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());
|
||||
}
|
||||
}
|
||||
|
||||
-29
@@ -99,9 +99,6 @@ 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"))));
|
||||
@@ -230,32 +227,6 @@ 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();
|
||||
|
||||
+54
-1
@@ -16,6 +16,57 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class DispatchCandidateMapperContractTest {
|
||||
/** 执行正式仓ID的完整CASE及外层过滤,保留实库存优先、客户来源和长ID文本身份。 */
|
||||
@Test
|
||||
void wholeCandidateWarehouseProjectionExecutesAsTextWithoutChangingSourceOwnership() throws Exception {
|
||||
String sql = String.join(" ", DispatchCandidateMapper.class.getMethod("wholeAvailable", String.class,
|
||||
String.class, List.class, String.class, String.class, String.class, String.class).getAnnotation(Select.class).value());
|
||||
int start = sql.indexOf("CAST(CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id)");
|
||||
int end = sql.indexOf(" AS overseasWarehouseId", start);
|
||||
assertTrue(start >= 0 && end > start, "正式whole候选必须在整个仓CASE外统一为文本投影");
|
||||
String expression = sql.substring(start, end).replace(">", ">").replace("<", "<");
|
||||
assertTrue(expression.endsWith("END AS CHAR(128))"));
|
||||
assertTrue(expression.contains("NULLIF(CAST(ip.overseas_warehouse_id AS CHAR(128)),'')"));
|
||||
String warehouseFilter = sql.substring(sql.lastIndexOf(") selected WHERE ") + 1)
|
||||
.replace("#{warehouseId}", "?").replace("</script>", "");
|
||||
String query = "SELECT selected.planPalletId FROM (SELECT p.id AS planPalletId," + expression
|
||||
+ " AS overseasWarehouseId FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id)"
|
||||
+ warehouseFilter;
|
||||
try (var connection = DriverManager.getConnection("jdbc:h2:mem:dispatch-whole-warehouse-text;MODE=MySQL")) {
|
||||
var statement = connection.createStatement();
|
||||
statement.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR(128),overseas_warehouse_id BIGINT,source_order_id BIGINT)");
|
||||
statement.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,warehouse_pallet_id BIGINT)");
|
||||
statement.execute("CREATE TABLE ll_inventory_position(customer_id VARCHAR(128),pallet_id BIGINT,available_packages BIGINT,overseas_warehouse_id VARCHAR(128))");
|
||||
statement.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(128),overseas_warehouse_id VARCHAR(128))");
|
||||
statement.execute("INSERT INTO ll_inbound_plan VALUES (1,'A',193000000000200002,NULL),"
|
||||
+ "(2,'A',193000000000100001,NULL),(3,'B',NULL,20),(4,'A',193000000000100001,NULL),"
|
||||
+ "(5,'B',193000000000200002,NULL),(6,'A',NULL,NULL),(7,'A',193000000000100001,21),"
|
||||
+ "(8,'A',193000000000100001,NULL),(9,'A',NULL,21)");
|
||||
statement.execute("INSERT INTO ll_inbound_plan_pallet VALUES (1,1,10),(2,2,NULL),(3,3,NULL),"
|
||||
+ "(4,4,40),(5,5,50),(6,6,NULL),(7,7,NULL),(8,8,80),(9,9,NULL)");
|
||||
statement.execute("INSERT INTO ll_inventory_position VALUES ('A',10,1,'193000000000100001'),"
|
||||
+ "('B',40,1,'193000000000100001'),('A',40,0,'193000000000100001'),"
|
||||
+ "('B',50,1,'193000000000100001'),('A',80,1,'193000000000200002')");
|
||||
statement.execute("INSERT INTO ll_order VALUES (20,'A','193000000000200002'),"
|
||||
+ "(20,'B','193000000000100001'),(21,'B','193000000000100001')");
|
||||
try (var prepared = connection.prepareStatement(query)) {
|
||||
assertWarehouseRows(prepared, "193000000000100001", List.of(1L, 2L, 3L, 5L, 7L));
|
||||
assertWarehouseRows(prepared, "193000000000200002", List.of(8L));
|
||||
for (String invalid : List.of("19300000000010000", "193000000000100001x", "0193000000000100001", "1.93e17", "wrong"))
|
||||
assertWarehouseRows(prepared, invalid, List.of());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 每次实际执行同一参数化仓过滤,禁止用前缀、数字强转或其他客户库存替代身份。 */
|
||||
private void assertWarehouseRows(java.sql.PreparedStatement prepared, String warehouse, List<Long> expected) throws Exception {
|
||||
prepared.setString(1, warehouse);
|
||||
var actual = new ArrayList<Long>();
|
||||
try (var rows = prepared.executeQuery()) { while (rows.next()) actual.add(rows.getLong(1)); }
|
||||
actual.sort(Long::compareTo);
|
||||
assertEquals(expected, actual, warehouse);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ordinaryAndReturnRelabelCandidatesExcludeDirectOutboundDeliveryModes() throws Exception {
|
||||
for (String method : List.of("relabelAvailable", "nativeReturnRelabelAvailable")) {
|
||||
@@ -200,7 +251,9 @@ class DispatchCandidateMapperContractTest {
|
||||
String sql = String.join(" ", method.getAnnotation(Select.class).value());
|
||||
assertTrue(sql.contains("CASE WHEN p.warehouse_pallet_id IS NOT NULL"));
|
||||
assertTrue(sql.contains("pos.overseas_warehouse_id"));
|
||||
assertTrue(sql.contains("NULLIF(CAST(ip.overseas_warehouse_id AS CHAR),'')"));
|
||||
assertTrue(sql.contains(method.getName().equals("available")
|
||||
? "NULLIF(CAST(ip.overseas_warehouse_id AS CHAR(128)),'')"
|
||||
: "NULLIF(CAST(ip.overseas_warehouse_id AS CHAR),'')"));
|
||||
assertTrue(sql.contains("source_order.id=ip.source_order_id"));
|
||||
assertTrue(sql.contains("source_order.customer_id=ip.customer_id"));
|
||||
assertTrue(sql.contains("source_order.overseas_warehouse_id"));
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package com.baiyue.lastleg.dispatch.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/** 实际Mapper查询仓/客户/实托交集;MySQL不同排序规则另由只读真实库探针验证。 */
|
||||
class DispatchSourceMapperWarehouseBindingTest {
|
||||
private static final long WAREHOUSE = 193000000000200003L;
|
||||
private static final long PHYSICAL_PALLET = 2112349761578209281L;
|
||||
private static final long PLAN = 2112349764502786049L;
|
||||
private static final long PLAN_PALLET = 2112349764502786050L;
|
||||
private static final String CUSTOMER = "2112349740878327809";
|
||||
private static final String SECOND_CUSTOMER = "2112349740878327810";
|
||||
private JdbcTemplate db;
|
||||
private DispatchSourceMapper mapper;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
var data = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID()
|
||||
+ ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
db = new JdbcTemplate(data);
|
||||
// 仅保留本查询所需列,类型/长度与lastleg.sql一致:计划仓BIGINT,库存仓VARCHAR(128)。
|
||||
db.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),overseas_warehouse_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT PRIMARY KEY,plan_id BIGINT,customer_id VARCHAR(128),warehouse_pallet_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_inventory_position(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),overseas_warehouse_id VARCHAR(128),pallet_id BIGINT)");
|
||||
plan(PLAN, PLAN_PALLET, CUSTOMER, CUSTOMER, WAREHOUSE, PHYSICAL_PALLET);
|
||||
position(1, CUSTOMER, Long.toString(WAREHOUSE), PHYSICAL_PALLET);
|
||||
var config = new MybatisConfiguration();
|
||||
config.addMapper(DispatchSourceMapper.class);
|
||||
var factory = new MybatisSqlSessionFactoryBean();
|
||||
factory.setDataSource(data);
|
||||
factory.setConfiguration(config);
|
||||
mapper = new SqlSessionTemplate(factory.getObject()).getMapper(DispatchSourceMapper.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void longStringStockWarehouseMatchesTheSameNumericPlanWarehouseWithoutLosingIdentity() {
|
||||
assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE), List.of(PHYSICAL_PALLET)))
|
||||
.containsExactly(new DispatchSourceMapper.PlanReference(PLAN_PALLET, CUSTOMER));
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestedWarehouseMustMatchTheRealStockPosition() {
|
||||
assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE + 1), List.of(PHYSICAL_PALLET))).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void matchingRequestedStockWarehouseDoesNotAuthorizeAPlanInAnotherWarehouse() {
|
||||
db.update("UPDATE ll_inbound_plan SET overseas_warehouse_id=? WHERE id=?", WAREHOUSE + 1, PLAN);
|
||||
assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE), List.of(PHYSICAL_PALLET))).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void samePhysicalPalletCannotBorrowAnotherCustomersInventoryPosition() {
|
||||
db.update("UPDATE ll_inventory_position SET customer_id=? WHERE id=1", SECOND_CUSTOMER);
|
||||
assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE), List.of(PHYSICAL_PALLET))).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void matchingHeaderAndPositionCannotBypassThePlanPalletCustomer() {
|
||||
db.update("UPDATE ll_inbound_plan_pallet SET customer_id=? WHERE id=?", SECOND_CUSTOMER, PLAN_PALLET);
|
||||
assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE), List.of(PHYSICAL_PALLET))).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void completeSharedPalletCompositionRetainsBothSeparatelyOwnedCustomerPlans() {
|
||||
plan(PLAN + 10, PLAN_PALLET + 10, SECOND_CUSTOMER, SECOND_CUSTOMER, WAREHOUSE, PHYSICAL_PALLET);
|
||||
position(2, SECOND_CUSTOMER, Long.toString(WAREHOUSE), PHYSICAL_PALLET);
|
||||
// 无本仓库存的第三份计划不能混入当前组成。
|
||||
plan(PLAN + 20, PLAN_PALLET + 20, "2112349740878327811", "2112349740878327811", WAREHOUSE, PHYSICAL_PALLET);
|
||||
assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE), List.of(PHYSICAL_PALLET)))
|
||||
.containsExactly(new DispatchSourceMapper.PlanReference(PLAN_PALLET, CUSTOMER),
|
||||
new DispatchSourceMapper.PlanReference(PLAN_PALLET + 10, SECOND_CUSTOMER));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMatchingWarehouseAndCustomerCannotBypassTheRequestedPhysicalPalletSet() {
|
||||
assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE), List.of(PHYSICAL_PALLET + 1))).isEmpty();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"0193000000000200003", "193000000000200003x", "193000000000200003.0"})
|
||||
void pseudoWarehouseIdentifiersAreNotAcceptedThroughNumericCoercion(String pseudoWarehouse) {
|
||||
db.update("UPDATE ll_inventory_position SET overseas_warehouse_id=? WHERE id=1", pseudoWarehouse);
|
||||
// 请求与库存字符串确实相同;必须仍因不等于计划仓的完整字符身份而拒绝。
|
||||
assertThat(mapper.physicalPlans(pseudoWarehouse, List.of(PHYSICAL_PALLET))).isEmpty();
|
||||
}
|
||||
|
||||
private void plan(long planId, long planPalletId, String headerCustomer, String palletCustomer,
|
||||
long warehouse, long physicalPallet) {
|
||||
db.update("INSERT INTO ll_inbound_plan VALUES(?,?,?)", planId, headerCustomer, warehouse);
|
||||
db.update("INSERT INTO ll_inbound_plan_pallet VALUES(?,?,?,?)", planPalletId, planId, palletCustomer, physicalPallet);
|
||||
}
|
||||
|
||||
private void position(long positionId, String customer, String warehouse, long pallet) {
|
||||
db.update("INSERT INTO ll_inventory_position VALUES(?,?,?,?)", positionId, customer, warehouse, pallet);
|
||||
}
|
||||
}
|
||||
+218
@@ -35,6 +35,166 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
|
||||
/** 主单/资源/占用/账本真实SQL回归;外部目录与选货权威提供者使用受控输入。 */
|
||||
public class DispatchAggregatePersistenceTest {
|
||||
/** 正式司机附件从排车冻结引用选出真实司机,再复用司机域及File的读取边界。 */
|
||||
@Test void masterDriverEvidenceUsesFrozenDriverOwnerForMetadataAndContent() throws Exception {
|
||||
var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,71L,List.of(81L));
|
||||
e.allowFile(81L);var before=e.f.db.queryForMap("SELECT resources_json,record_version FROM ll_dispatch_order WHERE dispatch_no=?",number);
|
||||
assertThat(e.f.service.evidenceMetadata(e.f.actor,number,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,81L).fileId()).isEqualTo(81L);
|
||||
assertThat(e.f.service.evidenceContent(e.f.actor,number,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,81L)).containsExactly(1,2,3);
|
||||
verify(e.files,times(2)).metadata(71L,81L);verify(e.files,times(2)).download(71L,81L);verifyNoInteractions(e.dispatchFiles);
|
||||
assertThat(e.f.db.queryForMap("SELECT resources_json,record_version FROM ll_dispatch_order WHERE dispatch_no=?",number)).isEqualTo(before);
|
||||
}
|
||||
|
||||
/** 主档后来换证不改写本车冻结证照;旧附件必须仍满足该司机的历史关联规则。 */
|
||||
@Test void frozenHistoricalDriverLicenseRemainsReadableAfterMasterReplacement() throws Exception {
|
||||
var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,71L,List.of(81L));
|
||||
e.driver.setLicenseFileId(82L);when(e.history.selectCount(any())).thenReturn(1L);e.allowFile(81L);
|
||||
assertThat(e.f.service.evidenceMetadata(e.f.actor,number,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,81L).fileId()).isEqualTo(81L);
|
||||
assertThat(e.f.service.evidenceContent(e.f.actor,number,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,81L)).containsExactly(1,2,3);
|
||||
verify(e.history,times(2)).selectCount(any());verify(e.files,times(2)).metadata(71L,81L);verifyNoInteractions(e.dispatchFiles);
|
||||
}
|
||||
|
||||
/** 外配附件仍限本车证据;不能因为共用DRIVER展示类型而改走正式司机档案。 */
|
||||
@Test void externalDriverEvidenceRetainsExactDispatchOwnerForBothReads() throws Exception {
|
||||
var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.EXTERNAL,null,List.of(81L));
|
||||
var dispatch=e.f.repository.find(number).getId();var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER;
|
||||
var metadata=new com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File(81L,"external.pdf","application/pdf",3L);
|
||||
when(e.dispatchFiles.metadata(dispatch,kind,81L)).thenReturn(metadata);when(e.dispatchFiles.download(dispatch,kind,81L)).thenReturn(new byte[]{4,5,6});
|
||||
assertThat(e.f.service.evidenceMetadata(e.f.actor,number,kind,81L)).isEqualTo(metadata);
|
||||
assertThat(e.f.service.evidenceContent(e.f.actor,number,kind,81L)).containsExactly(4,5,6);
|
||||
verify(e.dispatchFiles).metadata(dispatch,kind,81L);verify(e.dispatchFiles).download(dispatch,kind,81L);
|
||||
verifyNoInteractions(e.driverRows,e.history,e.files);
|
||||
}
|
||||
|
||||
/** 冻结引用不绕过司机当前或历史关联,提供者明确拒绝后不得尝试排车类别。 */
|
||||
@Test void unlinkedFrozenMasterFileCannotFallBackToDispatchEvidence() throws Exception {
|
||||
var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,71L,List.of(81L));
|
||||
e.driver.setLicenseFileId(82L);when(e.history.selectCount(any())).thenReturn(0L);
|
||||
var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER;
|
||||
assertThatThrownBy(()->e.f.service.evidenceMetadata(e.f.actor,number,kind,81L)).hasMessageContaining("未关联");
|
||||
assertThatThrownBy(()->e.f.service.evidenceContent(e.f.actor,number,kind,81L)).hasMessageContaining("未关联");
|
||||
verifyNoInteractions(e.files,e.dispatchFiles);
|
||||
}
|
||||
|
||||
/** File拒绝主档四元组时直接失败,不以另一业务类别或其他司机重新读取。 */
|
||||
@Test void masterFileProviderRejectionCannotFallBackToAnotherCategory() throws Exception {
|
||||
var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,71L,List.of(81L));
|
||||
when(e.files.metadata(71L,81L)).thenThrow(new BizException(com.baiyue.common.core.api.ResultCode.NOT_FOUND,"业务附件不存在或归属不匹配"));
|
||||
var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER;
|
||||
assertThatThrownBy(()->e.f.service.evidenceMetadata(e.f.actor,number,kind,81L)).hasMessageContaining("归属不匹配");
|
||||
assertThatThrownBy(()->e.f.service.evidenceContent(e.f.actor,number,kind,81L)).hasMessageContaining("归属不匹配");
|
||||
verify(e.files,never()).download(anyLong(),anyLong());verifyNoInteractions(e.dispatchFiles);
|
||||
}
|
||||
|
||||
/** MASTER改外配的编辑尚未保存时,新上传文件只走本车归属,不误借旧司机白名单。 */
|
||||
@Test void unsavedExternalUploadOnMasterDispatchRetainsOriginalDispatchFileReads() throws Exception {
|
||||
var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,71L,List.of(81L));
|
||||
var dispatch=e.f.repository.find(number).getId();var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER;
|
||||
var metadata=new com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File(91L,"new-external.pdf","application/pdf",3L);
|
||||
when(e.dispatchFiles.metadata(dispatch,kind,91L)).thenReturn(metadata);when(e.dispatchFiles.download(dispatch,kind,91L)).thenReturn(new byte[]{4,5,6});
|
||||
assertThat(e.f.service.evidenceMetadata(e.f.actor,number,kind,91L)).isEqualTo(metadata);
|
||||
assertThat(e.f.service.evidenceContent(e.f.actor,number,kind,91L)).containsExactly(4,5,6);
|
||||
verifyNoInteractions(e.driverRows,e.history,e.files);
|
||||
assertThat(e.f.db.queryForObject("SELECT resources_json FROM ll_dispatch_order WHERE dispatch_no=?",String.class,number)).contains("MASTER").doesNotContain("91");
|
||||
}
|
||||
|
||||
/** 未列入MASTER冻结集合或声明为外配的档案文件只能接受本车四元组核验,失败后不转司机域。 */
|
||||
@Test void foreignOrWrongModeMasterFilesNeverGainDriverDomainAccess() throws Exception {
|
||||
for(var mode:List.of(ResourceMode.MASTER,ResourceMode.EXTERNAL)) {
|
||||
var e=new DriverEvidenceFixture();var number=e.dispatch(mode,71L,mode==ResourceMode.MASTER?List.of(81L):List.of(91L));
|
||||
var dispatch=e.f.repository.find(number).getId();var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER;
|
||||
when(e.dispatchFiles.metadata(dispatch,kind,91L)).thenThrow(new BizException(com.baiyue.common.core.api.ResultCode.NOT_FOUND,"业务附件不存在或归属不匹配"));
|
||||
when(e.dispatchFiles.download(dispatch,kind,91L)).thenThrow(new BizException(com.baiyue.common.core.api.ResultCode.NOT_FOUND,"业务附件不存在或归属不匹配"));
|
||||
assertThatThrownBy(()->e.f.service.evidenceMetadata(e.f.actor,number,kind,91L)).hasMessageContaining("归属不匹配");
|
||||
assertThatThrownBy(()->e.f.service.evidenceContent(e.f.actor,number,kind,91L)).hasMessageContaining("归属不匹配");
|
||||
verifyNoInteractions(e.driverRows,e.history,e.files);
|
||||
}
|
||||
}
|
||||
|
||||
/** 命中冻结文件集合仍须有本车真实正数司机身份,不能拿车号或其他司机替代。 */
|
||||
@Test void masterFileWithMissingOrInvalidDriverIdentityFailsBeforeAnyFileProvider() throws Exception {
|
||||
for(Long driverId:Arrays.asList(null,0L,-1L)) {
|
||||
var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,driverId,List.of(81L));
|
||||
var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER;
|
||||
assertThatThrownBy(()->e.f.service.evidenceMetadata(e.f.actor,number,kind,81L)).hasMessageContaining("身份无效");
|
||||
assertThatThrownBy(()->e.f.service.evidenceContent(e.f.actor,number,kind,81L)).hasMessageContaining("身份无效");
|
||||
verifyNoInteractions(e.driverRows,e.history,e.files,e.dispatchFiles);
|
||||
}
|
||||
}
|
||||
|
||||
/** 无法解释的资源模式不选择任何文件域,防止异常快照放宽读取。 */
|
||||
@Test void unknownDriverModeFailsBeforeEitherFileProvider() throws Exception {
|
||||
var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,71L,List.of(81L));
|
||||
e.f.db.update("UPDATE ll_dispatch_order SET resources_json=REPLACE(resources_json,'MASTER','UNKNOWN') WHERE dispatch_no=?",number);
|
||||
var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER;
|
||||
assertThatThrownBy(()->e.f.service.evidenceMetadata(e.f.actor,number,kind,81L)).hasMessageContaining("快照读取失败");
|
||||
assertThatThrownBy(()->e.f.service.evidenceContent(e.f.actor,number,kind,81L)).hasMessageContaining("快照读取失败");
|
||||
verifyNoInteractions(e.driverRows,e.history,e.files,e.dispatchFiles);
|
||||
}
|
||||
|
||||
/** 使用真实排车Mapper/资源JSON及司机Service,只替代外部文件内容和主档持久提供者。 */
|
||||
private static final class DriverEvidenceFixture {
|
||||
final Fixture f;final DriverMapper driverRows=mock(DriverMapper.class);final DriverHistoryMapper history=mock(DriverHistoryMapper.class);
|
||||
final DriverFileGateway files=mock(DriverFileGateway.class);final DriverEntity driver=new DriverEntity();
|
||||
final com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway dispatchFiles;
|
||||
/** 真实司机Service继续执行当前/历史附件关联检查,不由排车测试替身伪装通过。 */
|
||||
DriverEvidenceFixture() throws Exception {
|
||||
f=new Fixture();driver.setId(71L);driver.setLicenseFileId(81L);when(driverRows.selectById(71L)).thenReturn(driver);
|
||||
var drivers=new DriverService(driverRows,history,files,f.json,mock(FleetWarehouseDirectory.class));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(f.fleet,"drivers",drivers);
|
||||
dispatchFiles=(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway)org.springframework.test.util.ReflectionTestUtils.getField(f.service,"evidence");
|
||||
}
|
||||
/** 在真实主单保存服务端资源快照,HTTP请求本身不能指定司机、模式或允许文件集合。 */
|
||||
String dispatch(ResourceMode mode,Long driverId,List<Long> ids) throws Exception {
|
||||
var created=f.tx.execute(t->f.service.create(f.actor,f.create("evidence-"+UUID.randomUUID(),List.of())));
|
||||
var resource=new DispatchContracts.Resources(null,new DispatchContracts.DriverSnapshot(mode,driverId,"测试司机","123456789",
|
||||
new DispatchContracts.DriverEvidence("TEST-LICENSE",LocalDate.of(2099,1,1),ids,Instant.parse("2098-01-01T00:00:00Z"))));
|
||||
f.db.update("UPDATE ll_dispatch_order SET resources_json=? WHERE dispatch_no=?",f.json.writeValueAsString(resource),created.dispatchNo());return created.dispatchNo();
|
||||
}
|
||||
/** File内容固定为离线测试字节;真实复合归属由既有两个HTTP网关合同分别覆盖。 */
|
||||
void allowFile(long fileId) {
|
||||
var metadata=new DriverModels.File(Long.toString(fileId),"driver.pdf","application/pdf",3L);
|
||||
when(files.metadata(71L,fileId)).thenReturn(metadata);when(files.download(71L,fileId)).thenReturn(new byte[]{1,2,3});
|
||||
}
|
||||
}
|
||||
/** 空单零版本取消使用真实来源规则,成功及原键重放均不制造来源、库存或重复命令。 */
|
||||
@Test void emptyVersionZeroCancellationUsesRealSourceRuleAndPreservesOriginalReplay() throws Exception {
|
||||
var f=new Fixture();var created=f.tx.execute(t->f.service.create(f.actor,f.create("empty-real-cancel",List.of())));
|
||||
var rules=new DispatchService();var facts=mock(com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.class);
|
||||
var sources=mock(DispatchSourceRepository.class);var serial=mock(com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort.class);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(rules,"wholeHashes",new DispatchCommandHasher(f.json));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(rules,"relabelSources",facts);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(rules,"wholeSources",sources);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(rules,"palletSerial",serial);
|
||||
when(facts.snapshot(anyString(),anyList())).thenThrow(new IllegalArgumentException("仓库和实托集合不能为空"));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(f.service,"sourceRules",rules);
|
||||
assertThat(created.recordVersion()).isZero();
|
||||
var request=new DispatchCommands.ReasonCommand(0L,"empty-cancel-original","取消尚未添加货物的空车");
|
||||
var cancelled=f.tx.execute(t->f.service.cancel(f.actor,created.dispatchNo(),request));
|
||||
assertThat(cancelled.status()).isEqualTo(Status.CANCELLED);assertThat(cancelled.stage()).isEqualTo(Stage.BEFORE_LOADING);
|
||||
assertThat(f.tx.execute(t->f.service.cancel(f.actor,created.dispatchNo(),request)).replayed()).isTrue();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='empty-cancel-original'",Long.class)).isOne();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item",Long.class)).isZero();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock",Long.class)).isZero();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock",Long.class)).isZero();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class)).isZero();
|
||||
assertThat(f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("CANCELLED");
|
||||
verifyNoInteractions(facts,sources,serial);
|
||||
}
|
||||
|
||||
/** 空单编辑保留零货量与版本命令,不借编辑隐式制造可装车来源。 */
|
||||
@Test void emptyShellEditPreservesZeroCargoAndVersionedReplay() throws Exception {
|
||||
var f=new Fixture();var created=f.tx.execute(t->f.service.create(f.actor,f.create("empty-edit",List.of())));
|
||||
var request=new DispatchCommands.Update(0L,"empty-edit-original",null,null,null,null,"空单补充备注");
|
||||
var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request));
|
||||
assertThat(saved.stage()).isEqualTo(Stage.BEFORE_LOADING);assertThat(saved.counts().packageCount()).isZero();
|
||||
assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion());
|
||||
assertThat(f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request)).replayed()).isTrue();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item",Long.class)).isZero();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='empty-edit-original'",Long.class)).isOne();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class)).isZero();
|
||||
}
|
||||
|
||||
/** 移货、取消只释放本车原占用;重排后旧键恢复不得释放新车的活动实托键。 */
|
||||
@Test void removalCancellationAndOldCommandRecoveryNeverReleaseNewTruckClaims() throws Exception {
|
||||
var f=new Fixture();var a=f.source(1,"101",501,"ONT8",3);var b=f.source(2,"202",502,"LAX9",5);
|
||||
@@ -127,6 +287,64 @@ public class DispatchAggregatePersistenceTest {
|
||||
assertThatThrownBy(()->f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),new DispatchCommands.LoadingComplete(approved.recordVersion(),"missing-rejected",preview.manifestHash(),List.of(),null)))).isInstanceOf(BizException.class).hasMessageContaining("核平");
|
||||
assertThat(f.db.queryForObject("SELECT stage FROM ll_dispatch_order",String.class)).isEqualTo("LOADING");
|
||||
}
|
||||
/** 已提交根的公开资格关闭;新键重复提交不改变事实,原键仍先于当前状态恢复结果。 */
|
||||
@Test void pendingSubmissionGateRejectsNewKeyWithoutMutationAndRestoresOriginalCommand() throws Exception {
|
||||
var security=org.springframework.security.core.context.SecurityContextHolder.getContext();var previous=security.getAuthentication();
|
||||
security.setAuthentication(new org.springframework.security.authentication.TestingAuthenticationToken("operator-test","",
|
||||
"platform:overseas-warehouse:dispatch:list","platform:overseas-warehouse:dispatch:edit"));
|
||||
try {
|
||||
var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3);
|
||||
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("submission-gate-create",List.of(source))));
|
||||
var request=new DispatchCommands.SubmitReview(created.recordVersion(),"submission-gate-original",null);
|
||||
var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),request));
|
||||
var detail=f.tx.execute(s->f.service.detail(f.actor,created.dispatchNo()));
|
||||
assertThat(detail.reviewState()).isEqualTo(ReviewState.DISPATCH_PENDING);
|
||||
assertThat(detail.actions().get(Action.SUBMIT_REVIEW).allowed()).isFalse();
|
||||
var before=f.json.writeValueAsString(f.repository.find(created.dispatchNo()));
|
||||
var commands=f.db.queryForList("SELECT id,idempotency_key,record_version,command_hash FROM ll_dispatch_command ORDER BY id");
|
||||
assertThatThrownBy(()->f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),
|
||||
new DispatchCommands.SubmitReview(detail.recordVersion(),"submission-gate-duplicate",null))))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("当前审核已提交");
|
||||
assertThat(f.json.writeValueAsString(f.repository.find(created.dispatchNo()))).isEqualTo(before);
|
||||
assertThat(f.db.queryForList("SELECT id,idempotency_key,record_version,command_hash FROM ll_dispatch_command ORDER BY id")).isEqualTo(commands);
|
||||
var replay=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),request));
|
||||
assertThat(replay.replayed()).isTrue();assertThat(replay.recordVersion()).isEqualTo(submitted.recordVersion());
|
||||
assertThat(replay.result()).isEqualTo(submitted.result());
|
||||
assertThat(f.json.writeValueAsString(f.repository.find(created.dispatchNo()))).isEqualTo(before);
|
||||
assertThat(f.db.queryForList("SELECT id,idempotency_key,record_version,command_hash FROM ll_dispatch_command ORDER BY id")).isEqualTo(commands);
|
||||
} finally { security.setAuthentication(previous); }
|
||||
}
|
||||
|
||||
/** 真实两级审核流证明三种可重提状态开放,而两种待审状态均关闭提交资格。 */
|
||||
@Test void submissionActionMatchesDraftRejectedAndPendingReviewStates() throws Exception {
|
||||
var security=org.springframework.security.core.context.SecurityContextHolder.getContext();var previous=security.getAuthentication();
|
||||
security.setAuthentication(new org.springframework.security.authentication.TestingAuthenticationToken("operator-test","",
|
||||
"platform:overseas-warehouse:dispatch:list","platform:overseas-warehouse:dispatch:edit",
|
||||
"platform:fleet:trip:dispatch-review","platform:fleet:trip:loading-review"));
|
||||
try {
|
||||
var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3);
|
||||
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("submission-states-create",List.of(source))));
|
||||
assertSubmissionGate(f,created.dispatchNo(),ReviewState.DRAFT,true);
|
||||
var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submission-states-1",null)));
|
||||
assertSubmissionGate(f,created.dispatchNo(),ReviewState.DISPATCH_PENDING,false);
|
||||
var rejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"submission-states-dispatch-rejected",Decision.REJECTED,"核对调度")));
|
||||
assertSubmissionGate(f,created.dispatchNo(),ReviewState.DISPATCH_REJECTED,true);
|
||||
var resubmitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(rejected.recordVersion(),"submission-states-2",null)));
|
||||
var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(resubmitted.recordVersion(),"submission-states-dispatch-approved",Decision.APPROVED,null)));
|
||||
assertSubmissionGate(f,created.dispatchNo(),ReviewState.LOADING_PENDING,false);
|
||||
var loadingRejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"submission-states-loading-rejected",Decision.REJECTED,"核对装载")));
|
||||
assertSubmissionGate(f,created.dispatchNo(),ReviewState.LOADING_REJECTED,true);
|
||||
f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(loadingRejected.recordVersion(),"submission-states-3",null)));
|
||||
assertSubmissionGate(f,created.dispatchNo(),ReviewState.DISPATCH_PENDING,false);
|
||||
} finally { security.setAuthentication(previous); }
|
||||
}
|
||||
|
||||
/** 从真实详情DTO读取动作资格,避免只测内部枚举或权限缺失导致的假阴性。 */
|
||||
private static void assertSubmissionGate(Fixture f,String number,ReviewState state,boolean allowed) {
|
||||
var detail=f.tx.execute(s->f.service.detail(f.actor,number));
|
||||
assertThat(detail.reviewState()).isEqualTo(state);assertThat(detail.actions().get(Action.SUBMIT_REVIEW).allowed()).isEqualTo(allowed);
|
||||
}
|
||||
|
||||
/** 两级顺序、驳回重提、原结果恢复和零扫码退回均实际提交到同一整车。 */
|
||||
@Test void reviewsUseActualRootRoundAndImmutableCommandsBeforeAndAfterLoading() throws Exception {
|
||||
var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3);
|
||||
|
||||
+87
-13
@@ -10,7 +10,7 @@ import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient;
|
||||
import com.baiyue.lastleg.client.PrintPdfClient;
|
||||
import com.baiyue.lastleg.dispatch.model.DispatchBolData;
|
||||
import java.util.Map;
|
||||
import java.util.ArrayList;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
@@ -33,11 +33,13 @@ class DispatchBolPrintServiceTest {
|
||||
@InjectMocks private DispatchBolPrintService service;
|
||||
|
||||
@BeforeEach
|
||||
/** 设置本次受控打印调用的内部身份。 */
|
||||
void setUp() {
|
||||
ReflectionTestUtils.setField(service, "internalToken", "internal-test-token");
|
||||
}
|
||||
|
||||
@Test
|
||||
/** 核对首页十八行及续页二十六行的全部分页边界。 */
|
||||
void usesDenseFirstAndContinuationPageCapacities() {
|
||||
assertThat(DispatchBolPrintService.pageCount(1)).isEqualTo(1);
|
||||
assertThat(DispatchBolPrintService.pageCount(18)).isEqualTo(1);
|
||||
@@ -49,10 +51,11 @@ class DispatchBolPrintServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
/** 保留从实际起运仓主档解析营业地址的打印行为。 */
|
||||
void resolvesTheOperatingWarehouseAddressIntoTheBolRequest() {
|
||||
String warehouseId = "193000000000200003";
|
||||
DispatchBolData detail = new DispatchBolData("DS-WH-20260911-624449",warehouseId,"洛杉矶仓","五月开发验收车队",null,
|
||||
LocalDateTime.of(2026,9,12,2,40,20),List.of(line("101","ABE2","ABE2",1)),Map.of());
|
||||
LocalDateTime.of(2026,9,12,2,40,20),List.of(line("101","ABE2","ABE2",1)));
|
||||
var address = new DomesticWarehouseDirectoryClient.Address("OPERATING", "US", "California", "Rancho Cucamonga",
|
||||
null, "91730", "10767 7th Street", null);
|
||||
var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L,
|
||||
@@ -74,10 +77,11 @@ class DispatchBolPrintServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
/** 无预约号时不同目的仓仍分别生成各自BOL。 */
|
||||
void createsOneBolDocumentPerFbaWarehouse() {
|
||||
String warehouseId = "193000000000200003";
|
||||
DispatchBolData detail = new DispatchBolData("DS-WH-20260911-624449",warehouseId,"洛杉矶仓","五月开发验收车队",null,
|
||||
LocalDateTime.of(2026,9,12,2,40,20),List.of(line("101","ABE2","ABE2 Warehouse",1),line("102","ONT8","ONT8 Warehouse",1)),Map.of());
|
||||
LocalDateTime.of(2026,9,12,2,40,20),List.of(line("101","ABE2","ABE2 Warehouse",1),line("102","ONT8","ONT8 Warehouse",1)));
|
||||
var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L,
|
||||
"US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE", null, null);
|
||||
when(dispatches.read(detail.dispatchNo())).thenReturn(detail);
|
||||
@@ -96,21 +100,91 @@ class DispatchBolPrintServiceTest {
|
||||
.containsExactly("ABE2", "ONT8");
|
||||
}
|
||||
@Test
|
||||
void keepsTwoCustomersAtTheSameDestinationInSeparateBolDocuments() {
|
||||
var first=line("101","ABE2","ABE2 Warehouse",3);var second=line("102","ABE2","ABE2 Warehouse",5);
|
||||
var detail=new DispatchBolData("DS-ONE-TRUCK","9","Operating Warehouse",null,null,null,List.of(first,second),
|
||||
Map.of(first.groupKey(),"CONF-A",second.groupKey(),"CONF-B"));
|
||||
/** 同仓同号合并客户,同时保留各自入仓码、托盘与箱数。 */
|
||||
void mergesCustomersWithTheSameDestinationAndAppointmentWithoutLosingTheirCargo() {
|
||||
var request=render(List.of(cargo(11,"101","ABE2","IN-A",3,"CONF-SHARED"),cargo(12,"102","ABE2","IN-B",5,"CONF-SHARED")),1);
|
||||
assertThat(request.documents()).singleElement().satisfies(document->{
|
||||
assertThat(document.isaNumber()).isEqualTo("CONF-SHARED");assertThat(document.totalPackages()).isEqualTo(8);
|
||||
assertThat(document.pageNumber()).isEqualTo(1);assertThat(document.pageCount()).isEqualTo(1);
|
||||
assertThat(document.cargoRows()).extracting(PrintPdfClient.CargoRow::warehouseNo).containsExactly("IN-A","IN-B");
|
||||
assertThat(document.cargoRows()).extracting(PrintPdfClient.CargoRow::packageCount).containsExactly(3,5);
|
||||
assertThat(document.cargoRows()).extracting(PrintPdfClient.CargoRow::palletNo).containsExactly("PALLET-11","PALLET-12");
|
||||
});
|
||||
}
|
||||
@Test
|
||||
/** 同目的仓的不同预约号不得合成同一份BOL。 */
|
||||
void keepsDifferentAppointmentsAtTheSameDestinationInSeparateDocuments() {
|
||||
var request=render(List.of(cargo(11,"101","ABE2","IN-A",3,"CONF-A"),cargo(12,"102","ABE2","IN-B",5,"CONF-B")),2);
|
||||
assertThat(request.documents()).extracting(PrintPdfClient.Document::isaNumber).containsExactly("CONF-A","CONF-B");
|
||||
assertThat(request.documents()).extracting(PrintPdfClient.Document::totalPackages).containsExactly(3,5);
|
||||
assertThat(request.documents().get(0).cargoRows()).extracting(PrintPdfClient.CargoRow::warehouseNo).containsExactly("IN-A");
|
||||
assertThat(request.documents().get(1).cargoRows()).extracting(PrintPdfClient.CargoRow::warehouseNo).containsExactly("IN-B");
|
||||
}
|
||||
@Test
|
||||
/** 相同预约号不能将不同目的仓的货物混入同一份BOL。 */
|
||||
void keepsTheSameAppointmentAtDifferentDestinationsInSeparateDocuments() {
|
||||
var request=render(List.of(cargo(11,"101","ABE2","IN-A",3,"CONF-SHARED"),cargo(12,"102","ONT8","IN-B",5,"CONF-SHARED")),2);
|
||||
assertThat(request.documents()).extracting(PrintPdfClient.Document::isaNumber).containsExactly("CONF-SHARED","CONF-SHARED");
|
||||
assertThat(request.documents()).extracting(PrintPdfClient.Document::fbaWarehouseCode).containsExactly("ABE2","ONT8");
|
||||
assertThat(request.documents()).extracting(PrintPdfClient.Document::totalPackages).containsExactly(3,5);
|
||||
}
|
||||
@Test
|
||||
/** 无号及空白号合并同仓货物,并把ISA作为空串传递。 */
|
||||
void mergesMissingAppointmentsAtTheSameDestinationAndLeavesIsaEmpty() {
|
||||
var request=render(List.of(cargo(11,"101","ABE2","IN-A",3,null),cargo(12,"102","ABE2","IN-B",5," ")),1);
|
||||
assertThat(request.documents()).singleElement().satisfies(document->{
|
||||
assertThat(document.isaNumber()).isEmpty();assertThat(document.totalPackages()).isEqualTo(8);
|
||||
assertThat(document.cargoRows()).extracting(PrintPdfClient.CargoRow::warehouseNo).containsExactly("IN-A","IN-B");
|
||||
});
|
||||
}
|
||||
@Test
|
||||
/** 两仓各两个预约在一次PDF请求中保留四份独立BOL。 */
|
||||
void keepsTwoAppointmentsForEachOfTwoWarehousesInOnePdfRequest() {
|
||||
var request=render(List.of(cargo(11,"101","ABE2","IN-A1",2,"CONF-A1"),cargo(12,"102","ABE2","IN-A2",3,"CONF-A2"),
|
||||
cargo(13,"101","ONT8","IN-B1",4,"CONF-B1"),cargo(14,"102","ONT8","IN-B2",5,"CONF-B2")),4);
|
||||
assertThat(request.documents()).extracting(PrintPdfClient.Document::isaNumber).containsExactly("CONF-A1","CONF-A2","CONF-B1","CONF-B2");
|
||||
assertThat(request.documents()).extracting(PrintPdfClient.Document::fbaWarehouseCode).containsExactly("ABE2","ABE2","ONT8","ONT8");
|
||||
assertThat(request.documents()).extracting(PrintPdfClient.Document::totalPackages).containsExactly(2,3,4,5);
|
||||
assertThat(request.downloadFileName()).isEqualTo("DS-ONE-TRUCK-BOL.pdf");
|
||||
}
|
||||
@Test
|
||||
/** 每份BOL重置页码,跨页全部原始货物仅累计一次。 */
|
||||
void restartsPaginationPerBolAndCountsEveryCargoRowOnlyOnce() {
|
||||
var lines=new ArrayList<DispatchBolData.Line>();
|
||||
for(int i=0;i<45;i++)lines.add(cargo(100+i,i%2==0?"101":"102","ABE2","IN-A-"+i,i%3+1,"CONF-A"));
|
||||
for(int i=0;i<19;i++)lines.add(cargo(200+i,"103","ABE2","IN-B-"+i,2,"CONF-B"));
|
||||
var documents=render(lines,5).documents();
|
||||
assertThat(documents).extracting(PrintPdfClient.Document::pageNumber).containsExactly(1,2,3,1,2);
|
||||
assertThat(documents).extracting(PrintPdfClient.Document::pageCount).containsExactly(3,3,3,2,2);
|
||||
assertThat(documents.stream().map(d->d.cargoRows().size()).toList()).containsExactly(18,26,1,18,1);
|
||||
assertThat(documents).extracting(PrintPdfClient.Document::totalPackages).containsExactly(90,90,90,38,38);
|
||||
assertThat(documents).extracting(PrintPdfClient.Document::isaNumber).containsExactly("CONF-A","CONF-A","CONF-A","CONF-B","CONF-B");
|
||||
var rows=documents.stream().flatMap(d->d.cargoRows().stream()).toList();assertThat(rows).hasSize(64);
|
||||
assertThat(rows.stream().mapToInt(PrintPdfClient.CargoRow::packageCount).sum()).isEqualTo(128);
|
||||
assertThat(rows).extracting(PrintPdfClient.CargoRow::warehouseNo).containsExactlyElementsOf(lines.stream().map(DispatchBolData.Line::warehouseNo).toList());
|
||||
}
|
||||
/** 所有分组仍通过一次远程调用生成同一个PDF,并保留原文件名。 */
|
||||
private PrintPdfClient.Request render(List<DispatchBolData.Line> lines,int pages) {
|
||||
var detail=new DispatchBolData("DS-ONE-TRUCK","9","Operating Warehouse",null,null,null,lines);
|
||||
when(dispatches.read(detail.dispatchNo())).thenReturn(detail);
|
||||
var warehouse=new DomesticWarehouseDirectoryClient.Warehouse(9L,0L,"WH9","Operating Warehouse","OVERSEAS","US","ACTIVE",null,null);
|
||||
when(warehouses.activeWarehouse(9L,"internal-test-token")).thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(warehouse,List.of())));
|
||||
when(print.generateDispatchBol(eq("internal-test-token"),eq("two-customers"),any())).thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).body("%PDF-".getBytes(StandardCharsets.US_ASCII)));
|
||||
service.generate(detail.dispatchNo(),"two-customers");
|
||||
when(print.generateDispatchBol(eq("internal-test-token"),eq("grouped-bol"),any())).thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF)
|
||||
.header("X-Print-Page-Count",Integer.toString(pages)).body("%PDF-".getBytes(StandardCharsets.US_ASCII)));
|
||||
var generated=service.generate(detail.dispatchNo(),"grouped-bol");
|
||||
assertThat(generated.fileName()).isEqualTo("DS-ONE-TRUCK-BOL.pdf");assertThat(generated.pageCount()).isEqualTo(Integer.toString(pages));
|
||||
assertThat(generated.content()).isEqualTo("%PDF-".getBytes(StandardCharsets.US_ASCII));
|
||||
var request=ArgumentCaptor.forClass(PrintPdfClient.Request.class);
|
||||
verify(print).generateDispatchBol(eq("internal-test-token"),eq("two-customers"),request.capture());
|
||||
assertThat(request.getValue().documents()).hasSize(2);
|
||||
assertThat(request.getValue().documents().stream().map(PrintPdfClient.Document::isaNumber).toList()).containsExactly("CONF-A","CONF-B");
|
||||
verify(print).generateDispatchBol(eq("internal-test-token"),eq("grouped-bol"),request.capture());
|
||||
return request.getValue();
|
||||
}
|
||||
/** 新快照携带真实明细及原订单身份,不恢复旧预约号映射。 */
|
||||
private static DispatchBolData.Line cargo(long item,String customer,String code,String incoming,int packages,String appointment) {
|
||||
return new DispatchBolData.Line(item,item+1000,customer,"FBA",null,null,"TSTU3539519","PALLET-"+item,code,code+" Warehouse",incoming,
|
||||
"SHIP-"+item,"REF-"+item,packages,appointment);
|
||||
}
|
||||
/** 为既有仓地址与目的地测试构造没有预约号的真实身份快照。 */
|
||||
private static DispatchBolData.Line line(String customer,String code,String name,int packages) {
|
||||
return new DispatchBolData.Line(customer,"FBA",null,null,"TSTU3539519","DP-001",code,name,"NYEXX2609110214",null,null,packages);
|
||||
return new DispatchBolData.Line(Long.valueOf(customer),Long.parseLong(customer)+1000,customer,"FBA",null,null,"TSTU3539519","DP-001",code,name,"NYEXX2609110214",null,null,packages,null);
|
||||
}
|
||||
}
|
||||
|
||||
+187
-14
@@ -1,41 +1,214 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchBolMapper;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
|
||||
import com.baiyue.lastleg.dispatch.model.DispatchBolData;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchBolRepository;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import java.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 真实H2/MyBatis读取来源与预约分配;不通过mock返回新模型自证数量守恒。 */
|
||||
class DispatchBolReadPersistenceTest {
|
||||
/** 同仓同目的地无号货物跨客户合并文档,逐行客户和来源仍独立。 */
|
||||
@Test void wholeTruckBolReadsBothTrueOwnersAndPreservesTheirOriginalLineComposition() throws Exception {
|
||||
var f=new Fixture();f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(1,10),(2,20)");f.db.update("INSERT INTO ll_inbound_plan VALUES(10,'101','CN-A'),(20,'102','CN-B')");
|
||||
f.db.update("INSERT INTO ll_inbound_plan_line VALUES(100,'101',1000,'ONT8','ONT8','IN-A'),(200,'102',2000,'ONT8','ONT8','IN-B')");f.db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(1,100,0,3),(2,200,0,5)");
|
||||
f.db.update("INSERT INTO ll_order VALUES(1000,'101','CN-A'),(2000,'102','CN-B')");f.db.update("INSERT INTO ll_order_detail VALUES(1000,'101','SHIP-A','REF-A'),(2000,'102','SHIP-B','REF-B')");
|
||||
f.item(11,"101",1,null,"FBA",null,3);f.item(12,"102",2,null,"FBA",null,5);
|
||||
var result=f.service.read("DP-ONE");assertThat(result.bolLines()).extracting(l->l.customerId()).containsExactly("101","102");assertThat(result.bolLines()).extracting(l->l.packageCount()).containsExactly(3,5);assertThat(result.bolLines()).extracting(l->l.fbaShipmentId()).containsExactly("SHIP-A","SHIP-B");
|
||||
assertThat(result.bolLines().get(0).groupKey()).isNotEqualTo(result.bolLines().get(1).groupKey());
|
||||
var f=new Fixture();f.source(11,"101",1,1000,3,"ONT8","RECEIVED",0);f.source(12,"102",2,2000,5,"ONT8","RECEIVED",0);
|
||||
f.db.update("INSERT INTO ll_order_detail VALUES(1000,'101','SHIP-A','REF-A'),(2000,'102','SHIP-B','REF-B')");
|
||||
var result=f.read();assertThat(result.bolLines()).extracting(DispatchBolData.Line::customerId).containsExactly("101","102");
|
||||
assertThat(result.bolLines()).extracting(DispatchBolData.Line::packageCount).containsExactly(3,5);
|
||||
assertThat(result.bolLines()).extracting(DispatchBolData.Line::fbaShipmentId).containsExactly("SHIP-A","SHIP-B");
|
||||
assertThat(result.bolLines().get(0).groupKey()).isEqualTo(result.bolLines().get(1).groupKey());
|
||||
assertThat(result.bolLines()).allSatisfy(line->assertThat(line.appointmentNo()).isNull());
|
||||
}
|
||||
|
||||
/** 部分退回仅打印所选箱数,不借原计划已清点/已拣总数放大。 */
|
||||
@Test void partialReturnReceiptPrintsOnlySelectedQuantityAndPrivateActualAddress() throws Exception {
|
||||
var f=new Fixture();f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(1,10)");f.db.update("INSERT INTO ll_inbound_plan VALUES(10,'101','CN-A')");f.db.update("INSERT INTO ll_inbound_plan_line VALUES(100,'101',1000,'ONT8','ONT8','ORIGINAL-IN')");f.db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(1,100,8,8)");
|
||||
f.item(11,"101",1,99L,"PRIVATE","Actual receiving street",2);
|
||||
var result=f.service.read("DP-ONE");assertThat(result.bolLines()).singleElement().satisfies(l->{assertThat(l.packageCount()).isEqualTo(2);assertThat(l.destinationLabel()).isEqualTo("Actual receiving street");assertThat(l.warehouseNo()).isEqualTo("RETURN-IN");});
|
||||
var f=new Fixture();f.source(11,"101",1,1000,8,"ONT8","RECEIVED",8);
|
||||
f.db.update("UPDATE ll_dispatch_item SET return_receipt_id=99,destination_type='PRIVATE',delivery_address='Actual receiving street',inbound_code='RETURN-IN',package_count=2 WHERE id=11");
|
||||
var result=f.read();assertThat(result.bolLines()).singleElement().satisfies(line->{
|
||||
assertThat(line.packageCount()).isEqualTo(2);assertThat(line.destinationLabel()).isEqualTo("Actual receiving street");
|
||||
assertThat(line.warehouseNo()).isEqualTo("RETURN-IN");assertThat(line.appointmentNo()).isNull();
|
||||
});
|
||||
}
|
||||
|
||||
/** 换标履约仅采用该逻辑明细,不能复用原计划所有货物。 */
|
||||
@Test void relabelSourceDoesNotBorrowOriginalPlanQuantity() throws Exception {
|
||||
var f=new Fixture();f.source(11,"101",1,1000,8,"ONT8","RECEIVED",8);
|
||||
f.db.update("UPDATE ll_dispatch_item SET source_business_type='RELABEL_ORDER',source_stage='RELABEL',package_count=2,inbound_code='RELABEL-IN' WHERE id=11");
|
||||
assertThat(f.read().bolLines()).singleElement().satisfies(line->{assertThat(line.packageCount()).isEqualTo(2);assertThat(line.warehouseNo()).isEqualTo("RELABEL-IN");});
|
||||
}
|
||||
|
||||
/** 同实物分成两个预约时只消费各自份额,多条订单商品资料不得重复箱数。 */
|
||||
@Test void twoAppointmentsConsumeOnePhysicalSourceExactlyOncePerAllocation() throws Exception {
|
||||
var f=new Fixture();f.source(11,"101",1,1000,5,"ONT8","RECEIVED",1);
|
||||
f.db.update("INSERT INTO ll_order_detail VALUES(1000,'101','SHIP-A','REF-A'),(1000,'101','SHIP-B','REF-B'),(1000,'101','SHIP-A','REF-A')");
|
||||
f.appointment(1," ISA-A ","101","ORDER:1000",1000,"ONT8");f.appointment(2,"ISA-B","101","ORDER:1000",1000,"ONT8");
|
||||
f.allocate(11,1,1,"101","ORDER:1000","FBA:ONT8",2);f.allocate(11,1,2,"101","ORDER:1000","FBA:ONT8",3);
|
||||
var lines=f.read().bolLines();assertThat(lines).hasSize(2);
|
||||
assertThat(lines).extracting(DispatchBolData.Line::appointmentNo).containsExactly("ISA-A","ISA-B");
|
||||
assertThat(lines).extracting(DispatchBolData.Line::packageCount).containsExactly(2,3);
|
||||
assertThat(lines).allSatisfy(line->{assertThat(line.dispatchItemId()).isEqualTo(11L);assertThat(line.orderId()).isEqualTo(1000L);assertThat(line.fbaShipmentId()).isEqualTo("SHIP-A, SHIP-B");});
|
||||
assertThat(lines.stream().mapToInt(DispatchBolData.Line::packageCount).sum()).isEqualTo(5);
|
||||
}
|
||||
|
||||
/** 同一明细同计划实托的两个订单各取自己的预约份额,商品JOIN不能将另一订单或整托复制过去。 */
|
||||
@Test void oneDispatchItemWithTwoOrdersAllocatesEachOriginalSourceToItsOwnAppointment() throws Exception {
|
||||
var f=new Fixture();f.source(11,"101",1,1000,3,"ONT8","RECEIVED",1);
|
||||
f.db.update("UPDATE ll_dispatch_item SET package_count=5 WHERE id=11");
|
||||
f.db.update("INSERT INTO ll_inbound_plan_line VALUES(120,'101',10,2000,'ONT8','ONT8','ONT8','IN-2000')");
|
||||
f.db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(121,'101',10,1,120,0,2,2)");
|
||||
f.db.update("INSERT INTO ll_order VALUES(2000,'101','CN-2000')");
|
||||
f.db.update("INSERT INTO ll_order_detail VALUES(1000,'101','SHIP-A1','REF-A1'),(1000,'101','SHIP-A2','REF-A2'),(1000,'101','SHIP-A1','REF-A1'),(2000,'101','SHIP-B1','REF-B1'),(2000,'101','SHIP-B2','REF-B2')");
|
||||
f.appointment(1,"ISA-A","101","ORDER:1000",1000,"ONT8");f.appointment(2,"ISA-B","101","ORDER:2000",2000,"ONT8");
|
||||
f.allocate(11,1,1,"101","ORDER:1000","FBA:ONT8",3);f.allocate(11,1,2,"101","ORDER:2000","FBA:ONT8",2);
|
||||
var lines=f.read().bolLines();assertThat(lines).hasSize(2);
|
||||
assertThat(lines).extracting(DispatchBolData.Line::dispatchItemId).containsExactly(11L,11L);
|
||||
assertThat(lines).extracting(DispatchBolData.Line::orderId).containsExactly(1000L,2000L);
|
||||
assertThat(lines).extracting(DispatchBolData.Line::appointmentNo).containsExactly("ISA-A","ISA-B");
|
||||
assertThat(lines).extracting(DispatchBolData.Line::packageCount).containsExactly(3,2);
|
||||
assertThat(lines).extracting(DispatchBolData.Line::warehouseNo).containsExactly("IN-1000","IN-2000");
|
||||
assertThat(lines).extracting(DispatchBolData.Line::fbaShipmentId).containsExactly("SHIP-A1, SHIP-A2","SHIP-B1, SHIP-B2");
|
||||
assertThat(lines).extracting(DispatchBolData.Line::poReferenceNo).containsExactly("REF-A1, REF-A2","REF-B1, REF-B2");
|
||||
assertThat(lines.stream().mapToInt(DispatchBolData.Line::packageCount).sum()).isEqualTo(5);
|
||||
}
|
||||
|
||||
/** BOOKING来源通过原预约item的持久order_id关联,不把真实稳定键改拼成ORDER。 */
|
||||
@Test void bookingSourceUsesPersistedAppointmentOrderMapping() throws Exception {
|
||||
var f=new Fixture();f.source(11,"101",1,1000,5,"ONT8","RECEIVED",0);
|
||||
f.appointment(1,"BOOKING-ISA","101","BOOKING:EXT-REAL",1000,"ONT8");
|
||||
f.allocate(11,1,1,"101","BOOKING:EXT-REAL","FBA:ONT8",5);
|
||||
assertThat(f.read().bolLines()).singleElement().satisfies(line->{assertThat(line.orderId()).isEqualTo(1000L);assertThat(line.appointmentNo()).isEqualTo("BOOKING-ISA");assertThat(line.packageCount()).isEqualTo(5);});
|
||||
}
|
||||
|
||||
/** 同外部约号、同目的地的不同客户保留各自份额,但共用文档键。 */
|
||||
@Test void sameAppointmentAcrossCustomersSharesDocumentWithoutLosingOwnership() throws Exception {
|
||||
var f=new Fixture();f.source(11,"101",1,1000,3,"ONT8","RECEIVED",0);f.source(12,"102",2,2000,5,"ONT8","RECEIVED",0);
|
||||
f.appointment(1,"SHARED-ISA","101","ORDER:1000",1000,"ONT8");f.appointment(2," SHARED-ISA ","102","ORDER:2000",2000,"ONT8");
|
||||
f.allocate(11,1,1,"101","ORDER:1000","FBA:ONT8",3);f.allocate(12,2,2,"102","ORDER:2000","FBA:ONT8",5);
|
||||
var lines=f.read().bolLines();assertThat(lines).extracting(DispatchBolData.Line::customerId).containsExactly("101","102");
|
||||
assertThat(lines).extracting(DispatchBolData.Line::packageCount).containsExactly(3,5);assertThat(lines.stream().map(DispatchBolData.Line::groupKey).distinct().count()).isOne();
|
||||
}
|
||||
|
||||
/** 同约号不同目的地仍分文档;缺约号不以系统预约单号补充。 */
|
||||
@Test void destinationSeparatesSharedNumbersAndBlankConfirmationRemainsPrintable() throws Exception {
|
||||
var f=new Fixture();f.source(11,"101",1,1000,3,"ONT8","RECEIVED",0);f.source(12,"102",2,2000,5,"LAX9","RECEIVED",0);
|
||||
f.appointment(1,"SAME","101","ORDER:1000",1000,"ONT8");f.appointment(2,"SAME","102","ORDER:2000",2000,"LAX9");
|
||||
f.allocate(11,1,1,"101","ORDER:1000","FBA:ONT8",3);f.allocate(12,2,2,"102","ORDER:2000","FBA:LAX9",5);
|
||||
assertThat(f.read().bolLines().stream().map(DispatchBolData.Line::groupKey).distinct().count()).isEqualTo(2);
|
||||
f.db.update("UPDATE ll_inbound_fba_appointment SET confirmation_no=' '");
|
||||
assertThat(f.read().bolLines()).allSatisfy(line->{assertThat(line.appointmentNo()).isNull();assertThat(line.packageCount()).isPositive();});
|
||||
}
|
||||
|
||||
/** 未约余量有真实来源数量依据,空ISA保留;不能把全托复制到已有预约。 */
|
||||
@Test void unassignedRemainderKeepsItsOwnQuantityAndBlankAppointment() throws Exception {
|
||||
var f=new Fixture();f.source(11,"101",1,1000,5,"ONT8","RECEIVED",1);
|
||||
f.appointment(1,"ISA-A","101","ORDER:1000",1000,"ONT8");f.allocate(11,1,1,"101","ORDER:1000","FBA:ONT8",2);
|
||||
var lines=f.read().bolLines();assertThat(lines).extracting(DispatchBolData.Line::packageCount).containsExactly(2,3);
|
||||
assertThat(lines.get(0).appointmentNo()).isEqualTo("ISA-A");assertThat(lines.get(1).appointmentNo()).isNull();
|
||||
}
|
||||
|
||||
/** PLAN用计划箱数,RECEIVED用清点箱数;部分扫码量从不替代原调度份额。 */
|
||||
@Test void plannedAndReceivedCountsMatchTheirAuthoritativeSourceStage() throws Exception {
|
||||
for(var stage:List.of("PLAN","RECEIVED")) {
|
||||
var f=new Fixture();f.source(11,"101",1,1000,5,"ONT8",stage,1);
|
||||
if(stage.equals("PLAN"))f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=2");
|
||||
else f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET planned_package_count=9");
|
||||
assertThat(f.read().bolLines()).singleElement().satisfies(line->assertThat(line.packageCount()).isEqualTo(5));
|
||||
}
|
||||
}
|
||||
|
||||
/** 来源合计与本车选择量不一致或数量未知时失败,不以整托/扫码常数补齐。 */
|
||||
@Test void missingCountsAndChangedSelectedTotalsFailClosed() throws Exception {
|
||||
for(var change:List.of("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=NULL",
|
||||
"UPDATE ll_dispatch_item SET package_count=4")) {
|
||||
var f=new Fixture();f.source(11,"101",1,1000,5,"ONT8","RECEIVED",1);f.db.update(change);
|
||||
assertThatThrownBy(f::read).isInstanceOf(BizException.class).hasMessageContaining("尚未核实");
|
||||
}
|
||||
}
|
||||
|
||||
/** 明细、客户、来源、目的地、原计划与预约item任一不匹配均不能取得打印份额。 */
|
||||
@Test void foreignOrIncompleteAllocationCannotBorrowAnotherSource() throws Exception {
|
||||
for(var fault:List.of("customer","item","plan","source","destination","appointment-customer","order","missing-order","missing-batch")) {
|
||||
var f=new Fixture();f.source(11,"101",1,1000,5,"ONT8","RECEIVED",0);
|
||||
f.appointment(1,"ISA","101","BOOKING:REAL",1000,"ONT8");f.allocate(11,1,1,"101","BOOKING:REAL","FBA:ONT8",5);
|
||||
f.db.update(switch(fault) {
|
||||
case "customer" -> "UPDATE ll_dispatch_appointment_allocation SET customer_id='FOREIGN'";
|
||||
case "item" -> "UPDATE ll_dispatch_appointment_allocation SET dispatch_item_id=99";
|
||||
case "plan" -> "UPDATE ll_dispatch_appointment_allocation SET plan_pallet_id=99";
|
||||
case "source" -> "UPDATE ll_dispatch_appointment_allocation SET source_key='ORDER:1000'";
|
||||
case "destination" -> "UPDATE ll_dispatch_appointment_allocation SET destination_key='FBA:LAX9'";
|
||||
case "appointment-customer" -> "UPDATE ll_inbound_fba_appointment_item SET customer_id='FOREIGN'";
|
||||
case "order" -> "UPDATE ll_inbound_fba_appointment_item SET order_id=9999";
|
||||
case "missing-order" -> "UPDATE ll_inbound_fba_appointment_item SET order_id=NULL";
|
||||
default -> "DELETE FROM ll_inbound_fba_appointment";
|
||||
});
|
||||
assertThatThrownBy(f::read).as(fault).isInstanceOf(BizException.class).hasMessageContaining("尚未核实");
|
||||
}
|
||||
}
|
||||
|
||||
/** 单份超量和多份累计超量均失败,原货物和分配记录不被读取操作修复。 */
|
||||
@Test void individualAndCumulativeOverAllocationFailWithoutChangingFacts() throws Exception {
|
||||
for(boolean cumulative:List.of(false,true)) {
|
||||
var f=new Fixture();f.source(11,"101",1,1000,5,"ONT8","RECEIVED",0);
|
||||
f.appointment(1,"ISA-A","101","ORDER:1000",1000,"ONT8");f.allocate(11,1,1,"101","ORDER:1000","FBA:ONT8",cumulative?3:6);
|
||||
if(cumulative){f.appointment(2,"ISA-B","101","ORDER:1000",1000,"ONT8");f.allocate(11,1,2,"101","ORDER:1000","FBA:ONT8",3);}
|
||||
var before=f.db.queryForList("SELECT * FROM ll_dispatch_appointment_allocation ORDER BY batch_key");
|
||||
assertThatThrownBy(f::read).isInstanceOf(BizException.class).hasMessageContaining("尚未核实");
|
||||
assertThat(f.db.queryForList("SELECT * FROM ll_dispatch_appointment_allocation ORDER BY batch_key")).isEqualTo(before);
|
||||
}
|
||||
}
|
||||
|
||||
/** 测试表仅取完整模块DDL中实际引用列,保留来源/分配唯一键,事务覆盖两个真实Mapper查询。 */
|
||||
private static final class Fixture {
|
||||
final JdbcTemplate db;final DispatchBolReadService service;
|
||||
final JdbcTemplate db;final DispatchBolReadService service;final TransactionTemplate tx;
|
||||
/** 根主单只提供身份,全部货物和预约事实均经实际Mapper读取。 */
|
||||
Fixture() throws Exception {
|
||||
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");db=new JdbcTemplate(ds);
|
||||
db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,dispatch_order_id BIGINT,customer_id VARCHAR(64),plan_pallet_id BIGINT,return_receipt_id BIGINT,source_business_type VARCHAR(32),destination_type VARCHAR(32),delivery_address VARCHAR(128),postal_code VARCHAR(32),pallet_no VARCHAR(64),fba_warehouse_code VARCHAR(64),destination_warehouse_code VARCHAR(64),destination_warehouse_name VARCHAR(64),inbound_code VARCHAR(64),package_count INT)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT)");db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR(64),container_no VARCHAR(64))");db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT,picked_package_count INT,planned_package_count INT)");db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR(64),order_id BIGINT,destination_warehouse_code VARCHAR(64),destination_warehouse_name VARCHAR(64),inbound_code VARCHAR(64))");db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(64),container_no VARCHAR(64))");db.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR(64),shipment_no VARCHAR(64),fba_tracking_no VARCHAR(64))");
|
||||
db.execute("CREATE TABLE ll_dispatch_item(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT,customer_id VARCHAR(128),plan_pallet_id BIGINT,return_receipt_id BIGINT,source_business_type VARCHAR(32),source_stage VARCHAR(32),order_id BIGINT,destination_type VARCHAR(32),delivery_address VARCHAR(1000),postal_code VARCHAR(32),pallet_no VARCHAR(64),fba_warehouse_code VARCHAR(128),destination_warehouse_code VARCHAR(128),destination_warehouse_name VARCHAR(255),inbound_code VARCHAR(128),package_count INT)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT PRIMARY KEY,plan_id BIGINT)");db.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),container_no VARCHAR(64))");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),plan_id BIGINT,pallet_id BIGINT,line_id BIGINT,picked_package_count INT,planned_package_count INT,counted_package_count INT,UNIQUE(line_id,pallet_id))");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),plan_id BIGINT,order_id BIGINT,fba_warehouse_code VARCHAR(128),destination_warehouse_code VARCHAR(128),destination_warehouse_name VARCHAR(255),inbound_code VARCHAR(128),UNIQUE(plan_id,order_id))");
|
||||
db.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),container_no VARCHAR(64))");db.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR(128),shipment_no VARCHAR(255),fba_tracking_no VARCHAR(255))");
|
||||
db.execute("CREATE TABLE ll_dispatch_appointment_allocation(dispatch_id BIGINT NOT NULL,dispatch_item_id BIGINT NOT NULL,plan_pallet_id BIGINT NOT NULL,batch_key VARCHAR(96) NOT NULL,customer_id VARCHAR(64) NOT NULL,source_key VARCHAR(160) NOT NULL,destination_key VARCHAR(160) NOT NULL,package_count INT NOT NULL,PRIMARY KEY(dispatch_item_id,batch_key,source_key,destination_key))");
|
||||
db.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT PRIMARY KEY,appointment_no VARCHAR(64),confirmation_no VARCHAR(128))");
|
||||
db.execute("CREATE TABLE ll_inbound_fba_appointment_item(id BIGINT PRIMARY KEY,appointment_id BIGINT NOT NULL,source_key VARCHAR(160) NOT NULL,customer_id VARCHAR(128) NOT NULL,order_id BIGINT,fba_warehouse_code VARCHAR(128) NOT NULL,UNIQUE(appointment_id,source_key))");
|
||||
var config=new MybatisConfiguration();config.addMapper(DispatchBolMapper.class);var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);factory.afterPropertiesSet();var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DispatchBolMapper.class);
|
||||
var roots=mock(DispatchAggregateRepository.class);var row=new DispatchAggregate();row.setId(1L);row.setDispatchNo("DP-ONE");row.setOverseasWarehouseId("9");row.setResourcesJson(new ObjectMapper().writeValueAsString(new DispatchContracts.Resources(null,null)));when(roots.find("DP-ONE")).thenReturn(row);when(roots.batches(1)).thenReturn(List.of());service=new DispatchBolReadService(roots,mapper,new ObjectMapper());
|
||||
var roots=mock(DispatchAggregateRepository.class);var row=new DispatchAggregate();row.setId(1L);row.setDispatchNo("DP-ONE");row.setOverseasWarehouseId("9");row.setResourcesJson(new ObjectMapper().writeValueAsString(new DispatchContracts.Resources(null,null)));when(roots.find("DP-ONE")).thenReturn(row);
|
||||
service=new DispatchBolReadService(roots,new DispatchBolRepository(mapper),new ObjectMapper());
|
||||
tx=new TransactionTemplate(new DataSourceTransactionManager(ds));tx.setReadOnly(true);tx.setIsolationLevel(TransactionDefinition.ISOLATION_REPEATABLE_READ);
|
||||
}
|
||||
/** 本地一致事务读取,不接触任何环境或远程文件。 */
|
||||
DispatchBolData read(){return tx.execute(status->service.read("DP-ONE"));}
|
||||
/** 创建一票真实计划来源;picked与原调度/清点数量有意独立。 */
|
||||
void source(long item,String customer,long pallet,long order,int packages,String code,String stage,int picked) {
|
||||
long plan=pallet*10,line=item*10;
|
||||
db.update("INSERT INTO ll_inbound_plan_pallet VALUES(?,?)",pallet,plan);db.update("INSERT INTO ll_inbound_plan VALUES(?,?,?)",plan,customer,"CN-"+order);
|
||||
db.update("INSERT INTO ll_inbound_plan_line VALUES(?,?,?,?,?,?,?,?)",line,customer,plan,order,code,code,code,"IN-"+order);
|
||||
db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,?,?,?,?,?,?,?)",line+1,customer,plan,pallet,line,picked,packages,packages);
|
||||
db.update("INSERT INTO ll_order VALUES(?,?,?)",order,customer,"CN-"+order);
|
||||
db.update("INSERT INTO ll_dispatch_item VALUES(?,1,?,?,NULL,NULL,?,?, 'FBA',NULL,NULL,?,?,?,?,?,?)",item,customer,pallet,stage,order,"P-"+pallet,code,code,code,"IN-"+order,packages);
|
||||
}
|
||||
/** 系统预约业务号与对外确认号分开,稳定来源键原样保存。 */
|
||||
void appointment(long id,String confirmation,String customer,String source,long order,String code) {
|
||||
db.update("INSERT INTO ll_inbound_fba_appointment VALUES(?,?,?)",id,"SYSTEM-APPOINTMENT-"+id,confirmation);
|
||||
db.update("INSERT INTO ll_inbound_fba_appointment_item VALUES(?,?,?,?,?,?)",id,id,source,customer,order,code);
|
||||
}
|
||||
/** 按模块实际主键建立本车明细的真实逐票分配,不以客户目的地总量替代。 */
|
||||
void allocate(long item,long pallet,long appointment,String customer,String source,String destination,int packages) {
|
||||
db.update("INSERT INTO ll_dispatch_appointment_allocation VALUES(1,?,?,?,?,?,?,?)",item,pallet,"FBA_APPOINTMENT:"+appointment,customer,source,destination,packages);
|
||||
}
|
||||
void item(long id,String customer,long plan,Long returned,String type,String address,int packages){db.update("INSERT INTO ll_dispatch_item VALUES(?,1,?,?,?,NULL,?,?,NULL,?,'ONT8','ONT8','ONT8',?,?)",id,customer,plan,returned,type,address,"P-"+id,returned==null?"IN-"+customer:"RETURN-IN",packages);}
|
||||
}
|
||||
}
|
||||
|
||||
+30
-1
@@ -18,15 +18,44 @@ class DispatchServiceTest {
|
||||
final DispatchDraftMapper drafts=mock(DispatchDraftMapper.class);
|
||||
final RelabelApplicationMapper relabels=mock(RelabelApplicationMapper.class);
|
||||
final RelabelPalletMeasurementService measurements=mock(RelabelPalletMeasurementService.class);
|
||||
final com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort facts=mock(com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.class);
|
||||
final com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort palletSerial=mock(com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort.class);
|
||||
@BeforeEach void setup(){
|
||||
ReflectionTestUtils.setField(service,"wholeSources",sources);ReflectionTestUtils.setField(service,"wholeHashes",new DispatchCommandHasher(new com.fasterxml.jackson.databind.ObjectMapper()));
|
||||
ReflectionTestUtils.setField(service,"candidates",candidates);ReflectionTestUtils.setField(service,"drafts",drafts);
|
||||
ReflectionTestUtils.setField(service,"relabelApplications",relabels);ReflectionTestUtils.setField(service,"relabelMeasurements",measurements);
|
||||
ReflectionTestUtils.setField(service,"relabelBindingGuard",mock(RelabelBindingGuard.class));ReflectionTestUtils.setField(service,"returnRelabelStock",mock(ReturnRelabelDispatchStock.class));
|
||||
var facts=mock(com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.class);
|
||||
// 本用例只验证完整选择的早期拒绝;实际贡献/HOLD由持久化主链覆盖。
|
||||
when(facts.snapshot(anyString(),anyList())).thenReturn(new com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.Snapshot(List.of(),List.of(),List.of(),"CONTROLLED",true,List.of()));
|
||||
ReflectionTestUtils.setField(service,"relabelSources",facts);
|
||||
ReflectionTestUtils.setField(service,"palletSerial",palletSerial);
|
||||
}
|
||||
/** 空车返回仓范围内的确定空快照,不对不存在的申请、来源或实托取得锁。 */
|
||||
@Test void emptyExecutionSourcesHaveWarehouseScopedSnapshotWithoutAnySourceLocks(){
|
||||
var empty=service.lockExecutionSources("9",List.of());
|
||||
assertThat(empty.complete()).isTrue();assertThat(empty.applications()).isEmpty();assertThat(empty.sources()).isEmpty();
|
||||
assertThat(empty.positions()).isEmpty();assertThat(empty.blockReasons()).isEmpty();
|
||||
assertThat(empty.compositionVersion()).matches("[0-9a-f]{64}");
|
||||
assertThat(service.lockExecutionSources("9",List.of()).compositionVersion()).isEqualTo(empty.compositionVersion());
|
||||
assertThat(service.lockExecutionSources("10",List.of()).compositionVersion()).isNotEqualTo(empty.compositionVersion());
|
||||
verifyNoInteractions(facts,sources,palletSerial,relabels,measurements);
|
||||
}
|
||||
/** 空车也不能缺失起运仓,不能把无范围请求解释成合法空来源。 */
|
||||
@Test void emptyExecutionSourcesStillRequireWarehouse(){
|
||||
for(String warehouse:Arrays.asList(null,""," "))assertThatThrownBy(()->service.lockExecutionSources(warehouse,List.of()))
|
||||
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("起运仓库");
|
||||
verifyNoInteractions(facts,sources,palletSerial);
|
||||
}
|
||||
/** 非空货物缺实托或提供者拒绝时保留原错误,不能借空车分支绕过闭包。 */
|
||||
@Test void nonemptyExecutionSourcesRetainStrictProviderValidation(){
|
||||
var item=new com.baiyue.lastleg.dispatch.entity.DispatchItem();
|
||||
when(facts.snapshot(eq("9"),eq(List.of()))).thenThrow(new IllegalArgumentException("仓库和实托集合不能为空"));
|
||||
assertThatThrownBy(()->service.lockExecutionSources("9",List.of(item))).hasMessageContaining("实托集合不能为空");
|
||||
verify(facts).snapshot("9",List.of());
|
||||
item.setWarehousePalletId(501L);
|
||||
when(facts.snapshot("9",List.of(501L))).thenThrow(new IllegalStateException("完整实托来源已变化"));
|
||||
assertThatThrownBy(()->service.lockExecutionSources("9",List.of(item))).hasMessageContaining("完整实托来源已变化");
|
||||
verify(facts).snapshot("9",List.of(501L));verifyNoInteractions(sources,palletSerial);
|
||||
}
|
||||
@Test void anotherOperatorsDraftIsRejectedBeforeAnyCandidateRead(){
|
||||
assertThatThrownBy(()->service.wholeCandidates("operator",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"PLAN","foreign"))).hasMessageContaining("草稿");verifyNoInteractions(sources);
|
||||
|
||||
+4
-1
@@ -513,7 +513,7 @@ class WarehouseTransferServiceTest {
|
||||
verifyNoInteractions(transfers);
|
||||
}
|
||||
|
||||
/** 先按原订单来源快照拒绝外部换标箱,不进入后续选箱占用。 */
|
||||
/** 来源箱快照先通过占用校验,再单独拒绝草稿中的外部换标箱。 */
|
||||
@Test
|
||||
void submittingOldDraftRechecksRelabelCartonOwnership() {
|
||||
var transfer = transfer(WarehouseTransferService.DRAFT);
|
||||
@@ -521,6 +521,9 @@ 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);
|
||||
|
||||
@@ -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-mixed-palletization', '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-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,7 +61,6 @@ 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']],
|
||||
|
||||
@@ -172,8 +172,6 @@ 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 报告,不自动清理。
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
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,10 +76,14 @@ function model(fault = '', countPatch = {}) {
|
||||
if (path === '/api/merchant/v1/iam/tenant/profile') return response({ id: fault === 'foreign login' ? 'OTHER' : merchant.id })
|
||||
if (path === '/api/merchant/v1/last-leg/warehouse-transfers') {
|
||||
assert.ok(merchant); assert.equal(body.inboundCode, undefined); assert.deepEqual(body.sourceLastlegOrderIds, [])
|
||||
assert.equal(body.needRelabel, false); assert.equal(body.items.length, 1); assert.equal(body.items[0].packageCount, 1)
|
||||
assert.equal(body.needRelabel, false); assert.equal(body.items.length, 1); assert.equal(body.items[0].packageCount, 1); assert.equal(body.items[0].quantity, 1)
|
||||
const transferNo = `IN${transfers.length}`
|
||||
const itemId = `FORECAST-ITEM${transfers.length}`
|
||||
const transfer = { transferNo, inboundCode: transferNo, customerId: merchant.id, operatingWarehouseId: body.operatingWarehouseId,
|
||||
status: 'DRAFT', pallets: [] }
|
||||
status: 'DRAFT', packageCount: 1, pallets: [],
|
||||
items: [{ ...copy(body.items[0]), id: itemId, ...(fault === 'missing declared quantity' ? { quantity: null } : {}) }],
|
||||
cartons: fault === 'missing forecast carton' ? [] : [{ cartonMark: `BX-FORECAST${transfers.length}`, status: 'FORECAST',
|
||||
transferItemId: fault === 'foreign forecast item' ? 'OTHER-ITEM' : itemId }] }
|
||||
transfers.push(transfer); return response(transfer)
|
||||
}
|
||||
if (path.includes('/warehouse-transfers/')) {
|
||||
@@ -88,10 +92,39 @@ function model(fault = '', countPatch = {}) {
|
||||
if (path.startsWith('/api/merchant/')) assert.equal(transfer.customerId, merchant.id)
|
||||
if (path.endsWith('/submit')) transfer.status = 'PENDING_REVIEW'
|
||||
else if (path.endsWith('/review')) { assert.equal(url.searchParams.get('customerId'), transfer.customerId); assert.equal(body.decision, 'APPROVE'); transfer.status = 'WAIT_PDA_RECEIVING' }
|
||||
else if (path.endsWith('/pallets/drafts')) transfer.pallets = [{ id: `DRAFT${n}`, status: 'DRAFT', cartonCount: body.cartonCount }]
|
||||
else if (path.endsWith('/pallets/drafts')) {
|
||||
// The actual PalletDraft DTO accepts identity only; never derive measurement from ignored request fields.
|
||||
assert.deepEqual(Object.keys(body), ['idempotencyKey'])
|
||||
transfer.pallets = [{ id: `DRAFT${n}`, status: 'DRAFT', cartonCount: 0, lengthCm: 0, widthCm: 0, heightCm: 0, volumeCbm: 0, grossWeightKg: 0 }]
|
||||
}
|
||||
else if (path.endsWith('/generate')) Object.assign(transfer.pallets[0], { status: 'GENERATED', warehousePalletId: `PAL${n}`, palletNo: `PALLET${n}` })
|
||||
else if (path.endsWith('/bind')) { assert.equal(body.palletCode, transfer.pallets[0].palletNo); transfer.pallets[0].status = 'BOUND' }
|
||||
else if (path.endsWith('/palletize')) { transfer.status = 'WAIT_CUSTOMER_CONFIRMATION'; transfer.pallets[0].status = 'COMPLETED' }
|
||||
else if (path.endsWith('/cartons/count')) {
|
||||
assert.equal(method, 'POST'); assert.equal(transfer.pallets[0].status, 'BOUND')
|
||||
assert.equal(transfer.pallets[0].cartonCount, 0); assert.equal(body.forecastPackageCount, transfer.packageCount)
|
||||
assert.equal(body.boxes.length, 1)
|
||||
const box = body.boxes[0], item = transfer.items[0], carton = transfer.cartons[0], pallet = transfer.pallets[0]
|
||||
assert.equal(box.cartonMark, carton.cartonMark); assert.equal(box.transferItemId, item.id)
|
||||
assert.equal(box.transferPalletId, pallet.id); assert.notEqual(box.transferPalletId, pallet.warehousePalletId)
|
||||
assert.deepEqual([box.length, box.width, box.height, box.weight], [100, 100, 100, 100])
|
||||
assert.deepEqual(box.items, [{ skuCode: item.skuCode, quantity: item.quantity }])
|
||||
Object.assign(carton, { transferPalletId: pallet.id, status: 'COUNTED', countedAt: '2026-10-20T09:00:00',
|
||||
length: box.length, width: box.width, height: box.height, weight: box.weight, quantity: 1,
|
||||
items: [{ transferItemId: item.id, skuCode: item.skuCode, quantity: 1 }] })
|
||||
Object.assign(pallet, { cartonCount: 1, volumeCbm: 1, grossWeightKg: 100 })
|
||||
if (fault === 'uncounted carton') { carton.status = 'FORECAST'; carton.countedAt = null }
|
||||
if (fault === 'wrong counted pallet') carton.transferPalletId = 'OTHER-PALLET'
|
||||
if (fault === 'wrong counted sku') carton.items[0].skuCode = 'OTHER-SKU'
|
||||
if (fault === 'missing carton measurement') carton.weight = null
|
||||
if (fault === 'zero counted pallet') pallet.cartonCount = 0
|
||||
if (fault === 'wrong measured pallet weight') pallet.grossWeightKg = 0
|
||||
}
|
||||
else if (path.endsWith('/palletize')) {
|
||||
assert.equal(transfer.cartons.length, 1); assert.equal(transfer.cartons[0].status, 'COUNTED'); assert.ok(transfer.cartons[0].countedAt)
|
||||
assert.equal(transfer.pallets[0].cartonCount, 1); assert.equal(transfer.pallets[0].grossWeightKg, 100)
|
||||
assert.equal(transfer.pallets[0].volumeCbm, 1); assert.ok(body.photoFileIds.length)
|
||||
transfer.status = 'WAIT_CUSTOMER_CONFIRMATION'; transfer.pallets[0].status = 'COMPLETED'
|
||||
}
|
||||
else if (path.endsWith('/confirm-measurement')) transfer.status = 'READY_FOR_DISPATCH'
|
||||
else assert.equal(method, 'GET')
|
||||
return response(transfer)
|
||||
@@ -182,13 +215,26 @@ test('real helpers build independent current-run customers, receipts and one com
|
||||
assert.equal(proof.status, 'READY'); assert.deepEqual(proof.sessionCleanup.map(row => row.status), ['LOGGED_OUT', 'LOGGED_OUT'])
|
||||
assert.equal(m.trip.dispatches.length, 1); assert.equal(new Set(fixture.members.map(row => row.dispatchNo)).size, 1); assert.deepEqual(m.trip.trip.stops.map(row => row.destinationKey).filter(Boolean), ['FBA:FBA'])
|
||||
assert.equal(m.calls.filter(row => row.method === 'POST' && row.raw === '/api/platform/v1/last-leg/dispatches').length, 1)
|
||||
assert.equal(m.calls.filter(row => row.raw.endsWith('/cartons/count')).length, 2)
|
||||
for (const member of fixture.members) {
|
||||
const prefix = `/api/pda/v1/last-leg/warehouse-transfers/${member.incomingNo}`
|
||||
const draft = m.calls.findIndex(row => row.raw === `${prefix}/pallets/drafts`)
|
||||
const bind = m.calls.findIndex(row => row.raw.startsWith(`${prefix}/pallets/`) && row.raw.endsWith('/bind'))
|
||||
const count = m.calls.findIndex(row => row.raw === `${prefix}/cartons/count`)
|
||||
const photo = m.calls.findIndex(row => row.raw === `${prefix}/measurement-photos/files`)
|
||||
const receipt = m.calls.findIndex(row => row.raw === `${prefix}/palletize`)
|
||||
assert.ok(draft < bind && bind < count && count < photo && photo < receipt)
|
||||
assert.equal(member.measurementReceipt.countedCarton.cartonMark, m.calls[count].body.boxes[0].cartonMark)
|
||||
assert.equal(member.measurementReceipt.syntheticTestMaterials, true)
|
||||
assert.equal(member.measurementReceipt.finalizedPallet.cartonCount, 1)
|
||||
}
|
||||
assert.equal(m.calls.filter(row => row.raw.endsWith('/transfer-delivery')).length, 0)
|
||||
assert.ok(!m.calls.some(row => /\/pick$|\/depart$|\/bookings|\/capacity-plan/.test(row.raw)))
|
||||
const persisted = JSON.stringify(m.context.createdResources)
|
||||
for (const secret of ['caller-pda-secret', 'admin-secret', 'fresh-secret-', 'merchant-100-secret', 'refresh-100-secret']) assert.ok(!persisted.includes(secret))
|
||||
})
|
||||
|
||||
for (const fault of ['disabled receiving', 'reused customer', 'foreign login', 'foreign position', 'truncated ledger', 'wrong raw scope', 'wrong raw ledger position', 'wrong raw ledger balance', 'foreign candidate', 'missing second cargo', 'foreign delivery', 'logout failure']) {
|
||||
for (const fault of ['disabled receiving', 'reused customer', 'foreign login', 'missing forecast carton', 'missing declared quantity', 'foreign forecast item', 'uncounted carton', 'wrong counted pallet', 'wrong counted sku', 'missing carton measurement', 'zero counted pallet', 'wrong measured pallet weight', 'foreign position', 'truncated ledger', 'wrong raw scope', 'wrong raw ledger position', 'wrong raw ledger balance', 'foreign candidate', 'missing second cargo', 'foreign delivery', 'logout failure']) {
|
||||
test(`fail closed, retain partial resources and clean owned sessions: ${fault}`, async () => {
|
||||
const m = model(fault)
|
||||
await assert.rejects(createFleetCrossCustomerFixture(m.context, m.options))
|
||||
@@ -198,6 +244,8 @@ for (const fault of ['disabled receiving', 'reused customer', 'foreign login', '
|
||||
assert.equal(logouts.length, logins.length)
|
||||
assert.ok(!m.calls.some(row => row.method === 'DELETE' || row.raw.includes('/users/')))
|
||||
if (fault === 'disabled receiving') assert.equal(m.calls.filter(row => row.method !== 'GET').length, 0)
|
||||
if (['missing forecast carton', 'missing declared quantity', 'foreign forecast item', 'uncounted carton', 'wrong counted pallet', 'wrong counted sku', 'missing carton measurement', 'zero counted pallet', 'wrong measured pallet weight'].includes(fault))
|
||||
assert.equal(m.calls.some(row => row.raw.endsWith('/palletize') || row.raw === '/api/platform/v1/last-leg/dispatches'), false)
|
||||
const previousCount = m.calls.length
|
||||
await assert.rejects(createFleetCrossCustomerFixture(m.context, m.options), /already started/)
|
||||
assert.equal(m.calls.length, previousCount)
|
||||
|
||||
@@ -151,7 +151,7 @@ export async function createFleetCrossCustomerFixture(context, { execute = false
|
||||
destinationWarehouseCode: destination.warehouse.warehouseCode, destinationWarehouseName: destination.warehouse.warehouseName,
|
||||
deliveryAddress, packageCount: 1, volumeCbm: 1, deliveryMethod: 'CUSTOMER_DELIVERY', estimatedArrivalAt: localTime(1800000),
|
||||
needRelabel: false, remark: member.customerMarker, idempotencyKey: key(`CREATE-${suffix}`),
|
||||
items: [{ skuCode: `CROSS-${key(suffix).slice(0, 16)}`, productNameZh: '受控测试一箱货物', packageCount: 1 }] }
|
||||
items: [{ skuCode: `CROSS-${key(suffix).slice(0, 16)}`, productNameZh: '受控测试一箱货物', packageCount: 1, quantity: 1 }] }
|
||||
let incoming = await request('POST', MERCHANT, createBody, `Create new incoming ${suffix}`, auth.accessToken)
|
||||
member.incomingNo = incoming?.transferNo
|
||||
check('Ordinary transfer creates fresh incoming without borrowed source inventory', member.incomingNo && incoming.inboundCode === member.incomingNo
|
||||
@@ -167,8 +167,14 @@ export async function createFleetCrossCustomerFixture(context, { execute = false
|
||||
check('PDA task is untouched and belongs to the requested receiving warehouse', task?.transferNo === member.incomingNo
|
||||
&& task.inboundCode === member.incomingNo && id(task.operatingWarehouseId) === id(warehouseId)
|
||||
&& task.status === 'WAIT_PDA_RECEIVING' && task.pallets?.length === 0)
|
||||
const measured = await request('POST', `${pdaPath}/pallets/drafts`, { idempotencyKey: key(`MEASURE-${suffix}`),
|
||||
cartonCount: 1, lengthCm: 100, widthCm: 100, heightCm: 100, volumeCbm: 1, grossWeightKg: 100 }, 'Measure one real test pallet', pdaToken)
|
||||
const forecastItem = task.items?.[0], forecastCarton = task.cartons?.[0]
|
||||
check('One original forecast carton and declared SKU belong to this fresh one-box incoming', safeFixtureCount(task.packageCount) === 1
|
||||
&& task.items?.length === 1 && forecastItem?.id && forecastItem.skuCode === createBody.items[0].skuCode
|
||||
&& safeFixtureCount(forecastItem.packageCount) === 1 && safeFixtureCount(forecastItem.quantity) === 1
|
||||
&& task.cartons?.length === 1 && forecastCarton?.cartonMark
|
||||
&& (forecastCarton.transferItemId == null || id(forecastCarton.transferItemId) === id(forecastItem.id)))
|
||||
// PalletDraft creates only an empty identity; measurements belong to the real carton-count command below.
|
||||
const measured = await request('POST', `${pdaPath}/pallets/drafts`, { idempotencyKey: key(`MEASURE-${suffix}`) }, 'Create empty TEST pallet draft', pdaToken)
|
||||
check('Measurement created one pallet draft', measured?.pallets?.length === 1 && measured.pallets[0].id && measured.pallets[0].status === 'DRAFT')
|
||||
const palletPath = `${pdaPath}/pallets/${encode(measured.pallets[0].id)}`
|
||||
const generated = await request('POST', `${palletPath}/generate`, { idempotencyKey: key(`GENERATE-${suffix}`) }, 'Generate authoritative warehouse pallet', pdaToken)
|
||||
@@ -176,8 +182,28 @@ export async function createFleetCrossCustomerFixture(context, { execute = false
|
||||
check('Generated physical pallet has real references', generated?.pallets?.length === 1 && pallet.status === 'GENERATED' && pallet.warehousePalletId && pallet.palletNo)
|
||||
member.palletId = id(pallet.warehousePalletId); member.palletNo = pallet.palletNo
|
||||
const bound = await request('POST', `${palletPath}/bind`, { palletCode: member.palletNo, idempotencyKey: key(`BIND-${suffix}`) }, 'Bind scanned physical pallet', pdaToken)
|
||||
check('Bound pallet is exactly the measured one-box pallet', bound?.pallets?.length === 1 && bound.pallets[0].status === 'BOUND'
|
||||
&& id(bound.pallets[0].warehousePalletId) === member.palletId && safeFixtureCount(bound.pallets[0].cartonCount) === 1)
|
||||
check('Bound pallet is exactly the generated empty physical pallet before carton counting', bound?.pallets?.length === 1 && bound.pallets[0].status === 'BOUND'
|
||||
&& id(bound.pallets[0].id) === id(measured.pallets[0].id)
|
||||
&& id(bound.pallets[0].warehousePalletId) === member.palletId && safeFixtureCount(bound.pallets[0].cartonCount) === 0)
|
||||
const counted = await request('POST', `${pdaPath}/cartons/count`, { idempotencyKey: key(`COUNT-${suffix}`),
|
||||
forecastPackageCount: safeFixtureCount(task.packageCount), boxes: [{ cartonMark: forecastCarton.cartonMark,
|
||||
transferItemId: id(forecastItem.id), transferPalletId: id(measured.pallets[0].id),
|
||||
length: 100, width: 100, height: 100, weight: 100,
|
||||
items: [{ skuCode: forecastItem.skuCode, quantity: safeFixtureCount(forecastItem.quantity) }] }] },
|
||||
'Count original TEST carton, record dimensions/SKU and allocate it to the bound pallet', pdaToken)
|
||||
const countedCarton = counted?.cartons?.[0], countedPallet = counted?.pallets?.[0]
|
||||
check('Actual carton count proves one declared box/SKU on the original bound pallet', counted?.transferNo === member.incomingNo
|
||||
&& counted.cartons?.length === 1 && countedCarton.cartonMark === forecastCarton.cartonMark
|
||||
&& countedCarton.status === 'COUNTED' && countedCarton.countedAt
|
||||
&& id(countedCarton.transferPalletId) === id(measured.pallets[0].id)
|
||||
&& id(countedCarton.transferItemId) === id(forecastItem.id)
|
||||
&& ['length', 'width', 'height', 'weight'].every(field => Number(countedCarton[field]) === 100)
|
||||
&& safeFixtureCount(countedCarton.quantity) === 1 && countedCarton.items?.length === 1
|
||||
&& countedCarton.items[0].skuCode === forecastItem.skuCode
|
||||
&& id(countedCarton.items[0].transferItemId) === id(forecastItem.id) && safeFixtureCount(countedCarton.items[0].quantity) === 1
|
||||
&& counted.pallets?.length === 1 && countedPallet.status === 'BOUND' && id(countedPallet.id) === id(measured.pallets[0].id)
|
||||
&& id(countedPallet.warehousePalletId) === member.palletId && safeFixtureCount(countedPallet.cartonCount) === 1
|
||||
&& Number(countedPallet.grossWeightKg) === 100 && Number(countedPallet.volumeCbm) === 1)
|
||||
const photoPath = `${pdaPath}/measurement-photos/files`
|
||||
const photo = record('POST', photoPath, undefined, await context.requestMultipart('POST', photoPath,
|
||||
{ name: `${member.customerMarker}-TEST-MEASUREMENT.png`, type: 'image/png', content: testImage }, pdaToken), 'Upload TEST measurement evidence')
|
||||
@@ -189,7 +215,7 @@ export async function createFleetCrossCustomerFixture(context, { execute = false
|
||||
check('Customer confirmation makes ordinary incoming dispatchable', incoming?.transferNo === member.incomingNo && incoming.status === 'READY_FOR_DISPATCH')
|
||||
member.receipt = await stock(member)
|
||||
member.balanceId = id(member.receipt.balance.id); member.positionId = id(member.receipt.positions[0].id); member.receiptVerified = true
|
||||
member.measurementReceipt = { sourceBusinessNo: member.incomingNo, syntheticTestMaterials: true,
|
||||
member.measurementReceipt = { sourceBusinessNo: member.incomingNo, syntheticTestMaterials: true, countedCarton,
|
||||
sourceType: 'WAREHOUSE_TRANSFER_PDA', finalizedPallet: incoming.pallets?.find(row => id(row.warehousePalletId) === member.palletId) }
|
||||
const candidates = await request('GET', `${ROOT}/dispatches/candidates?${new URLSearchParams({ warehouseId: id(warehouseId), orderNo: member.incomingNo })}`,
|
||||
undefined, 'Read own native received source selection')
|
||||
|
||||
Reference in New Issue
Block a user