This commit is contained in:
wujinliang
2026-09-18 11:41:04 +08:00
parent 263068ac85
commit 1fc3e04723
38 changed files with 378 additions and 61 deletions
@@ -28,7 +28,7 @@ final class ExportSchemaContracts {
column("mainStatus", "订单主状态", ExportColumnType.STRING),
column("customerName", "客户", ExportColumnType.STRING),
column("channelName", "渠道", ExportColumnType.STRING),
column("channelChanged", "曾变更渠道", ExportColumnType.BOOLEAN),
column("channelChanged", "曾变更渠道", ExportColumnType.STRING),
column("receivingWarehouseName", "定位仓库", ExportColumnType.STRING),
column("destinationWarehouseName", "交货仓", ExportColumnType.STRING),
column("expectedShipDate", "预计出运日期", ExportColumnType.DATE),
@@ -44,7 +44,7 @@ final class ExportSchemaContracts {
column("skuNos", "SKU", ExportColumnType.STRING),
column("taxMode", "报关方式", ExportColumnType.STRING),
column("containerMode", "装柜方式", ExportColumnType.STRING),
column("customsInspection", "是否商检", ExportColumnType.BOOLEAN),
column("customsInspection", "是否商检", ExportColumnType.STRING),
column("containerNo", "集装箱号", ExportColumnType.STRING),
column("cabinetNo", "柜次号", ExportColumnType.STRING),
column("vesselName", "船名", ExportColumnType.STRING),
@@ -114,7 +114,7 @@ components:
mainStatus: {type: string, title: 订单主状态}
customerName: {type: string, title: 客户}
channelName: {type: string, title: 渠道}
channelChanged: {type: boolean, title: 曾变更渠道}
channelChanged: {type: string, title: 曾变更渠道, description: 是或否}
receivingWarehouseName: {type: string, title: 定位仓库}
destinationWarehouseName: {type: string, title: 交货仓}
expectedShipDate: {type: string, format: date, title: 预计出运日期}
@@ -130,7 +130,7 @@ components:
skuNos: {type: string, title: SKU}
taxMode: {type: string, title: 报关方式}
containerMode: {type: string, title: 装柜方式}
customsInspection: {type: boolean, title: 是否商检}
customsInspection: {type: string, title: 是否商检, description: 是或否}
containerNo: {type: string, title: 集装箱号}
cabinetNo: {type: string, title: 柜次号}
vesselName: {type: string, title: 船名}
@@ -57,6 +57,8 @@ class DomesticWarehouseOrderExportContractTest {
assertThat(columns).containsKeys("expectedVolumeCbm", "expectedArrivalAt")
.doesNotContainKeys("orderId", "tenantId", "customerId", "inboundId", "receivingWarehouseId",
"destinationWarehouseId");
assertThat(map(columns.get("channelChanged")).get("type")).isEqualTo("string");
assertThat(map(columns.get("customsInspection")).get("type")).isEqualTo("string");
assertThat(source).contains("platform:order:export", "selectedOrderNos 非空时优先", "no-store");
assertThat(paths).containsKey("/api/platform/v1/files/export-tasks/{taskId}/retry");
assertThat(map(map(schemas.get("ExportTaskRetryState")).get("properties")))
@@ -157,6 +157,9 @@ class GenericXlsxExportRunnerTest {
Map<String, Object> row = new java.util.LinkedHashMap<>();
row.put("inboundCode", "IN-1"); row.put("orderNo", "ORD-1");
row.put("expectedVolumeCbm", "1.25"); row.put("expectedArrivalAt", "2026-09-03T12:30:00");
row.put("mainStatus", "待审核"); row.put("channelChanged", "否");
row.put("feeReviewStatus", "未提交"); row.put("taxMode", "买单报关");
row.put("containerMode", "拼箱"); row.put("customsInspection", "否");
var batch = new ExportBatchResponse(1, "sha256:domestic-orders", "snapshot-orders",
columns, List.of(row), null, false, 1L);
when(provider.batch(anyString(), eq(ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_LIST), any(), any()))
@@ -173,6 +176,12 @@ class GenericXlsxExportRunnerTest {
assertEquals("预计到货时间", sheet.getRow(0).getCell(9).getStringCellValue());
assertEquals("预计体积(m³)", sheet.getRow(0).getCell(10).getStringCellValue());
assertEquals(1.25d, sheet.getRow(1).getCell(10).getNumericCellValue());
assertEquals("待审核", sheet.getRow(1).getCell(2).getStringCellValue());
assertEquals("否", sheet.getRow(1).getCell(5).getStringCellValue());
assertEquals("未提交", sheet.getRow(1).getCell(17).getStringCellValue());
assertEquals("买单报关", sheet.getRow(1).getCell(19).getStringCellValue());
assertEquals("拼箱", sheet.getRow(1).getCell(20).getStringCellValue());
assertEquals("否", sheet.getRow(1).getCell(21).getStringCellValue());
}
ArgumentCaptor<ExportBatchRequest> request = ArgumentCaptor.forClass(ExportBatchRequest.class);
verify(provider).batch(anyString(), eq(ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_LIST),
@@ -23,6 +23,8 @@ public final class InboundMeasurementResponses {
Integer expectedPalletCount,
String processStatus,
String measureReviewStatus,
@Schema(description = "当前最新费用版本是否已由客户确认;确认后测量数据不可再修改")
boolean customerFeeConfirmed,
@Schema(description = "测量完成后的推荐托盘类型,仅为建议,不代表已分配托盘",
allowableValues = "STANDARD", example = "STANDARD")
String recommendedPalletType,
@@ -109,7 +109,7 @@ public class InboundMeasurementService {
MeasureReviewRecordEntity review = repository.findLatestReview(tenantId, inbound.getId());
LatestDeclaration latestDeclaration = loadLatestDeclaration(inbound);
return toDetail(inbound, measurement, items, weights, allocationSlots, allocationsByWeight,
review, latestDeclaration);
review, latestDeclaration, customerFeeConfirmed(tenantId, inbound.getId()));
}
public PageResult<InboundMeasurementResponses.PalletSnapshot> weightPalletOptions(
@@ -236,10 +236,9 @@ public class InboundMeasurementService {
InboundOrderEntity inbound, String subject) {
var currentFee = pricingRepository.findLatestFeeForUpdate(tenantId, inboundId);
boolean customerConfirmed = currentFee != null && currentFee.getCustomerConfirmedAt() != null;
boolean loadingCompleted = Set.of("LOADED", "DELIVERED").contains(inbound.getProcessStatus());
if (customerConfirmed && loadingCompleted) {
if (customerConfirmed) {
throw new BizException(ResultCode.CONFLICT,
"客户已确认且该票已装柜完成,不能修改" + subject);
"客户已确认费用,不能修改" + subject);
}
}
@@ -569,12 +568,14 @@ public class InboundMeasurementService {
List<com.baiyue.firstleg.entity.InboundPalletAllocationEntity> allocationSlots,
Map<Long, com.baiyue.firstleg.entity.InboundPalletAllocationEntity> allocationsByWeight,
MeasureReviewRecordEntity review,
LatestDeclaration latestDeclaration) {
LatestDeclaration latestDeclaration,
boolean customerFeeConfirmed) {
InboundMeasurementResponses.DeclarationSnapshot declaration = latestDeclaration.snapshot();
return new InboundMeasurementResponses.Detail(
inbound.getId(), inbound.getInboundCode(), customerDisplayName(inbound), inbound.getChannelCode(),
inbound.getChannelName(), inbound.getFulfillmentType(), inbound.getPalletCount(),
inbound.getProcessStatus(), inbound.getMeasureReviewStatus(),
customerFeeConfirmed,
RECOMMENDED_PALLET_TYPE,
review == null ? null : review.getReviewReason(), review == null ? null : review.getReviewerId(),
review == null ? null : review.getReviewerName(), measurement == null ? null : measurement.getId(),
@@ -603,6 +604,11 @@ public class InboundMeasurementService {
toPalletLocationGuide(allocationSlots));
}
private boolean customerFeeConfirmed(long tenantId, long inboundId) {
var latestFee = pricingRepository.findLatestFee(tenantId, inboundId);
return latestFee != null && latestFee.getCustomerConfirmedAt() != null;
}
private static InboundMeasurementResponses.PalletLocationGuide toPalletLocationGuide(
List<com.baiyue.firstleg.entity.InboundPalletAllocationEntity> slots) {
if (slots.isEmpty()) return null;
@@ -1018,8 +1018,7 @@ paths:
summary: 更新箱规计算规则
description: >-
独立更新当前测量批次全部明细的箱规计算规则并重算体积,不覆盖整批测量数据,
不改变测量提交或审核状态。客户未确认或该票尚未装柜完成时均可修改;
仅当客户已确认费用且该票已装柜完成时冻结。
不改变测量提交或审核状态。客户确认费用后立即冻结,不得再修改。
parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
@@ -1036,8 +1035,7 @@ paths:
summary: 更新仓库外箱长宽高
description: >-
独立更新当前测量批次指定明细的仓库外箱尺寸并重算体积,不覆盖整批测量数据,
不改变测量提交或审核状态。客户未确认或该票尚未装柜完成时均可修改;
仅当客户已确认费用且该票已装柜完成时冻结。
不改变测量提交或审核状态。客户确认费用后立即冻结,不得再修改。
parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
@@ -1905,7 +1903,7 @@ components:
type: object
description: >-
入库测量权威详情。尺寸证据天然归属 items,称重证据天然归属 weightResults,响应不提供统一 images。
required: [inboundId, inboundVersion, items, weightResults]
required: [inboundId, inboundVersion, customerFeeConfirmed, items, weightResults]
properties:
inboundId: {type: string, pattern: '^[1-9][0-9]*$'}
inboundCode: {type: string, nullable: true, description: 当前测量详情唯一入仓码,UI业务主展示字段}
@@ -1914,6 +1912,7 @@ components:
channelName: {type: string, nullable: true}
processStatus: {type: string, nullable: true}
measureReviewStatus: {type: string, nullable: true}
customerFeeConfirmed: {type: boolean, description: 当前最新费用版本已由客户确认,确认后测量数据不可再修改}
recommendedPalletType: {type: string, nullable: true}
reviewReason: {type: string, nullable: true}
reviewerId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
@@ -124,8 +124,8 @@ class InboundMeasurementServiceTest {
}
@Test
void locksCalculationStrategyOnlyAfterCustomerConfirmationAndLoadingCompletion() {
InboundOrderEntity inbound = inbound("LOADED");
void locksCalculationStrategyAsSoonAsCustomerConfirmsFee() {
InboundOrderEntity inbound = inbound("WAIT_SPLIT");
InboundFeeEntity fee = new InboundFeeEntity();
fee.setCustomerConfirmedAt(LocalDateTime.now());
when(repository.lockInbound(1L, 10L)).thenReturn(inbound);
@@ -134,7 +134,7 @@ class InboundMeasurementServiceTest {
assertThatThrownBy(() -> service.updateCalculationStrategy(1L, 10L,
new InboundMeasurementRequests.UpdateCalculationStrategyRequest(30L, 5, "ACTUAL", null)))
.isInstanceOf(BizException.class)
.hasMessageContaining("客户已确认且该票已装柜完成");
.hasMessageContaining("客户已确认费用");
verify(repository, never()).lockMeasurement(anyLong(), anyLong(), anyLong());
}
@@ -245,6 +245,23 @@ class InboundMeasurementServiceTest {
verify(repository, never()).updateInbound(any());
}
@Test
void detailReportsCustomerFeeConfirmationForTheMeasurementLock() {
InboundOrderEntity inbound = inbound("WAIT_SPLIT");
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(30L);
InboundFeeEntity fee = new InboundFeeEntity();
fee.setCustomerConfirmedAt(LocalDateTime.now());
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findLatestMeasurement(1L, 10L)).thenReturn(measurement);
when(repository.listWeights(1L, 10L)).thenReturn(List.of());
when(pricingRepository.findLatestFee(1L, 10L)).thenReturn(fee);
InboundMeasurementResponses.Detail result = service.detail(1L, 10L);
assertThat(result.customerFeeConfirmed()).isTrue();
}
@Test
void detailReportsOrderContractNotReadyInsteadOfExposingDownstreamBadRequest() {
InboundOrderEntity inbound = inbound("MEASURE_PENDING");
@@ -51,6 +51,7 @@ public final class DispatchRequests {
public record Update(@Size(max = 64) String vehiclePlate, @Size(max = 128) String driverName,
@Size(max = 64) String driverPhone, @Size(max = 255) String carrierName,
LocalDateTime departAt,
@Size(max = 64) String fbaAppointmentNo,
@Size(max = 32) String fbaAppointmentStatus,
@Size(max = 32) String vehicleAppointmentStatus,
@@ -29,6 +29,14 @@ public interface DispatchCandidateMapper {
"MIN(l.order_id) AS orderId," +
"MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," +
"MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," +
"COALESCE(NULLIF(SUM(CASE WHEN COALESCE(l.volume_cbm,0)&gt;0 THEN " +
"COALESCE((SELECT SUM(d.gross_weight_kg) FROM ll_order_detail d WHERE d.customer_id=ip.customer_id AND d.order_id=l.order_id)," +
"(SELECT inbound.gross_weight_kg FROM ll_inbound_order inbound WHERE inbound.id=l.order_id AND inbound.customer_id=ip.customer_id LIMIT 1),0) " +
"* COALESCE(a.allocated_volume_cbm,0) / l.volume_cbm ELSE " +
"COALESCE((SELECT SUM(d.gross_weight_kg) FROM ll_order_detail d WHERE d.customer_id=ip.customer_id AND d.order_id=l.order_id)," +
"(SELECT inbound.gross_weight_kg FROM ll_inbound_order inbound WHERE inbound.id=l.order_id AND inbound.customer_id=ip.customer_id LIMIT 1),0) " +
"/ NULLIF((SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a2 WHERE a2.line_id=a.line_id),0) END),0)," +
"MIN(o.volume_weight_kg)) AS grossWeightKg," +
"MIN(ip.container_no) AS containerNo," +
"(SELECT MAX(fa.appointment_at) FROM ll_inbound_fba_appointment_item fi " +
"JOIN ll_inbound_fba_appointment fa ON fa.id=fi.appointment_id WHERE fi.line_id IN " +
@@ -38,7 +46,13 @@ public interface DispatchCandidateMapper {
"(SELECT pa.line_id FROM ll_inbound_plan_pallet_allocation pa WHERE pa.pallet_id=p.id)) AS expectedLoadingAt," +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " +
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " +
"AND pos.available_packages &gt; 0) ELSE CAST(ip.overseas_warehouse_id AS CHAR) END AS overseasWarehouseId," +
"AND pos.available_packages &gt; 0) ELSE COALESCE(NULLIF(CAST(ip.overseas_warehouse_id AS CHAR),'')," +
"(SELECT NULLIF(TRIM(source_order.overseas_warehouse_id),'') FROM ll_order source_order " +
"WHERE source_order.id=ip.source_order_id AND source_order.customer_id=ip.customer_id LIMIT 1)) END AS overseasWarehouseId," +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_code) " +
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0) " +
"ELSE MIN(o.overseas_warehouse_code) END AS overseasWarehouseCode," +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_name) " +
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0) " +
@@ -115,6 +129,14 @@ public interface DispatchCandidateMapper {
"MIN(l.order_id) AS orderId," +
"MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," +
"MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," +
"COALESCE(NULLIF(SUM(CASE WHEN COALESCE(l.volume_cbm,0)&gt;0 THEN " +
"COALESCE((SELECT SUM(d.gross_weight_kg) FROM ll_order_detail d WHERE d.customer_id=ip.customer_id AND d.order_id=l.order_id)," +
"(SELECT inbound.gross_weight_kg FROM ll_inbound_order inbound WHERE inbound.id=l.order_id AND inbound.customer_id=ip.customer_id LIMIT 1),0) " +
"* COALESCE(a.allocated_volume_cbm,0) / l.volume_cbm ELSE " +
"COALESCE((SELECT SUM(d.gross_weight_kg) FROM ll_order_detail d WHERE d.customer_id=ip.customer_id AND d.order_id=l.order_id)," +
"(SELECT inbound.gross_weight_kg FROM ll_inbound_order inbound WHERE inbound.id=l.order_id AND inbound.customer_id=ip.customer_id LIMIT 1),0) " +
"/ NULLIF((SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a2 WHERE a2.line_id=a.line_id),0) END),0)," +
"MIN(o.volume_weight_kg)) AS grossWeightKg," +
"MIN(ip.container_no) AS containerNo," +
"(SELECT MAX(fa.appointment_at) FROM ll_inbound_fba_appointment_item fi " +
"JOIN ll_inbound_fba_appointment fa ON fa.id=fi.appointment_id WHERE fi.line_id IN " +
@@ -124,7 +146,13 @@ public interface DispatchCandidateMapper {
"(SELECT pa.line_id FROM ll_inbound_plan_pallet_allocation pa WHERE pa.pallet_id=p.id)) AS expectedLoadingAt," +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " +
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " +
"AND pos.available_packages &gt; 0) ELSE CAST(ip.overseas_warehouse_id AS CHAR) END AS overseasWarehouseId," +
"AND pos.available_packages &gt; 0) ELSE COALESCE(NULLIF(CAST(ip.overseas_warehouse_id AS CHAR),'')," +
"(SELECT NULLIF(TRIM(source_order.overseas_warehouse_id),'') FROM ll_order source_order " +
"WHERE source_order.id=ip.source_order_id AND source_order.customer_id=ip.customer_id LIMIT 1)) END AS overseasWarehouseId," +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_code) " +
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0) " +
"ELSE MIN(o.overseas_warehouse_code) END AS overseasWarehouseCode," +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_name) " +
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0) " +
@@ -185,8 +213,8 @@ public interface DispatchCandidateMapper {
@Select("<script>" +
"SELECT NULL AS planId,NULL AS planPalletId,pos.pallet_id AS warehousePalletId,a.lastleg_order_id AS orderId," +
"a.customer_id AS customerId,NULL AS customerName,NULL AS planNo,'RELABEL_APPLICATION' AS orderType,NULL AS volumeWeightKg,NULL AS containerNo," +
"NULL AS appointmentAt,NULL AS expectedLoadingAt,a.overseas_warehouse_id AS overseasWarehouseId,MIN(ri.source_warehouse_name) AS overseasWarehouseName," +
"a.customer_id AS customerId,NULL AS customerName,NULL AS planNo,'RELABEL_APPLICATION' AS orderType,NULL AS volumeWeightKg,NULL AS grossWeightKg,NULL AS containerNo," +
"NULL AS appointmentAt,NULL AS expectedLoadingAt,a.overseas_warehouse_id AS overseasWarehouseId,MIN(ri.source_warehouse_code) AS overseasWarehouseCode,MIN(ri.source_warehouse_name) AS overseasWarehouseName," +
"MIN(COALESCE(NULLIF(ri.source_inbound_order_no,''),NULLIF(a.customer_order_no,''),a.relabel_no)) AS orderNo,a.relabel_no AS orderRefs,MIN(ri.source_inbound_code) AS inboundCode,pos.pallet_no AS palletNo,NULL AS destinationWarehouseId," +
"GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS destinationWarehouseCode," +
"GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS destinationWarehouseName," +
@@ -13,10 +13,12 @@ public record DispatchPalletCandidate(
String planNo,
String orderType,
BigDecimal volumeWeightKg,
BigDecimal grossWeightKg,
String containerNo,
LocalDateTime appointmentAt,
LocalDateTime expectedLoadingAt,
String overseasWarehouseId,
String overseasWarehouseCode,
String overseasWarehouseName,
String orderNo,
String orderRefs,
@@ -43,7 +45,7 @@ public record DispatchPalletCandidate(
String destinationWarehouseCode, String destinationWarehouseName,
String fbaWarehouseCode, Integer packageCount, BigDecimal volumeCbm,
String sourceStage) {
this(planId, planPalletId, warehousePalletId, orderId, null, null, null, null, null, null, null, null, overseasWarehouseId,
this(planId, planPalletId, warehousePalletId, orderId, null, null, null, null, null, null, null, null, null, overseasWarehouseId, null,
overseasWarehouseName, orderNo, orderRefs, inboundCode, palletNo, destinationWarehouseId,
destinationWarehouseCode, destinationWarehouseName, fbaWarehouseCode, packageCount, volumeCbm,
sourceStage, null, null, null, null, null, null, null);
@@ -54,7 +56,7 @@ public record DispatchPalletCandidate(
String destinationWarehouseId, String destinationWarehouseCode,
String destinationWarehouseName, String fbaWarehouseCode, Integer packageCount,
BigDecimal volumeCbm, String sourceStage) {
this(planId, planPalletId, warehousePalletId, orderId, null, null, null, null, null, null, null, null, null, null, orderNo, orderRefs, inboundCode,
this(planId, planPalletId, warehousePalletId, orderId, null, null, null, null, null, null, null, null, null, null, null, null, orderNo, orderRefs, inboundCode,
palletNo, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
fbaWarehouseCode, packageCount, volumeCbm, sourceStage, null, null, null, null, null, null, null);
}
@@ -536,6 +536,7 @@ public class DispatchService {
dispatch.setDriverName(trimToNull(request.driverName()));
dispatch.setDriverPhone(trimToNull(request.driverPhone()));
dispatch.setCarrierName(trimToNull(request.carrierName()));
dispatch.setDepartAt(request.departAt());
dispatch.setRoute(itemRoute(assignedItems));
dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo()));
dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus()));
@@ -12,6 +12,7 @@ public record InboundPlanListResponse(
String inboundCode,
String lxCode,
String orderType,
Long overseasWarehouseId,
String overseasWarehouseCode,
String overseasWarehouseName,
String warehouseId,
@@ -34,4 +35,12 @@ public record InboundPlanListResponse(
String dispatchStatus,
LocalDateTime createdAt,
Integer version
) { }
) {
public InboundPlanListResponse withReceivingWarehouse(String warehouseCode, String warehouseName) {
return new InboundPlanListResponse(lineId, planId, planNo, customerId, orderId, inboundCode, lxCode,
orderType, overseasWarehouseId, warehouseCode, warehouseName, warehouseId, this.warehouseCode,
this.warehouseName, fbaWarehouseCode, customerName, packageCount, pickedPackageCount, volumeCbm,
palletCount, assigned, mergeAllowed, urgent, status, appointmentNo, appointmentStatus, appointmentAt,
expectedLoadingAt, dispatchStatus, createdAt, version);
}
}
@@ -25,7 +25,7 @@ public interface InboundPlanQueryMapper {
"<if test='q.inboundCode != null and q.inboundCode != \"\"'> AND l.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')</if>" +
"<if test='q.lxCode != null and q.lxCode != \"\"'> AND o.lx_code LIKE CONCAT('%',#{q.lxCode},'%')</if>" +
"<if test='q.status != null and q.status != \"\"'> AND p.status=#{q.status}</if>" +
"<if test='q.overseasWarehouseId != null and q.overseasWarehouseId != \"\"'> AND o.overseas_warehouse_id=#{q.overseasWarehouseId}</if>" +
"<if test='q.overseasWarehouseId != null and q.overseasWarehouseId != \"\"'> AND p.overseas_warehouse_id=#{q.overseasWarehouseId}</if>" +
"<if test='q.destinationWarehouseCode != null and q.destinationWarehouseCode != \"\"'> AND l.destination_warehouse_code=#{q.destinationWarehouseCode}</if>" +
"<if test='q.warehouse != null and q.warehouse != \"\"'> AND (l.destination_warehouse_id LIKE CONCAT('%',#{q.warehouse},'%') OR l.destination_warehouse_code LIKE CONCAT('%',#{q.warehouse},'%') OR l.destination_warehouse_name LIKE CONCAT('%',#{q.warehouse},'%'))</if>" +
"<if test='q.fbaWarehouseCode != null and q.fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code LIKE CONCAT('%',#{q.fbaWarehouseCode},'%')</if>" +
@@ -38,7 +38,7 @@ public interface InboundPlanQueryMapper {
@Select("<script>SELECT l.id lineId,p.id planId,p.plan_no planNo,p.customer_id customerId," +
"l.order_id orderId,l.inbound_code inboundCode,o.lx_code lxCode,o.order_type orderType," +
"o.overseas_warehouse_code overseasWarehouseCode,o.overseas_warehouse_name overseasWarehouseName," +
"p.overseas_warehouse_id overseasWarehouseId,o.overseas_warehouse_code overseasWarehouseCode,o.overseas_warehouse_name overseasWarehouseName," +
"l.destination_warehouse_id warehouseId,l.destination_warehouse_code warehouseCode," +
"l.destination_warehouse_name warehouseName,l.fba_warehouse_code fbaWarehouseCode," +
"o.customer_name customerName,l.required_package_count packageCount," +
@@ -179,10 +179,21 @@ public class InboundPlanService {
long total = queryMapper.count(customerId, query);
List<InboundPlanListResponse> records = total == 0 ? List.of() : queryMapper.rows(
customerId, query, (query.page() - 1) * query.size(), query.size());
Map<Long, WarehouseResourceGateway.WarehouseReference> receivingWarehouses = new HashMap<>();
records = records.stream().map(record -> canonicalizeReceivingWarehouse(record, receivingWarehouses)).toList();
long pages = total == 0 ? 0 : (total + query.size() - 1) / query.size();
return new PageResult<>(records, query.page(), query.size(), total, pages);
}
private InboundPlanListResponse canonicalizeReceivingWarehouse(InboundPlanListResponse record,
Map<Long, WarehouseResourceGateway.WarehouseReference> cache) {
Long warehouseId = record.overseasWarehouseId();
if (warehouseId == null) return record.withReceivingWarehouse(null, null);
WarehouseResourceGateway.WarehouseReference warehouse = cache.computeIfAbsent(warehouseId,
id -> requiredReceivingWarehouse(String.valueOf(id)));
return record.withReceivingWarehouse(warehouse.warehouseCode(), warehouse.warehouseName());
}
@Transactional(readOnly = true)
public ContainerInboundPlanPreviewResponse previewContainer(String sourceContainerBookingId) {
String bookingId = trimToNull(sourceContainerBookingId);
@@ -62,17 +62,42 @@ public interface InventoryQueryMapper {
"<when test='q.inventoryStatus == \"PROCUREMENT\"'> AND b.procurement_packages &gt; 0</when>" +
"</choose>";
@Select("<script>SELECT * FROM ll_inventory_balance" + BALANCE_FILTER +
@Select("<script>SELECT MIN(id) id," +
"customer_id,MAX(customer_name) customer_name," +
"overseas_warehouse_id,MAX(overseas_warehouse_code) overseas_warehouse_code," +
"MAX(overseas_warehouse_name) overseas_warehouse_name," +
"destination_warehouse_id,destination_warehouse_code," +
"MAX(destination_warehouse_name) destination_warehouse_name," +
"NULL inbound_order_id,NULL inbound_order_no,NULL inbound_code," +
"sku_code,MAX(product_name_zh) product_name_zh,MAX(product_name_en) product_name_en," +
"COALESCE(SUM(available_packages),0) available_packages," +
"COALESCE(SUM(hold_packages),0) hold_packages," +
"COALESCE(SUM(allocated_packages),0) allocated_packages," +
"COALESCE(SUM(picked_packages),0) picked_packages," +
"COALESCE(SUM(in_transit_packages),0) in_transit_packages," +
"COALESCE(SUM(receiving_packages),0) receiving_packages," +
"COALESCE(SUM(procurement_packages),0) procurement_packages," +
"MAX(updated_at) updated_at FROM ll_inventory_balance" + BALANCE_FILTER +
" GROUP BY customer_id,overseas_warehouse_id,destination_warehouse_id,destination_warehouse_code,sku_code" +
" ORDER BY updated_at DESC,id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<InventoryBalance> balances(@Param("customerId") String customerId,
@Param("q") InventoryQueryRequests.BalanceQuery query,
@Param("offset") long offset,
@Param("limit") long limit);
@Select("<script>SELECT COUNT(*) FROM ll_inventory_balance" + BALANCE_FILTER + "</script>")
@Select("<script>SELECT COUNT(*) FROM (SELECT 1 FROM ll_inventory_balance" + BALANCE_FILTER +
" GROUP BY customer_id,overseas_warehouse_id,destination_warehouse_id,destination_warehouse_code,sku_code" +
") grouped_balances</script>")
long countBalances(@Param("customerId") String customerId,
@Param("q") InventoryQueryRequests.BalanceQuery query);
@Select("<script>SELECT * FROM ll_inventory_balance" + BALANCE_FILTER +
" ORDER BY updated_at DESC,id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<InventoryBalance> sourceBalances(@Param("customerId") String customerId,
@Param("q") InventoryQueryRequests.BalanceQuery query,
@Param("offset") long offset,
@Param("limit") long limit);
@Select("<script>SELECT COUNT(*) dimension_count," +
"COALESCE(SUM(available_packages),0) available_packages," +
"COALESCE(SUM(hold_packages),0) hold_packages," +
@@ -105,7 +105,7 @@ public class InventoryQueryService {
final int batchSize = 1000;
long offset = 0;
while (true) {
List<InventoryBalanceView> batch = mapper.balances(customerId, query, offset, batchSize).stream().map(this::balanceView).toList();
List<InventoryBalanceView> batch = mapper.sourceBalances(customerId, query, offset, batchSize).stream().map(this::balanceView).toList();
if (batch.isEmpty()) break;
for (InventoryBalanceView v : batch) output.write((
String.join(",", csv(v.customerId()), csv(v.customerName()), csv(v.overseasWarehouseCode()), csv(v.overseasWarehouseName()),
@@ -2,7 +2,11 @@ package com.baiyue.lastleg.model;
/** Standard main-process statuses for a last-leg order. */
public enum LastlegOrderStatus {
DRAFT("草稿"),
PENDING_REVIEW("待审核"),
APPROVED("审核通过"),
DESTINATION_ASSIGNED("已分配目的仓"),
INBOUND_PLANNED("已生成入库计划"),
INBOUND_MEASURING("入库测量中"),
PENDING_ASSIGNMENT("待分配"),
PENDING_INBOUND("待入库"),
@@ -16,6 +20,7 @@ public enum LastlegOrderStatus {
PARTIALLY_RECEIVED("部分收货"),
RECEIVED("已收货"),
IN_TRANSIT("运输中"),
PROCESSING("处理中"),
COMPLETED("已完成"),
CANCELLED("已取消"),
PENDING_DELIVERY("待派送"),
@@ -31,4 +36,13 @@ public enum LastlegOrderStatus {
public String label() {
return label;
}
public static String labelOf(String status) {
if (status == null || status.isBlank()) return status;
try {
return valueOf(status).label();
} catch (IllegalArgumentException ignored) {
return status;
}
}
}
@@ -34,6 +34,7 @@ import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper;
import com.baiyue.lastleg.mapper.ReceivingWarehouseOperationMapper;
import com.baiyue.lastleg.mapper.ContainerArrivalTimeOperationMapper;
import com.baiyue.lastleg.model.LastlegOrderType;
import com.baiyue.lastleg.model.LastlegOrderStatus;
import com.baiyue.lastleg.shipping.entity.ContainerShippingGate;
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
import jakarta.annotation.Resource;
@@ -584,12 +585,12 @@ public class LastlegOrderService {
csv(order.getSourceOrderNo()), csv(order.getCustomerName()), csv(order.getOverseasWarehouseName()),
csv(order.getTransferWarehouseName()), csv(order.getTransferWarehouseCode()), csv(order.getFbaWarehouseCode()),
csv(order.getContainerNo()), csv(StringUtils.hasText(order.getSourceContainerBookingId())
? containerNodes.get(order.getSourceContainerBookingId()) : null), csv(order.getAbnormal()), csv(order.getDetained()),
? containerNodes.get(order.getSourceContainerBookingId()) : null), csv(yesOrNo(order.getAbnormal())), csv(yesOrNo(order.getDetained())),
csv(order.getPalletCount()), csv(order.getPackageCount()), csv(order.getReceivedPackageCount()),
csv(packageDifference(order)), 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(relabelBusinessStatus(order.getRelabelStatus())),
csv(order.getPalletMergeFlag()), csv(order.getCurrentStatus()), csv(order.getSubOrderStatus()), csv(order.getPrivateRemark()),
csv(order.getPalletMergeFlag()), csv(LastlegOrderStatus.labelOf(order.getCurrentStatus())), csv(order.getSubOrderStatus()), csv(order.getPrivateRemark()),
csv(order.getCustomerRemark()), csv(order.getWarehouseRemark()))) + "\r\n";
output.write(line.getBytes(StandardCharsets.UTF_8));
}
@@ -730,6 +731,11 @@ public class LastlegOrderService {
? "COMPLETED" : "PENDING_PROCESSING";
}
private static String yesOrNo(Boolean value) {
if (value == null) return null;
return value ? "是" : "否";
}
private static Long parseLong(String value) {
try { return StringUtils.hasText(value) ? Long.parseLong(value.trim()) : null; }
catch (NumberFormatException ignored) { return null; }
@@ -1204,6 +1204,32 @@ CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment_item (
KEY idx_ll_fba_appointment_item_order (order_id)
) ENGINE=InnoDB COMMENT='FBA入仓预约明细';
-- Upgrade the pre-customer-scope appointment table created by older deployments.
SET @ddl = (SELECT IF(
NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='ll_inbound_fba_appointment' AND column_name='customer_id')
AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='ll_inbound_fba_appointment' AND column_name='tenant_id'),
'ALTER TABLE ll_inbound_fba_appointment CHANGE COLUMN tenant_id customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''',
'SELECT 1'));
PREPARE fba_appointment_customer_scope FROM @ddl; EXECUTE fba_appointment_customer_scope; DEALLOCATE PREPARE fba_appointment_customer_scope;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='ll_inbound_fba_appointment' AND column_name='expected_loading_at'),
'SELECT 1',
'ALTER TABLE ll_inbound_fba_appointment ADD COLUMN expected_loading_at DATETIME(3) NULL COMMENT ''预计装车时间'' AFTER appointment_at'));
PREPARE fba_appointment_expected_loading FROM @ddl; EXECUTE fba_appointment_expected_loading; DEALLOCATE PREPARE fba_appointment_expected_loading;
UPDATE ll_inbound_fba_appointment
SET expected_loading_at=appointment_at
WHERE expected_loading_at IS NULL;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='ll_inbound_fba_appointment' AND column_name='expected_loading_at' AND is_nullable='YES'),
'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN expected_loading_at DATETIME(3) NOT NULL COMMENT ''预计装车时间''',
'SELECT 1'));
PREPARE fba_appointment_expected_loading_required FROM @ddl; EXECUTE fba_appointment_expected_loading_required; DEALLOCATE PREPARE fba_appointment_expected_loading_required;
ALTER TABLE ll_loading_order_status_history
MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '状态历史主键',
MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '尾程订单 ID',
@@ -463,7 +463,7 @@ paths:
/api/platform/v1/last-leg/inbound-plans:
get:
summary: 平台分页查询海外入库订单
description: 权限 platform:overseas-warehouse:inbound-order:list;列表中的 overseasWarehouseCode/overseasWarehouseName 只取尾程订单预分配接柜仓事实,warehouseCode/warehouseName 仍表示目的仓。
description: 权限 platform:overseas-warehouse:inbound-order:list;列表中的 overseasWarehouseCode/overseasWarehouseName 按入库计划冻结接柜仓ID从海外仓权威目录解析,warehouseCode/warehouseName 仍表示目的仓。
security: [{ bearerAuth: [] }]
parameters:
- { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
@@ -2162,10 +2162,12 @@ components:
planNo: { type: string, nullable: true }
orderType: { type: string, nullable: true }
volumeWeightKg: { type: number, nullable: true }
grossWeightKg: { type: number, nullable: true, description: 候选托盘实际毛重(千克);按入库计划分配比例汇总,缺失时回退体积重 }
containerNo: { type: string, nullable: true }
appointmentAt: { type: string, format: date-time, nullable: true }
expectedLoadingAt: { type: string, format: date-time, nullable: true }
overseasWarehouseId: { type: string }
overseasWarehouseCode: { type: string, nullable: true, description: 实际海外接柜仓编码,用于智能排车区域筛选 }
overseasWarehouseName: { type: string }
orderNo: { type: string }
orderRefs: { type: string }
@@ -2230,12 +2232,13 @@ components:
palletIds: { type: array, items: { type: string } }
DispatchUpdate:
type: object
description: 普通编辑字段;不包含 status、departAt 或路线,路线由已排托盘关联的 FBA 仓自动汇总。
description: 普通编辑字段;不包含 status 或路线,路线由已排托盘关联的 FBA 仓自动汇总。
properties:
vehiclePlate: { type: string, nullable: true, maxLength: 64 }
driverName: { type: string, nullable: true, maxLength: 128 }
driverPhone: { type: string, nullable: true, maxLength: 64 }
carrierName: { type: string, nullable: true, maxLength: 255 }
departAt: { type: string, format: date-time, nullable: true }
fbaAppointmentNo: { type: string, nullable: true, maxLength: 64 }
fbaAppointmentStatus: { type: string, nullable: true, maxLength: 32 }
vehicleAppointmentStatus: { type: string, nullable: true, maxLength: 32 }
@@ -2918,8 +2921,9 @@ components:
orderId: { type: string, pattern: '^[0-9]+$' }
inboundCode: { type: string }
orderType: { type: string, nullable: true }
overseasWarehouseCode: { type: string, description: 接柜仓编码,取自尾程订单预分配接收仓事实 }
overseasWarehouseName: { type: string, description: 接柜仓名称,取自尾程订单预分配接收仓事实 }
overseasWarehouseId: { type: string, pattern: '^[0-9]+$', nullable: true, description: 入库计划冻结的接柜海外仓ID,不是FBA目的仓ID }
overseasWarehouseCode: { type: string, description: 按入库计划冻结接柜仓ID从海外仓权威目录解析的编码 }
overseasWarehouseName: { type: string, description: 按入库计划冻结接柜仓ID从海外仓权威目录解析的名称 }
warehouseId: { type: string, nullable: true }
warehouseCode: { type: string, nullable: true }
warehouseName: { type: string, nullable: true }
@@ -112,7 +112,7 @@ class PlatformDispatchOutboundContractTest {
assertTrue(Arrays.stream(DispatchController.class.getDeclaredMethods())
.noneMatch(method -> "depart".equals(method.getName())));
assertEquals(Set.of("vehiclePlate", "driverName", "driverPhone", "carrierName",
"fbaAppointmentNo", "fbaAppointmentStatus", "vehicleAppointmentStatus", "estimatedArrivalAt"),
"departAt", "fbaAppointmentNo", "fbaAppointmentStatus", "vehicleAppointmentStatus", "estimatedArrivalAt"),
Arrays.stream(DispatchRequests.Update.class.getRecordComponents())
.map(component -> component.getName()).collect(java.util.stream.Collectors.toSet()));
}
@@ -12,8 +12,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class DispatchCandidateMapperContractTest {
private static final List<String> CONSTRUCTOR_ALIASES = List.of(
"planId", "planPalletId", "warehousePalletId", "orderId", "customerId", "customerName",
"planNo", "orderType", "volumeWeightKg", "containerNo", "appointmentAt", "expectedLoadingAt",
"overseasWarehouseId", "overseasWarehouseName", "orderNo", "orderRefs", "inboundCode", "palletNo",
"planNo", "orderType", "volumeWeightKg", "grossWeightKg", "containerNo", "appointmentAt", "expectedLoadingAt",
"overseasWarehouseId", "overseasWarehouseCode", "overseasWarehouseName", "orderNo", "orderRefs", "inboundCode", "palletNo",
"destinationWarehouseId", "destinationWarehouseCode", "destinationWarehouseName", "fbaWarehouseCode",
"packageCount", "volumeCbm", "sourceStage", "cartonMarks", "relabelCartonMarks",
"destinationType", "deliveryAddress", "postalCode", "contactName", "contactPhone"
@@ -70,7 +70,12 @@ class DispatchCandidateMapperContractTest {
String sql = String.join(" ", method.getAnnotation(Select.class).value());
assertTrue(sql.contains("CASE WHEN p.warehouse_pallet_id IS NOT NULL"));
assertTrue(sql.contains("pos.overseas_warehouse_id"));
assertTrue(sql.contains("CAST(ip.overseas_warehouse_id AS CHAR)"));
assertTrue(sql.contains("NULLIF(CAST(ip.overseas_warehouse_id AS CHAR),'')"));
assertTrue(sql.contains("source_order.id=ip.source_order_id"));
assertTrue(sql.contains("source_order.customer_id=ip.customer_id"));
assertTrue(sql.contains("source_order.overseas_warehouse_id"));
assertTrue(sql.contains("AS overseasWarehouseCode"));
assertTrue(sql.contains("b.overseas_warehouse_code"));
assertTrue(sql.contains("ll_inventory_balance"));
}
}
@@ -89,6 +94,22 @@ class DispatchCandidateMapperContractTest {
}
}
@Test
void candidateGrossWeightIsAllocatedByPalletAndFallsBackToVolumeWeight() throws Exception {
for (Method method : List.of(
DispatchCandidateMapper.class.getMethod("available", String.class, List.class, String.class,
String.class, String.class, String.class),
DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class))) {
String sql = String.join(" ", method.getAnnotation(Select.class).value());
assertTrue(sql.contains("SUM(d.gross_weight_kg)"));
assertTrue(sql.contains("inbound.gross_weight_kg"));
assertTrue(sql.contains("ll_inbound_order inbound"));
assertTrue(sql.contains("a.allocated_volume_cbm"));
assertTrue(sql.contains("a2.line_id=a.line_id"));
assertTrue(sql.contains("MIN(o.volume_weight_kg)) AS grossWeightKg"));
}
}
private void assertConstructorColumnOrder(Method method) {
String sql = String.join(" ", method.getAnnotation(Select.class).value());
int previous = -1;
@@ -31,12 +31,15 @@ class SmartDispatchOpenApiContractTest {
assertTrue(yaml.contains("platform:overseas-warehouse:inbound-order:smart-dispatch"));
assertTrue(yaml.contains("SmartDispatchPreArrange"));
assertTrue(yaml.contains("SmartDispatchCandidate"));
assertTrue(yaml.contains("overseasWarehouseCode: { type: string, nullable: true, description: 实际海外接柜仓编码"));
assertTrue(yaml.contains("grossWeightKg: { type: number, nullable: true, description: 候选托盘实际毛重"));
String preArrangeSchema = yaml.substring(yaml.indexOf(" SmartDispatchPreArrange:"),
yaml.indexOf(" SmartDispatchDraftPallet:"));
String updateSchema = yaml.substring(yaml.indexOf(" DispatchUpdate:"),
yaml.indexOf(" DeliveryTransfer:"));
assertFalse(preArrangeSchema.contains("route:"));
assertFalse(updateSchema.contains("route:"));
assertTrue(updateSchema.contains("departAt: { type: string, format: date-time, nullable: true }"));
assertTrue(yaml.contains("name: planIds"));
String planIdsParameter = yaml.substring(yaml.indexOf("name: planIds"),
yaml.indexOf("name: orderNo", yaml.indexOf("name: planIds")));
@@ -370,13 +370,16 @@ class DispatchServiceTest {
DispatchItem ont8 = item(11L, "ONT8");
DispatchItem abe4 = item(12L, "ABE4");
when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(ont8, abe4, item(13L, "ONT8")));
LocalDateTime departAt = LocalDateTime.of(2026, 9, 18, 10, 30);
DispatchRequests.Update request = new DispatchRequests.Update("ABC-1", "Driver", null, null,
null, null, null, LocalDateTime.now());
departAt, null, null, null, LocalDateTime.now());
DispatchResponse result = service.update("PC1", request);
assertEquals(DispatchService.PENDING_DEPARTURE, dispatch.getStatus());
assertEquals("ABC-1", result.vehiclePlate());
assertEquals(departAt, result.departAt());
assertEquals(departAt, dispatch.getDepartAt());
assertEquals("ONT8,ABE4", result.route());
assertEquals("ONT8,ABE4", dispatch.getRoute());
verify(orders).updateById(dispatch);
@@ -390,7 +393,7 @@ class DispatchServiceTest {
dispatch.setStatus("DEPARTED");
when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch);
DispatchRequests.Update request = new DispatchRequests.Update(null, null, null, null,
null, null, null, null);
null, null, null, null, null);
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.update("PC1", request));
@@ -28,6 +28,8 @@ class InboundPlanSchemaContractTest {
assertTrue(schema.contains("create table if not exists ll_inbound_fba_appointment"));
assertTrue(schema.contains("unique key uk_ll_fba_appointment_no (customer_id, appointment_no)"));
assertTrue(schema.contains("expected_loading_at"));
assertTrue(schema.contains("change column tenant_id customer_id"));
assertTrue(schema.contains("set expected_loading_at=appointment_at"));
assertTrue(schema.contains("planned_arrival_date"));
assertTrue(schema.contains("creation_mode varchar(32)"));
assertTrue(schema.contains("modify column creation_mode varchar(32)"));
@@ -16,6 +16,9 @@ class InboundPlanQueryMapperContractTest {
String sql = String.join(" ", select.value());
assertTrue(sql.contains("p.version version"));
assertTrue(sql.contains("l.order_id orderId,l.inbound_code inboundCode,o.lx_code lxCode,o.order_type orderType"));
assertTrue(sql.contains("p.overseas_warehouse_id overseasWarehouseId"));
assertTrue(sql.contains("p.overseas_warehouse_id=#{q.overseasWarehouseId}"));
assertTrue(sql.contains("WHERE <if test='customerId != null and customerId != \"\"'>di.customer_id=#{customerId} AND </if>di.order_id=l.order_id"));
}
}
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.inboundplan.service;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanListResponse;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanQueryMapper;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import jakarta.validation.Validation;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -22,12 +23,14 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InboundPlanPaginationTest {
@Mock InboundPlanQueryMapper queryMapper;
@Mock WarehouseResourceGateway warehouseGateway;
private InboundPlanService service;
@BeforeEach
void setUp() {
service = new InboundPlanService();
ReflectionTestUtils.setField(service, "queryMapper", queryMapper);
ReflectionTestUtils.setField(service, "warehouseGateway", warehouseGateway);
}
@Test
@@ -48,19 +51,22 @@ class InboundPlanPaginationTest {
null, "PENDING_ASSIGNMENT", null, null, "ONT", "ONT8", "UNAPPOINTED", "UNSCHEDULED",
LocalDateTime.of(2026, 8, 1, 0, 0), LocalDateTime.of(2026, 8, 31, 23, 59));
InboundPlanListResponse row = new InboundPlanListResponse(9007199254740993L, 2L, "LIP2", "100",
3L, "IN-1", null, "FIRST_LEG", "US-LAX-01", "洛杉矶仓", "W1", "ONT", "安大略仓",
3L, "IN-1", null, "FIRST_LEG", 193000000000200003L, "ACY2", "错误目的仓", "W1", "ONT", "安大略仓",
"ONT8", "客户A", 10, 0, BigDecimal.ONE, 1, true, true, true, "PENDING_ASSIGNMENT", null, null, null,
null, "UNSCHEDULED", LocalDateTime.of(2026, 8, 20, 10, 0), 0);
when(queryMapper.count("100", query)).thenReturn(11L);
when(queryMapper.rows("100", query, 10, 10)).thenReturn(List.of(row));
when(warehouseGateway.requireActiveSharedWarehouse(193000000000200003L)).thenReturn(
new WarehouseResourceGateway.WarehouseReference(193000000000200003L, "US-LAX-01", "洛杉矶仓", "ACTIVE"));
var result = service.page("100", query);
assertThat(result.records()).containsExactly(row);
assertThat(result.records()).containsExactly(row.withReceivingWarehouse("US-LAX-01", "洛杉矶仓"));
assertThat(result.records().get(0).lineId()).isEqualTo(9007199254740993L);
assertThat(result.records().get(0).appointmentStatus()).isNull();
assertThat(result.records().get(0).overseasWarehouseCode()).isEqualTo("US-LAX-01");
assertThat(result.records().get(0).overseasWarehouseName()).isEqualTo("洛杉矶仓");
assertThat(result.records().get(0).overseasWarehouseId()).isEqualTo(193000000000200003L);
assertThat(result.records().get(0).assigned()).isTrue();
assertThat(result.records().get(0).mergeAllowed()).isTrue();
assertThat(result.records().get(0).urgent()).isTrue();
@@ -76,9 +82,9 @@ class InboundPlanPaginationTest {
.getAnnotation(org.apache.ibatis.annotations.Select.class).value()) + InboundPlanQueryMapper.FROM_AND_FILTER;
assertThat(sql).contains("a2.customer_id=#{customerId}", "ORDER BY a2.created_at DESC,ai2.id DESC LIMIT 1");
assertThat(sql).contains("di.customer_id=#{customerId} AND </if>di.order_id=l.order_id");
assertThat(sql).contains("o.overseas_warehouse_code overseasWarehouseCode",
assertThat(sql).contains("p.overseas_warehouse_id overseasWarehouseId", "o.overseas_warehouse_code overseasWarehouseCode",
"o.overseas_warehouse_name overseasWarehouseName",
"o.overseas_warehouse_id=#{q.overseasWarehouseId}");
"p.overseas_warehouse_id=#{q.overseasWarehouseId}");
assertThat(sql).contains("p.assigned_flag assigned", "p.merge_required mergeAllowed",
"p.urgent_flag urgent");
assertThat(sql).doesNotContain("l.destination_warehouse_id=#{q.overseasWarehouseId}");
@@ -88,6 +88,21 @@ class InventorySchemaContractTest {
void balanceQueriesHideZeroRowsAndMutationsKeepWarehouseDimensionCanonical() throws Exception {
assertTrue(InventoryQueryMapper.BALANCE_FILTER.contains("procurement_packages)>0"));
assertTrue(InventoryQueryMapper.JOINED_BALANCE_FILTER.contains("b.procurement_packages)>0"));
String balancesSql = String.join(" ", InventoryQueryMapper.class
.getMethod("balances", String.class, InventoryQueryRequests.BalanceQuery.class, long.class, long.class)
.getAnnotation(Select.class).value());
assertTrue(balancesSql.contains("SUM(available_packages)"));
assertTrue(balancesSql.contains("GROUP BY customer_id,overseas_warehouse_id,destination_warehouse_id,destination_warehouse_code,sku_code"));
assertTrue(balancesSql.contains("NULL inbound_order_id"));
String balanceCountSql = String.join(" ", InventoryQueryMapper.class
.getMethod("countBalances", String.class, InventoryQueryRequests.BalanceQuery.class)
.getAnnotation(Select.class).value());
assertTrue(balanceCountSql.contains("FROM (SELECT 1 FROM ll_inventory_balance"));
assertTrue(balanceCountSql.contains("GROUP BY customer_id,overseas_warehouse_id,destination_warehouse_id,destination_warehouse_code,sku_code"));
String sourceBalancesSql = String.join(" ", InventoryQueryMapper.class
.getMethod("sourceBalances", String.class, InventoryQueryRequests.BalanceQuery.class, long.class, long.class)
.getAnnotation(Select.class).value());
assertFalse(sourceBalancesSql.contains("GROUP BY"));
String bindSql = String.join(" ", com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper.class
.getMethod("bindInboundPlan", String.class, String.class, Long.class, String.class,
String.class, java.time.LocalDateTime.class)
@@ -207,7 +207,7 @@ class InventoryQueryServiceTest {
@Test
void balanceExportUsesInboundOrderPackageColumnsOnly() {
InventoryQueryRequests.BalanceQuery query = balanceQuery(null);
when(mapper.balances("100", query, 0, 10_000)).thenReturn(List.of());
when(mapper.sourceBalances("100", query, 0, 10_000)).thenReturn(List.of());
String csv = new String(service.exportBalances("100", query), StandardCharsets.UTF_8);
@@ -20,6 +20,7 @@ import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper;
import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper;
import com.baiyue.lastleg.mapper.ReceivingWarehouseOperationMapper;
import com.baiyue.lastleg.mapper.ContainerArrivalTimeOperationMapper;
import com.baiyue.lastleg.model.LastlegOrderStatus;
import com.baiyue.lastleg.entity.ContainerArrivalTimeOperation;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.shipping.entity.ContainerShippingGate;
@@ -37,6 +38,7 @@ import java.util.List;
import java.time.LocalDateTime;
import java.time.LocalDate;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -387,6 +389,31 @@ class LastlegOrderServiceTest {
assertEquals(25, page.getValue().getSize());
}
@Test
void exportUsesChineseBooleanAndMainStatusLabels() {
LastlegOrder order = order(11L, "100", "PENDING_ASSIGNMENT", null);
order.setAbnormal(false);
order.setDetained(true);
var resultPage = new com.baomidou.mybatisplus.extension.plugins.pagination.Page<LastlegOrder>(1, 1000);
resultPage.setRecords(List.of(order));
resultPage.setTotal(1);
when(orders.selectPage(any(), any())).thenReturn(resultPage);
String csv = new String(service.exportCsv(null, new LastlegOrderRequests.Query(
1L, 20L, null, null, null, null, null, null, null, null, null, null,
null, null, null, null)), StandardCharsets.UTF_8);
assertTrue(csv.contains("\"否\",\"是\""));
assertTrue(csv.contains("\"待分配\""));
assertFalse(csv.contains("\"false\""));
assertFalse(csv.contains("\"PENDING_ASSIGNMENT\""));
assertEquals("草稿", LastlegOrderStatus.labelOf("DRAFT"));
assertEquals("审核通过", LastlegOrderStatus.labelOf("APPROVED"));
assertEquals("已分配目的仓", LastlegOrderStatus.labelOf("DESTINATION_ASSIGNED"));
assertEquals("已生成入库计划", LastlegOrderStatus.labelOf("INBOUND_PLANNED"));
assertEquals("处理中", LastlegOrderStatus.labelOf("PROCESSING"));
}
@Test
void pageWarehouseFilterMatchesFrozenWarehouseName() {
assertWarehouseFilter("洛杉矶仓", "洛杉矶仓", true);
@@ -49,7 +49,7 @@ public class DomesticWarehouseOrderExportProviderService {
column("mainStatus", "订单主状态", ExportColumnType.STRING, 16),
column("customerName", "客户", ExportColumnType.STRING, 24),
column("channelName", "渠道", ExportColumnType.STRING, 20),
column("channelChanged", "曾变更渠道", ExportColumnType.BOOLEAN, 12),
column("channelChanged", "曾变更渠道", ExportColumnType.STRING, 12),
column("receivingWarehouseName", "定位仓库", ExportColumnType.STRING, 20),
column("destinationWarehouseName", "交货仓", ExportColumnType.STRING, 20),
column("expectedShipDate", "预计出运日期", ExportColumnType.DATE, 14),
@@ -65,7 +65,7 @@ public class DomesticWarehouseOrderExportProviderService {
column("skuNos", "SKU", ExportColumnType.STRING, 28),
column("taxMode", "报关方式", ExportColumnType.STRING, 16),
column("containerMode", "装柜方式", ExportColumnType.STRING, 14),
column("customsInspection", "是否商检", ExportColumnType.BOOLEAN, 12),
column("customsInspection", "是否商检", ExportColumnType.STRING, 12),
column("containerNo", "集装箱号", ExportColumnType.STRING, 20),
column("cabinetNo", "柜次号", ExportColumnType.STRING, 20),
column("vesselName", "船名", ExportColumnType.STRING, 18),
@@ -126,16 +126,16 @@ public class DomesticWarehouseOrderExportProviderService {
private Map<String, Object> row(UnifiedOrderResponse.Row value) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("inboundCode", value.inboundCode()); row.put("orderNo", value.orderNo());
row.put("mainStatus", value.mainStatus()); row.put("customerName", value.customerName());
row.put("channelName", value.channelName()); row.put("channelChanged", value.channelChanged()); row.put("receivingWarehouseName", value.receivingWarehouseName());
row.put("mainStatus", mainStatusLabel(value.mainStatus())); row.put("customerName", value.customerName());
row.put("channelName", value.channelName()); row.put("channelChanged", yesOrNo(value.channelChanged())); row.put("receivingWarehouseName", value.receivingWarehouseName());
row.put("destinationWarehouseName", value.destinationWarehouseName()); row.put("expectedShipDate", value.expectedShipDate());
row.put("expectedArrivalAt", value.expectedArrivalAt()); row.put("expectedVolumeCbm", value.expectedVolumeCbm());
row.put("totalPackageCount", value.totalPackageCount()); row.put("totalQuantity", value.totalQuantity());
row.put("actualGrossWeightKg", value.actualGrossWeightKg()); row.put("actualVolumeCbm", value.actualVolumeCbm());
row.put("chargeableWeightKg", value.chargeableWeightKg()); row.put("chargeableVolumeCbm", value.chargeableVolumeCbm());
row.put("feeReviewStatus", value.feeReviewStatus()); row.put("skuNos", join(value.skuNos()));
row.put("taxMode", value.taxMode()); row.put("containerMode", value.containerMode());
row.put("customsInspection", value.customsInspection());
row.put("feeReviewStatus", feeReviewStatusLabel(value.feeReviewStatus())); row.put("skuNos", join(value.skuNos()));
row.put("taxMode", taxModeLabel(value.taxMode())); row.put("containerMode", containerModeLabel(value.containerMode()));
row.put("customsInspection", yesOrNo(value.customsInspection()));
var transport = value.transport();
row.put("containerNo", transport == null ? null : transport.containerNo());
row.put("cabinetNo", transport == null ? null : transport.cabinetNo());
@@ -233,6 +233,33 @@ public class DomesticWarehouseOrderExportProviderService {
}
private static String join(List<String> values) { return values == null ? null : String.join(",", values); }
private static String mainStatusLabel(String value) {
return label(value, Map.ofEntries(
Map.entry("DRAFT", "草稿"), Map.entry("SUBMITTED", "待审核"), Map.entry("APPROVED", "已审核"),
Map.entry("ACCEPTED", "已受理"), Map.entry("REJECTED", "已驳回"), Map.entry("PROCESSING", "处理中"),
Map.entry("COMPLETED", "已完成"), Map.entry("CANCELLED", "已取消"),
Map.entry("PENDING_REVIEW", "待审核"), Map.entry("WAIT_MEASURE", "待测量数据"),
Map.entry("WAIT_PALLETIZE", "待一件代发组托"), Map.entry("INBOUNDED", "已入库"),
Map.entry("WAIT_SPLIT", "待分柜"), Map.entry("LOADING", "装柜中"),
Map.entry("LOADED", "装柜完成"), Map.entry("DELIVERED", "派送完成")));
}
private static String feeReviewStatusLabel(String value) {
return label(value, Map.ofEntries(
Map.entry("NOT_SUBMITTED", "未提交"), Map.entry("UNRECORDED", "未录入"),
Map.entry("PENDING", "待审核"), Map.entry("APPROVED", "审核通过"),
Map.entry("REJECTED", "审核驳回"), Map.entry("CONFIRMED", "已确认")));
}
private static String taxModeLabel(String value) {
return label(value, Map.of("BUY_DECLARATION", "买单报关", "TAX_REFUND", "退税报关"));
}
private static String containerModeLabel(String value) {
return label(value, Map.of("FULL_CONTAINER", "整柜", "FCL", "整柜", "LCL", "拼箱"));
}
private static String yesOrNo(Boolean value) { return value == null ? null : value ? "是" : "否"; }
private static String label(String value, Map<String, String> labels) {
if (value == null) return null;
return labels.getOrDefault(value.trim().toUpperCase(), value);
}
private static ExportColumn column(String key, String title, ExportColumnType type, int width) {
return new ExportColumn(key, title, type, width);
}
@@ -527,10 +527,13 @@ public class MerchantBatchImportService {
private static <T> Map<String, T> optionMap(List<T> values, Function<T, String> labeler) {
Map<String, T> result = new LinkedHashMap<>();
for (T value : values) {
String label = labeler.apply(value);
if (result.putIfAbsent(label, value) != null) {
throw new BizException(ResultCode.CONFLICT, "模板选项显示名称重复,请联系平台处理");
String baseLabel = labeler.apply(value);
String label = baseLabel;
int duplicateNumber = 2;
while (result.containsKey(label)) {
label = baseLabel + "(同名选项 " + duplicateNumber++ + ")";
}
result.put(label, value);
}
return result;
}
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest;
import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse;
import com.baiyue.common.core.export.ExportContracts.ExportColumnType;
import com.baiyue.order.dto.UnifiedOrderQuery;
import com.baiyue.order.dto.UnifiedOrderResponse;
import jakarta.annotation.Resource;
@@ -130,6 +131,29 @@ class DomesticWarehouseOrderExportProviderServiceTest {
.hasMessageContaining("does not match");
}
@Test
void exportsChineseDisplayValuesForStatusAndEnumColumns() {
UnifiedOrderResponse.Row row = row("ORD-1", "RK-1", "客户甲");
when(row.mainStatus()).thenReturn("PENDING_REVIEW");
when(row.channelChanged()).thenReturn(false);
when(row.feeReviewStatus()).thenReturn("NOT_SUBMITTED");
when(row.taxMode()).thenReturn("BUY_DECLARATION");
when(row.containerMode()).thenReturn("LCL");
when(row.customsInspection()).thenReturn(false);
when(orders.platformRowsByOrderNos(List.of("ORD-1"))).thenReturn(List.of(row));
ExportBatchResponse response = service.exportBatch(request("1009",
Map.of("selectedOrderNos", List.of("ORD-1")), null, null, 100));
assertThat(response.rows().get(0)).containsEntry("mainStatus", "待审核")
.containsEntry("channelChanged", "否").containsEntry("feeReviewStatus", "未提交")
.containsEntry("taxMode", "买单报关").containsEntry("containerMode", "拼箱")
.containsEntry("customsInspection", "否");
assertThat(response.columns()).filteredOn(column -> column.key().equals("channelChanged")
|| column.key().equals("customsInspection")).extracting(column -> column.type())
.containsOnly(ExportColumnType.STRING);
}
private ExportBatchRequest request(String taskId, Map<String, Object> parameters,
String cursor, String snapshot, int limit) {
return new ExportBatchRequest(taskId, "0", "PLATFORM", "7001", 1,
@@ -229,6 +229,26 @@ class MerchantBatchImportServiceTest {
verify(warehouses).page("Bearer test", 2, 200, "ACTIVE");
}
@Test
void templateKeepsDuplicateDisplayNamesAsDistinctBusinessOptions() throws Exception {
var domestic = new MerchantWarehouseCatalogClient.Warehouse(21L, "CN-YW", "义乌收货仓",
"DOMESTIC", "CN", "ACTIVE");
var firstFba = new MerchantWarehouseCatalogClient.Warehouse(22L, "ATL6", "ATL6",
"FBA", "US", "ACTIVE");
var secondFba = new MerchantWarehouseCatalogClient.Warehouse(24L, "ATL6", "ATL6",
"FBA", "US", "ACTIVE");
when(warehouses.page("Bearer test", 1, 200, "ACTIVE"))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(domestic, firstFba, secondFba), 1, 200, 3, 1)));
byte[] content = service.template(88L, "Bearer test");
try (var workbook = new XSSFWorkbook(new ByteArrayInputStream(content))) {
var options = workbook.getSheet("选项数据");
assertThat(hasCellValue(options, "ATL6")).isTrue();
assertThat(hasCellValue(options, "ATL6(同名选项 2)")).isTrue();
}
}
@Test
void templateFailsClosedWhenLaterWarehousePageCannotBeRead() {
List<MerchantWarehouseCatalogClient.Warehouse> firstPage = new ArrayList<>();
+4 -3
View File
@@ -630,9 +630,10 @@ WHERE l.fba_warehouse_code IS NULL AND o.fba_warehouse_code IS NOT NULL;
CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment (
id BIGINT NOT NULL COMMENT '主键ID',
tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
appointment_no VARCHAR(64) NOT NULL COMMENT '预约单号',
appointment_at DATETIME(3) NOT NULL COMMENT '预约时间',
expected_loading_at DATETIME(3) NOT NULL COMMENT '预计装车时间',
appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '预约状态',
appointment_remark VARCHAR(1000) NULL COMMENT '预约备注',
fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA仓库编码',
@@ -641,8 +642,8 @@ CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_fba_appointment_no (tenant_id, appointment_no),
KEY idx_ll_fba_appointment_list (tenant_id, appointment_status, fba_warehouse_code, created_at)
UNIQUE KEY uk_ll_fba_appointment_no (customer_id, appointment_no),
KEY idx_ll_fba_appointment_list (customer_id, appointment_status, fba_warehouse_code, created_at)
) ENGINE=InnoDB COMMENT='FBA入仓预约单';
CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment_item (
@@ -183,7 +183,7 @@ public class WarehouseResourceService {
throw new BizException(ResultCode.CONFLICT, "托盘版本已变化,请刷新后重试");
}
if ("SCRAPPED".equals(existing.getStatus()) && !"SCRAPPED".equals(request.status())) {
throw new BizException(ResultCode.CONFLICT, "SCRAPPED pallet cannot be reused");
throw new BizException(ResultCode.CONFLICT, "已报废的托盘不允许修改为其他状态");
}
String palletCode = StringUtils.hasText(request.palletCode()) ? request.palletCode().trim()
: existing.getPalletCode();
@@ -332,7 +332,7 @@ class WarehouseResourceServiceTest {
assertThatThrownBy(() -> service.updatePallet(TENANT, WAREHOUSE, 40002L,
new PalletRequests.SaveRequest(null, "STANDARD", null, BigDecimal.ONE, "IDLE", null, null, null, 0)))
.isInstanceOf(BizException.class)
.hasMessageContaining("cannot be reused");
.hasMessageContaining("已报废的托盘不允许修改为其他状态");
}
@Test