This commit is contained in:
2026-09-15 20:52:02 +08:00
parent 4b671bb0ab
commit 8b19085bc4
24 changed files with 387 additions and 38 deletions
+1 -1
View File
@@ -44,7 +44,7 @@ baiyue:
file:
url: ${FILE_SERVICE_URL}
domestic-file:
url: ${DOMESTIC_FILE_SERVICE_URL}
url: ${FILE_SERVICE_URL}
event-bridge:
url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL}}
warehouse:
+1 -1
View File
@@ -141,7 +141,7 @@ if [ "$SERVICE" = "firstleg" ]; then
fi
if [ "$SERVICE" = "lastleg-service" ]; then
for key in LASTLEG_DB_URL LASTLEG_DB_USERNAME LASTLEG_DB_PASSWORD FIRSTLEG_EVENT_BRIDGE_URL DOMESTIC_WAREHOUSE_URL; do
for key in LASTLEG_DB_URL LASTLEG_DB_USERNAME LASTLEG_DB_PASSWORD FIRSTLEG_EVENT_BRIDGE_URL DOMESTIC_WAREHOUSE_URL FILE_SERVICE_URL; do
if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then
echo "部署 lastleg-service 时,环境文件必须包含非空 $key" >&2
exit 2
+1 -1
View File
@@ -111,7 +111,7 @@ esac
case " $SERVICES " in
*" lastleg-service "*)
for key in LASTLEG_DB_URL LASTLEG_DB_USERNAME LASTLEG_DB_PASSWORD FIRSTLEG_EVENT_BRIDGE_URL DOMESTIC_WAREHOUSE_URL; do
for key in LASTLEG_DB_URL LASTLEG_DB_USERNAME LASTLEG_DB_PASSWORD FIRSTLEG_EVENT_BRIDGE_URL DOMESTIC_WAREHOUSE_URL FILE_SERVICE_URL; do
if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then
echo "部署 lastleg-service 时,环境文件必须包含非空 $key" >&2
exit 2
+1 -1
View File
@@ -20,7 +20,7 @@
| Order 申报同步与渠道变更履约门禁 | `controller/InternalInboundOrderController.java` 的 `/order-channel-changes/validate`、`/order-declaration-updated`,`service/OrderDeclarationSyncService.java`、`mapper/OrderDeclarationSyncMapper.java`、`dto/OrderDeclarationSync.java`;契约 `src/main/resources/openapi/firstleg-v1.yaml`,只接受内部服务令牌 |
| 平台测量/OCR 与箱规计费快照 | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`service/CartonMeasurementService.java`、`service/CustomerDimensionWeightingService.java`;手工单箱测量保存实际尺寸、可选外箱修正、`chargeRuleId` 与 `coefficientMode`,计算结果以服务端返回值为准 |
| 箱唛箱码生成、打印与测量绑定 | 审核建单生成钩子 `controller/InternalInboundOrderController.java`;生成/解析 `service/InboundCartonLabelService.java`、`repository/InboundCartonLabelRepository.java`;平台与客户打印 `controller/PlatformInboundOrderController.java`、`MerchantInboundCartonLabelController.java`、`service/InboundCartonLabelPrintService.java`。一件代发箱唛以 `fl_inbound_carton_label` 表示物理箱,`fl_inbound_carton_label_sku` 冻结箱内一条或多条 SKU;生成时经 `client/OrderCargoClient.java` 获取。缺 SKU 时禁止生成,打印面显示箱内 SKU;`box_code` 与二维码内容仍只携带稳定箱码。基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql`、`../deploy/sql/头程数据库统一增量-MySQL8.sql` |
| 一件代发测量后 PDA 清点组托 | `controller/PlatformDropshipPalletizationController.java`、`service/DropshipPalletizationService.java`、`mapper/DropshipPalletizationMapper.java`;测量审核通过进入 `WAIT_PALLETIZE`,按预计托数预生成 `DP` 托盘码并下发独立任务,不创建国内库存;托盘信息单固定为每托一页 A4,包含托盘 Code 128 一维码、入仓码、总箱数与 SKU/商品名称/箱数/实点数量,不展示作业状态。PDA 逐箱清点,支持先扫箱或先扫托、归托和逐托封托,预计托数与已生成托盘数不一致时禁止作业,全部箱和全部预生成托盘完成后进入 `WAIT_SPLIT`。契约 `src/main/resources/openapi/dropship-palletization-v1.yaml` |
| 一件代发测量后 PDA 清点组托 | `controller/PlatformDropshipPalletizationController.java`、`service/DropshipPalletizationService.java`、`mapper/DropshipPalletizationMapper.java`;测量审核通过进入 `WAIT_PALLETIZE`,按预计托数预生成 `DP` 托盘码并下发独立任务,不创建国内库存;托盘信息单固定为每托一页 A4,包含托盘 Code 128 一维码、入仓码、总箱数与 SKU/商品名称/箱数/实点数量,不展示作业状态。PDA 逐箱清点,支持先扫箱或先扫托、归托、逐托封托和在未进入装柜计划前解除封托;合托解封时同步恢复所有参与任务及入仓单。预计托数与已生成托盘数不一致时禁止作业,全部箱和实际参与托盘完成后进入 `WAIT_SPLIT`。契约 `src/main/resources/openapi/dropship-palletization-v1.yaml` |
| 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` |
| 称重确认与整票库位块 | `service/MeasurementLocationOccupationService.java` 的 `ensureForConfirmedWeight/ensureForSubmission`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`client/WarehousePalletLocationClient.java`;Warehouse 块服务见 `../warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java`,接口与上架引导见 `src/main/resources/openapi/firstleg-v1.yaml` |
| 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` |
@@ -113,6 +113,16 @@ public class PlatformDropshipPalletizationController {
return ApiResponse.success(service.seal(taskId, palletId, request, user.userId(), user.username()));
}
@PostMapping("/tasks/{taskId}/pallets/{palletId}/unseal")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Task> unseal(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@PathVariable @Positive long palletId,
@Valid @RequestBody DropshipPalletizationDtos.UnsealPalletRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.unseal(taskId, palletId, request, user.userId(), user.username()));
}
@GetMapping("/tasks/{taskId}/pallets/{palletId}/print")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ResponseEntity<byte[]> printTaskPallet(@AuthenticationPrincipal AuthenticatedUser user,
@@ -44,6 +44,10 @@ public final class DropshipPalletizationDtos {
@NotBlank @Size(max = 128) String operationId,
@Positive int expectedVersion) {}
public record UnsealPalletRequest(@Positive long warehouseId,
@NotBlank @Size(max = 128) String operationId,
@Positive int expectedVersion) {}
public record PendingTask(Long taskId, Long inboundId, String inboundCode, Long tenantId,
String customerName, Long warehouseId, String warehouseName,
Integer totalCartons, Integer countedCartons, Integer boundCartons,
@@ -170,6 +170,15 @@ public interface DropshipPalletizationMapper {
AND EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.pallet_id=#{palletId} AND c.task_id=#{taskId})""")
int seal(@Param("taskId") long taskId,@Param("palletId") long palletId,@Param("expectedVersion") int expectedVersion,
@Param("warehouseId") long warehouseId,@Param("operationId") String operationId,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Select("SELECT COUNT(*) FROM fl_loading_plan_item WHERE pallet_id=#{palletId} AND deleted=0")
int activeLoadingAssignmentCount(@Param("palletId") long palletId);
@Update("""
UPDATE fl_dropship_pallet SET pallet_status='OPEN',seal_operation_id=#{operationId},completed_by=NULL,
completed_by_name=NULL,completed_at=NULL,version=version+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{palletId} AND warehouse_id=#{warehouseId} AND pallet_status='SEALED' AND version=#{expectedVersion}
AND EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.pallet_id=#{palletId} AND c.task_id=#{taskId})""")
int unseal(@Param("taskId") long taskId,@Param("palletId") long palletId,@Param("expectedVersion") int expectedVersion,
@Param("warehouseId") long warehouseId,@Param("operationId") String operationId);
@Select("""
SELECT COUNT(DISTINCT p.id) FROM fl_dropship_pallet_carton c
JOIN fl_dropship_pallet p ON p.id=c.pallet_id
@@ -189,6 +198,13 @@ public interface DropshipPalletizationMapper {
WHERE o.id=#{inboundId} AND o.tenant_id=#{tenantId} AND o.process_status='WAIT_PALLETIZE'
AND EXISTS(SELECT 1 FROM fl_dropship_pallet_task t WHERE t.id=#{taskId} AND t.task_status='COMPLETED')""")
int completeInbound(@Param("taskId") long taskId,@Param("tenantId") long tenantId,@Param("inboundId") long inboundId,@Param("operatorId") long operatorId);
@Update("""
UPDATE fl_dropship_pallet_task SET task_status='CLAIMED',completed_at=NULL,version=version+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{taskId} AND task_status='COMPLETED'""") int reopenTask(@Param("taskId") long taskId);
@Update("""
UPDATE fl_inbound_order SET process_status='WAIT_PALLETIZE',updated_by=#{operatorId},version=version+1
WHERE id=#{inboundId} AND tenant_id=#{tenantId} AND process_status='WAIT_SPLIT'""")
int reopenInbound(@Param("tenantId") long tenantId,@Param("inboundId") long inboundId,@Param("operatorId") long operatorId);
@Select("""
SELECT c.*,p.pallet_no,o.inbound_code FROM fl_dropship_pallet_carton c
@@ -130,7 +130,7 @@ public class DropshipPalletizationService {
public Task seal(long taskId,long palletId,SealPalletRequest request,long operatorId,String operatorName){
TaskRow task=requireWorkingTask(taskId,request.warehouseId(),operatorId);
PalletRow replay=mapper.palletBySealOperation(operation(request.operationId()));
if(replay!=null){ if(!Objects.equals(replay.getId(),palletId)||!Objects.equals(replay.getWarehouseId(),request.warehouseId())) throw conflict("封托幂等号已用于其他托盘"); return view(requireTask(taskId,request.warehouseId())); }
if(replay!=null){ if(!Objects.equals(replay.getId(),palletId)||!Objects.equals(replay.getWarehouseId(),request.warehouseId())||!"SEALED".equals(replay.getPalletStatus())) throw conflict("封托幂等号已用于其他操作"); return view(requireTask(taskId,request.warehouseId())); }
requireOpenPallet(taskId,palletId,request.warehouseId());
if(mapper.seal(taskId,palletId,request.expectedVersion(),request.warehouseId(),operation(request.operationId()),operatorId,operatorName)!=1)
throw conflict("空托盘、托盘状态或版本已变化,请刷新后重试");
@@ -145,6 +145,38 @@ public class DropshipPalletizationService {
return view(requireTask(taskId,request.warehouseId()));
}
@Transactional
public Task unseal(long taskId,long palletId,UnsealPalletRequest request,long operatorId,String operatorName){
TaskRow task=requireTask(taskId,request.warehouseId());
if(task.getAssigneeId()==null || !Objects.equals(task.getAssigneeId(),operatorId)) throw conflict("任务不属于当前操作员");
if(!List.of("CLAIMED","COMPLETED").contains(task.getTaskStatus())) throw conflict("当前任务状态不能解除封托");
String operation=operation(request.operationId());
PalletRow replay=mapper.palletBySealOperation(operation);
if(replay!=null){
if(!Objects.equals(replay.getId(),palletId)||!Objects.equals(replay.getWarehouseId(),request.warehouseId())||!"OPEN".equals(replay.getPalletStatus())) throw conflict("解除封托幂等号已用于其他操作");
return view(requireTask(taskId,request.warehouseId()));
}
PalletRow pallet=mapper.pallet(taskId,palletId,request.warehouseId());
if(pallet==null) throw new BizException(ResultCode.NOT_FOUND,"托盘不存在");
if(!"SEALED".equals(pallet.getPalletStatus())) throw conflict("托盘尚未封托");
if(mapper.activeLoadingAssignmentCount(palletId)>0) throw conflict("托盘已进入装柜计划,请先从柜次移除后再解除封托");
List<TaskRow> participants=new ArrayList<>();
for(Long participatingTaskId:mapper.taskIdsByPallet(palletId)){
TaskRow participating=requireTask(participatingTaskId,request.warehouseId());
if(!List.of("WAIT_PALLETIZE","WAIT_SPLIT").contains(participating.getProcessStatus())) throw conflict("托盘已进入后续履约,不能解除封托");
participants.add(participating);
}
if(mapper.unseal(taskId,palletId,request.expectedVersion(),request.warehouseId(),operation)!=1)
throw conflict("托盘状态或版本已变化,请刷新后重试");
for(TaskRow participating:participants){
if("COMPLETED".equals(participating.getTaskStatus()) && mapper.reopenTask(participating.getId())!=1)
throw conflict("合托任务状态已变化,请刷新后重试");
if("WAIT_SPLIT".equals(participating.getProcessStatus()) && mapper.reopenInbound(participating.getTenantId(),participating.getInboundId(),operatorId)!=1)
throw conflict("入仓单状态已变化,请刷新后重试");
}
return view(requireTask(taskId,request.warehouseId()));
}
public List<Pallet> inboundPallets(long tenantId,long inboundId){
String code=mapper.inboundCode(tenantId,inboundId);
if(code==null) throw new BizException(ResultCode.NOT_FOUND,"入库单不存在");
@@ -65,6 +65,16 @@ paths:
- {name: palletId, in: path, required: true, schema: {type: integer, format: int64}}
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/SealPalletRequest'}}}}
responses: {'200': {description: OK}}
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/{palletId}/unseal:
post:
summary: 解除封托,恢复为可继续组托的开放托盘
description: 仅允许尚未进入有效装柜计划或后续履约的已封托托盘;合托时原子恢复所有参与任务和入仓单的待组托状态。
x-required-authority: platform:firstleg:pda:work
parameters:
- {$ref: '#/components/parameters/TaskId'}
- {name: palletId, in: path, required: true, schema: {type: integer, format: int64}}
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/UnsealPalletRequest'}}}}
responses: {'200': {description: OK}}
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/{palletId}/print:
get:
summary: PDA 获取预生成托盘码的 A4 托盘信息单
@@ -206,5 +216,12 @@ components:
warehouseId: {type: integer, format: int64}
expectedVersion: {type: integer, minimum: 1}
operationId: {type: string, maxLength: 128}
UnsealPalletRequest:
type: object
required: [warehouseId, expectedVersion, operationId]
properties:
warehouseId: {type: integer, format: int64}
expectedVersion: {type: integer, minimum: 1}
operationId: {type: string, maxLength: 128}
PalletNo: {type: string, pattern: '^DP-[0-9]{6}-[0-9]{4}$'}
NullablePalletNo: {type: string, pattern: '^DP-[0-9]{6}-[0-9]{4}$', nullable: true}
@@ -93,4 +93,20 @@ class DropshipPalletizationSqlContractTest {
assertThat(complete).contains("fl_dropship_pallet_carton c JOIN fl_dropship_pallet p")
.doesNotContain("p.task_id=#{taskId}");
}
@Test
void unsealIsAProtectedIdempotentPdaCommandAndBlocksActiveLoadingAssignments() throws Exception {
var controller = com.baiyue.firstleg.controller.PlatformDropshipPalletizationController.class;
var method = java.util.Arrays.stream(controller.getDeclaredMethods())
.filter(item -> item.getName().equals("unseal")).findFirst().orElseThrow();
assertThat(method.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:firstleg:pda:work')");
String api = new ClassPathResource("openapi/dropship-palletization-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(api).contains("/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/{palletId}/unseal:")
.contains("$ref: '#/components/schemas/UnsealPalletRequest'");
String assignment = DropshipPalletizationMapper.class.getDeclaredMethod("activeLoadingAssignmentCount", long.class)
.getAnnotation(Select.class).value()[0];
assertThat(assignment).contains("fl_loading_plan_item").contains("deleted=0");
}
}
@@ -4,6 +4,7 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.DropshipPalletizationDtos.CountCartonRequest;
import com.baiyue.firstleg.dto.DropshipPalletizationDtos.DiscrepancyAction;
import com.baiyue.firstleg.dto.DropshipPalletizationDtos.CountSkuLine;
import com.baiyue.firstleg.dto.DropshipPalletizationDtos.UnsealPalletRequest;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.LabelRow;
@@ -87,11 +88,46 @@ class DropshipPalletizationServiceTest {
.isInstanceOf(BizException.class).hasMessageContaining("预计 2 托");
}
@Test void sealedPalletCanReopenBeforeItEntersALoadingPlan(){
TaskRow current=task(); PalletRow sealed=sealedPallet();
when(mapper.task(10L,100L)).thenReturn(current); when(mapper.pallet(10L,20L,100L)).thenReturn(sealed);
when(mapper.taskIdsByPallet(20L)).thenReturn(java.util.List.of(10L)); when(mapper.unseal(10L,20L,1,100L,"undo-1")).thenReturn(1);
when(mapper.palletCount(10L)).thenReturn(2); when(mapper.pallets(10L)).thenReturn(java.util.List.of());
var result=service.unseal(10L,20L,new UnsealPalletRequest(100L,"undo-1",1),7L,"张三");
assertThat(result.taskStatus()).isEqualTo("CLAIMED");
verify(mapper).activeLoadingAssignmentCount(20L); verify(mapper).unseal(10L,20L,1,100L,"undo-1");
verify(mapper,never()).reopenTask(anyLong()); verify(mapper,never()).reopenInbound(anyLong(),anyLong(),anyLong());
}
@Test void unsealingACompletedSharedPalletReopensEveryParticipatingInbound(){
TaskRow completed=task(); completed.setTaskStatus("COMPLETED"); completed.setProcessStatus("WAIT_SPLIT");
TaskRow other=task(); other.setId(11L); other.setTenantId(2L); other.setInboundId(31L); other.setTaskStatus("COMPLETED"); other.setProcessStatus("WAIT_SPLIT");
TaskRow reopened=task();
when(mapper.task(10L,100L)).thenReturn(completed,completed,reopened);
when(mapper.task(11L,100L)).thenReturn(other); when(mapper.pallet(10L,20L,100L)).thenReturn(sealedPallet());
when(mapper.taskIdsByPallet(20L)).thenReturn(java.util.List.of(10L,11L)); when(mapper.unseal(10L,20L,1,100L,"undo-2")).thenReturn(1);
when(mapper.reopenTask(10L)).thenReturn(1); when(mapper.reopenTask(11L)).thenReturn(1);
when(mapper.reopenInbound(1L,30L,7L)).thenReturn(1); when(mapper.reopenInbound(2L,31L,7L)).thenReturn(1);
when(mapper.palletCount(10L)).thenReturn(2); when(mapper.pallets(10L)).thenReturn(java.util.List.of());
service.unseal(10L,20L,new UnsealPalletRequest(100L,"undo-2",1),7L,"张三");
verify(mapper).reopenTask(10L); verify(mapper).reopenTask(11L);
verify(mapper).reopenInbound(1L,30L,7L); verify(mapper).reopenInbound(2L,31L,7L);
}
@Test void palletAssignedToALoadingPlanCannotBeUnsealed(){
when(mapper.task(10L,100L)).thenReturn(task()); when(mapper.pallet(10L,20L,100L)).thenReturn(sealedPallet());
when(mapper.activeLoadingAssignmentCount(20L)).thenReturn(1);
assertThatThrownBy(()->service.unseal(10L,20L,new UnsealPalletRequest(100L,"undo-3",1),7L,"张三"))
.isInstanceOf(BizException.class).hasMessageContaining("已进入装柜计划");
verify(mapper,never()).unseal(anyLong(),anyLong(),anyInt(),anyLong(),anyString());
}
private static TaskRow task(){TaskRow t=new TaskRow();t.setId(10L);t.setTenantId(1L);t.setInboundId(30L);t.setWarehouseId(100L);t.setInboundCode("IN-1");t.setTaskStatus("CLAIMED");t.setAssigneeId(7L);t.setProcessStatus("WAIT_PALLETIZE");t.setTotalCartons(2);t.setCountedCartons(0);t.setAllocatedCartons(0);t.setDiscrepancyCartons(0);t.setExpectedPalletCount(2);t.setVersion(1);return t;}
private static LabelRow label(){LabelRow l=new LabelRow();l.setCartonLabelId(50L);l.setInboundDetailId(40L);l.setSkuCode("SKU-1");l.setCargoName("货物");l.setUnitsPerCarton(12);l.setInboundCode("IN-1");l.setBoxCode("BOX-1");return l;}
private static LabelSkuRow labelSku(){LabelSkuRow l=new LabelSkuRow();l.setInboundDetailId(40L);l.setSkuCode("SKU-1");l.setCargoName("货物");l.setExpectedQuantity(12);l.setLineNo(1);return l;}
private static CartonSkuRow cartonSku(){CartonSkuRow l=new CartonSkuRow();l.setInboundDetailId(40L);l.setSkuCode("SKU-1");l.setCargoName("货物");l.setExpectedQuantity(12);l.setActualQuantity(12);l.setDiscrepancyStatus("MATCH");l.setLineNo(1);return l;}
private static CountCartonRequest countRequest(String palletNo){return new CountCartonRequest(100L,"BOX-1",java.util.List.of(new CountSkuLine(40L,"SKU-1",12,DiscrepancyAction.MATCH,null)),palletNo,"op-1");}
private static PalletRow pallet(){PalletRow p=new PalletRow();p.setId(20L);p.setTaskId(10L);p.setTenantId(1L);p.setInboundId(30L);p.setWarehouseId(100L);p.setPalletNo("DP-260911-0001");p.setPalletStatus("OPEN");p.setVersion(1);return p;}
private static PalletRow sealedPallet(){PalletRow p=pallet();p.setPalletStatus("SEALED");return p;}
private static CartonRow carton(){CartonRow c=new CartonRow();c.setId(60L);c.setTaskId(10L);c.setPalletId(20L);c.setInboundId(30L);c.setInboundDetailId(40L);c.setPalletNo("DP-260911-0001");c.setSkuCode("SKU-1");c.setCargoName("货物");c.setBoxCode("BOX-1");c.setExpectedSkuQuantity(12);c.setActualSkuQuantity(12);c.setDiscrepancyStatus("MATCH");return c;}
}
+2 -2
View File
@@ -27,7 +27,7 @@
|---|---|
| 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 |
| 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码、有效计划及托盘资源异常并分页返回全部稳定来源柜,已完成计划转换的柜仍保留用于接柜仓查询与修改;单票手工计划保存后,整柜最后一票齐全时自动生成并绑定全柜托盘码,一票柜立即生成,`resourcePendingPlanCount` 只作为失败重试事实;`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 |
| 尾程订单完整计划、柜级到仓时间、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;Admin 清关提柜列表通过 `/orders/container-arrival-times/query` 批量读取整柜预计/实际到仓,预计时间在任何入库计划生成前按柜级版本与幂等键人工维护并原子同步全柜 `ll_order.estimated_warehouse_at`,实际时间只取海外 PDA 会话首次 `receiving_at`。单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;预计到仓日期不接受计划请求输入,必须锁定来源柜全部订单的 `estimated_warehouse_at`,缺失或同柜不一致时阻断,人工与自动计划统一写其日期;一件代发人工计划托数必须严格等于不可变头程封托托盘数,禁止保存可增减托数但无法绑定真实资源的半成品计划;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`,已拣货、已发运或已有出库单时禁止新增暂扣;暂扣期间不得进入排车或出库。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 |
| 尾程订单完整计划、柜级到仓时间、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;Admin 清关提柜列表通过 `/orders/container-arrival-times/query` 批量读取整柜预计/实际到仓,预计时间在任何入库计划生成前按柜级版本与幂等键人工维护并原子同步全柜 `ll_order.estimated_warehouse_at`,实际时间只取海外 PDA 会话首次 `receiving_at`。单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;预计到仓日期不接受计划请求输入,必须锁定来源柜全部订单的 `estimated_warehouse_at`,缺失或同柜不一致时阻断,人工与自动计划统一写其日期;一件代发人工计划必须逐托沿用不可变头程封托的 DP 托盘码和箱数,托数及各托箱数均不得重新分配,禁止保存无法绑定真实资源的半成品计划;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`,已拣货、已发运或已有出库单时禁止新增暂扣;暂扣期间不得进入排车或出库。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 |
| 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` |
| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V3,消费端除冻结 V2 货物事实外,按 `sourceContainerBookingId` 幂等投影物理柜五类国内装柜照片;FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;V1/V2 仅用于历史事件读取 |
| 尾程单号生成 | `service/LastlegOrderNoGenerator.java` |
@@ -42,7 +42,7 @@
| FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` |
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`;普通 FBA/OVERSEAS 分别使用 bulk/carton 收货,一件代发只允许 `/current/receipts/pallet` 按国内冻结来源托盘整托收货。合托只作现场可选提示;全部计划完整绑定真实 pallet/location/lock 前不得查看、准备或签收该柜,首扫只确认已预留绑定,不得重新分配计划资源 |
| PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` |
| PDA 国内装柜照片对照 | `ReceivingContainerController.java` 的 `/current/domestic-loading-photos/{evidenceType}/content`、`ContainerLoadingEvidenceMapper.java`、`DomesticLoadingEvidenceGateway.java`;元数据按物理柜投影,不保存客户,下载必须经当前 PDA 会话鉴权并由 `DOMESTIC_FILE_SERVICE_URL` 只读访问国内 File,单图不可用不改变拆柜状态 |
| PDA 国内装柜照片对照 | `ReceivingContainerController.java` 的 `/current/domestic-loading-photos/{evidenceType}/content`、`ContainerLoadingEvidenceMapper.java`、`DomesticLoadingEvidenceGateway.java`;元数据按物理柜投影,不保存客户,下载必须经当前 PDA 会话鉴权并复用 `FILE_SERVICE_URL` 只读访问 File 服务,单图不可用不改变拆柜状态 |
| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` |
| 拆柜打托成本与客户应收分摊 | `inboundplan/service/DestuffingPalletCostJob.java`、`DestuffingPalletCostService.java`;仅全柜所有 PDA 入仓任务完成后执行,按实物托盘单价汇总并按客户实收 CBM 分摊,尾差落最后一位客户;每份分摊发布 `OVERSEAS_CONTAINER_FEE/DESTUFF_PALLET` 应收费用来源,不直接改国内钱包 |
| 尾程暂扣状态回传 | `service/InboundOrderDetainedOutboxService.java`;暂扣与解除暂扣在订单更新事务内按 `sourceInboundId` 写入海外到国内 Outbox,国内按来源入仓单绑定更新 |
@@ -27,6 +27,15 @@ public class OutboundPdaController {
identity.operatorKey(), taskNo, request));
}
@PostMapping("/{taskNo}/pallets/{palletNo}/pick")
public ApiResponse<DeliveryResponses.PdaTask> pickPallet(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String taskNo, @PathVariable String palletNo,
@Valid @RequestBody DeliveryRequests.PdaPalletPick request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.pdaPickPallet(String.valueOf(identity.overseasWarehouseId()),
identity.operatorKey(), taskNo, palletNo, request));
}
@GetMapping
public ApiResponse<java.util.List<DeliveryResponses.PdaTask>> list(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required=false) String status) {
@@ -89,6 +89,7 @@ public final class DeliveryRequests {
this(outboundPalletItemId, inventoryPositionId, packages, idempotencyKey, List.of());
}
}
public record PdaPalletPick(@NotBlank @Size(max=255) String idempotencyKey) {}
public record PdaComplete(@NotBlank @Size(max=255) String idempotencyKey) {}
public record Cancel(@NotBlank @Size(max=1000) String reason) {}
}
@@ -78,6 +78,7 @@ import com.fasterxml.jackson.core.JsonProcessingException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.stream.Collectors;
@Service
public class DeliveryTransferService {
@@ -476,7 +477,7 @@ public class DeliveryTransferService {
}
OutboundPdaPick pick = new OutboundPdaPick(); pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId()); pick.setInventoryPositionId(null); pick.setPackages(request.packages()); pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick);
item.setPickedPackageCount(request.packages().intValue()); item.setStatus("PICKED"); outboundPallets.updateById(item);
outbound.setStatus("PICKED"); outbound.setUpdatedAt(LocalDateTime.now()); outboundOrders.updateById(outbound);
updateOutboundPickingStatus(customerId, outbound);
task.setStatus("IN_PROGRESS"); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task);
return taskResult(outbound, task);
}
@@ -529,10 +530,7 @@ public class DeliveryTransferService {
pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId()); pick.setInventoryPositionId(request.inventoryPositionId());
pick.setPackages(request.packages()); pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick);
item.setPickedPackageCount(request.packages().intValue()); item.setStatus("PICKED"); outboundPallets.updateById(item);
List<OutboundPalletItem> pickedItems = outboundPallets.findByOutbound(customerId, outbound.getId());
if (!pickedItems.isEmpty() && pickedItems.stream().allMatch(value -> "PICKED".equals(value.getStatus()))) {
outbound.setStatus("PICKED"); outbound.setUpdatedAt(LocalDateTime.now()); outboundOrders.updateById(outbound);
}
updateOutboundPickingStatus(customerId, outbound);
task.setStatus("IN_PROGRESS"); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task);
return taskResult(outbound, task);
}
@@ -578,14 +576,69 @@ public class DeliveryTransferService {
for (DropshipCarton carton : cartons) { carton.setStatus("SHIPPED"); carton.setOutboundOrderNo(outbound.getOutboundNo()); carton.setVersion((carton.getVersion() == null ? 0 : carton.getVersion()) + 1); carton.setUpdatedAt(LocalDateTime.now()); dropshipCartons.updateById(carton); }
OutboundPdaPick pick = new OutboundPdaPick(); pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId()); pick.setInventoryPositionId(request.inventoryPositionId()); pick.setPackages(request.packages()); pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick);
item.setPickedPackageCount(value(item.getPickedPackageCount()) + request.packages().intValue()); item.setStatus(item.getPickedPackageCount() >= item.getPackageCount() ? "PICKED" : "PARTIAL_PICKED"); outboundPallets.updateById(item);
List<OutboundPalletItem> pickedItems = outboundPallets.findByOutbound(customerId, outbound.getId());
if (!pickedItems.isEmpty() && pickedItems.stream().allMatch(i -> "PICKED".equals(i.getStatus()))) {
outbound.setStatus("PICKED"); outbound.setUpdatedAt(LocalDateTime.now()); outboundOrders.updateById(outbound);
}
updateOutboundPickingStatus(customerId, outbound);
task.setStatus("IN_PROGRESS"); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task);
return taskResult(outbound, task);
}
@Transactional
public DeliveryResponses.PdaTask pdaPickPallet(String warehouseId, String operatorId, String taskNo,
String palletNo, DeliveryRequests.PdaPalletPick request) {
String boundWarehouseId = required(warehouseId, "PDA warehouse binding is required");
String code = required(taskNo, "PDA task number is required");
String palletCode = required(palletNo, "Pallet number is required");
OutboundPdaTask task = pdaTasks.lockByNoAndWarehouse(code, boundWarehouseId);
if (task == null) throw notFound("PDA task not found in the bound warehouse");
OutboundOrder outbound = outboundOrders.lockByNo(task.getCustomerId(), requireOutbound(task).getOutboundNo());
if (outbound == null || !boundWarehouseId.equals(outbound.getOverseasWarehouseId())) {
throw notFound("Outbound order not found in the bound warehouse");
}
if (!"ONE_CLICK_DROPSHIP".equals(outbound.getOutboundType())) {
throw conflict("整托扫码仅适用于一件代发出库");
}
if (!Set.of("PENDING", "IN_PROGRESS").contains(task.getStatus())) {
throw conflict("PDA task is not available for picking");
}
List<OutboundPalletItem> palletItems = outboundPallets.findByOutbound(task.getCustomerId(), outbound.getId())
.stream().filter(item -> palletCode.equals(item.getPalletNo())).toList();
if (palletItems.isEmpty()) throw notFound("当前出库任务没有该托盘");
if (palletItems.stream().allMatch(item -> value(item.getPickedPackageCount()) >= value(item.getPackageCount()))) {
return taskResult(outbound, task);
}
List<PalletPickPlan> plans = new ArrayList<>();
int sequence = 0;
for (OutboundPalletItem item : palletItems) {
long remaining = value(item.getPackageCount()) - value(item.getPickedPackageCount());
if (remaining <= 0) continue;
Map<Long, List<DeliveryResponses.PdaPickPosition>> positionsById = pdaPickPositions(
task.getCustomerId(), boundWarehouseId, item).stream().collect(Collectors.groupingBy(
DeliveryResponses.PdaPickPosition::inventoryPositionId, LinkedHashMap::new, Collectors.toList()));
for (Map.Entry<Long, List<DeliveryResponses.PdaPickPosition>> entry : positionsById.entrySet()) {
if (remaining <= 0) break;
List<String> marks = entry.getValue().stream().map(DeliveryResponses.PdaPickPosition::cartonMark)
.filter(StringUtils::hasText).distinct().toList();
long available = marks.isEmpty()
? entry.getValue().stream().mapToLong(DeliveryResponses.PdaPickPosition::availablePackages).sum()
: marks.size();
long packages = Math.min(remaining, available);
if (packages <= 0) continue;
List<String> selectedMarks = marks.isEmpty() ? List.of() : marks.stream().limit(packages).toList();
plans.add(new PalletPickPlan(item.getId(), entry.getKey(), packages, selectedMarks, sequence++));
remaining -= packages;
}
if (remaining > 0) throw conflict("托盘可用库存不足,整托拣货未执行:" + palletCode);
}
DeliveryResponses.PdaTask result = taskResult(outbound, task);
for (PalletPickPlan plan : plans) {
String operationId = palletPickOperationId(request.idempotencyKey(), code, palletCode, plan);
result = pdaPick(boundWarehouseId, operatorId, code, new DeliveryRequests.PdaPick(
plan.itemId(), plan.positionId(), plan.packages(), operationId, plan.cartonMarks()));
}
return result;
}
@Transactional
public DeliveryResponses.PdaTask completePda(String warehouseId, String taskNo,
DeliveryRequests.PdaComplete request) {
@@ -909,6 +962,27 @@ public class DeliveryTransferService {
.stream().map(LastlegOrder::getLxCode).filter(StringUtils::hasText)
.map(String::trim).distinct().collect(java.util.stream.Collectors.joining(","));
}
private void updateOutboundPickingStatus(String customerId, OutboundOrder outbound) {
List<OutboundPalletItem> items = outboundPallets.findByOutbound(customerId, outbound.getId());
String nextStatus = !items.isEmpty() && items.stream().allMatch(item -> "PICKED".equals(item.getStatus()))
? "PICKED" : "PICKING";
outbound.setStatus(nextStatus);
outbound.setUpdatedAt(LocalDateTime.now());
outboundOrders.updateById(outbound);
}
private static String palletPickOperationId(String idempotencyKey, String taskNo, String palletNo,
PalletPickPlan plan) {
String source = required(idempotencyKey, "Idempotency key is required") + '\u0000' + taskNo + '\u0000'
+ palletNo + '\u0000' + plan.itemId() + '\u0000' + plan.positionId() + '\u0000' + plan.sequence();
try {
return "PALLET_PICK:" + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(source.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException error) {
throw new IllegalStateException("无法生成整托拣货幂等号", error);
}
}
private record PalletPickPlan(Long itemId, Long positionId, long packages,
List<String> cartonMarks, int sequence) {}
private BigDecimal totalWeight(String customerId, List<DispatchItem> items) {
List<Long> palletIds = items.stream().map(DispatchItem::getWarehousePalletId)
.filter(Objects::nonNull).distinct().toList();
@@ -13,6 +13,7 @@ import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import org.springframework.web.server.ResponseStatusException;
@Component
@@ -32,24 +33,36 @@ public class HttpDomesticLoadingEvidenceGateway implements DomesticLoadingEviden
@Override
public DownloadedFile download(long containerBookingId, long fileId) {
ResponseEntity<byte[]> response = client.get()
.uri("/internal/file/v1/loading-evidence/{containerBookingId}/{fileId}/content",
containerBookingId, fileId)
.header("X-Internal-Service-Token", internalToken).retrieve()
.onStatus(HttpStatusCode::is4xxClientError, (request, result) -> {
throw new ResponseStatusException(result.getStatusCode().value() == 404
? HttpStatus.NOT_FOUND : HttpStatus.BAD_REQUEST, "国内装柜照片不可用");
})
.onStatus(HttpStatusCode::is5xxServerError, (request, result) -> {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "国内装柜照片暂时无法加载");
}).toEntity(byte[].class);
ResponseEntity<byte[]> response;
try {
response = client.get()
.uri("/internal/file/v1/loading-evidence/{containerBookingId}/{fileId}/content",
containerBookingId, fileId)
.header("X-Internal-Service-Token", internalToken).retrieve()
.onStatus(HttpStatusCode::is4xxClientError, (request, result) -> {
throw new ResponseStatusException(result.getStatusCode().value() == 404
? HttpStatus.NOT_FOUND : HttpStatus.BAD_REQUEST, "国内装柜照片不可用");
})
.onStatus(HttpStatusCode::is5xxServerError, (request, result) -> {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "国内装柜照片暂时无法加载");
}).toEntity(byte[].class);
} catch (ResponseStatusException exception) {
throw exception;
} catch (RestClientException exception) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"国内装柜照片暂时无法加载", exception);
}
byte[] content = response.getBody() == null ? new byte[0] : response.getBody();
MediaType mediaType = response.getHeaders().getContentType();
if (content.length == 0 || mediaType == null || !"image".equalsIgnoreCase(mediaType.getType())) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"国内装柜照片返回内容无效");
}
String disposition = response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION);
String fileName = null;
try { fileName = disposition == null ? null : ContentDisposition.parse(disposition).getFilename(); }
catch (IllegalArgumentException ignored) { }
String contentType = response.getHeaders().getContentType() == null
? MediaType.APPLICATION_OCTET_STREAM_VALUE : response.getHeaders().getContentType().toString();
String contentType = mediaType.toString();
return new DownloadedFile(content, fileName == null ? fileId + ".bin" : fileName, contentType);
}
}
@@ -134,8 +134,10 @@ public class InboundPlanService {
requiredReceivingWarehouse(containerWarehouseId);
validateOrders(snapshots);
String orderType = trimToNull(orderMapper.findOrderType(customerId, request.orderId()));
List<DropshipPalletSnapshot> dropshipPalletSnapshots = "ONE_CLICK_DROPSHIP".equals(orderType)
? dropshipOrders.palletSnapshots(customerId, request.orderId()) : List.of();
int sourcePalletCount = "ONE_CLICK_DROPSHIP".equals(orderType)
? dropshipOrders.palletSnapshots(customerId, request.orderId()).size()
? dropshipPalletSnapshots.size()
: Objects.requireNonNullElse(
sourcePalletMapper.palletNos(customerId, request.orderId()), List.<String>of()).size();
if ("ONE_CLICK_DROPSHIP".equals(orderType)) {
@@ -154,7 +156,9 @@ public class InboundPlanService {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"人工计划托数不能少于柜级预览建议托数 " + minimumPalletCount);
}
InboundPlanResponse response = createPlan(customerId, request.orderId(), snapshots, List.of(), null,
List<WholePalletInput> wholePallets = "ONE_CLICK_DROPSHIP".equals(orderType)
? dropshipPalletSnapshots.stream().map(this::wholePallet).toList() : null;
InboundPlanResponse response = createPlan(customerId, request.orderId(), snapshots, List.of(), wholePallets,
request, receivingWarehouse, plannedArrivalDate);
LastlegOrderCommandRecord command = new LastlegOrderCommandRecord();
command.setId(IdWorker.getId()); command.setCustomerId(customerId); command.setOrderId(request.orderId());
@@ -500,7 +504,7 @@ public class InboundPlanService {
lineByOrder.values().forEach(lineMapper::insert);
Map<Long, Integer> palletCounts;
if (manual != null) {
if (manual != null && wholePallets == null) {
createManualPallets(plan, orders.get(0), lineByOrder.get(orders.get(0).orderId()), manual);
palletCounts = Map.of(orders.get(0).orderId(), manual.palletCount());
} else if (wholePallets == null) {
@@ -512,7 +516,8 @@ public class InboundPlanService {
item -> item.pallet().sequenceNo(), Collectors.toSet()), java.util.Set::size)));
} else {
SourceOrderSnapshot order = orders.get(0);
createWholePallets(plan, order, lineByOrder.get(order.orderId()), wholePallets, reservedPallets);
createWholePallets(plan, order, lineByOrder.get(order.orderId()), wholePallets, reservedPallets,
manual == null ? null : manual.palletType());
palletCounts = Map.of(order.orderId(), wholePallets.size());
}
for (SourceOrderSnapshot order : orders) {
@@ -804,7 +809,8 @@ public class InboundPlanService {
private void createWholePallets(InboundPlan plan, SourceOrderSnapshot order, InboundPlanLine line,
List<WholePalletInput> sourcePallets,
Map<Integer, WarehouseResourceGateway.PalletBatchItem> reservedPallets) {
Map<Integer, WarehouseResourceGateway.PalletBatchItem> reservedPallets,
String palletType) {
BigDecimal remainingVolume = volume(order);
int remainingPackages = order.packageCount();
for (int index = 0; index < sourcePallets.size(); index++) {
@@ -816,6 +822,7 @@ public class InboundPlanService {
InboundPlanPallet pallet = new InboundPlanPallet();
pallet.setId(IdWorker.getId()); pallet.setCustomerId(plan.getCustomerId()); pallet.setPlanId(plan.getId());
pallet.setPalletNo(source.palletNo().trim()); pallet.setSourcePalletNo(source.palletNo().trim());
pallet.setPalletType(trimToNull(palletType) == null ? null : palletType.trim().toUpperCase());
pallet.setDestinationWarehouseId(order.destinationWarehouseId());
pallet.setDestinationWarehouseCode(order.destinationWarehouseCode());
pallet.setDestinationWarehouseName(order.destinationWarehouseName());
@@ -78,7 +78,7 @@ baiyue:
file:
url: ${FILE_SERVICE_URL:http://127.0.0.1:9205}
domestic-file:
url: ${DOMESTIC_FILE_SERVICE_URL:http://127.0.0.1:9205}
url: ${FILE_SERVICE_URL:http://127.0.0.1:9205}
event-bridge:
url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:}}
firstleg:
@@ -69,6 +69,15 @@ paths:
parameters: [{ name: taskNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/OutboundPick' } } } }
responses: { '200': { description: 更新后的统一出库任务 }, '409': { description: 库存、数量或幂等冲突 } }
/api/pda/v1/last-leg/outbound-tasks/{taskNo}/pallets/{palletNo}/pick:
post:
summary: 扫描一件代发托盘并原子拣取整托剩余箱件
description: 仅适用于一件代发出库;按托盘汇总本任务内全部 SKU 和未拣箱唛,任一库存校验失败则整托回滚。
parameters:
- { name: taskNo, in: path, required: true, schema: { type: string } }
- { name: palletNo, in: path, required: true, schema: { type: string } }
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/IdempotentAction' } } } }
responses: { '200': { description: 整托拣货后的任务 }, '404': { description: 当前任务没有该托盘 }, '409': { description: 非一件代发、库存不足或幂等冲突 } }
/api/pda/v1/last-leg/outbound-tasks/{taskNo}/complete:
post:
summary: 完成一件代发出库任务
@@ -0,0 +1,60 @@
package com.baiyue.lastleg.inboundplan.pda.gateway;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import org.springframework.web.server.ResponseStatusException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
class HttpDomesticLoadingEvidenceGatewayTest {
private HttpDomesticLoadingEvidenceGateway gateway;
private MockRestServiceServer server;
@BeforeEach
void setUp() {
RestClient.Builder builder = RestClient.builder();
server = MockRestServiceServer.bindTo(builder).build();
gateway = new HttpDomesticLoadingEvidenceGateway();
ReflectionTestUtils.setField(gateway, "client", builder.baseUrl("http://domestic-file").build());
ReflectionTestUtils.setField(gateway, "internalToken", "internal-token");
}
@Test
void downloadsImageFromDomesticFileService() {
server.expect(requestTo("http://domestic-file/internal/file/v1/loading-evidence/8/9/content"))
.andExpect(header("X-Internal-Service-Token", "internal-token"))
.andRespond(withSuccess("photo-bytes".getBytes(), MediaType.IMAGE_JPEG)
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=seal.jpg"));
DomesticLoadingEvidenceGateway.DownloadedFile result = gateway.download(8L, 9L);
assertThat(result.content()).containsExactly("photo-bytes".getBytes());
assertThat(result.fileName()).isEqualTo("seal.jpg");
assertThat(result.contentType()).isEqualTo(MediaType.IMAGE_JPEG_VALUE);
server.verify();
}
@Test
void rejectsBusinessErrorEnvelopeReturnedWithHttp200() {
server.expect(requestTo("http://domestic-file/internal/file/v1/loading-evidence/8/9/content"))
.andRespond(withSuccess("{\"code\":\"S0500\",\"message\":\"Internal server error\"}",
MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> gateway.download(8L, 9L))
.isInstanceOfSatisfying(ResponseStatusException.class, error -> {
assertThat(error.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
assertThat(error.getReason()).isEqualTo("国内装柜照片返回内容无效");
});
server.verify();
}
}
@@ -215,7 +215,7 @@ class InboundPlanServiceTest {
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(source));
when(orderMapper.findOrderType("100", 2L)).thenReturn("ONE_CLICK_DROPSHIP");
when(dropshipOrders.palletSnapshots("100", 2L)).thenReturn(List.of(
new DropshipPalletSnapshot("DP-260914-0001", 10L, List.of())));
new DropshipPalletSnapshot("DP-260914-0001", 33L, List.of())));
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convertManual(
new ManualInboundPlanRequest("100", 2L, 0,
@@ -233,7 +233,7 @@ class InboundPlanServiceTest {
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(source));
when(orderMapper.findOrderType("100", 2L)).thenReturn("ONE_CLICK_DROPSHIP");
when(dropshipOrders.palletSnapshots("100", 2L)).thenReturn(List.of(
new DropshipPalletSnapshot("DP-260914-0001", 10L, List.of())));
new DropshipPalletSnapshot("DP-260914-0001", 33L, List.of())));
when(orderMapper.markPlannedManual(eq("100"), eq(2L), any(), eq(1), eq(false), eq(0), any()))
.thenReturn(1);
@@ -246,6 +246,40 @@ class InboundPlanServiceTest {
verify(palletMapper).insert(any(InboundPlanPallet.class));
}
@Test
void oneClickDropshipManualPlanPreservesUnevenFrozenPalletPackageCounts() {
SourceOrderSnapshot source = new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-2",
"PENDING_ASSIGNMENT", null, new BigDecimal("3.3651"), 20, 3,
"193000000000200003", "US-LAX-01", "洛杉矶仓", null,
"193000000000200003", null, "洛杉矶仓", 1, 0, false, 0);
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(source));
when(orderMapper.findOrderType("100", 2L)).thenReturn("ONE_CLICK_DROPSHIP");
when(dropshipOrders.palletSnapshots("100", 2L)).thenReturn(List.of(
new DropshipPalletSnapshot("DP-260915-0092", 10L, List.of()),
new DropshipPalletSnapshot("DP-260915-0093", 3L, List.of()),
new DropshipPalletSnapshot("DP-260915-0094", 7L, List.of())));
when(orderMapper.markPlannedManual(eq("100"), eq(2L), any(), eq(3), eq(false), eq(0), any()))
.thenReturn(1);
var result = service.convertManual(new ManualInboundPlanRequest("100", 2L, 0,
3, "WOOD", false, false, false, null), "dropship-uneven-pallet-counts", "900");
ArgumentCaptor<InboundPlanPallet> pallets = ArgumentCaptor.forClass(InboundPlanPallet.class);
ArgumentCaptor<InboundPlanPalletAllocation> allocations =
ArgumentCaptor.forClass(InboundPlanPalletAllocation.class);
verify(palletMapper, times(3)).insert(pallets.capture());
verify(allocationMapper, times(3)).insert(allocations.capture());
assertEquals(List.of("DP-260915-0092", "DP-260915-0093", "DP-260915-0094"),
pallets.getAllValues().stream().map(InboundPlanPallet::getSourcePalletNo).toList());
assertEquals(List.of("WOOD", "WOOD", "WOOD"),
pallets.getAllValues().stream().map(InboundPlanPallet::getPalletType).toList());
assertEquals(List.of(10, 3, 7), allocations.getAllValues().stream()
.map(InboundPlanPalletAllocation::getPlannedPackageCount).toList());
assertEquals(new BigDecimal("3.3651"), allocations.getAllValues().stream()
.map(InboundPlanPalletAllocation::getAllocatedVolumeCbm).reduce(BigDecimal.ZERO, BigDecimal::add));
assertEquals(3, result.palletCount());
}
@Test
void containerConversionBindsRealWarehousePalletsBackToSignedManualPlanBeforeReceiving() {
InboundPlan plan = existingPlan("100", 2L, "IP-MANUAL");
@@ -38,6 +38,7 @@ class PdaOperationsOpenApiContractTest {
assertNoServerOwnedFields(DispatchPdaRequests.CompletePicking.class);
assertNoServerOwnedFields(DispatchPdaRequests.Depart.class);
assertNoServerOwnedFields(DeliveryRequests.PdaPick.class);
assertNoServerOwnedFields(DeliveryRequests.PdaPalletPick.class);
assertNoServerOwnedFields(DeliveryRequests.PdaComplete.class);
}
@@ -77,9 +78,10 @@ class PdaOperationsOpenApiContractTest {
"/api/pda/v1/last-leg/outbound-tasks/resolve/{businessNo}:",
"/api/pda/v1/last-leg/outbound-tasks/{taskNo}:",
"/api/pda/v1/last-leg/outbound-tasks/{taskNo}/pick:",
"/api/pda/v1/last-leg/outbound-tasks/{taskNo}/pallets/{palletNo}/pick:",
"/api/pda/v1/last-leg/outbound-tasks/{taskNo}/complete:");
for (String path : paths) assertTrue(yaml.contains(path), path);
assertEquals(33, paths.size());
assertEquals(34, paths.size());
assertTrue(yaml.contains("enum: [LOCATION, PALLET]"));
assertTrue(yaml.contains("enum: [APPROVE, REWORK]"));
assertTrue(yaml.contains("Warehouse 权威实物托盘码"));
@@ -49,6 +49,8 @@ class PdaOutboundDispatchContractTest {
.noneMatch(component -> "warehouseId".equals(component.getName()) || "customerId".equals(component.getName())));
assertTrue(Arrays.stream(DeliveryRequests.PdaPick.class.getRecordComponents())
.noneMatch(component -> "warehouseId".equals(component.getName()) || "customerId".equals(component.getName())));
assertTrue(Arrays.stream(DeliveryRequests.PdaPalletPick.class.getRecordComponents())
.noneMatch(component -> "warehouseId".equals(component.getName()) || "customerId".equals(component.getName())));
}
@Test
@@ -86,6 +88,7 @@ class PdaOutboundDispatchContractTest {
assertTrue(yaml.contains("/api/pda/v1/last-leg/dispatches:"));
assertTrue(yaml.contains("/api/pda/v1/last-leg/dispatches/{dispatchNo}/depart:"));
assertTrue(yaml.contains("/api/pda/v1/last-leg/outbound-tasks/{taskNo}/complete:"));
assertTrue(yaml.contains("/api/pda/v1/last-leg/outbound-tasks/{taskNo}/pallets/{palletNo}/pick:"));
assertFalse(yaml.contains("name: customerId"));
assertFalse(yaml.contains("name: warehouseId"));
assertFalse(yaml.contains("/api/pda/v1/last-leg/delivery"));
@@ -84,6 +84,8 @@ test('overseas compose is isolated from CN defaults and disables implicit schema
assert.doesNotMatch(compose, /overseas-security-service|9307|OVERSEAS_SECURITY_DB|OVERSEAS_SECURITY_SERVICE_URL|OVERSEAS_SECURITY_EVENT_CONSUMER_URL/)
assert.match(compose, /CN_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_TARGET_URL:-http:\/\/event-bridge-cn:9312\}/)
assert.match(compose, /FIRSTLEG_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_TARGET_URL:-http:\/\/event-bridge-cn:9312\}/)
assert.match(compose, /FILE_SERVICE_URL: \$\{FILE_SERVICE_URL\}/)
assert.doesNotMatch(compose, /DOMESTIC_FILE_SERVICE_URL/)
assert.doesNotMatch(compose, /CN_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_URL:/)
assert.match(compose, /aliases: \[event-bridge-overseas\]/)
})
@@ -114,11 +116,15 @@ test('CN and overseas event bridges have explicit opposite-region consumers', as
assert.match(firstleg, /LASTLEG_OUTBOX_RELAY_LEASE_SECONDS/)
assert.match(lastleg, /CN_EVENT_BRIDGE_URL:\$\{FIRSTLEG_EVENT_BRIDGE_URL\}/)
assert.match(lastleg, /WAREHOUSE_OVERSEAS_SERVICE_URL:http:\/\/warehouse-overseas-service:9305/)
assert.match(lastleg, /domestic-file:\s*\n\s*url: \$\{FILE_SERVICE_URL\}/)
assert.doesNotMatch(lastleg, /DOMESTIC_FILE_SERVICE_URL/)
assert.match(gatewayOverseas, /LASTLEG_SECURITY_SERVICE_URL:http:\/\/lastleg-service:9303/)
assert.match(overseas, /LASTLEG_SECURITY_EVENT_CONSUMER_URL:http:\/\/lastleg-service:9303\/internal\/lastleg\/security\/v1\/events/)
assert.doesNotMatch(`${gatewayOverseas}\n${overseas}`, /overseas-security-service|9307|OVERSEAS_SECURITY_/)
assert.match(scripts, /FIRSTLEG_EVENT_BRIDGE_URL/)
assert.match(scripts, /LASTLEG_EVENT_BRIDGE_URL/)
assert.match(scripts, /FILE_SERVICE_URL/)
assert.doesNotMatch(scripts, /DOMESTIC_FILE_SERVICE_URL/)
assert.match(scripts, /docker-compose\.overseas\.yml/)
assert.match(scripts, /REGION_CODE=CN 或 REGION_CODE=OVERSEAS/)
assert.match(scripts, /env_file_value REGION_CODE/)