贴标申请,仓储转运
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
+3
@@ -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; }
|
||||
|
||||
+10
@@ -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,
|
||||
|
||||
+10
@@ -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,
|
||||
|
||||
+4
-1
@@ -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) {}
|
||||
}
|
||||
|
||||
+22
@@ -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;
|
||||
}
|
||||
+12
@@ -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) { }
|
||||
}
|
||||
+135
@@ -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) { }
|
||||
}
|
||||
+3
@@ -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);
|
||||
}
|
||||
|
||||
+14
@@ -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);
|
||||
}
|
||||
+21
@@ -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) {
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+33
@@ -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()))
|
||||
|
||||
+75
@@ -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); }
|
||||
}
|
||||
+8
-1
@@ -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
|
||||
|
||||
+5
@@ -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()
|
||||
|
||||
+9
-3
@@ -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) { }
|
||||
|
||||
+6
-2
@@ -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,
|
||||
|
||||
+6
@@ -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);
|
||||
|
||||
+2
@@ -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")
|
||||
|
||||
+9
@@ -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);
|
||||
}
|
||||
|
||||
+62
-15
@@ -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;
|
||||
|
||||
+4
-1
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
+36
-4
@@ -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})));
|
||||
}
|
||||
}
|
||||
|
||||
+85
@@ -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());
|
||||
}
|
||||
}
|
||||
+1
@@ -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);
|
||||
|
||||
+34
-1
@@ -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
|
||||
|
||||
+45
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user