fix(lastleg): complete relabel document contract and regression gates

This commit is contained in:
2026-09-09 12:54:14 +08:00
parent ac3ce3703e
commit 655237e37f
19 changed files with 474 additions and 23 deletions
@@ -67,6 +67,8 @@ public class RelabelApplicationController {
@PathVariable String relabelNo, @RequestParam String scope, @RequestParam(required = false) Long itemId,
@RequestPart("file") MultipartFile file) {
requirePlatform(user);
if (!"TOTAL".equalsIgnoreCase(scope) && !"ITEM".equalsIgnoreCase(scope)) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "资料范围必须为 TOTAL 或 ITEM");
if ("TOTAL".equalsIgnoreCase(scope) && itemId != null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "总资料不能指定明细");
String customerId = service.customerId(relabelNo);
return ApiResponse.success("ITEM".equalsIgnoreCase(scope)
? service.uploadItemFile(customerId, relabelNo, itemId == null ? 0L : itemId, operatorLong(user), file)
@@ -80,7 +82,7 @@ public class RelabelApplicationController {
@RequestParam(required = false) Long itemId) {
requirePlatform(user);
var file = service.downloadFile(service.customerId(relabelNo), relabelNo, scope, itemId, fileId);
return ResponseEntity.ok().header("Content-Disposition", ContentDisposition.inline().filename(file.fileName(), StandardCharsets.UTF_8).build().toString())
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());
}
private void requirePlatform(AuthenticatedUser user) {
@@ -37,7 +37,7 @@ public class HttpRelabelDocumentFileGateway implements RelabelDocumentFileGatewa
.onStatus(HttpStatusCode::isError, (request, result) -> { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "换标PDF上传失败"); })
.body(MetadataEnvelope.class);
requireSuccess(response, "换标PDF上传失败"); Metadata data = response == null ? null : response.data();
if (data == null || data.fileId() == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "文件服务未返回换标PDF信息");
if (data == null || data.fileId() == null || data.fileId() <= 0) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "文件服务未返回换标PDF信息");
return new UploadedFile(data.fileId(), data.fileName(), data.contentType(), data.sizeBytes() == null ? 0 : data.sizeBytes());
}
@@ -47,7 +47,7 @@ public class HttpRelabelDocumentFileGateway implements RelabelDocumentFileGatewa
throw new ResponseStatusException(result.getStatusCode().is4xxClientError() ? HttpStatus.NOT_FOUND : HttpStatus.SERVICE_UNAVAILABLE, "换标PDF读取失败");
}).toEntity(byte[].class);
String name = response.getHeaders().getContentDisposition().getFilename();
return new DownloadedFile(response.getBody() == null ? new byte[0] : response.getBody(), name == null ? fileId + ".pdf" : name,
return new DownloadedFile(response.getBody() == null ? new byte[0] : response.getBody(), name == null ? "换标资料.pdf" : name,
response.getHeaders().getContentType() == null ? MediaType.APPLICATION_PDF_VALUE : response.getHeaders().getContentType().toString());
}
@@ -16,6 +16,8 @@ import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
import com.baiyue.lastleg.relabel.dto.RelabelResponses;
import com.baiyue.lastleg.relabel.gateway.RelabelDocumentFileGateway;
import org.springframework.web.multipart.MultipartFile;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.entity.RelabelStatusHistory;
@@ -63,6 +65,75 @@ public class RelabelApplicationService implements WalletResultHandler {
@Resource private InventoryCommandService inventoryCommands;
@Resource private InventoryBalanceMapper inventoryBalances;
@Resource private InventoryPositionMapper inventoryPositions;
@Resource private RelabelDocumentFileGateway documentFiles;
@Transactional(readOnly = true)
public String customerId(String relabelNo) { return require(null, relabelNo).getCustomerId(); }
@Transactional
public RelabelResponses.Detail uploadTotalFile(String customerId, String relabelNo, long operatorId, MultipartFile file) {
RelabelApplication application = lockForDocument(customerId, relabelNo);
requirePdf(file);
var uploaded = documentFiles.upload(application.getCustomerId(), "OVERSEAS_RELABEL_TOTAL_DOCUMENT", String.valueOf(application.getId()), operatorId, file);
application.setTotalFileId(String.valueOf(uploaded.fileId())); application.setTotalFileName(uploaded.fileName());
application.setTotalFileUrl(documentUrl(relabelNo, uploaded.fileId(), null)); application.setUpdatedAt(LocalDateTime.now());
applications.updateById(application);
return detail(application.getCustomerId(), relabelNo);
}
@Transactional
public RelabelResponses.Detail uploadItemFile(String customerId, String relabelNo, long itemId, long operatorId, MultipartFile file) {
RelabelApplication application = lockForDocument(customerId, relabelNo);
RelabelItem item = requireDocumentItem(application, itemId);
requirePdf(file);
var uploaded = documentFiles.upload(application.getCustomerId(), "OVERSEAS_RELABEL_ITEM_DOCUMENT", String.valueOf(item.getId()), operatorId, file);
item.setRelabelFileId(String.valueOf(uploaded.fileId())); item.setRelabelFileName(uploaded.fileName());
item.setRelabelFileUrl(documentUrl(relabelNo, uploaded.fileId(), itemId));
items.updateById(item);
application.setUpdatedAt(LocalDateTime.now()); applications.updateById(application);
return detail(application.getCustomerId(), relabelNo);
}
@Transactional(readOnly = true)
public RelabelDocumentFileGateway.DownloadedFile downloadFile(String customerId, String relabelNo, String scope, Long itemId, long fileId) {
RelabelApplication application = require(customerId, relabelNo);
if(fileId <= 0) throw notFound("换标资料不存在");
if ("ITEM".equalsIgnoreCase(scope)) {
if (itemId == null) throw badRequest("明细资料必须指定明细");
RelabelItem item = requireDocumentItem(application, itemId);
if (!String.valueOf(fileId).equals(item.getRelabelFileId())) throw notFound("换标资料未绑定当前明细");
return documentFiles.download(application.getCustomerId(), "OVERSEAS_RELABEL_ITEM_DOCUMENT", String.valueOf(item.getId()), fileId);
}
if (!"TOTAL".equalsIgnoreCase(scope) || itemId != null) throw badRequest("资料范围必须为 TOTAL 或 ITEM,总资料不指定明细");
if (!String.valueOf(fileId).equals(application.getTotalFileId())) throw notFound("换标资料未绑定当前申请单");
return documentFiles.download(application.getCustomerId(), "OVERSEAS_RELABEL_TOTAL_DOCUMENT", String.valueOf(application.getId()), fileId);
}
private RelabelApplication lockForDocument(String customerId, String relabelNo) {
if (!StringUtils.hasText(customerId)) throw badRequest("客户不能为空");
RelabelApplication application = applications.lockNo(customerId, relabelNo);
if (application == null) throw notFound("Relabel application not found");
if (!Set.of(PENDING_PROCESSING, "PROCESSING").contains(application.getStatus())) throw conflict("当前换标状态不允许修改资料");
return application;
}
private RelabelItem requireDocumentItem(RelabelApplication application, long itemId) {
RelabelItem item = itemId > 0 ? items.selectById(itemId) : null;
if (item == null || !Objects.equals(item.getApplicationId(), application.getId()) || !Objects.equals(item.getCustomerId(), application.getCustomerId())) throw notFound("换标明细不属于当前申请单");
return item;
}
private static void requirePdf(MultipartFile file) {
if (file == null || file.isEmpty() || file.getSize() > 10L * 1024 * 1024) throw badRequest("请上传不超过10MB的PDF文件");
if (file.getOriginalFilename() == null || !file.getOriginalFilename().toLowerCase(java.util.Locale.ROOT).endsWith(".pdf") || !"application/pdf".equalsIgnoreCase(file.getContentType())) throw badRequest("换标资料仅支持PDF文件");
try (var stream = file.getInputStream()) {
if (!java.util.Arrays.equals(stream.readNBytes(5), "%PDF-".getBytes(StandardCharsets.US_ASCII))) throw badRequest("文件内容不是PDF");
} catch (java.io.IOException exception) { throw badRequest("无法读取PDF文件"); }
}
private static String documentUrl(String relabelNo, long fileId, Long itemId) {
return "/api/platform/v1/last-leg/relabel-applications/" + java.net.URLEncoder.encode(relabelNo, StandardCharsets.UTF_8) + "/files/" + fileId + "/download?scope=" + (itemId == null ? "TOTAL" : "ITEM&itemId=" + itemId);
}
@Transactional(readOnly = true)
public List<RelabelResponses.InventoryCandidate> candidates(String customerId, String operationWarehouseId,
@@ -101,7 +172,6 @@ public class RelabelApplicationService implements WalletResultHandler {
int total = 0;
int notified = 0;
List<SourceInventory> sources = new java.util.ArrayList<>();
Set<Long> selectedPositions = new HashSet<>();
Set<String> selectedCartonMarks = new HashSet<>();
for (RelabelRequests.Item item : request.items()) {
validateOperation(item);
@@ -110,9 +180,6 @@ public class RelabelApplicationService implements WalletResultHandler {
}
total = Math.addExact(total, item.totalPackageCount());
notified = Math.addExact(notified, item.notifiedPackageCount());
if (!selectedPositions.add(item.sourceInventoryPositionId())) {
throw badRequest("The same inventory position cannot be selected more than once");
}
if (!selectedCartonMarks.add(item.cartonMark().trim().toUpperCase())) {
throw badRequest("The same carton mark cannot create more than one relabel task");
}
@@ -135,11 +202,12 @@ public class RelabelApplicationService implements WalletResultHandler {
long applicationId = IdWorker.getId();
String operationWarehouseId = request.operationWarehouseId().trim();
String relabelNo = orderNoGenerator.next("RELABEL", operationWarehouseId, applicationId);
int holdIndex = 0;
for (SourceInventory source : sources) {
inventoryCommands.hold(customerId, operatorId, new InventoryCommandRequests.Move(
source.request().sourceInventoryPositionId(), source.request().notifiedPackageCount(), relabelNo,
null, "RELABEL:" + relabelNo, request.idempotencyKey().trim() + ":HOLD:"
+ source.request().sourceInventoryPositionId(), "贴标申请冻结原入仓单库存"));
null, "RELABEL:" + relabelNo, "RELABEL:" + applicationId + ":HOLD:" + holdIndex++,
"贴标申请冻结原入仓单库存"));
}
LastlegOrder tailOrder = insertTailOrder(customerId, request, requestHash, relabelNo,
operationWarehouseId, total, now);
@@ -321,6 +389,7 @@ public class RelabelApplicationService implements WalletResultHandler {
Objects.requireNonNullElse(value.getItemCount(), detailItems.size()),
Objects.requireNonNullElse(value.getTotalPackageCount(), 0),
Objects.requireNonNullElse(value.getNotifiedPackageCount(), 0), skuCodes,
value.getTotalFileId(), value.getTotalFileName(), value.getTotalFileUrl(),
value.getChargeAmount(), value.getChargeCurrency(), value.getWalletStatus(), value.getWalletErrorMessage(),
value.getCreatedAt());
}