一件代发拣货

This commit is contained in:
2026-09-01 09:57:10 +08:00
parent a213be67c5
commit 2f1bda8369
16 changed files with 154 additions and 9 deletions
@@ -2792,6 +2792,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID,不建立跨库外键',
cargo_item_no INT UNSIGNED NOT NULL COMMENT '订单货物明细序号快照',
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
sku_code VARCHAR(128) NULL COMMENT '箱唛绑定的SKU快照;二维码内容不变',
detail_package_count INT UNSIGNED NOT NULL COMMENT '该订单货物明细箱数快照',
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标',
@@ -2829,6 +2830,7 @@ BEGIN
END$$
DELIMITER ;
CALL unified_carton_label_add_column('label_version', 'INT UNSIGNED NOT NULL DEFAULT 1 COMMENT ''内部兼容批次字段,固定为1且不进入箱码'' AFTER carton_index');
CALL unified_carton_label_add_column('sku_code', 'VARCHAR(128) NULL COMMENT ''箱唛绑定的SKU快照;二维码内容不变'' AFTER cargo_name');
CALL unified_carton_label_add_column('batch_status', 'VARCHAR(16) NOT NULL DEFAULT ''ACTIVE'' COMMENT ''批次状态:ACTIVE有效、VOID已作废'' AFTER label_version');
CALL unified_carton_label_add_column('cargo_fingerprint', 'CHAR(64) NULL COMMENT ''影响标签字段的SHA-256指纹'' AFTER batch_status');
CALL unified_carton_label_add_column('voided_at', 'DATETIME(3) NULL COMMENT ''批次作废时间'' AFTER cargo_fingerprint');
@@ -63,7 +63,12 @@ public interface OrderCargoClient {
}
record CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm,
String skuNo, Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm,
BigDecimal heightCm) {
public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm,
BigDecimal heightCm) {
this(cargoId, orderId, itemNo, cargoName, null, packageCount, lengthCm, widthCm, heightCm);
}
}
}
@@ -22,6 +22,7 @@ public class InboundCartonLabelEntity {
private Long inboundDetailId;
private Integer cargoItemNo;
private String cargoName;
private String skuCode;
private Integer detailPackageCount;
private String inboundCode;
private String boxCode;
@@ -62,6 +62,16 @@ public class InboundCartonLabelRepository {
.set(InboundCartonLabelEntity::getCargoFingerprint, fingerprint));
}
public void initializeLegacySku(long tenantId, long inboundId, long inboundDetailId, String skuCode) {
if (skuCode == null || skuCode.isBlank()) return;
labels.update(null, new LambdaUpdateWrapper<InboundCartonLabelEntity>()
.eq(InboundCartonLabelEntity::getTenantId, tenantId)
.eq(InboundCartonLabelEntity::getInboundId, inboundId)
.eq(InboundCartonLabelEntity::getInboundDetailId, inboundDetailId)
.isNull(InboundCartonLabelEntity::getSkuCode)
.set(InboundCartonLabelEntity::getSkuCode, skuCode.trim()));
}
public boolean initializeOriginalPackageCount(long tenantId, long inboundId, int packageCount) {
return inbounds.update(null, new LambdaUpdateWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, tenantId)
@@ -50,6 +50,14 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
throw new BizException(ResultCode.CONFLICT, "当前入库单状态不允许首次生成箱号");
}
List<OrderCargoClient.CargoLabelBinding> cargoes = loadCargoes(inbound);
if ("ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())
&& cargoes.stream().anyMatch(cargo -> cargo.skuNo() == null || cargo.skuNo().isBlank())) {
throw new BizException(ResultCode.VALIDATION_FAILED, "一件代发货物明细缺少SKU,不能生成箱唛");
}
existing.forEach(label -> cargoes.stream()
.filter(cargo -> java.util.Objects.equals(cargo.cargoId(), label.getInboundDetailId()))
.findFirst().ifPresent(cargo -> repository.initializeLegacySku(tenantId, inboundId,
label.getInboundDetailId(), cargo.skuNo())));
String fingerprint = fingerprint(cargoes);
if (!existing.isEmpty()) {
String existingFingerprint = consistent.get(0).getCargoFingerprint();
@@ -202,6 +210,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
label.setInboundDetailId(cargo.cargoId());
label.setCargoItemNo(cargo.itemNo());
label.setCargoName(cargo.cargoName());
label.setSkuCode(cargo.skuNo());
label.setDetailPackageCount(cargo.packageCount());
label.setInboundCode(inbound.getInboundCode());
label.setBoxCode(inbound.getInboundCode() + "#" + total + "-" + index);
@@ -256,7 +265,8 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
private String fingerprint(List<OrderCargoClient.CargoLabelBinding> cargoes) {
String source = cargoes.stream().map(cargo -> String.join("|", String.valueOf(cargo.itemNo()),
cargo.cargoName() == null ? "" : cargo.cargoName().trim(), String.valueOf(cargo.packageCount()),
cargo.cargoName() == null ? "" : cargo.cargoName().trim(),
cargo.skuNo() == null ? "" : cargo.skuNo().trim(), String.valueOf(cargo.packageCount()),
decimalOrEmpty(cargo.lengthCm()), decimalOrEmpty(cargo.widthCm()), decimalOrEmpty(cargo.heightCm())))
.collect(java.util.stream.Collectors.joining("\n"));
try {
@@ -15,6 +15,7 @@ DELIMITER ;
CALL phase2_carton_label_add_column('label_version', 'INT UNSIGNED NOT NULL DEFAULT 1 COMMENT ''内部兼容批次字段,固定为1且不进入箱码'' AFTER carton_index');
CALL phase2_carton_label_add_column('batch_status', 'VARCHAR(16) NOT NULL DEFAULT ''ACTIVE'' COMMENT ''批次状态:ACTIVE有效、VOID已作废'' AFTER label_version');
CALL phase2_carton_label_add_column('cargo_fingerprint', 'CHAR(64) NULL COMMENT ''影响标签字段的SHA-256指纹'' AFTER batch_status');
CALL phase2_carton_label_add_column('sku_code', 'VARCHAR(128) NULL COMMENT ''箱唛绑定的SKU快照;二维码内容不变'' AFTER cargo_name');
CALL phase2_carton_label_add_column('voided_at', 'DATETIME(3) NULL COMMENT ''批次作废时间'' AFTER cargo_fingerprint');
DROP PROCEDURE phase2_carton_label_add_column;
@@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID,不建立跨库外键',
cargo_item_no INT UNSIGNED NOT NULL COMMENT '订单货物明细序号快照',
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
sku_code VARCHAR(128) NULL COMMENT '箱唛绑定的SKU快照;二维码内容不变',
detail_package_count INT UNSIGNED NOT NULL COMMENT '该订单货物明细箱数快照',
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标',
@@ -197,6 +197,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID,不建立跨库外键',
cargo_item_no INT UNSIGNED NOT NULL COMMENT '订单货物明细序号快照',
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
sku_code VARCHAR(128) NULL COMMENT '箱唛绑定的SKU快照;二维码内容不变',
detail_package_count INT UNSIGNED NOT NULL COMMENT '该订单货物明细箱数快照',
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标',
@@ -154,6 +154,14 @@ public class DispatchPdaWorkflowService {
if (replay != null) return read(replay, DispatchPdaResponses.PickingCompleted.class).withReplay();
requireVehicle(order);
DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId());
if (task != null && "COMPLETED".equals(task.getStatus())) {
List<DispatchItem> completedItems = items.findByDispatchOrder(customerId, order.getId());
long planned = completedItems.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull)
.mapToLong(Integer::longValue).sum();
long picked = picks.listTask(customerId, task.getId()).stream().mapToLong(p -> n(p.getPackages())).sum();
return new DispatchPdaResponses.PickingCompleted(order.getDispatchNo(), order.getStatus(), planned,
picked, Math.max(0, planned - picked), picked < planned, true);
}
if (task == null || !("PDA_PENDING".equals(task.getStatus()) || "PICKING".equals(task.getStatus()))) {
throw conflict("Dispatch PDA task is not available for completion");
}
@@ -243,6 +243,18 @@ public class InboundPlanService {
if (trimToNull(request.warehouseName()) != null) line.setDestinationWarehouseName(request.warehouseName().trim());
lineMapper.updateById(line);
}
// Pallets carry the same destination snapshot and are consumed by PDA/resource assignment.
// Update them in this transaction so a plan edit cannot leave lines and pallets pointing at
// different warehouses.
List<InboundPlanPallet> pallets = palletMapper.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getPlanId, plan.getId())
.eq(InboundPlanPallet::getCustomerId, plan.getCustomerId()));
for (InboundPlanPallet pallet : pallets) {
pallet.setDestinationWarehouseId(String.valueOf(request.overseasWarehouseId()));
if (trimToNull(request.warehouseCode()) != null) pallet.setDestinationWarehouseCode(request.warehouseCode().trim());
if (trimToNull(request.warehouseName()) != null) pallet.setDestinationWarehouseName(request.warehouseName().trim());
palletMapper.updateById(pallet);
}
return detail(customerId, plan);
}
@@ -32,7 +32,7 @@ class LastlegOrderControllerTest {
private final LastlegOrderService service = mock(LastlegOrderService.class);
private final LastlegOrderController controller = controller();
private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query(
1, 20, null, null, null, null, null, null, null, null, null, null, null, null, null);
1L, 20L, null, null, null, null, null, null, null, null, null, null, null, null, null);
@Test
void platformEndpointRejectsMerchantIdentity() {
@@ -18,7 +18,7 @@ class MerchantLastlegOrderControllerTest {
private final LastlegOrderService service = mock(LastlegOrderService.class);
private final MerchantLastlegOrderController controller = controller();
private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query(
1, 20, null, null, null, null, null, null, null, null, null, null, null, null, null);
1L, 20L, null, null, null, null, null, null, null, null, null, null, null, null, null);
@Test
void merchantListUsesOnlyAuthenticatedTenant() {
@@ -2,11 +2,17 @@ package com.baiyue.lastleg.dispatch.pda.service;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaOperation;
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPick;
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask;
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaOperationMapper;
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickMapper;
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -15,6 +21,8 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -25,6 +33,9 @@ class DispatchPdaWorkflowServiceTest {
@Mock private DispatchOrderMapper orders;
@Mock private DispatchPdaOperationMapper operations;
@Mock private OutboundOrderMapper outboundOrders;
@Mock private DispatchItemMapper items;
@Mock private DispatchPdaTaskMapper tasks;
@Mock private DispatchPdaPickMapper picks;
@Test
void labelsTransferredPalletsWithOriginalAndCurrentFbaWarehouses() {
@@ -126,4 +137,34 @@ class DispatchPdaWorkflowServiceTest {
assertEquals("DEPARTED", result.status());
assertEquals(true, result.replayed());
}
@Test
void completingAnAutomaticallyCompletedTaskReturnsIdempotentSuccess() {
DispatchPdaWorkflowService service = new DispatchPdaWorkflowService();
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "operations", operations);
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "tasks", tasks);
ReflectionTestUtils.setField(service, "picks", picks);
DispatchOrder order = new DispatchOrder();
order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1");
order.setStatus("READY_TO_DEPART"); order.setVehiclePlate("CA-100");
DispatchPdaTask task = new DispatchPdaTask();
task.setId(2L); task.setStatus("COMPLETED");
DispatchItem item = new DispatchItem(); item.setPackageCount(6);
DispatchPdaPick pick = new DispatchPdaPick(); pick.setPackages(6L);
DispatchPdaRequests.CompletePicking request = new DispatchPdaRequests.CompletePicking("complete-1", "normal");
when(orders.findByDispatchNo("100", "PC1")).thenReturn(order);
when(orders.lockById("100", 1L)).thenReturn(order);
when(operations.findKey("100", "complete-1")).thenReturn(null);
when(tasks.lockByDispatch("100", 1L)).thenReturn(task);
when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item));
when(picks.listTask("100", 2L)).thenReturn(List.of(pick));
DispatchPdaResponses.PickingCompleted result = service.completePicking("100", "PC1", request);
assertEquals("READY_TO_DEPART", result.status());
assertEquals(6, result.pickedPackages());
assertEquals(true, result.replayed());
}
}
@@ -2,6 +2,7 @@ package com.baiyue.lastleg.inboundplan.service;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
import com.baiyue.lastleg.inboundplan.dto.UpdateInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
@@ -199,6 +200,53 @@ class InboundPlanServiceTest {
.map(InboundPlanPalletAllocation::getPlannedPackageCount).toList());
}
@Test
void updateSynchronizesDestinationSnapshotToLinesAndPallets() {
InboundPlan plan = new InboundPlan();
plan.setId(20L);
plan.setPlanNo("IP-20");
plan.setCustomerId("100");
plan.setStatus("PENDING_ASSIGNMENT");
plan.setVersion(3);
InboundPlanLine line = new InboundPlanLine();
line.setId(21L);
line.setCustomerId("100");
line.setPlanId(20L);
line.setDestinationWarehouseId("OLD-WH");
line.setDestinationWarehouseCode("OLD-CODE");
line.setDestinationWarehouseName("Old warehouse");
InboundPlanPallet pallet = new InboundPlanPallet();
pallet.setId(22L);
pallet.setCustomerId("100");
pallet.setPlanId(20L);
pallet.setDestinationWarehouseId("OLD-WH");
pallet.setDestinationWarehouseCode("OLD-CODE");
pallet.setDestinationWarehouseName("Old warehouse");
when(planMapper.selectOne(any())).thenReturn(plan);
when(lineMapper.selectList(any())).thenReturn(List.of(line), List.of(line));
when(palletMapper.selectList(any())).thenReturn(List.of(pallet), List.of(pallet));
when(allocationMapper.selectList(any())).thenReturn(List.of());
service.update("100", "IP-20",
new UpdateInboundPlanRequest("100", 193000000000200003L,
" US-LAX-01 ", " Los Angeles Warehouse ", 3));
ArgumentCaptor<InboundPlanLine> updatedLine = ArgumentCaptor.forClass(InboundPlanLine.class);
verify(lineMapper).updateById(updatedLine.capture());
assertEquals("193000000000200003", updatedLine.getValue().getDestinationWarehouseId());
assertEquals("US-LAX-01", updatedLine.getValue().getDestinationWarehouseCode());
assertEquals("Los Angeles Warehouse", updatedLine.getValue().getDestinationWarehouseName());
ArgumentCaptor<InboundPlanPallet> updatedPallet = ArgumentCaptor.forClass(InboundPlanPallet.class);
verify(palletMapper).updateById(updatedPallet.capture());
assertEquals("193000000000200003", updatedPallet.getValue().getDestinationWarehouseId());
assertEquals("US-LAX-01", updatedPallet.getValue().getDestinationWarehouseCode());
assertEquals("Los Angeles Warehouse", updatedPallet.getValue().getDestinationWarehouseName());
}
private SourceOrderSnapshot order(Long id, String inboundCode, String volume, String warehouse) {
return new SourceOrderSnapshot(id, "100", "CONT-1", inboundCode, "PENDING_ASSIGNMENT", null,
new BigDecimal(volume), new BigDecimal(volume).multiply(BigDecimal.TEN).intValue(),
@@ -182,7 +182,7 @@ class LastlegOrderServiceTest {
@Test
void pageUsesPaginationAndPlatformFiltersWithoutCustomerScope() {
var query = new LastlegOrderRequests.Query(3, 25, "LL-", "FIRST_LEG", "IN-", null,
var query = new LastlegOrderRequests.Query(3L, 25L, "LL-", "FIRST_LEG", "IN-", null,
"", "PENDING_ASSIGNMENT", null, "LAX", null, null, null, null, null);
var resultPage = new com.baomidou.mybatisplus.extension.plugins.pagination.Page<LastlegOrder>(3, 25);
resultPage.setTotal(0);
@@ -208,7 +208,7 @@ class LastlegOrderServiceTest {
when(orders.findPalletMergeRelations(List.of(11L))).thenReturn(List.of(
new LastlegOrderMapper.PalletMergeRelationRow(11L, "INBOUND-2", "PMG-1"),
new LastlegOrderMapper.PalletMergeRelationRow(11L, "INBOUND-3", "PMG-1")));
var query = new LastlegOrderRequests.Query(1, 10, null, null, null, null,
var query = new LastlegOrderRequests.Query(1L, 10L, null, null, null, null,
null, null, null, null, null, null, null, null, null);
var result = service.page(null, query);
@@ -74,8 +74,8 @@ public class InternalOrderCargoController {
}
return ApiResponse.success(orders.findCargoInheritedByOrder(tenantId, orderId).stream()
.map(cargo -> new CargoLabelBinding(cargo.getId(), orderId, cargo.getItemNo(),
cargo.getCargoName(), cargo.getPackageCount(), cargo.getLengthCm(), cargo.getWidthCm(),
cargo.getHeightCm()))
cargo.getCargoName(), cargo.getSkuNo(), cargo.getPackageCount(), cargo.getLengthCm(),
cargo.getWidthCm(), cargo.getHeightCm()))
.toList());
}
@@ -155,8 +155,13 @@ public class InternalOrderCargoController {
}
public record CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer packageCount, java.math.BigDecimal lengthCm,
String skuNo, Integer packageCount, java.math.BigDecimal lengthCm,
java.math.BigDecimal widthCm, java.math.BigDecimal heightCm) {
public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer packageCount, java.math.BigDecimal lengthCm,
java.math.BigDecimal widthCm, java.math.BigDecimal heightCm) {
this(cargoId, orderId, itemNo, cargoName, null, packageCount, lengthCm, widthCm, heightCm);
}
}
public record MeasurementDeclarationSnapshot(