feat: precreate dropship pallet codes after measurement

This commit is contained in:
2026-09-11 12:12:41 +08:00
parent 69076ea712
commit e61050c490
13 changed files with 108 additions and 198 deletions
@@ -93,15 +93,6 @@ public class PlatformDropshipPalletizationController {
return ApiResponse.success(service.bindPending(taskId, request, user.userId(), user.username()));
}
@PostMapping("/tasks/{taskId}/pallets/create-and-bind")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Task> createAndBind(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@Valid @RequestBody DropshipPalletizationDtos.CreateAndBindRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.createAndBind(taskId, request, user.userId(), user.username()));
}
@PostMapping("/tasks/{taskId}/cartons/{cartonId}/move")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Task> moveCarton(@AuthenticationPrincipal AuthenticatedUser user,
@@ -130,11 +121,12 @@ public class PlatformDropshipPalletizationController {
@RequestParam @Positive long warehouseId,
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
FirstlegScopeGuard.platform(user);
Pallet pallet = service.task(taskId, warehouseId, user.userId()).pallets().stream()
Task task = service.task(taskId, warehouseId, user.userId());
Pallet pallet = task.pallets().stream()
.filter(item -> item.palletId() == palletId).findFirst()
.orElseThrow(() -> new com.baiyue.common.core.exception.BizException(
com.baiyue.common.core.api.ResultCode.NOT_FOUND, "托盘不存在"));
return printService.generateDropshipPallet(pallet, requestId);
return printService.generateDropshipPallet(pallet, task.inboundCode(), requestId);
}
@GetMapping("/inbound-orders/{inboundId}/pallets")
@@ -158,6 +150,6 @@ public class PlatformDropshipPalletizationController {
.filter(item -> item.palletId() == palletId).findFirst()
.orElseThrow(() -> new com.baiyue.common.core.exception.BizException(
com.baiyue.common.core.api.ResultCode.NOT_FOUND, "托盘不存在"));
return printService.generateDropshipPallet(pallet, requestId);
return printService.generateDropshipPallet(pallet, service.inboundCode(accountId, inboundId), requestId);
}
}
@@ -15,9 +15,6 @@ public final class DropshipPalletizationDtos {
public record ClaimRequest(@Positive long warehouseId) {}
public record CreateAndBindRequest(@Positive long warehouseId,
@NotBlank @Size(max = 128) String operationId) {}
public enum DiscrepancyAction { MATCH, ACCEPT_VARIANCE }
public record CountCartonRequest(@Positive long warehouseId,
@@ -28,8 +28,14 @@ public final class InboundMeasurementRequests {
Long measurementId,
@NotNull @PositiveOrZero Integer version,
@Size(max = 255) String actualGoodsName,
@Positive @Max(1000) Integer expectedPalletCount,
@NotEmpty @Size(max = 500) List<@Valid ItemRequest> items
) {
public SaveDraftRequest(Long measurementId, Integer version, String actualGoodsName,
List<ItemRequest> items) {
this(measurementId, version, actualGoodsName, null, items);
}
@JsonAnySetter
public void rejectUnknownField(String fieldName, Object ignored) {
throw new IllegalArgumentException("测量草稿不接受字段: " + fieldName);
@@ -19,6 +19,8 @@ public final class InboundMeasurementResponses {
String companyName,
String channelCode,
String channelName,
String fulfillmentType,
Integer expectedPalletCount,
String processStatus,
String measureReviewStatus,
@Schema(description = "测量完成后的推荐托盘类型,仅为建议,不代表已分配托盘",
@@ -13,11 +13,6 @@ import java.util.List;
/** 一件代发逐箱清点、待绑托与封托事实。 */
@Mapper
public interface DropshipPalletizationMapper {
@Select("SELECT id FROM fl_dropship_pallet_task WHERE id=#{taskId} AND warehouse_id=#{warehouseId} FOR UPDATE")
Long lockTask(@Param("taskId") long taskId,@Param("warehouseId") long warehouseId);
@Select("SELECT * FROM fl_dropship_pallet WHERE open_operation_id=#{operationId}")
PalletRow palletByOpenOperation(@Param("operationId") String operationId);
@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)
@@ -69,14 +64,12 @@ public interface DropshipPalletizationMapper {
""")
PalletRow pallet(@Param("taskId") long taskId,@Param("palletId") long palletId,@Param("warehouseId") long warehouseId);
@Select("""
SELECT DISTINCT p.* FROM fl_dropship_pallet p
JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id
WHERE c.task_id=#{taskId} ORDER BY p.id
SELECT p.* FROM fl_dropship_pallet p
WHERE p.task_id=#{taskId} ORDER BY p.id
""") List<PalletRow> pallets(@Param("taskId") long taskId);
@Select("""
SELECT DISTINCT p.* FROM fl_dropship_pallet p
JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id
WHERE c.tenant_id=#{tenantId} AND c.inbound_id=#{inboundId} ORDER BY p.id
SELECT p.* FROM fl_dropship_pallet p
WHERE p.tenant_id=#{tenantId} AND p.inbound_id=#{inboundId} ORDER BY p.id
""")
List<PalletRow> palletsByInbound(@Param("tenantId") long tenantId,@Param("inboundId") long inboundId);
@Select("SELECT inbound_code FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0")
@@ -139,27 +132,19 @@ public interface DropshipPalletizationMapper {
completed_by_name=#{operatorName},completed_at=CURRENT_TIMESTAMP(3),version=version+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{palletId} AND warehouse_id=#{warehouseId} AND pallet_status='OPEN' AND version=#{expectedVersion}
AND EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.pallet_id=#{palletId})
AND EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.pallet_id=#{palletId} AND c.task_id=#{taskId})
AND NOT EXISTS(
SELECT 1 FROM fl_dropship_pallet_task t
WHERE EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.pallet_id=#{palletId} AND c.task_id=t.id)
AND (t.counted_cartons<>t.total_cartons OR t.allocated_cartons<>t.total_cartons
OR EXISTS(SELECT 1 FROM fl_dropship_pallet_carton pending WHERE pending.task_id=t.id AND pending.pallet_id IS NULL))
)""")
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(DISTINCT p.id) FROM fl_dropship_pallet p
JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id
WHERE c.task_id=#{taskId} AND p.pallet_status='OPEN'
SELECT COUNT(*) FROM fl_dropship_pallet p
WHERE p.task_id=#{taskId} AND p.pallet_status='OPEN'
""") int openPalletCount(@Param("taskId") long taskId);
@Select("SELECT DISTINCT task_id FROM fl_dropship_pallet_carton WHERE pallet_id=#{palletId} ORDER BY task_id")
List<Long> taskIdsByPallet(@Param("palletId") long palletId);
@Update("""
UPDATE fl_dropship_pallet_task SET task_status='COMPLETED',completed_at=CURRENT_TIMESTAMP(3),version=version+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{taskId} AND task_status='CLAIMED' AND counted_cartons=total_cartons AND allocated_cartons=total_cartons
AND NOT EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c JOIN fl_dropship_pallet p ON p.id=c.pallet_id
WHERE c.task_id=#{taskId} AND p.pallet_status='OPEN')""") int completeTask(@Param("taskId") long taskId);
AND NOT EXISTS(SELECT 1 FROM fl_dropship_pallet p WHERE p.task_id=#{taskId} AND p.pallet_status='OPEN')""") int completeTask(@Param("taskId") long taskId);
@Update("""
UPDATE fl_inbound_order o SET o.process_status='WAIT_SPLIT',o.pallet_count=(SELECT COUNT(DISTINCT p.id)
FROM fl_dropship_pallet_carton c JOIN fl_dropship_pallet p ON p.id=c.pallet_id
@@ -9,7 +9,6 @@ import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.*;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@@ -24,37 +23,16 @@ public class DropshipPalletizationService {
@Resource private FirstlegDocumentNumberGenerator documentNumbers;
@Transactional
public Task createAndBind(long taskId,CreateAndBindRequest request,long operatorId,String operatorName){
if(mapper.lockTask(taskId,request.warehouseId())==null) throw new BizException(ResultCode.NOT_FOUND,"未找到当前仓库的一件代发清点组托任务");
TaskRow task=requireTask(taskId,request.warehouseId());
if(!Objects.equals(task.getAssigneeId(),operatorId)) throw conflict("任务不属于当前操作员");
String operation=operation(request.operationId());
PalletRow replay=mapper.palletByOpenOperation(operation);
if(replay!=null){
if(!Objects.equals(replay.getTaskId(),taskId)||!Objects.equals(replay.getWarehouseId(),request.warehouseId())
||!Objects.equals(replay.getTenantId(),task.getTenantId())) throw conflict("创建托盘幂等号已用于不同任务");
return view(task);
}
if(!"CLAIMED".equals(task.getTaskStatus())||!"WAIT_PALLETIZE".equals(task.getProcessStatus())) throw conflict("当前任务不允许创建托盘");
if(!Objects.equals(task.getCountedCartons(),task.getTotalCartons())) throw conflict("请先完成全部箱码清点,再生成托盘码");
int expected=mapper.unboundCount(taskId);
if(expected==0) throw conflict("当前没有待绑托箱,请先清点箱码");
long palletId=IdWorker.getId();
String palletNo=documentNumbers.next("DP");
mapper.insertPallet(palletId,taskId,task.getTenantId(),task.getInboundId(),task.getWarehouseId(),palletNo,operation,operatorId,operatorName);
int bound=mapper.bindPending(taskId,palletId,operation,operatorId,operatorName);
if(bound!=expected||mapper.adjustBound(taskId,operatorId,bound)!=1) throw conflict("待绑托箱已变化,请刷新后重试");
if(mapper.seal(taskId,palletId,1,request.warehouseId(),operation,operatorId,operatorName)!=1)
throw conflict("托盘完成状态已变化,请刷新后重试");
if(mapper.completeTask(taskId)!=1 || mapper.completeInbound(taskId,task.getTenantId(),task.getInboundId(),operatorId)!=1)
throw conflict("一件代发入库完成状态已变化,请刷新后重试");
return view(requireTask(taskId,request.warehouseId()));
}
public void createTask(InboundOrderEntity inbound) {
public void createTask(InboundOrderEntity inbound,long operatorId,String operatorName) {
if (!"ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())) return;
if (inbound.getTotalPackageCount()==null || inbound.getTotalPackageCount()<1) throw invalid("一件代发测量完成箱数无效,不能下发清点组托任务");
mapper.insertTask(IdWorker.getId(),inbound.getTenantId(),inbound.getId(),inbound.getWarehouseId(),inbound.getTotalPackageCount());
if (inbound.getPalletCount()==null || inbound.getPalletCount()<1) throw invalid("一件代发预计托数无效,不能生成托盘码");
long taskId=IdWorker.getId();
if(mapper.insertTask(taskId,inbound.getTenantId(),inbound.getId(),inbound.getWarehouseId(),inbound.getTotalPackageCount())!=1) return;
for(int sequence=1;sequence<=inbound.getPalletCount();sequence++){
mapper.insertPallet(IdWorker.getId(),taskId,inbound.getTenantId(),inbound.getId(),inbound.getWarehouseId(),
documentNumbers.next("DP"),"dropship-precreate-"+taskId+"-"+sequence,operatorId,operatorName);
}
}
public List<PendingTask> pending(long operatorId,long warehouseId,String keyword){ return mapper.pending(operatorId,warehouseId,normalize(keyword)); }
@@ -104,8 +82,7 @@ public class DropshipPalletizationService {
if(!mismatch && request.discrepancyAction()!=DiscrepancyAction.MATCH) throw invalid("实收数量一致时差异动作必须为 MATCH");
if(mismatch && request.discrepancyAction()!=DiscrepancyAction.ACCEPT_VARIANCE) throw conflict("实收数量存在差异,请确认差异处理结果");
if(mismatch && !StringUtils.hasText(remark)) throw invalid("接受数量差异时必须填写处理说明");
if(StringUtils.hasText(request.palletNo())) throw invalid("逐箱清点阶段不能提前绑定托盘码");
PalletRow pallet=null;
PalletRow pallet=StringUtils.hasText(request.palletNo())?requireAssignablePallet(task,request.palletNo().trim()):null;
long cartonId=IdWorker.getId();
mapper.insertCarton(cartonId,taskId,pallet==null?null:pallet.getId(),task.getTenantId(),task.getInboundId(),
label.getInboundDetailId(),label.getCartonLabelId(),label.getSkuCode(),label.getCargoName(),label.getBoxCode(),
@@ -118,10 +95,9 @@ public class DropshipPalletizationService {
@Transactional
public Task bindPending(long taskId,BindPendingRequest request,long operatorId,String operatorName){
TaskRow task=requireWorkingTask(taskId,request.warehouseId(),operatorId);
if(!Objects.equals(task.getCountedCartons(),task.getTotalCartons())) throw conflict("请先完成全部箱码清点,再绑定托盘码");
String operation=operation(request.operationId());
if(mapper.bindOperationCount(taskId,operation)>0) return view(requireTask(taskId,request.warehouseId()));
PalletRow pallet=getOrCreatePallet(task,request.palletNo().trim(),operation,operatorId,operatorName);
PalletRow pallet=requireAssignablePallet(task,request.palletNo().trim());
int expected=mapper.unboundCount(taskId);
if(expected==0) throw conflict("当前没有待绑托箱");
int bound=mapper.bindPending(taskId,pallet.getId(),operation,operatorId,operatorName);
@@ -136,7 +112,7 @@ public class DropshipPalletizationService {
if(carton==null) throw new BizException(ResultCode.NOT_FOUND,"清点箱不存在");
if(carton.getPalletId()!=null) requireOpenPallet(taskId,carton.getPalletId(),request.warehouseId());
PalletRow target=StringUtils.hasText(request.targetPalletNo())
? getOrCreatePallet(task,request.targetPalletNo().trim(),operation(request.operationId()),operatorId,operatorName):null;
? requireAssignablePallet(task,request.targetPalletNo().trim()):null;
if(Objects.equals(carton.getPalletId(),target==null?null:target.getId())) return view(task);
if(mapper.moveCarton(taskId,cartonId,target==null?null:target.getId(),operation(request.operationId()),operatorId,operatorName)!=1)
throw conflict("清点箱已被其他操作修改,请刷新后重试");
@@ -151,7 +127,6 @@ public class DropshipPalletizationService {
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())); }
requireOpenPallet(taskId,palletId,request.warehouseId());
if(mapper.unboundCount(taskId)>0) throw conflict("仍有已清点待绑托箱,不能封托");
if(mapper.seal(taskId,palletId,request.expectedVersion(),request.warehouseId(),operation(request.operationId()),operatorId,operatorName)!=1)
throw conflict("空托盘、托盘状态或版本已变化,请刷新后重试");
for(Long participatingTaskId:mapper.taskIdsByPallet(palletId)){
@@ -170,18 +145,17 @@ public class DropshipPalletizationService {
if(code==null) throw new BizException(ResultCode.NOT_FOUND,"入库单不存在");
return mapper.palletsByInbound(tenantId,inboundId).stream().map(p->palletView(p,code)).toList();
}
public String inboundCode(long tenantId,long inboundId){
String code=mapper.inboundCode(tenantId,inboundId);
if(code==null) throw new BizException(ResultCode.NOT_FOUND,"入库单不存在");
return code;
}
private PalletRow getOrCreatePallet(TaskRow task,String palletNo,String operation,long operatorId,String operatorName){
private PalletRow requireAssignablePallet(TaskRow task,String palletNo){
PalletRow existing=mapper.palletByNo(palletNo);
if(existing!=null){
if(!Objects.equals(existing.getWarehouseId(),task.getWarehouseId())) throw conflict("托盘码属于其他仓库");
if(!"OPEN".equals(existing.getPalletStatus())) throw conflict("托盘已封托,不能继续绑定箱");
return existing;
}
try{ mapper.insertPallet(IdWorker.getId(),task.getId(),task.getTenantId(),task.getInboundId(),task.getWarehouseId(),palletNo,operation,operatorId,operatorName); }
catch(DuplicateKeyException e){ existing=mapper.palletByNo(palletNo); if(existing==null) throw conflict("托盘码已被并发占用"); }
existing=mapper.palletByNo(palletNo);
if(existing==null || !Objects.equals(existing.getWarehouseId(),task.getWarehouseId()) || !"OPEN".equals(existing.getPalletStatus())) throw conflict("托盘码已被其他仓库占用或已经封托");
if(existing==null) throw new BizException(ResultCode.NOT_FOUND,"托盘码不是本单测量审核后生成的托盘码");
if(!Objects.equals(existing.getTaskId(),task.getId())||!Objects.equals(existing.getWarehouseId(),task.getWarehouseId())) throw conflict("托盘码不属于当前任务");
if(!"OPEN".equals(existing.getPalletStatus())) throw conflict("托盘已封托,不能继续绑定箱");
return existing;
}
@@ -114,18 +114,19 @@ public class InboundCartonLabelPrintService {
}
/** 一件代发托盘标签:托盘号、入仓码、SKU箱数和托盘二维码。 */
public ResponseEntity<byte[]> generateDropshipPallet(Pallet pallet, String requestId) {
if (pallet == null || !"SEALED".equals(pallet.status())
|| !StringUtils.hasText(pallet.palletNo()) || pallet.items().isEmpty()) {
throw new BizException(ResultCode.CONFLICT, "只有已完成且包含货物的一件代发托盘可以打印");
public ResponseEntity<byte[]> generateDropshipPallet(Pallet pallet, String inboundCode, String requestId) {
if (pallet == null || !StringUtils.hasText(pallet.palletNo())) {
throw new BizException(ResultCode.CONFLICT, "托盘码尚未生成,不能打印");
}
String inboundCodes = pallet.items().stream().map(item -> item.inboundCode())
.filter(StringUtils::hasText).distinct().collect(java.util.stream.Collectors.joining("/"));
if (!StringUtils.hasText(inboundCodes)) inboundCodes = inboundCode;
String skuSummary = pallet.items().stream()
.map(item -> item.skuCode() + "×" + item.cartonCount())
.collect(java.util.stream.Collectors.joining(" / "));
if (!StringUtils.hasText(skuSummary)) skuSummary = "待归托";
PrintPdfClient.Label label = new PrintPdfClient.Label(pallet.palletNo(), inboundCodes,
"CTNS:" + pallet.cartonCount(), skuSummary, pallet.palletNo(), null);
pallet.cartonCount() == 0 ? "CTNS:—" : "CTNS:" + pallet.cartonCount(), skuSummary, pallet.palletNo(), null);
PrintPdfClient.InboundMarkPdfRequest request = new PrintPdfClient.InboundMarkPdfRequest(
"INBOUND_MARK_V1", new PrintPdfClient.PageSpec(PAGE_MM, PAGE_MM),
pallet.palletNo() + "-托盘唛.pdf", List.of(label));
@@ -131,6 +131,10 @@ public class InboundMeasurementService {
throw new BizException(ResultCode.CONFLICT,
"测量已提交待审核,不能再保存整批测量数据;如需修改请先退回后重新提交");
}
if ("ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())
&& (request.expectedPalletCount() == null || request.expectedPalletCount() < 1)) {
throw new BizException(ResultCode.VALIDATION_FAILED, "一件代发必须录入预计托数");
}
validateInboundDetails(tenantId, inbound, request.items());
request.items().stream().map(InboundMeasurementRequests.ItemRequest::chargeRuleId)
.filter(java.util.Objects::nonNull).distinct().forEach(ruleId -> {
@@ -162,6 +166,11 @@ public class InboundMeasurementService {
item.overLimitWarning(), item.outerLengthCm(), item.outerWidthCm(), item.outerHeightCm(),
item.chargeRuleId(), item.coefficientMode(), item.remark())).toList());
InboundMeasurementEntity saved = batchService.saveBatch(command);
if ("ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())) {
inbound.setPalletCount(request.expectedPalletCount());
inbound.setUpdatedBy(operatorId);
if (!repository.updateInbound(inbound)) throw concurrentModification();
}
return new InboundMeasurementResponses.DraftSaved(saved.getId(), saved.getVersion(), saved.getStatus());
}
@@ -261,7 +270,13 @@ public class InboundMeasurementService {
inbound.setMeasureCompletedAt(LocalDateTime.now());
inbound.setActualGrossWeightKg(confirmedTotals.grossWeightKg());
inbound.setActualNetWeightKg(confirmedTotals.netWeightKg());
inbound.setPalletCount(confirmedTotals.palletCount());
if ("ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())) {
if (inbound.getPalletCount() == null || inbound.getPalletCount() < 1) {
throw new BizException(ResultCode.VALIDATION_FAILED, "请先录入预计托数并保存,再提交测量审核");
}
} else {
inbound.setPalletCount(confirmedTotals.palletCount());
}
if (measurement.getTotalVolumeCbm() != null) {
inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm());
}
@@ -547,7 +562,8 @@ public class InboundMeasurementService {
InboundMeasurementResponses.DeclarationSnapshot declaration = latestDeclaration.snapshot();
return new InboundMeasurementResponses.Detail(
inbound.getId(), inbound.getInboundCode(), inbound.getCompanyName(), inbound.getChannelCode(),
inbound.getChannelName(), inbound.getProcessStatus(), inbound.getMeasureReviewStatus(),
inbound.getChannelName(), inbound.getFulfillmentType(), inbound.getPalletCount(),
inbound.getProcessStatus(), inbound.getMeasureReviewStatus(),
RECOMMENDED_PALLET_TYPE,
review == null ? null : review.getReviewReason(), review == null ? null : review.getReviewerId(),
review == null ? null : review.getReviewerName(), measurement == null ? null : measurement.getId(),
@@ -139,7 +139,7 @@ public class MeasurementReviewService {
repository.insertReview(reviewRecord(tenantId, inboundId, request.measurementId(), result,
request.reason(), false, null, false, operatorId, operatorName));
if (approved && "ONE_CLICK_DROPSHIP".equals(order.getFulfillmentType())) {
dropshipPalletization.createTask(order);
dropshipPalletization.createTask(order, operatorId, operatorName);
}
String approvedStatus = "ONE_CLICK_DROPSHIP".equals(order.getFulfillmentType())
? "WAIT_PALLETIZE" : "WAIT_SPLIT";
@@ -179,7 +179,7 @@ public class MeasurementReviewService {
throw concurrentConflict();
}
if ("ONE_CLICK_DROPSHIP".equals(order.getFulfillmentType())) {
dropshipPalletization.createTask(order);
dropshipPalletization.createTask(order, operatorId, operatorName);
}
repository.insertReview(reviewRecord(tenantId, inboundId, request.measurementId(), "REJECTED",
null, true, request.reason(), true, operatorId, operatorName));
@@ -1,8 +1,8 @@
openapi: 3.0.3
info:
title: 一件代发国内清点组托 API
version: 2.0.0
description: 测量、计费后独立下发。先逐箱确认 SKU 实收数量;全部箱码清点完成后才原子生成、绑定并封存正式 DP 托盘码,随后可打印托盘标签。
version: 3.0.0
description: 测量环节录入预计托数,审核通过后预生成全部 DP 托盘码并可立即打印。PDA 支持先扫箱或先扫托,逐托封托后完成入库。
paths:
/api/platform/v1/first-leg/pda-dropship/tasks/pending:
get:
@@ -35,7 +35,7 @@ paths:
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/cartons/count:
post:
summary: 逐箱确认 SKU 实收数量
description: 清点阶段只记录箱级实收并进入 COUNTED_UNBOUND,不得提前生成或绑定托盘码。数量不一致必须 ACCEPT_VARIANCE 且填写说明。
description: 未传 palletNo 时进入待归托区;传入本任务预生成的 OPEN 托盘码时直接归托。数量不一致必须 ACCEPT_VARIANCE 且填写说明。
x-required-authority: platform:firstleg:pda:work
parameters: [{$ref: '#/components/parameters/TaskId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CountCartonRequest'}}}}
@@ -47,14 +47,6 @@ paths:
parameters: [{$ref: '#/components/parameters/TaskId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/BindPendingRequest'}}}}
responses: {'200': {description: OK}}
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/create-and-bind:
post:
summary: 完成一件代发入库并生成正式 DP 托盘码
description: 仅全部箱码已经清点的本人任务可执行;同一事务内生成 DP 托盘码、绑定全部箱、封托并把入仓单推进到待分柜。未完成清点或没有待绑箱时不发码、不建空托。operationId 重放不重复创建;返回已完成 Task。
x-required-authority: platform:firstleg:pda:work
parameters: [{$ref: '#/components/parameters/TaskId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CreateAndBindRequest'}}}}
responses: {'200': {description: Task}}
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/cartons/{cartonId}/move:
post:
summary: 换托,targetPalletNo 为空时退回待绑区
@@ -66,7 +58,7 @@ paths:
responses: {'200': {description: OK}}
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/{palletId}/seal:
post:
summary: 封托;禁止空托、待绑箱、版本冲突和重复归托
summary: 封托;禁止空托、版本冲突和重复归托
x-required-authority: platform:firstleg:pda:work
parameters:
- {$ref: '#/components/parameters/TaskId'}
@@ -75,8 +67,8 @@ paths:
responses: {'200': {description: OK}}
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/{palletId}/print:
get:
summary: PDA 打印已封托的一件代发托盘码
description: 仅当前仓库、当前操作员可访问;未封托托盘拒绝打印。返回 100mm × 100mm PDF。
summary: PDA 打印预生成的一件代发托盘码
description: 测量审核通过并生成托盘码后即可打印,空托打印“待归托”。仅当前仓库、当前操作员可访问。返回 100mm × 100mm PDF。
x-required-authority: platform:firstleg:pda:view
parameters:
- {$ref: '#/components/parameters/TaskId'}
@@ -143,6 +135,7 @@ components:
properties:
warehouseId: {type: integer, format: int64}
boxCode: {type: string, maxLength: 80}
palletNo: {$ref: '#/components/schemas/NullablePalletNo'}
actualSkuQuantity: {type: integer, minimum: 1}
discrepancyAction: {type: string, enum: [MATCH, ACCEPT_VARIANCE]}
discrepancyRemark: {type: string, maxLength: 500, nullable: true}
@@ -169,10 +162,4 @@ components:
expectedVersion: {type: integer, minimum: 1}
operationId: {type: string, maxLength: 128}
PalletNo: {type: string, pattern: '^DP-[0-9]{6}-[0-9]{4}$'}
CreateAndBindRequest:
type: object
required: [warehouseId, operationId]
properties:
warehouseId: {type: integer, format: int64, minimum: 1}
operationId: {type: string, minLength: 1, maxLength: 128}
NullablePalletNo: {type: string, pattern: '^DP-[0-9]{6}-[0-9]{4}$', nullable: true}
@@ -1605,6 +1605,8 @@ components:
lxCode: {type: string, nullable: true, description: 一件代发线路或履约产品编码快照}
channelCode: {type: string, nullable: true}
channelName: {type: string, nullable: true}
fulfillmentType: {type: string, nullable: true, enum: [STANDARD, ONE_CLICK_DROPSHIP]}
expectedPalletCount: {type: integer, minimum: 1, maximum: 1000, nullable: true, description: 一件代发预计托数;测量审核通过后按此数量预生成 DP 托盘码}
taxMode: {type: string, nullable: true}
pricingTaxMode: {type: string, nullable: true}
containerMode: {type: string, nullable: true}
@@ -2000,6 +2002,7 @@ components:
measurementId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
version: {type: integer, minimum: 0}
actualGoodsName: {type: string, maxLength: 255, nullable: true}
expectedPalletCount: {type: integer, minimum: 1, maximum: 1000, nullable: true, description: 一件代发必填;普通入库可不传}
items:
type: array
minItems: 1
@@ -3,9 +3,10 @@ package com.baiyue.firstleg.service;
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.entity.InboundOrderEntity;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.CartonRow;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.LabelRow;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.CartonRow;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.PalletRow;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.TaskRow;
import org.junit.jupiter.api.Test;
@@ -19,94 +20,45 @@ class DropshipPalletizationServiceTest {
private final FirstlegDocumentNumberGenerator numbers=mock(FirstlegDocumentNumberGenerator.class);
private final DropshipPalletizationMapper mapper=mock(DropshipPalletizationMapper.class);
private final DropshipPalletizationService service=new DropshipPalletizationService();
DropshipPalletizationServiceTest(){ ReflectionTestUtils.setField(service,"mapper",mapper); ReflectionTestUtils.setField(service,"documentNumbers",numbers); }
@Test void generatesBindsSealsAndCompletesOnlyAfterEveryCartonIsCounted(){
when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(readyToComplete());
when(mapper.unboundCount(10L)).thenReturn(2); when(numbers.next("DP")).thenReturn("DP-260909-0001");
when(mapper.bindPending(eq(10L),anyLong(),eq("create-1"),eq(7L),eq("张三"))).thenReturn(2);
when(mapper.adjustBound(10L,7L,2)).thenReturn(1);
when(mapper.seal(eq(10L),anyLong(),eq(1),eq(100L),eq("create-1"),eq(7L),eq("张三"))).thenReturn(1);
when(mapper.completeTask(10L)).thenReturn(1); when(mapper.completeInbound(10L,1L,30L,7L)).thenReturn(1);
service.createAndBind(10L,new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"create-1"),7L,"张三");
verify(mapper).insertPallet(anyLong(),eq(10L),eq(1L),eq(30L),eq(100L),eq("DP-260909-0001"),eq("create-1"),eq(7L),eq("张三"));
verify(numbers,times(1)).next("DP");
@Test void reviewCreatesExpectedNumberOfPrintablePalletCodes(){
InboundOrderEntity inbound=dropshipInbound(); when(mapper.insertTask(anyLong(),eq(1L),eq(30L),eq(100L),eq(8))).thenReturn(1);
when(numbers.next("DP")).thenReturn("DP-260911-0001","DP-260911-0002");
service.createTask(inbound,7L,"张三");
verify(mapper,times(2)).insertPallet(anyLong(),anyLong(),eq(1L),eq(30L),eq(100L),startsWith("DP-"),startsWith("dropship-precreate-"),eq(7L),eq("张三"));
}
@Test void incompleteCountingNeverAllocatesAPalletNumber(){
when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(task());
assertThatThrownBy(()->service.createAndBind(10L,new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"early"),7L,"张三"))
.hasMessageContaining("完成全部箱码清点");
verifyNoInteractions(numbers);
}
@Test void noPendingCartonsNeverAllocatesNumberOrEmptyPallet(){
when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(readyToComplete());
assertThatThrownBy(()->service.createAndBind(10L,new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"empty"),7L,"张三")).hasMessageContaining("没有待绑托箱");
verifyNoInteractions(numbers);
verify(mapper,never()).insertPallet(anyLong(),anyLong(),anyLong(),anyLong(),anyLong(),anyString(),anyString(),anyLong(),anyString());
}
@Test void replaysAfterCompletionWithoutGeneratingAnotherPallet(){
TaskRow completed=task(); completed.setTaskStatus("COMPLETED"); completed.setProcessStatus("WAIT_SPLIT");
when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(completed);
when(mapper.palletByOpenOperation("replay")).thenReturn(pallet());
service.createAndBind(10L,new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"replay"),7L,"张三");
verifyNoInteractions(numbers); verify(mapper,never()).unboundCount(anyLong());
}
@Test void rejectsOtherOperatorAndCrossTaskReplay(){
when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(readyToComplete());
var request=new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"replay");
assertThatThrownBy(()->service.createAndBind(10L,request,8L,"李四")).hasMessageContaining("当前操作员");
PalletRow other=pallet(); other.setTaskId(11L); when(mapper.palletByOpenOperation("replay")).thenReturn(other);
assertThatThrownBy(()->service.createAndBind(10L,request,7L,"张三")).hasMessageContaining("不同任务");
verifyNoInteractions(numbers);
}
@Test void rejectsUnknownWarehouseAndChangedBindCount(){
var request=new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"new");
assertThatThrownBy(()->service.createAndBind(10L,request,7L,"张三")).hasMessageContaining("当前仓库");
when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(readyToComplete());
when(mapper.unboundCount(10L)).thenReturn(2); when(numbers.next("DP")).thenReturn("DP-260909-0002");
assertThatThrownBy(()->service.createAndBind(10L,request,7L,"张三")).hasMessageContaining("待绑托箱已变化");
verify(mapper,never()).adjustBound(anyLong(),anyLong(),anyInt());
@Test void replayedReviewDoesNotGenerateDuplicatePalletCodes(){
when(mapper.insertTask(anyLong(),anyLong(),anyLong(),anyLong(),anyInt())).thenReturn(0);
service.createTask(dropshipInbound(),7L,"张三");
verifyNoInteractions(numbers); verify(mapper,never()).insertPallet(anyLong(),anyLong(),anyLong(),anyLong(),anyLong(),anyString(),anyString(),anyLong(),anyString());
}
@Test void resolvesUncountedCartonBeforeQuantityConfirmation(){
TaskRow task=task(); LabelRow label=label();
when(mapper.task(10L,100L)).thenReturn(task); when(mapper.label(10L,"BOX-1")).thenReturn(label);
when(mapper.task(10L,100L)).thenReturn(task()); when(mapper.label(10L,"BOX-1")).thenReturn(label());
var result=service.resolveCarton(10L,100L,"BOX-1",7L);
assertThat(result.inboundCode()).isEqualTo("IN-1");
assertThat(result.skuCode()).isEqualTo("SKU-1");
assertThat(result.expectedSkuQuantity()).isEqualTo(12);
assertThat(result.countStatus()).isEqualTo("UNCOUNTED");
assertThat(result.canCount()).isTrue();
assertThat(result.inboundCode()).isEqualTo("IN-1"); assertThat(result.expectedSkuQuantity()).isEqualTo(12); assertThat(result.canCount()).isTrue();
}
@Test void mismatchCannotBeRecordedWithoutAcceptedVarianceAndRemark(){
@Test void cartonCanBindDirectlyToAPreGeneratedPallet(){
when(mapper.task(10L,100L)).thenReturn(task()); when(mapper.label(10L,"BOX-1")).thenReturn(label()); when(mapper.palletByNo("DP-260911-0001")).thenReturn(pallet());
when(mapper.addCounted(10L,7L,1,0)).thenReturn(1); when(mapper.carton(eq(10L),anyLong())).thenReturn(carton());
when(mapper.pallet(10L,20L,100L)).thenReturn(pallet()); when(mapper.cartons(20L)).thenReturn(java.util.List.of());
service.countCarton(10L,new CountCartonRequest(100L,"BOX-1",12,DiscrepancyAction.MATCH,null,"DP-260911-0001","op-1"),7L,"张三");
verify(mapper).insertCarton(anyLong(),eq(10L),eq(20L),eq(1L),eq(30L),eq(40L),eq(50L),eq("SKU-1"),any(),eq("BOX-1"),eq(12),eq(12),eq("MATCH"),isNull(),eq("op-1"),eq(7L),eq("张三"));
}
@Test void unknownPalletCodeCannotCreateAnAdHocPallet(){
when(mapper.task(10L,100L)).thenReturn(task()); when(mapper.label(10L,"BOX-1")).thenReturn(label());
var wrongAction=new CountCartonRequest(100L,"BOX-1",11,DiscrepancyAction.MATCH,null,null,"op-1");
assertThatThrownBy(()->service.countCarton(10L,wrongAction,7L,"张三"))
.isInstanceOf(BizException.class).hasMessageContaining("确认差异处理结果");
var noRemark=new CountCartonRequest(100L,"BOX-1",11,DiscrepancyAction.ACCEPT_VARIANCE,null,null,"op-2");
assertThatThrownBy(()->service.countCarton(10L,noRemark,7L,"张三"))
.isInstanceOf(BizException.class).hasMessageContaining("处理说明");
verify(mapper,never()).insertCarton(anyLong(),anyLong(),any(),anyLong(),anyLong(),anyLong(),anyLong(),any(),any(),any(),anyInt(),anyInt(),any(),any(),any(),anyLong(),any());
var request=new CountCartonRequest(100L,"BOX-1",12,DiscrepancyAction.MATCH,null,"DP-260911-9999","op-1");
assertThatThrownBy(()->service.countCarton(10L,request,7L,"张三")).isInstanceOf(BizException.class).hasMessageContaining("测量审核后生成");
verify(mapper,never()).insertPallet(anyLong(),anyLong(),anyLong(),anyLong(),anyLong(),anyString(),anyString(),anyLong(),anyString());
}
@Test void countingCannotExposeOrBindAPalletNumberEarly(){
when(mapper.task(10L,100L)).thenReturn(task()); when(mapper.label(10L,"BOX-1")).thenReturn(label());
var request=new CountCartonRequest(100L,"BOX-1",12,DiscrepancyAction.MATCH,null,"DP-260908-0001","op-1");
assertThatThrownBy(()->service.countCarton(10L,request,7L,"张三"))
.hasMessageContaining("不能提前绑定托盘码");
verify(mapper,never()).insertCarton(anyLong(),anyLong(),any(),anyLong(),anyLong(),anyLong(),anyLong(),any(),any(),any(),anyInt(),anyInt(),any(),any(),any(),anyLong(),any());
}
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.setVersion(1);return t; }
private static TaskRow readyToComplete(){ TaskRow t=task();t.setCountedCartons(2);return t; }
private static InboundOrderEntity dropshipInbound(){InboundOrderEntity i=new InboundOrderEntity();i.setId(30L);i.setTenantId(1L);i.setWarehouseId(100L);i.setFulfillmentType("ONE_CLICK_DROPSHIP");i.setTotalPackageCount(8);i.setPalletCount(2);return i;}
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.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 PalletRow pallet(){PalletRow p=new PalletRow();p.setId(20L);p.setTaskId(10L);p.setTenantId(1L);p.setInboundId(30L);p.setWarehouseId(100L);p.setPalletNo("DP-260908-0001");p.setPalletStatus("OPEN");p.setVersion(1);return p;}
private static CartonRow carton(){CartonRow c=new CartonRow();c.setId(60L);c.setTaskId(10L);c.setPalletId(20L);c.setPalletNo("DP-260908-0001");c.setInboundId(30L);c.setInboundDetailId(40L);c.setSkuCode("SKU-1");c.setCargoName("货物");c.setBoxCode("BOX-1");c.setExpectedSkuQuantity(12);c.setActualSkuQuantity(12);c.setDiscrepancyStatus("MATCH");return c;}
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 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;}
}
@@ -38,21 +38,16 @@ class InboundCartonLabelPrintServiceTest {
}
@Test
void dropshipPalletLabelIsAvailableOnlyAfterSeal() {
void dropshipPalletLabelIsAvailableBeforeSeal() {
Pallet open = dropshipPallet("OPEN");
assertThatThrownBy(() -> service.generateDropshipPallet(open, "request-open"))
.isInstanceOf(BizException.class)
.hasMessageContaining("已完成");
verifyNoInteractions(print);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_PDF);
when(print.generateInboundMarks(org.mockito.ArgumentMatchers.anyString(),
org.mockito.ArgumentMatchers.eq("request-sealed"), org.mockito.ArgumentMatchers.any()))
org.mockito.ArgumentMatchers.eq("request-open"), org.mockito.ArgumentMatchers.any()))
.thenReturn(new ResponseEntity<>(new byte[] {7, 8, 9}, headers,
org.springframework.http.HttpStatus.OK));
ResponseEntity<byte[]> response = service.generateDropshipPallet(dropshipPallet("SEALED"), "request-sealed");
ResponseEntity<byte[]> response = service.generateDropshipPallet(open, "PCHK2608040174", "request-open");
assertThat(response.getBody()).containsExactly(7, 8, 9);
}