This commit is contained in:
2026-10-16 16:53:32 +08:00
parent fee0268391
commit 6211d4ae37
18 changed files with 222 additions and 65 deletions
@@ -0,0 +1,2 @@
-- 对应数据库:baiyue_lastleg;可重复执行,不修改原箱号、SKU预报件数和实测数据。
ALTER TABLE ll_warehouse_transfer_carton MODIFY COLUMN transfer_item_id BIGINT NULL COMMENT '实际箱内首项SKU明细ID,预报补箱可为空';
@@ -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<byte[]> 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<byte[]> downloadShippingLabel(@AuthenticationPrincipal AuthenticatedUser user,
@@ -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<byte[]> 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<byte[]> downloadCompletionPhoto(@AuthenticationPrincipal AuthenticatedUser user,
@@ -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<WarehouseTransferResponses.Detail> 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<WarehouseTransferResponses.Detail> 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<WarehouseTransferResponses.Label> cartonLabel(@AuthenticationPrincipal AuthenticatedUser user,
@@ -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
) {}
@@ -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<WarehouseTransferItem> items(String customerId, Long transferId) {
return items.selectList(new LambdaQueryWrapper<WarehouseTransferItem>()
.eq(WarehouseTransferItem::getCustomerId, customerId)
.eq(WarehouseTransferItem::getTransferOrderId, transferId).orderByAsc(WarehouseTransferItem::getId));
}
/** 读取原箱号用于确定新增编号,不删除或重建原箱。 */
public List<WarehouseTransferCarton> 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<WarehouseTransferCarton> added, LocalDateTime now, String operatorId) {
String customerId = transfer.getCustomerId();
var order = orders.selectOne(new LambdaQueryWrapper<LastlegOrder>()
.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<WarehouseTransferCarton>()
@@ -65,18 +57,13 @@ public class TransferForecastRepository {
inboundOrders.update(null, new LambdaUpdateWrapper<LastlegInboundOrder>()
.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<LastlegOrderDetail>()
.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<InboundPlanLine>()
.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);
}
}
@@ -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<WarehouseTransferCarton>();
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);
}
}
@@ -410,9 +410,13 @@ public class WarehouseTransferService {
inboundOrders.updateById(inbound);
}
// 草稿可能已打印箱唛,编辑时保留已有序号对应的编号,只追加或移除尾部箱子。
Map<Integer, WarehouseTransferCarton> originalCartons = transferCartons.listOrder(customerId, transfer.getId())
.stream().collect(Collectors.toMap(WarehouseTransferCarton::getCartonIndex, Function.identity()));
transferCartons.delete(new LambdaQueryWrapper<WarehouseTransferCarton>()
.eq(WarehouseTransferCarton::getCustomerId, customerId)
.eq(WarehouseTransferCarton::getTransferOrderId, transfer.getId()));
.eq(WarehouseTransferCarton::getTransferOrderId, transfer.getId())
.gt(WarehouseTransferCarton::getCartonIndex, request.packageCount()));
transferItems.delete(new LambdaQueryWrapper<WarehouseTransferItem>()
.eq(WarehouseTransferItem::getCustomerId, customerId)
.eq(WarehouseTransferItem::getTransferOrderId, transfer.getId()));
@@ -431,8 +435,16 @@ public class WarehouseTransferService {
List<String> 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<WarehouseTransferResponses.Carton> 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<String> 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");
@@ -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待实收',
@@ -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: 下载换标快递面单
@@ -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());
}
@@ -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",
@@ -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));
@@ -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);
}
}
@@ -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,
@@ -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<WarehouseTransferCarton>) 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());
}
}
@@ -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"));
}
/** 提交预报后即可打印全部预报箱唛,不依赖测量或照片完成。 */
@@ -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,预报补箱可为空';