This commit is contained in:
2026-09-15 14:30:47 +08:00
parent a474ca4d93
commit d1a00b8e01
18 changed files with 141 additions and 30 deletions
+2
View File
@@ -81,6 +81,8 @@ services:
WAREHOUSE_DB_USERNAME: ${WAREHOUSE_OVERSEAS_DB_USERNAME} WAREHOUSE_DB_USERNAME: ${WAREHOUSE_OVERSEAS_DB_USERNAME}
WAREHOUSE_DB_PASSWORD: ${WAREHOUSE_OVERSEAS_DB_PASSWORD} WAREHOUSE_DB_PASSWORD: ${WAREHOUSE_OVERSEAS_DB_PASSWORD}
DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL} DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL}
# 与海外 BOL 共用国内 Print;不能在海外 Nacos namespace 中发现该服务。
PRINT_SERVICE_URL: ${PRINT_SERVICE_URL:-http://print:9207}
event-bridge-service: event-bridge-service:
<<: *overseas-common <<: *overseas-common
+1 -1
View File
@@ -52,7 +52,7 @@
| 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/`、`relabel/pda/`;一条 `ll_relabel_item` 对应一个客户填写的我方箱唛任务,`CARTON_LABEL`、`SKU_LABEL`、`CARTON_AND_SKU` 分别保存箱唛、SKU 或组合需求,SKU 子项 JSON 数量按件;普通尾程入库没有箱唛到实物托盘的精确快照,历史入仓库存仓位只能作为冻结记账锚点和现场大概位置,禁止把该仓位解释为箱唛已绑定具体托盘。库存只按箱任务冻结一次。涉及箱唛换标时申请必须选择 `TRUCK/EXPRESS/TRUCK_PICKUP/EXPRESS_PICKUP`,两种快递方式可要求打印并上传面单;PDA 扫我方箱唛完成逐箱事实和照片,仓库复核通过进入 `CUSTOMER_CONFIRM_PENDING`,客户查看照片确认后进入 `READY_FOR_OUTBOUND`:仅卡车派送为 `WAIT_PRE_DISPATCH`,快递派送和两种自提直接创建换标出库单。客户确认前不得进入出库 | | 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/`、`relabel/pda/`;一条 `ll_relabel_item` 对应一个客户填写的我方箱唛任务,`CARTON_LABEL`、`SKU_LABEL`、`CARTON_AND_SKU` 分别保存箱唛、SKU 或组合需求,SKU 子项 JSON 数量按件;普通尾程入库没有箱唛到实物托盘的精确快照,历史入仓库存仓位只能作为冻结记账锚点和现场大概位置,禁止把该仓位解释为箱唛已绑定具体托盘。库存只按箱任务冻结一次。涉及箱唛换标时申请必须选择 `TRUCK/EXPRESS/TRUCK_PICKUP/EXPRESS_PICKUP`,两种快递方式可要求打印并上传面单;PDA 扫我方箱唛完成逐箱事实和照片,仓库复核通过进入 `CUSTOMER_CONFIRM_PENDING`,客户查看照片确认后进入 `READY_FOR_OUTBOUND`:仅卡车派送为 `WAIT_PRE_DISPATCH`,快递派送和两种自提直接创建换标出库单。客户确认前不得进入出库 |
| PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java`、`pda/warehouse/service/PdaWarehouseService.java`;解析库存、库位和托盘码并查询操作历史,固定要求 `lastleg:pda:operate` 与 `ROLE_WAREHOUSE_PICKER`,仓库范围只能取自 PDA Token | | PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java`、`pda/warehouse/service/PdaWarehouseService.java`;解析库存、库位和托盘码并查询操作历史,固定要求 `lastleg:pda:operate` 与 `ROLE_WAREHOUSE_PICKER`,仓库范围只能取自 PDA Token |
| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java`、`shipping/entity/ContainerShippingGate.java`、`shipping/mapper/ContainerShippingGateMapper.java`;按 `containerBookingId` 柜级接收,信封使用 `tenantId=0` 且门禁不存客户;只单调接受实际标准节点,`PICKUP_READY` 及以后才允许对应柜进入 PDA 收货候选 | | 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java`、`shipping/entity/ContainerShippingGate.java`、`shipping/mapper/ContainerShippingGateMapper.java`;按 `containerBookingId` 柜级接收,信封使用 `tenantId=0` 且门禁不存客户;只单调接受实际标准节点,`PICKUP_READY` 及以后才允许对应柜进入 PDA 收货候选 |
| 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,查询、预排与执行边界见“海外仓全流程开发路由” | | 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,路线由当前已排托盘的 FBA 仓编码去重后按稳定顺序以英文逗号连接,预排与普通编辑请求均不接收路线;查询、预排与执行边界见“海外仓全流程开发路由” |
| 排车编辑、PDA 出库、派送与交接 | `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`MerchantDeliveryController.java`、`OutboundPdaController.java` | | 排车编辑、PDA 出库、派送与交接 | `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`MerchantDeliveryController.java`、`OutboundPdaController.java` |
| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` | | 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` |
| 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java`、`transfer/service/WarehouseTransferService.java` | | 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java`、`transfer/service/WarehouseTransferService.java` |
@@ -29,7 +29,7 @@ public class DispatchController {
@PutMapping("/{dispatchNo}") @PutMapping("/{dispatchNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_EDIT + "')") @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_EDIT + "')")
@Operation(summary = "Edit vehicle, driver, appointment and route data") @Operation(summary = "Edit vehicle, driver and appointment data; route is derived from assigned FBA warehouses")
public ApiResponse<DispatchResponse> update( public ApiResponse<DispatchResponse> update(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo, @PathVariable String dispatchNo,
@@ -20,18 +20,18 @@ public final class DispatchRequests {
} }
public record PreArrange(@NotEmpty @Size(max = 500) List<@NotNull Long> planPalletIds, public record PreArrange(@NotEmpty @Size(max = 500) List<@NotNull Long> planPalletIds,
@Size(max = 255) String route, @Size(max = 64) String fbaAppointmentNo, @Size(max = 64) String fbaAppointmentNo,
@Size(max = 32) String fbaAppointmentStatus, @Size(max = 32) String fbaAppointmentStatus,
@Size(max = 32) String vehicleAppointmentStatus, @Size(max = 32) String vehicleAppointmentStatus,
@Size(max = 64) String draftToken, @Size(max = 64) String draftToken,
@Size(max = 64) String relabelNo) { @Size(max = 64) String relabelNo) {
public PreArrange(List<Long> planPalletIds, String route, String fbaAppointmentNo, public PreArrange(List<Long> planPalletIds, String fbaAppointmentNo,
String fbaAppointmentStatus, String vehicleAppointmentStatus) { String fbaAppointmentStatus, String vehicleAppointmentStatus) {
this(planPalletIds, route, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, null, null); this(planPalletIds, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, null, null);
} }
public PreArrange(List<Long> planPalletIds, String route, String fbaAppointmentNo, public PreArrange(List<Long> planPalletIds, String fbaAppointmentNo,
String fbaAppointmentStatus, String vehicleAppointmentStatus, String draftToken) { String fbaAppointmentStatus, String vehicleAppointmentStatus, String draftToken) {
this(planPalletIds, route, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, draftToken, null); this(planPalletIds, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, draftToken, null);
} }
} }
@@ -51,7 +51,6 @@ public final class DispatchRequests {
public record Update(@Size(max = 64) String vehiclePlate, @Size(max = 128) String driverName, public record Update(@Size(max = 64) String vehiclePlate, @Size(max = 128) String driverName,
@Size(max = 64) String driverPhone, @Size(max = 255) String carrierName, @Size(max = 64) String driverPhone, @Size(max = 255) String carrierName,
@Size(max = 255) String route,
@Size(max = 64) String fbaAppointmentNo, @Size(max = 64) String fbaAppointmentNo,
@Size(max = 32) String fbaAppointmentStatus, @Size(max = 32) String fbaAppointmentStatus,
@Size(max = 32) String vehicleAppointmentStatus, @Size(max = 32) String vehicleAppointmentStatus,
@@ -305,7 +305,7 @@ public class DispatchService {
dispatch.setCreatedBy(trimToNull(operatorId)); dispatch.setCreatedBy(trimToNull(operatorId));
dispatch.setCreatedAt(now); dispatch.setCreatedAt(now);
} }
dispatch.setRoute(StringUtils.hasText(request.route()) ? request.route().trim() : route(selected)); dispatch.setRoute(candidateRoute(selected));
dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo()));
dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus()));
dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus()));
@@ -427,7 +427,7 @@ public class DispatchService {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
DispatchOrder dispatch = new DispatchOrder(); dispatch.setId(IdWorker.getId()); dispatch.setCustomerId(customerId); DispatchOrder dispatch = new DispatchOrder(); dispatch.setId(IdWorker.getId()); dispatch.setCustomerId(customerId);
dispatch.setDispatchNo(orderNoGenerator.next("DISPATCH", warehouse, dispatch.getId())); dispatch.setOverseasWarehouseId(warehouse); dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName()); dispatch.setDispatchNo(orderNoGenerator.next("DISPATCH", warehouse, dispatch.getId())); dispatch.setOverseasWarehouseId(warehouse); dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName());
dispatch.setRoute(StringUtils.hasText(request.route()) ? request.route().trim() : route(selected)); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setStatus(PENDING_DEPARTURE); dispatch.setItemCount(selected.size()); dispatch.setPalletCount(selected.size()); dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); dispatch.setPickedPackageCount(0); dispatch.setShippedPackageCount(0); dispatch.setCreatedBy(trimToNull(operatorId)); dispatch.setCreatedAt(now); dispatch.setUpdatedAt(now); orders.insert(dispatch); dispatch.setRoute(candidateRoute(selected)); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setStatus(PENDING_DEPARTURE); dispatch.setItemCount(selected.size()); dispatch.setPalletCount(selected.size()); dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); dispatch.setPickedPackageCount(0); dispatch.setShippedPackageCount(0); dispatch.setCreatedBy(trimToNull(operatorId)); dispatch.setCreatedAt(now); dispatch.setUpdatedAt(now); orders.insert(dispatch);
for (DispatchPalletCandidate candidate : selected) { DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); item.setOverseasWarehouseName(candidate.overseasWarehouseName()); item.setOrderNo(candidate.orderNo()); item.setOrderRefs(candidate.orderRefs()); item.setInboundCode(candidate.inboundCode()); item.setPalletNo(candidate.palletNo()); item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setSourceStage("RELABEL"); item.setCartonMarks(candidate.relabelCartonMarks()); item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(request.relabelNo()); item.setCreatedAt(now); items.insert(item); } for (DispatchPalletCandidate candidate : selected) { DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); item.setOverseasWarehouseName(candidate.overseasWarehouseName()); item.setOrderNo(candidate.orderNo()); item.setOrderRefs(candidate.orderRefs()); item.setInboundCode(candidate.inboundCode()); item.setPalletNo(candidate.palletNo()); item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setSourceStage("RELABEL"); item.setCartonMarks(candidate.relabelCartonMarks()); item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(request.relabelNo()); item.setCreatedAt(now); items.insert(item); }
DispatchPdaTask task = new DispatchPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setDispatchOrderId(dispatch.getId()); task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING"); task.setWarehouseId(warehouse); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task); DispatchPdaTask task = new DispatchPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setDispatchOrderId(dispatch.getId()); task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING"); task.setWarehouseId(warehouse); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task);
if (candidates.markRelabelPreDispatched(customerId, request.relabelNo()) != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单已被其他排车占用"); if (candidates.markRelabelPreDispatched(customerId, request.relabelNo()) != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单已被其他排车占用");
@@ -502,18 +502,19 @@ public class DispatchService {
"Only a pending dispatch can be edited"); "Only a pending dispatch can be edited");
} }
String customerId = dispatch.getCustomerId(); String customerId = dispatch.getCustomerId();
List<DispatchItem> assignedItems = items.findByDispatchOrder(customerId, dispatch.getId());
dispatch.setVehiclePlate(trimToNull(request.vehiclePlate())); dispatch.setVehiclePlate(trimToNull(request.vehiclePlate()));
dispatch.setDriverName(trimToNull(request.driverName())); dispatch.setDriverName(trimToNull(request.driverName()));
dispatch.setDriverPhone(trimToNull(request.driverPhone())); dispatch.setDriverPhone(trimToNull(request.driverPhone()));
dispatch.setCarrierName(trimToNull(request.carrierName())); dispatch.setCarrierName(trimToNull(request.carrierName()));
dispatch.setRoute(trimToNull(request.route())); dispatch.setRoute(itemRoute(assignedItems));
dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo()));
dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus()));
dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus()));
dispatch.setEstimatedArrivalAt(request.estimatedArrivalAt()); dispatch.setEstimatedArrivalAt(request.estimatedArrivalAt());
dispatch.setUpdatedAt(LocalDateTime.now()); dispatch.setUpdatedAt(LocalDateTime.now());
orders.updateById(dispatch); orders.updateById(dispatch);
return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId())); return response(dispatch, assignedItems);
} }
@Transactional @Transactional
@@ -545,6 +546,7 @@ public class DispatchService {
dispatch.setPalletCount(remaining.size()); dispatch.setPalletCount(remaining.size());
dispatch.setPackageCount(remaining.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull) dispatch.setPackageCount(remaining.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull)
.mapToInt(Integer::intValue).sum()); .mapToInt(Integer::intValue).sum());
dispatch.setRoute(itemRoute(remaining));
dispatch.setUpdatedAt(now); dispatch.setUpdatedAt(now);
orders.updateById(dispatch); orders.updateById(dispatch);
return response(dispatch, remaining); return response(dispatch, remaining);
@@ -608,7 +610,7 @@ public class DispatchService {
String orderNos = itemList.stream().flatMap(item -> splitOrderRefs(item.getOrderRefs(), item.getOrderNo()).stream()) String orderNos = itemList.stream().flatMap(item -> splitOrderRefs(item.getOrderRefs(), item.getOrderNo()).stream())
.distinct().collect(Collectors.joining(",")); .distinct().collect(Collectors.joining(","));
return new DispatchResponse(order.getId(), order.getDispatchNo(), order.getVehiclePlate(), order.getDriverName(), return new DispatchResponse(order.getId(), order.getDispatchNo(), order.getVehiclePlate(), order.getDriverName(),
order.getDriverPhone(), order.getCarrierName(), orderNos, order.getRoute(), order.getDriverPhone(), order.getCarrierName(), orderNos, itemRoute(itemList),
order.getOverseasWarehouseId(), order.getOverseasWarehouseName(), order.getDepartAt(), order.getOverseasWarehouseId(), order.getOverseasWarehouseName(), order.getDepartAt(),
order.getFbaAppointmentNo(), order.getFbaAppointmentStatus(), order.getVehicleAppointmentStatus(), order.getFbaAppointmentNo(), order.getFbaAppointmentStatus(), order.getVehicleAppointmentStatus(),
order.getEstimatedArrivalAt(), order.getStatus(), Objects.requireNonNullElse(order.getItemCount(), 0), order.getEstimatedArrivalAt(), order.getStatus(), Objects.requireNonNullElse(order.getItemCount(), 0),
@@ -625,9 +627,24 @@ public class DispatchService {
bolLineList == null ? List.of() : bolLineList); bolLineList == null ? List.of() : bolLineList);
} }
private static String route(List<DispatchPalletCandidate> selected) { private static String candidateRoute(List<DispatchPalletCandidate> selected) {
return selected.stream().map(DispatchPalletCandidate::destinationWarehouseCode).filter(StringUtils::hasText) return selected.stream().map(DispatchService::candidateFbaCode).filter(StringUtils::hasText)
.distinct().collect(Collectors.joining(" -> ")); .distinct().collect(Collectors.joining(","));
}
private static String itemRoute(List<DispatchItem> assignedItems) {
return assignedItems.stream().map(DispatchService::itemFbaCode).filter(StringUtils::hasText)
.distinct().collect(Collectors.joining(","));
}
private static String candidateFbaCode(DispatchPalletCandidate candidate) {
return StringUtils.hasText(candidate.fbaWarehouseCode())
? candidate.fbaWarehouseCode().trim() : trimToNull(candidate.destinationWarehouseCode());
}
private static String itemFbaCode(DispatchItem item) {
return StringUtils.hasText(item.getFbaWarehouseCode())
? item.getFbaWarehouseCode().trim() : trimToNull(item.getDestinationWarehouseCode());
} }
private DispatchPalletCandidate requireDraftCandidate(String customerId, Long planPalletId) { private DispatchPalletCandidate requireDraftCandidate(String customerId, Long planPalletId) {
@@ -2113,7 +2113,6 @@ components:
required: [planPalletIds] required: [planPalletIds]
properties: properties:
planPalletIds: { type: array, minItems: 1, maxItems: 500, uniqueItems: true, items: { type: string } } planPalletIds: { type: array, minItems: 1, maxItems: 500, uniqueItems: true, items: { type: string } }
route: { type: string, nullable: true, maxLength: 255 }
fbaAppointmentNo: { type: string, nullable: true, maxLength: 64 } fbaAppointmentNo: { type: string, nullable: true, maxLength: 64 }
fbaAppointmentStatus: { type: string, nullable: true, maxLength: 32, default: PENDING } fbaAppointmentStatus: { type: string, nullable: true, maxLength: 32, default: PENDING }
vehicleAppointmentStatus: { type: string, nullable: true, maxLength: 32, default: PENDING } vehicleAppointmentStatus: { type: string, nullable: true, maxLength: 32, default: PENDING }
@@ -2160,13 +2159,12 @@ components:
palletIds: { type: array, items: { type: string } } palletIds: { type: array, items: { type: string } }
DispatchUpdate: DispatchUpdate:
type: object type: object
description: 普通编辑字段;不包含 status 或 departAt。 description: 普通编辑字段;不包含 status、departAt 或路线,路线由已排托盘关联的 FBA 仓自动汇总。
properties: properties:
vehiclePlate: { type: string, nullable: true, maxLength: 64 } vehiclePlate: { type: string, nullable: true, maxLength: 64 }
driverName: { type: string, nullable: true, maxLength: 128 } driverName: { type: string, nullable: true, maxLength: 128 }
driverPhone: { type: string, nullable: true, maxLength: 64 } driverPhone: { type: string, nullable: true, maxLength: 64 }
carrierName: { type: string, nullable: true, maxLength: 255 } carrierName: { type: string, nullable: true, maxLength: 255 }
route: { type: string, nullable: true, maxLength: 255 }
fbaAppointmentNo: { type: string, nullable: true, maxLength: 64 } fbaAppointmentNo: { type: string, nullable: true, maxLength: 64 }
fbaAppointmentStatus: { type: string, nullable: true, maxLength: 32 } fbaAppointmentStatus: { type: string, nullable: true, maxLength: 32 }
vehicleAppointmentStatus: { type: string, nullable: true, maxLength: 32 } vehicleAppointmentStatus: { type: string, nullable: true, maxLength: 32 }
@@ -31,6 +31,12 @@ class SmartDispatchOpenApiContractTest {
assertTrue(yaml.contains("platform:overseas-warehouse:inbound-order:smart-dispatch")); assertTrue(yaml.contains("platform:overseas-warehouse:inbound-order:smart-dispatch"));
assertTrue(yaml.contains("SmartDispatchPreArrange")); assertTrue(yaml.contains("SmartDispatchPreArrange"));
assertTrue(yaml.contains("SmartDispatchCandidate")); assertTrue(yaml.contains("SmartDispatchCandidate"));
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(yaml.contains("name: planIds")); assertTrue(yaml.contains("name: planIds"));
String planIdsParameter = yaml.substring(yaml.indexOf("name: planIds"), String planIdsParameter = yaml.substring(yaml.indexOf("name: planIds"),
yaml.indexOf("name: orderNo", yaml.indexOf("name: planIds"))); yaml.indexOf("name: orderNo", yaml.indexOf("name: planIds")));
@@ -53,7 +53,7 @@ class SmartDispatchControllerTest {
@Test @Test
void preArrangeResolvesSingleCustomerAndAuthenticatedOperator() { void preArrangeResolvesSingleCustomerAndAuthenticatedOperator() {
DispatchRequests.PreArrange request = new DispatchRequests.PreArrange( DispatchRequests.PreArrange request = new DispatchRequests.PreArrange(
List.of(10L, 11L), "ONT8 -> LAX9", "APT-1", "SUCCESS", "PENDING"); List.of(10L, 11L), "APT-1", "SUCCESS", "PENDING");
when(service.resolveCustomerScopeForPallets(List.of(10L, 11L))).thenReturn("100"); when(service.resolveCustomerScopeForPallets(List.of(10L, 11L))).thenReturn("100");
controller.preArrange(platform(), request); controller.preArrange(platform(), request);
@@ -101,6 +101,7 @@ class DispatchServiceTest {
assertNotNull(DispatchService.class.getMethod("preArrange", String.class, String.class, assertNotNull(DispatchService.class.getMethod("preArrange", String.class, String.class,
DispatchRequests.PreArrange.class).getAnnotation(Transactional.class)); DispatchRequests.PreArrange.class).getAnnotation(Transactional.class));
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8"))); when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8")));
when(items.findByDispatchOrder(eq("100"), any())).thenReturn(List.of(item(11L, "ONT8")));
var result = service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, var result = service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null,
null, null, null)); null, null, null));
@@ -119,6 +120,22 @@ class DispatchServiceTest {
verify(items).insert(any(DispatchItem.class)); verify(items).insert(any(DispatchItem.class));
} }
@Test
void preArrangeBuildsCommaSeparatedDistinctFbaRouteFromAssignedPallets() {
when(candidates.lockSelected("100", List.of(10L, 11L, 12L))).thenReturn(List.of(
candidate(10L, null, "ONT8"), candidate(11L, null, "ABE4"), candidate(12L, null, "ONT8")));
when(items.findByDispatchOrder(eq("100"), any())).thenReturn(List.of(
item(21L, "ONT8"), item(22L, "ABE4"), item(23L, "ONT8")));
DispatchResponse result = service.preArrange("100", "7",
new DispatchRequests.PreArrange(List.of(10L, 11L, 12L), null, null, null));
assertEquals("ONT8,ABE4", result.route());
ArgumentCaptor<DispatchOrder> dispatch = ArgumentCaptor.forClass(DispatchOrder.class);
verify(orders).insert(dispatch.capture());
assertEquals("ONT8,ABE4", dispatch.getValue().getRoute());
}
@Test @Test
void movingPalletIntoDraftImmediatelyCreatesExpiringExclusiveLock() { void movingPalletIntoDraftImmediatelyCreatesExpiringExclusiveLock() {
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8"))); when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8")));
@@ -197,7 +214,7 @@ class DispatchServiceTest {
when(locks.lockActive("100", "PLAN:10")).thenReturn(lock); when(locks.lockActive("100", "PLAN:10")).thenReturn(lock);
when(drafts.lockActive("draft-1", "7")).thenReturn(draft); when(drafts.lockActive("draft-1", "7")).thenReturn(draft);
service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, null, null, null, "draft-1")); service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, null, null, "draft-1"));
assertNotNull(lock.getDispatchOrderId()); assertNotNull(lock.getDispatchOrderId());
assertEquals("draft-1", lock.getDraftToken()); assertEquals("draft-1", lock.getDraftToken());
@@ -326,13 +343,18 @@ class DispatchServiceTest {
dispatch.setId(1L); dispatch.setCustomerId("100"); dispatch.setDispatchNo("PC1"); dispatch.setId(1L); dispatch.setCustomerId("100"); dispatch.setDispatchNo("PC1");
dispatch.setStatus(DispatchService.PENDING_DEPARTURE); dispatch.setStatus(DispatchService.PENDING_DEPARTURE);
when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch); when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch);
DispatchRequests.Update request = new DispatchRequests.Update("ABC-1", "Driver", null, null, null, DispatchItem ont8 = item(11L, "ONT8");
DispatchItem abe4 = item(12L, "ABE4");
when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(ont8, abe4, item(13L, "ONT8")));
DispatchRequests.Update request = new DispatchRequests.Update("ABC-1", "Driver", null, null,
null, null, null, LocalDateTime.now()); null, null, null, LocalDateTime.now());
DispatchResponse result = service.update("PC1", request); DispatchResponse result = service.update("PC1", request);
assertEquals(DispatchService.PENDING_DEPARTURE, dispatch.getStatus()); assertEquals(DispatchService.PENDING_DEPARTURE, dispatch.getStatus());
assertEquals("ABC-1", result.vehiclePlate()); assertEquals("ABC-1", result.vehiclePlate());
assertEquals("ONT8,ABE4", result.route());
assertEquals("ONT8,ABE4", dispatch.getRoute());
verify(orders).updateById(dispatch); verify(orders).updateById(dispatch);
verify(items).findByDispatchOrder("100", 1L); verify(items).findByDispatchOrder("100", 1L);
} }
@@ -343,7 +365,7 @@ class DispatchServiceTest {
dispatch.setId(1L); dispatch.setCustomerId("100"); dispatch.setDispatchNo("PC1"); dispatch.setId(1L); dispatch.setCustomerId("100"); dispatch.setDispatchNo("PC1");
dispatch.setStatus("DEPARTED"); dispatch.setStatus("DEPARTED");
when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch); when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch);
DispatchRequests.Update request = new DispatchRequests.Update(null, null, null, null, null, DispatchRequests.Update request = new DispatchRequests.Update(null, null, null, null,
null, null, null, null); null, null, null, null);
ResponseStatusException exception = assertThrows(ResponseStatusException.class, ResponseStatusException exception = assertThrows(ResponseStatusException.class,
@@ -442,4 +464,11 @@ class DispatchServiceTest {
"LIP1-P001", destination, destination, destination, destination, packageCount, "LIP1-P001", destination, destination, destination, destination, packageCount,
new BigDecimal("1.2000"), warehousePalletId == null ? "PLAN" : "RECEIVED"); new BigDecimal("1.2000"), warehousePalletId == null ? "PLAN" : "RECEIVED");
} }
private DispatchItem item(long id, String fbaWarehouseCode) {
DispatchItem item = new DispatchItem();
item.setId(id);
item.setFbaWarehouseCode(fbaWarehouseCode);
return item;
}
} }
+2
View File
@@ -37,6 +37,8 @@ export async function run(context) {
## 可用上下文 ## 可用上下文
混柜收货的每一步均区分同键同载荷重放、同键异载荷冲突和同货物新键再次扫码:普通海外仓重复箱返回冲突,FBA 相同累计数和代发同托新键返回零增量;随后回查完整会话,确保三票事实均不变。
`mixed-container-receiving.mjs` 通过总入口 `--modules mixed-container-receiving` 执行,依赖客户资质准备。新建一票双托一件代发、一票普通 FBA、一票普通海外仓订单,固定同一起运仓和渠道,经正式接口混装同柜、完成国内装柜和船节点、按柜生成海外计划。使用本轮 PDA 身份交替执行 FBA 累计收货、代发整托收货、普通海外仓逐箱收货;每步核对全部票进度、同键重放和未齐正常完成拒绝。终点为 `MIXED_CONTAINER_INBOUND_COMPLETED`,不创建后续出库或派送,不代表仓储转运单混装。报告复用 `createdResources.dropshipPalletMatrix`;失败保留阻断,成功数据保留,finally 停用本轮 PDA 操作员。真实运行须环境授权,离线合同 `mixed-container-receiving-contract.test.mjs` 不能代替真实 HTTP。 `mixed-container-receiving.mjs` 通过总入口 `--modules mixed-container-receiving` 执行,依赖客户资质准备。新建一票双托一件代发、一票普通 FBA、一票普通海外仓订单,固定同一起运仓和渠道,经正式接口混装同柜、完成国内装柜和船节点、按柜生成海外计划。使用本轮 PDA 身份交替执行 FBA 累计收货、代发整托收货、普通海外仓逐箱收货;每步核对全部票进度、同键重放和未齐正常完成拒绝。终点为 `MIXED_CONTAINER_INBOUND_COMPLETED`,不创建后续出库或派送,不代表仓储转运单混装。报告复用 `createdResources.dropshipPalletMatrix`;失败保留阻断,成功数据保留,finally 停用本轮 PDA 操作员。真实运行须环境授权,离线合同 `mixed-container-receiving-contract.test.mjs` 不能代替真实 HTTP。
一件代发矩阵出库前运行 `dropship-outbound-edge-cases.mjs` 的候选查询探针:先模拟平台首次打开页面只传海外仓,再传客户筛选,要求本轮每票全部托盘、箱数、仓位及 SKU 事实一致,并核对仓库与指定客户隔离。缺托、错误客户/仓库、A0400 或筛选后仓位漂移均阻断;候选达到服务端上限导致本轮数据不可见也不能按空场景通过。该探针不证明跨客户混柜造数已覆盖。 一件代发矩阵出库前运行 `dropship-outbound-edge-cases.mjs` 的候选查询探针:先模拟平台首次打开页面只传海外仓,再传客户筛选,要求本轮每票全部托盘、箱数、仓位及 SKU 事实一致,并核对仓库与指定客户隔离。缺托、错误客户/仓库、A0400 或筛选后仓位漂移均阻断;候选达到服务端上限导致本轮数据不可见也不能按空场景通过。该探针不证明跨客户混柜造数已覆盖。
@@ -334,16 +334,19 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
return data return data
} }
let summary let summary
let summaryResponse
for (let attempt = 0; attempt < 60; attempt++) { for (let attempt = 0; attempt < 60; attempt++) {
const response = await context.requestOverseas('GET', `${base}/orders/container-summaries/${bookingId}`) const response = await context.requestOverseas('GET', `${base}/orders/container-summaries/${bookingId}`)
summaryResponse = response
summary = response?.body?.code === '200' ? response.body.data : null summary = response?.body?.code === '200' ? response.body.data : null
if (summary?.orders?.length === group.length) break if (summary?.orders?.length === group.length) break
if (attempt < 59) await new Promise((resolve) => setTimeout(resolve, 1000)) if (attempt < 59) await new Promise((resolve) => setTimeout(resolve, 1000))
} }
check(`${label}跨区事件逐票建单完整`, null, check(`${label}跨区事件逐票建单完整`, summaryResponse,
summary?.orders?.length === group.length && group.every((record) => summary?.orders?.length === group.length && group.every((record) =>
summary.orders.some((order) => order.inboundCode === record.source.inboundCode summary.orders.some((order) => order.inboundCode === record.source.inboundCode
&& String(order.customerId) === String(record.order.tenantId)))) && String(order.customerId) === String(record.order.tenantId))),
`containerNo=${loading.containerNo}, expectedOrders=${group.length}, actualOrders=${summary?.orders?.length ?? 'unavailable'}`)
const preview = await call('入库计划预览', 'GET', `${base}/inbound-plans/container-preview?sourceContainerBookingId=${bookingId}`) const preview = await call('入库计划预览', 'GET', `${base}/inbound-plans/container-preview?sourceContainerBookingId=${bookingId}`)
check(`${label}全柜入库计划就绪`, null, preview.ready === true && Number(preview.totalOrderCount) === group.length) check(`${label}全柜入库计划就绪`, null, preview.ready === true && Number(preview.totalOrderCount) === group.length)
const body = { sourceContainerBookingId: bookingId, snapshotVersion: Number(preview.snapshotVersion), const body = { sourceContainerBookingId: bookingId, snapshotVersion: Number(preview.snapshotVersion),
@@ -37,7 +37,8 @@ test('schedule interleaves three modes and uses FBA cumulative count rather than
assert.throws(() => mixedReceivingSchedule(fixtures().slice(0, 2))) assert.throws(() => mixedReceivingSchedule(fixtures().slice(0, 2)))
}) })
function driver({ earlyComplete = false, wrongModeAccepted = false, drift = false } = {}) { function driver({ earlyComplete = false, wrongModeAccepted = false, drift = false, repeatIncrement = false,
changedPayloadAccepted = false } = {}) {
const group = fixtures() const group = fixtures()
const expected = new Map(group.map(r => [r.source.inboundCode, 0])) const expected = new Map(group.map(r => [r.source.inboundCode, 0]))
const schedule = mixedReceivingSchedule(group) const schedule = mixedReceivingSchedule(group)
@@ -50,7 +51,14 @@ function driver({ earlyComplete = false, wrongModeAccepted = false, drift = fals
inbounds: [...expected].map(([inboundCode, receivedPackages]) => ({ inboundCode, totalPackages: 4, inbounds: [...expected].map(([inboundCode, receivedPackages]) => ({ inboundCode, totalPackages: 4,
receivedPackages, pendingPackages: 4 - receivedPackages })) }) receivedPackages, pendingPackages: 4 - receivedPackages })) })
return { group, marker: 'MIXED-TEST', check: (name, _response, passed) => assert.ok(passed, name), return { group, marker: 'MIXED-TEST', check: (name, _response, passed) => assert.ok(passed, name),
request: async (_method, path) => ({ body: { code: (path.endsWith('/complete') ? earlyComplete : wrongModeAccepted) ? '200' : 'A0409' } }), request: async (_method, path, body) => {
if (body.idempotencyKey.endsWith('-duplicate')) {
return { body: path.endsWith('/carton') ? { code: 'A0409' }
: { code: '200', data: { result: 'ALREADY_RECEIVED', acceptedPackages: repeatIncrement ? 1 : 0 } } }
}
if (seen.has(body.idempotencyKey)) return { body: { code: changedPayloadAccepted ? '200' : 'A0409' } }
return { body: { code: (path.endsWith('/complete') ? earlyComplete : wrongModeAccepted) ? '200' : 'A0409' } }
},
call: async (_name, method, path, body) => { call: async (_name, method, path, body) => {
if (method === 'GET') return snapshot() if (method === 'GET') return snapshot()
if (seen.has(body.idempotencyKey)) return structuredClone(seen.get(body.idempotencyKey)) if (seen.has(body.idempotencyKey)) return structuredClone(seen.get(body.idempotencyKey))
@@ -82,3 +90,9 @@ test('mixed receiving fails if overseas or dropship can bypass pallet/carton rec
test('mixed receiving detects correct aggregate count allocated to the wrong inbound', async () => { test('mixed receiving detects correct aggregate count allocated to the wrong inbound', async () => {
await assert.rejects(receiveMixedContainer(driver({ drift: true }))) await assert.rejects(receiveMixedContainer(driver({ drift: true })))
}) })
test('mixed receiving rejects a new-key rescan that accepts another box', async () => {
await assert.rejects(receiveMixedContainer(driver({ repeatIncrement: true })))
})
test('mixed receiving rejects same-key different-payload success', async () => {
await assert.rejects(receiveMixedContainer(driver({ changedPayloadAccepted: true })))
})
@@ -58,6 +58,18 @@ export async function receiveMixedContainer({ group, call, check, request, marke
expected.find(row => row.inboundCode === step.record.source.inboundCode).received += step.count expected.find(row => row.inboundCode === step.record.source.inboundCode).received += step.count
const after = await call('混柜每步后逐票回查', 'GET', base) const after = await call('混柜每步后逐票回查', 'GET', base)
check('混柜交替收货不串票不重计', null, receivingProgressMatches(after, expected)) check('混柜交替收货不串票不重计', null, receivingProgressMatches(after, expected))
const changedBody = step.path === 'pallet' ? { ...body, sourcePalletNo: `${body.sourcePalletNo}-CHANGED` }
: step.path === 'bulk' ? { ...body, packageCount: body.packageCount + 1 }
: { ...body, palletCode: `${body.palletCode}-CHANGED` }
const changed = await request('POST', path, changedBody)
check('混柜收货同键异载荷必须冲突', changed, changed.body?.code === 'A0409')
const duplicate = await request('POST', path, { ...body, idempotencyKey: `${body.idempotencyKey}-duplicate` })
check('混柜同货物新键重复操作不重复收货', duplicate, step.path === 'carton'
? duplicate.body?.code === 'A0409'
: duplicate.body?.code === '200' && duplicate.body?.data?.result === 'ALREADY_RECEIVED'
&& Number(duplicate.body?.data?.acceptedPackages) === 0)
const afterRetries = await call('混柜重复和冲突请求后回查全部票', 'GET', base)
check('混柜冲突及新键重复后全柜事实不变', null, JSON.stringify(afterRetries) === JSON.stringify(after))
} }
const body = { allowDifference: false, idempotencyKey: `${marker}-mixed-complete` } const body = { allowDifference: false, idempotencyKey: `${marker}-mixed-complete` }
const completed = await call('混柜全部核平后正常完成入库', 'POST', `${base}/complete`, body) const completed = await call('混柜全部核平后正常完成入库', 'POST', `${base}/complete`, body)
@@ -64,8 +64,10 @@ test('current loading event binds each pallet carton group to an explicit source
const producer = fs.readFileSync(path.join(here, '../../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java'), 'utf8') const producer = fs.readFileSync(path.join(here, '../../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java'), 'utf8')
assert.match(model, /record SkuPackageSnapshot\(String sourceInboundDetailId, String skuCode/) assert.match(model, /record SkuPackageSnapshot\(String sourceInboundDetailId, String skuCode/)
assert.match(model, /sourceInboundDetailId == null \|\| sourceInboundDetailId\.isBlank\(\)/) assert.match(model, /sourceInboundDetailId == null \|\| sourceInboundDetailId\.isBlank\(\)/)
assert.match(producer, /SkuPackageSnapshot\(detail\.detailId\(\)\.toString\(\), detail\.skuCode\(\)/)
assert.match(producer, /SkuPackageSnapshot\(detail\.getSourceInboundDetailId\(\)\.toString\(\), detail\.getSkuCode\(\)/) assert.match(producer, /SkuPackageSnapshot\(detail\.getSourceInboundDetailId\(\)\.toString\(\), detail\.getSkuCode\(\)/)
assert.match(producer, /groupingBy\(PalletRow::getSourceInboundDetailId/)
assert.match(producer, /map\(PalletRow::getActualSkuQuantity\)/)
assert.match(producer, /cartonQuantities\(detailRows\.getValue\(\), marks\)/)
const measurement = fs.readFileSync(path.join(here, 'inbound-measurement.mjs'), 'utf8') const measurement = fs.readFileSync(path.join(here, 'inbound-measurement.mjs'), 'utf8')
const loading = fs.readFileSync(path.join(here, 'phase3-loading.mjs'), 'utf8') const loading = fs.readFileSync(path.join(here, 'phase3-loading.mjs'), 'utf8')
const finalize = fs.readFileSync(path.join(here, 'phase3-loading-finalize.mjs'), 'utf8') const finalize = fs.readFileSync(path.join(here, 'phase3-loading-finalize.mjs'), 'utf8')
@@ -155,6 +155,18 @@ test('domestic event bridge SQL converges legacy tenant columns to the customer
assert.match(incremental, /bridge_outbox_customer_column = 0 AND @bridge_outbox_tenant_column = 1/) assert.match(incremental, /bridge_outbox_customer_column = 0 AND @bridge_outbox_tenant_column = 1/)
}) })
test('both overseas PDF consumers explicitly connect to the existing CN Print service', async () => {
const compose = await read('deploy/docker-compose.overseas.yml')
for (const service of ['lastleg-service', 'warehouse-overseas-service']) {
const block = compose.match(new RegExp(`^ ${service}:\\n([\\s\\S]*?)(?=^ [a-z][\\w-]*:|^networks:)`, 'm'))?.[1]
assert.ok(block, `${service} must have a deployment definition`)
assert.match(block, /PRINT_SERVICE_URL: \$\{PRINT_SERVICE_URL:-http:\/\/print:9207\}/,
`${service} cannot discover the CN Print service from its overseas Nacos namespace`)
const config = await read(`${service}/src/main/resources/application.yml`)
assert.match(config, /print:\s*\n\s+url: \$\{PRINT_SERVICE_URL:\}/)
}
})
test('domestic unified SQL creates the overseas hold version before queuing its comment', async () => { test('domestic unified SQL creates the overseas hold version before queuing its comment', async () => {
const incremental = await read('deploy/sql/头程数据库统一增量-MySQL8.sql') const incremental = await read('deploy/sql/头程数据库统一增量-MySQL8.sql')
const addColumn = incremental.indexOf('CALL add_inbound_overseas_hold_version();') const addColumn = incremental.indexOf('CALL add_inbound_overseas_hold_version();')
+1 -1
View File
@@ -17,7 +17,7 @@
| 托盘解析与业务绑定 | `controller/InternalWarehousePalletController.java`、`dto/WarehousePalletResolveResponse.java`、`entity/WarehousePalletBindingEntity.java` | | 托盘解析与业务绑定 | `controller/InternalWarehousePalletController.java`、`dto/WarehousePalletResolveResponse.java`、`entity/WarehousePalletBindingEntity.java` |
| 柜级入库计划批量托盘 | `controller/InternalWarehousePalletBatchController.java`、`service/WarehousePalletBatchService.java`、`warehouse-pallet-batch-internal-v1.yaml`;以稳定批次键幂等执行 `ensure-reserved → confirm`,权威托盘码已存在且资源可预留时必须复用,未提供或不存在时才创建;Lastleg 本地计划事务失败时仅补偿仍为 `RESERVED` 的未确认资源,已 `CONFIRMED` 批次不得回退,同柜重放不得产生第二批资源 | | 柜级入库计划批量托盘 | `controller/InternalWarehousePalletBatchController.java`、`service/WarehousePalletBatchService.java`、`warehouse-pallet-batch-internal-v1.yaml`;以稳定批次键幂等执行 `ensure-reserved → confirm`,权威托盘码已存在且资源可预留时必须复用,未提供或不存在时才创建;Lastleg 本地计划事务失败时仅补偿仍为 `RESERVED` 的未确认资源,已 `CONFIRMED` 批次不得回退,同柜重放不得产生第二批资源 |
| 仓储资源服务 | `service/WarehouseResourceService.java`、`WarehouseResourceLockService.java` | | 仓储资源服务 | `service/WarehouseResourceService.java`、`WarehouseResourceLockService.java` |
| 单张托盘标签 PDF | `controller/PlatformWarehouseResourceController.java` 的 `/pallets/{id}/label`、`service/PalletLabelPrintService.java`、`client/PrintPdfClient.java`;契约 `src/main/resources/openapi/pallet-label-v1.yaml`。使用公共托盘真实编码并复用 Print `INBOUND_MARK_V1`,模板仍只在 Print 维护;沿用托盘列表权限与海外 Gateway 的 `warehouse-admin/**` 路由;`baiyue.clients.print.url` 对应 `PRINT_SERVICE_URL`,未配置时使用服务发现 | | 单张托盘标签 PDF | `controller/PlatformWarehouseResourceController.java` 的 `/pallets/{id}/label`、`service/PalletLabelPrintService.java`、`client/PrintPdfClient.java`;契约 `src/main/resources/openapi/pallet-label-v1.yaml`。使用公共托盘真实编码并复用 Print `INBOUND_MARK_V1`,模板仍只在 Print 维护;沿用托盘列表权限与海外 Gateway 的 `warehouse-admin/**` 路由。`baiyue.clients.print.url` 对应 `PRINT_SERVICE_URL`,海外 Compose 必须与 BOL 一样显式注入国内 Print 地址,不能依赖海外 Nacos 发现国内服务;Feign 及熔断包装异常统一返回 `S0503` |
| 内部服务令牌 | `security/InternalServiceTokenGuard.java` | | 内部服务令牌 | `security/InternalServiceTokenGuard.java` |
| 数据库基线与中文注释契约 | `src/main/resources/db/warehouse.sql`、`src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java` | | 数据库基线与中文注释契约 | `src/main/resources/db/warehouse.sql`、`src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java` |
| 测试 | `src/test/java/com/baiyue/warehouse/` | | 测试 | `src/test/java/com/baiyue/warehouse/` |
@@ -9,6 +9,7 @@ import java.math.BigDecimal;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.List; import java.util.List;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException;
import org.springframework.http.ContentDisposition; import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
@@ -49,7 +50,7 @@ public class PalletLabelPrintService {
.header(HttpHeaders.CONTENT_DISPOSITION, .header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename(fileName, StandardCharsets.UTF_8).build().toString()) ContentDisposition.attachment().filename(fileName, StandardCharsets.UTF_8).build().toString())
.header(HttpHeaders.CACHE_CONTROL, "no-store").body(body); .header(HttpHeaders.CACHE_CONTROL, "no-store").body(body);
} catch (FeignException exception) { } catch (FeignException | NoFallbackAvailableException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务暂不可用,请稍后重试"); throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务暂不可用,请稍后重试");
} }
} }
@@ -8,6 +8,7 @@ import com.baiyue.warehouse.entity.WarehousePalletEntity;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
@@ -66,6 +67,19 @@ class PalletLabelPrintServiceTest {
verifyNoInteractions(print); verifyNoInteractions(print);
} }
@Test
void mapsCircuitBreakerWrappedPrintFailureToServiceUnavailable() {
when(print.generateInboundMarks(anyString(), anyString(), any()))
.thenThrow(new NoFallbackAvailableException("No fallback available.",
new IllegalStateException("No instances available for print-service")));
assertThatThrownBy(() -> service.print(200L, 9L, "request-1"))
.isInstanceOfSatisfying(BizException.class, exception -> {
assertThat(exception.getResultCode()).isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
assertThat(exception.getMessage()).contains("打印服务暂不可用");
});
}
@Test @Test
void rejectsJsonErrorInsteadOfDownloadingItAsPdf() { void rejectsJsonErrorInsteadOfDownloadingItAsPdf() {
when(print.generateInboundMarks(anyString(), anyString(), any())) when(print.generateInboundMarks(anyString(), anyString(), any()))