diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V29__allow_unbound_transfer_forecast_cartons.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V29__allow_unbound_transfer_forecast_cartons.sql new file mode 100644 index 000000000..5f26df5f5 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V29__allow_unbound_transfer_forecast_cartons.sql @@ -0,0 +1,2 @@ +-- 对应数据库:baiyue_lastleg;可重复执行,不修改原箱号、SKU预报件数和实测数据。 +ALTER TABLE ll_warehouse_transfer_carton MODIFY COLUMN transfer_item_id BIGINT NULL COMMENT '实际箱内首项SKU明细ID,预报补箱可为空'; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java index 419d2e3f4..fdc6b2fcc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java @@ -74,6 +74,15 @@ public class MerchantRelabelApplicationController { if ("ITEM".equalsIgnoreCase(scope)) return ApiResponse.success(service.uploadItemFile(customer(user), relabelNo, itemId == null ? 0L : itemId, operatorLong(user), file)); return ApiResponse.success(service.uploadTotalFile(customer(user), relabelNo, operatorLong(user), file, kind)); } + /** 按登录客户及申请绑定关系读取贴标资料,禁止跨客户或跨明细下载。 */ + @GetMapping("/{relabelNo}/files/{fileId}/download") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')") + public ResponseEntity downloadFile(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @PathVariable long fileId, @RequestParam String scope, + @RequestParam(required = false) Long itemId) { + var file = service.downloadFile(customer(user), relabelNo, scope, itemId, fileId); + return download(file.content(), file.fileName(), file.contentType()); + } @GetMapping("/{relabelNo}/shipping-label/{fileId}/download") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')") public ResponseEntity downloadShippingLabel(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java index c3cab46c9..9fa946db2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java @@ -134,6 +134,17 @@ public class RelabelApplicationController { return ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore()).header("Content-Disposition", ContentDisposition.inline().filename(file.fileName(), StandardCharsets.UTF_8).build().toString()) .contentType(org.springframework.http.MediaType.parseMediaType(file.contentType())).contentLength(file.content().length).body(file.content()); } + /** 平台按申请绑定关系读取客户上传的面单。 */ + @GetMapping("/{relabelNo}/shipping-label/{fileId}/download") + @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')") + public ResponseEntity downloadShippingLabel(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @PathVariable long fileId) { + requirePlatform(user); + var file = service.downloadShippingLabel(service.customerId(relabelNo), relabelNo, fileId); + return ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore()) + .header("Content-Disposition", ContentDisposition.inline().filename(file.fileName(), StandardCharsets.UTF_8).build().toString()) + .contentType(org.springframework.http.MediaType.parseMediaType(file.contentType())).contentLength(file.content().length).body(file.content()); + } @GetMapping("/{relabelNo}/completion-photos/{fileId}/download") @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')") public ResponseEntity downloadCompletionPhoto(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java index 8d3c9f135..879e5e7d9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java @@ -20,6 +20,7 @@ import org.springframework.web.server.ResponseStatusException; public class WarehouseTransferController { public static final String MANAGE_PERMISSION = "lastleg:warehouse-transfer:manage"; @Resource private WarehouseTransferService service; + @Resource private com.baiyue.lastleg.transfer.service.WarehouseTransferForecastService forecasts; @PostMapping public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @@ -41,6 +42,17 @@ public class WarehouseTransferController { return ApiResponse.success(service.detail(scope(user, customerId), transferNo)); } + /** 平台人员在测量完成前追加整单预报箱,沿用统一锁单及箱唛生成规则。 */ + @PostMapping("/{transferNo}/increase-forecast-cartons") + @PreAuthorize("hasAuthority('" + OverseasPermissions.WAREHOUSE_TRANSFER_APPROVE + "')") + public ApiResponse increaseForecastCartons(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String customerId, @PathVariable String transferNo, + @Valid @RequestBody WarehouseTransferRequests.IncreaseForecastCartons request) { + if (user == null || !"PLATFORM".equals(user.appCode()) || user.tenantId() == null || user.tenantId() != 0L) + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform identity is required"); + return ApiResponse.success(forecasts.increase(scope(user, customerId), transferNo, operator(user), request)); + } + @GetMapping("/{transferNo}/carton-label") @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") public ApiResponse cartonLabel(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java index e9580c0da..79ac571a3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java @@ -105,11 +105,9 @@ public final class WarehouseTransferRequests { public record Outbound(@NotBlank @Size(max = 255) String idempotencyKey, @Size(max = 1000) String remark) {} - /** 仅增加既有SKU的预报箱数,携带原箱数用于并发核对及重试去重。 */ + /** 仅增加整单预报箱数,箱内SKU由PDA清点确定,携带原箱数用于并发核对及重试去重。 */ public record IncreaseForecastCartons( @Positive @Max(5000) int expectedPackageCount, - @NotNull Long transferItemId, - @Min(0) @Max(5000) int expectedItemPackageCount, @Positive @Max(5000) int packageCount ) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferForecastRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferForecastRepository.java index 0698295b2..83e29a1f8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferForecastRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferForecastRepository.java @@ -6,7 +6,6 @@ import com.baiyue.lastleg.transfer.entity.*; import com.baiyue.lastleg.transfer.mapper.*; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegInboundOrder; -import com.baiyue.lastleg.entity.LastlegOrderDetail; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import com.baiyue.lastleg.mapper.*; import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; @@ -20,11 +19,11 @@ import java.util.List; @Repository public class TransferForecastRepository { @Resource private WarehouseTransferOrderMapper transfers; - @Resource private WarehouseTransferItemMapper items; + @Resource private WarehouseTransferCartonMapper cartons; @Resource private LastlegOrderMapper orders; @Resource private LastlegInboundOrderMapper inboundOrders; - @Resource private LastlegOrderDetailMapper orderDetails; + @Resource private LastlegOrderStatusHistoryMapper histories; @Resource private InboundPlanLineMapper planLines; @@ -33,26 +32,19 @@ public class TransferForecastRepository { return transfers.lockNo(customerId, transferNo); } - /** 按创建顺序读取预报明细,保持关联尾程明细的序号稳定。 */ - public List items(String customerId, Long transferId) { - return items.selectList(new LambdaQueryWrapper() - .eq(WarehouseTransferItem::getCustomerId, customerId) - .eq(WarehouseTransferItem::getTransferOrderId, transferId).orderByAsc(WarehouseTransferItem::getId)); - } - /** 读取原箱号用于确定新增编号,不删除或重建原箱。 */ public List cartons(String customerId, Long transferId) { return cartons.listOrder(customerId, transferId); } /** 追加预报箱并同步各预报口径;清点数据保留,整单清点确认失效。 */ - public void append(WarehouseTransferOrder transfer, WarehouseTransferItem item, int itemIndex, + public void append(WarehouseTransferOrder transfer, List added, LocalDateTime now, String operatorId) { String customerId = transfer.getCustomerId(); var order = orders.selectOne(new LambdaQueryWrapper() .eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getId, transfer.getLastlegOrderId())); if (order == null) throw new IllegalStateException("转运关联尾程订单不存在"); - items.updateById(item); + transfers.updateById(transfer); for (var carton : added) cartons.insert(carton); cartons.update(null, new LambdaUpdateWrapper() @@ -65,18 +57,13 @@ public class TransferForecastRepository { inboundOrders.update(null, new LambdaUpdateWrapper() .eq(LastlegInboundOrder::getCustomerId, customerId).eq(LastlegInboundOrder::getId, transfer.getInboundOrderId()) .set(LastlegInboundOrder::getPackageCount, transfer.getPackageCount()).set(LastlegInboundOrder::getUpdatedAt, now)); - int updated = orderDetails.update(null, new LambdaUpdateWrapper() - .eq(LastlegOrderDetail::getCustomerId, customerId).eq(LastlegOrderDetail::getOrderId, transfer.getLastlegOrderId()) - .eq(LastlegOrderDetail::getSourceInboundDetailId, transfer.getTransferNo() + ":" + itemIndex) - .set(LastlegOrderDetail::getPackageCount, item.getPackageCount())); - if (updated != 1) throw new IllegalStateException("转运关联尾程货物明细不完整"); planLines.update(null, new LambdaUpdateWrapper() .eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getOrderId, transfer.getLastlegOrderId()) .set(InboundPlanLine::getRequiredPackageCount, transfer.getPackageCount())); var history = new LastlegOrderStatusHistory(); history.setCustomerId(customerId); history.setOrderId(transfer.getLastlegOrderId()); history.setFromStatus(order.getCurrentStatus()); history.setToStatus(order.getCurrentStatus()); history.setEventId(transfer.getTransferNo()); history.setCreatedAt(now); - history.setRemark("客户增加预报箱数,补充箱唛" + added.size() + "个;操作人:" + operatorId); + history.setRemark("增加预报箱数,补充箱唛" + added.size() + "个;操作人:" + operatorId); histories.insert(history); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferForecastService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferForecastService.java index 710fa717a..40996d75f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferForecastService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferForecastService.java @@ -29,17 +29,13 @@ public class WarehouseTransferForecastService { if (transfer == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "转运单不存在"); if (!transferNo.equals(transfer.getInboundCode()) || !Set.of("PENDING_REVIEW", "WAIT_PDA_RECEIVING").contains(transfer.getStatus())) throw new ResponseStatusException(HttpStatus.CONFLICT, "仅测量完成前的外来转运单可增加预报箱数"); - var items = repository.items(customerId, transfer.getId()); - var item = items.stream().filter(row -> row.getId().equals(request.transferItemId())).findFirst() - .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "SKU不属于当前转运单")); - int addedCount = request.packageCount() - request.expectedItemPackageCount(); - long total = (long) request.expectedPackageCount() + addedCount; + int addedCount = request.packageCount() - request.expectedPackageCount(); + int total = request.packageCount(); if (addedCount <= 0 || total > 5000 || request.expectedPackageCount() <= 0) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "只能增加预报箱数,整单最多5000箱"); - // 相同目标值视为已完成的重试;不同原快照则要求刷新,避免多端覆盖。 - if (item.getPackageCount() == request.packageCount() && transfer.getPackageCount() == total) - return transfers.detail(customerId, transferNo); - if (transfer.getPackageCount() != request.expectedPackageCount() || item.getPackageCount() != request.expectedItemPackageCount()) + // 相同目标值重试不再生成箱唛;不同原快照必须刷新,防止并发覆盖。 + if (transfer.getPackageCount() == total) return transfers.detail(customerId, transferNo); + if (transfer.getPackageCount() != request.expectedPackageCount()) throw new ResponseStatusException(HttpStatus.CONFLICT, "预报箱数已变化,请刷新后重试"); var existing = repository.cartons(customerId, transfer.getId()); int index = existing.stream().mapToInt(WarehouseTransferCarton::getCartonIndex).max().orElse(0); @@ -47,13 +43,14 @@ public class WarehouseTransferForecastService { var added = new ArrayList(); for (int i = 0; i < addedCount; i++) { var carton = new WarehouseTransferCarton(); carton.setId(IdWorker.getId()); carton.setCustomerId(customerId); - carton.setTransferOrderId(transfer.getId()); carton.setTransferItemId(item.getId()); + carton.setTransferOrderId(transfer.getId()); + // 预报箱仅代表一个箱唛,不预先绑定SKU,实收箱内明细由PDA记录。 carton.setCartonMark(LastlegOrderNoGenerator.compactCartonMark(carton.getId())); carton.setCartonIndex(++index); carton.setTotalCartons((int) total); carton.setStatus("EXPECTED"); carton.setCreatedAt(now); carton.setUpdatedAt(now); added.add(carton); } - item.setPackageCount(request.packageCount()); transfer.setPackageCount((int) total); transfer.setUpdatedAt(now); - repository.append(transfer, item, items.indexOf(item), added, now, operatorId); + transfer.setPackageCount(total); transfer.setUpdatedAt(now); + repository.append(transfer, added, now, operatorId); return transfers.detail(customerId, transferNo); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java index efa26edfd..6244bb1ba 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -410,9 +410,13 @@ public class WarehouseTransferService { inboundOrders.updateById(inbound); } + // 草稿可能已打印箱唛,编辑时保留已有序号对应的编号,只追加或移除尾部箱子。 + Map originalCartons = transferCartons.listOrder(customerId, transfer.getId()) + .stream().collect(Collectors.toMap(WarehouseTransferCarton::getCartonIndex, Function.identity())); transferCartons.delete(new LambdaQueryWrapper() .eq(WarehouseTransferCarton::getCustomerId, customerId) - .eq(WarehouseTransferCarton::getTransferOrderId, transfer.getId())); + .eq(WarehouseTransferCarton::getTransferOrderId, transfer.getId()) + .gt(WarehouseTransferCarton::getCartonIndex, request.packageCount())); transferItems.delete(new LambdaQueryWrapper() .eq(WarehouseTransferItem::getCustomerId, customerId) .eq(WarehouseTransferItem::getTransferOrderId, transfer.getId())); @@ -431,8 +435,16 @@ public class WarehouseTransferService { List customerMarks = splitMarks(source.shippingMark()); for (int itemCarton = 0; itemCarton < source.packageCount(); itemCarton++) { cartonIndex++; - insertTransferCarton(customerId, transfer.getId(), itemId, transferNo, cartonIndex, - request.packageCount(), itemCarton < customerMarks.size() ? customerMarks.get(itemCarton) : null, now); + var existingCarton = originalCartons.get(cartonIndex); + String customerMark = itemCarton < customerMarks.size() ? customerMarks.get(itemCarton) : null; + if (existingCarton == null) { + insertTransferCarton(customerId, transfer.getId(), itemId, transferNo, cartonIndex, + request.packageCount(), customerMark, now); + } else { + existingCarton.setTransferItemId(itemId); existingCarton.setTotalCartons(request.packageCount()); + existingCarton.setCustomerCartonMark(customerMark); existingCarton.setUpdatedAt(now); + transferCartons.updateById(existingCarton); + } } insertOrderDetail(customerId, order.getId(), transferNo, index, source, request, now); } @@ -698,12 +710,11 @@ public class WarehouseTransferService { return cartonLabel(transfer.getCustomerId(), transferNo); } - /** 提交预报后打印整单固定箱唛,测量及未收状态不改变箱号集合。 */ + /** 保存草稿后即可打印整单固定箱唛,测量及未收状态不改变箱号集合。 */ @Transactional(readOnly = true) public WarehouseTransferResponses.Label cartonLabel(String customerId, String transferNo) { WarehouseTransferOrder transfer = transfers.findNo(customerId, transferNo); if (transfer == null) throw notFound("Warehouse transfer order not found"); - if (DRAFT.equals(transfer.getStatus())) throw conflict("Submit the warehouse transfer order before printing carton labels"); List cartons = transferCartons.listOrder(customerId, transfer.getId()).stream() .map(value -> new WarehouseTransferResponses.Carton(value.getCartonMark(), value.getCustomerCartonMark(), value.getCartonIndex(), value.getTotalCartons(), value.getStatus(), value.getTransferItemId(), value.getTransferPalletId(), value.getLengthCm(), value.getWidthCm(), value.getHeightCm(), value.getGrossWeightKg(), value.getCountedQuantity(), value.getDiscrepancyReason(), value.getCountedBy(), value.getCountedAt(), cartonContents(value))).toList(); StringBuilder labels = new StringBuilder(); @@ -930,8 +941,6 @@ public class WarehouseTransferService { var carton = byMark.get(input.cartonMark()); if (carton == null || !seen.add(input.cartonMark())) throw badRequest("箱号不属于本单或重复填写"); - var item = byItem.get(carton.getTransferItemId()); - if (item == null) throw badRequest("箱子申报明细不属于本单"); if (input.items() == null || input.items().isEmpty() || input.items().size() > 200) throw badRequest("每箱必须填写 1 至 200 行 SKU"); Set skuCodes = new java.util.HashSet<>(); @@ -941,7 +950,7 @@ public class WarehouseTransferService { || !skuCodes.add(line.skuCode())) throw badRequest("箱内 SKU 必须来自本单预报,同箱不能重复"); if (line.quantity() <= 0 || line.quantity() > Integer.MAX_VALUE) throw badRequest("每行 SKU 必须填写有效实点件数"); } - if (!skuCodes.contains(item.getSkuCode())) throw badRequest("请保留箱子原申报 SKU,可添加其他本单 SKU"); + // 箱内实际SKU以PDA清点为准,允许混箱或调整,仍须属于当前单预报。 if (input.length() == null || input.width() == null || input.height() == null || input.weight() == null || input.length().signum() <= 0 || input.width().signum() <= 0 || input.height().signum() <= 0 || input.weight().signum() <= 0) throw badRequest("每箱必须填写有效尺寸、重量和实点件数"); @@ -963,6 +972,7 @@ public class WarehouseTransferService { var declared = byItem.values().stream().filter(value -> value.getSkuCode().equals(line.skuCode())).findFirst().orElseThrow(); return new WarehouseTransferResponses.CartonItem(declared.getId(), line.skuCode(), line.quantity()); }).toList(); + carton.setTransferItemId(contents.get(0).transferItemId()); try { carton.setSkuItemsJson(objectMapper.writeValueAsString(contents)); } catch (com.fasterxml.jackson.core.JsonProcessingException e) { throw new IllegalStateException("Cannot serialize carton contents", e); } carton.setCountedBy(operatorId); carton.setCountedAt(now); carton.setStatus("COUNTED"); diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 9685b16a9..0767e4e17 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -2282,7 +2282,7 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_carton ( transfer_pallet_id BIGINT NULL COMMENT '签收箱所属转运托盘事实ID', sku_items_json JSON NULL COMMENT '实收箱内SKU及逐项件数明细', id BIGINT NOT NULL COMMENT '预报箱主键', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', - transfer_item_id BIGINT NOT NULL COMMENT '转运申报明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + transfer_item_id BIGINT NULL COMMENT '实际箱内首项SKU明细ID,预报补箱可为空', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', customer_carton_mark VARCHAR(255) NULL COMMENT '客户原箱唛,用于换标现场绑定', carton_mark VARCHAR(255) NOT NULL COMMENT '系统生成的一箱一码箱唛', carton_index INT NOT NULL COMMENT '全单箱序号', total_cartons INT NOT NULL COMMENT '首次生成时全单总箱数', status VARCHAR(32) NOT NULL DEFAULT 'EXPECTED' COMMENT '箱状态:EXPECTED待实收', 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 740f02964..4fca6c337 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -7,7 +7,7 @@ paths: /api/merchant/v1/last-leg/warehouse-transfers/{transferNo}/increase-forecast-cartons: post: summary: 测量完成前增加预报箱数并补充箱唛 - description: 客户身份及transfer:create权限;仅外来货物待审核或待PDA测量可用,按原预报快照追加既有SKU的箱数,原箱唛与件数不变。补箱及PDA完工锁定同一订单;成功后PDA刷新继续清点,无新增表字段。 + description: 客户身份及transfer:create权限;仅外来货物待审核或待PDA测量可用,按原预报快照追加整单箱数,不绑定SKU,实际箱内SKU由PDA清点确定,原箱唛与件数不变。补箱及PDA完工锁定同一订单;成功后PDA刷新继续清点。 parameters: - {name: transferNo, in: path, required: true, schema: {type: string}} requestBody: @@ -16,14 +16,33 @@ paths: application/json: schema: type: object - required: [expectedPackageCount, transferItemId, expectedItemPackageCount, packageCount] + required: [expectedPackageCount, packageCount] properties: expectedPackageCount: {type: integer, minimum: 1, maximum: 5000, description: 修改前整单预报箱数} - transferItemId: {type: integer, format: int64, description: 当前单既有SKU明细} - expectedItemPackageCount: {type: integer, minimum: 0, maximum: 5000, description: 修改前该SKU预报箱数} - packageCount: {type: integer, minimum: 1, maximum: 5000, description: 增加后该SKU预报箱数} + packageCount: {type: integer, minimum: 1, maximum: 5000, description: 增加后整单预报箱数} responses: '200': {description: 返回补箱后的转运详情,相同原快照和目标箱数重试不重复生成箱唛} + /api/platform/v1/last-leg/warehouse-transfers/{transferNo}/increase-forecast-cartons: + post: + summary: 平台在测量完成前增加整单预报箱数 + description: 平台身份及 platform:overseas-warehouse:warehouse-transfer:approve 权限;指定目标客户,沿用客户补箱的状态、并发及幂等规则,不改变SKU预报件数。 + security: [{ bearerAuth: [] }] + parameters: + - { name: transferNo, in: path, required: true, schema: { type: string } } + - { name: customerId, in: query, required: true, schema: { type: string } } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [expectedPackageCount, packageCount] + properties: + expectedPackageCount: { type: integer, minimum: 1, maximum: 5000 } + packageCount: { type: integer, minimum: 1, maximum: 5000 } + responses: + '200': { description: 补箱后的转运详情 } + '409': { description: 已完成测量或原预报箱数过期 } /api/pda/v1/last-leg/warehouse-transfers/{transferNo}/carton-label: get: summary: PDA读取转运箱标签 @@ -1796,6 +1815,31 @@ paths: - { name: itemId, in: query, required: false, schema: { type: integer, format: int64 } } requestBody: { required: true, content: { multipart/form-data: { schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } } } } } responses: { '200': { description: 更新后的换标详情 }, '403': { description: 缺少换标文件维护权限 }, '409': { description: 状态或归属冲突 } } + /api/merchant/v1/last-leg/relabel-applications/{relabelNo}/files/{fileId}/download: + get: + summary: 客户查看或下载申请已绑定的贴标文件 + description: 权限 merchant:overseas-warehouse:relabel:list;按登录客户隔离,并校验总文件或明细文件归属。响应禁止缓存。 + security: [{ bearerAuth: [] }] + parameters: + - { name: relabelNo, in: path, required: true, schema: { type: string } } + - { name: fileId, in: path, required: true, schema: { type: integer, format: int64, minimum: 1 } } + - { name: scope, in: query, required: true, schema: { type: string, enum: [TOTAL, CARTON_TOTAL, SKU_TOTAL, ITEM] } } + - { name: itemId, in: query, required: false, schema: { type: integer, format: int64 } } + responses: + '200': { description: 贴标 PDF, content: { application/pdf: { schema: { type: string, format: binary } } } } + '403': { description: 缺少客户查询权限 } + '404': { description: 申请或文件不存在或不属于当前客户 } + /api/platform/v1/last-leg/relabel-applications/{relabelNo}/shipping-label/{fileId}/download: + get: + summary: 平台查看客户上传的换标面单 + description: 权限 platform:overseas-warehouse:relabel-order:list;校验平台身份及申请的面单绑定关系。 + security: [{ bearerAuth: [] }] + parameters: + - { name: relabelNo, in: path, required: true, schema: { type: string } } + - { name: fileId, in: path, required: true, schema: { type: integer, format: int64, minimum: 1 } } + responses: + '200': { description: 面单文件, content: { application/pdf: { schema: { type: string, format: binary } }, image/jpeg: { schema: { type: string, format: binary } }, image/png: { schema: { type: string, format: binary } } } } + '404': { description: 文件未绑定当前申请 } /api/merchant/v1/last-leg/relabel-applications/{relabelNo}/shipping-label/{fileId}/download: get: summary: 下载换标快递面单 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationControllerTest.java index dce3a6aab..ff534bcb6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationControllerTest.java @@ -34,6 +34,20 @@ class MerchantRelabelApplicationControllerTest { () -> controller.page(user(0L, "PLATFORM"), query)); } + /** 下载必须使用登录客户,且响应禁止缓存。 */ + @Test + void documentDownloadUsesAuthenticatedCustomerAndRejectsPlatform() { + org.mockito.Mockito.when(service.downloadFile("100", "RL001", "SKU_TOTAL", null, 99L)) + .thenReturn(new com.baiyue.lastleg.relabel.gateway.RelabelDocumentFileGateway.DownloadedFile( + "%PDF-1.7".getBytes(), "SKU标签.pdf", "application/pdf")); + var result = controller.downloadFile(user(100L, "MERCHANT"), "RL001", 99L, "SKU_TOTAL", null); + verify(service).downloadFile("100", "RL001", "SKU_TOTAL", null, 99L); + org.junit.jupiter.api.Assertions.assertEquals("no-store", result.getHeaders().getCacheControl()); + org.junit.jupiter.api.Assertions.assertEquals("application/pdf", result.getHeaders().getContentType().toString()); + assertThrows(ResponseStatusException.class, + () -> controller.downloadFile(user(0L, "PLATFORM"), "RL001", 99L, "SKU_TOTAL", null)); + } + private static AuthenticatedUser user(long tenantId, String appCode) { return new AuthenticatedUser(1L, tenantId, tenantId, 9L, "merchant", appCode, "WEB", Set.of()); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationControllerTest.java index 7ecff3482..987cdb4d5 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationControllerTest.java @@ -61,6 +61,18 @@ class RelabelApplicationControllerTest { org.junit.jupiter.api.Assertions.assertEquals("application/pdf", response.getHeaders().getContentType().toString()); } + /** 平台面单下载按申请解析客户,避免任意文件读取。 */ + @Test + void shippingLabelDownloadUsesApplicationCustomer() { + org.mockito.Mockito.when(service.customerId("RL-1")).thenReturn("101"); + org.mockito.Mockito.when(service.downloadShippingLabel("101", "RL-1", 88L)) + .thenReturn(new com.baiyue.lastleg.relabel.gateway.RelabelDocumentFileGateway.DownloadedFile( + "%PDF-1.7".getBytes(), "shipping.pdf", "application/pdf")); + var response = controller.downloadShippingLabel(platform(), "RL-1", 88L); + verify(service).downloadShippingLabel("101", "RL-1", 88L); + org.junit.jupiter.api.Assertions.assertEquals("no-store", response.getHeaders().getCacheControl()); + } + private RelabelRequests.Create request() { return new RelabelRequests.Create("CUSTOMER-1", "WH-1", LocalDateTime.of(2026, 8, 20, 10, 0), "Li", "13800000000", null, null, "key-1", diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferControllerTest.java index 87425575d..bebe9059a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferControllerTest.java @@ -54,7 +54,7 @@ class MerchantWarehouseTransferControllerTest { /** 补预报只使用登录客户身份,平台身份不可越权操作。 */ @Test void forecastIncreaseUsesAuthenticatedCustomerAndRejectsPlatform() { - var request = new WarehouseTransferRequests.IncreaseForecastCartons(2,9L,2,3); + var request = new WarehouseTransferRequests.IncreaseForecastCartons(2,3); controller.increaseForecastCartons(user(100L,"MERCHANT"),"TR001",request); verify(forecastService).increase("100","TR001","1",request); assertThrows(ResponseStatusException.class,()->controller.increaseForecastCartons(user(0L,"PLATFORM"),"TR001",request)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferForecastControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferForecastControllerTest.java new file mode 100644 index 000000000..13d3f6bf5 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferForecastControllerTest.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.transfer.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests; +import com.baiyue.lastleg.transfer.service.WarehouseTransferForecastService; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; +import java.util.Set; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class WarehouseTransferForecastControllerTest { + /** 平台补箱明确使用选中客户和操作者,拒绝客户身份调用平台入口。 */ + @Test void platformIncreaseRequiresPlatformAndExplicitCustomer() { + var service = mock(WarehouseTransferForecastService.class); + var controller = new WarehouseTransferController(); + ReflectionTestUtils.setField(controller, "forecasts", service); + var platform = new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", Set.of()); + var customer = new AuthenticatedUser(8L, 101L, 101L, 9L, "customer", "MERCHANT", "WEB", Set.of()); + var request = new WarehouseTransferRequests.IncreaseForecastCartons(4, 6); + controller.increaseForecastCartons(platform, "101", "WT-1", request); + verify(service).increase("101", "WT-1", "7", request); + assertThrows(ResponseStatusException.class, () -> controller.increaseForecastCartons(customer, "101", "WT-1", request)); + assertThrows(ResponseStatusException.class, () -> controller.increaseForecastCartons(platform, "", "WT-1", request)); + verifyNoMoreInteractions(service); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java index c9de1402a..4bf3aaddb 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java @@ -55,6 +55,15 @@ class WarehouseTransferCartonCountTest { verify(cartons,never()).updateById(any(WarehouseTransferCarton.class)); assertThrows(ResponseStatusException.class,()->service.countCartons("OP1",77L,"WT1",request("K1",input("BOX1","A",13),input("BOX2","B",7)))); } + /** 补充预报箱可无SKU归属,箱内装货按PDA实点记录。 */ + @Test void acceptsUnassignedForecastAndDifferentOriginalSku() { + a.setTransferItemId(null); + service.countCartons("OP1",77L,"WT1",request("UNBOUND",input("BOX1","B",3),input("BOX2","A",4))); + assertNotNull(a.getTransferItemId()); + assertTrue(a.getSkuItemsJson().contains("B")); + assertTrue(b.getSkuItemsJson().contains("A")); + } + /** 整托汇总后再保留三位,不能把每箱先舍入后累加。 */ @Test void volumeRoundsOnceAfterSummingBoxes() { var first = new WarehouseTransferRequests.CountCarton("BOX1", null, 200L, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferForecastServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferForecastServiceTest.java index 737881332..6e0e260fd 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferForecastServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferForecastServiceTest.java @@ -26,31 +26,31 @@ class WarehouseTransferForecastServiceTest { order = new WarehouseTransferOrder(); order.setId(1L); order.setCustomerId("C"); order.setTransferNo("WT1"); order.setInboundCode("WT1"); order.setStatus("WAIT_PDA_RECEIVING"); order.setPackageCount(2); item = new WarehouseTransferItem(); item.setId(2L); item.setPackageCount(2); item.setDeclaredQuantity(10L); carton = new WarehouseTransferCarton(); carton.setCartonIndex(2); carton.setCartonMark("ORIGINAL"); carton.setStatus("COUNTED"); - when(repository.lock("C","WT1")).thenReturn(order); when(repository.items("C",1L)).thenReturn(List.of(item)); + when(repository.lock("C","WT1")).thenReturn(order); } /** 补箱追加编号,预报件数与原箱号保持不变,相同快照重放不重复增加。 */ @Test void appendPreservesMarksAndQuantityAndReplays() { when(repository.cartons("C",1L)).thenReturn(List.of(carton)); - var request = new WarehouseTransferRequests.IncreaseForecastCartons(2,2L,2,4); + var request = new WarehouseTransferRequests.IncreaseForecastCartons(2,4); service.increase("C","WT1","OP",request); var added = org.mockito.ArgumentCaptor.forClass(List.class); - verify(repository).append(eq(order),eq(item),eq(0),added.capture(),any(),eq("OP")); + verify(repository).append(eq(order),added.capture(),any(),eq("OP")); var rows = (List) added.getValue(); assertEquals(2,rows.size()); assertEquals(3,rows.get(0).getCartonIndex()); assertEquals(4,rows.get(1).getCartonIndex()); assertNotEquals(rows.get(0).getCartonMark(),rows.get(1).getCartonMark()); assertEquals("ORIGINAL",carton.getCartonMark()); - assertEquals(10L,item.getDeclaredQuantity()); assertEquals(4,order.getPackageCount()); + assertNull(rows.get(0).getTransferItemId()); assertEquals(2,item.getPackageCount()); assertEquals(10L,item.getDeclaredQuantity()); assertEquals(4,order.getPackageCount()); clearInvocations(repository); service.increase("C","WT1","OP",request); - verify(repository,never()).append(any(),any(),anyInt(),anyList(),any(),anyString()); + verify(repository,never()).append(any(),anyList(),any(),anyString()); } /** 不允许完工后、跨单SKU、旧快照或减少箱数。 */ @Test void rejectsCompletedForeignStaleAndDecreasing() { order.setStatus("WAIT_CUSTOMER_CONFIRMATION"); - assertThrows(ResponseStatusException.class,()->service.increase("C","WT1","OP",new WarehouseTransferRequests.IncreaseForecastCartons(2,2L,2,3))); + assertThrows(ResponseStatusException.class,()->service.increase("C","WT1","OP",new WarehouseTransferRequests.IncreaseForecastCartons(2,3))); order.setStatus("WAIT_PDA_RECEIVING"); - for(var request:List.of(new WarehouseTransferRequests.IncreaseForecastCartons(2,99L,2,3),new WarehouseTransferRequests.IncreaseForecastCartons(1,2L,2,3),new WarehouseTransferRequests.IncreaseForecastCartons(2,2L,2,1))) + for(var request:List.of(new WarehouseTransferRequests.IncreaseForecastCartons(1,3),new WarehouseTransferRequests.IncreaseForecastCartons(2,1))) assertThrows(ResponseStatusException.class,()->service.increase("C","WT1","OP",request)); - verify(repository,never()).append(any(),any(),anyInt(),anyList(),any(),anyString()); + verify(repository,never()).append(any(),anyList(),any(),anyString()); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java index 7619025d3..71d1cb406 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java @@ -767,6 +767,29 @@ class WarehouseTransferServiceTest { verify(orderDetails).insert(any(LastlegOrderDetail.class)); } + /** 已打印草稿再次保存时保留箱唛,并保存SKU预报件数。 */ + @Test + void draftUpdatePreservesPrintedCartonMarksAndDeclaredQuantity() throws Exception { + var transfer = persistedTransfer(); transfer.setStatus(WarehouseTransferService.DRAFT); + var tailOrder = historicalOrder(); tailOrder.setId(31L); tailOrder.setCustomerId("TENANT-A"); tailOrder.setCurrentStatus(WarehouseTransferService.DRAFT); + when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(orders.selectById(31L)).thenReturn(tailOrder); + var box = new WarehouseTransferCarton(); box.setId(88L); box.setCartonIndex(1); box.setCartonMark("BX-KEEP"); box.setTotalCartons(5); box.setStatus("EXPECTED"); + when(transferCartons.listOrder("TENANT-A", transfer.getId())).thenReturn(List.of(box)); + var jsonMapper = new ObjectMapper().findAndRegisterModules(); + com.fasterxml.jackson.databind.node.ObjectNode requestJson = jsonMapper.valueToTree(request(false)); + ((com.fasterxml.jackson.databind.node.ObjectNode) requestJson.get("items").get(0)).put("quantity", 120L); + var withQuantity = jsonMapper.treeToValue(requestJson, WarehouseTransferRequests.Create.class); + var result = service.updateDraft("TENANT-A", "WTR-1", "USER-1", withQuantity); + var savedItem = ArgumentCaptor.forClass(WarehouseTransferItem.class); + verify(transferItems).insert(savedItem.capture()); + assertEquals(120L, savedItem.getValue().getDeclaredQuantity()); + assertEquals("BX-KEEP", box.getCartonMark()); + verify(transferCartons).updateById(box); + verify(transferCartons, times(4)).insert(any(WarehouseTransferCarton.class)); + } + @Test void merchantCannotUpdateSubmittedTransfer() { WarehouseTransferOrder transfer = persistedTransfer(); @@ -790,17 +813,15 @@ class WarehouseTransferServiceTest { verify(transfers, never()).updateById(any(WarehouseTransferOrder.class)); } + /** 草稿预先打印与后续正式提交使用同一服务端箱唛。 */ @Test - void draftCannotPrintCartonLabelsBeforeSubmission() { - WarehouseTransferOrder transfer = persistedTransfer(); - transfer.setStatus(WarehouseTransferService.DRAFT); + void draftCanPrintPersistedCartonLabels() { + WarehouseTransferOrder transfer = persistedTransfer(); transfer.setStatus(WarehouseTransferService.DRAFT); when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); - - ResponseStatusException error = assertThrows(ResponseStatusException.class, - () -> service.cartonLabel("TENANT-A", "WTR-1")); - - assertEquals(409, error.getStatusCode().value()); - verify(transferCartons, never()).listOrder(anyString(), anyLong()); + var carton = new WarehouseTransferCarton(); carton.setCartonMark("BX-PRESERVED"); carton.setCartonIndex(1); carton.setTotalCartons(1); carton.setStatus("EXPECTED"); + when(transferCartons.listOrder("TENANT-A", transfer.getId())).thenReturn(List.of(carton)); + var result = service.cartonLabel("TENANT-A", "WTR-1"); + assertTrue(result.printableHtml().contains("BX-PRESERVED")); } /** 提交预报后即可打印全部预报箱唛,不依赖测量或照片完成。 */ diff --git a/scripts/db/warehouse-transfer-forecast-unbound-cartons-20261016.sql b/scripts/db/warehouse-transfer-forecast-unbound-cartons-20261016.sql new file mode 100644 index 000000000..ca8ddfc79 --- /dev/null +++ b/scripts/db/warehouse-transfer-forecast-unbound-cartons-20261016.sql @@ -0,0 +1,3 @@ +-- 对应数据库:baiyue_lastleg;可重复执行,不修改原箱号、SKU预报件数和实测数据。 +USE baiyue_lastleg; +ALTER TABLE ll_warehouse_transfer_carton MODIFY COLUMN transfer_item_id BIGINT NULL COMMENT '实际箱内首项SKU明细ID,预报补箱可为空';