贴标申请,仓储转运

This commit is contained in:
2026-10-14 11:36:35 +08:00
parent 83dc6dea3f
commit e14bdb4f49
37 changed files with 884 additions and 32 deletions
@@ -9,6 +9,7 @@ import java.time.Duration;
@ConfigurationProperties(prefix = "baiyue.http.clients")
public class LastlegRestClientProperties {
private ClientTimeouts unloadPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30));
private ClientTimeouts cargoDiscardPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30));
private ClientTimeouts relabelPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30));
private ClientTimeouts customerVisitFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30));
private ClientTimeouts procurementFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30));
@@ -18,6 +19,8 @@ public class LastlegRestClientProperties {
public ClientTimeouts getUnloadPhotoFile() { return unloadPhotoFile; }
public void setUnloadPhotoFile(ClientTimeouts value) { unloadPhotoFile = required(value, "unload-photo-file"); }
public ClientTimeouts getCargoDiscardPhotoFile() { return cargoDiscardPhotoFile; }
public void setCargoDiscardPhotoFile(ClientTimeouts value) { cargoDiscardPhotoFile = required(value, "cargo-discard-photo-file"); }
public ClientTimeouts getRelabelPhotoFile() { return relabelPhotoFile; }
public void setRelabelPhotoFile(ClientTimeouts value) { relabelPhotoFile = required(value, "relabel-photo-file"); }
public ClientTimeouts getCustomerVisitFile() { return customerVisitFile; }
@@ -52,6 +52,16 @@ public class CargoDiscardController {
return ApiResponse.success(service.detail(scope(user, customerId), discardNo));
}
@GetMapping("/{discardNo}/photos/files/{fileId}/download")
@org.springframework.security.access.prepost.PreAuthorize("hasAuthority('platform:overseas-warehouse:discard:list')")
public org.springframework.http.ResponseEntity<byte[]> downloadPhoto(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String customerId, @PathVariable String discardNo, @PathVariable long fileId) {
var file = service.downloadPhoto(scope(user, customerId), discardNo, fileId);
return org.springframework.http.ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore())
.header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION,
org.springframework.http.ContentDisposition.inline().filename(file.fileName(), java.nio.charset.StandardCharsets.UTF_8).build().toString())
.contentType(org.springframework.http.MediaType.parseMediaType(file.contentType())).body(file.content());
}
@PostMapping("/{discardNo}/review")
public ApiResponse<CargoDiscardResponses.Detail> review(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String discardNo,
@@ -36,6 +36,16 @@ public class MerchantCargoDiscardController {
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')")
public ApiResponse<CargoDiscardResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String discardNo) { return ApiResponse.success(service.detail(customer(user), discardNo)); }
@GetMapping("/{discardNo}/photos/files/{fileId}/download")
@org.springframework.security.access.prepost.PreAuthorize("hasAuthority('merchant:overseas-warehouse:discard:list')")
public org.springframework.http.ResponseEntity<byte[]> downloadPhoto(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String discardNo, @PathVariable long fileId) {
var file = service.downloadPhoto(customer(user), discardNo, fileId);
return org.springframework.http.ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore())
.header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION,
org.springframework.http.ContentDisposition.inline().filename(file.fileName(), java.nio.charset.StandardCharsets.UTF_8).build().toString())
.contentType(org.springframework.http.MediaType.parseMediaType(file.contentType())).body(file.content());
}
@PostMapping("/{discardNo}/cancel")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_CANCEL + "')")
public ApiResponse<CargoDiscardResponses.Detail> cancel(@AuthenticationPrincipal AuthenticatedUser user,
@@ -25,7 +25,10 @@ public final class CargoDiscardResponses {
public record History(String fromStatus, String toStatus, String operatorId,
String remark, LocalDateTime createdAt) {}
public record Photo(Long fileId, String palletNo, String fileName, String contentType, Long sizeBytes,
String operatorId, LocalDateTime createdAt, LocalDateTime submittedAt) {}
public record Detail(Summary summary, BigDecimal feePerPackage, String remark,
String reviewedBy, String reviewRemark, List<Item> items,
List<History> histories) {}
List<History> histories, List<Photo> photos) {}
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.discard.entity;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_cargo_discard_photo")
public class CargoDiscardPhoto {
@TableId private Long id;
private String customerId;
private Long applicationId;
private String palletNo;
private Long fileId;
private String fileName;
private String contentType;
private Long sizeBytes;
private String operatorId;
private LocalDateTime createdAt;
private LocalDateTime submittedAt;
}
@@ -0,0 +1,12 @@
package com.baiyue.lastleg.discard.gateway;
import org.springframework.web.multipart.MultipartFile;
public interface CargoDiscardPhotoFileGateway {
UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file);
void validate(String customerId, long taskId, long fileId);
DownloadedFile download(String customerId, long taskId, long fileId);
record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) { }
record DownloadedFile(byte[] content, String fileName, String contentType) { }
}
@@ -0,0 +1,135 @@
package com.baiyue.lastleg.discard.gateway;
import com.baiyue.lastleg.config.LastlegRestClientFactory;
import com.baiyue.lastleg.config.LastlegRestClientProperties;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
@Component
public class HttpCargoDiscardPhotoFileGateway implements CargoDiscardPhotoFileGateway {
private static final String BUSINESS_TYPE = "OVERSEAS_CARGO_DISCARD_PHOTO";
@Resource private RestClient.Builder restClientBuilder;
@Resource private LastlegRestClientFactory restClientFactory;
@Resource private LastlegRestClientProperties restClientProperties;
@Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
private RestClient client;
@PostConstruct
void initializeClient() {
client = restClientFactory.build(restClientBuilder, baseUrl,
restClientProperties.getCargoDiscardPhotoFile());
}
@Override
public UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file) {
long tenantId = tenantId(customerId);
MultipartBodyBuilder body = new MultipartBodyBuilder();
try {
body.part("file", new ByteArrayResource(file.getBytes()) {
@Override public String getFilename() { return file.getOriginalFilename(); }
}).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM
: MediaType.parseMediaType(file.getContentType()));
} catch (IOException exception) {
throw new IllegalArgumentException("Cannot read cargo-discard photo", exception);
}
MetadataEnvelope response = client.post()
.uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}",
tenantId, BUSINESS_TYPE, taskId)
.header("X-Internal-Service-Token", internalToken)
.header("X-Operator-Id", String.valueOf(operatorId))
.contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> {
throw new ResponseStatusException(result.getStatusCode().is4xxClientError()
? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE,
"Cargo discard photo upload failed");
}).body(MetadataEnvelope.class);
requireSuccess(response, "Cargo discard photo upload failed");
Metadata data = response == null ? null : response.data();
if (data == null || data.fileId() == null) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"File service returned no cargo-discard photo metadata");
}
return new UploadedFile(data.fileId(), data.fileName(), data.contentType(),
data.sizeBytes() == null ? 0L : data.sizeBytes());
}
@Override
public void validate(String customerId, long taskId, long fileId) {
long tenantId = tenantId(customerId);
MetadataEnvelope response = client.get()
.uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}",
tenantId, BUSINESS_TYPE, taskId, fileId)
.header("X-Internal-Service-Token", internalToken).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> {
throw new ResponseStatusException(result.getStatusCode().is4xxClientError()
? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE,
"Cargo discard photo does not exist or its ownership is invalid");
}).body(MetadataEnvelope.class);
requireSuccess(response, "Cargo discard photo does not exist or its ownership is invalid");
Metadata data = response == null ? null : response.data();
if (data == null || data.fileId() == null || data.fileId() != fileId
|| data.tenantId() == null || data.tenantId() != tenantId
|| !BUSINESS_TYPE.equals(data.businessType())
|| !String.valueOf(taskId).equals(data.businessId())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Cargo discard photo metadata does not match the PDA task");
}
}
@Override
public DownloadedFile download(String customerId, long taskId, long fileId) {
validate(customerId, taskId, fileId);
ResponseEntity<byte[]> response = client.get()
.uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content",
tenantId(customerId), BUSINESS_TYPE, taskId, fileId)
.header("X-Internal-Service-Token", internalToken).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> {
throw new ResponseStatusException(result.getStatusCode().is4xxClientError()
? HttpStatus.NOT_FOUND : HttpStatus.SERVICE_UNAVAILABLE, "Cargo discard photo download failed");
}).toEntity(byte[].class);
String fileName = response.getHeaders().getContentDisposition().getFilename();
String contentType = response.getHeaders().getContentType() == null
? MediaType.APPLICATION_OCTET_STREAM_VALUE : response.getHeaders().getContentType().toString();
return new DownloadedFile(response.getBody() == null ? new byte[0] : response.getBody(),
fileName == null ? "cargo-discard-photo" : fileName, contentType);
}
private static long tenantId(String customerId) {
try { return Long.parseLong(customerId); }
catch (NumberFormatException exception) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Customer ID must be numeric", exception);
}
}
private static void requireSuccess(MetadataEnvelope response, String fallback) {
if (response == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, fallback);
if ("200".equals(response.code())) return;
HttpStatus status = switch (String.valueOf(response.code())) {
case "A0400", "A0422" -> HttpStatus.BAD_REQUEST;
case "A0403" -> HttpStatus.FORBIDDEN;
case "A0404" -> HttpStatus.NOT_FOUND;
default -> HttpStatus.SERVICE_UNAVAILABLE;
};
throw new ResponseStatusException(status,
response.message() == null || response.message().isBlank() ? fallback : response.message());
}
private record MetadataEnvelope(String code, String message, Metadata data) { }
private record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
String fileName, String contentType, Long sizeBytes) { }
}
@@ -19,4 +19,7 @@ public interface CargoDiscardApplicationMapper extends BaseMapper<CargoDiscardAp
@Select("SELECT * FROM ll_cargo_discard_application WHERE discard_no=#{discardNo} AND overseas_warehouse_id=#{warehouseId} LIMIT 1 FOR UPDATE")
CargoDiscardApplication findNoByWarehouse(@Param("discardNo") String discardNo, @Param("warehouseId") String warehouseId);
@Select("SELECT * FROM ll_cargo_discard_application WHERE discard_no=#{discardNo} AND overseas_warehouse_id=#{warehouseId} LIMIT 1")
CargoDiscardApplication readNoByWarehouse(@Param("discardNo") String discardNo, @Param("warehouseId") String warehouseId);
}
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.discard.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.discard.entity.CargoDiscardPhoto;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface CargoDiscardPhotoMapper extends BaseMapper<CargoDiscardPhoto> {
@Select("SELECT * FROM ll_cargo_discard_photo WHERE customer_id=#{customerId} AND application_id=#{applicationId} ORDER BY created_at,id")
List<CargoDiscardPhoto> listApplication(@Param("customerId") String customerId, @Param("applicationId") Long applicationId);
}
@@ -24,6 +24,27 @@ public class PdaCargoDiscardController {
@Valid @RequestBody PdaCargoDiscardDtos.Scan request) {
return ApiResponse.success(service.resolve(PdaWarehouseIdentity.require(user), request.palletNo()));
}
@PostMapping(value = "/{discardNo}/pallets/{palletNo}/photos/files", consumes = org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<com.baiyue.lastleg.discard.dto.CargoDiscardResponses.Photo> uploadPhoto(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String discardNo, @PathVariable String palletNo,
@RequestParam("file") org.springframework.web.multipart.MultipartFile file) {
return ApiResponse.success(service.uploadPhoto(PdaWarehouseIdentity.require(user), discardNo, palletNo,
user.userId() == null ? 0L : user.userId(), file));
}
@GetMapping("/{discardNo}/pallets/{palletNo}/photos/files")
public ApiResponse<List<com.baiyue.lastleg.discard.dto.CargoDiscardResponses.Photo>> photos(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String discardNo, @PathVariable String palletNo) {
return ApiResponse.success(service.photos(PdaWarehouseIdentity.require(user), discardNo, palletNo));
}
@GetMapping("/{discardNo}/pallets/{palletNo}/photos/files/{fileId}/download")
public org.springframework.http.ResponseEntity<byte[]> downloadPhoto(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String discardNo, @PathVariable String palletNo, @PathVariable long fileId) {
var file = service.downloadPhoto(PdaWarehouseIdentity.require(user), discardNo, palletNo, fileId);
return org.springframework.http.ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore())
.header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION,
org.springframework.http.ContentDisposition.inline().filename(file.fileName(), java.nio.charset.StandardCharsets.UTF_8).build().toString())
.contentType(org.springframework.http.MediaType.parseMediaType(file.contentType())).body(file.content());
}
@PostMapping("/{discardNo}/pallets/{palletNo}/confirm")
public ApiResponse<PdaCargoDiscardDtos.PalletTask> confirm(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String discardNo, @PathVariable String palletNo, @Valid @RequestBody PdaCargoDiscardDtos.Confirm request) {
@@ -12,7 +12,8 @@ public final class PdaCargoDiscardDtos {
private PdaCargoDiscardDtos() { }
public record Scan(@NotBlank @Size(max = 128) String palletNo) { }
public record ActualLine(@NotNull Long itemId, @PositiveOrZero int actualPackages) { }
public record Confirm(@NotEmpty List<@Valid ActualLine> lines) { }
public record Confirm(@NotEmpty List<@Valid ActualLine> lines,
@NotEmpty @Size(max = 20) List<@NotNull @jakarta.validation.constraints.Positive Long> photoFileIds) { }
public record Sku(Long itemId, String skuCode, String productName, int plannedPackages,
Integer actualPackages, String status) { }
public record PalletTask(String discardNo, String customerId, String overseasWarehouseId,
@@ -23,6 +23,7 @@ import java.util.stream.Collectors;
@Service
public class PdaCargoDiscardService {
@Resource private com.baiyue.lastleg.discard.service.CargoDiscardPhotoService photoService;
@Resource private CargoDiscardApplicationMapper applications;
@Resource private CargoDiscardItemMapper items;
@Resource private CargoDiscardCartonMapper cartons;
@@ -78,6 +79,8 @@ public class PdaCargoDiscardService {
|| !application.getCustomerId().equals(item.getCustomerId())
|| !Objects.equals(application.getId(), item.getApplicationId())))
throw conflict("废弃申请终态存在未完成明细,不能恢复作业结果");
photoService.confirm(application, task.palletNo(), request.photoFileIds(),
palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus())));
// A completed pallet is an immutable result, including the final pallet after application completion.
if (palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus()))) return task;
if (terminal) throw conflict("废弃终态不能执行未完成托盘");
@@ -109,6 +112,36 @@ public class PdaCargoDiscardService {
.findFirst().orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "托盘不属于该废弃单"));
}
@Transactional
public CargoDiscardResponses.Photo uploadPhoto(PdaWarehouseIdentity identity, String discardNo, String palletNo,
long operatorUserId, org.springframework.web.multipart.MultipartFile file) {
CargoDiscardApplication application = photoApplication(identity, discardNo, palletNo, true);
var task = palletTasks(application).stream().filter(t -> t.palletNo().equals(palletNo.trim())).findFirst().orElseThrow();
if (!CargoDiscardService.APPROVED.equals(application.getStatus()) || "COMPLETED".equals(task.status()))
throw conflict("已完成或未审核的废弃托盘不能上传照片");
return photoService.upload(application, task.palletNo(), identity.operatorKey(), operatorUserId, file);
}
@Transactional(readOnly = true)
public List<CargoDiscardResponses.Photo> photos(PdaWarehouseIdentity identity, String discardNo, String palletNo) {
return photoService.list(photoApplication(identity, discardNo, palletNo, false), palletNo.trim(), false);
}
@Transactional(readOnly = true)
public com.baiyue.lastleg.discard.gateway.CargoDiscardPhotoFileGateway.DownloadedFile downloadPhoto(
PdaWarehouseIdentity identity, String discardNo, String palletNo, long fileId) {
return photoService.download(photoApplication(identity, discardNo, palletNo, false), palletNo.trim(), fileId, false);
}
private CargoDiscardApplication photoApplication(PdaWarehouseIdentity identity, String discardNo, String palletNo, boolean lock) {
var warehouseId = String.valueOf(identity.overseasWarehouseId());
var application = lock ? applications.findNoByWarehouse(discardNo, warehouseId) : applications.readNoByWarehouse(discardNo, warehouseId);
if (application == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "废弃任务不存在或不属于当前仓库");
if (palletTasks(application).stream().noneMatch(t -> t.palletNo().equals(palletNo.trim())))
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "托盘不属于该废弃单");
return application;
}
private List<PdaCargoDiscardDtos.PalletTask> palletTasks(CargoDiscardApplication application) {
return items.listApplication(application.getCustomerId(), application.getId()).stream()
.collect(Collectors.groupingBy(this::physicalGroupKey, LinkedHashMap::new, Collectors.toList()))
@@ -0,0 +1,75 @@
package com.baiyue.lastleg.discard.service;
import com.baiyue.lastleg.discard.dto.CargoDiscardResponses;
import com.baiyue.lastleg.discard.entity.CargoDiscardApplication;
import com.baiyue.lastleg.discard.entity.CargoDiscardPhoto;
import com.baiyue.lastleg.discard.gateway.CargoDiscardPhotoFileGateway;
import com.baiyue.lastleg.discard.mapper.CargoDiscardPhotoMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
@Service
public class CargoDiscardPhotoService {
@Resource private CargoDiscardPhotoMapper photos;
@Resource private CargoDiscardPhotoFileGateway files;
public List<CargoDiscardResponses.Photo> list(CargoDiscardApplication application, String palletNo, boolean submittedOnly) {
return rows(application, palletNo).stream().filter(p -> !submittedOnly || p.getSubmittedAt() != null)
.map(this::view).toList();
}
public CargoDiscardResponses.Photo upload(CargoDiscardApplication application, String palletNo,
String operatorId, long operatorUserId, MultipartFile file) {
if (rows(application, palletNo).size() >= 20) throw conflict("每个托盘最多上传 20 张废弃照片");
var uploaded = files.upload(application.getCustomerId(), application.getId(), operatorUserId, file);
CargoDiscardPhoto photo = new CargoDiscardPhoto();
photo.setCustomerId(application.getCustomerId()); photo.setApplicationId(application.getId());
photo.setPalletNo(palletNo); photo.setFileId(uploaded.fileId()); photo.setFileName(uploaded.fileName());
photo.setContentType(uploaded.contentType()); photo.setSizeBytes(uploaded.sizeBytes());
photo.setOperatorId(operatorId); photo.setCreatedAt(LocalDateTime.now()); photos.insert(photo);
return view(photo);
}
// Called under the application row lock, in the same transaction as inventory deduction.
public void confirm(CargoDiscardApplication application, String palletNo, List<Long> fileIds, boolean completed) {
if (fileIds == null || fileIds.isEmpty() || fileIds.size() > 20
|| fileIds.stream().anyMatch(id -> id == null || id <= 0) || new HashSet<>(fileIds).size() != fileIds.size())
throw conflict("每个托盘须提交 1–20 张不重复的废弃照片");
var rows = rows(application, palletNo);
var submitted = rows.stream().filter(p -> p.getSubmittedAt() != null).map(CargoDiscardPhoto::getFileId).toList();
if (completed) {
if (!new HashSet<>(submitted).equals(new HashSet<>(fileIds))) throw conflict("已完成托盘的废弃照片不能修改");
return;
}
var selected = rows.stream().filter(p -> fileIds.contains(p.getFileId())).toList();
if (selected.size() != fileIds.size()) throw conflict("照片不属于当前废弃单或托盘");
selected.forEach(p -> files.validate(application.getCustomerId(), application.getId(), p.getFileId()));
var now = LocalDateTime.now();
selected.forEach(p -> { p.setSubmittedAt(now); photos.updateById(p); });
}
public CargoDiscardPhotoFileGateway.DownloadedFile download(CargoDiscardApplication application, String palletNo,
long fileId, boolean submittedOnly) {
var photo = rows(application, palletNo).stream().filter(p -> Objects.equals(p.getFileId(), fileId))
.filter(p -> !submittedOnly || p.getSubmittedAt() != null).findFirst()
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "废弃照片不存在或尚未提交"));
return files.download(application.getCustomerId(), application.getId(), photo.getFileId());
}
private List<CargoDiscardPhoto> rows(CargoDiscardApplication application, String palletNo) {
return photos.listApplication(application.getCustomerId(), application.getId()).stream()
.filter(p -> palletNo == null || palletNo.equals(p.getPalletNo())).toList();
}
private CargoDiscardResponses.Photo view(CargoDiscardPhoto photo) {
return new CargoDiscardResponses.Photo(photo.getFileId(), photo.getPalletNo(), photo.getFileName(),
photo.getContentType(), photo.getSizeBytes(), photo.getOperatorId(), photo.getCreatedAt(), photo.getSubmittedAt());
}
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -60,6 +60,7 @@ public class CargoDiscardService {
public static final String COMPLETED = "COMPLETED";
private static final Set<String> STATUSES = Set.of(PENDING_REVIEW, APPROVED, REJECTED, CANCELLED, PDA_COMPLETED, COMPLETED);
@Resource private CargoDiscardPhotoService photoService;
@Resource private CargoDiscardApplicationMapper applications;
@Resource private CargoDiscardItemMapper items;
@Resource private CargoDiscardCartonMapper discardCartons;
@@ -214,7 +215,13 @@ public class CargoDiscardService {
.map(value -> new CargoDiscardResponses.History(value.getFromStatus(), value.getToStatus(),
value.getOperatorId(), value.getRemark(), value.getCreatedAt())).toList();
return new CargoDiscardResponses.Detail(summary(application), application.getFeePerPackage(),
application.getRemark(), application.getReviewedBy(), application.getReviewRemark(), itemViews, historyViews);
application.getRemark(), application.getReviewedBy(), application.getReviewRemark(), itemViews, historyViews, photoService.list(application, null, true));
}
public com.baiyue.lastleg.discard.gateway.CargoDiscardPhotoFileGateway.DownloadedFile downloadPhoto(String customerId, String discardNo, long fileId) {
CargoDiscardApplication application = applications.findNo(customerId, discardNo);
if (application == null) throw notFound("Cargo discard application not found");
return photoService.download(application, null, fileId, true);
}
@Transactional
@@ -434,6 +434,11 @@ public class RelabelApplicationService implements WalletResultHandler {
long quantity = requestedSku.merge(skuItem.skuCode().toUpperCase(java.util.Locale.ROOT), (long) skuItem.quantity(), Long::sum);
if (quantity > availableSku.getOrDefault(skuItem.skuCode().toUpperCase(java.util.Locale.ROOT), 0L))
throw conflict("SKU " + skuItem.skuCode() + " 的换标数量超过来源箱可用数量");
var physical = returnCartonItems.lockCarton(sourceReturnCartonId).stream()
.filter(line -> line.getSkuCode().equalsIgnoreCase(skuItem.skuCode())).findFirst().orElse(null);
if (physical != null && quantity < value(physical.getQuantity())
&& returnPackingTraces.ownership(customerId, carton.getCartonMark(), physical.getSkuCode()).size() > 1)
throw conflict("跨来源单同 SKU 部分贴标须先现场分拣确认,不能按单号猜测实物来源");
}
if ("CARTON_AND_SKU".equals(operationType(item)) && (!requestedSku.equals(availableSku)
|| returnCartonItems.lockCarton(sourceReturnCartonId).stream()
@@ -46,7 +46,8 @@ public final class ReturnInventoryResponses {
public record Label(String cartonMark, String overseasWarehouseId, String customerId, String sourceBatchNo, String sourceReturnNo,
long totalQuantity, BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight,
List<CartonItem> items, LocalDateTime generatedAt, String printableHtml) { }
public record MergeResult(String cartonMark, String skuCode, long totalQuantity, Label label, String planNo) {
public record MergeResult(String cartonMark, String skuCode, long totalQuantity, Label label, String planNo, String operatorName) {
public MergeResult(String cartonMark, String skuCode, long totalQuantity, Label label, String planNo) { this(cartonMark, skuCode, totalQuantity, label, planNo, null); }
public MergeResult(String cartonMark, String skuCode, long totalQuantity, Label label) { this(cartonMark, skuCode, totalQuantity, label, null); }
}
public record PutawayResult(String cartonMark, String locationCode, String palletCode, LocalDateTime putawayAt) { }
@@ -59,8 +60,13 @@ public final class ReturnInventoryResponses {
public record PackingPlan(String planNo, String customerId, String overseasWarehouseId, List<String> returnNos,
String mode, String status, List<PlanBox> boxes, String remark, LocalDateTime pushedAt,
List<String> sourceCartonMarks, List<PlanSource> sources,
LocalDateTime createdAt, List<CartonDetail> resultCartons, LocalDateTime completedAt) { }
public record PlanSource(String cartonMark, List<PlanItem> items) { }
LocalDateTime createdAt, List<CartonDetail> resultCartons, LocalDateTime completedAt,
List<Label> completionCartons, List<MergeHistory> transfers, String completedBy) { }
public record PlanSource(String cartonMark, List<PlanItem> items, String sourceReturnNo,
List<CartonItem> originalItems, String participation) { }
public record SourceOwnership(String returnNo, String skuCode, long quantity) { }
public record CurrentCarton(CartonDetail carton, List<SourceOwnership> ownership, long orderQuantity,
String labelStatus, boolean canRelabel, String blockReason) { }
public record PlanBox(int boxNo, List<PlanItem> items, BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight) { }
public record PlanItem(String skuCode, long quantity) { }
public record PackingPlanCompletion(String planNo, List<Label> labels) { }
@@ -14,7 +14,11 @@ public final class ReturnOrderResponses {
public record Item(String skuCode, String productNameZh, long expectedQuantity,
long receivedQuantity) { }
public record Detail(Summary order, String remark, List<Item> items, List<Receipt> receipts, List<PalletDetail> pallets, List<Photo> photos,
List<PackingDetail> packingDetails, List<ReceiptCarton> cartons) {
List<PackingDetail> packingDetails, List<ReceiptCarton> cartons,
List<ReturnInventoryResponses.CurrentCarton> currentCartons) {
public Detail(Summary order, String remark, List<Item> items, List<Receipt> receipts, List<PalletDetail> pallets, List<Photo> photos, List<PackingDetail> packingDetails, List<ReceiptCarton> cartons) {
this(order, remark, items, receipts, pallets, photos, packingDetails, cartons, List.of());
}
public Detail(Summary order, String remark, List<Item> items, List<Receipt> receipts, List<PalletDetail> pallets, List<Photo> photos, List<PackingDetail> packingDetails) {
this(order, remark, items, receipts, pallets, photos, packingDetails, List.of());
}
@@ -30,7 +34,7 @@ public final class ReturnOrderResponses {
this(receiptNo, palletCode, cartonMark, countStatus, countedAt, items, null, null, null, null);
}
}
public record PackingDetail(String sourceCartonMark, String targetCartonMark, String returnNo,
public record PackingDetail(String planNo, String sourceCartonMark, String targetCartonMark, String returnNo,
String skuCode, long quantity, LocalDateTime createdAt) { }
public record Photo(long fileId, String fileName, String contentType, long sizeBytes) { }
public record PalletDetail(String palletCode, int skuTypes, long totalQuantity, List<PalletItem> items,
@@ -66,6 +66,12 @@ public interface ReturnInventoryBalanceMapper extends BaseMapper<ReturnInventory
int reserveAvailable(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId,
@Param("skuCode") String skuCode, @Param("quantity") long quantity);
@Update("UPDATE ll_return_inventory_balance SET available_quantity=available_quantity+#{quantity},updated_at=NOW(3) " +
"WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} AND sku_code=#{skuCode} " +
"AND #{quantity}>0 AND total_quantity-available_quantity>=#{quantity}")
int releaseAvailable(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId,
@Param("skuCode") String skuCode, @Param("quantity") long quantity);
@Select("SELECT COALESCE(available_quantity,0) FROM ll_return_inventory_balance WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} AND sku_code=#{skuCode} LIMIT 1")
long availableQuantity(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId,
@Param("skuCode") String skuCode);
@@ -10,6 +10,8 @@ import java.util.List;
@Mapper
public interface ReturnInventoryCartonMapper extends BaseMapper<ReturnInventoryCarton> {
@Select("SELECT * FROM ll_return_inventory_carton WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} AND FIND_IN_SET(#{returnNo},source_return_no)>0 AND total_quantity>0 ORDER BY created_at DESC,id DESC")
List<ReturnInventoryCarton> currentReturnCartons(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId, @Param("returnNo") String returnNo);
@Select("SELECT * FROM ll_return_inventory_carton WHERE overseas_warehouse_id=#{warehouseId} " +
"AND pallet_code=#{palletCode} AND total_quantity>0 " +
"AND status IN ('AVAILABLE','PACKING_RESERVED','RELABEL_PENDING','OUTBOUND_PENDING','DISCARD_PENDING') ORDER BY id")
@@ -25,6 +25,15 @@ public interface ReturnPackingTraceMapper {
@Select("SELECT id,source_carton_mark,target_carton_mark,return_no,sku_code,quantity,created_at FROM ll_return_packing_trace WHERE customer_id=#{customerId} AND return_no=#{returnNo} ORDER BY created_at,id")
List<Trace> listReturn(@Param("customerId") String customerId, @Param("returnNo") String returnNo);
@Select("SELECT JSON_UNQUOTE(JSON_EXTRACT(o.result_json,'$.planNo')) AS plan_no,t.source_carton_mark,t.target_carton_mark,t.return_no,t.sku_code,t.quantity,t.created_at FROM ll_return_packing_trace t JOIN ll_return_inventory_operation o ON o.id=t.operation_id WHERE t.customer_id=#{customerId} AND t.return_no=#{returnNo} AND t.source_carton_mark<>'' ORDER BY t.created_at,t.id")
List<com.baiyue.lastleg.returnreceipt.dto.ReturnOrderResponses.PackingDetail> returnHistory(@Param("customerId") String customerId, @Param("returnNo") String returnNo);
@Select("SELECT JSON_UNQUOTE(JSON_EXTRACT(o.result_json,'$.planNo')) AS plan_no,t.source_carton_mark,t.target_carton_mark,t.return_no,t.sku_code,t.quantity,t.created_at FROM ll_return_packing_trace t JOIN ll_return_inventory_operation o ON o.id=t.operation_id WHERE t.customer_id=#{customerId} AND JSON_UNQUOTE(JSON_EXTRACT(o.result_json,'$.planNo'))=#{planNo} AND t.source_carton_mark<>'' ORDER BY t.created_at,t.id")
List<com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses.MergeHistory> planHistory(@Param("customerId") String customerId, @Param("planNo") String planNo);
@Select("SELECT JSON_UNQUOTE(JSON_EXTRACT(o.result_json,'$.operatorName')) FROM ll_return_inventory_operation o WHERE JSON_UNQUOTE(JSON_EXTRACT(o.result_json,'$.planNo'))=#{planNo} AND o.operation_type='MERGE' ORDER BY o.id LIMIT 1")
String planOperator(@Param("planNo") String planNo);
@Select("SELECT JSON_UNQUOTE(JSON_EXTRACT(o.result_json,'$.planNo')) AS plan_no,t.source_carton_mark,t.target_carton_mark,t.return_no,t.sku_code,t.quantity,t.created_at FROM ll_return_packing_trace t JOIN ll_return_inventory_operation o ON o.id=t.operation_id WHERE t.customer_id=#{customerId} AND o.operation_type='MERGE' AND t.source_carton_mark<>'' AND (t.source_carton_mark=#{mark} OR t.target_carton_mark=#{mark}) ORDER BY t.created_at DESC,t.id DESC")
List<com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses.MergeHistory> listCartonHistory(@Param("customerId") String customerId, @Param("mark") String mark);
}
@@ -159,7 +159,10 @@ public class ReturnInventoryService {
|| !AVAILABLE.equals(carton.getStatus())
|| !sourceMatches(carton, returnNos)) throw conflict("来源箱不属于所选退货单或已被占用");
ReturnCartonCountGuard.requireConfirmed(carton);
for (ReturnInventoryCartonItem item : cartonItems.lockCarton(carton.getId())) {
List<ReturnInventoryCartonItem> lockedItems = cartonItems.lockCarton(carton.getId());
List<ReturnInventoryResponses.CartonItem> originalItems = lockedItems.stream().map(item ->
new ReturnInventoryResponses.CartonItem(item.getSkuCode(), item.getProductNameZh(), value(item.getQuantity()))).toList();
for (ReturnInventoryCartonItem item : lockedItems) {
long free = Math.max(0, value(item.getQuantity()) - value(item.getPackingReservedQuantity()));
if (value(item.getPackingReservedQuantity()) > 0) throw conflict("来源包裹/箱已有合箱预占");
long take = "BOX".equals(mode) ? free : exact == null ? Math.min(free, value(remaining.get(item.getSkuCode())))
@@ -170,7 +173,7 @@ public class ReturnInventoryService {
if ("SKU".equals(mode)) remaining.put(item.getSkuCode(), remaining.get(item.getSkuCode()) - take);
item.setPackingReservedQuantity(value(item.getPackingReservedQuantity()) + take);
cartonItems.updateById(item);
allocation.add(new SourceItem(carton.getCartonMark(), item.getSkuCode(), take));
allocation.add(new SourceItem(carton.getCartonMark(), item.getSkuCode(), take, carton.getSourceReturnNo(), originalItems));
}
if (allocation.stream().anyMatch(line -> line.cartonMark().equals(carton.getCartonMark()))) {
carton.setStatus("PACKING_RESERVED"); cartons.updateById(carton);
@@ -194,12 +197,34 @@ public class ReturnInventoryService {
return packingPlan(plan);
}
private record SourceItem(String cartonMark, String skuCode, long quantity) { }
private record SourceItem(String cartonMark, String skuCode, long quantity, String sourceReturnNo,
List<ReturnInventoryResponses.CartonItem> originalItems) {
SourceItem(String cartonMark, String skuCode, long quantity) { this(cartonMark, skuCode, quantity, null, null); }
}
@Transactional(readOnly = true)
public List<ReturnOrderResponses.PackingDetail> packingHistory(String customerId, String returnNo) {
return packingTrace.listReturn(customerId, returnNo).stream().filter(t -> StringUtils.hasText(t.sourceCartonMark()))
.map(t -> new ReturnOrderResponses.PackingDetail(t.sourceCartonMark(), t.targetCartonMark(), t.returnNo(), t.skuCode(), t.quantity(), t.createdAt())).toList();
return packingTrace.returnHistory(customerId, returnNo);
}
@Transactional(readOnly = true)
public List<ReturnInventoryResponses.CurrentCarton> currentReturnCartons(String customerId, String warehouseId, String returnNo) {
return cartons.currentReturnCartons(customerId, warehouseId, returnNo).stream()
.filter(carton -> !List.of(MERGED, "OUTBOUNDED", "DISCARDED").contains(carton.getStatus()))
.map(carton -> {
var detail = cartonDetail(carton);
List<ReturnInventoryResponses.SourceOwnership> origins = new ArrayList<>();
for (var item : detail.items()) if (item.quantity() > 0) {
for (var origin : ownership(carton, item.skuCode(), item.quantity()))
origins.add(new ReturnInventoryResponses.SourceOwnership(origin.returnNo(), item.skuCode(), origin.quantity()));
}
long orderQuantity = origins.stream().filter(origin -> returnNo.equals(origin.returnNo()))
.mapToLong(ReturnInventoryResponses.SourceOwnership::quantity).sum();
boolean available = AVAILABLE.equals(carton.getStatus()) && "CONFIRMED".equals(carton.getCountStatus())
&& detail.relabelReservedQuantity() == 0 && detail.items().stream().anyMatch(item -> item.remainingRelabelQuantity() > 0);
return new ReturnInventoryResponses.CurrentCarton(detail, origins, orderQuantity, carton.getLabelStatus(),
available, available ? null : "箱内待清点、已占用或没有待贴标数量");
}).filter(row -> row.orderQuantity() > 0).toList();
}
@Transactional
@@ -217,7 +242,10 @@ public class ReturnInventoryService {
item.setPackingReservedQuantity(value(item.getPackingReservedQuantity()) - source.quantity()); cartonItems.updateById(item);
if ("PACKING_RESERVED".equals(carton.getStatus())) { carton.setStatus(AVAILABLE); cartons.updateById(carton); }
}
for (var entry : sourceQuantities(allocation).entrySet()) balances.adjust(IdWorker.getId(), plan.getCustomerId(), plan.getOverseasWarehouseId(), entry.getKey(), null, 0, entry.getValue(), 0);
for (var entry : sourceQuantities(allocation).entrySet()) {
if (balances.releaseAvailable(plan.getCustomerId(), plan.getOverseasWarehouseId(), entry.getKey(), entry.getValue()) != 1)
throw conflict("计划库存余额预占异常,请重新读取库存");
}
plan.setStatus("CANCELLED"); plan.setUpdatedAt(LocalDateTime.now()); packingPlans.updateById(plan);
return packingPlan(plan);
}
@@ -321,8 +349,9 @@ public class ReturnInventoryService {
selected.add(new SourceItem(deduction.cartonMark(), deduction.skuCode(), deduction.quantity()));
}
sourceAllocation = selected;
if ("SKU".equals(plan.getMode()) && !new java.util.HashSet<>(sourceAllocation).equals(new java.util.HashSet<>(allocation)))
throw conflict("部分合箱实际来源与计划锁定来源数量不一致");
if (!deductionQuantities(sourceAllocation).equals(deductionQuantities(plannedTarget == null ? allocation : allocation.stream()
.filter(source -> !plannedTarget.equals(source.cartonMark())).toList())))
throw conflict("实际来源箱和 SKU 数量必须与计划锁定明细完全一致");
}
final List<SourceItem> selectedAllocation = sourceAllocation;
if (plannedTarget != null && allocation.stream().anyMatch(source -> !plannedTarget.equals(source.cartonMark())
@@ -354,8 +383,10 @@ public class ReturnInventoryService {
}
if ("PACKING_RESERVED".equals(targetSource.getStatus())) { targetSource.setStatus(AVAILABLE); cartons.updateById(targetSource); }
}
for (var entry : sourceQuantities(allocation).entrySet()) balances.adjust(IdWorker.getId(), plan.getCustomerId(),
plan.getOverseasWarehouseId(), entry.getKey(), null, 0, entry.getValue(), 0);
for (var entry : sourceQuantities(allocation).entrySet()) {
if (balances.releaseAvailable(plan.getCustomerId(), plan.getOverseasWarehouseId(), entry.getKey(), entry.getValue()) != 1)
throw conflict("计划库存余额预占异常,请重新读取库存");
}
List<ReturnInventoryResponses.Label> labels = new ArrayList<>();
List<String> generatedMarks = "SKU".equals(plan.getMode()) || plannedTarget == null ? packingMarks(request.boxes()) : Collections.nCopies(request.boxes().size(), null);
int index = 0;
@@ -371,7 +402,7 @@ public class ReturnInventoryService {
labels.add(result.label());
ReturnInventoryOperation completedOperation = operations.lockKey(identity.operatorKey(), request.idempotencyKey() + "-" + (index - 1));
if (completedOperation != null) {
completedOperation.setResultJson(json(new ReturnInventoryResponses.MergeResult(result.cartonMark(), result.skuCode(), result.totalQuantity(), result.label(), plan.getPlanNo())));
completedOperation.setResultJson(json(new ReturnInventoryResponses.MergeResult(result.cartonMark(), result.skuCode(), result.totalQuantity(), result.label(), plan.getPlanNo(), identity.operatorName())));
operations.updateById(completedOperation);
}
}
@@ -395,12 +426,25 @@ public class ReturnInventoryService {
return new ReturnInventoryResponses.PackingPlan(plan.getPlanNo(), plan.getCustomerId(), plan.getOverseasWarehouseId(),
read(plan.getReturnNosJson(), new TypeReference<>() { }), plan.getMode(), plan.getStatus(), responseBoxes, plan.getRemark(), plan.getPushedAt(),
sources.stream().map(SourceItem::cartonMark).distinct().toList(),
sources.stream().map(SourceItem::cartonMark).distinct().map(mark -> new ReturnInventoryResponses.PlanSource(mark,
sources.stream().filter(source -> source.cartonMark().equals(mark)).map(source -> new ReturnInventoryResponses.PlanItem(source.skuCode(), source.quantity())).toList())).toList(),
sources.stream().map(SourceItem::cartonMark).distinct().map(mark -> {
var lines = sources.stream().filter(source -> source.cartonMark().equals(mark)).toList();
var original = lines.get(0).originalItems();
long selected = lines.stream().mapToLong(SourceItem::quantity).sum();
String participation = original == null ? "UNKNOWN" : original.stream().mapToLong(ReturnInventoryResponses.CartonItem::quantity).sum() == selected ? "FULL" : "PARTIAL";
return new ReturnInventoryResponses.PlanSource(mark, lines.stream().map(source -> new ReturnInventoryResponses.PlanItem(source.skuCode(), source.quantity())).toList(),
lines.get(0).sourceReturnNo(), original == null ? List.of() : original, participation);
}).toList(),
plan.getCreatedAt(), "COMPLETED".equals(plan.getStatus())
? read(plan.getResultLabelsJson(), new TypeReference<List<ReturnInventoryResponses.Label>>() { }).stream()
.map(label -> cartons.findCustomerMark(plan.getCustomerId(), label.cartonMark()))
.filter(java.util.Objects::nonNull).map(this::cartonDetail).toList() : List.of(), plan.getCompletedAt());
.filter(java.util.Objects::nonNull).map(this::cartonDetail).toList() : List.of(), plan.getCompletedAt(),
"COMPLETED".equals(plan.getStatus()) ? read(plan.getResultLabelsJson(), new TypeReference<List<ReturnInventoryResponses.Label>>() { }) : List.of(),
packingTrace.planHistory(plan.getCustomerId(), plan.getPlanNo()), packingTrace.planOperator(plan.getPlanNo()));
}
private Map<String, Long> deductionQuantities(List<SourceItem> lines) {
Map<String, Long> quantities = new LinkedHashMap<>();
for (var line : lines) quantities.merge(line.cartonMark() + "\u0000" + line.skuCode(), line.quantity(), Math::addExact);
return quantities;
}
private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception error) { throw new IllegalStateException("合箱计划序列化失败", error); } }
private <T> T read(String value, TypeReference<T> type) { try { return objectMapper.readValue(value == null ? "[]" : value, type); } catch (Exception error) { throw new IllegalStateException("合箱计划数据损坏", error); } }
@@ -1044,7 +1088,10 @@ public class ReturnInventoryService {
consumedBatchNos.add(required(source.getSourceBatchNo(), "来源签收批次缺失,无法合箱"));
consumedReturnNos.addAll(java.util.Arrays.asList(required(source.getSourceReturnNo(), "来源退货单缺失,无法合箱").split(",")));
long toTrace = take;
for (var origin : ownership(source, sku, value(item.getQuantity()))) {
var origins = ownership(source, sku, value(item.getQuantity()));
if (origins.size() > 1 && take < value(item.getQuantity()))
throw conflict("跨来源单同 SKU 部分移箱须先现场分拣确认,不能自动猜测来源归属");
for (var origin : origins) {
long traced = Math.min(toTrace, origin.quantity());
if (traced > 0) transfers.add(new TraceTransfer(source.getCartonMark(), origin.returnNo(), sku, traced));
toTrace -= traced;
@@ -321,7 +321,8 @@ public class ReturnReceiptService {
.map(file -> new ReturnOrderResponses.Photo(file.fileId(), file.fileName(), file.contentType(), file.sizeBytes())).toList();
return new ReturnOrderResponses.Detail(summary(order, relabelSubOrders(List.of(order)).getOrDefault(order.getLastlegOrderId(), List.of())), order.getRemark(), items, receiptViews, palletViews, photos,
inventory.packingHistory(order.getCustomerId(), order.getReturnNo()),
orderReceipts.stream().flatMap(receipt -> inventory.receiptContents(receipt).stream()).toList());
orderReceipts.stream().flatMap(receipt -> inventory.receiptContents(receipt).stream()).toList(),
inventory.currentReturnCartons(order.getCustomerId(), order.getOverseasWarehouseId(), order.getReturnNo()));
}
private String combineLabels(List<ReturnOrderResponses.CartonLabel> labels) {
@@ -484,6 +485,8 @@ public class ReturnReceiptService {
skuQuantity -= allocatedBySourceSku.getOrDefault(sourceSku, 0L);
if (skuQuantity <= 0) continue;
long allocated = Math.min(remaining, skuQuantity);
if (text(carton.getSourceReturnNo()) != null && carton.getSourceReturnNo().contains(","))
throw conflict("跨来源单合箱请按当前实物箱号申请贴标,不能按单据分配混箱中的 SKU");
allocatedBySourceSku.merge(sourceSku, allocated, Math::addExact);
ReturnCartonCountGuard.requireConfirmed(carton);
RelabelItem item = new RelabelItem(); item.setId(IdWorker.getId()); item.setApplicationId(appId); item.setCustomerId(customerId);