贴标申请,仓储转运

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
+2
View File
@@ -145,3 +145,5 @@ node --test scripts/admin-test-env-modules/initialization-baseline-contract.test
本次整理使用隔离 MySQL 8.4,国内与海外分别在不存在对方区域 schema 的空库执行两轮,逐表比对整理前最终字段、默认值、索引、约束、注释及业务触发器。SHOW CREATE 的冗余字符集显示与运行态自增计数不视为结构差异;移除示例种子和强制改密是明确行为变更。这不代表已部署或完成真实 HTTP 验收。
码头车队邮件配置见 [Firstleg 车队邮件](../../firstleg/docs/terminal-drayage-mail.md)。
`migrations/overseas/baiyue_lastleg/V24__cargo_discard_photos.sql` 仅新增废弃照片关联表,按客户、申请和托盘关联文件及提交时间。与 Lastleg、File 和三端照片功能共同发布;不回填历史废弃单,不修改库存。脚本存在不表示目标环境已执行。
@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS ll_cargo_discard_photo (
id BIGINT NOT NULL,
customer_id VARCHAR(64) NOT NULL,
application_id BIGINT NOT NULL,
pallet_no VARCHAR(128) NOT NULL,
file_id BIGINT NOT NULL,
file_name VARCHAR(255) NOT NULL,
content_type VARCHAR(128) NOT NULL,
size_bytes BIGINT NOT NULL,
operator_id VARCHAR(64) NOT NULL,
created_at DATETIME(6) NOT NULL,
submitted_at DATETIME(6) NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_cargo_discard_photo_file (file_id),
KEY idx_cargo_discard_photo_application (customer_id, application_id, pallet_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -4303,3 +4303,21 @@ CREATE TABLE ll_return_sku_conversion (
KEY idx_return_sku_conversion_carton(customer_id,carton_id),
CONSTRAINT chk_return_sku_conversion_quantity CHECK(quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货实物SKU换标库存转换和冲回记录';
-- PDA cargo discard photo evidence
CREATE TABLE IF NOT EXISTS ll_cargo_discard_photo (
id BIGINT NOT NULL,
customer_id VARCHAR(64) NOT NULL,
application_id BIGINT NOT NULL,
pallet_no VARCHAR(128) NOT NULL,
file_id BIGINT NOT NULL,
file_name VARCHAR(255) NOT NULL,
content_type VARCHAR(128) NOT NULL,
size_bytes BIGINT NOT NULL,
operator_id VARCHAR(64) NOT NULL,
created_at DATETIME(6) NOT NULL,
submitted_at DATETIME(6) NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_cargo_discard_photo_file (file_id),
KEY idx_cargo_discard_photo_application (customer_id, application_id, pallet_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -31,6 +31,7 @@ enum BusinessFileCategory {
CONTAINER_DELIVERY_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
CONTAINER_PICKUP_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_UNLOAD_PHOTO(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_CARGO_DISCARD_PHOTO(true, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_RELABEL_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_TRANSFER_MEASUREMENT_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_RELABEL_TOTAL_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
@@ -0,0 +1,12 @@
openapi: 3.0.3
info:
title: Cargo discard photo storage ownership contract
version: 1.0.0
description: >-
Uses the existing internal business-file upload, metadata and content endpoints, protected
by X-Internal-Service-Token. businessType is OVERSEAS_CARGO_DISCARD_PHOTO; tenantId is a
positive customer ID and businessId is the positive cargo discard application ID.
File enforces composite ownership and non-empty JPG/PNG bytes with image signature and
10 MiB limit. Lastleg owns the pallet binding, submitted visibility and immutable confirmation.
No anonymous or public file endpoint is introduced.
paths: {}
@@ -0,0 +1,25 @@
package com.baiyue.file.service;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.*;
class CargoDiscardPhotoStorageTest {
@TempDir Path root;
@Test void discardEvidenceIsCustomerOwnedAndRequiresRealImageSignature() throws Exception {
var category = BusinessFileCategory.require("OVERSEAS_CARGO_DISCARD_PHOTO");
category.validateOwner(7);
assertThatThrownBy(() -> category.validateOwner(0)).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
assertThatThrownBy(() -> category.storageBusinessId("0")).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
var storage = new BusinessFileStorage(); ReflectionTestUtils.setField(storage, "configuredRoot", root); storage.initializeRoot();
byte[] bytes = {(byte)0x89, 0x50, 0x4e, 0x47, 13, 10, 26, 10};
var result = storage.store(7, "11", new MockMultipartFile("file", "discard.png", "image/png", bytes), category);
assertThat(storage.read(result.objectKey())).isEqualTo(bytes);
assertThatThrownBy(() -> storage.store(7, "11", new MockMultipartFile("file", "fake.png", "image/png", "fake".getBytes()), category)).hasMessageContaining("不是有效图片");
assertThatThrownBy(() -> storage.store(7, "11", new MockMultipartFile("file", "doc.pdf", "application/pdf", "%PDF-test".getBytes()), category)).hasMessageContaining("仅支持JPG");
assertThatThrownBy(() -> storage.store(7, "11", new MockMultipartFile("file", "large.png", "image/png", new byte[10*1024*1024+1]), category)).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
}
}
@@ -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);
@@ -3947,3 +3947,21 @@ CREATE TABLE ll_selfop_sku_profile (
UNIQUE KEY uk_selfop_profile_version (profile_run_id,source_account,customer_code,warehouse_code,sku_code),
KEY idx_selfop_profile_evidence (profile_run_id,earliest_evidence_at_utc,customer_code,sku_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='随发布版本固定的 SKU 维度与观察依据';
-- PDA cargo discard photo evidence
CREATE TABLE IF NOT EXISTS ll_cargo_discard_photo (
id BIGINT NOT NULL,
customer_id VARCHAR(64) NOT NULL,
application_id BIGINT NOT NULL,
pallet_no VARCHAR(128) NOT NULL,
file_id BIGINT NOT NULL,
file_name VARCHAR(255) NOT NULL,
content_type VARCHAR(128) NOT NULL,
size_bytes BIGINT NOT NULL,
operator_id VARCHAR(64) NOT NULL,
created_at DATETIME(6) NOT NULL,
submitted_at DATETIME(6) NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_cargo_discard_photo_file (file_id),
KEY idx_cargo_discard_photo_application (customer_id, application_id, pallet_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,121 @@
openapi: 3.0.3
info:
title: Cargo discard PDA photo evidence
version: 1.0.0
description: >-
Each approved pallet requires 1–20 JPG/PNG photos before actual disposal confirmation.
Uploads persist as drafts and recover through the PDA list endpoint. Confirmation binds
selected photos in the same transaction as inventory deduction. Completed quantities and
photo sets are immutable; identical replay does not deduct stock or create evidence again.
Platform and merchant details expose only submitted photos, including photos of completed
pallets while other pallets are still pending. Historic applications are not backfilled.
security:
- bearerAuth: []
paths:
/api/pda/v1/last-leg/cargo-discards/{discardNo}/pallets/{palletNo}/photos/files:
parameters:
- $ref: '#/components/parameters/DiscardNo'
- $ref: '#/components/parameters/PalletNo'
post:
summary: Upload an approved, unfinished pallet photo (PDA bound warehouse only)
x-permissions: [lastleg:pda:operate, ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR]
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file: { type: string, format: binary, description: Non-empty JPG/PNG up to 10 MiB with image signature validation }
responses:
'200': { description: ApiResponse with uploaded Photo metadata; business errors use body code }
get:
summary: Recover all uploaded photos for this application and pallet
x-permissions: [lastleg:pda:operate, ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR]
responses:
'200': { description: ApiResponse with a Photo array, including unsubmitted uploads }
/api/pda/v1/last-leg/cargo-discards/{discardNo}/pallets/{palletNo}/photos/files/{fileId}/download:
parameters:
- $ref: '#/components/parameters/DiscardNo'
- $ref: '#/components/parameters/PalletNo'
- $ref: '#/components/parameters/FileId'
get:
summary: Download a photo associated with this pallet in the PDA bound warehouse
x-permissions: [lastleg:pda:operate, ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR]
responses:
'200': { description: Inline JPG/PNG bytes with Cache-Control no-store }
/api/pda/v1/last-leg/cargo-discards/{discardNo}/pallets/{palletNo}/confirm:
parameters:
- $ref: '#/components/parameters/DiscardNo'
- $ref: '#/components/parameters/PalletNo'
post:
summary: Confirm actual disposal with owned pallet photos
x-permissions: [lastleg:pda:operate, ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [lines, photoFileIds]
properties:
lines:
type: array
minItems: 1
items:
type: object
required: [itemId, actualPackages]
properties:
itemId: { type: string, pattern: '^[1-9][0-9]*$' }
actualPackages: { type: integer, minimum: 0 }
photoFileIds:
type: array
minItems: 1
maxItems: 20
uniqueItems: true
items: { type: string, pattern: '^[1-9][0-9]*$' }
responses:
'200': { description: ApiResponse containing the completed PalletTask; conflicts use business body code }
/api/platform/v1/last-leg/cargo-discards/{discardNo}/photos/files/{fileId}/download:
parameters:
- $ref: '#/components/parameters/DiscardNo'
- $ref: '#/components/parameters/FileId'
- name: customerId
in: query
required: true
schema: { type: string, pattern: '^[1-9][0-9]*$' }
get:
summary: Download a submitted photo after application/customer ownership validation
x-permissions: [platform:overseas-warehouse:discard:list]
responses:
'200': { description: Inline JPG/PNG bytes with Cache-Control no-store }
/api/merchant/v1/last-leg/cargo-discards/{discardNo}/photos/files/{fileId}/download:
parameters:
- $ref: '#/components/parameters/DiscardNo'
- $ref: '#/components/parameters/FileId'
get:
summary: Download a submitted photo belonging to the authenticated customer only
x-permissions: [merchant:overseas-warehouse:discard:list]
responses:
'200': { description: Inline JPG/PNG bytes with Cache-Control no-store; no customer override allowed }
components:
securitySchemes:
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
parameters:
DiscardNo: { name: discardNo, in: path, required: true, schema: { type: string } }
PalletNo: { name: palletNo, in: path, required: true, schema: { type: string } }
FileId: { name: fileId, in: path, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
schemas:
Photo:
type: object
required: [fileId, palletNo, fileName, contentType, sizeBytes, operatorId, createdAt]
properties:
fileId: { type: string }
palletNo: { type: string }
fileName: { type: string }
contentType: { type: string, enum: [image/jpeg, image/png] }
sizeBytes: { type: integer, format: int64, maximum: 10485760 }
operatorId: { type: string }
createdAt: { type: string, format: date-time }
submittedAt: { type: string, format: date-time, nullable: true }
@@ -8,7 +8,7 @@ paths:
/api/platform/v1/last-leg/return-packing-plans:
get:
summary: 合箱计划分页
description: return-inventory:list;page/size/status/planNo 查询,planNo 去除首尾空格后按字面包含匹配,统计与分页使用相同条件。每条计划返回 createdAt 和 completedAt(实际合箱完成时间);已完成计划 resultCartons 返回实际箱唛、SKU数量及当前库位、载体托盘。完成时间不使用箱子的 putawayAt,原箱多次合箱时仍显示本次计划完成时间。
description: return-inventory:list;page/size/status/planNo 查询,planNo 去除首尾空格后按字面包含匹配,统计与分页使用相同条件。每条计划返回 createdAt 和 completedAt(实际合箱完成时间);sources 返回来源箱唛、sourceReturnNo、创建时 originalItems、计划取出 items 和 participation(FULL/PARTIAL/UNKNOWN)。已完成计划 completionCartons 为不可变完成时标签及箱内数量快照,transfers 为来源单/来源箱/SKU/目标箱的本次流向,completedBy 为操作员名称;resultCartons 仅返回当前库存及库位,不替代历史结果。完成时间不使用箱子的 putawayAt,原箱多次合箱时仍显示本次计划完成时间。
parameters:
- { name: planNo, in: query, schema: { type: string, maxLength: 128 }, description: 合箱计划号,支持部分编号查询 }
- { name: customerId, in: query, schema: { type: string, maxLength: 64 }, description: 客户筛选 }
@@ -40,13 +40,13 @@ paths:
/api/pda/v1/last-leg/return-inventory/packing/plans/{planNo}:
get:
summary: 读取精确锁定的来源和数量
description: 绑定仓范围;sources含来源箱逐SKU锁定件数,boxes含汇总数量。
description: 绑定仓范围;sources含来源单、原箱内容快照、参与方式及逐SKU锁定件数,boxes含汇总数量。业务详情currentCartons显示当前实物箱及ownership逐来源单SKU贡献,整箱库存只计一次;packingDetails带planNo。跨来源单同SKU的部分移箱或部分贴标须先现场分拣确认,不自动猜测实物归属。
parameters: [{ name: planNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 业务响应信封 code为200成功,A0400/A0404/A0409校验或状态错误,requestId及traceId保留 }, '403': { description: 权限或角色不符 } }
/api/pda/v1/last-leg/return-inventory/packing/plans/{planNo}/complete:
post:
summary: 多箱分配并完成计划
description: SKU目标总量须等于锁定量。SKU仅支持新增目标箱;BOX整箱可扫描已有目标箱,部分扣除仅支持新增目标箱,sourceDeductions逐来源箱及SKU指定实际扣除量(不超过锁定量),未使用预占释放;目标原库存不扣减,新箱须填写箱规并生成系统箱唛。全事务、同载荷重放标签快照。
description: SKU目标总量须等于锁定量。SKU仅支持新增目标箱;BOX整箱可扫描已有目标箱,部分扣除仅支持新增目标箱,sourceDeductions逐来源箱及SKU必须与计划锁定量完全一致(作为目标的计划原箱除外);目标原库存不扣减,新箱须填写箱规并生成系统箱唛。全事务、同载荷重放标签快照。
parameters: [{ name: planNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/Complete' } } } }
responses: { '200': { description: 业务响应信封 code为200成功,A0400/A0404/A0409校验或状态错误,requestId及traceId保留 }, '403': { description: 权限或角色不符 } }
@@ -12,6 +12,7 @@ import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class MerchantCargoDiscardControllerTest {
private final CargoDiscardService service = mock(CargoDiscardService.class);
@@ -36,6 +37,17 @@ class MerchantCargoDiscardControllerTest {
() -> controller.page(user(0L, "PLATFORM"), query));
}
@Test void photoDownloadUsesTokenCustomerAndReturnsPrivateImageBytes() {
byte[] bytes = {1, 2, 3};
when(service.downloadPhoto("100", "DS001", 101L)).thenReturn(
new com.baiyue.lastleg.discard.gateway.CargoDiscardPhotoFileGateway.DownloadedFile(bytes, "discard.png", "image/png"));
var response = controller.downloadPhoto(user(100L, "MERCHANT"), "DS001", 101L);
org.junit.jupiter.api.Assertions.assertArrayEquals(bytes, response.getBody());
org.junit.jupiter.api.Assertions.assertEquals("no-store", response.getHeaders().getCacheControl());
verify(service).downloadPhoto("100", "DS001", 101L);
assertThrows(ResponseStatusException.class, () -> controller.downloadPhoto(user(0L, "PLATFORM"), "DS001", 101L));
}
private static AuthenticatedUser user(long tenantId, String appCode) {
return new AuthenticatedUser(1L, tenantId, tenantId, 9L, "merchant", appCode, "WEB", Set.of());
}
@@ -37,10 +37,12 @@ class PdaCargoDiscardServiceTest {
private CargoDiscardApplication application;
private CargoDiscardItem item;
private final PdaWarehouseIdentity identity = new PdaWarehouseIdentity("OP", "operator", 99L);
private PdaCargoDiscardDtos.Confirm request(int actual) { return new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, actual))); }
private final com.baiyue.lastleg.discard.service.CargoDiscardPhotoService photoService = mock(com.baiyue.lastleg.discard.service.CargoDiscardPhotoService.class);
private PdaCargoDiscardDtos.Confirm request(int actual) { return new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, actual)), List.of(101L)); }
@BeforeEach void setup() {
service = new PdaCargoDiscardService();
ReflectionTestUtils.setField(service, "photoService", photoService);
ReflectionTestUtils.setField(service, "applications", applications);
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "cartons", cartons);
@@ -93,8 +95,8 @@ class PdaCargoDiscardServiceTest {
void finishedQuantityCannotChange(String status) { finished(status); assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(0))); noWrites(); }
@Test void missingOrForeignLineAndWrongPalletCannotRecover() {
finished("PDA_COMPLETED");
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", new PdaCargoDiscardDtos.Confirm(List.of())));
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(999L, 1)))));
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", new PdaCargoDiscardDtos.Confirm(List.of(), List.of(101L))));
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(999L, 1)), List.of(101L))));
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "OTHER-PALLET", request(1))); noWrites();
}
@Test void foreignWarehouseHasNoApplication() {
@@ -116,7 +118,37 @@ class PdaCargoDiscardServiceTest {
when(items.listApplication("CUSTOMER", 1L)).thenReturn(List.of(item, pending)); when(profiles.selectById(23L)).thenReturn(other);
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(1))); noWrites();
}
@Test void duplicateLineCannotRecover() { finished("COMPLETED"); assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, 1), new PdaCargoDiscardDtos.ActualLine(11L, 1))))); noWrites(); }
@Test void duplicateLineCannotRecover() { finished("COMPLETED"); assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, 1), new PdaCargoDiscardDtos.ActualLine(11L, 1)), List.of(101L)))); noWrites(); }
@Test void loadedForeignCustomerItemCannotRecover() { finished("COMPLETED"); item.setCustomerId("OTHER"); assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(1))); noWrites(); }
@Test void loadedDifferentApplicationItemCannotRecover() { finished("COMPLETED"); item.setApplicationId(2L); assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(1))); noWrites(); }
@ParameterizedTest @ValueSource(ints = {3, 1, 0})
void threeLocalOrdersUploadConfirmAndReplayWithFullPartialAndZeroActualDisposal(int actual) {
var realPhotos = new com.baiyue.lastleg.discard.service.CargoDiscardPhotoService();
var photoMapper = mock(com.baiyue.lastleg.discard.mapper.CargoDiscardPhotoMapper.class);
var photoFiles = mock(com.baiyue.lastleg.discard.gateway.CargoDiscardPhotoFileGateway.class);
var stored = new java.util.ArrayList<com.baiyue.lastleg.discard.entity.CargoDiscardPhoto>();
ReflectionTestUtils.setField(realPhotos, "photos", photoMapper); ReflectionTestUtils.setField(realPhotos, "files", photoFiles);
ReflectionTestUtils.setField(service, "photoService", realPhotos);
when(photoMapper.listApplication("CUSTOMER", 1L)).thenAnswer(invocation -> stored);
when(photoMapper.insert(any(com.baiyue.lastleg.discard.entity.CargoDiscardPhoto.class))).thenAnswer(invocation -> { stored.add(invocation.getArgument(0)); return 1; });
when(photoFiles.upload(eq("CUSTOMER"), eq(1L), eq(9L), any())).thenReturn(new com.baiyue.lastleg.discard.gateway.CargoDiscardPhotoFileGateway.UploadedFile(101L, "discard.png", "image/png", 8L));
when(applications.readNoByWarehouse("CD-1", "99")).thenReturn(application);
item.setDiscardPackages(3);
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, actual)), List.of())));
verifyNoInteractions(adjustments);
service.uploadPhoto(identity, "CD-1", "PALLET-1", 9L, new org.springframework.mock.web.MockMultipartFile("file", new byte[]{1}));
assertEquals(1, service.photos(identity, "CD-1", "PALLET-1").size());
assertTrue(realPhotos.list(application, null, true).isEmpty());
service.confirm(identity, "CD-1", "PALLET-1", request(actual));
assertEquals(CargoDiscardService.PDA_COMPLETED, application.getStatus());
assertEquals(actual, application.getTotalPackages());
assertEquals(new BigDecimal("5.00").multiply(BigDecimal.valueOf(actual)), application.getTotalFee());
assertEquals(1, realPhotos.list(application, null, true).size());
assertNotNull(realPhotos.list(application, null, true).get(0).submittedAt());
service.confirm(identity, "CD-1", "PALLET-1", request(actual));
verify(adjustments, times(1)).discardPalletItem(eq("CUSTOMER"), same(application), same(item), anyList(), eq(actual), eq("OP"), any());
verify(photoMapper, times(1)).updateById(any(com.baiyue.lastleg.discard.entity.CargoDiscardPhoto.class));
assertThrows(ResponseStatusException.class, () -> service.uploadPhoto(identity, "CD-1", "PALLET-1", 9L, new org.springframework.mock.web.MockMultipartFile("file", new byte[]{1})));
}
}
@@ -0,0 +1,85 @@
package com.baiyue.lastleg.discard.service;
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 org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class CargoDiscardPhotoServiceTest {
private final CargoDiscardPhotoMapper photos = mock(CargoDiscardPhotoMapper.class);
private final CargoDiscardPhotoFileGateway files = mock(CargoDiscardPhotoFileGateway.class);
private final CargoDiscardPhotoService service = new CargoDiscardPhotoService();
private final CargoDiscardApplication application = new CargoDiscardApplication();
private final List<CargoDiscardPhoto> stored = new ArrayList<>();
@BeforeEach void setup() {
application.setId(1L); application.setCustomerId("7");
ReflectionTestUtils.setField(service, "photos", photos); ReflectionTestUtils.setField(service, "files", files);
when(photos.listApplication("7", 1L)).thenAnswer(invocation -> stored);
when(photos.insert(any(CargoDiscardPhoto.class))).thenAnswer(invocation -> { stored.add(invocation.getArgument(0)); return 1; });
when(files.upload(eq("7"), eq(1L), eq(9L), any())).thenReturn(new CargoDiscardPhotoFileGateway.UploadedFile(101L, "discard.png", "image/png", 8L));
}
private CargoDiscardPhoto photo(long fileId, String palletNo) {
var photo = new CargoDiscardPhoto(); photo.setFileId(fileId); photo.setPalletNo(palletNo);
photo.setCustomerId("7"); photo.setApplicationId(1L); stored.add(photo); return photo;
}
@Test void uploadsRestoreForPdaButOnlySubmittedPhotosAppearInBothDetails() {
var uploaded = service.upload(application, "PALLET-A", "operator", 9L, new MockMultipartFile("file", new byte[]{1}));
assertEquals(101L, uploaded.fileId()); assertEquals("PALLET-A", uploaded.palletNo());
assertEquals(1, service.list(application, "PALLET-A", false).size());
assertTrue(service.list(application, null, true).isEmpty());
assertThrows(ResponseStatusException.class, () -> service.download(application, null, 101L, true));
service.confirm(application, "PALLET-A", List.of(101L), false);
assertEquals(1, service.list(application, null, true).size());
assertNotNull(service.list(application, null, true).get(0).submittedAt());
service.download(application, null, 101L, true); verify(files).download("7", 1L, 101L);
service.confirm(application, "PALLET-A", List.of(101L), true);
verify(photos, times(1)).updateById(any(CargoDiscardPhoto.class));
}
@Test void noPhotosDuplicatesAndCrossPalletOrApplicationReferencesCannotSubmit() {
photo(101L, "PALLET-A"); photo(102L, "PALLET-B");
for (var ids : List.of(List.<Long>of(), List.of(101L, 101L), List.of(102L), List.of(999L)))
assertThrows(ResponseStatusException.class, () -> service.confirm(application, "PALLET-A", ids, false));
assertThrows(ResponseStatusException.class, () -> service.download(application, "PALLET-A", 102L, false));
verify(files, never()).validate(anyString(), anyLong(), anyLong());
verify(photos, never()).updateById(any(CargoDiscardPhoto.class));
}
@Test void ownershipValidationFailureDoesNotBindAnyPhoto() {
photo(101L, "PALLET-A"); photo(102L, "PALLET-A");
doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST)).when(files).validate("7", 1L, 102L);
assertThrows(ResponseStatusException.class, () -> service.confirm(application, "PALLET-A", List.of(101L, 102L), false));
verify(photos, never()).updateById(any(CargoDiscardPhoto.class));
assertTrue(stored.stream().allMatch(p -> p.getSubmittedAt() == null));
}
@Test void completedEvidenceCannotChangeAndReplayDoesNotWrite() {
photo(101L, "PALLET-A").setSubmittedAt(LocalDateTime.now()); photo(102L, "PALLET-A");
service.confirm(application, "PALLET-A", List.of(101L), true);
assertThrows(ResponseStatusException.class, () -> service.confirm(application, "PALLET-A", List.of(102L), true));
assertThrows(ResponseStatusException.class, () -> service.confirm(application, "PALLET-A", List.of(101L, 102L), true));
verifyNoInteractions(files); verify(photos, never()).updateById(any(CargoDiscardPhoto.class));
}
@Test void uploadLimitIsPerPalletAndStopsBeforeFileStorage() {
for (long id=1; id<=20; id++) photo(id, "PALLET-A");
assertThrows(ResponseStatusException.class, () -> service.upload(application, "PALLET-A", "operator", 9L, new MockMultipartFile("file", new byte[]{1})));
verify(files, never()).upload(anyString(), anyLong(), anyLong(), any());
assertEquals(101L, service.upload(application, "PALLET-B", "operator", 9L, new MockMultipartFile("file", new byte[]{1})).fileId());
}
}
@@ -67,6 +67,7 @@ class CargoDiscardServiceTest {
@BeforeEach
void setUp() {
service = new CargoDiscardService();
ReflectionTestUtils.setField(service, "photoService", org.mockito.Mockito.mock(CargoDiscardPhotoService.class));
ReflectionTestUtils.setField(service, "applications", applications);
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "discardCartons", discardCartons);
@@ -536,11 +536,15 @@ class ReturnInventoryServiceTest {
when(cartonItems.lockCarton(31L)).thenReturn(List.of(line));
when(cartonItems.lockSku(31L, "SKU-A")).thenReturn(line);
when(balances.reserveAvailable("10001", "42", "SKU-A", 2)).thenReturn(1);
when(balances.releaseAvailable("10001", "42", "SKU-A", 2)).thenReturn(1);
when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("PLAN-1");
var request = new ReturnInventoryRequests.PackingPlanCreate(List.of("RT-1"), "SKU",
List.of(new ReturnInventoryRequests.PlanBox(List.of(new ReturnInventoryRequests.PlanItem("SKU-A", 2)), null, null, null, null)), null, "plan-key");
var created = service.createPackingPlan("platform", request);
assertThat(created.status()).isEqualTo("PENDING_REVIEW"); assertThat(line.getPackingReservedQuantity()).isEqualTo(2);
assertThat(created.sources().get(0).sourceReturnNo()).isEqualTo("RT-1");
assertThat(created.sources().get(0).participation()).isEqualTo("PARTIAL");
assertThat(created.sources().get(0).originalItems().get(0).quantity()).isEqualTo(5);
assertThat(source.getStatus()).isEqualTo("PACKING_RESERVED");
var captor = ArgumentCaptor.forClass(com.baiyue.lastleg.returnreceipt.entity.ReturnPackingPlan.class);
verify(packingPlans).insert(captor.capture());
@@ -548,7 +552,36 @@ class ReturnInventoryServiceTest {
assertThat(service.cancelPackingPlan("PLAN-1").status()).isEqualTo("CANCELLED");
assertThat(line.getPackingReservedQuantity()).isEqualTo(0);
assertThat(source.getStatus()).isEqualTo("AVAILABLE");
verify(balances).adjust(anyLong(), org.mockito.ArgumentMatchers.eq("10001"), org.mockito.ArgumentMatchers.eq("42"), org.mockito.ArgumentMatchers.eq("SKU-A"), org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.eq(0L), org.mockito.ArgumentMatchers.eq(2L), org.mockito.ArgumentMatchers.eq(0L));
verify(balances).releaseAvailable("10001", "42", "SKU-A", 2);
}
@Test
void completedPlanKeepsResultSnapshotWhenLiveCartonHasBeenConsumed() {
var plan = new com.baiyue.lastleg.returnreceipt.entity.ReturnPackingPlan();
plan.setPlanNo("PLAN-SNAPSHOT"); plan.setCustomerId("10001"); plan.setOverseasWarehouseId("42");
plan.setStatus("COMPLETED"); plan.setMode("SKU"); plan.setReturnNosJson("[\"RT-1\",\"RT-2\"]");
plan.setSourceItemsJson("[]"); plan.setBoxesJson("[]");
plan.setResultLabelsJson("[{\"cartonMark\":\"RESULT\",\"customerId\":\"10001\",\"overseasWarehouseId\":\"42\",\"totalQuantity\":15,\"items\":[{\"skuCode\":\"SKU-A\",\"quantity\":15}]}]");
when(packingPlans.findNo("PLAN-SNAPSHOT")).thenReturn(plan);
var result = service.packingPlan(new PdaWarehouseIdentity("worker", "验收员", 42), "PLAN-SNAPSHOT");
assertThat(result.resultCartons()).isEmpty();
assertThat(result.completionCartons()).hasSize(1);
assertThat(result.completionCartons().get(0).totalQuantity()).isEqualTo(15);
}
@Test
void boxPlanRejectsPartialSourceDeductionsEvenWhenTargetIsNew() {
var plan = new com.baiyue.lastleg.returnreceipt.entity.ReturnPackingPlan();
plan.setPlanNo("PLAN-FULL"); plan.setCustomerId("10001"); plan.setOverseasWarehouseId("42");
plan.setStatus("READY"); plan.setMode("BOX");
plan.setSourceItemsJson("[{\"cartonMark\":\"A\",\"skuCode\":\"SKU-A\",\"quantity\":10}]");
when(packingPlans.lockNo("PLAN-FULL")).thenReturn(plan);
var request = new ReturnInventoryRequests.PackingPlanComplete(
List.of(new ReturnInventoryRequests.PlanBox(List.of(new ReturnInventoryRequests.PlanItem("SKU-A", 5)), BigDecimal.TEN, BigDecimal.TEN, BigDecimal.TEN, BigDecimal.ONE)),
"full-key", List.of(new ReturnInventoryRequests.SourceDeduction("A", "SKU-A", 5)));
var error = assertThrows(ResponseStatusException.class, () -> service.completePackingPlan(new PdaWarehouseIdentity("worker", "worker", 42), "PLAN-FULL", request));
assertThat(error.getReason()).contains("完全一致");
verifyNoInteractions(balances, cartonItems);
}
@Test
@@ -0,0 +1,45 @@
package com.baiyue.lastleg.returnreceipt.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryBalanceMapper;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
class ReturnPackingBalancePersistenceTest {
@Test
void releasingPlanReservationPreservesBalanceConstraintsAndRejectsExcessOrMissingStock() throws Exception {
var ds = new DriverManagerDataSource("jdbc:h2:mem:packing-balance-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
var jdbc = new JdbcTemplate(ds);
jdbc.execute("CREATE TABLE ll_return_inventory_balance (id BIGINT PRIMARY KEY, customer_id VARCHAR(128), " +
"overseas_warehouse_id VARCHAR(128), sku_code VARCHAR(128), product_name_zh VARCHAR(255), " +
"total_quantity BIGINT NOT NULL, available_quantity BIGINT NOT NULL, carton_quantity BIGINT NOT NULL, " +
"created_at TIMESTAMP, updated_at TIMESTAMP, " +
"CONSTRAINT chk_ll_return_inventory_balance_quantity CHECK(total_quantity>=available_quantity AND available_quantity>=0 AND carton_quantity>=0))");
jdbc.update("INSERT INTO ll_return_inventory_balance(id,customer_id,overseas_warehouse_id,sku_code,total_quantity,available_quantity,carton_quantity) VALUES(1,'C','W','SKU',4,4,1)");
var config = new MybatisConfiguration();
config.addMapper(ReturnInventoryBalanceMapper.class);
var factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(ds); factory.setConfiguration(config);
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(ReturnInventoryBalanceMapper.class);
assertEquals(1, mapper.reserveAvailable("C", "W", "SKU", 4));
assertEquals(1, mapper.releaseAvailable("C", "W", "SKU", 4));
assertEquals(4L, jdbc.queryForObject("SELECT available_quantity FROM ll_return_inventory_balance", Long.class));
assertEquals(4L, jdbc.queryForObject("SELECT total_quantity FROM ll_return_inventory_balance", Long.class));
assertEquals(1L, jdbc.queryForObject("SELECT carton_quantity FROM ll_return_inventory_balance", Long.class));
assertEquals(0, mapper.releaseAvailable("C", "W", "SKU", 1));
assertEquals(0, mapper.releaseAvailable("OTHER", "W", "SKU", 1));
assertEquals(0, mapper.releaseAvailable("C", "OTHER", "SKU", 1));
assertEquals(0, mapper.releaseAvailable("C", "W", "MISSING", 1));
assertEquals(1, mapper.reserveAvailable("C", "W", "SKU", 3));
assertEquals(0, mapper.releaseAvailable("C", "W", "SKU", 4));
assertEquals(0, mapper.releaseAvailable("C", "W", "SKU", -1));
assertEquals(1, mapper.releaseAvailable("C", "W", "SKU", 3));
assertEquals(1L, jdbc.queryForObject("SELECT COUNT(*) FROM ll_return_inventory_balance", Long.class));
}
}