diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 60381210e..8fe3214e4 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -34,6 +34,8 @@ 预约工作事项新增结构由 `lastleg-service/src/main/resources/db/appointment-work.sql` 提供五张表:完整范围协调锁、工作关系、不可变历史、承诺影响版本与逐票批次实物分配,已同步 Lastleg 和海外空库初始化入口;对已有 TEST 仅审阅并授权这五表 CREATE 及协调锁初始行。直送目的仓实际卸货独立表由 `firstleg/src/main/resources/db/phase3-direct-delivery-schema.sql` 提供并同步国内空库入口,不产生海外库存。两项独立写权限见 `iam/src/main/resources/db/appointment-work-permissions.sql`,先校验精确权限身份再写入;默认只授权平台管理员。旧预约表的有损重建只允许单独审阅 `reset-test-fba-appointments.sql`;若历史、核实或工作/分配表已存在,前置检查会在 DROP 前拒绝,不能重放或擅自扩大重置范围。以上只维护脚本,不代表已执行或已部署。 +换标直接出库的实物体积快照在 `ll_outbound_order.volume_cbm`、`ll_outbound_pallet_item.volume_cbm`、`ll_delivery_order.total_volume_cbm` 三列均允许 NULL,表示来源尚未确认;已有实盘尺寸按箱计算,不能用默认 0 代替未知,也不按 SKU 件数重复累计。两份初始化基线同步此定义,已有 TEST 只能在核对结构和授权后精确调整这三列,不重放空库文件或回填历史行。 + ## 唯一入口与区域边界 两个统一 SQL 文件仍只用于空库初始化,名称中的“增量”不表示可向已有业务库重放。已有生产库的受控结构补齐单独放在 `migrations/`,只允许明确审阅过的增量变更;不提供存量数据回填、清理或兼容入口。 diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 287d92fa7..cd0aa331d 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -271,7 +271,7 @@ CREATE TABLE ll_delivery_order ( package_count int NOT NULL DEFAULT '0' COMMENT '派送箱数', pallet_count int NOT NULL DEFAULT '0' COMMENT '派送托盘数', total_weight_kg DECIMAL(18,4) NULL COMMENT '总重量(千克),任一来源未确认时为空', - total_volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '总体积(立方米)', + total_volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米),来源未确认时为空', planned_delivery_at datetime(3) DEFAULT NULL COMMENT '预计派送时间', dispatched_at datetime(3) DEFAULT NULL COMMENT '实际派送时间', expected_arrival_at datetime(3) DEFAULT NULL COMMENT '预计到达时间', @@ -1725,7 +1725,7 @@ CREATE TABLE ll_outbound_order ( pallet_count int NOT NULL DEFAULT '0' COMMENT '托盘数', gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克),来源未确认时为空', volume_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '材积重(千克)', - volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '体积(立方米)', + volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米),来源未确认时为空', fee_coverage_json JSON NULL COMMENT '本次发运引用的原费用版本、实际货物及明确服务覆盖确认快照', idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键', created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID', @@ -1754,7 +1754,7 @@ CREATE TABLE ll_outbound_pallet_item ( package_count int NOT NULL DEFAULT '0' COMMENT '箱数', gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克),来源未确认时为空', volume_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '材积重(千克)', - volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '体积(立方米)', + volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米),来源未确认时为空', picked_package_count int NOT NULL DEFAULT '0' COMMENT '已拣箱数', status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态', sku_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '一件代发SKU', diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index 6abe4ce17..05f1940c5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -1478,10 +1478,17 @@ public class DeliveryTransferService { && !Objects.equals(delivery.getDestinationWarehouseCode(), item.getDestinationWarehouseCode())) continue; ReturnInventoryCarton carton = returnCartons.findCustomerMark(customerId, item.getCartonMarks()); if (carton == null) throw conflict("退货派送单来源箱记录缺失"); - for (var line : returnCartonItems.listCarton(carton.getId())) + boolean relabelCarton = "RELABEL_OUTBOUND".equals(sourceOutbound.getOutboundType()); + boolean firstSku = true; + // Match receipt cargo semantics: stable first SKU carries the physical box once, never per piece. + for (var line : returnCartonItems.listCarton(carton.getId())) { cargo.add(new DeliveryResponses.DeliverySku(item.getInboundCode(), delivery.getCustomerName(), carton.getDestinationWarehouseCode(), delivery.getDeliveryAddress(), null, null, - line.getSkuCode(), line.getProductNameZh(), 1, null, null, line.getQuantity())); + line.getSkuCode(), line.getProductNameZh(), !relabelCarton || firstSku ? 1 : 0, + relabelCarton && firstSku ? item.getGrossWeightKg() : null, + relabelCarton && firstSku ? item.getVolumeCbm() : null, line.getQuantity())); + firstSku = false; + } } return new DeliveryResponses.DeliveryDetail(deliveryRow(delivery), cargo, feeSources.listForBusiness(customerId, "DELIVERY_ORDER", delivery.getId()).stream().map(this::feeRow).toList(), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java index 11da41ef8..cd13fa70a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java @@ -105,6 +105,8 @@ public class RelabelApplicationService implements WalletResultHandler { @Resource private OutboundPalletItemMapper outboundItems; @Resource private OutboundPdaTaskMapper outboundTasks; @Resource private com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper deliveryOrders; + @Resource private com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customerDirectory; + @Resource private com.baiyue.lastleg.fleet.driver.FleetWarehouseDirectory warehouseDirectory; @Resource private OverseasFeeSourceService feeSources; @Resource private WarehouseTransferOrderMapper warehouseTransfers; @Resource private com.baiyue.lastleg.transfer.service.WarehouseTransferRelabelCompletionService transferRelabelCompletion; @@ -1089,6 +1091,7 @@ public class RelabelApplicationService implements WalletResultHandler { return; } List relabelItems = itemList(application.getId()); + Map confirmedReturnCartons = new LinkedHashMap<>(); for (RelabelItem source : relabelItems) { if (source.getReturnCartonId() == null) continue; ReturnInventoryCarton carton = returnCartons.lockId(source.getReturnCartonId()); @@ -1099,6 +1102,22 @@ public class RelabelApplicationService implements WalletResultHandler { || intValue(source.getNotifiedPackageCount()) != 1) throw conflict("退货整箱换标出库必须覆盖该箱全部 SKU 数量:" + source.getCartonMark()); com.baiyue.lastleg.returnreceipt.service.ReturnCartonCountGuard.requireConfirmed(carton); + confirmedReturnCartons.put(carton.getId(), carton); + } + boolean returnOnly = relabelItems.stream().allMatch(item -> item.getReturnCartonId() != null); + String customerName = null; + String warehouseName = null; + if (returnOnly) { + // Resolve once per new outbound, before any insert; every destination shares these snapshots. + var customer = customerDirectory.find(application.getCustomerId()); + var warehouse = warehouseDirectory.required(Long.parseLong(application.getOverseasWarehouseId()), "OVERSEAS"); + if (customer == null || !application.getCustomerId().equals(customer.id()) || !StringUtils.hasText(customer.name()) + || warehouse == null || !application.getOverseasWarehouseId().equals(warehouse.id()) + || !"OVERSEAS".equals(warehouse.region()) || !StringUtils.hasText(warehouse.name())) { + throw conflict("客户或作业仓库名称未完整返回,请核实主档后重试"); + } + customerName = customer.name().trim(); + warehouseName = warehouse.name().trim(); } OutboundOrder outbound = new OutboundOrder(); outbound.setId(IdWorker.getId()); outbound.setCustomerId(application.getCustomerId()); @@ -1107,6 +1126,10 @@ public class RelabelApplicationService implements WalletResultHandler { outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(relabelItems.stream().mapToInt(item -> item.getReturnCartonId() == null ? intValue(item.getNotifiedPackageCount()) : 1).sum()); outbound.setPalletCount((int) relabelItems.stream().map(this::outboundSourceKey).distinct().count()); outbound.setRecipientInfo(application.getDeliveryMethod()); outbound.setIdempotencyKey(key); + if (returnOnly) { + var measurement = returnMeasurement(relabelItems, confirmedReturnCartons); + outbound.setGrossWeightKg(measurement.weightKg()); outbound.setVolumeCbm(measurement.volumeCbm()); + } outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); outboundOrders.insert(outbound); Map> byPosition = relabelItems.stream().collect(Collectors.groupingBy( this::outboundSourceKey, LinkedHashMap::new, Collectors.toList())); @@ -1132,12 +1155,16 @@ public class RelabelApplicationService implements WalletResultHandler { .filter(StringUtils::hasText).distinct().collect(Collectors.joining(","))); item.setInboundCode(group.stream().map(RelabelItem::getSourceInboundCode).filter(StringUtils::hasText).distinct().collect(Collectors.joining(","))); item.setOrderRefs(writeOutboundRefs(application.getRelabelNo(), position == null ? null : entry.getKey(), group)); + if (position == null) { + var measurement = returnMeasurement(group, confirmedReturnCartons); + item.setGrossWeightKg(measurement.weightKg()); item.setVolumeCbm(measurement.volumeCbm()); + } item.setCreatedAt(now); outboundItems.insert(item); } OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(application.getCustomerId()); task.setOutboundOrderId(outbound.getId()); task.setTaskNo("PDA" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + task.getId()); task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); outboundTasks.insert(task); - if (relabelItems.stream().allMatch(item -> item.getReturnCartonId() != null)) { + if (returnOnly) { Map> destinations = relabelItems.stream().collect(Collectors.groupingBy( item -> Objects.toString(item.getDestinationFbaWarehouseCode(), ""), LinkedHashMap::new, Collectors.toList())); int index = 0; @@ -1147,12 +1174,15 @@ public class RelabelApplicationService implements WalletResultHandler { delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY", application.getOverseasWarehouseId(), delivery.getId())); delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo()); delivery.setOverseasWarehouseId(application.getOverseasWarehouseId()); + delivery.setCustomerName(customerName); delivery.setOverseasWarehouseName(warehouseName); delivery.setDeliveryGroupKey("RETURN_RELABEL:" + destination.getKey()); delivery.setDestinationWarehouseCode(destination.getKey()); delivery.setDestinationType(StringUtils.hasText(destination.getKey()) ? "FBA" : null); delivery.setContactName(application.getContactName()); delivery.setContactPhone(application.getContactPhone()); delivery.setDeliveryMethod(application.getDeliveryMethod()); delivery.setStatus("WAIT_DELIVERY"); delivery.setPackageCount(destination.getValue().size()); delivery.setPalletCount(0); + var measurement = returnMeasurement(destination.getValue(), confirmedReturnCartons); + delivery.setTotalWeightKg(measurement.weightKg()); delivery.setTotalVolumeCbm(measurement.volumeCbm()); delivery.setIdempotencyKey(key + ":DELIVERY:" + index++); delivery.setVersion(0L); delivery.setCreatedBy(operatorId); delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery); @@ -1161,6 +1191,24 @@ public class RelabelApplicationService implements WalletResultHandler { application.setOutboundOrderNo(outbound.getOutboundNo()); } + private record ReturnMeasurement(BigDecimal weightKg, BigDecimal volumeCbm) { } + + private ReturnMeasurement returnMeasurement(List sources, Map cartons) { + BigDecimal weight = BigDecimal.ZERO; + BigDecimal volume = BigDecimal.ZERO; + // The selected physical carton contributes once, regardless of its SKU count or piece quantities. + for (Long cartonId : sources.stream().map(RelabelItem::getReturnCartonId).distinct().toList()) { + ReturnInventoryCarton carton = cartons.get(cartonId); + if (carton == null) return new ReturnMeasurement(null, null); + weight = weight == null || !positive(carton.getWeight()) ? null : weight.add(carton.getWeight()); + volume = volume == null || !positive(carton.getLength()) || !positive(carton.getWidth()) || !positive(carton.getHeight()) + ? null : volume.add(carton.getLength().multiply(carton.getWidth()).multiply(carton.getHeight()).movePointLeft(6)); + } + return new ReturnMeasurement(weight, volume); + } + + private static boolean positive(BigDecimal value) { return value != null && value.signum() > 0; } + private String writeOutboundRefs(String relabelNo, Long positionId, List values) { try { Map refs = new LinkedHashMap<>(); diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index fd865eb5c..f1d8da421 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -300,7 +300,7 @@ CREATE TABLE IF NOT EXISTS ll_delivery_order ( package_count INT NOT NULL DEFAULT 0 COMMENT '派送箱数', pallet_count INT NOT NULL DEFAULT 0 COMMENT '派送托盘数', total_weight_kg DECIMAL(18,4) NULL COMMENT '总重量(千克),任一来源未确认时为空', - total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)', + total_volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米),来源未确认时为空', planned_delivery_at DATETIME(3) NULL COMMENT '预计派送时间', dispatched_at DATETIME(3) NULL COMMENT '实际派送时间', expected_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', @@ -2053,7 +2053,7 @@ CREATE TABLE IF NOT EXISTS ll_outbound_order ( overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_OUTBOUND' COMMENT '业务状态', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', pallet_count INT NOT NULL DEFAULT 0 COMMENT '托盘数', gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克),来源未确认时为空', volume_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '材积重(千克)', - volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)', + volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米),来源未确认时为空', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', created_by VARCHAR(128) NULL COMMENT '创建人ID', 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 '更新时间', outbound_type VARCHAR(32) NOT NULL DEFAULT 'DISPATCH_DELIVERY' COMMENT '出库类型:派送转出库、一件代发', lx_codes VARCHAR(1000) NULL COMMENT '关联一件代发领星入库单代码汇总', @@ -2069,7 +2069,7 @@ CREATE TABLE IF NOT EXISTS ll_outbound_pallet_item ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID', warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', - package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克),来源未确认时为空', volume_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '材积重(千克)', volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)', picked_package_count INT NOT NULL DEFAULT 0 COMMENT '已拣箱数', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态', sku_code VARCHAR(128) NULL COMMENT '一件代发SKU', return_sku_quantity BIGINT NULL COMMENT '退货单SKU本来源计划出库件数快照,NULL为历史整箱出库', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克),来源未确认时为空', volume_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '材积重(千克)', volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米),来源未确认时为空', picked_package_count INT NOT NULL DEFAULT 0 COMMENT '已拣箱数', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态', sku_code VARCHAR(128) NULL COMMENT '一件代发SKU', return_sku_quantity BIGINT NULL COMMENT '退货单SKU本来源计划出库件数快照,NULL为历史整箱出库', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), UNIQUE KEY uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id,inbound_code,sku_code), KEY idx_ll_outbound_pallet_list (customer_id,outbound_order_id), KEY idx_ll_outbound_pallet_destination (customer_id,destination_warehouse_code) diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index bda885e5a..68d054a7f 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -2054,6 +2054,10 @@ components: required: [deliveryNo, packageCount, signedPackages] properties: deliveryNo: { type: string } + customerName: { type: string, nullable: true, description: 客户名称快照;退货换标直出创建时从权威客户目录获取,查询不按ID猜名 } + overseasWarehouseName: { type: string, nullable: true, description: 作业海外仓名称快照;退货换标直出创建时从权威仓库目录获取 } + totalWeightKg: { type: number, nullable: true, description: 实物毛重千克;退货换标直出按选中实际箱每箱一次汇总,任一箱缺测则为空,不按SKU件数倍乘 } + totalVolumeCbm: { type: number, nullable: true, description: 实物体积立方米;退货换标直出按每箱实测长宽高厘米相乘除以1000000,任一箱缺尺寸则为空 } packageCount: { type: integer, minimum: 0 } signedPackages: type: integer @@ -2080,7 +2084,7 @@ components: order: { $ref: '#/components/schemas/DeliveryOrderRow' } skus: type: array - description: 当前派送目的地的本次排车货物投影;数量按冻结排车与本次PDA实拣归属,不返回整单申报箱数。未实拣或MIXED库存没有单SKU归属时保留数量,SKU及品名为null;没有本次SKU测量事实时重量与体积为null,不按比例分摊整单测量。 + description: 当前派送目的地的本次排车货物投影;数量按冻结排车与本次PDA实拣归属,不返回整单申报箱数。未实拣或MIXED库存没有单SKU归属时保留数量,SKU及品名为null;没有本次SKU测量事实时重量与体积为null,不按比例分摊整单测量。退货换标直接出库沿签收货物约定,按每实物箱的sku_code排序,仅首SKU行承载1箱及冻结的整箱重量/体积,其余SKU行承载0箱、物理量为空;每行productQuantity仍是对应SKU件数,整箱物理量不是单SKU净重。 items: { $ref: '#/components/schemas/DeliveryCargoSku' } DeliveryCargoSku: type: object @@ -2094,9 +2098,10 @@ components: fbaTrackingNo: { type: string, nullable: true } skuCode: { type: string, nullable: true } productName: { type: string, nullable: true } - packageCount: { type: integer, minimum: 0, description: 本次排车/实拣的箱数,不是整单申报箱数 } - weightKg: { type: number, nullable: true } - volumeCbm: { type: number, nullable: true } + packageCount: { type: integer, minimum: 0, description: 本次实物箱贡献数,不是件数或整单申报箱数;退货换标直出同箱仅首SKU行计1,其余为0 } + productQuantity: { type: string, nullable: true, pattern: '^[0-9]+$', description: 退货箱中本行SKU件数,long按字符串返回;不用于乘算箱级重量或体积 } + weightKg: { type: number, nullable: true, description: 千克;退货换标直出同箱首SKU行承载冻结整箱毛重,其余行或缺测为null } + volumeCbm: { type: number, nullable: true, description: 立方米;退货换标直出同箱首SKU行承载冻结整箱体积,其余行或缺测为null } ReturnOrderCreate: type: object required: [overseasWarehouseId, needRelabel, idempotencyKey, items] diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java index 68df52f74..f96710cda 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java @@ -30,6 +30,18 @@ class DeliverySchemaContractTest { } } @Test + void directOutboundSnapshotsKeepUnknownVolumeNullableAcrossBothBaselines() throws Exception { + for (Path path : java.util.List.of(Path.of("src/main/resources/db/lastleg.sql"), Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"))) { + String sql = Files.readString(path, StandardCharsets.UTF_8); + for (String table : java.util.List.of("ll_outbound_order", "ll_outbound_pallet_item", "ll_delivery_order")) { + var matcher = java.util.regex.Pattern.compile("(?is)CREATE TABLE(?: IF NOT EXISTS)? " + table + " \\(.*?;").matcher(sql); + assertTrue(matcher.find(), table); + String column = "ll_delivery_order".equals(table) ? "total_volume_cbm" : "volume_cbm"; + assertTrue(java.util.regex.Pattern.compile("(?i)" + column + " DECIMAL\\(18,4\\) NULL COMMENT").matcher(matcher.group()).find(), table + " keeps unknown volume NULL"); + } + } + } + @Test void baselineDefinesOutboundPalletsAndDeliveryContracts() throws Exception { String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8) .toLowerCase(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java index 3ecfe4615..0d8e6a3d9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -311,6 +311,38 @@ class DeliveryTransferServiceTest { assertEquals(1, detail.skus().get(0).packageCount()); } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {true, false}) + void relabelDeliveryDetailCarriesFrozenPhysicalMeasurementOnFirstSkuOnly(boolean measured) { + DeliveryOrder delivery = new DeliveryOrder(); delivery.setId(501L); delivery.setCustomerId("TENANT-A"); + delivery.setDeliveryNo("DL-501"); delivery.setOutboundOrderId(301L); delivery.setOutboundNo("OUT-301"); + delivery.setOverseasWarehouseId("WH-1"); delivery.setCustomerName("客户快照"); delivery.setOverseasWarehouseName("仓库快照"); + delivery.setDeliveryGroupKey("RETURN_RELABEL:ABE4"); delivery.setDestinationWarehouseCode("ABE4"); + delivery.setStatus("WAIT_DELIVERY"); + OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "relabel-1"); outbound.setOutboundType("RELABEL_OUTBOUND"); + OutboundPalletItem item = new OutboundPalletItem(); item.setCartonMarks("RC-1"); item.setDestinationWarehouseCode("ABE4"); + if (measured) { item.setGrossWeightKg(BigDecimal.ONE); item.setVolumeCbm(new BigDecimal("0.008")); } + ReturnInventoryCarton carton = returnCarton("RC-1", null); carton.setId(901L); + carton.setWeight(new BigDecimal("99")); // Reading the current carton must not replace the frozen outbound measurement. + var first = new ReturnInventoryCartonItem(); first.setSkuCode("SKU-A"); first.setQuantity(3L); + var second = new ReturnInventoryCartonItem(); second.setSkuCode("SKU-B"); second.setQuantity(2L); + when(deliveryOrders.findByNo("TENANT-A", "DL-501")).thenReturn(delivery); + when(outboundOrders.selectById(301L)).thenReturn(outbound); + when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item)); + when(returnCartons.findCustomerMark("TENANT-A", "RC-1")).thenReturn(carton); + when(returnCartonItems.listCarton(901L)).thenReturn(List.of(first, second)); + + var detail = service.deliveryDetail("TENANT-A", "DL-501"); + + assertEquals("客户快照", detail.order().customerName()); assertEquals("仓库快照", detail.order().overseasWarehouseName()); + assertEquals(2, detail.skus().size()); assertEquals(1, detail.skus().stream().mapToInt(row -> row.packageCount()).sum()); + assertEquals(5L, detail.skus().stream().mapToLong(row -> row.productQuantity()).sum()); + assertEquals(measured ? BigDecimal.ONE : null, detail.skus().get(0).weightKg()); + assertEquals(measured ? new BigDecimal("0.008") : null, detail.skus().get(0).volumeCbm()); + assertEquals(0, detail.skus().get(1).packageCount()); + assertNull(detail.skus().get(1).weightKg()); assertNull(detail.skus().get(1).volumeCbm()); + } + @Test void returnCartonPickDelegatesInventoryDeductionOnce() { OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "return-1"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java index a3990662b..2776687fd 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java @@ -1078,6 +1078,14 @@ class RelabelApplicationServiceTest { ReflectionTestUtils.setField(service, "bindingGuard", realGuard); ReflectionTestUtils.setField(service, "skuConversions", realConversions); ReflectionTestUtils.setField(service, "returnCartons", cartons); ReflectionTestUtils.setField(service, "deliveryOrders", org.mockito.Mockito.mock(com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper.class)); + if (!"SKU_LABEL".equals(operation)) { + var customers = org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.class); + var warehouses = org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.driver.FleetWarehouseDirectory.class); + ReflectionTestUtils.setField(service, "customerDirectory", customers); + ReflectionTestUtils.setField(service, "warehouseDirectory", warehouses); + when(customers.find("10001")).thenReturn(new com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.Customer("10001", "C", "客户", null, null)); + when(warehouses.required(900L, "OVERSEAS")).thenReturn(new com.baiyue.lastleg.fleet.driver.DriverModels.WarehouseOption("900", "WH", "海外仓", "OVERSEAS")); + } var command = new RelabelRequests.CustomerConfirm("converted-confirm", null); service.confirmCompletion("10001", "FBR-1", "customer-1", command); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/ReturnRelabelDeliveryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/ReturnRelabelDeliveryTest.java index 1e4e35616..a19d418ee 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/ReturnRelabelDeliveryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/ReturnRelabelDeliveryTest.java @@ -1,6 +1,11 @@ package com.baiyue.lastleg.relabel.service; import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; +import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory; +import com.baiyue.lastleg.fleet.driver.FleetWarehouseDirectory; +import com.baiyue.lastleg.fleet.driver.DriverModels; import com.baiyue.lastleg.delivery.mapper.*; import com.baiyue.lastleg.relabel.entity.*; import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; @@ -12,13 +17,82 @@ import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; import java.time.LocalDateTime; import java.util.List; +import java.math.BigDecimal; +import org.mockito.ArgumentCaptor; +import org.springframework.web.server.ResponseStatusException; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; import static org.mockito.ArgumentMatchers.*; class ReturnRelabelDeliveryTest { @org.junit.jupiter.params.ParameterizedTest @org.junit.jupiter.params.provider.ValueSource(strings = {"EXPRESS", "PICKUP", "AMAZON_PICKUP", "CUSTOMER_PICKUP"}) - void directNativeRelabelCreatesTwoPhysicalBoxesAndOneDeliveryPerDestination(String deliveryMethod) { + void directNativeRelabelSnapshotsNamesAndCountsMeasuredBoxesOncePerDestination(String deliveryMethod) { + var f = fixture(deliveryMethod); + f.create(); + verify(f.deliveries, times(2)).insert(any(DeliveryOrder.class)); + var rows = ArgumentCaptor.forClass(DeliveryOrder.class); + verify(f.deliveries, times(2)).insert(rows.capture()); + for (var row : rows.getAllValues()) { + assertEquals("测试客户", row.getCustomerName()); assertEquals("洛杉矶测试仓", row.getOverseasWarehouseName()); + assertEquals("100", row.getOverseasWarehouseId()); assertEquals(deliveryMethod, row.getDeliveryMethod()); + assertEquals("WAIT_DELIVERY", row.getStatus()); assertEquals(f.application.getOutboundOrderNo(), row.getOutboundNo()); + int boxes = "A".equals(row.getDestinationWarehouseCode()) ? 2 : 1; + assertEquals(boxes, row.getPackageCount()); + assertEquals(0, BigDecimal.valueOf(boxes).compareTo(row.getTotalWeightKg())); + assertEquals(0, new BigDecimal("0.008").multiply(BigDecimal.valueOf(boxes)).compareTo(row.getTotalVolumeCbm())); + } + var outbound = ArgumentCaptor.forClass(OutboundOrder.class); + verify(f.outbounds).insert(outbound.capture()); + assertEquals(3, outbound.getValue().getPackageCount()); + assertEquals(0, new BigDecimal("3").compareTo(outbound.getValue().getGrossWeightKg())); + assertEquals(0, new BigDecimal("0.024").compareTo(outbound.getValue().getVolumeCbm())); + var physical = ArgumentCaptor.forClass(OutboundPalletItem.class); + verify(f.outboundItems, times(3)).insert(physical.capture()); + assertTrue(physical.getAllValues().stream().allMatch(row -> row.getPackageCount() == 1 + && BigDecimal.ONE.compareTo(row.getGrossWeightKg()) == 0 && new BigDecimal("0.008").compareTo(row.getVolumeCbm()) == 0)); + verify(f.customers, times(1)).find("C1"); verify(f.warehouses, times(1)).required(100L, "OVERSEAS"); + } + + @Test + void incompleteMeasurementStaysUnknownWithoutBlockingAndDoesNotContaminateOtherDestination() { + var f = fixture("AMAZON_PICKUP"); + f.cartonRows.get(0).setWeight(null); f.cartonRows.get(0).setHeight(null); + f.create(); + var rows = ArgumentCaptor.forClass(DeliveryOrder.class); verify(f.deliveries, times(2)).insert(rows.capture()); + var a = rows.getAllValues().stream().filter(row -> "A".equals(row.getDestinationWarehouseCode())).findFirst().orElseThrow(); + var b = rows.getAllValues().stream().filter(row -> "B".equals(row.getDestinationWarehouseCode())).findFirst().orElseThrow(); + assertNull(a.getTotalWeightKg()); assertNull(a.getTotalVolumeCbm()); + assertEquals(0, BigDecimal.ONE.compareTo(b.getTotalWeightKg())); + assertEquals(0, new BigDecimal("0.008").compareTo(b.getTotalVolumeCbm())); + var outbound = ArgumentCaptor.forClass(OutboundOrder.class); verify(f.outbounds).insert(outbound.capture()); + assertNull(outbound.getValue().getGrossWeightKg()); assertNull(outbound.getValue().getVolumeCbm()); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"customer-name", "customer-id", "warehouse-name", "warehouse-id", "unavailable"}) + void invalidDirectorySnapshotDoesNotCreateAnyDocument(String failure) { + var f = fixture("CUSTOMER_PICKUP"); + if (failure.startsWith("customer")) when(f.customers.find("C1")).thenReturn(new FleetCustomerDirectory.Customer( + "customer-id".equals(failure) ? "OTHER" : "C1", "C", "customer-name".equals(failure) ? " " : "客户", null, null)); + else if (failure.startsWith("warehouse")) when(f.warehouses.required(100L, "OVERSEAS")).thenReturn(new DriverModels.WarehouseOption( + "warehouse-id".equals(failure) ? "200" : "100", "WH", "warehouse-name".equals(failure) ? " " : "仓库", "OVERSEAS")); + else when(f.customers.find("C1")).thenThrow(new ResponseStatusException(org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE)); + assertThrows(ResponseStatusException.class, f::create); + verify(f.outbounds, never()).insert(any(OutboundOrder.class)); + verifyNoInteractions(f.deliveries, f.outboundItems); + } + + @Test + void existingOutboundReplayDoesNotRefreshNamesOrMeasurements() { + var f = fixture("PICKUP"); var existing = new OutboundOrder(); existing.setOutboundNo("OUT-EXISTING"); + when(f.outbounds.findByIdempotency("C1", "RELABEL:1:OUTBOUND")).thenReturn(existing); + f.create(); + assertEquals("OUT-EXISTING", f.application.getOutboundOrderNo()); + verifyNoInteractions(f.customers, f.warehouses, f.deliveries, f.outboundItems); + } + + private Fixture fixture(String deliveryMethod) { var service = new RelabelApplicationService(); var items = mock(RelabelItemMapper.class); var cartons = mock(ReturnInventoryCartonMapper.class); @@ -26,28 +100,36 @@ class ReturnRelabelDeliveryTest { ReflectionTestUtils.setField(service, "items", items); ReflectionTestUtils.setField(service, "returnCartons", cartons); ReflectionTestUtils.setField(service, "deliveryOrders", deliveries); - ReflectionTestUtils.setField(service, "outboundOrders", mock(OutboundOrderMapper.class)); - ReflectionTestUtils.setField(service, "outboundItems", mock(OutboundPalletItemMapper.class)); + var outbounds = mock(OutboundOrderMapper.class); var outboundItems = mock(OutboundPalletItemMapper.class); + ReflectionTestUtils.setField(service, "outboundOrders", outbounds); + ReflectionTestUtils.setField(service, "outboundItems", outboundItems); + var customers = mock(FleetCustomerDirectory.class); var warehouses = mock(FleetWarehouseDirectory.class); + ReflectionTestUtils.setField(service, "customerDirectory", customers); ReflectionTestUtils.setField(service, "warehouseDirectory", warehouses); + when(customers.find("C1")).thenReturn(new FleetCustomerDirectory.Customer("C1", "C", "测试客户", null, null)); + when(warehouses.required(100L, "OVERSEAS")).thenReturn(new DriverModels.WarehouseOption("100", "WH", "洛杉矶测试仓", "OVERSEAS")); ReflectionTestUtils.setField(service, "outboundTasks", mock(OutboundPdaTaskMapper.class)); ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator()); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); var application = new RelabelApplication(); application.setId(1L); application.setCustomerId("C1"); - application.setRelabelNo("REL1"); application.setOverseasWarehouseId("WH1"); application.setDeliveryMethod(deliveryMethod); - var a = source(10L, "A"); var b = source(11L, "B"); - when(items.selectList(any())).thenReturn(List.of(a, b)); - for (var item : List.of(a, b)) { + application.setRelabelNo("REL1"); application.setOverseasWarehouseId("100"); application.setDeliveryMethod(deliveryMethod); + var sources = List.of(source(10L, "A"), source(11L, "B"), source(12L, "A")); + when(items.selectList(any())).thenReturn(sources); + var cartonRows = new java.util.ArrayList(); + for (var item : sources) { var carton = new ReturnInventoryCarton(); carton.setId(item.getReturnCartonId()); carton.setCustomerId("C1"); carton.setRelabelApplicationId(1L); carton.setStatus("RELABEL_PENDING"); - carton.setOverseasWarehouseId("WH1"); + carton.setOverseasWarehouseId("100"); carton.setCountStatus("CONFIRMED"); carton.setSourceUnitType("PALLET"); + carton.setWeight(BigDecimal.ONE); carton.setLength(new BigDecimal("20")); + carton.setWidth(new BigDecimal("20")); carton.setHeight(new BigDecimal("20")); + carton.setTotalQuantity(5L); carton.setSkuTypes(2); cartonRows.add(carton); when(cartons.lockId(carton.getId())).thenReturn(carton); } - ReflectionTestUtils.invokeMethod(service, "createDirectOutbound", application, "OP1", LocalDateTime.now()); - verify(deliveries, times(2)).insert(any(DeliveryOrder.class)); - verify(deliveries).insert(argThat((DeliveryOrder d) -> "A".equals(d.getDestinationWarehouseCode()) - && d.getPackageCount() == 1 && "WAIT_DELIVERY".equals(d.getStatus()) - && deliveryMethod.equals(d.getDeliveryMethod()) - && application.getOutboundOrderNo().equals(d.getOutboundNo()))); - verify(deliveries).insert(argThat((DeliveryOrder d) -> "B".equals(d.getDestinationWarehouseCode()) && d.getPackageCount() == 1)); + return new Fixture(service, application, customers, warehouses, deliveries, outbounds, outboundItems, cartonRows); + } + private record Fixture(RelabelApplicationService service, RelabelApplication application, FleetCustomerDirectory customers, + FleetWarehouseDirectory warehouses, DeliveryOrderMapper deliveries, OutboundOrderMapper outbounds, + OutboundPalletItemMapper outboundItems, List cartonRows) { + void create() { ReflectionTestUtils.invokeMethod(service, "createDirectOutbound", application, "OP1", LocalDateTime.now()); } } private RelabelItem source(long id, String destination) { var item = new RelabelItem(); item.setId(id); item.setReturnCartonId(id);