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
@@ -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;}
}