修复海外散货托盘收货与短少入库闭环
This commit is contained in:
@@ -35,8 +35,8 @@
|
|||||||
| 入仓计划分页、柜级预览、转换与详情 | 平台 `inboundplan/controller/InboundPlanController.java`,客户只读 `inboundplan/controller/MerchantInboundPlanController.java`;共用 `inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`ContainerInboundPlanPreviewResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java`;客户入口固定 `/api/merchant/v1/last-leg/inbound-plans`,范围只取 Token。`/container-preview` 按全柜返回 FBA/一件代发/私人地址分组、托数、就绪、阻断项及 `snapshotVersion/snapshotHash`;`ContainerInboundPlanConversionCoordinator.java` 用唯一柜级 Saga 锁柜重算,先经 `WarehouseResourceGateway` 批量预留真实托盘,再本地原子生成剩余独立计划并确认资源,本地失败只补偿未确认资源,远端确认失败的重放只重试确认,同柜不同幂等键也不得产生第二批资源 |
|
| 入仓计划分页、柜级预览、转换与详情 | 平台 `inboundplan/controller/InboundPlanController.java`,客户只读 `inboundplan/controller/MerchantInboundPlanController.java`;共用 `inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`ContainerInboundPlanPreviewResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java`;客户入口固定 `/api/merchant/v1/last-leg/inbound-plans`,范围只取 Token。`/container-preview` 按全柜返回 FBA/一件代发/私人地址分组、托数、就绪、阻断项及 `snapshotVersion/snapshotHash`;`ContainerInboundPlanConversionCoordinator.java` 用唯一柜级 Saga 锁柜重算,先经 `WarehouseResourceGateway` 批量预留真实托盘,再本地原子生成剩余独立计划并确认资源,本地失败只补偿未确认资源,远端确认失败的重放只重试确认,同柜不同幂等键也不得产生第二批资源 |
|
||||||
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
|
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
|
||||||
| FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` |
|
| 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 入仓、实收、上架与完成 | `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` 按国内冻结来源托盘整托收货。普通 OVERSEAS 允许在本票已预留托盘间按现场实际分配箱数,逐托建议箱数不作为收货上限,仍校验本票总箱数、单托66箱、目的仓及客户归属;库存按已扫箱码的SKU和真实托盘归属入账。合托只作现场可选提示;全部计划完整绑定真实 pallet/location/lock 前不得查看、准备或签收该柜,托盘校验和提交均只确认本票已预留绑定。Admin 入库单详情可按行打印已预留真实托盘标签,复用海外托盘列表权限和标签PDF接口,不生成第二个托盘码 |
|
||||||
| FBA收货短少确认 | `inboundplan/controller/InboundPlanController.java` 的 `/{planNo}/confirm-shortage` → `InboundPdaWorkflowService.confirmShortage`;仅平台入库编辑权限指定客户操作,要求待差异核对、原版本及应收/实收快照、原因和幂等键。仅同柜次同目的仓收货组单票且冻结申报单SKU可按总数确认,多SKU及跨票自动分摊拒绝;逐SKU实收不得超原申报,确认后保留应收与现场结束时间,按实收生成库存和原计租批次,计划/PDA任务进入 `PARTIAL_INBOUND`,尾程订单完成入库;同事务交接原预排,明确零实收的托盘同步移除预排明细、释放本车占用及预约分配并重算整车货量;缺少零实收证据或已有拣货拒绝。不重复发布卸柜事件。无实收、超收或逐SKU事实不一致拒绝;空预留托盘不能再次作为计划货源排车。 |
|
| 普通散货收货短少确认 | `inboundplan/controller/InboundPlanController.java` 的 `/{planNo}/confirm-shortage` → `InboundPdaWorkflowService.confirmShortage`;仅平台入库编辑权限指定客户操作,要求待差异核对、原版本及应收/实收快照、原因和幂等键。FBA仅同柜次同目的仓收货组单票且冻结申报单SKU可按总数确认,多SKU及跨票自动分摊拒绝;普通OVERSEAS按已扫箱码精确核对SKU与逐托实收,可处理多SKU短少,一件代发整托货物拒绝此操作。逐SKU实收不得超原申报,确认后保留应收与现场结束时间,按实收生成库存和原计租批次,OVERSEAS已收箱转AVAILABLE、未收箱保留原状态,计划/PDA任务进入 `PARTIAL_INBOUND`,尾程订单完成入库;同事务交接原预排,明确零实收的托盘同步移除预排明细、释放本车占用及预约分配并重算整车货量;缺少零实收证据或已有拣货拒绝。不重复发布卸柜事件。无实收、超收或逐SKU事实不一致拒绝;空预留托盘不能再次作为计划货源排车。 |
|
||||||
| 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/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 会话鉴权并复用 `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` |
|
| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` |
|
||||||
|
|||||||
+38
-25
@@ -402,10 +402,8 @@ public class InboundPdaWorkflowService {
|
|||||||
allocation.setLineId(line.getId()); allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(ZERO_VOLUME);
|
allocation.setLineId(line.getId()); allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(ZERO_VOLUME);
|
||||||
allocation.setPlannedPackageCount(0); allocation.setCountedPackageCount(0); allocation.setPickedPackageCount(0);
|
allocation.setPlannedPackageCount(0); allocation.setCountedPackageCount(0); allocation.setPickedPackageCount(0);
|
||||||
allocation.setActualVolumeCbm(ZERO_VOLUME); allocation.setStatus("PICKING"); allocation.setVersion(0); repository.insert(allocation);
|
allocation.setActualVolumeCbm(ZERO_VOLUME); allocation.setStatus("PICKING"); allocation.setVersion(0); repository.insert(allocation);
|
||||||
} else if (value(allocation.getPlannedPackageCount()) > 0
|
|
||||||
&& value(allocation.getPickedPackageCount()) + requestedCount > value(allocation.getPlannedPackageCount())) {
|
|
||||||
throw conflict("Requested carton quantity exceeds the recommended pallet package count");
|
|
||||||
}
|
}
|
||||||
|
// 普通海外仓货物按现场实际打托;本票总箱数在上方核对,单托容量由收货入口校验。
|
||||||
BigDecimal perPackage = perPackageVolume(line);
|
BigDecimal perPackage = perPackageVolume(line);
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
for (InboundPlanCarton item : cartonsToScan) {
|
for (InboundPlanCarton item : cartonsToScan) {
|
||||||
@@ -716,7 +714,7 @@ public class InboundPdaWorkflowService {
|
|||||||
repository.update(plan);
|
repository.update(plan);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 平台确认 FBA 短少后将真实实收入库;原应收、现场结束时间和短少证据保留。 */
|
/** 平台确认普通散货短少后按真实实收入库,保留原应收、现场时间及箱码证据。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public PdaResponses.Task confirmShortage(String customerId, String planNo, String operatorId,
|
public PdaResponses.Task confirmShortage(String customerId, String planNo, String operatorId,
|
||||||
String idempotencyKey, com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest request) {
|
String idempotencyKey, com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest request) {
|
||||||
@@ -738,14 +736,16 @@ public class InboundPdaWorkflowService {
|
|||||||
throw conflict("仅待差异核对的入库单可确认短少,请刷新后重试");
|
throw conflict("仅待差异核对的入库单可确认短少,请刷新后重试");
|
||||||
}
|
}
|
||||||
if (!Objects.equals(plan.getVersion(), request.version())) throw conflict("入库单已变化,请刷新后重新确认短少");
|
if (!Objects.equals(plan.getVersion(), request.version())) throw conflict("入库单已变化,请刷新后重新确认短少");
|
||||||
if (mode(plan) != InboundMode.FBA) throw conflict("当前短少确认仅适用于按 SKU 登记实收的 FBA 入库单");
|
InboundMode inboundMode = mode(plan);
|
||||||
|
if (inboundMode == InboundMode.DROPSHIP) throw conflict("一件代发必须按完整来源托盘收货,不能确认散货短少");
|
||||||
if (unloadPhotoFileIds(task).isEmpty() || task.getUnloadPhotoUploadedAt() == null || task.getCompletedAt() == null) {
|
if (unloadPhotoFileIds(task).isEmpty() || task.getUnloadPhotoUploadedAt() == null || task.getCompletedAt() == null) {
|
||||||
throw conflict("现场收货尚未结束或缺少卸柜凭证,不能确认短少");
|
throw conflict("现场收货尚未结束或缺少卸柜凭证,不能确认短少");
|
||||||
}
|
}
|
||||||
List<InboundPlanLine> lines = repository.lines(plan.getId());
|
List<InboundPlanLine> lines = repository.lines(plan.getId());
|
||||||
List<InboundPlanPalletAllocation> allocations = repository.allocations(plan.getId());
|
List<InboundPlanPalletAllocation> allocations = repository.allocations(plan.getId());
|
||||||
if (lines.isEmpty() || lines.stream().anyMatch(line -> !"FBA".equals(destinationType(line)))) {
|
String expectedType = inboundMode == InboundMode.FBA ? "FBA" : "OVERSEAS";
|
||||||
throw conflict("短少确认要求每条明细均为 FBA 实收货物");
|
if (lines.isEmpty() || lines.stream().anyMatch(line -> !expectedType.equals(destinationType(line)))) {
|
||||||
|
throw conflict("短少确认要求各条明细使用一致的收货方式");
|
||||||
}
|
}
|
||||||
int expected = lines.stream().mapToInt(line -> value(line.getRequiredPackageCount())).sum();
|
int expected = lines.stream().mapToInt(line -> value(line.getRequiredPackageCount())).sum();
|
||||||
int received = allocations.stream().mapToInt(allocation -> value(allocation.getPickedPackageCount())).sum();
|
int received = allocations.stream().mapToInt(allocation -> value(allocation.getPickedPackageCount())).sum();
|
||||||
@@ -753,6 +753,37 @@ public class InboundPdaWorkflowService {
|
|||||||
throw conflict("应收或实收数量已变化,请刷新后重新确认");
|
throw conflict("应收或实收数量已变化,请刷新后重新确认");
|
||||||
}
|
}
|
||||||
if (received <= 0 || received >= expected) throw conflict("仅有实际收货且实收少于应收时可确认短少");
|
if (received <= 0 || received >= expected) throw conflict("仅有实际收货且实收少于应收时可确认短少");
|
||||||
|
if (inboundMode == InboundMode.FBA) validateFbaShortage(customerId, plan, lines, allocations, expected);
|
||||||
|
if (allocations.stream().anyMatch(a -> value(a.getPickedPackageCount()) < 0
|
||||||
|
|| value(a.getPickedPackageCount()) != value(a.getCountedPackageCount()))) {
|
||||||
|
throw conflict("清点与收货数量不一致,不能确认短少");
|
||||||
|
}
|
||||||
|
List<InboundPlanPallet> pallets = new ArrayList<>(repository.pallets(plan.getId()));
|
||||||
|
Set<Long> palletIds = pallets.stream().map(InboundPlanPallet::getId).collect(java.util.stream.Collectors.toSet());
|
||||||
|
pallets.addAll(repository.palletsByIds(allocations.stream().map(InboundPlanPalletAllocation::getPalletId)
|
||||||
|
.filter(id -> id != null && !palletIds.contains(id)).collect(java.util.stream.Collectors.toSet())));
|
||||||
|
// 普通海外仓的 SKU、逐托箱数由库存入口按已收箱码复验,缺失精确事实时整次失败。
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
stockReceipts.stockIn(customerId, plan, task, lines, allocations, pallets, task.getCompletedAt(), true);
|
||||||
|
if (inboundMode == InboundMode.OWN_OVERSEAS) {
|
||||||
|
for (InboundPlanCarton carton : repository.cartons(plan.getId())) {
|
||||||
|
if (!"ON_PALLET".equals(carton.getStatus())) continue;
|
||||||
|
carton.setStatus("AVAILABLE"); carton.setReceivedAt(task.getCompletedAt()); carton.setUpdatedAt(now);
|
||||||
|
carton.setVersion(value(carton.getVersion()) + 1); repository.update(carton);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
task.setStatus("PARTIAL_INBOUND"); task.setUpdatedAt(now); task.setVersion(value(task.getVersion()) + 1);
|
||||||
|
repository.update(task);
|
||||||
|
plan.setStatus("PARTIAL_INBOUND"); plan.setUpdatedAt(now); repository.update(plan);
|
||||||
|
dispatchReceipts.complete(List.of(plan.getId()), operatorId, now);
|
||||||
|
PdaResponses.Task result = taskResponse(task, plan, planNo);
|
||||||
|
saveOperation(customerId, task.getId(), null, "CONFIRM_SHORTAGE", idempotencyKey, command, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FBA 按总数分摊的收货组仅允许单票单 SKU 确认短少,避免猜测缺货归属。 */
|
||||||
|
private void validateFbaShortage(String customerId, InboundPlan plan, List<InboundPlanLine> lines,
|
||||||
|
List<InboundPlanPalletAllocation> allocations, int expected) {
|
||||||
var skuReceipts = repository.skuReceipts(customerId, plan.getId());
|
var skuReceipts = repository.skuReceipts(customerId, plan.getId());
|
||||||
// 批量收货的 SKU 行来自计划顺序分摊;只有单票单 SKU 的整组总数才是无歧义实收。
|
// 批量收货的 SKU 行来自计划顺序分摊;只有单票单 SKU 的整组总数才是无歧义实收。
|
||||||
if (lines.size() != 1) throw conflict("多票收货不能仅按总箱数确认短少,请先核实每票及每个 SKU 的实际数量");
|
if (lines.size() != 1) throw conflict("多票收货不能仅按总箱数确认短少,请先核实每票及每个 SKU 的实际数量");
|
||||||
@@ -776,24 +807,6 @@ public class InboundPdaWorkflowService {
|
|||||||
throw conflict("逐票 SKU 实收与托盘数量不一致或存在超收,不能按短少确认");
|
throw conflict("逐票 SKU 实收与托盘数量不一致或存在超收,不能按短少确认");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (allocations.stream().anyMatch(a -> value(a.getPickedPackageCount()) < 0
|
|
||||||
|| value(a.getPickedPackageCount()) != value(a.getCountedPackageCount()))) {
|
|
||||||
throw conflict("清点与收货数量不一致,不能确认短少");
|
|
||||||
}
|
|
||||||
List<InboundPlanPallet> pallets = new ArrayList<>(repository.pallets(plan.getId()));
|
|
||||||
Set<Long> palletIds = pallets.stream().map(InboundPlanPallet::getId).collect(java.util.stream.Collectors.toSet());
|
|
||||||
pallets.addAll(repository.palletsByIds(allocations.stream().map(InboundPlanPalletAllocation::getPalletId)
|
|
||||||
.filter(id -> id != null && !palletIds.contains(id)).collect(java.util.stream.Collectors.toSet())));
|
|
||||||
// 库存和库龄采用原现场收货时间,不因后台核对延迟重置;不重复发布柜级卸货事件。
|
|
||||||
LocalDateTime now = LocalDateTime.now();
|
|
||||||
stockReceipts.stockIn(customerId, plan, task, lines, allocations, pallets, task.getCompletedAt(), true);
|
|
||||||
task.setStatus("PARTIAL_INBOUND"); task.setUpdatedAt(now); task.setVersion(value(task.getVersion()) + 1);
|
|
||||||
repository.update(task);
|
|
||||||
plan.setStatus("PARTIAL_INBOUND"); plan.setUpdatedAt(now); repository.update(plan);
|
|
||||||
dispatchReceipts.complete(List.of(plan.getId()), operatorId, now);
|
|
||||||
PdaResponses.Task result = taskResponse(task, plan, planNo);
|
|
||||||
saveOperation(customerId, task.getId(), null, "CONFIRM_SHORTAGE", idempotencyKey, command, result);
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 操作员明确选择仅将当前已收箱数入库。 */
|
/** 操作员明确选择仅将当前已收箱数入库。 */
|
||||||
|
|||||||
+41
-1
@@ -7,6 +7,8 @@ import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
|
|||||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
|
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
|
||||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
|
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
|
||||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
|
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
|
||||||
|
import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
|
||||||
|
import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository;
|
||||||
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
|
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
|
||||||
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
|
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
|
||||||
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt;
|
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt;
|
||||||
@@ -45,6 +47,7 @@ public class InboundStockReceiptService {
|
|||||||
@Resource private LastlegOrderDetailMapper orderDetails;
|
@Resource private LastlegOrderDetailMapper orderDetails;
|
||||||
@Resource private InboundStockReceiptMapper receipts;
|
@Resource private InboundStockReceiptMapper receipts;
|
||||||
@Resource private ReceivingSkuReceiptMapper receivingSkuReceipts;
|
@Resource private ReceivingSkuReceiptMapper receivingSkuReceipts;
|
||||||
|
@Resource private PdaWorkflowRepository workflowRepository;
|
||||||
@Resource private InventoryCommandService inventory;
|
@Resource private InventoryCommandService inventory;
|
||||||
@Resource private WarehouseResourceGateway warehouseGateway;
|
@Resource private WarehouseResourceGateway warehouseGateway;
|
||||||
@Resource private DropshipOrderService dropshipOrders;
|
@Resource private DropshipOrderService dropshipOrders;
|
||||||
@@ -58,7 +61,7 @@ public class InboundStockReceiptService {
|
|||||||
stockIn(customerId, plan, task, lines, allocations, pallets, receivedAt, false);
|
stockIn(customerId, plan, task, lines, allocations, pallets, receivedAt, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 部分入库仍按逐 SKU 实收生成凭证,原申报箱数仅作为上限。 */
|
/** 按逐 SKU 实收生成凭证;普通海外仓货物以已扫描箱码和实托归属为准。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task,
|
public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task,
|
||||||
List<InboundPlanLine> lines, List<InboundPlanPalletAllocation> allocations,
|
List<InboundPlanLine> lines, List<InboundPlanPalletAllocation> allocations,
|
||||||
@@ -105,6 +108,12 @@ public class InboundStockReceiptService {
|
|||||||
throw conflict("PDA picked packages must equal the inbound order package total");
|
throw conflict("PDA picked packages must equal the inbound order package total");
|
||||||
}
|
}
|
||||||
List<ReceivingSkuReceipt> exactSkuReceipts = skuReceiptsByLine.getOrDefault(line.getId(), List.of());
|
List<ReceivingSkuReceipt> exactSkuReceipts = skuReceiptsByLine.getOrDefault(line.getId(), List.of());
|
||||||
|
if (!"ONE_CLICK_DROPSHIP".equals(order.getOrderType())
|
||||||
|
&& "OVERSEAS".equals(first(line.getDestinationType(), order.getDestinationType()))) {
|
||||||
|
created.addAll(persistExactSkuReceipts(customerId, plan, task, line, lineAllocations,
|
||||||
|
palletById, order, cartonSkuReceipts(customerId, plan, line, palletById), pickedPackages, receivedAt, allowPartial));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!exactSkuReceipts.isEmpty()) {
|
if (!exactSkuReceipts.isEmpty()) {
|
||||||
created.addAll(persistExactSkuReceipts(customerId, plan, task, line, lineAllocations,
|
created.addAll(persistExactSkuReceipts(customerId, plan, task, line, lineAllocations,
|
||||||
palletById, order, exactSkuReceipts, pickedPackages, receivedAt, allowPartial));
|
palletById, order, exactSkuReceipts, pickedPackages, receivedAt, allowPartial));
|
||||||
@@ -147,6 +156,37 @@ public class InboundStockReceiptService {
|
|||||||
completeOrders(customerId, plan, orderById, receivedAt, allowPartial);
|
completeOrders(customerId, plan, orderById, receivedAt, allowPartial);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 从本票已收箱码汇总各实托的真实 SKU,防止短少或改托后按申报顺序猜测归属。 */
|
||||||
|
private List<ReceivingSkuReceipt> cartonSkuReceipts(String customerId, InboundPlan plan, InboundPlanLine line,
|
||||||
|
Map<Long, InboundPlanPallet> pallets) {
|
||||||
|
Map<String, SkuRemainder> expected = skuRemainders(line.getOrderId(), value(line.getRequiredPackageCount())).stream()
|
||||||
|
.collect(Collectors.toMap(sku -> sku.skuCode.toUpperCase(java.util.Locale.ROOT), sku -> sku, (left, right) -> left));
|
||||||
|
Map<Long, Map<String, Integer>> counts = new java.util.LinkedHashMap<>();
|
||||||
|
for (InboundPlanCarton carton : workflowRepository.cartons(plan.getId())) {
|
||||||
|
if (!Objects.equals(line.getId(), carton.getLineId()) || !"ON_PALLET".equals(carton.getStatus())) continue;
|
||||||
|
String skuCode = trim(carton.getSkuCode());
|
||||||
|
SkuRemainder sku = skuCode == null ? null : expected.get(skuCode.toUpperCase(java.util.Locale.ROOT));
|
||||||
|
InboundPlanPallet pallet = pallets.get(carton.getPlanPalletId());
|
||||||
|
if (!customerId.equals(carton.getCustomerId()) || !Objects.equals(plan.getId(), carton.getPlanId())
|
||||||
|
|| !Objects.equals(line.getOrderId(), carton.getOrderId()) || pallet == null || sku == null
|
||||||
|
|| !Objects.equals(pallet.getWarehousePalletId(), carton.getWarehousePalletId())
|
||||||
|
|| !Objects.equals(pallet.getWarehouseLocationId(), carton.getWarehouseLocationId())) {
|
||||||
|
throw conflict("已收箱码的客户、计划、托盘或 SKU 与本票冻结货物不一致");
|
||||||
|
}
|
||||||
|
counts.computeIfAbsent(carton.getPlanPalletId(), ignored -> new java.util.LinkedHashMap<>())
|
||||||
|
.merge(sku.skuCode, 1, Integer::sum);
|
||||||
|
}
|
||||||
|
List<ReceivingSkuReceipt> result = new java.util.ArrayList<>();
|
||||||
|
counts.forEach((palletId, skus) -> skus.forEach((skuCode, packages) -> {
|
||||||
|
SkuRemainder sku = expected.get(skuCode.toUpperCase(java.util.Locale.ROOT));
|
||||||
|
ReceivingSkuReceipt receipt = new ReceivingSkuReceipt();
|
||||||
|
receipt.setLineId(line.getId()); receipt.setPalletId(palletId); receipt.setSkuCode(skuCode);
|
||||||
|
receipt.setProductName(first(sku.productNameZh, sku.productNameEn)); receipt.setReceivedPackages(packages);
|
||||||
|
result.add(receipt);
|
||||||
|
}));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/** 逐 SKU 校验实收归属;短少可以少于冻结箱数,但不允许其他 SKU 超收抵扣。 */
|
/** 逐 SKU 校验实收归属;短少可以少于冻结箱数,但不允许其他 SKU 超收抵扣。 */
|
||||||
private List<InboundStockReceipt> persistExactSkuReceipts(String customerId, InboundPlan plan,
|
private List<InboundStockReceipt> persistExactSkuReceipts(String customerId, InboundPlan plan,
|
||||||
InboundPdaTask task, InboundPlanLine line, List<InboundPlanPalletAllocation> lineAllocations,
|
InboundPdaTask task, InboundPlanLine line, List<InboundPlanPalletAllocation> lineAllocations,
|
||||||
|
|||||||
+13
-33
@@ -393,6 +393,7 @@ public class ReceivingContainerService {
|
|||||||
inbound.assigned(), inbound.mergeAllowed(), inbound.urgent(), inbound.mergeHint());
|
inbound.assigned(), inbound.mergeAllowed(), inbound.urgent(), inbound.mergeHint());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 校验当前入仓单已预留的实物托盘,与实收提交使用相同资源范围。 */
|
||||||
public ReceivingContainerResponses.PalletResolved resolvePallet(String operatorKey,
|
public ReceivingContainerResponses.PalletResolved resolvePallet(String operatorKey,
|
||||||
ReceivingContainerRequests.PalletResolve request) {
|
ReceivingContainerRequests.PalletResolve request) {
|
||||||
ReceivingContainerSession session = requireSigned(operatorKey);
|
ReceivingContainerSession session = requireSigned(operatorKey);
|
||||||
@@ -402,7 +403,7 @@ public class ReceivingContainerService {
|
|||||||
&& !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())
|
&& !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())
|
||||||
? requireFbaPlanRefs(session.getSourceContainerBookingId(), session.getContainerNo(), ref) : List.of(ref);
|
? requireFbaPlanRefs(session.getSourceContainerBookingId(), session.getContainerNo(), ref) : List.of(ref);
|
||||||
PalletSelection selection = group.size() == 1 && !"FBA".equals(destinationType(ref.line(), ref.order()))
|
PalletSelection selection = group.size() == 1 && !"FBA".equals(destinationType(ref.line(), ref.order()))
|
||||||
? resolveReceivingPallet(ref, request.palletCode(), false) : resolveFbaGroupPallet(group, request.palletCode());
|
? resolveReceivingPallet(ref, request.palletCode()) : resolveFbaGroupPallet(group, request.palletCode());
|
||||||
WarehouseResourceGateway.WarehousePalletReference pallet = selection.resource();
|
WarehouseResourceGateway.WarehousePalletReference pallet = selection.resource();
|
||||||
int received = selection.pallet() == null ? 0 : group.stream().mapToInt(value -> allocations.selectList(
|
int received = selection.pallet() == null ? 0 : group.stream().mapToInt(value -> allocations.selectList(
|
||||||
new LambdaQueryWrapper<InboundPlanPalletAllocation>()
|
new LambdaQueryWrapper<InboundPlanPalletAllocation>()
|
||||||
@@ -506,6 +507,7 @@ public class ReceivingContainerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
|
/** 按冻结箱码接收一箱货物,并确认本计划预留的托盘资源。 */
|
||||||
public ReceivingContainerResponses.Receipt receiveCarton(String operatorKey, String operatorName,
|
public ReceivingContainerResponses.Receipt receiveCarton(String operatorKey, String operatorName,
|
||||||
ReceivingContainerRequests.CartonReceive request) {
|
ReceivingContainerRequests.CartonReceive request) {
|
||||||
ReceivingContainerOperation replay = replay(operatorKey, "CARTON_RECEIVE", request.idempotencyKey(), request);
|
ReceivingContainerOperation replay = replay(operatorKey, "CARTON_RECEIVE", request.idempotencyKey(), request);
|
||||||
@@ -525,7 +527,7 @@ public class ReceivingContainerService {
|
|||||||
if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) {
|
if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) {
|
||||||
throw conflict("FBA destination must use bulk count");
|
throw conflict("FBA destination must use bulk count");
|
||||||
}
|
}
|
||||||
PalletSelection selection = resolveReceivingPallet(ref, request.palletCode(), true);
|
PalletSelection selection = resolveReceivingPallet(ref, request.palletCode());
|
||||||
WarehouseResourceGateway.WarehousePalletReference resource = selection.resource();
|
WarehouseResourceGateway.WarehousePalletReference resource = selection.resource();
|
||||||
InboundPlanPallet pallet = selection.pallet();
|
InboundPlanPallet pallet = selection.pallet();
|
||||||
long packagesOnPallet = cartons.selectCount(new LambdaQueryWrapper<InboundPlanCarton>()
|
long packagesOnPallet = cartons.selectCount(new LambdaQueryWrapper<InboundPlanCarton>()
|
||||||
@@ -902,20 +904,8 @@ public class ReceivingContainerService {
|
|||||||
return values;
|
return values;
|
||||||
}
|
}
|
||||||
|
|
||||||
private InboundPlanPallet choosePallet(InboundRef ref, WarehouseResourceGateway.WarehousePalletReference resource) {
|
/** 收货只确认已预留的托盘,避免空闲托盘校验通过后在实收时才失败。 */
|
||||||
List<InboundPlanPallet> candidates = pallets.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
|
private PalletSelection resolveReceivingPallet(InboundRef ref, String palletCode) {
|
||||||
.eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId())
|
|
||||||
.eq(InboundPlanPallet::getPlanId, ref.plan().getId())).stream()
|
|
||||||
.filter(value -> sameDestination(ref.line(), value))
|
|
||||||
.filter(value -> value.getWarehousePalletId() == null || Objects.equals(value.getWarehousePalletId(), resource.palletId()))
|
|
||||||
.sorted(Comparator.comparing((InboundPlanPallet value) -> !Objects.equals(value.getWarehousePalletId(), resource.palletId()))
|
|
||||||
.thenComparing(value -> value.getActualPackageCount() == null ? 0 : value.getActualPackageCount()))
|
|
||||||
.toList();
|
|
||||||
if (candidates.isEmpty()) throw conflict("Pallet does not match an available destination pallet in this inbound code");
|
|
||||||
return candidates.get(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private PalletSelection resolveReceivingPallet(InboundRef ref, String palletCode, boolean requirePlanPallet) {
|
|
||||||
String normalizedPalletCode = palletCode.trim();
|
String normalizedPalletCode = palletCode.trim();
|
||||||
List<InboundPlanPallet> reserved = pallets.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
|
List<InboundPlanPallet> reserved = pallets.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
|
||||||
.eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId())
|
.eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId())
|
||||||
@@ -927,23 +917,13 @@ public class ReceivingContainerService {
|
|||||||
if (reserved.size() > 1) {
|
if (reserved.size() > 1) {
|
||||||
throw conflict("Pallet code matches multiple reserved resources in this inbound plan");
|
throw conflict("Pallet code matches multiple reserved resources in this inbound plan");
|
||||||
}
|
}
|
||||||
WarehouseResourceGateway.WarehousePalletReference resource;
|
if (reserved.isEmpty()) throw conflict("Pallet code is not reserved for this inbound code");
|
||||||
InboundPlanPallet pallet;
|
InboundPlanPallet pallet = reserved.get(0);
|
||||||
if (reserved.size() == 1) {
|
long warehouseId = ref.plan().getOverseasWarehouseId() == null
|
||||||
pallet = reserved.get(0);
|
? Long.parseLong(ref.order().getOverseasWarehouseId()) : ref.plan().getOverseasWarehouseId();
|
||||||
long warehouseId = ref.plan().getOverseasWarehouseId() == null
|
WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolveReservedPallet(
|
||||||
? Long.parseLong(ref.order().getOverseasWarehouseId()) : ref.plan().getOverseasWarehouseId();
|
pallet.getPalletNo(), ref.order().getCustomerId(), warehouseId, pallet.getWarehousePalletId(),
|
||||||
resource = warehouse.resolveReservedPallet(pallet.getPalletNo(), ref.order().getCustomerId(), warehouseId,
|
pallet.getWarehouseLocationId(), pallet.getWarehouseResourceLockId());
|
||||||
pallet.getWarehousePalletId(), pallet.getWarehouseLocationId(), pallet.getWarehouseResourceLockId());
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
resource = warehouse.resolveAvailablePallet(normalizedPalletCode);
|
|
||||||
} catch (IllegalArgumentException exception) {
|
|
||||||
throw notFound("Pallet was not found");
|
|
||||||
}
|
|
||||||
if (resource == null) throw notFound("Pallet was not found");
|
|
||||||
pallet = requirePlanPallet ? choosePallet(ref, resource) : null;
|
|
||||||
}
|
|
||||||
validatePallet(ref, resource);
|
validatePallet(ref, resource);
|
||||||
return new PalletSelection(pallet, resource);
|
return new PalletSelection(pallet, resource);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -711,8 +711,8 @@ paths:
|
|||||||
'404': { description: 入库订单不存在或不属于指定客户 }
|
'404': { description: 入库订单不存在或不属于指定客户 }
|
||||||
/api/platform/v1/last-leg/inbound-plans/{planNo}/confirm-shortage:
|
/api/platform/v1/last-leg/inbound-plans/{planNo}/confirm-shortage:
|
||||||
post:
|
post:
|
||||||
summary: 确认FBA入库短少并按实收入库
|
summary: 确认普通散货入库短少并按实收入库
|
||||||
description: 权限 platform:overseas-warehouse:inbound-order:edit;仅平台指定客户操作。仅支持同柜次同目的仓收货组单票、冻结申报单SKU;多SKU或跨票合收不能按自动分摊确认。PENDING_RECONCILIATION且0<实收<应收时,核对版本及逐SKU实收,保留原应收和现场收货时间,按实收写入库存并进入PARTIAL_INBOUND,同事务交接原预排并移除明确零实收托盘的排车明细、占用和预约分配。操作原因与操作人写入收货操作记录;不重复发布卸柜事件。超收、SKU不一致、客户不匹配和陈旧版本拒绝;相同请求键重放不会重复入库。
|
description: 权限 platform:overseas-warehouse:inbound-order:edit;仅平台指定客户操作。FBA仅支持同柜次同目的仓收货组单票、冻结申报单SKU,多SKU或跨票合收不能按自动分摊确认。普通OVERSEAS货物按已扫描箱码的SKU及实际托盘归属确认,可处理多SKU短少,一件代发整托货物拒绝本操作。PENDING_RECONCILIATION且0<实收<应收时,核对版本及精确实收,保留原应收和现场收货时间,按实收写入库存并进入PARTIAL_INBOUND;OVERSEAS已收箱转AVAILABLE,未收箱保留原状态。同事务交接原预排并移除明确零实收托盘的排车明细、占用和预约分配。操作原因与操作人写入收货操作记录;不重复发布卸柜事件。超收、SKU不一致、客户不匹配和陈旧版本拒绝;相同请求键重放不会重复入库。
|
||||||
security: [{ bearerAuth: [] }]
|
security: [{ bearerAuth: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: planNo, in: path, required: true, schema: { type: string } }
|
- { name: planNo, in: path, required: true, schema: { type: string } }
|
||||||
|
|||||||
+24
@@ -348,6 +348,30 @@ class InboundPdaWorkflowServiceTest {
|
|||||||
assertEquals("UNASSIGNED", second.getStatus());
|
assertEquals("UNASSIGNED", second.getStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 同票允许按实际打托超过单托建议数,同时保留整票箱数上限。 */
|
||||||
|
@Test
|
||||||
|
void cartonScanCanExceedPalletSuggestionButCannotExceedInboundTotal() {
|
||||||
|
InboundPlanCarton carton = carton("BOX-REAL", "UNASSIGNED", targetLine.getId(), "WH-A");
|
||||||
|
activeTask(); target.setPickedPackageCount(3);
|
||||||
|
when(repository.lockCarton("100", "BOX-REAL")).thenReturn(carton);
|
||||||
|
when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet);
|
||||||
|
when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine);
|
||||||
|
when(repository.lockAllocation(plan.getId(), targetLine.getId(), targetPallet.getId())).thenReturn(target);
|
||||||
|
when(repository.scannedCartons(plan.getId(), targetLine.getId())).thenReturn(3L);
|
||||||
|
|
||||||
|
service.scanCarton("100", "LIP1", new PdaRequests.ScanCarton(
|
||||||
|
"BOX-REAL", targetPallet.getId(), "actual-pallet", "7", "worker"));
|
||||||
|
assertEquals(4, target.getPickedPackageCount());
|
||||||
|
assertEquals(3, target.getPlannedPackageCount());
|
||||||
|
|
||||||
|
InboundPlanCarton extra = carton("BOX-EXTRA", "UNASSIGNED", targetLine.getId(), "WH-A");
|
||||||
|
when(repository.lockCarton("100", "BOX-EXTRA")).thenReturn(extra);
|
||||||
|
when(repository.scannedCartons(plan.getId(), targetLine.getId())).thenReturn(5L);
|
||||||
|
assertThrows(ResponseStatusException.class, () -> service.scanCarton("100", "LIP1", new PdaRequests.ScanCarton(
|
||||||
|
"BOX-EXTRA", targetPallet.getId(), "extra-pallet", "7", "worker")));
|
||||||
|
assertEquals("UNASSIGNED", extra.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void cartonScanRejectsDuplicateAndWrongDestination() {
|
void cartonScanRejectsDuplicateAndWrongDestination() {
|
||||||
InboundPlanCarton repeated = carton("BOX-002", "ON_PALLET", targetLine.getId(), "WH-A");
|
InboundPlanCarton repeated = carton("BOX-002", "ON_PALLET", targetLine.getId(), "WH-A");
|
||||||
|
|||||||
+22
@@ -81,6 +81,28 @@ class InboundShortageConfirmationTest {
|
|||||||
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 19)));
|
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 19)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 普通海外仓逐箱货物确认短少后,已收箱可用、缺失箱保留,原现场时间不变。 */
|
||||||
|
@Test void overseasShortageMakesOnlyReceivedCartonsAvailable() {
|
||||||
|
line.setFbaWarehouseCode(null); line.setDestinationType("OVERSEAS");
|
||||||
|
InboundPlanCarton received = new InboundPlanCarton();
|
||||||
|
received.setId(20L); received.setStatus("ON_PALLET"); received.setVersion(1);
|
||||||
|
InboundPlanCarton missing = new InboundPlanCarton();
|
||||||
|
missing.setId(21L); missing.setStatus("UNASSIGNED"); missing.setVersion(0);
|
||||||
|
when(repository.cartons(1L)).thenReturn(List.of(received, missing));
|
||||||
|
|
||||||
|
var result = service.confirmShortage("100", "IP-1", "admin", "overseas-shortage", request(4, 20));
|
||||||
|
|
||||||
|
assertEquals("PARTIAL_INBOUND", result.status());
|
||||||
|
assertEquals("AVAILABLE", received.getStatus());
|
||||||
|
assertEquals(receivedAt, received.getReceivedAt());
|
||||||
|
assertEquals("UNASSIGNED", missing.getStatus());
|
||||||
|
assertEquals(21, line.getRequiredPackageCount());
|
||||||
|
verify(stock).stockIn(eq("100"), eq(plan), eq(task), eq(List.of(line)),
|
||||||
|
eq(List.of(allocation)), anyList(), eq(receivedAt), eq(true));
|
||||||
|
verify(repository).update(received); verify(repository, never()).update(missing);
|
||||||
|
verifyNoInteractions(outbox);
|
||||||
|
}
|
||||||
|
|
||||||
/** 页面旧版本或旧实收数不能确认库存。 */
|
/** 页面旧版本或旧实收数不能确认库存。 */
|
||||||
@Test void rejectsStaleVersionAndCounts() {
|
@Test void rejectsStaleVersionAndCounts() {
|
||||||
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(3, 20)));
|
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(3, 20)));
|
||||||
|
|||||||
+71
@@ -6,6 +6,8 @@ import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
|
|||||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
|
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
|
||||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
|
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
|
||||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
|
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
|
||||||
|
import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
|
||||||
|
import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository;
|
||||||
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
|
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
|
||||||
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
|
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
|
||||||
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt;
|
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt;
|
||||||
@@ -38,6 +40,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
|||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyLong;
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
@@ -50,6 +53,7 @@ class InboundStockReceiptServiceTest {
|
|||||||
@Mock LastlegOrderDetailMapper orderDetails;
|
@Mock LastlegOrderDetailMapper orderDetails;
|
||||||
@Mock InboundStockReceiptMapper receipts;
|
@Mock InboundStockReceiptMapper receipts;
|
||||||
@Mock ReceivingSkuReceiptMapper receivingSkuReceipts;
|
@Mock ReceivingSkuReceiptMapper receivingSkuReceipts;
|
||||||
|
@Mock PdaWorkflowRepository workflowRepository;
|
||||||
@Mock InventoryCommandService inventory;
|
@Mock InventoryCommandService inventory;
|
||||||
@Mock WarehouseResourceGateway warehouseGateway;
|
@Mock WarehouseResourceGateway warehouseGateway;
|
||||||
@Mock DropshipOrderService dropshipOrders;
|
@Mock DropshipOrderService dropshipOrders;
|
||||||
@@ -57,6 +61,7 @@ class InboundStockReceiptServiceTest {
|
|||||||
private InboundStockReceiptService service;
|
private InboundStockReceiptService service;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
|
/** 组装库存、资源与逐箱事实的隔离依赖。 */
|
||||||
void setUp() {
|
void setUp() {
|
||||||
service = new InboundStockReceiptService();
|
service = new InboundStockReceiptService();
|
||||||
org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
|
org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
|
||||||
@@ -64,6 +69,7 @@ class InboundStockReceiptServiceTest {
|
|||||||
ReflectionTestUtils.setField(service, "orderDetails", orderDetails);
|
ReflectionTestUtils.setField(service, "orderDetails", orderDetails);
|
||||||
ReflectionTestUtils.setField(service, "receipts", receipts);
|
ReflectionTestUtils.setField(service, "receipts", receipts);
|
||||||
ReflectionTestUtils.setField(service, "receivingSkuReceipts", receivingSkuReceipts);
|
ReflectionTestUtils.setField(service, "receivingSkuReceipts", receivingSkuReceipts);
|
||||||
|
ReflectionTestUtils.setField(service, "workflowRepository", workflowRepository);
|
||||||
ReflectionTestUtils.setField(service, "inventory", inventory);
|
ReflectionTestUtils.setField(service, "inventory", inventory);
|
||||||
ReflectionTestUtils.setField(service, "warehouseGateway", warehouseGateway);
|
ReflectionTestUtils.setField(service, "warehouseGateway", warehouseGateway);
|
||||||
org.mockito.Mockito.lenient().when(warehouseGateway.resolveLocation(eq(900L), anyLong())).thenAnswer(invocation -> {
|
org.mockito.Mockito.lenient().when(warehouseGateway.resolveLocation(eq(900L), anyLong())).thenAnswer(invocation -> {
|
||||||
@@ -174,6 +180,71 @@ class InboundStockReceiptServiceTest {
|
|||||||
assertEquals(5L, requests.getAllValues().stream().mapToLong(InventoryCommandRequests.StockIn::packages).sum());
|
assertEquals(5L, requests.getAllValues().stream().mapToLong(InventoryCommandRequests.StockIn::packages).sum());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 海外逐箱收货按箱码保留实际 SKU,短少时也能准确识别缺货商品。 */
|
||||||
|
@Test
|
||||||
|
void overseasShortageStocksTheScannedSkuInsteadOfTheFirstDeclaredSku() {
|
||||||
|
Fixture fixture = fixture(4, 1, 1, 4);
|
||||||
|
fixture.line.setDestinationType("OVERSEAS"); fixture.order.setDestinationType("OVERSEAS");
|
||||||
|
fixture.detail.setPackageCount(2);
|
||||||
|
when(orderDetails.selectList(any())).thenReturn(List.of(fixture.detail, detail(202L, "SKU-2", 2, 2)));
|
||||||
|
when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order));
|
||||||
|
when(workflowRepository.cartons(1L)).thenReturn(List.of(receivedCarton(1L, fixture.pallet, "SKU-2")));
|
||||||
|
when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class)))
|
||||||
|
.thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 1, 0, 0, 0, false, false));
|
||||||
|
|
||||||
|
service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line),
|
||||||
|
List.of(fixture.allocation), List.of(fixture.pallet), LocalDateTime.now(), true);
|
||||||
|
|
||||||
|
ArgumentCaptor<InventoryCommandRequests.StockIn> request = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class);
|
||||||
|
verify(inventory).stockIn(eq("100"), eq("operator"), request.capture());
|
||||||
|
assertEquals("SKU-2", request.getValue().skuCode());
|
||||||
|
assertEquals(1L, request.getValue().packages());
|
||||||
|
assertEquals(2001L, request.getValue().palletId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同票现场调整打托后,两种 SKU 的实托归属仍由箱码决定。 */
|
||||||
|
@Test
|
||||||
|
void overseasCompleteReceiptKeepsReversedSkuPalletAssignments() {
|
||||||
|
Fixture fixture = fixture(2, 1, 1, 2);
|
||||||
|
fixture.line.setDestinationType("OVERSEAS"); fixture.detail.setPackageCount(1);
|
||||||
|
InboundPlanPallet secondPallet = pallet(22L, 2002L);
|
||||||
|
InboundPlanPalletAllocation secondAllocation = allocation(32L, 12L, 22L, 1);
|
||||||
|
when(orderDetails.selectList(any())).thenReturn(List.of(fixture.detail, detail(202L, "SKU-2", 1, 1)));
|
||||||
|
when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order));
|
||||||
|
when(workflowRepository.cartons(1L)).thenReturn(List.of(
|
||||||
|
receivedCarton(1L, fixture.pallet, "SKU-2"), receivedCarton(2L, secondPallet, "SKU-1")));
|
||||||
|
when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class)))
|
||||||
|
.thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 1, 0, 0, 0, false, false));
|
||||||
|
|
||||||
|
service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line),
|
||||||
|
List.of(fixture.allocation, secondAllocation), List.of(fixture.pallet, secondPallet), LocalDateTime.now());
|
||||||
|
|
||||||
|
ArgumentCaptor<InventoryCommandRequests.StockIn> requests = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class);
|
||||||
|
verify(inventory, times(2)).stockIn(eq("100"), eq("operator"), requests.capture());
|
||||||
|
assertEquals(java.util.Set.of("SKU-2:2001", "SKU-1:2002"), requests.getAllValues().stream()
|
||||||
|
.map(value -> value.skuCode() + ":" + value.palletId()).collect(java.util.stream.Collectors.toSet()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 缺少逐箱证据时拒绝库存入账,不按申报数量补出实收。 */
|
||||||
|
@Test
|
||||||
|
void overseasReceiptRequiresScannedCartonEvidence() {
|
||||||
|
Fixture fixture = fixture(4, 1, 1, 4);
|
||||||
|
fixture.line.setDestinationType("OVERSEAS");
|
||||||
|
when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order));
|
||||||
|
assertThrows(ResponseStatusException.class, () -> service.stockIn("100", fixture.plan, fixture.task,
|
||||||
|
List.of(fixture.line), List.of(fixture.allocation), List.of(fixture.pallet), LocalDateTime.now(), true));
|
||||||
|
verify(inventory, never()).stockIn(anyString(), anyString(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构造已扫描且绑定真实托盘的箱码事实。 */
|
||||||
|
private InboundPlanCarton receivedCarton(long id, InboundPlanPallet pallet, String sku) {
|
||||||
|
InboundPlanCarton carton = new InboundPlanCarton();
|
||||||
|
carton.setId(id); carton.setCustomerId("100"); carton.setPlanId(1L); carton.setLineId(12L);
|
||||||
|
carton.setOrderId(101L); carton.setPlanPalletId(pallet.getId()); carton.setSkuCode(sku);
|
||||||
|
carton.setWarehousePalletId(pallet.getWarehousePalletId()); carton.setWarehouseLocationId(pallet.getWarehouseLocationId());
|
||||||
|
carton.setStatus("ON_PALLET"); return carton;
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void stocksInToHeldInventoryWhenInboundOrderIsDetained() {
|
void stocksInToHeldInventoryWhenInboundOrderIsDetained() {
|
||||||
Fixture fixture = fixture(5, 5, 5, 10);
|
Fixture fixture = fixture(5, 5, 5, 10);
|
||||||
|
|||||||
+23
-1
@@ -652,12 +652,34 @@ class ReceivingContainerServiceBehaviorTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
/** 非本票预留托盘在校验和提交阶段均拒绝,避免误报可收货。 */
|
||||||
|
void overseasUnreservedPalletIsRejectedBeforeWarehouseLookup() {
|
||||||
|
stubCurrent("OVERSEAS", "CONT-A");
|
||||||
|
when(cartons.selectOne(any())).thenReturn(carton(1L, "IN-1#1"));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.resolvePallet("7",
|
||||||
|
new ReceivingContainerRequests.PalletResolve("PAL-OTHER", "IN-1")))
|
||||||
|
.hasMessageContaining("Pallet code is not reserved for this inbound code");
|
||||||
|
assertThatThrownBy(() -> service.receiveCarton("7", "receiver",
|
||||||
|
new ReceivingContainerRequests.CartonReceive("IN-1#1", "PAL-OTHER", "wrong-pallet")))
|
||||||
|
.hasMessageContaining("Pallet code is not reserved for this inbound code");
|
||||||
|
verifyNoInteractions(warehouse);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
/** 业务目的仓与接柜仓不同,仍按已预留的实际接柜仓校验。 */
|
||||||
void overseasPalletUsesActualReceivingWarehouseInsteadOfBusinessDestinationId() {
|
void overseasPalletUsesActualReceivingWarehouseInsteadOfBusinessDestinationId() {
|
||||||
stubCurrent("OVERSEAS", "CONT-A");
|
stubCurrent("OVERSEAS", "CONT-A");
|
||||||
InboundPlanLine overseasLine = line("OVERSEAS");
|
InboundPlanLine overseasLine = line("OVERSEAS");
|
||||||
overseasLine.setDestinationWarehouseId("900");
|
overseasLine.setDestinationWarehouseId("900");
|
||||||
when(lines.selectList(any())).thenReturn(List.of(overseasLine));
|
when(lines.selectList(any())).thenReturn(List.of(overseasLine));
|
||||||
when(warehouse.resolveAvailablePallet("PAL-1")).thenReturn(resource(100L, null));
|
InboundPlanPallet reserved = planPallet();
|
||||||
|
reserved.setDestinationWarehouseId("900"); reserved.setPalletNo("PAL-1");
|
||||||
|
reserved.setWarehousePalletId(30L); reserved.setWarehouseLocationId(40L);
|
||||||
|
reserved.setWarehouseResourceLockId(50L); reserved.setAssignmentStatus("RESERVED");
|
||||||
|
when(pallets.selectList(any())).thenReturn(List.of(reserved));
|
||||||
|
when(warehouse.resolveReservedPallet("PAL-1", "100", 100L, 30L, 40L, 50L))
|
||||||
|
.thenReturn(resource(100L, null));
|
||||||
|
|
||||||
var resolved = service.resolvePallet("7",
|
var resolved = service.resolvePallet("7",
|
||||||
new ReceivingContainerRequests.PalletResolve("PAL-1", "IN-1"));
|
new ReceivingContainerRequests.PalletResolve("PAL-1", "IN-1"));
|
||||||
|
|||||||
+37
-7
@@ -7,12 +7,21 @@ import com.baiyue.print.dto.GenericPalletPdfDtos;
|
|||||||
import com.baiyue.print.renderer.GenericPalletRenderer;
|
import com.baiyue.print.renderer.GenericPalletRenderer;
|
||||||
import com.baiyue.print.renderer.PdfBarcodeRenderer;
|
import com.baiyue.print.renderer.PdfBarcodeRenderer;
|
||||||
import com.baiyue.print.renderer.PdfUnits;
|
import com.baiyue.print.renderer.PdfUnits;
|
||||||
|
import com.google.zxing.BarcodeFormat;
|
||||||
|
import com.google.zxing.BinaryBitmap;
|
||||||
|
import com.google.zxing.DecodeHintType;
|
||||||
|
import com.google.zxing.MultiFormatReader;
|
||||||
|
import com.google.zxing.RGBLuminanceSource;
|
||||||
|
import com.google.zxing.common.HybridBinarizer;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.text.Normalizer;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import org.apache.pdfbox.Loader;
|
import org.apache.pdfbox.Loader;
|
||||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||||
|
import org.apache.pdfbox.rendering.PDFRenderer;
|
||||||
import org.apache.pdfbox.text.PDFTextStripper;
|
import org.apache.pdfbox.text.PDFTextStripper;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||||
@@ -22,22 +31,43 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
|||||||
class GenericPalletPdfServiceGenerationTest {
|
class GenericPalletPdfServiceGenerationTest {
|
||||||
@jakarta.annotation.Resource private GenericPalletPdfService service;
|
@jakarta.annotation.Resource private GenericPalletPdfService service;
|
||||||
|
|
||||||
|
/** 从真实横向 A4 PDF 反解短码和最长托盘码,同时核对中文、页序和字体嵌入。 */
|
||||||
@Test
|
@Test
|
||||||
void generatesLandscapeA4WithoutUsingTheCartonMarkTemplate() throws Exception {
|
void generatesLandscapeA4WithoutUsingTheCartonMarkTemplate() throws Exception {
|
||||||
|
var labels = List.of(
|
||||||
|
new GenericPalletPdfDtos.Label("Los Angeles Warehouse", "Transfer WTR-1", "PALLET-001"),
|
||||||
|
new GenericPalletPdfDtos.Label("洛杉矶收货仓", "入库单 IP-DEMO-1", "D0001-IN-DEMO-001"),
|
||||||
|
new GenericPalletPdfDtos.Label("Private warehouse", "入库单 IP-DEMO-2", "A".repeat(80)));
|
||||||
var generated = service.generate(new GenericPalletPdfDtos.Request(
|
var generated = service.generate(new GenericPalletPdfDtos.Request(
|
||||||
GenericPalletPdfDtos.TEMPLATE,
|
GenericPalletPdfDtos.TEMPLATE,
|
||||||
new GenericPalletPdfDtos.PageSpec(new BigDecimal("297"), new BigDecimal("210")),
|
new GenericPalletPdfDtos.PageSpec(new BigDecimal("297"), new BigDecimal("210")),
|
||||||
"pallet.pdf", List.of(new GenericPalletPdfDtos.Label(
|
"pallet.pdf", labels));
|
||||||
"Los Angeles Warehouse", "Transfer WTR-1", "PALLET-001"))));
|
|
||||||
Files.createDirectories(Path.of("target"));
|
Files.createDirectories(Path.of("target"));
|
||||||
Files.write(Path.of("target", "generic-pallet-a4-preview.pdf"), generated.content());
|
Files.write(Path.of("target", "generic-pallet-a4-preview.pdf"), generated.content());
|
||||||
|
|
||||||
try (PDDocument pdf = Loader.loadPDF(generated.content())) {
|
try (PDDocument pdf = Loader.loadPDF(generated.content())) {
|
||||||
assertThat(pdf.getNumberOfPages()).isEqualTo(1);
|
assertThat(pdf.getNumberOfPages()).isEqualTo(labels.size());
|
||||||
assertThat(pdf.getPage(0).getMediaBox().getWidth()).isCloseTo(PdfUnits.mmToPt(297), org.assertj.core.data.Offset.offset(.002f));
|
var renderer = new PDFRenderer(pdf);
|
||||||
assertThat(pdf.getPage(0).getMediaBox().getHeight()).isCloseTo(PdfUnits.mmToPt(210), org.assertj.core.data.Offset.offset(.002f));
|
for (int index = 0; index < labels.size(); index++) {
|
||||||
assertThat(new PDFTextStripper().getText(pdf)).contains(
|
var page = pdf.getPage(index);
|
||||||
"Los Angeles Warehouse", "Transfer WTR-1", "PALLET-001");
|
assertThat(page.getMediaBox().getWidth()).isCloseTo(PdfUnits.mmToPt(297), org.assertj.core.data.Offset.offset(.002f));
|
||||||
|
assertThat(page.getMediaBox().getHeight()).isCloseTo(PdfUnits.mmToPt(210), org.assertj.core.data.Offset.offset(.002f));
|
||||||
|
var stripper = new PDFTextStripper();
|
||||||
|
stripper.setStartPage(index + 1); stripper.setEndPage(index + 1);
|
||||||
|
var label = labels.get(index);
|
||||||
|
// 字体的共享字形可能反解为康熙部首,中文语义按 Unicode 兼容标准化核对。
|
||||||
|
assertThat(Normalizer.normalize(stripper.getText(pdf), Normalizer.Form.NFKC))
|
||||||
|
.contains(label.heading(), label.reference(), label.palletCode());
|
||||||
|
for (var name : page.getResources().getFontNames()) {
|
||||||
|
assertThat(page.getResources().getFont(name).isEmbedded()).isTrue();
|
||||||
|
}
|
||||||
|
var image = renderer.renderImageWithDPI(index, 300);
|
||||||
|
var source = new RGBLuminanceSource(image.getWidth(), image.getHeight(),
|
||||||
|
image.getRGB(0, 0, image.getWidth(), image.getHeight(), null, 0, image.getWidth()));
|
||||||
|
var decoded = new MultiFormatReader().decode(new BinaryBitmap(new HybridBinarizer(source)),
|
||||||
|
Map.of(DecodeHintType.POSSIBLE_FORMATS, List.of(BarcodeFormat.CODE_128)));
|
||||||
|
assertThat(decoded.getText()).isEqualTo(label.palletCode());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user