废弃 ,换标文件上传 ,排车托盘锁粒度

This commit is contained in:
2026-09-14 20:21:07 +08:00
parent b3d74c5441
commit 70145e6b3b
9 changed files with 70 additions and 19 deletions
+1 -1
View File
@@ -47,7 +47,7 @@
| 尾程暂扣状态回传 | `service/InboundOrderDetainedOutboxService.java`;暂扣与解除暂扣在订单更新事务内按 `sourceInboundId` 写入海外到国内 Outbox,国内按来源入仓单绑定更新 |
| 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 |
| 海外PDA盘点、同仓整托移库、托盘组拆换绑与换标现场作业 | `pda/stocktake/`、`pda/move/`、`palletops/`、`relabel/pda/`;版本化契约 `src/main/resources/openapi/overseas-pda-operations-v1.yaml`;全部范围取自PDA Token,排车和出库使用独立 PDA Controller |
| 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/`、`relabel/pda/`;一条 `ll_relabel_item` 对应一个客户填写的我方箱唛任务,`CARTON_LABEL`、`SKU_LABEL`、`CARTON_AND_SKU` 分别保存箱唛、SKU 或组合需求,SKU 子项 JSON 数量按件;普通尾程入库没有箱唛到实物托盘的精确快照,历史入仓库存仓位只能作为冻结记账锚点和现场大概位置,禁止把该仓位解释为箱唛已绑定具体托盘。库存只按箱任务冻结一次。涉及箱唛换标时申请必须选择 `TRUCK/EXPRESS/TRUCK_PICKUP/EXPRESS_PICKUP`,两种快递方式可要求打印并上传面单;PDA 扫我方箱唛完成逐箱事实和照片,仓库复核通过进入 `CUSTOMER_CONFIRM_PENDING`,客户查看照片确认后进入 `READY_FOR_OUTBOUND`:仅卡车派送为 `WAIT_PRE_DISPATCH`,快递派送和两种自提直接创建换标出库单。客户确认前不得进入出库 |
| 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/`、`relabel/pda/`;一条 `ll_relabel_item` 对应一个客户填写的我方箱唛任务,`CARTON_LABEL`、`SKU_LABEL`、`CARTON_AND_SKU` 分别保存箱唛、SKU 或组合需求,SKU 子项 JSON 数量按件;普通尾程入库没有箱唛到实物托盘的精确快照,历史入仓库存仓位只能作为冻结记账锚点和现场大概位置,禁止把该仓位解释为箱唛已绑定具体托盘。库存只按箱任务冻结一次。涉及箱唛换标时申请必须选择 `TRUCK/EXPRESS/TRUCK_PICKUP/EXPRESS_PICKUP`,两种快递方式可要求打印并上传面单;PDA 扫我方箱唛完成逐箱事实和照片,仓库复核后等待客户查看照片确认。对外换标状态只投影为 `PENDING_PROCESSING/COMPLETED`(待处理/换标完成),`PROCESSING`、`CUSTOMER_CONFIRM_PENDING`、`READY_FOR_OUTBOUND` 只属于内部作业节点且不得直接出现在业务列表;客户确认后仅卡车派送进入 `WAIT_PRE_DISPATCH`,快递派送和两种自提直接创建换标出库单。客户确认前不得进入出库 |
| PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java`、`pda/warehouse/service/PdaWarehouseService.java`;解析库存、库位和托盘码并查询操作历史,固定要求 `lastleg:pda:operate` 与 `ROLE_WAREHOUSE_PICKER`,仓库范围只能取自 PDA Token |
| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java`、`shipping/entity/ContainerShippingGate.java`、`shipping/mapper/ContainerShippingGateMapper.java`;按 `containerBookingId` 柜级接收,信封使用 `tenantId=0` 且门禁不存客户;只单调接受实际标准节点,`PICKUP_READY` 及以后才允许对应柜进入 PDA 收货候选 |
| 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,查询、预排与执行边界见“海外仓全流程开发路由” |
@@ -222,11 +222,12 @@ public class CargoDiscardService {
application.setUpdatedAt(reviewedAt); applications.updateById(application);
LastlegOrder linked = orders.findSource(customerId, "LASTLEG_DISCARD", "CARGO_DISCARD:" + application.getId());
if (linked != null) {
linked.setCurrentStatus(next); linked.setVersion((linked.getVersion() == null ? 0 : linked.getVersion()) + 1); linked.setUpdatedAt(reviewedAt);
String linkedStatus = mainOrderStatus(next);
linked.setCurrentStatus(linkedStatus); linked.setVersion((linked.getVersion() == null ? 0 : linked.getVersion()) + 1); linked.setUpdatedAt(reviewedAt);
orders.updateById(linked);
LastlegOrderStatusHistory orderHistory = new LastlegOrderStatusHistory(); orderHistory.setId(IdWorker.getId());
orderHistory.setCustomerId(customerId); orderHistory.setOrderId(linked.getId()); orderHistory.setFromStatus(PENDING_REVIEW);
orderHistory.setToStatus(next); orderHistory.setEventId("CARGO_DISCARD_REVIEW:" + application.getId()); orderHistory.setRemark(historyRemark); orderHistory.setCreatedAt(reviewedAt);
orderHistory.setToStatus(linkedStatus); orderHistory.setEventId("CARGO_DISCARD_REVIEW:" + application.getId()); orderHistory.setRemark(historyRemark); orderHistory.setCreatedAt(reviewedAt);
orderHistories.insert(orderHistory);
}
history(customerId, application.getId(), PENDING_REVIEW, next, operatorId, historyRemark, reviewedAt);
@@ -248,10 +249,12 @@ public class CargoDiscardService {
LocalDateTime at = now(); application.setStatus(to); application.setUpdatedAt(at); applications.updateById(application);
LastlegOrder linked = orders.findSource(customerId, "LASTLEG_DISCARD", "CARGO_DISCARD:" + application.getId());
if (linked != null) {
linked.setCurrentStatus(to); linked.setPackageCount(application.getTotalPackages());
String linkedFrom = mainOrderStatus(from);
String linkedTo = mainOrderStatus(to);
linked.setCurrentStatus(linkedTo); linked.setPackageCount(application.getTotalPackages());
linked.setVersion((linked.getVersion() == null ? 0 : linked.getVersion()) + 1); linked.setUpdatedAt(at); orders.updateById(linked);
LastlegOrderStatusHistory h = new LastlegOrderStatusHistory(); h.setId(IdWorker.getId()); h.setCustomerId(customerId); h.setOrderId(linked.getId());
h.setFromStatus(from); h.setToStatus(to); h.setEventId("CARGO_DISCARD_STATUS:" + application.getId() + ":" + to); h.setRemark(remark); h.setCreatedAt(at); orderHistories.insert(h);
h.setFromStatus(linkedFrom); h.setToStatus(linkedTo); h.setEventId("CARGO_DISCARD_STATUS:" + application.getId() + ":" + linkedTo); h.setRemark(remark); h.setCreatedAt(at); orderHistories.insert(h);
}
history(customerId, application.getId(), from, to, operatorId, remark, at);
}
@@ -360,6 +363,15 @@ public class CargoDiscardService {
}
}
/** Maps application/PDA-only states to the five states exposed by the tail-leg main order. */
private static String mainOrderStatus(String status) {
return switch (status) {
case REJECTED -> CANCELLED;
case PDA_COMPLETED -> "PROCESSING";
default -> status;
};
}
private String hash(Object value) {
try {
byte[] json = objectMapper.writeValueAsBytes(value);
@@ -26,6 +26,9 @@ import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
@@ -62,6 +65,7 @@ public class DispatchService {
@Resource private LastlegOrderNoGenerator orderNoGenerator;
@Resource private LastlegOrderMapper tailOrders;
@Resource private OutboundOrderMapper outboundOrders;
@Resource private RelabelApplicationMapper relabelApplications;
public String resolveCustomerScopeForPlans(List<Long> planIds) {
return requireSingleCustomerScope(candidates.findCustomerScopesByPlanIds(normalizeIds(planIds, "Plan IDs")),
@@ -75,9 +79,16 @@ public class DispatchService {
}
public String resolveCustomerScopeForRelabel(String relabelNo) {
List<DispatchPalletCandidate> values = candidates.relabelAvailable(null, null, trimToNull(relabelNo));
return requireSingleCustomerScope(values.stream().map(DispatchPalletCandidate::customerId)
.filter(StringUtils::hasText).distinct().toList(), "Selected relabel order");
String normalized = trimToNull(relabelNo);
RelabelApplication application = normalized == null ? null : relabelApplications.findAnyByRelabelNo(normalized);
if (application == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "换标单不存在,请检查换标单号");
if (!RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "换标尚未完成或客户尚未确认,无法进行排车");
}
if (!"TRUCK".equals(application.getDeliveryMethod())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "该换标单不是卡车派送,无需进行排车");
}
return application.getCustomerId();
}
public List<DispatchPalletCandidate> candidates(String customerId, List<Long> planIds, String orderNo,
@@ -415,6 +426,10 @@ public class DispatchService {
@Transactional
private DispatchResponse preArrangeRelabel(String customerId, String operatorId, DispatchRequests.PreArrange request) {
RelabelApplication application = relabelApplications.findNo(customerId, request.relabelNo());
if (application == null || !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "换标尚未完成或客户尚未确认,无法进行排车");
}
List<DispatchPalletCandidate> selected = candidates.relabelAvailable(customerId, null, request.relabelNo());
Set<Long> requested = request.planPalletIds().stream().map(Long::valueOf).collect(Collectors.toSet());
selected = selected.stream().filter(c -> requested.contains(c.warehousePalletId())).toList();
@@ -13,7 +13,9 @@ public enum LastlegOrderType {
/** Return shipment that also requires relabeling. */
RETURN_RELABEL("退货换标"),
/** One-click dropship order, retained for existing data and workflows. */
ONE_CLICK_DROPSHIP("一件代发");
ONE_CLICK_DROPSHIP("一件代发"),
/** Cargo discard order created from an approved cargo discard application. */
CARGO_DISCARD("货物废弃订单");
private final String label;
@@ -381,11 +381,11 @@ public class RelabelApplicationService implements WalletResultHandler {
if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) {
throw badRequest("createdFrom must not be after createdTo");
}
String requestedStatus = trim(query.status());
LambdaQueryWrapper<RelabelApplication> wrapper = new LambdaQueryWrapper<RelabelApplication>()
.eq(StringUtils.hasText(customerId), RelabelApplication::getCustomerId, customerId)
.like(StringUtils.hasText(query.relabelNo()), RelabelApplication::getRelabelNo, query.relabelNo())
.like(StringUtils.hasText(query.customerOrderNo()), RelabelApplication::getCustomerOrderNo, query.customerOrderNo())
.eq(StringUtils.hasText(query.status()), RelabelApplication::getStatus, query.status())
.eq(StringUtils.hasText(query.overseasWarehouseId()), RelabelApplication::getOverseasWarehouseId,
trim(query.overseasWarehouseId()))
.ge(query.createdFrom() != null, RelabelApplication::getCreatedAt, query.createdFrom())
@@ -394,6 +394,14 @@ public class RelabelApplicationService implements WalletResultHandler {
"EXISTS (SELECT 1 FROM ll_relabel_item ri WHERE ri.application_id=ll_relabel_application.id AND ri.customer_id=ll_relabel_application.customer_id AND ri.sku_code LIKE CONCAT('%',{0},'%'))",
query.skuCode())
.orderByDesc(RelabelApplication::getCreatedAt);
if (PENDING_PROCESSING.equals(requestedStatus)) {
wrapper.in(RelabelApplication::getStatus, PENDING_PROCESSING, "PROCESSING", CUSTOMER_CONFIRM_PENDING,
"REJECTED", "CANCELLED");
} else if ("COMPLETED".equals(requestedStatus)) {
wrapper.in(RelabelApplication::getStatus, READY_FOR_OUTBOUND, "COMPLETED");
} else if (StringUtils.hasText(requestedStatus)) {
wrapper.eq(RelabelApplication::getStatus, requestedStatus);
}
Page<RelabelApplication> page = applications.selectPage(new Page<>(query.page(), query.size()), wrapper);
List<RelabelResponses.Summary> records = page.getRecords().stream().map(value -> summary(value, itemList(value.getId()))).toList();
return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
@@ -488,7 +496,7 @@ public class RelabelApplicationService implements WalletResultHandler {
};
order.setCurrentStatus(orderStatus);
order.setSubOrderStatus("RELABEL:" + next);
order.setRelabelStatus(next);
order.setRelabelStatus(businessRelabelStatus(next));
order.setUpdatedAt(now);
orders.updateById(order);
if (!Objects.equals(orderBefore, orderStatus)) {
@@ -586,7 +594,7 @@ public class RelabelApplicationService implements WalletResultHandler {
String orderBefore = order.getCurrentStatus();
order.setCurrentStatus("PENDING_ASSIGNMENT");
order.setSubOrderStatus("RELABEL:" + application.getFulfillmentStatus());
order.setRelabelStatus(READY_FOR_OUTBOUND);
order.setRelabelStatus("COMPLETED");
order.setUpdatedAt(now);
orders.updateById(order);
if (!Objects.equals(orderBefore, order.getCurrentStatus())) {
@@ -609,9 +617,9 @@ public class RelabelApplicationService implements WalletResultHandler {
if (transferOrder != null && customerId.equals(transferOrder.getCustomerId())) {
String transferOrderBefore = transferOrder.getCurrentStatus();
transferOrder.setCurrentStatus("READY_FOR_DISPATCH");
transferOrder.setRelabelStatus(READY_FOR_OUTBOUND);
transferOrder.setRelabelStatus("COMPLETED");
transferOrder.setSubOrderStatus("WAREHOUSE_TRANSFER:READY_FOR_DISPATCH;RELABEL:"
+ READY_FOR_OUTBOUND);
+ "COMPLETED");
transferOrder.setUpdatedAt(now);
orders.updateById(transferOrder);
if (!Objects.equals(transferOrderBefore, transferOrder.getCurrentStatus())) {
@@ -889,6 +897,11 @@ public class RelabelApplicationService implements WalletResultHandler {
|| READY_FOR_OUTBOUND.equals(from) && Set.of("COMPLETED", "CANCELLED").contains(to);
if (!allowed) throw conflict("Relabel status cannot change from " + from + " to " + to);
}
private static String businessRelabelStatus(String workflowStatus) {
return Set.of(READY_FOR_OUTBOUND, "COMPLETED").contains(workflowStatus)
? "COMPLETED" : PENDING_PROCESSING;
}
private String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(objectMapper.copy().findAndRegisterModules().writeValueAsString(value).getBytes(StandardCharsets.UTF_8))); }
@@ -52,6 +52,7 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@Service
@@ -511,7 +512,7 @@ public class LastlegOrderService {
? containerNodes.get(order.getSourceContainerBookingId()) : null), csv(order.getAbnormal()), csv(order.getDetained()),
csv(order.getPalletCount()), csv(order.getPackageCount()), csv(order.getVolumeCbm()), csv(order.getProductQuantity()),
csv(order.getVolumeWeightKg()), csv(order.getEstimatedArrivalAt()), csv(order.getActualArrivalAt()),
csv(order.getEstimatedWarehouseAt()), csv(order.getInboundOrderNo()), csv(order.getRelabelOrderNo()), csv(order.getRelabelStatus()),
csv(order.getEstimatedWarehouseAt()), csv(order.getInboundOrderNo()), csv(order.getRelabelOrderNo()), csv(relabelBusinessStatus(order.getRelabelStatus())),
csv(order.getPalletMergeFlag()), csv(order.getCurrentStatus()), csv(order.getSubOrderStatus()), csv(order.getPrivateRemark()),
csv(order.getCustomerRemark()), csv(order.getWarehouseRemark()))) + "\r\n";
output.write(line.getBytes(StandardCharsets.UTF_8));
@@ -602,7 +603,7 @@ public class LastlegOrderService {
Boolean.TRUE.equals(o.getManualNodeOverride()), Boolean.TRUE.equals(o.getManualNodeOverride()) ? "MANUAL" : "CONTAINER",
o.getVersion() == null ? 0 : o.getVersion(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(),
o.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(),
o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), dispatchStatus, o.getRelabelOrderNo(), o.getRelabelStatus(),
o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), dispatchStatus, o.getRelabelOrderNo(), relabelBusinessStatus(o.getRelabelStatus()),
o.getPalletMergeFlag(), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::mergeGroupNo).distinct().findFirst().orElse(null),
related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::relatedInboundNo).filter(Objects::nonNull).distinct().toList(),
o.getCurrentStatus(), o.getSubOrderStatus(), o.getPrivateRemark(),
@@ -639,6 +640,12 @@ public class LastlegOrderService {
? null : order.getInboundCode();
}
private static String relabelBusinessStatus(String workflowStatus) {
if (!StringUtils.hasText(workflowStatus) || "NOT_REQUIRED".equals(workflowStatus)) return workflowStatus;
return Set.of("READY_FOR_OUTBOUND", "COMPLETED").contains(workflowStatus)
? "COMPLETED" : "PENDING_PROCESSING";
}
private static Long parseLong(String value) {
try { return StringUtils.hasText(value) ? Long.parseLong(value.trim()) : null; }
catch (NumberFormatException ignored) { return null; }
@@ -175,7 +175,7 @@ paths:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
- { name: orderNo, in: query, schema: { type: string } }
- { name: orderType, in: query, schema: { type: string, enum: [FIRST_LEG, RELABEL_APPLICATION, WAREHOUSE_TRANSFER, LOCAL_LOOSEBOARD, RETURN_RELABEL, ONE_CLICK_DROPSHIP] } }
- { name: orderType, in: query, schema: { type: string, enum: [FIRST_LEG, RELABEL_APPLICATION, WAREHOUSE_TRANSFER, LOCAL_LOOSEBOARD, RETURN_RELABEL, ONE_CLICK_DROPSHIP, CARGO_DISCARD] } }
- { name: currentStatus, in: query, schema: { type: string } }
- { name: inboundCode, in: query, schema: { type: string } }
- { name: sourceOrderNo, in: query, schema: { type: string } }
@@ -11,11 +11,13 @@ class LastlegOrderTypeTest {
void exposesTheSupportedBusinessTypes() {
assertEquals(
Arrays.asList("FIRST_LEG", "RELABEL_APPLICATION", "WAREHOUSE_TRANSFER",
"LOCAL_LOOSEBOARD", "RETURN_RELABEL", "ONE_CLICK_DROPSHIP"),
"LOCAL_LOOSEBOARD", "RETURN_RELABEL", "ONE_CLICK_DROPSHIP", "CARGO_DISCARD"),
Arrays.stream(LastlegOrderType.values()).map(Enum::name).toList());
assertEquals("换标下单", LastlegOrderType.RELABEL_APPLICATION.label());
assertEquals("本土散板", LastlegOrderType.LOCAL_LOOSEBOARD.label());
assertEquals("一件代发", LastlegOrderType.labelOf("ONE_CLICK_DROPSHIP"));
assertEquals("货物废弃订单", LastlegOrderType.CARGO_DISCARD.label());
assertEquals("货物废弃订单", LastlegOrderType.labelOf("CARGO_DISCARD"));
assertEquals("LEGACY_TYPE", LastlegOrderType.labelOf("LEGACY_TYPE"));
}
}
@@ -440,7 +440,7 @@ class RelabelApplicationServiceTest {
assertEquals("READY_FOR_DISPATCH", transfer.getStatus());
assertNotNull(transfer.getRelabelCompletedAt());
assertEquals("READY_FOR_DISPATCH", transferOrder.getCurrentStatus());
assertEquals(RelabelApplicationService.READY_FOR_OUTBOUND, transferOrder.getRelabelStatus());
assertEquals("COMPLETED", transferOrder.getRelabelStatus());
verify(warehouseTransfers).updateById(transfer);
verify(orders).updateById(transferOrder);
}