Test #1

Merged
admin merged 1726 commits from test into main 2026-09-17 11:52:45 +08:00
123 changed files with 8194 additions and 205 deletions
Showing only changes of commit c68eedd3f3 - Show all commits
+42 -6
View File
@@ -1,12 +1,12 @@
# 测试环境 SQL 执行说明
## 唯一数据库发布入口
## 唯一数据库初始化与发布入口
- 固定入口:`头程数据库统一增量-MySQL8.sql`。
- 该脚本汇总一期、二期当前全部数据库基线、存量升级、IAM 权限资源和最终结构校验,适用于 MySQL 8.0。
- 后续数据库变更只允许在该文件末尾增加带日期和业务说明的幂等增量区块,不再创建新的部署入口 SQL。
- 固定入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境兼容升级,不再按日期拆分发布脚本。
- 该脚本汇总一期、二期及已实现三期出仓能力的数据库基线、存量升级、IAM 权限资源和最终结构校验,适用于 MySQL 8.0。
- 正式上线前的数据库变更直接维护该文件中的当前基线、存量兼容处理和最终校验,不再创建按日期拆分的发布 SQL;正式上线后的迁移策略在生产发布规范中另行冻结。
- 模块 `src/main/resources/db/` 下的 SQL 用于开发溯源;`deploy/sql/` 下已有日期文件全部冻结为历史记录,不再单独部署,也不得与固定入口重复执行。
- 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`,不会写入固定密码账号、演示仓库、演示快递公司或演示订单。
- 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`,不会写入固定密码账号、演示仓库、演示快递公司或演示订单;正式平台公共仓库主数据已内联到固定入口。
- `warehouse/phase2-pallet-location-schema-migration.sql` 的有效结构已被当前 `phase2-pallet-location-schema.sql` 和后续共享溢出位迁移完整覆盖,因此不重复内联,避免每次部署无条件删除并重建同名唯一索引。
- 脚本会创建 8 个独立数据库:`baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`。Gateway、Print、Integration Service 无数据库。
- 完整执行所有增量区块并通过末段真实结构校验后,脚本最末才会返回唯一的 `PASS`;任一强校验失败会通过 `SIGNAL` 终止。
@@ -46,7 +46,43 @@
在 Navicat、DataGrip 或 MySQL 命令行中以“运行脚本”方式完整执行,确保客户端支持 `DELIMITER`。不要只选中局部语句执行。
执行结束后必须看到 `migration_status = PASS`,并确认消息为“一期与二期统一增量执行完成,关键结构与二期权限校验通过”。如果中途出现 `SQLSTATE 45000`,必须按错误中给出的缺失表、字段或权限处理后重新完整执行。
执行结束后必须看到 `migration_status = PASS`。如果中途出现 `SQLSTATE 45000`,必须按错误中给出的缺失表、字段或权限处理后重新完整执行。
## 三期分柜、预装、拣货与装柜当前基线
- 模块表结构来源:`firstleg/src/main/resources/db/phase3-split-preload-schema.sql`、`firstleg/src/main/resources/db/phase3-loading-delivery-schema.sql`。
- 三期平台权限直接维护在统一初始化与发布入口中,避免额外增加独立权限脚本。
- 测试和生产环境仍只执行固定入口 `头程数据库统一增量-MySQL8.sql`,不得再串行执行上述模块脚本。
- 必须先执行统一增量并取得 `PASS`,再启动或重启 Firstleg Service;否则统一订单摘要查询会因缺少三期操作表而失败。
- 统一脚本会创建 `fl_preload_plan`、`fl_split_operation`、`fl_loading_evidence`、`fl_loading_operation`、`fl_loading_pick_task`,并补齐 9 项平台操作权限。新增权限默认只授予平台系统管理员,其他角色由平台权限管理按职责配置。
部署前可只读盘点:
```sql
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'baiyue_firstleg'
AND table_name IN (
'fl_preload_plan', 'fl_split_operation', 'fl_loading_evidence',
'fl_loading_operation', 'fl_loading_pick_task'
)
ORDER BY table_name;
SELECT permission_code
FROM baiyue_iam.iam_permission
WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND status = 'ACTIVE' AND deleted = 0
AND permission_code IN (
'firstleg:inbound:split', 'firstleg:inbound:preload',
'firstleg:inbound:preload-view',
'platform:firstleg:inbound:loading:edit',
'platform:firstleg:inbound:loading:view',
'platform:firstleg:inbound:loading:complete',
'platform:firstleg:inbound:delivery:complete',
'platform:firstleg:inbound:picking:edit',
'platform:firstleg:inbound:picking:view'
)
ORDER BY permission_code;
```
## 公共导出中心存量升级
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,51 @@
package com.baiyue.file.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.service.LoadingEvidenceService;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
/** Firstleg 使用的装柜证据文件内部接口。 */
@RestController
@RequestMapping("/internal/file/v1/loading-evidence")
public class InternalLoadingEvidenceController {
@Resource private LoadingEvidenceService service;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
private byte[] expectedToken;
@PostConstruct void initializeToken() { expectedToken = internalToken.getBytes(StandardCharsets.UTF_8); }
@PostMapping(value = "/{tenantId}/{inboundId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<LoadingEvidenceService.UploadResult> upload(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader("X-Operator-Id") long operatorId, @PathVariable long tenantId, @PathVariable long inboundId,
@RequestParam String evidenceType, @RequestPart("file") MultipartFile file) {
requireToken(token);
if (tenantId <= 0 || inboundId <= 0 || operatorId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单和操作主体ID必须大于0");
return ApiResponse.success(service.upload(tenantId, inboundId, operatorId, evidenceType, file));
}
@GetMapping("/{tenantId}/{inboundId}/{fileId}")
public ApiResponse<LoadingEvidenceService.FileMetadata> validate(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long tenantId, @PathVariable long inboundId, @PathVariable long fileId) {
requireToken(token);
if (tenantId <= 0 || inboundId <= 0 || fileId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单和文件ID必须大于0");
return ApiResponse.success(service.validate(tenantId, inboundId, fileId));
}
private void requireToken(String token) {
if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
}
}
}
@@ -0,0 +1,54 @@
package com.baiyue.file.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.file.service.LoadingEvidenceService;
import jakarta.annotation.Resource;
import org.springframework.http.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.nio.charset.StandardCharsets;
/** 平台及作业端装柜证据上传和读取。 */
@RestController
@RequestMapping("/api/platform/v1/files/loading-evidence")
public class PlatformLoadingEvidenceController {
@Resource private LoadingEvidenceService service;
@PostMapping(value = "/{tenantId}/{inboundId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:edit')")
public ApiResponse<LoadingEvidenceService.UploadResult> upload(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId, @PathVariable long inboundId,
@RequestParam String evidenceType, @RequestPart("file") MultipartFile file) {
long operatorId = requirePlatform(user); requirePositive(tenantId, inboundId);
return ApiResponse.success(service.upload(tenantId, inboundId, operatorId, evidenceType, file));
}
@GetMapping("/{tenantId}/{inboundId}/{fileId}")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:view')")
public ResponseEntity<byte[]> download(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long tenantId, @PathVariable long inboundId, @PathVariable long fileId) {
requirePlatform(user); requirePositive(tenantId, inboundId, fileId);
var value = service.download(tenantId, inboundId, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(value.contentType())).contentLength(value.content().length)
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline()
.filename(value.fileName(), StandardCharsets.UTF_8).build().toString()).body(value.content());
}
private long requirePlatform(AuthenticatedUser user) {
if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode()) || user.tenantId() == null
|| user.tenantId() != 0L || user.userId() == null || user.userId() <= 0) {
throw new BizException(ResultCode.FORBIDDEN, "需要平台端身份");
}
return user.userId();
}
private static void requirePositive(long... ids) {
for (long id : ids) if (id <= 0) throw new BizException(ResultCode.BAD_REQUEST, "业务ID必须大于0");
}
}
@@ -0,0 +1,74 @@
package com.baiyue.file.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.entity.FileObjectEntity;
import com.baiyue.file.mapper.FileObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime;
/** 装柜四步照片和铅封证据的受控文件入口。 */
@Service
public class LoadingEvidenceService {
@Resource private MeasurementImageStorage storage;
@Resource private FileObjectMapper files;
@Transactional
public UploadResult upload(long tenantId, long inboundId, long operatorId, String evidenceType,
MultipartFile file) {
validateType(evidenceType);
var stored = storage.storeLoading(tenantId, inboundId, file);
FileObjectEntity object = new FileObjectEntity();
object.setTenantId(tenantId);
object.setBusinessType("LOADING_" + evidenceType);
object.setBusinessId(Long.toString(inboundId));
object.setOriginalName(stored.originalName());
object.setObjectKey(stored.objectKey());
object.setStorageProvider("LOCAL");
object.setBucketName("loading-evidence");
object.setContentType(stored.contentType());
object.setSizeBytes(stored.size());
object.setSha256(stored.sha256());
object.setStatus("ACTIVE");
object.setCreatedBy(operatorId); object.setUpdatedBy(operatorId);
object.setCreatedAt(LocalDateTime.now()); object.setUpdatedAt(LocalDateTime.now());
object.setDeleted(false); object.setVersion(0);
files.insert(object);
return new UploadResult(object.getId(), object.getOriginalName(), object.getContentType(), object.getSizeBytes(), object.getBusinessType());
}
public FileMetadata validate(long tenantId, long inboundId, long fileId) {
FileObjectEntity object = requireObject(tenantId, inboundId, fileId);
return new FileMetadata(object.getId(), object.getOriginalName(), object.getBusinessType(), object.getContentType());
}
public Download download(long tenantId, long inboundId, long fileId) {
FileObjectEntity object = requireObject(tenantId, inboundId, fileId);
return new Download(object.getOriginalName(), object.getContentType(), storage.read(object.getObjectKey()));
}
private FileObjectEntity requireObject(long tenantId, long inboundId, long fileId) {
FileObjectEntity object = files.selectOne(new LambdaQueryWrapper<FileObjectEntity>()
.eq(FileObjectEntity::getTenantId, tenantId).eq(FileObjectEntity::getId, fileId)
.eq(FileObjectEntity::getBusinessId, Long.toString(inboundId))
.likeRight(FileObjectEntity::getBusinessType, "LOADING_")
.eq(FileObjectEntity::getDeleted, false));
if (object == null) throw new BizException(ResultCode.NOT_FOUND, "装柜证据文件不存在");
return object;
}
private static void validateType(String type) {
if (type == null || !(type.matches("PHOTO_STEP_[1-4]") || "SEAL".equals(type))) {
throw new BizException(ResultCode.BAD_REQUEST, "不支持的装柜证据类型");
}
}
public record UploadResult(Long fileId, String fileName, String contentType, Long sizeBytes, String businessType) {}
public record FileMetadata(Long fileId, String fileName, String businessType, String contentType) {}
public record Download(String fileName, String contentType, byte[] content) {}
}
@@ -30,13 +30,21 @@ public class MeasurementImageStorage {
void initializeRoot() { root = configuredRoot.toAbsolutePath().normalize(); }
public Stored store(long tenantId, long inboundId, MultipartFile file) {
return store(tenantId, inboundId, file, "measurements");
}
public Stored storeLoading(long tenantId, long inboundId, MultipartFile file) {
return store(tenantId, inboundId, file, "loading-evidence");
}
private Stored store(long tenantId, long inboundId, MultipartFile file, String directory) {
if (file == null || file.isEmpty()) throw bad("称重图片不能为空");
if (file.getSize() > MAX_SIZE) throw bad("称重图片不能超过5MB");
String type = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT);
if (!TYPES.contains(type)) throw bad("称重图片仅支持JPG、PNG、BMP");
verifyImage(file);
String extension = "image/png".equals(type) ? ".png" : ("image/bmp".equals(type) ? ".bmp" : ".jpg");
String objectKey = "measurements/" + tenantId + "/" + inboundId + "/" + UUID.randomUUID() + extension;
String objectKey = directory + "/" + tenantId + "/" + inboundId + "/" + UUID.randomUUID() + extension;
Path target = root.resolve(objectKey).normalize();
if (!target.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界");
try {
@@ -0,0 +1,51 @@
package com.baiyue.file.service;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.entity.FileObjectEntity;
import com.baiyue.file.mapper.FileObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockMultipartFile;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class LoadingEvidenceServiceTest {
@Mock MeasurementImageStorage storage;
@Mock FileObjectMapper files;
@InjectMocks LoadingEvidenceService service;
@Test
void uploadsEvidenceWithDedicatedBusinessType() {
var multipart = new MockMultipartFile("file", "step.jpg", "image/jpeg", new byte[]{1});
when(storage.storeLoading(10, 20, multipart)).thenReturn(new MeasurementImageStorage.Stored(
"loading-evidence/10/20/a.jpg", Path.of("a.jpg"), "image/jpeg", 1, "hash", "step.jpg"));
var result = service.upload(10, 20, 7, "PHOTO_STEP_2", multipart);
assertThat(result.businessType()).isEqualTo("LOADING_PHOTO_STEP_2");
verify(files).insert(any(FileObjectEntity.class));
}
@Test
void rejectsUnknownEvidenceType() {
var multipart = new MockMultipartFile("file", "x.jpg", "image/jpeg", new byte[]{1});
assertThatThrownBy(() -> service.upload(10, 20, 7, "UNKNOWN", multipart)).isInstanceOf(BizException.class);
}
@Test
void validatesTenantBoundFile() {
FileObjectEntity object = new FileObjectEntity();
object.setId(30L); object.setOriginalName("seal.jpg"); object.setBusinessType("LOADING_SEAL"); object.setContentType("image/jpeg");
when(files.selectOne(any(Wrapper.class))).thenReturn(object);
assertThat(service.validate(10, 20, 30).fileId()).isEqualTo(30L);
}
}
@@ -0,0 +1,19 @@
package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
/** 仅用于确认证据文件属于当前客户和入库单,不在 firstleg 复制文件元数据。 */
@FeignClient(name = "file-service", contextId = "firstlegLoadingEvidenceFileClient",
url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/loading-evidence")
public interface LoadingEvidenceFileClient {
@GetMapping("/{tenantId}/{inboundId}/{fileId}")
ApiResponse<FileMetadata> validate(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId, @PathVariable long inboundId,
@PathVariable long fileId);
record FileMetadata(Long fileId, String fileName, String businessType, String contentType) {}
}
@@ -9,6 +9,12 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.OrderApprovedInboundCommand;
import com.baiyue.firstleg.dto.InboundOrderReference;
import com.baiyue.firstleg.service.InboundOrderCreationService;
import com.baiyue.firstleg.service.UnifiedInboundQueryService;
import com.baiyue.firstleg.dto.UnifiedInboundDetail;
import com.baiyue.firstleg.dto.UnifiedInboundQuery;
import com.baiyue.firstleg.dto.UnifiedInboundRequest;
import com.baiyue.firstleg.dto.UnifiedInboundSummary;
import com.baiyue.common.core.api.PageResult;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -28,6 +34,8 @@ import java.security.MessageDigest;
public class InternalInboundOrderController {
@Resource
private InboundOrderCreationService service;
@Resource
private UnifiedInboundQueryService unifiedQueryService;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
private byte[] expectedToken;
@@ -60,6 +68,31 @@ public class InternalInboundOrderController {
request.tenantId(), request.orderIds().stream().distinct().toList()));
}
@PostMapping("/unified-orders/query")
public ApiResponse<PageResult<UnifiedInboundSummary>> unifiedPage(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody UnifiedInboundQuery request) {
requireToken(token);
return ApiResponse.success(unifiedQueryService.page(request));
}
@PostMapping("/unified-orders/summaries")
public ApiResponse<List<UnifiedInboundSummary>> unifiedSummaries(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody UnifiedInboundRequest.BatchRequest request) {
requireToken(token);
return ApiResponse.success(unifiedQueryService.summaries(
request.tenantId(), request.orderIds().stream().distinct().toList()));
}
@PostMapping("/unified-orders/detail")
public ApiResponse<UnifiedInboundDetail> unifiedDetail(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody UnifiedInboundRequest.DetailRequest request) {
requireToken(token);
return ApiResponse.success(unifiedQueryService.detail(request.tenantId(), request.orderId()));
}
private void requireToken(String token) {
if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
@@ -10,7 +10,6 @@ import com.baiyue.firstleg.dto.ExceptionReleaseRequest;
import com.baiyue.firstleg.dto.MeasurementReviewRequest;
import com.baiyue.firstleg.entity.MeasureReviewRecordEntity;
import com.baiyue.firstleg.service.MeasurementReviewService;
import com.baiyue.firstleg.service.AutomaticInboundCompletionService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import io.swagger.v3.oas.annotations.Operation;
@@ -36,12 +35,11 @@ import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/api/platform/v1/inbounds")
public class PlatformInboundMeasurementReviewController {
@Resource private MeasurementReviewService service;
@Resource private AutomaticInboundCompletionService automaticCompletion;
@BusinessAudit(event = "INBOUND_MEASURE_APPROVE", resourceType = "INBOUND",
resourceId = "#inboundId", description = "测量审核通过")
@Operation(summary = "测量审核通过",
description = "客服审核通过后自动计费、发布费用给客户并完成入库;客户无需确认费用。")
description = "客服确认测量数据后直接进入待分柜;费用和其他资料可在后续继续维护。")
@PostMapping("/{inboundId}/measurements/approve")
@PreAuthorize("hasAuthority('firstleg:inbound:measurement-review')")
public ApiResponse<FeeSnapshotResponse> approve(
@@ -53,9 +51,9 @@ public class PlatformInboundMeasurementReviewController {
@Valid @RequestBody MeasurementReviewRequest request) {
validateScope(user, tenantId);
service.approve(tenantId, user.userId(), user.username(), inboundId, idempotencyKey, request);
return ApiResponse.success(automaticCompletion.completeApproved(
tenantId, inboundId, user.userId(), user.username(), request.measurementId(),
request.expectedVersion() + 1));
// 统一订单主线要求测量审核后直接进入 WAIT_SPLIT,不把计费、库位或其他资料
// 作为本版本的强制门禁。保留原 FeeSnapshotResponse 泛型以兼容既有客户端。
return ApiResponse.success(null);
}
@BusinessAudit(event = "INBOUND_MEASURE_REJECT", resourceType = "INBOUND",
@@ -6,7 +6,8 @@ import java.util.Set;
public final class InboundProcessStatus {
public static final Set<String> VALUES = Set.of(
"WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
"WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "INBOUNDED", "VOIDED"
"WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "INBOUNDED",
"WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED", "VOIDED"
);
private InboundProcessStatus() {
@@ -0,0 +1,14 @@
package com.baiyue.firstleg.dto;
import java.time.LocalDateTime;
import java.util.List;
/** Detail payload for the product-level order detail. */
public record UnifiedInboundDetail(
UnifiedInboundSummary summary,
List<TimelineEvent> timeline
) {
public record TimelineEvent(String source, String eventType, String fromStatus, String toStatus,
String action, String remark, Long operatorId, String operatorName,
LocalDateTime occurredAt) {}
}
@@ -0,0 +1,41 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** Order service internal query for the product-level order line. */
public record UnifiedInboundQuery(
Long tenantId,
@Size(max = 64) String keyword,
@Size(max = 64) String orderNo,
@Size(max = 32) String mainStatus,
Long customerId,
Long receivingWarehouseId,
Long destinationWarehouseId,
@Size(max = 64) String inboundCode,
@Size(max = 32) String internalOrderStatus,
LocalDate expectedArrivalDate,
@Size(max = 32) String feeReviewStatus,
@Size(max = 128) String channelName,
@Size(max = 64) String containerNo,
@Size(max = 64) String cabinetNo,
LocalDate sailingDate,
Boolean loaded,
Boolean fullContainer,
@Size(max = 32) String measurementReviewStatus,
List<Long> candidateOrderIds,
LocalDateTime createdFrom,
LocalDateTime createdTo,
@Min(1) long page,
@Min(1) @Max(200) long size
) {
public UnifiedInboundQuery {
if (page == 0) page = 1;
if (size == 0) size = 20;
}
}
@@ -0,0 +1,17 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.util.List;
/** Internal request contracts used by Order service. */
public final class UnifiedInboundRequest {
private UnifiedInboundRequest() {}
public record BatchRequest(Long tenantId,
@NotEmpty @Size(max = 200) List<@Positive Long> orderIds) {}
public record DetailRequest(@Positive long tenantId, @Positive long orderId) {}
}
@@ -0,0 +1,23 @@
package com.baiyue.firstleg.dto;
import java.time.LocalDateTime;
/** Product-level status and operational summaries returned to Order service. */
public record UnifiedInboundSummary(
InboundOrderSummary inbound,
String mainStatus,
String nextAction,
PreloadSummary preload,
LoadingSummary loading,
DeliverySummary delivery
) {
public record PreloadSummary(String status, Integer version, Integer itemCount, LocalDateTime updatedAt) {}
/**
* loadingVersion is currently a compatibility alias of pickingVersion because no separate
* loading aggregate row exists. C04 loading complete must use inbound.version instead.
*/
public record LoadingSummary(String status, Integer loadingVersion, Integer pickingVersion,
Integer expectedCount, Integer pickedCount, Integer evidenceCount,
String sealNo, LocalDateTime updatedAt) {}
public record DeliverySummary(String status, LocalDateTime completedAt, String trackingNo) {}
}
@@ -0,0 +1,102 @@
package com.baiyue.firstleg.loading.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteDeliveryRequest;
import com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteLoadingRequest;
import com.baiyue.firstleg.loading.dto.LoadingRequests.UpsertEvidenceRequest;
import com.baiyue.firstleg.loading.dto.LoadingRequests.PickingRequest;
import com.baiyue.firstleg.loading.dto.LoadingResponses.EvidenceListResponse;
import com.baiyue.firstleg.loading.dto.LoadingResponses.LoadingActionResponse;
import com.baiyue.firstleg.loading.dto.LoadingResponses.PickingResponse;
import com.baiyue.firstleg.loading.service.LoadingDeliveryService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
/** 平台端装柜证据、装柜完成和派送完成操作。 */
@Tag(name = "订单装柜与派送")
@RestController
@RequestMapping("/api/platform/v1/inbounds")
public class PlatformLoadingDeliveryController {
@Resource private LoadingDeliveryService service;
@Operation(summary = "新增或修改装柜证据")
@PostMapping("/{inboundId}/loading/evidence")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:edit')")
@BusinessAudit(event = "INBOUND_LOADING_EVIDENCE_UPSERT", resourceType = "INBOUND", resourceId = "#inboundId", description = "维护装柜证据")
public ApiResponse<LoadingActionResponse> evidence(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId,
@RequestHeader("Idempotency-Key") String key, @Valid @RequestBody UpsertEvidenceRequest request) {
FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.upsertEvidence(tenantId, inboundId, user.userId(), user.username(), key, request));
}
@Operation(summary = "查询装柜证据")
@GetMapping("/{inboundId}/loading/evidence")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:view')")
public ApiResponse<EvidenceListResponse> evidence(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId) {
FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.evidence(tenantId, inboundId));
}
@Operation(summary = "完成装柜")
@PostMapping("/{inboundId}/loading/complete")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:complete')")
@BusinessAudit(event = "INBOUND_COMPLETE_LOADING", resourceType = "INBOUND", resourceId = "#inboundId", description = "确认装柜完成")
public ApiResponse<LoadingActionResponse> completeLoading(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId,
@RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CompleteLoadingRequest request) {
FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.completeLoading(tenantId, inboundId, user.userId(), user.username(), key, request));
}
@Operation(summary = "确认派送完成")
@PostMapping("/{inboundId}/delivery/complete")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:delivery:complete')")
@BusinessAudit(event = "INBOUND_COMPLETE_DELIVERY", resourceType = "INBOUND", resourceId = "#inboundId", description = "确认派送完成")
public ApiResponse<LoadingActionResponse> completeDelivery(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId,
@RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CompleteDeliveryRequest request) {
FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.completeDelivery(tenantId, inboundId, user.userId(), user.username(), key, request));
}
@Operation(summary = "PDA开始拣货")
@PostMapping("/{inboundId}/loading/picking/start")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:picking:edit')")
@BusinessAudit(event = "INBOUND_START_PICKING", resourceType = "INBOUND", resourceId = "#inboundId", description = "开始PDA拣货")
public ApiResponse<PickingResponse> startPicking(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId,
@RequestHeader("Idempotency-Key") String key, @Valid @RequestBody PickingRequest request) {
FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.startPicking(tenantId, inboundId, user.userId(), user.username(), key, request));
}
@Operation(summary = "PDA完成拣货")
@PostMapping("/{inboundId}/loading/picking/complete")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:picking:edit')")
@BusinessAudit(event = "INBOUND_COMPLETE_PICKING", resourceType = "INBOUND", resourceId = "#inboundId", description = "完成PDA拣货")
public ApiResponse<PickingResponse> completePicking(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId,
@RequestHeader("Idempotency-Key") String key, @Valid @RequestBody PickingRequest request) {
FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.completePicking(tenantId, inboundId, user.userId(), user.username(), key, request));
}
@Operation(summary = "查询PDA拣货任务")
@GetMapping("/{inboundId}/loading/picking")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:picking:view')")
public ApiResponse<PickingResponse> picking(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId) {
FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.picking(tenantId, inboundId));
}
}
@@ -0,0 +1,17 @@
package com.baiyue.firstleg.loading.domain;
import java.util.Set;
/**
* 装柜证据类型。照片语义尚待产品确认,当前只固定四个步骤和一个铅封证据槽位。
*/
public final class LoadingEvidenceType {
public static final String PHOTO_STEP_1 = "PHOTO_STEP_1";
public static final String PHOTO_STEP_2 = "PHOTO_STEP_2";
public static final String PHOTO_STEP_3 = "PHOTO_STEP_3";
public static final String PHOTO_STEP_4 = "PHOTO_STEP_4";
public static final String SEAL = "SEAL";
public static final Set<String> VALUES = Set.of(PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL);
private LoadingEvidenceType() {}
}
@@ -0,0 +1,8 @@
package com.baiyue.firstleg.loading.domain;
public final class PickingStatus {
public static final String PENDING = "PENDING";
public static final String IN_PROGRESS = "IN_PROGRESS";
public static final String COMPLETED = "COMPLETED";
private PickingStatus() {}
}
@@ -0,0 +1,32 @@
package com.baiyue.firstleg.loading.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
public final class LoadingRequests {
private LoadingRequests() {}
public record UpsertEvidenceRequest(
@NotNull @PositiveOrZero Integer expectedVersion,
@NotBlank @Size(max = 32) String evidenceType,
@Positive Long fileId,
@Size(max = 255) String fileName,
@Size(max = 64) String sealNo,
@Size(max = 1000) String remark) {}
public record CompleteLoadingRequest(
@NotNull @PositiveOrZero Integer expectedVersion,
@Size(max = 1000) String remark) {}
public record CompleteDeliveryRequest(
@NotNull @PositiveOrZero Integer expectedVersion,
@Size(max = 1000) String remark) {}
public record PickingRequest(@NotNull @PositiveOrZero Integer expectedVersion,
@PositiveOrZero Integer expectedCount,
@PositiveOrZero Integer pickedCount,
@Size(max = 1000) String remark) {}
}
@@ -0,0 +1,19 @@
package com.baiyue.firstleg.loading.dto;
import java.time.LocalDateTime;
import java.util.List;
public final class LoadingResponses {
private LoadingResponses() {}
public record EvidenceResponse(Long id, Long inboundId, Long tenantId, String evidenceType,
Long fileId, String fileName, String sealNo, String remark,
Integer version, LocalDateTime createdAt, LocalDateTime updatedAt) {}
public record EvidenceListResponse(Long inboundId, List<EvidenceResponse> evidences) {}
public record LoadingActionResponse(String mainStatus, EvidenceListResponse evidences) {}
public record PickingResponse(Long id, Long inboundId, String status, Integer version,
Integer expectedCount, Integer pickedCount, String remark) {}
}
@@ -0,0 +1,31 @@
package com.baiyue.firstleg.loading.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_loading_evidence")
public class LoadingEvidenceEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private String evidenceType;
private Long fileId;
private String fileName;
private String sealNo;
private String remark;
private Integer version;
private Long createdBy;
private String createdByName;
private Long updatedBy;
private String updatedByName;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,27 @@
package com.baiyue.firstleg.loading.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_loading_operation")
public class LoadingOperationEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private Long operatorId;
private String operation;
private String idempotencyKey;
private String requestHash;
private String status;
private String resultJson;
private LocalDateTime createdAt;
private LocalDateTime completedAt;
}
@@ -0,0 +1,27 @@
package com.baiyue.firstleg.loading.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_loading_pick_task")
public class LoadingPickTaskEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long tenantId;
private Long inboundId;
private String status;
private Integer version;
private Integer expectedCount;
private Integer pickedCount;
private String remark;
private Long updatedBy;
private String updatedByName;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,20 @@
package com.baiyue.firstleg.loading.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface LoadingEvidenceMapper extends BaseMapper<LoadingEvidenceEntity> {
@Update("UPDATE fl_loading_evidence SET file_id=#{fileId}, file_name=#{fileName}, seal_no=#{sealNo}, "
+ "remark=#{remark}, updated_by=#{operatorId}, updated_by_name=#{operatorName}, version=version+1 "
+ "WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND evidence_type=#{evidenceType} "
+ "AND version=#{expectedVersion}")
int updateEvidence(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("evidenceType") String evidenceType, @Param("fileId") Long fileId,
@Param("fileName") String fileName, @Param("sealNo") String sealNo,
@Param("remark") String remark, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName, @Param("expectedVersion") int expectedVersion);
}
@@ -0,0 +1,25 @@
package com.baiyue.firstleg.loading.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface LoadingInboundMapper {
@Select("SELECT id, tenant_id AS tenantId, process_status AS processStatus, version "
+ "FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0")
Snapshot find(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
@Update("UPDATE fl_inbound_order SET process_status='LOADED', updated_by=#{operatorId}, version=version+1 "
+ "WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0 AND process_status='LOADING' AND version=#{expectedVersion}")
int completeLoading(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion);
@Update("UPDATE fl_inbound_order SET process_status='DELIVERED', updated_by=#{operatorId}, version=version+1 "
+ "WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0 AND process_status='LOADED' AND version=#{expectedVersion}")
int completeDelivery(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion);
record Snapshot(Long id, Long tenantId, String processStatus, Integer version) {}
}
@@ -0,0 +1,25 @@
package com.baiyue.firstleg.loading.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.loading.entity.LoadingOperationEntity;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface LoadingOperationMapper extends BaseMapper<LoadingOperationEntity> {
@Insert("INSERT IGNORE INTO fl_loading_operation "
+ "(id,tenant_id,inbound_id,operator_id,operation,idempotency_key,request_hash,status,created_at) "
+ "VALUES(#{id},#{tenantId},#{inboundId},#{operatorId},#{operation},#{key},#{requestHash},'PROCESSING',CURRENT_TIMESTAMP(3))")
int claim(@Param("id") long id, @Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId, @Param("operation") String operation,
@Param("key") String key, @Param("requestHash") String requestHash);
@Update("UPDATE fl_loading_operation SET status='SUCCESS', result_json=#{resultJson}, completed_at=CURRENT_TIMESTAMP(3) "
+ "WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND operator_id=#{operatorId} "
+ "AND operation=#{operation} AND idempotency_key=#{key} AND status='PROCESSING'")
int complete(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId, @Param("operation") String operation,
@Param("key") String key, @Param("resultJson") String resultJson);
}
@@ -0,0 +1,21 @@
package com.baiyue.firstleg.loading.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity> {
@Update("UPDATE fl_loading_pick_task SET status=#{toStatus}, updated_by=#{operatorId}, updated_by_name=#{operatorName}, "
+ "remark=COALESCE(#{remark}, remark), expected_count=COALESCE(#{expectedCount}, expected_count), "
+ "picked_count=COALESCE(#{pickedCount}, picked_count), version=version+1 WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} "
+ "AND status=#{fromStatus} AND version=#{expectedVersion}")
int changeStatus(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName,
@Param("remark") String remark, @Param("expectedCount") Integer expectedCount,
@Param("pickedCount") Integer pickedCount,
@Param("expectedVersion") int expectedVersion);
}
@@ -0,0 +1,89 @@
package com.baiyue.firstleg.loading.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity;
import com.baiyue.firstleg.loading.entity.LoadingOperationEntity;
import com.baiyue.firstleg.loading.mapper.LoadingEvidenceMapper;
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper;
import com.baiyue.firstleg.loading.mapper.LoadingOperationMapper;
import com.baiyue.firstleg.loading.mapper.LoadingPickTaskMapper;
import com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public class LoadingRepository {
@Resource private LoadingInboundMapper inbounds;
@Resource private LoadingEvidenceMapper evidences;
@Resource private LoadingOperationMapper operations;
@Resource private LoadingPickTaskMapper picks;
public LoadingInboundMapper.Snapshot findInbound(long tenantId, long inboundId) {
return inbounds.find(tenantId, inboundId);
}
public int completeLoading(long tenantId, long inboundId, long operatorId, int expectedVersion) {
return inbounds.completeLoading(tenantId, inboundId, operatorId, expectedVersion);
}
public int completeDelivery(long tenantId, long inboundId, long operatorId, int expectedVersion) {
return inbounds.completeDelivery(tenantId, inboundId, operatorId, expectedVersion);
}
public LoadingEvidenceEntity findEvidence(long tenantId, long inboundId, String type) {
return evidences.selectOne(new LambdaQueryWrapper<LoadingEvidenceEntity>()
.eq(LoadingEvidenceEntity::getTenantId, tenantId)
.eq(LoadingEvidenceEntity::getInboundId, inboundId)
.eq(LoadingEvidenceEntity::getEvidenceType, type));
}
public List<LoadingEvidenceEntity> listEvidence(long tenantId, long inboundId) {
return evidences.selectList(new LambdaQueryWrapper<LoadingEvidenceEntity>()
.eq(LoadingEvidenceEntity::getTenantId, tenantId)
.eq(LoadingEvidenceEntity::getInboundId, inboundId)
.orderByAsc(LoadingEvidenceEntity::getEvidenceType));
}
public void createEvidence(LoadingEvidenceEntity entity) { evidences.insert(entity); }
public int updateEvidence(long tenantId, long inboundId, String type, Long fileId, String fileName,
String sealNo, String remark, long operatorId, String operatorName, int version) {
return evidences.updateEvidence(tenantId, inboundId, type, fileId, fileName, sealNo, remark,
operatorId, operatorName, version);
}
public LoadingOperationEntity findOperation(long tenantId, long inboundId, long operatorId,
String operation, String key) {
return operations.selectOne(new LambdaQueryWrapper<LoadingOperationEntity>()
.eq(LoadingOperationEntity::getTenantId, tenantId)
.eq(LoadingOperationEntity::getInboundId, inboundId)
.eq(LoadingOperationEntity::getOperatorId, operatorId)
.eq(LoadingOperationEntity::getOperation, operation)
.eq(LoadingOperationEntity::getIdempotencyKey, key));
}
public boolean claimOperation(long tenantId, long inboundId, long operatorId, String operation,
String key, String requestHash) {
return operations.claim(IdWorker.getId(), tenantId, inboundId, operatorId, operation, key, requestHash) == 1;
}
public void completeOperation(long tenantId, long inboundId, long operatorId, String operation,
String key, String resultJson) {
operations.complete(tenantId, inboundId, operatorId, operation, key, resultJson);
}
public LoadingPickTaskEntity findPickTask(long tenantId, long inboundId) {
return picks.selectOne(new LambdaQueryWrapper<LoadingPickTaskEntity>()
.eq(LoadingPickTaskEntity::getTenantId, tenantId).eq(LoadingPickTaskEntity::getInboundId, inboundId));
}
public void createPickTask(LoadingPickTaskEntity task) { picks.insert(task); }
public int changePickStatus(long tenantId, long inboundId, String from, String to, long operatorId,
String operatorName, String remark, Integer expectedCount,
Integer pickedCount, int version) {
return picks.changeStatus(tenantId, inboundId, from, to, operatorId, operatorName, remark,
expectedCount, pickedCount, version);
}
}
@@ -0,0 +1,245 @@
package com.baiyue.firstleg.loading.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.LoadingEvidenceFileClient;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.loading.domain.LoadingEvidenceType;
import com.baiyue.firstleg.loading.dto.LoadingRequests.*;
import com.baiyue.firstleg.loading.dto.LoadingResponses.*;
import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity;
import com.baiyue.firstleg.loading.entity.LoadingOperationEntity;
import com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.loading.domain.PickingStatus;
import com.baiyue.firstleg.loading.dto.LoadingResponses.PickingResponse;
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.Snapshot;
import com.baiyue.firstleg.loading.repository.LoadingRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import feign.FeignException;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
/** 装柜证据、装柜完成和派送完成;不检查证据完整度,不阻断业务流转。 */
@Service
public class LoadingDeliveryService {
private static final String UPSERT_EVIDENCE = "UPSERT_LOADING_EVIDENCE";
private static final String COMPLETE_LOADING = "COMPLETE_LOADING";
private static final String COMPLETE_DELIVERY = "COMPLETE_DELIVERY";
@Resource private LoadingRepository repository;
@Resource private InboundStatusHistoryMapper histories;
@Resource private LoadingEvidenceFileClient files;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
@Transactional
public LoadingActionResponse upsertEvidence(long tenantId, long inboundId, long operatorId,
String operatorName, String key, UpsertEvidenceRequest request) {
String hash = hash(request);
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, UPSERT_EVIDENCE, key, hash);
if (replay != null) return replay;
requireLoadingStage(tenantId, inboundId);
if (!LoadingEvidenceType.VALUES.contains(request.evidenceType())) {
throw new BizException(ResultCode.BAD_REQUEST, "不支持的装柜证据类型");
}
var file = request.fileId() == null ? null : validateFile(tenantId, inboundId, request.fileId(), request.evidenceType());
String fileName = file == null ? request.fileName() : file.fileName();
LoadingEvidenceEntity existing = repository.findEvidence(tenantId, inboundId, request.evidenceType());
if (existing == null) {
if (request.expectedVersion() != 0) throw concurrent();
LoadingEvidenceEntity entity = new LoadingEvidenceEntity();
entity.setTenantId(tenantId); entity.setInboundId(inboundId);
entity.setEvidenceType(request.evidenceType()); entity.setFileId(request.fileId());
entity.setFileName(fileName); entity.setSealNo(request.sealNo()); entity.setRemark(request.remark());
entity.setVersion(0); entity.setCreatedBy(operatorId); entity.setCreatedByName(operatorName);
entity.setUpdatedBy(operatorId); entity.setUpdatedByName(operatorName);
repository.createEvidence(entity);
} else if (repository.updateEvidence(tenantId, inboundId, request.evidenceType(), request.fileId(), fileName,
request.sealNo(), request.remark(), operatorId, operatorName, request.expectedVersion()) != 1) {
throw concurrent();
}
LoadingActionResponse result = new LoadingActionResponse(requireInbound(tenantId, inboundId).processStatus(), evidenceResponse(tenantId, inboundId));
return complete(tenantId, inboundId, operatorId, UPSERT_EVIDENCE, key, result);
}
@Transactional
public LoadingActionResponse completeLoading(long tenantId, long inboundId, long operatorId,
String operatorName, String key, CompleteLoadingRequest request) {
String hash = hash(request);
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, COMPLETE_LOADING, key, hash);
if (replay != null) return replay;
Snapshot inbound = requireInbound(tenantId, inboundId);
if ("LOADED".equals(inbound.processStatus()) || "DELIVERED".equals(inbound.processStatus())) {
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse(inbound.processStatus(), evidenceResponse(tenantId, inboundId)));
}
if (!"LOADING".equals(inbound.processStatus())) throw new BizException(ResultCode.CONFLICT, "当前订单不在装柜中");
if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent();
addHistory(tenantId, inboundId, "LOADING", "LOADED", COMPLETE_LOADING, request.remark(), operatorId, operatorName);
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse("LOADED", evidenceResponse(tenantId, inboundId)));
}
@Transactional
public LoadingActionResponse completeDelivery(long tenantId, long inboundId, long operatorId,
String operatorName, String key, CompleteDeliveryRequest request) {
String hash = hash(request);
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, COMPLETE_DELIVERY, key, hash);
if (replay != null) return replay;
Snapshot inbound = requireInbound(tenantId, inboundId);
if ("DELIVERED".equals(inbound.processStatus())) {
return complete(tenantId, inboundId, operatorId, COMPLETE_DELIVERY, key,
new LoadingActionResponse("DELIVERED", evidenceResponse(tenantId, inboundId)));
}
if (!"LOADED".equals(inbound.processStatus())) throw new BizException(ResultCode.CONFLICT, "当前订单不在装柜完成");
if (repository.completeDelivery(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent();
addHistory(tenantId, inboundId, "LOADED", "DELIVERED", COMPLETE_DELIVERY, request.remark(), operatorId, operatorName);
return complete(tenantId, inboundId, operatorId, COMPLETE_DELIVERY, key,
new LoadingActionResponse("DELIVERED", evidenceResponse(tenantId, inboundId)));
}
public EvidenceListResponse evidence(long tenantId, long inboundId) {
requireLoadingStage(tenantId, inboundId);
return evidenceResponse(tenantId, inboundId);
}
@Transactional
public PickingResponse startPicking(long tenantId, long inboundId, long operatorId, String operatorName,
String key, PickingRequest request) {
String hash = hash(request);
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, "START_PICKING", key, hash);
if (replay != null) return pickResponse(tenantId, inboundId);
requireLoadingStage(tenantId, inboundId);
LoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId);
if (task == null) {
if (request.expectedVersion() != 0) throw concurrent();
task = new LoadingPickTaskEntity(); task.setTenantId(tenantId); task.setInboundId(inboundId);
task.setStatus(PickingStatus.IN_PROGRESS); task.setVersion(0); task.setExpectedCount(request.expectedCount());
task.setPickedCount(request.pickedCount());
task.setRemark(request.remark()); task.setUpdatedBy(operatorId); task.setUpdatedByName(operatorName);
repository.createPickTask(task);
} else if (repository.changePickStatus(tenantId, inboundId, task.getStatus(), PickingStatus.IN_PROGRESS,
operatorId, operatorName, request.remark(), request.expectedCount(), request.pickedCount(), request.expectedVersion()) != 1) throw concurrent();
PickingResponse result = pickResponse(tenantId, inboundId);
complete(tenantId, inboundId, operatorId, "START_PICKING", key,
new LoadingActionResponse(requireInbound(tenantId, inboundId).processStatus(), evidenceResponse(tenantId, inboundId)));
return result;
}
@Transactional
public PickingResponse completePicking(long tenantId, long inboundId, long operatorId, String operatorName,
String key, PickingRequest request) {
String hash = hash(request);
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, "COMPLETE_PICKING", key, hash);
if (replay != null) return pickResponse(tenantId, inboundId);
requireLoadingStage(tenantId, inboundId);
LoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId);
if (task == null) {
task = new LoadingPickTaskEntity(); task.setTenantId(tenantId); task.setInboundId(inboundId);
task.setStatus(PickingStatus.COMPLETED); task.setVersion(0); task.setExpectedCount(request.expectedCount());
task.setPickedCount(request.pickedCount());
task.setRemark(request.remark()); task.setUpdatedBy(operatorId); task.setUpdatedByName(operatorName);
repository.createPickTask(task);
} else if (repository.changePickStatus(tenantId, inboundId,
task.getStatus(), PickingStatus.COMPLETED, operatorId, operatorName, request.remark(), request.expectedCount(), request.pickedCount(), request.expectedVersion()) != 1) throw concurrent();
PickingResponse result = pickResponse(tenantId, inboundId);
complete(tenantId, inboundId, operatorId, "COMPLETE_PICKING", key,
new LoadingActionResponse(requireInbound(tenantId, inboundId).processStatus(), evidenceResponse(tenantId, inboundId)));
return result;
}
public PickingResponse picking(long tenantId, long inboundId) {
requireInbound(tenantId, inboundId);
return pickResponse(tenantId, inboundId);
}
private PickingResponse pickResponse(long tenantId, long inboundId) {
LoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId);
if (task == null) return null;
return new PickingResponse(task.getId(), task.getInboundId(), task.getStatus(), task.getVersion(),
task.getExpectedCount(), task.getPickedCount(), task.getRemark());
}
private LoadingEvidenceFileClient.FileMetadata validateFile(long tenantId, long inboundId, long fileId, String evidenceType) {
try {
var response = files.validate(internalToken, tenantId, inboundId, fileId);
if (response == null || !response.successful() || response.data() == null
|| !("LOADING_" + evidenceType).equals(response.data().businessType())) {
throw new BizException(ResultCode.BAD_REQUEST, "证据文件不存在或不属于当前订单");
}
return response.data();
} catch (FeignException ex) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用");
}
}
private Snapshot requireInbound(long tenantId, long inboundId) {
Snapshot value = repository.findInbound(tenantId, inboundId);
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "订单不存在");
return value;
}
private Snapshot requireLoadingStage(long tenantId, long inboundId) {
Snapshot value = requireInbound(tenantId, inboundId);
if (!("LOADING".equals(value.processStatus()) || "LOADED".equals(value.processStatus())
|| "DELIVERED".equals(value.processStatus()))) {
throw new BizException(ResultCode.CONFLICT, "订单尚未进入装柜阶段");
}
return value;
}
private EvidenceListResponse evidenceResponse(long tenantId, long inboundId) {
List<EvidenceResponse> values = repository.listEvidence(tenantId, inboundId).stream().map(e ->
new EvidenceResponse(e.getId(), e.getInboundId(), e.getTenantId(), e.getEvidenceType(), e.getFileId(),
e.getFileName(), e.getSealNo(), e.getRemark(), e.getVersion(), e.getCreatedAt(), e.getUpdatedAt())).toList();
return new EvidenceListResponse(inboundId, values);
}
private LoadingActionResponse claimOrReplay(long tenantId, long inboundId, long operatorId,
String operation, String key, String hash) {
validateKey(key);
if (repository.claimOperation(tenantId, inboundId, operatorId, operation, key, hash)) return null;
LoadingOperationEntity existing = repository.findOperation(tenantId, inboundId, operatorId, operation, key);
if (existing == null || !hash.equals(existing.getRequestHash())) throw new BizException(ResultCode.CONFLICT, "幂等键已用于其他请求");
if (!"SUCCESS".equals(existing.getStatus()) || !StringUtils.hasText(existing.getResultJson())) throw new BizException(ResultCode.CONFLICT, "相同请求正在处理中,请稍后重试");
try { return objectMapper.readValue(existing.getResultJson(), LoadingActionResponse.class); }
catch (JsonProcessingException e) { throw new IllegalStateException("无法读取装柜幂等结果", e); }
}
private LoadingActionResponse complete(long tenantId, long inboundId, long operatorId, String operation,
String key, LoadingActionResponse result) {
try { repository.completeOperation(tenantId, inboundId, operatorId, operation, key, objectMapper.writeValueAsString(result)); }
catch (JsonProcessingException e) { throw new IllegalStateException("无法保存装柜幂等结果", e); }
return result;
}
private void addHistory(long tenantId, long inboundId, String from, String to, String action, String reason,
long operatorId, String operatorName) {
InboundStatusHistoryEntity history = new InboundStatusHistoryEntity();
history.setTenantId(tenantId); history.setInboundId(inboundId); history.setFromStatus(from); history.setToStatus(to);
history.setAction(action); history.setReason(reason); history.setOperatorId(operatorId); history.setOperatorName(operatorName);
histories.insert(history);
}
private String hash(Object request) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(objectMapper.writeValueAsString(request).getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest);
} catch (Exception e) { throw new IllegalStateException("无法计算幂等请求摘要", e); }
}
private static void validateKey(String key) {
if (!StringUtils.hasText(key) || key.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "Idempotency-Key不能为空且不能超过128个字符");
}
private static BizException concurrent() { return new BizException(ResultCode.CONFLICT, "数据已被其他人修改,请刷新后重试"); }
}
@@ -11,7 +11,8 @@ import org.apache.ibatis.annotations.Update;
public interface InboundOrderMapper extends BaseMapper<InboundOrderEntity> {
@Update("""
UPDATE fl_inbound_order
SET process_status = 'WAIT_PRICING', measure_review_status = 'APPROVED',
SET process_status = 'WAIT_SPLIT', measure_review_status = 'APPROVED',
inbounded_at = COALESCE(inbounded_at, CURRENT_TIMESTAMP(3)),
updated_by = #{operatorId}, version = version + 1
WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0
AND process_status = 'MEASURE_PENDING' AND measure_review_status = 'PENDING'
@@ -27,6 +27,7 @@ import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import com.baiyue.firstleg.dto.UnifiedInboundQuery;
/** 入库单只读仓储;平台分页可跨企业查询,详情继续强制租户隔离。 */
@Repository
@@ -103,6 +104,88 @@ public class InboundOrderRepository {
.last("LIMIT 1"));
}
/** Internal unified-query accessors. They always retain the tenant predicate when one is supplied. */
public Page<InboundOrderEntity> pageUnifiedEntities(UnifiedInboundQuery query, List<String> processStatuses) {
LambdaQueryWrapper<InboundOrderEntity> wrapper = new LambdaQueryWrapper<InboundOrderEntity>()
.eq(query.tenantId() != null, InboundOrderEntity::getTenantId, query.tenantId())
.in(processStatuses != null && !processStatuses.isEmpty(), InboundOrderEntity::getProcessStatus, processStatuses)
.like(StringUtils.hasText(query.orderNo()), InboundOrderEntity::getOrderNo, query.orderNo())
.like(StringUtils.hasText(query.inboundCode()), InboundOrderEntity::getInboundCode, query.inboundCode())
.eq(StringUtils.hasText(query.internalOrderStatus()), InboundOrderEntity::getOrderStatusSnapshot,
query.internalOrderStatus())
.and(StringUtils.hasText(query.keyword()), nested -> nested
.like(InboundOrderEntity::getOrderNo, query.keyword())
.or().like(InboundOrderEntity::getInboundCode, query.keyword())
.or().like(InboundOrderEntity::getCustomerName, query.keyword()))
.eq(query.customerId() != null, InboundOrderEntity::getCustomerId, query.customerId())
.eq(query.receivingWarehouseId() != null, InboundOrderEntity::getWarehouseId, query.receivingWarehouseId())
.eq(query.destinationWarehouseId() != null, InboundOrderEntity::getDeliveryWarehouseId, query.destinationWarehouseId())
.ge(query.expectedArrivalDate() != null, InboundOrderEntity::getExpectedArrivalAt,
query.expectedArrivalDate() == null ? null : query.expectedArrivalDate().atStartOfDay())
.lt(query.expectedArrivalDate() != null, InboundOrderEntity::getExpectedArrivalAt,
query.expectedArrivalDate() == null ? null : query.expectedArrivalDate().plusDays(1).atStartOfDay())
.eq(StringUtils.hasText(query.channelName()), InboundOrderEntity::getChannelName, query.channelName())
.eq(query.fullContainer() != null, InboundOrderEntity::getContainerMode,
query.fullContainer() == null ? null : query.fullContainer() ? "FULL_CONTAINER" : "LCL")
.and("UNRECORDED".equals(query.measurementReviewStatus()), nested -> nested
.isNull(InboundOrderEntity::getMeasureReviewStatus)
.or().eq(InboundOrderEntity::getMeasureReviewStatus, ""))
.eq(StringUtils.hasText(query.measurementReviewStatus())
&& !"UNRECORDED".equals(query.measurementReviewStatus()),
InboundOrderEntity::getMeasureReviewStatus, query.measurementReviewStatus())
.in(query.candidateOrderIds() != null && !query.candidateOrderIds().isEmpty(),
InboundOrderEntity::getOrderId,
query.candidateOrderIds() == null ? List.of() : query.candidateOrderIds())
.apply("UNRECORDED".equals(query.feeReviewStatus()),
"NOT EXISTS (SELECT 1 FROM fl_inbound_fee f WHERE f.tenant_id = fl_inbound_order.tenant_id "
+ "AND f.inbound_id = fl_inbound_order.id)")
.apply(StringUtils.hasText(query.feeReviewStatus()) && !"UNRECORDED".equals(query.feeReviewStatus()),
"EXISTS (SELECT 1 FROM fl_inbound_fee f WHERE f.tenant_id = fl_inbound_order.tenant_id "
+ "AND f.inbound_id = fl_inbound_order.id AND f.review_status = {0} "
+ "AND NOT EXISTS (SELECT 1 FROM fl_inbound_fee newer "
+ "WHERE newer.tenant_id = f.tenant_id AND newer.inbound_id = f.inbound_id "
+ "AND (newer.fee_version > f.fee_version "
+ "OR (newer.fee_version = f.fee_version AND newer.id > f.id))))",
query.feeReviewStatus())
.apply(StringUtils.hasText(query.containerNo()),
"EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t "
+ "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id "
+ "AND t.container_no LIKE CONCAT('%', {0}, '%'))", query.containerNo())
.apply(StringUtils.hasText(query.cabinetNo()),
"EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t "
+ "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id "
+ "AND t.cabinet_no LIKE CONCAT('%', {0}, '%'))", query.cabinetNo())
.apply(query.sailingDate() != null,
"EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t "
+ "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id "
+ "AND t.sailing_at >= {0} AND t.sailing_at < {1})",
query.sailingDate() == null ? null : query.sailingDate().atStartOfDay(),
query.sailingDate() == null ? null : query.sailingDate().plusDays(1).atStartOfDay())
.apply(Boolean.TRUE.equals(query.loaded()),
"EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t "
+ "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id "
+ "AND COALESCE(TRIM(t.loading_status), '') <> '' "
+ "AND t.loading_status NOT IN ('UNLOADED', 'PENDING'))")
.apply(Boolean.FALSE.equals(query.loaded()),
"NOT EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t "
+ "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id "
+ "AND COALESCE(TRIM(t.loading_status), '') <> '' "
+ "AND t.loading_status NOT IN ('UNLOADED', 'PENDING'))")
.ge(query.createdFrom() != null, InboundOrderEntity::getCreatedAt, query.createdFrom())
.le(query.createdTo() != null, InboundOrderEntity::getCreatedAt, query.createdTo())
.orderByDesc(InboundOrderEntity::getCreatedAt).orderByDesc(InboundOrderEntity::getId);
return orders.selectPage(new Page<>(query.page(), query.size()), wrapper);
}
public List<InboundOrderEntity> findByOrderIds(Long tenantId, List<Long> orderIds) {
if (orderIds == null || orderIds.isEmpty()) return List.of();
return orders.selectList(new LambdaQueryWrapper<InboundOrderEntity>()
.eq(tenantId != null, InboundOrderEntity::getTenantId, tenantId)
.in(InboundOrderEntity::getOrderId, orderIds)
.orderByDesc(InboundOrderEntity::getCreatedAt));
}
public InboundOrderDetail detail(long tenantId, InboundOrderEntity order) {
long inboundId = order.getId();
InboundTransportSnapshotEntity transport = transports.selectOne(
@@ -0,0 +1,105 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity;
import com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.loading.mapper.LoadingEvidenceMapper;
import com.baiyue.firstleg.loading.mapper.LoadingPickTaskMapper;
import com.baiyue.firstleg.split.entity.PreloadPlanEntity;
import com.baiyue.firstleg.split.mapper.PreloadPlanMapper;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.mapper.InboundTransportSnapshotMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/** Read-only access to phase-three operational summaries for unified order queries. */
@Repository
public class UnifiedInboundReadRepository {
@Resource private PreloadPlanMapper preloads;
@Resource private LoadingPickTaskMapper picks;
@Resource private LoadingEvidenceMapper evidences;
@Resource private InboundStatusHistoryMapper histories;
@Resource private InboundTransportSnapshotMapper transports;
public PreloadPlanEntity findPreload(long tenantId, long inboundId) {
return preloads.selectOne(new LambdaQueryWrapper<PreloadPlanEntity>()
.eq(PreloadPlanEntity::getTenantId, tenantId)
.eq(PreloadPlanEntity::getInboundId, inboundId));
}
public LoadingPickTaskEntity findPick(long tenantId, long inboundId) {
return picks.selectOne(new LambdaQueryWrapper<LoadingPickTaskEntity>()
.eq(LoadingPickTaskEntity::getTenantId, tenantId)
.eq(LoadingPickTaskEntity::getInboundId, inboundId));
}
public List<LoadingEvidenceEntity> findEvidence(long tenantId, long inboundId) {
return evidences.selectList(new LambdaQueryWrapper<LoadingEvidenceEntity>()
.eq(LoadingEvidenceEntity::getTenantId, tenantId)
.eq(LoadingEvidenceEntity::getInboundId, inboundId)
.orderByAsc(LoadingEvidenceEntity::getEvidenceType));
}
public Map<Long, PreloadPlanEntity> findPreloads(Map<Long, Long> tenantByInbound) {
if (tenantByInbound.isEmpty()) return Map.of();
return preloads.selectList(new LambdaQueryWrapper<PreloadPlanEntity>()
.in(PreloadPlanEntity::getInboundId, tenantByInbound.keySet()))
.stream().filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId()))
.collect(Collectors.toMap(PreloadPlanEntity::getInboundId, Function.identity(), (left, right) -> left));
}
public Map<Long, LoadingPickTaskEntity> findPicks(Map<Long, Long> tenantByInbound) {
if (tenantByInbound.isEmpty()) return Map.of();
return picks.selectList(new LambdaQueryWrapper<LoadingPickTaskEntity>()
.in(LoadingPickTaskEntity::getInboundId, tenantByInbound.keySet()))
.stream().filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId()))
.collect(Collectors.toMap(LoadingPickTaskEntity::getInboundId, Function.identity(), (left, right) -> left));
}
public Map<Long, List<LoadingEvidenceEntity>> findEvidence(Map<Long, Long> tenantByInbound) {
if (tenantByInbound.isEmpty()) return Map.of();
return evidences.selectList(new LambdaQueryWrapper<LoadingEvidenceEntity>()
.in(LoadingEvidenceEntity::getInboundId, tenantByInbound.keySet())
.orderByAsc(LoadingEvidenceEntity::getEvidenceType))
.stream().filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId()))
.collect(Collectors.groupingBy(LoadingEvidenceEntity::getInboundId));
}
public Map<Long, InboundStatusHistoryEntity> findDeliveryHistory(Map<Long, Long> tenantByInbound) {
if (tenantByInbound.isEmpty()) return Map.of();
return histories.selectList(new LambdaQueryWrapper<InboundStatusHistoryEntity>()
.in(InboundStatusHistoryEntity::getInboundId, tenantByInbound.keySet())
.and(nested -> nested.eq(InboundStatusHistoryEntity::getAction, "COMPLETE_DELIVERY")
.or().eq(InboundStatusHistoryEntity::getToStatus, "DELIVERED"))
.orderByDesc(InboundStatusHistoryEntity::getOccurredAt))
.stream().filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId()))
.collect(Collectors.toMap(InboundStatusHistoryEntity::getInboundId,
Function.identity(), (latest, ignored) -> latest));
}
public InboundTransportSnapshotEntity findTransport(long tenantId, long inboundId) {
return transports.selectOne(new LambdaQueryWrapper<InboundTransportSnapshotEntity>()
.eq(InboundTransportSnapshotEntity::getTenantId, tenantId)
.eq(InboundTransportSnapshotEntity::getInboundId, inboundId));
}
public Map<Long, InboundTransportSnapshotEntity> findTransports(Map<Long, Long> tenantByInbound) {
if (tenantByInbound.isEmpty()) return Map.of();
return transports.selectList(new LambdaQueryWrapper<InboundTransportSnapshotEntity>()
.in(InboundTransportSnapshotEntity::getInboundId, tenantByInbound.keySet()))
.stream().filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId()))
.collect(Collectors.toMap(InboundTransportSnapshotEntity::getInboundId,
Function.identity(), (left, right) -> left));
}
private static boolean owns(Map<Long, Long> tenantByInbound, Long inboundId, Long tenantId) {
return inboundId != null && tenantId != null && tenantId.equals(tenantByInbound.get(inboundId));
}
}
@@ -34,8 +34,6 @@ public class InboundMeasurementService {
private static final String WEIGHT_NEEDS_MANUAL = "NEEDS_MANUAL";
/** 测量完成后的默认普通托盘类型;实际拆托和库位占用由提交编排服务执行。 */
private static final String RECOMMENDED_PALLET_TYPE = "STANDARD";
private static final Set<String> EDITABLE_INBOUND_STATUSES =
Set.of("WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
private static final BigDecimal LB_TO_KG = new BigDecimal("0.45359237");
@Resource
@@ -58,7 +56,6 @@ public class InboundMeasurementService {
long tenantId, long inboundId, long operatorId,
InboundMeasurementRequests.SaveDraftRequest request) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
requireEditable(inbound);
MeasurementBatchCommand command = new MeasurementBatchCommand(
tenantId, inboundId, request.measurementId(), request.version(),
MeasurementBatchService.SOURCE_MANUAL, "PLATFORM", null, null, null,
@@ -78,35 +75,42 @@ public class InboundMeasurementService {
long tenantId, long inboundId, long operatorId,
InboundMeasurementRequests.SubmitRequest request) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
requireEditable(inbound);
InboundMeasurementEntity measurement = requireMeasurement(tenantId, inboundId, request.measurementId());
requireVersion(request.version(), measurement.getVersion());
if (!Set.of(MeasurementBatchService.STATUS_DRAFT, MeasurementBatchService.STATUS_RECEIVED)
boolean alreadyAdvanced = !Set.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED")
.contains(inbound.getProcessStatus());
if (!alreadyAdvanced && !Set.of(MeasurementBatchService.STATUS_DRAFT, MeasurementBatchService.STATUS_RECEIVED)
.contains(measurement.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "当前测量批次不能重复提交审核");
}
List<InboundMeasurementItemEntity> items = repository.listItems(tenantId, measurement.getId());
if (items.isEmpty()) {
throw new BizException(ResultCode.VALIDATION_FAILED, "测量明细不能为空");
}
if (items.stream().noneMatch(item -> item.getSourceImageFileId() != null)) {
throw new BizException(ResultCode.VALIDATION_FAILED, "至少需要一张测量图片");
}
List<InboundWeightResultEntity> weights = repository.listWeights(tenantId, inboundId);
BigDecimal confirmedWeight = confirmedWeightForSubmission(weights);
measurement.setStatus(STATUS_SUBMITTED);
if (!repository.updateMeasurement(measurement)) {
throw concurrentModification();
if (!alreadyAdvanced) {
measurement.setStatus(STATUS_SUBMITTED);
if (!repository.updateMeasurement(measurement)) {
throw concurrentModification();
}
}
inbound.setProcessStatus("MEASURE_PENDING");
inbound.setMeasureReviewStatus("PENDING");
inbound.setTotalPackageCount(measurement.getTotalPackageCount());
inbound.setActualGrossWeightKg(confirmedWeight);
inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm());
inbound.setVolumeWeightKg(measurement.getVolumeWeightKg());
inbound.setMeasureCompletedAt(LocalDateTime.now());
if (!alreadyAdvanced) {
inbound.setProcessStatus("MEASURE_PENDING");
inbound.setMeasureReviewStatus("PENDING");
inbound.setMeasureCompletedAt(LocalDateTime.now());
}
if (measurement.getTotalPackageCount() != null) {
inbound.setTotalPackageCount(measurement.getTotalPackageCount());
}
if (confirmedWeight != null) {
inbound.setActualGrossWeightKg(confirmedWeight);
}
if (measurement.getTotalVolumeCbm() != null) {
inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm());
}
if (measurement.getVolumeWeightKg() != null) {
inbound.setVolumeWeightKg(measurement.getVolumeWeightKg());
}
inbound.setUpdatedBy(operatorId);
if (!repository.updateInbound(inbound)) {
throw concurrentModification();
@@ -121,7 +125,6 @@ public class InboundMeasurementService {
long tenantId, long inboundId, long operatorId,
InboundMeasurementRequests.CreateWeightResultRequest request) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
requireEditable(inbound);
if (repository.findWeightByFile(tenantId, inboundId, request.fileId()) != null) {
throw new BizException(ResultCode.CONFLICT, "同一称重图片不能重复计重");
}
@@ -150,7 +153,6 @@ public class InboundMeasurementService {
long tenantId, long inboundId, long resultId, long operatorId, String operatorName,
InboundMeasurementRequests.CorrectWeightResultRequest request) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
requireEditable(inbound);
InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId);
requireVersion(request.version(), weight.getVersion());
String adjustedUnit = normalizeUnit(request.adjustedWeightUnit());
@@ -181,7 +183,6 @@ public class InboundMeasurementService {
long tenantId, long inboundId, long resultId, long operatorId, String operatorName,
InboundMeasurementRequests.ConfirmWeightResultRequest request) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
requireEditable(inbound);
InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId);
requireVersion(request.version(), weight.getVersion());
if (WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) {
@@ -225,18 +226,12 @@ public class InboundMeasurementService {
}
private BigDecimal confirmedWeightForSubmission(List<InboundWeightResultEntity> weights) {
if (weights.isEmpty()) {
throw new BizException(ResultCode.VALIDATION_FAILED, "至少需要一张已确认的称重图片");
}
if (weights.stream().anyMatch(weight -> !WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus()))) {
throw new BizException(ResultCode.VALIDATION_FAILED, "所有有效称重图片必须先完成确认");
}
BigDecimal total = weights.stream().map(InboundWeightResultEntity::getFinalWeightKg)
BigDecimal total = weights.stream()
.filter(weight -> WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus()))
.map(InboundWeightResultEntity::getFinalWeightKg)
.filter(java.util.Objects::nonNull)
.reduce(BigDecimal.ZERO, BigDecimal::add).setScale(3, RoundingMode.HALF_UP);
if (!isPositive(total)) {
throw new BizException(ResultCode.VALIDATION_FAILED, "确认后的实际总重量必须大于 0");
}
return total;
return isPositive(total) ? total : null;
}
private void insertRevision(InboundWeightResultEntity weight, int revisionNo, String action,
@@ -328,12 +323,6 @@ public class InboundMeasurementService {
return weight;
}
private void requireEditable(InboundOrderEntity inbound) {
if (!EDITABLE_INBOUND_STATUSES.contains(inbound.getProcessStatus())) {
throw new BizException(ResultCode.CONFLICT, "当前入库状态不允许维护测量数据");
}
}
private void requireVersion(Integer expected, Integer actual) {
if (expected == null || !expected.equals(actual)) {
throw concurrentModification();
@@ -38,7 +38,8 @@ public class MeasurementBatchService {
@Transactional
public InboundMeasurementEntity saveBatch(MeasurementBatchCommand command) {
if (repository.findInbound(command.tenantId(), command.inboundId()) == null) {
var inbound = repository.findInbound(command.tenantId(), command.inboundId());
if (inbound == null) {
throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
}
validateSource(command);
@@ -50,7 +51,13 @@ public class MeasurementBatchService {
applyTotals(measurement, calculatedItems);
repository.insertMeasurement(measurement);
} else {
measurement = requireEditableManualBatch(command);
measurement = requireEditableBatch(command);
// 修正待测量或待审核资料时重新形成可提交的内部草稿;已入库订单只更新资料,
// 不回退其主状态,也不把已经审核的批次重新标成待审核。
if (Set.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED")
.contains(inbound.getProcessStatus())) {
measurement.setStatus(STATUS_DRAFT);
}
applyHeader(measurement, command);
applyTotals(measurement, calculatedItems);
if (!repository.updateMeasurement(measurement)) {
@@ -90,15 +97,12 @@ public class MeasurementBatchService {
return measurement;
}
private InboundMeasurementEntity requireEditableManualBatch(MeasurementBatchCommand command) {
private InboundMeasurementEntity requireEditableBatch(MeasurementBatchCommand command) {
InboundMeasurementEntity measurement = repository.findMeasurement(
command.tenantId(), command.inboundId(), command.measurementId());
if (measurement == null) {
throw new BizException(ResultCode.NOT_FOUND, "测量批次不存在");
}
if (!SOURCE_MANUAL.equals(measurement.getSourceType()) || !STATUS_DRAFT.equals(measurement.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "仅人工草稿允许直接修改");
}
if (!Integer.valueOf(command.expectedVersion()).equals(measurement.getVersion())) {
throw concurrentModification();
}
@@ -20,7 +20,8 @@ import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
/**
* 测量审核应用服务。审核是 MEASURE_PENDING 到计费阶段的唯一正常推进关口。
* 测量审核应用服务。客服确认测量数据后将入库单推进到产品主状态 WAIT_SPLIT;
* 计费、库位和其他资料不作为本版本的强制门禁。
*/
@Service
public class MeasurementReviewService {
@@ -79,7 +80,7 @@ public class MeasurementReviewService {
repository.insertReview(reviewRecord(tenantId, inboundId, request.measurementId(), result,
request.reason(), false, null, false, operatorId, operatorName));
repository.insertHistory(history(tenantId, inboundId, "MEASURE_PENDING",
approved ? "WAIT_PRICING" : "MEASURE_REJECTED",
approved ? "WAIT_SPLIT" : "MEASURE_REJECTED",
approved ? "MEASURE_APPROVE" : "MEASURE_REJECT", request.reason(), operatorId, operatorName));
idempotency.complete(context, inboundId);
}
@@ -11,15 +11,14 @@ import org.springframework.transaction.annotation.Transactional;
public class MeasurementSubmissionService {
@Resource
private InboundMeasurementService measurements;
@Resource
private MeasurementLocationOccupationService locations;
@Transactional
public InboundMeasurementResponses.MeasurementSubmitted submit(
long tenantId, long inboundId, long operatorId,
InboundMeasurementRequests.SubmitRequest request) {
var result = measurements.submit(tenantId, inboundId, operatorId, request);
locations.occupy(tenantId, inboundId, operatorId, result.measurementId(), result.version());
// 当前版本不把托盘/库位占用作为测量提交的强制门禁;入库主状态由客服确认测量时推进,
// 仓储资源可在后续作业阶段继续维护。
return result;
}
}
@@ -0,0 +1,185 @@
package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.firstleg.dto.InboundOrderDetail;
import com.baiyue.firstleg.dto.UnifiedInboundDetail;
import com.baiyue.firstleg.dto.UnifiedInboundQuery;
import com.baiyue.firstleg.dto.UnifiedInboundSummary;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity;
import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity;
import com.baiyue.firstleg.repository.InboundOrderRepository;
import com.baiyue.firstleg.repository.UnifiedInboundReadRepository;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/** Read-only adapter from internal inbound states to the seven product main statuses. */
@Service
public class UnifiedInboundQueryService {
private static final Set<String> MAIN_STATUSES = Set.of(
"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED");
private static final Set<String> REVIEW_STATUSES = Set.of("UNRECORDED", "PENDING", "APPROVED", "REJECTED");
@Resource private InboundOrderRepository inbounds;
@Resource private UnifiedInboundReadRepository operations;
@Resource private ObjectMapper json;
public PageResult<UnifiedInboundSummary> page(UnifiedInboundQuery query) {
validate(query);
if ("PENDING_REVIEW".equals(query.mainStatus())
|| query.candidateOrderIds() != null && query.candidateOrderIds().isEmpty()) {
return new PageResult<>(List.of(), query.page(), query.size(), 0, 0);
}
Page<InboundOrderEntity> source = inbounds.pageUnifiedEntities(query, internalStatuses(query.mainStatus()));
List<UnifiedInboundSummary> records = summaries(source.getRecords());
return new PageResult<>(records, source.getCurrent(), source.getSize(), source.getTotal(), source.getPages());
}
public List<UnifiedInboundSummary> summaries(Long tenantId, List<Long> orderIds) {
Map<Long, UnifiedInboundSummary> byOrder = new LinkedHashMap<>();
for (UnifiedInboundSummary item : summaries(inbounds.findByOrderIds(tenantId, orderIds)))
byOrder.putIfAbsent(item.inbound().orderId(), item);
return orderIds.stream().distinct().map(byOrder::get).filter(java.util.Objects::nonNull).toList();
}
public UnifiedInboundDetail detail(long tenantId, long orderId) {
InboundOrderEntity inbound = inbounds.findByOrderId(tenantId, orderId);
if (inbound == null) return null;
InboundOrderDetail detail = inbounds.detail(tenantId, inbound);
List<UnifiedInboundDetail.TimelineEvent> timeline = detail.statusHistory().stream()
.map(item -> new UnifiedInboundDetail.TimelineEvent(
"FIRSTLEG", "STATUS", item.fromStatus(), item.toStatus(), item.action(), item.reason(),
item.operatorId(), item.operatorName(), item.occurredAt()))
.toList();
return new UnifiedInboundDetail(summary(inbound, detail,
operations.findPreload(tenantId, inbound.getId()), operations.findPick(tenantId, inbound.getId()),
operations.findEvidence(tenantId, inbound.getId()), deliveryAt(detail.statusHistory()),
operations.findTransport(tenantId, inbound.getId())), timeline);
}
private List<UnifiedInboundSummary> summaries(List<InboundOrderEntity> records) {
Map<Long, Long> owners = records.stream().collect(Collectors.toMap(
InboundOrderEntity::getId, InboundOrderEntity::getTenantId, (left, right) -> left));
var preloads = operations.findPreloads(owners);
var picks = operations.findPicks(owners);
var evidences = operations.findEvidence(owners);
var deliveries = operations.findDeliveryHistory(owners);
var transports = operations.findTransports(owners);
return records.stream().map(inbound -> summary(inbound, null, preloads.get(inbound.getId()),
picks.get(inbound.getId()), evidences.getOrDefault(inbound.getId(), List.of()),
deliveries.get(inbound.getId()) == null ? null : deliveries.get(inbound.getId()).getOccurredAt(),
transports.get(inbound.getId()))).toList();
}
private UnifiedInboundSummary summary(InboundOrderEntity inbound, InboundOrderDetail detail,
com.baiyue.firstleg.split.entity.PreloadPlanEntity preload,
com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity pick,
List<LoadingEvidenceEntity> evidence, LocalDateTime deliveryAt,
InboundTransportSnapshotEntity transport) {
String mainStatus = mainStatus(inbound.getProcessStatus());
String sealNo = evidence.stream().filter(item -> "SEAL".equals(item.getEvidenceType()))
.map(LoadingEvidenceEntity::getSealNo).filter(StringUtils::hasText).findFirst().orElse(null);
var preloadSummary = preload == null ? null : new UnifiedInboundSummary.PreloadSummary(
preload.getStatus(), preload.getVersion(), itemCount(preload.getItemsJson()), preload.getUpdatedAt());
var loadingSummary = pick == null && evidence.isEmpty() ? null : new UnifiedInboundSummary.LoadingSummary(
pick == null ? null : pick.getStatus(), pick == null ? null : pick.getVersion(),
pick == null ? null : pick.getVersion(), pick == null ? null : pick.getExpectedCount(),
pick == null ? null : pick.getPickedCount(), evidence.size(), sealNo,
pick == null ? evidence.stream().map(LoadingEvidenceEntity::getUpdatedAt)
.filter(java.util.Objects::nonNull).max(LocalDateTime::compareTo).orElse(null) : pick.getUpdatedAt());
var deliverySummary = "LOADED".equals(mainStatus) || "DELIVERED".equals(mainStatus)
? new UnifiedInboundSummary.DeliverySummary(
"DELIVERED".equals(mainStatus) ? "COMPLETED" : "PENDING", deliveryAt,
inbound.getExpressTrackingNo()) : null;
var inboundSummary = detail == null
? com.baiyue.firstleg.dto.InboundOrderSummary.from(inbound, transport, null) : detail.summary();
return new UnifiedInboundSummary(inboundSummary, mainStatus,
nextAction(mainStatus, inbound.getProcessStatus(), preload == null ? null : preload.getStatus()),
preloadSummary, loadingSummary, deliverySummary);
}
private LocalDateTime deliveryAt(List<InboundOrderDetail.StatusHistorySummary> history) {
return history.stream()
.filter(item -> "COMPLETE_DELIVERY".equals(item.action()) || "DELIVERED".equals(item.toStatus()))
.map(InboundOrderDetail.StatusHistorySummary::occurredAt)
.filter(java.util.Objects::nonNull).max(LocalDateTime::compareTo).orElse(null);
}
static String mainStatus(String status) {
if (status == null) return null;
return switch (status) {
case "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED" -> "WAIT_MEASURE";
case "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT" -> "WAIT_SPLIT";
case "INBOUNDED" -> "INBOUNDED";
case "LOADING", "LOADED", "DELIVERED" -> status;
default -> null;
};
}
static String nextAction(String mainStatus, String processStatus, String preloadStatus) {
if (mainStatus == null) return null;
return switch (mainStatus) {
case "PENDING_REVIEW" -> "APPROVE_ORDER";
case "WAIT_MEASURE" -> "MEASURE_PENDING".equals(processStatus) ? "CONFIRM_MEASUREMENT" : null;
case "WAIT_SPLIT" -> !"WAIT_SPLIT".equals(processStatus) ? null
: "CONFIRMED".equals(preloadStatus) ? null
: preloadStatus == null ? "SMART_SPLIT" : "CONFIRM_PRELOAD";
case "LOADING" -> "COMPLETE_LOADING";
case "LOADED" -> "COMPLETE_DELIVERY";
case "DELIVERED" -> null;
default -> null;
};
}
private List<String> internalStatuses(String mainStatus) {
if (!StringUtils.hasText(mainStatus)) return List.of();
return switch (mainStatus) {
case "WAIT_MEASURE" -> List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED");
case "WAIT_SPLIT" -> List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT");
case "INBOUNDED", "LOADING", "LOADED", "DELIVERED" -> List.of(mainStatus);
default -> List.of("__NO_MATCH__");
};
}
private int itemCount(String itemsJson) {
if (!StringUtils.hasText(itemsJson)) return 0;
try {
JsonNode node = json.readTree(itemsJson);
return node.isArray() ? node.size() : 0;
} catch (Exception ignored) {
return 0;
}
}
private void validate(UnifiedInboundQuery query) {
if (StringUtils.hasText(query.mainStatus()) && !MAIN_STATUSES.contains(query.mainStatus())) {
throw new com.baiyue.common.core.exception.BizException(
com.baiyue.common.core.api.ResultCode.BAD_REQUEST, "统一订单主状态不合法");
}
if (query.createdFrom() != null && query.createdTo() != null
&& query.createdFrom().isAfter(query.createdTo())) {
throw new com.baiyue.common.core.exception.BizException(
com.baiyue.common.core.api.ResultCode.BAD_REQUEST, "创建时间起点不能晚于终点");
}
if (StringUtils.hasText(query.feeReviewStatus()) && !REVIEW_STATUSES.contains(query.feeReviewStatus())) {
throw new com.baiyue.common.core.exception.BizException(
com.baiyue.common.core.api.ResultCode.BAD_REQUEST, "费用审核状态不合法");
}
if (StringUtils.hasText(query.measurementReviewStatus())
&& !REVIEW_STATUSES.contains(query.measurementReviewStatus())) {
throw new com.baiyue.common.core.exception.BizException(
com.baiyue.common.core.api.ResultCode.BAD_REQUEST, "测量审核状态不合法");
}
}
}
@@ -0,0 +1,82 @@
package com.baiyue.firstleg.split.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest;
import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest;
import com.baiyue.firstleg.split.dto.SplitResponses.PreloadPlanResponse;
import com.baiyue.firstleg.split.dto.SplitResponses.SplitActionResponse;
import com.baiyue.firstleg.split.service.SplitPreloadService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** 平台端分柜、预装清单和装柜中主状态推进接口。 */
@Tag(name = "订单分柜与预装")
@RestController
@RequestMapping("/api/platform/v1/inbounds")
public class PlatformSplitPreloadController {
@Resource private SplitPreloadService service;
@Operation(summary = "智能分柜并生成或覆盖预装清单")
@PostMapping("/{inboundId}/split/smart")
@PreAuthorize("hasAuthority('firstleg:inbound:split')")
@BusinessAudit(event = "INBOUND_SMART_SPLIT", resourceType = "INBOUND", resourceId = "#inboundId",
description = "智能分柜")
public ApiResponse<SplitActionResponse> smart(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody SmartSplitRequest request) {
FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.smartSplit(tenantId, inboundId, user.userId(), user.username(), key, request));
}
@Operation(summary = "提交预装清单确认")
@PostMapping("/{inboundId}/preload/confirming")
@PreAuthorize("hasAuthority('firstleg:inbound:preload')")
@BusinessAudit(event = "INBOUND_PRELOAD_CONFIRMING", resourceType = "INBOUND", resourceId = "#inboundId",
description = "提交预装清单确认")
public ApiResponse<SplitActionResponse> confirming(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ConfirmPreloadRequest request) {
FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.startConfirming(tenantId, inboundId, user.userId(), user.username(), key, request));
}
@Operation(summary = "确认预装清单并进入装柜中")
@PostMapping("/{inboundId}/preload/confirm")
@PreAuthorize("hasAuthority('firstleg:inbound:preload')")
@BusinessAudit(event = "INBOUND_CONFIRM_PRELOAD", resourceType = "INBOUND", resourceId = "#inboundId",
description = "确认预装清单")
public ApiResponse<SplitActionResponse> confirm(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ConfirmPreloadRequest request) {
FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.confirmPreload(tenantId, inboundId, user.userId(), user.username(), key, request));
}
@Operation(summary = "查询预装清单")
@GetMapping("/{inboundId}/preload")
@PreAuthorize("hasAuthority('firstleg:inbound:preload-view')")
public ApiResponse<PreloadPlanResponse> detail(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@PathVariable long inboundId) {
FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.detail(tenantId, inboundId));
}
}
@@ -0,0 +1,12 @@
package com.baiyue.firstleg.split.domain;
import java.util.Set;
/** 预装清单子状态,固定为三态。 */
public final class PreloadStatus {
public static final String PENDING = "PENDING";
public static final String CONFIRMING = "CONFIRMING";
public static final String CONFIRMED = "CONFIRMED";
public static final Set<String> VALUES = Set.of(PENDING, CONFIRMING, CONFIRMED);
private PreloadStatus() {}
}
@@ -0,0 +1,29 @@
package com.baiyue.firstleg.split.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.util.List;
/** 分柜与预装清单写入请求。资料字段本版本不做完整性门禁。 */
public final class SplitRequests {
private SplitRequests() {}
public record SmartSplitRequest(
@NotNull @PositiveOrZero Integer expectedVersion,
List<@Valid PreloadItem> items,
@Size(max = 1000) String remark) {}
public record ConfirmPreloadRequest(@NotNull @PositiveOrZero Integer expectedVersion) {}
public record PreloadItem(
@Size(max = 64) String containerCode,
@Size(max = 128) String cargoReference,
@Positive Integer packageCount,
@PositiveOrZero BigDecimal volumeCbm,
@Size(max = 500) String remark) {}
}
@@ -0,0 +1,15 @@
package com.baiyue.firstleg.split.dto;
import java.time.LocalDateTime;
import java.util.List;
public final class SplitResponses {
private SplitResponses() {}
public record PreloadPlanResponse(Long id, Long inboundId, Long tenantId, String status,
Integer version, List<SplitRequests.PreloadItem> items,
String remark, LocalDateTime createdAt,
LocalDateTime updatedAt) {}
public record SplitActionResponse(String mainStatus, PreloadPlanResponse preloadPlan) {}
}
@@ -0,0 +1,29 @@
package com.baiyue.firstleg.split.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_preload_plan")
public class PreloadPlanEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private String status;
private Integer version;
private String itemsJson;
private String remark;
private Long createdBy;
private String createdByName;
private Long updatedBy;
private String updatedByName;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,25 @@
package com.baiyue.firstleg.split.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_split_operation")
public class SplitOperationEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private Long operatorId;
private String operation;
private String idempotencyKey;
private String status;
private String resultJson;
private LocalDateTime createdAt;
}
@@ -0,0 +1,27 @@
package com.baiyue.firstleg.split.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.split.entity.PreloadPlanEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface PreloadPlanMapper extends BaseMapper<PreloadPlanEntity> {
@Update("UPDATE fl_preload_plan SET items_json=#{itemsJson}, remark=#{remark}, status='PENDING', "
+ "updated_by=#{operatorId}, updated_by_name=#{operatorName}, version=version+1 "
+ "WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND version=#{expectedVersion}")
int replace(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("itemsJson") String itemsJson, @Param("remark") String remark,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName,
@Param("expectedVersion") int expectedVersion);
@Update("UPDATE fl_preload_plan SET status=#{toStatus}, updated_by=#{operatorId}, "
+ "updated_by_name=#{operatorName}, version=version+1 "
+ "WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND status=#{fromStatus} "
+ "AND version=#{expectedVersion}")
int changeStatus(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName,
@Param("expectedVersion") int expectedVersion);
}
@@ -0,0 +1,21 @@
package com.baiyue.firstleg.split.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface SplitInboundMapper {
@Select("SELECT id, tenant_id AS tenantId, process_status AS processStatus, version FROM fl_inbound_order "
+ "WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0")
InboundSnapshot find(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
@Update("UPDATE fl_inbound_order SET process_status='LOADING', updated_by=#{operatorId}, "
+ "version=version+1 WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0 "
+ "AND process_status='WAIT_SPLIT' AND version=#{expectedVersion}")
int beginLoading(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion);
record InboundSnapshot(Long id, Long tenantId, String processStatus, Integer version) {}
}
@@ -0,0 +1,25 @@
package com.baiyue.firstleg.split.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.split.entity.SplitOperationEntity;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface SplitOperationMapper extends BaseMapper<SplitOperationEntity> {
@Insert("INSERT IGNORE INTO fl_split_operation "
+ "(id,tenant_id,inbound_id,operator_id,operation,idempotency_key,status,created_at) "
+ "VALUES(#{id},#{tenantId},#{inboundId},#{operatorId},#{operation},#{key},'PROCESSING',CURRENT_TIMESTAMP(3))")
int claim(@Param("id") long id, @Param("tenantId") long tenantId,
@Param("inboundId") long inboundId, @Param("operatorId") long operatorId,
@Param("operation") String operation, @Param("key") String key);
@Update("UPDATE fl_split_operation SET status='SUCCESS', result_json=#{resultJson} "
+ "WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND operator_id=#{operatorId} "
+ "AND operation=#{operation} AND idempotency_key=#{key} AND status='PROCESSING'")
int complete(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId, @Param("operation") String operation,
@Param("key") String key, @Param("resultJson") String resultJson);
}
@@ -0,0 +1,71 @@
package com.baiyue.firstleg.split.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.firstleg.split.entity.PreloadPlanEntity;
import com.baiyue.firstleg.split.entity.SplitOperationEntity;
import com.baiyue.firstleg.split.mapper.PreloadPlanMapper;
import com.baiyue.firstleg.split.mapper.SplitInboundMapper;
import com.baiyue.firstleg.split.mapper.SplitOperationMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
@Repository
public class SplitRepository {
@Resource private SplitInboundMapper inbounds;
@Resource private PreloadPlanMapper plans;
@Resource private SplitOperationMapper operations;
public SplitInboundMapper.InboundSnapshot findInbound(long tenantId, long inboundId) {
return inbounds.find(tenantId, inboundId);
}
public int beginLoading(long tenantId, long inboundId, long operatorId, int expectedVersion) {
return inbounds.beginLoading(tenantId, inboundId, operatorId, expectedVersion);
}
public PreloadPlanEntity findPlan(long tenantId, long inboundId) {
return plans.selectOne(new LambdaQueryWrapper<PreloadPlanEntity>()
.eq(PreloadPlanEntity::getTenantId, tenantId)
.eq(PreloadPlanEntity::getInboundId, inboundId));
}
public void createPlan(PreloadPlanEntity plan) {
plans.insert(plan);
}
public int replacePlan(long tenantId, long inboundId, String itemsJson, String remark,
long operatorId, String operatorName, int expectedVersion) {
return plans.replace(tenantId, inboundId, itemsJson, remark, operatorId, operatorName, expectedVersion);
}
public int changePlanStatus(long tenantId, long inboundId, String fromStatus, String toStatus,
long operatorId, String operatorName, int expectedVersion) {
return plans.changeStatus(tenantId, inboundId, fromStatus, toStatus,
operatorId, operatorName, expectedVersion);
}
public SplitOperationEntity findOperation(long tenantId, long inboundId, long operatorId,
String operation, String key) {
return operations.selectOne(new LambdaQueryWrapper<SplitOperationEntity>()
.eq(SplitOperationEntity::getTenantId, tenantId)
.eq(SplitOperationEntity::getInboundId, inboundId)
.eq(SplitOperationEntity::getOperatorId, operatorId)
.eq(SplitOperationEntity::getOperation, operation)
.eq(SplitOperationEntity::getIdempotencyKey, key));
}
public void saveOperation(SplitOperationEntity operation) {
operations.insert(operation);
}
public boolean claimOperation(long tenantId, long inboundId, long operatorId,
String operation, String key) {
return operations.claim(IdWorker.getId(), tenantId, inboundId, operatorId, operation, key) == 1;
}
public void completeOperation(long tenantId, long inboundId, long operatorId,
String operation, String key, String resultJson) {
operations.complete(tenantId, inboundId, operatorId, operation, key, resultJson);
}
}
@@ -0,0 +1,213 @@
package com.baiyue.firstleg.split.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.split.domain.PreloadStatus;
import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest;
import com.baiyue.firstleg.split.dto.SplitRequests.PreloadItem;
import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest;
import com.baiyue.firstleg.split.dto.SplitResponses.PreloadPlanResponse;
import com.baiyue.firstleg.split.dto.SplitResponses.SplitActionResponse;
import com.baiyue.firstleg.split.entity.PreloadPlanEntity;
import com.baiyue.firstleg.split.entity.SplitOperationEntity;
import com.baiyue.firstleg.split.mapper.SplitInboundMapper.InboundSnapshot;
import com.baiyue.firstleg.split.repository.SplitRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.List;
/** 分柜、预装清单及其主状态推进;不校验资料完整度和客户确认。 */
@Service
public class SplitPreloadService {
private static final String SMART_SPLIT = "SMART_SPLIT";
private static final String START_CONFIRMING = "START_PRELOAD_CONFIRMING";
private static final String CONFIRM_PRELOAD = "CONFIRM_PRELOAD";
@Resource private SplitRepository repository;
@Resource private InboundStatusHistoryMapper histories;
@Resource private ObjectMapper objectMapper;
@Transactional
public SplitActionResponse smartSplit(long tenantId, long inboundId, long operatorId,
String operatorName, String key, SmartSplitRequest request) {
SplitActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, SMART_SPLIT, key);
if (replay != null) return replay;
InboundSnapshot inbound = requireInbound(tenantId, inboundId);
if (!"WAIT_SPLIT".equals(inbound.processStatus())) {
throw new BizException(ResultCode.CONFLICT, "仅待分柜订单可以生成预装清单");
}
PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId);
String itemsJson = write(request.items() == null ? List.of() : request.items());
if (plan == null) {
if (request.expectedVersion() != 0) throw concurrent();
plan = new PreloadPlanEntity();
plan.setTenantId(tenantId);
plan.setInboundId(inboundId);
plan.setStatus(PreloadStatus.PENDING);
plan.setVersion(0);
plan.setItemsJson(itemsJson);
plan.setRemark(request.remark());
plan.setCreatedBy(operatorId);
plan.setCreatedByName(operatorName);
plan.setUpdatedBy(operatorId);
plan.setUpdatedByName(operatorName);
repository.createPlan(plan);
} else if (repository.replacePlan(tenantId, inboundId, itemsJson, request.remark(),
operatorId, operatorName, request.expectedVersion()) != 1) {
throw concurrent();
}
PreloadPlanResponse result = response(repository.findPlan(tenantId, inboundId));
return complete(tenantId, inboundId, operatorId, SMART_SPLIT, key,
new SplitActionResponse("WAIT_SPLIT", result));
}
@Transactional
public SplitActionResponse startConfirming(long tenantId, long inboundId, long operatorId,
String operatorName, String key,
ConfirmPreloadRequest request) {
SplitActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, START_CONFIRMING, key);
if (replay != null) return replay;
InboundSnapshot inbound = requireInbound(tenantId, inboundId);
requireStatus(inbound, "WAIT_SPLIT", "仅待分柜订单可以提交预装清单确认");
PreloadPlanEntity plan = requirePlan(tenantId, inboundId);
if (PreloadStatus.CONFIRMING.equals(plan.getStatus())) {
return complete(tenantId, inboundId, operatorId, START_CONFIRMING, key,
new SplitActionResponse("WAIT_SPLIT", response(plan)));
}
if (!PreloadStatus.PENDING.equals(plan.getStatus())
|| repository.changePlanStatus(tenantId, inboundId, PreloadStatus.PENDING,
PreloadStatus.CONFIRMING, operatorId, operatorName, request.expectedVersion()) != 1) {
throw concurrent();
}
return complete(tenantId, inboundId, operatorId, START_CONFIRMING, key,
new SplitActionResponse("WAIT_SPLIT", response(repository.findPlan(tenantId, inboundId))));
}
@Transactional
public SplitActionResponse confirmPreload(long tenantId, long inboundId, long operatorId,
String operatorName, String key,
ConfirmPreloadRequest request) {
SplitActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key);
if (replay != null) return replay;
InboundSnapshot inbound = requireInbound(tenantId, inboundId);
if ("LOADING".equals(inbound.processStatus())) {
return complete(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key,
new SplitActionResponse("LOADING", response(repository.findPlan(tenantId, inboundId))));
}
requireStatus(inbound, "WAIT_SPLIT", "仅待分柜订单可以确认预装清单");
PreloadPlanEntity plan = requirePlan(tenantId, inboundId);
if (PreloadStatus.CONFIRMED.equals(plan.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "预装清单已确认但订单状态尚未推进,请刷新重试");
}
if (repository.changePlanStatus(tenantId, inboundId, plan.getStatus(), PreloadStatus.CONFIRMED,
operatorId, operatorName, request.expectedVersion()) != 1) {
throw concurrent();
}
if (repository.beginLoading(tenantId, inboundId, operatorId, inbound.version()) != 1) {
throw concurrent();
}
addHistory(tenantId, inboundId, "WAIT_SPLIT", "LOADING", CONFIRM_PRELOAD,
operatorId, operatorName);
return complete(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key,
new SplitActionResponse("LOADING", response(repository.findPlan(tenantId, inboundId))));
}
public PreloadPlanResponse detail(long tenantId, long inboundId) {
requireInbound(tenantId, inboundId);
return response(requirePlan(tenantId, inboundId));
}
private SplitActionResponse claimOrReplay(long tenantId, long inboundId, long operatorId,
String operation, String key) {
validateKey(key);
if (repository.claimOperation(tenantId, inboundId, operatorId, operation, key)) return null;
SplitOperationEntity existing = repository.findOperation(tenantId, inboundId, operatorId, operation, key);
if (existing == null || !"SUCCESS".equals(existing.getStatus())
|| !StringUtils.hasText(existing.getResultJson())) {
throw new BizException(ResultCode.CONFLICT, "相同幂等请求正在处理中,请稍后重试");
}
try {
return objectMapper.readValue(existing.getResultJson(), SplitActionResponse.class);
} catch (JsonProcessingException e) {
throw new IllegalStateException("无法读取分柜幂等结果", e);
}
}
private SplitActionResponse complete(long tenantId, long inboundId, long operatorId,
String operation, String key, SplitActionResponse result) {
repository.completeOperation(tenantId, inboundId, operatorId, operation, key, write(result));
return result;
}
private InboundSnapshot requireInbound(long tenantId, long inboundId) {
InboundSnapshot inbound = repository.findInbound(tenantId, inboundId);
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "订单不存在");
return inbound;
}
private PreloadPlanEntity requirePlan(long tenantId, long inboundId) {
PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId);
if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "预装清单不存在");
return plan;
}
private static void requireStatus(InboundSnapshot inbound, String expected, String message) {
if (!expected.equals(inbound.processStatus())) throw new BizException(ResultCode.CONFLICT, message);
}
private void addHistory(long tenantId, long inboundId, String from, String to, String action,
long operatorId, String operatorName) {
InboundStatusHistoryEntity history = new InboundStatusHistoryEntity();
history.setTenantId(tenantId);
history.setInboundId(inboundId);
history.setFromStatus(from);
history.setToStatus(to);
history.setAction(action);
history.setOperatorId(operatorId);
history.setOperatorName(operatorName);
histories.insert(history);
}
private PreloadPlanResponse response(PreloadPlanEntity plan) {
if (plan == null) return null;
return new PreloadPlanResponse(plan.getId(), plan.getInboundId(), plan.getTenantId(),
plan.getStatus(), plan.getVersion(), readItems(plan.getItemsJson()), plan.getRemark(),
plan.getCreatedAt(), plan.getUpdatedAt());
}
private List<PreloadItem> readItems(String json) {
if (!StringUtils.hasText(json)) return List.of();
try {
return objectMapper.readValue(json, new TypeReference<List<PreloadItem>>() {});
} catch (JsonProcessingException e) {
throw new IllegalStateException("无法读取预装清单", e);
}
}
private String write(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException e) {
throw new IllegalStateException("无法保存预装清单", e);
}
}
private static void validateKey(String key) {
if (!StringUtils.hasText(key) || key.length() > 128) {
throw new BizException(ResultCode.BAD_REQUEST, "Idempotency-Key不能为空且不能超过128个字符");
}
}
private static BizException concurrent() {
return new BizException(ResultCode.CONFLICT, "数据已被其他人修改,请刷新后重试");
}
}
@@ -0,0 +1,62 @@
SET NAMES utf8mb4;
USE baiyue_firstleg;
/* 装柜证据和派送完成;不设置证据完整度门禁,允许后续覆盖编辑。 */
CREATE TABLE IF NOT EXISTS fl_loading_evidence (
id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
inbound_id BIGINT UNSIGNED NOT NULL,
evidence_type VARCHAR(32) NOT NULL COMMENT 'PHOTO_STEP_1..4/SEAL',
file_id BIGINT UNSIGNED NULL COMMENT 'File 服务逻辑文件ID',
file_name VARCHAR(255) NULL,
seal_no VARCHAR(64) NULL,
remark VARCHAR(1000) NULL,
version INT NOT NULL DEFAULT 0,
created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL,
updated_by BIGINT UNSIGNED NOT NULL,
updated_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_loading_evidence_type (tenant_id, inbound_id, evidence_type),
KEY idx_loading_evidence_time (tenant_id, inbound_id, updated_at),
CONSTRAINT fk_loading_evidence_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='订单装柜四步照片和铅封证据';
CREATE TABLE IF NOT EXISTS fl_loading_operation (
id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
inbound_id BIGINT UNSIGNED NOT NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operation VARCHAR(48) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING',
result_json JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
completed_at DATETIME(3) NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_loading_operation (tenant_id, inbound_id, operator_id, operation, idempotency_key),
KEY idx_loading_operation_time (tenant_id, inbound_id, created_at),
CONSTRAINT fk_loading_operation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='装柜与派送写操作幂等记录';
CREATE TABLE IF NOT EXISTS fl_loading_pick_task (
id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
inbound_id BIGINT UNSIGNED NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/IN_PROGRESS/COMPLETED',
version INT NOT NULL DEFAULT 0,
expected_count INT UNSIGNED NULL,
picked_count INT UNSIGNED NULL,
remark VARCHAR(1000) NULL,
updated_by BIGINT UNSIGNED NOT NULL,
updated_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_loading_pick_tenant_inbound (tenant_id, inbound_id),
KEY idx_loading_pick_status (tenant_id, status, updated_at),
CONSTRAINT fk_loading_pick_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='PDA拣货任务及结果';
@@ -0,0 +1,39 @@
SET NAMES utf8mb4;
USE baiyue_firstleg;
/* 三期分柜与预装清单;独立于现有测量、计费表,可重复部署。 */
CREATE TABLE IF NOT EXISTS fl_preload_plan (
id BIGINT UNSIGNED NOT NULL COMMENT '预装清单ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单/订单ID',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/CONFIRMING/CONFIRMED',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
items_json JSON NOT NULL COMMENT '可编辑分柜明细快照',
remark VARCHAR(1000) NULL COMMENT '预装备注',
created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL,
updated_by BIGINT UNSIGNED NOT NULL,
updated_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_preload_tenant_inbound (tenant_id, inbound_id),
KEY idx_preload_status (tenant_id, status, updated_at),
CONSTRAINT fk_preload_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='订单分柜预装清单';
CREATE TABLE IF NOT EXISTS fl_split_operation (
id BIGINT UNSIGNED NOT NULL COMMENT '幂等记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
inbound_id BIGINT UNSIGNED NOT NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operation VARCHAR(40) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING' COMMENT 'PROCESSING/SUCCESS',
result_json JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_split_operation (tenant_id, inbound_id, operator_id, operation, idempotency_key),
KEY idx_split_operation_time (tenant_id, inbound_id, created_at),
CONSTRAINT fk_split_operation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='分柜操作幂等与审计索引';
@@ -3,6 +3,9 @@ package com.baiyue.firstleg.controller;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.InboundOrderReference;
import com.baiyue.firstleg.service.InboundOrderCreationService;
import com.baiyue.firstleg.service.UnifiedInboundQueryService;
import com.baiyue.firstleg.dto.UnifiedInboundQuery;
import com.baiyue.common.core.api.PageResult;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
@@ -22,6 +25,8 @@ import static org.mockito.Mockito.when;
class InternalInboundOrderReferenceControllerTest {
@MockBean
private InboundOrderCreationService service;
@MockBean
private UnifiedInboundQueryService unifiedQueryService;
@Resource
private InternalInboundOrderController controller;
@@ -44,4 +49,18 @@ class InternalInboundOrderReferenceControllerTest {
assertThatThrownBy(() -> controller.references("bad-token", request))
.isInstanceOf(BizException.class);
}
@Test
void unifiedQueryRequiresInternalTokenAndForwardsBatchRequest() {
UnifiedInboundQuery request = new UnifiedInboundQuery(
10001L, null, null, "WAIT_SPLIT", null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null,
null, null, 1, 20);
when(unifiedQueryService.page(request)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
assertThat(controller.unifiedPage("internal-token", request).data().records()).isEmpty();
assertThatThrownBy(() -> controller.unifiedPage("bad-token", request))
.isInstanceOf(BizException.class);
verify(unifiedQueryService).page(request);
}
}
@@ -0,0 +1,66 @@
package com.baiyue.firstleg.loading.service;
import com.baiyue.firstleg.client.LoadingEvidenceFileClient;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteLoadingRequest;
import com.baiyue.firstleg.loading.dto.LoadingRequests.UpsertEvidenceRequest;
import com.baiyue.firstleg.loading.dto.LoadingResponses.LoadingActionResponse;
import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity;
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.Snapshot;
import com.baiyue.firstleg.loading.repository.LoadingRepository;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class LoadingDeliveryServiceTest {
@Mock LoadingRepository repository;
@Mock InboundStatusHistoryMapper histories;
@Mock LoadingEvidenceFileClient files;
@InjectMocks LoadingDeliveryService service;
@BeforeEach
void injectObjectMapper() throws Exception {
var field = LoadingDeliveryService.class.getDeclaredField("objectMapper");
field.setAccessible(true);
field.set(service, new ObjectMapper());
var token = LoadingDeliveryService.class.getDeclaredField("internalToken");
token.setAccessible(true);
token.set(service, "token");
}
@Test
void completesLoadingWithoutEvidenceGate() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
when(repository.completeLoading(10, 20, 7, 3)).thenReturn(1);
when(repository.listEvidence(10, 20)).thenReturn(List.of());
LoadingActionResponse result = service.completeLoading(10, 20, 7, "admin", "k1", new CompleteLoadingRequest(3, null));
assertThat(result.mainStatus()).isEqualTo("LOADED");
verify(histories).insert(any(InboundStatusHistoryEntity.class));
}
@Test
void evidenceCanBeEditedAndDoesNotChangeMainStatus() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
when(repository.findEvidence(10, 20, "PHOTO_STEP_1")).thenReturn(null);
when(repository.listEvidence(10, 20)).thenReturn(List.of());
LoadingActionResponse result = service.upsertEvidence(10, 20, 7, "admin", "k2",
new UpsertEvidenceRequest(0, "PHOTO_STEP_1", null, "photo.jpg", null, "updated"));
assertThat(result.mainStatus()).isEqualTo("LOADING");
verify(repository).createEvidence(any(LoadingEvidenceEntity.class));
verify(repository, never()).completeLoading(anyLong(), anyLong(), anyLong(), anyInt());
}
}
@@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.firstleg.dto.InboundOrderQuery;
import com.baiyue.firstleg.dto.UnifiedInboundQuery;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity;
@@ -22,6 +23,9 @@ import jakarta.annotation.Resource;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.time.LocalDate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
@@ -38,6 +42,34 @@ class InboundOrderRepositoryTest {
@MockBean private InboundInternalNoteMapper notes;
@MockBean private InboundStatusHistoryMapper histories;
@Resource private InboundOrderRepository repository;
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void unifiedQueryPushesExpandedFiltersIntoDatabasePage() {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), "test"), InboundOrderEntity.class);
when(orders.selectPage(any(Page.class), any(Wrapper.class)))
.thenReturn(new Page<InboundOrderEntity>(1, 20));
UnifiedInboundQuery query = new UnifiedInboundQuery(
10001L, null, null, "LOADING", 30001L, 40001L, 50001L,
"RK-", "APPROVED", LocalDate.of(2026, 8, 6), "APPROVED", "美森",
"MSCU", "CAB", LocalDate.of(2026, 8, 8), true, true, "UNRECORDED",
List.of(77L, 78L), null, null, 1, 20);
repository.pageUnifiedEntities(query, List.of("LOADING"));
ArgumentCaptor<Wrapper<InboundOrderEntity>> captor = ArgumentCaptor.forClass(Wrapper.class);
verify(orders).selectPage(any(Page.class), captor.capture());
String sql = captor.getValue().getCustomSqlSegment();
assertThat(sql).contains("inbound_code", "order_status_snapshot", "expected_arrival_at",
"channel_name", "container_mode", "measure_review_status", "order_id IN",
"fl_inbound_fee", "fl_inbound_transport_snapshot", "sailing_at", "loading_status");
LambdaQueryWrapper<InboundOrderEntity> wrapper =
(LambdaQueryWrapper<InboundOrderEntity>) captor.getValue();
assertThat(wrapper.getParamNameValuePairs().values())
.contains(10001L, 30001L, 40001L, 50001L, "%RK-%", "APPROVED", "美森",
"MSCU", "CAB", 77L, 78L);
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void shouldApplyTenantCompanyAndWarehouseFilters() {
@@ -138,7 +138,7 @@ class InboundMeasurementServiceTest {
}
@Test
void submitRejectsAnyUnconfirmedWeightWithoutChangingState() {
void submitDoesNotBlockOnUnconfirmedWeight() {
InboundOrderEntity inbound = inbound("MEASURING");
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(30L);
@@ -151,13 +151,43 @@ class InboundMeasurementServiceTest {
when(repository.listItems(1L, 30L)).thenReturn(List.of(item));
when(repository.listWeights(1L, 10L)).thenReturn(List.of(
weight(20L, "20", "KG", "PENDING_CONFIRM", 0)));
when(repository.updateMeasurement(measurement)).thenReturn(true);
when(repository.updateInbound(inbound)).thenReturn(true);
assertThatThrownBy(() -> service.submit(1L, 10L, 9L,
new InboundMeasurementRequests.SubmitRequest(30L, 0)))
.isInstanceOf(BizException.class)
.hasMessageContaining("必须先完成确认");
InboundMeasurementResponses.MeasurementSubmitted result = service.submit(1L, 10L, 9L,
new InboundMeasurementRequests.SubmitRequest(30L, 0));
assertThat(result.processStatus()).isEqualTo("MEASURE_PENDING");
assertThat(inbound.getActualGrossWeightKg()).isNull();
verify(repository).updateMeasurement(measurement);
verify(repository).updateInbound(inbound);
}
@Test
void editingAndResubmittingInboundMeasurementDoesNotRollBackMainStatus() {
InboundOrderEntity inbound = inbound("WAIT_SPLIT");
inbound.setMeasureReviewStatus("APPROVED");
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(30L);
measurement.setStatus("APPROVED");
measurement.setVersion(2);
measurement.setTotalPackageCount(4);
measurement.setTotalVolumeCbm(new BigDecimal("2.0000"));
measurement.setVolumeWeightKg(new BigDecimal("334.000"));
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findMeasurement(1L, 10L, 30L)).thenReturn(measurement);
when(repository.listItems(1L, 30L)).thenReturn(List.of());
when(repository.listWeights(1L, 10L)).thenReturn(List.of());
when(repository.updateInbound(inbound)).thenReturn(true);
InboundMeasurementResponses.MeasurementSubmitted result = service.submit(1L, 10L, 9L,
new InboundMeasurementRequests.SubmitRequest(30L, 2));
assertThat(result.processStatus()).isEqualTo("WAIT_SPLIT");
assertThat(result.measureReviewStatus()).isEqualTo("APPROVED");
assertThat(measurement.getStatus()).isEqualTo("APPROVED");
verify(repository, never()).updateMeasurement(any());
verify(repository, never()).updateInbound(any());
verify(repository).updateInbound(inbound);
}
private InboundOrderEntity inbound(String status) {
@@ -104,6 +104,31 @@ class MeasurementBatchServiceTest {
org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(), any());
}
@Test
void approvedManualMeasurementCanBeEditedAsDraftBeforeReviewCompletes() {
InboundMeasurementEntity existing = new InboundMeasurementEntity();
existing.setId(100L);
existing.setTenantId(1L);
existing.setInboundId(10L);
existing.setSourceType("MANUAL");
existing.setStatus("APPROVED");
existing.setVersion(3);
when(repository.findMeasurement(1L, 10L, 100L)).thenReturn(existing);
when(repository.updateMeasurement(existing)).thenReturn(true);
MeasurementBatchCommand command = new MeasurementBatchCommand(
1L, 10L, 100L, 3, "MANUAL", "PLATFORM", null, null, null,
LocalDateTime.now(), null, "CKW-2", new BigDecimal("21"),
new BigDecimal("0.2"), "服装", 9L, List.of(item(BigDecimal.ONE)));
InboundMeasurementEntity saved = service.saveBatch(command);
assertThat(saved.getStatus()).isEqualTo("DRAFT");
verify(repository).updateMeasurement(existing);
verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L),
org.mockito.ArgumentMatchers.eq(100L), any());
}
private MeasurementBatchCommand.Item item(BigDecimal unitWeight) {
return new MeasurementBatchCommand.Item(
"001", "A", 2, new BigDecimal("40"), new BigDecimal("30"),
@@ -7,10 +7,12 @@ import com.baiyue.firstleg.dto.ExceptionReleaseRequest;
import com.baiyue.firstleg.dto.MeasurementReviewRequest;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.repository.InboundWorkflowRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
@@ -70,7 +72,11 @@ class MeasurementReviewServiceTest {
verify(fixture.repository).transitionReview(10001L, 10L, 9L, 3, true);
verify(fixture.repository).transitionMeasurement(10001L, 10L, 20L, "APPROVED");
verify(fixture.repository).insertReview(any());
verify(fixture.repository).insertHistory(any());
ArgumentCaptor<InboundStatusHistoryEntity> history =
ArgumentCaptor.forClass(InboundStatusHistoryEntity.class);
verify(fixture.repository).insertHistory(history.capture());
assertThat(history.getValue().getFromStatus()).isEqualTo("MEASURE_PENDING");
assertThat(history.getValue().getToStatus()).isEqualTo("WAIT_SPLIT");
}
@Test
@@ -9,10 +9,9 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.transaction.annotation.Transactional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(MeasurementSubmissionService.class)
@@ -38,7 +37,7 @@ class MeasurementSubmissionServiceTest {
assertThat(service.submit(10001L, 88L, 9L, request)).isSameAs(submitted);
verify(locations).occupy(10001L, 88L, 9L, 30L, 3);
verifyNoInteractions(locations);
}
@Test
@@ -47,10 +46,7 @@ class MeasurementSubmissionServiceTest {
var submitted = new InboundMeasurementResponses.MeasurementSubmitted(
30L, 3, "MEASURE_PENDING", "PENDING", "STANDARD", null, null, null);
when(measurements.submit(10001L, 88L, 9L, request)).thenReturn(submitted);
doThrow(new IllegalStateException("warehouse unavailable"))
.when(locations).occupy(10001L, 88L, 9L, 30L, 3);
assertThatThrownBy(() -> service.submit(10001L, 88L, 9L, request))
.isInstanceOf(IllegalStateException.class);
assertThat(service.submit(10001L, 88L, 9L, request)).isSameAs(submitted);
verifyNoInteractions(locations);
}
}
@@ -0,0 +1,54 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.UnifiedInboundQuery;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class UnifiedInboundQueryServiceTest {
@Test
void mapsAllCompatibleInternalStatesToProductMainStatuses() {
assertThat(UnifiedInboundQueryService.mainStatus("WAIT_MEASURE")).isEqualTo("WAIT_MEASURE");
assertThat(UnifiedInboundQueryService.mainStatus("MEASURING")).isEqualTo("WAIT_MEASURE");
assertThat(UnifiedInboundQueryService.mainStatus("MEASURE_PENDING")).isEqualTo("WAIT_MEASURE");
assertThat(UnifiedInboundQueryService.mainStatus("MEASURE_REJECTED")).isEqualTo("WAIT_MEASURE");
assertThat(UnifiedInboundQueryService.mainStatus("WAIT_PRICING")).isEqualTo("WAIT_SPLIT");
assertThat(UnifiedInboundQueryService.mainStatus("FEE_CONFIRMING")).isEqualTo("WAIT_SPLIT");
assertThat(UnifiedInboundQueryService.mainStatus("INBOUNDING")).isEqualTo("WAIT_SPLIT");
assertThat(UnifiedInboundQueryService.mainStatus("INBOUNDED")).isEqualTo("INBOUNDED");
assertThat(UnifiedInboundQueryService.mainStatus("LOADING")).isEqualTo("LOADING");
assertThat(UnifiedInboundQueryService.mainStatus("LOADED")).isEqualTo("LOADED");
assertThat(UnifiedInboundQueryService.mainStatus("DELIVERED")).isEqualTo("DELIVERED");
assertThat(UnifiedInboundQueryService.mainStatus("VOIDED")).isNull();
}
@Test
void nextActionReflectsExecutableMeasurementAndPreloadSubstates() {
assertThat(UnifiedInboundQueryService.nextAction("WAIT_MEASURE", "MEASURING", null)).isNull();
assertThat(UnifiedInboundQueryService.nextAction("WAIT_MEASURE", "MEASURE_PENDING", null))
.isEqualTo("CONFIRM_MEASUREMENT");
assertThat(UnifiedInboundQueryService.nextAction("WAIT_SPLIT", "WAIT_SPLIT", null))
.isEqualTo("SMART_SPLIT");
assertThat(UnifiedInboundQueryService.nextAction("WAIT_SPLIT", "WAIT_SPLIT", "PENDING"))
.isEqualTo("CONFIRM_PRELOAD");
assertThat(UnifiedInboundQueryService.nextAction("WAIT_SPLIT", "INBOUNDING", null)).isNull();
assertThat(UnifiedInboundQueryService.nextAction("WAIT_SPLIT", "WAIT_SPLIT", "CONFIRMED")).isNull();
assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "INBOUNDED", null)).isNull();
assertThat(UnifiedInboundQueryService.nextAction("LOADED", "LOADED", null))
.isEqualTo("COMPLETE_DELIVERY");
assertThat(UnifiedInboundQueryService.nextAction("DELIVERED", "DELIVERED", null)).isNull();
}
@Test
void rejectsUnknownReviewStatusBeforeRepositoryQuery() {
UnifiedInboundQuery query = new UnifiedInboundQuery(
null, null, null, null, null, null, null, null,
null, null, "UNKNOWN", null, null, null, null, null, null, "INVALID",
null, null, null, 1, 20);
assertThatThrownBy(() -> new UnifiedInboundQueryService().page(query))
.isInstanceOf(BizException.class);
}
}
@@ -0,0 +1,30 @@
package com.baiyue.firstleg.split.controller;
import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest;
import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
class PlatformSplitPreloadControllerContractTest {
@Test
void shouldDeclareStableOperationsAndButtonPermissions() throws Exception {
assertAuthority("smart", "hasAuthority('firstleg:inbound:split')", SmartSplitRequest.class);
assertAuthority("confirming", "hasAuthority('firstleg:inbound:preload')", ConfirmPreloadRequest.class);
assertAuthority("confirm", "hasAuthority('firstleg:inbound:preload')", ConfirmPreloadRequest.class);
assertThat(PlatformSplitPreloadController.class
.getDeclaredMethod("detail", com.baiyue.common.security.AuthenticatedUser.class,
long.class, long.class).getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('firstleg:inbound:preload-view')");
}
private void assertAuthority(String name, String expected, Class<?> requestType) throws Exception {
Method method = PlatformSplitPreloadController.class.getDeclaredMethod(name,
com.baiyue.common.security.AuthenticatedUser.class, long.class, long.class,
String.class, requestType);
assertThat(method.getAnnotation(PreAuthorize.class).value()).isEqualTo(expected);
}
}
@@ -0,0 +1,71 @@
package com.baiyue.firstleg.split.service;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest;
import com.baiyue.firstleg.split.dto.SplitRequests.PreloadItem;
import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest;
import com.baiyue.firstleg.split.dto.SplitResponses.SplitActionResponse;
import com.baiyue.firstleg.split.entity.PreloadPlanEntity;
import com.baiyue.firstleg.split.mapper.SplitInboundMapper.InboundSnapshot;
import com.baiyue.firstleg.split.repository.SplitRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class SplitPreloadServiceTest {
@Mock SplitRepository repository;
@Mock InboundStatusHistoryMapper histories;
@InjectMocks SplitPreloadService service;
private final ObjectMapper objectMapper = new ObjectMapper();
@BeforeEach
void objectMapper() throws Exception {
var field = SplitPreloadService.class.getDeclaredField("objectMapper");
field.setAccessible(true);
field.set(service, objectMapper);
}
@Test
void smartSplitCreatesEditablePendingPlan() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 3));
when(repository.findPlan(10, 20)).thenReturn(null);
SmartSplitRequest request = new SmartSplitRequest(0,
List.of(new PreloadItem("C1", "A", 2, BigDecimal.ONE, null)), "r");
SplitActionResponse result = service.smartSplit(10, 20, 7, "admin", "k2", request);
assertThat(result.mainStatus()).isEqualTo("WAIT_SPLIT");
verify(repository).createPlan(any(PreloadPlanEntity.class));
}
@Test
void confirmsPlanAndMovesInboundToLoading() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 4));
PreloadPlanEntity plan = new PreloadPlanEntity();
plan.setTenantId(10L); plan.setInboundId(20L); plan.setStatus("CONFIRMING"); plan.setVersion(1);
plan.setItemsJson("[]");
when(repository.findPlan(10, 20)).thenReturn(plan);
when(repository.changePlanStatus(10, 20, "CONFIRMING", "CONFIRMED", 7, "admin", 1)).thenReturn(1);
when(repository.beginLoading(10, 20, 7, 4)).thenReturn(1);
SplitActionResponse result = service.confirmPreload(10, 20, 7, "admin", "k3",
new ConfirmPreloadRequest(1));
assertThat(result.mainStatus()).isEqualTo("LOADING");
verify(repository).beginLoading(10, 20, 7, 4);
}
}
@@ -0,0 +1,62 @@
package com.baiyue.order.client;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** Typed internal read contract from Order service to Firstleg service. */
@FeignClient(name = "firstleg-service", contextId = "orderFirstlegUnifiedOrderClient",
url = "${baiyue.clients.firstleg.url:}", path = "/internal/firstleg/v1/inbounds/unified-orders")
public interface FirstlegUnifiedOrderClient {
@PostMapping("/query")
ApiResponse<PageResult<Summary>> page(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody Query request);
@PostMapping("/summaries")
ApiResponse<List<Summary>> summaries(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody BatchRequest request);
@PostMapping("/detail")
ApiResponse<Detail> detail(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody DetailRequest request);
record Query(Long tenantId, String keyword, String orderNo, String mainStatus, Long customerId,
Long receivingWarehouseId, Long destinationWarehouseId,
String inboundCode, String internalOrderStatus, LocalDate expectedArrivalDate,
String feeReviewStatus, String channelName, String containerNo, String cabinetNo,
LocalDate sailingDate, Boolean loaded, Boolean fullContainer,
String measurementReviewStatus, List<Long> candidateOrderIds,
LocalDateTime createdFrom, LocalDateTime createdTo, long page, long size) {}
record BatchRequest(Long tenantId, List<Long> orderIds) {}
record DetailRequest(long tenantId, long orderId) {}
record Summary(Inbound inbound, String mainStatus, String nextAction,
Preload preload, Loading loading, Delivery delivery) {}
record Detail(Summary summary, List<Timeline> timeline) {}
record Inbound(Long id, Long tenantId, Long orderId, String orderNo, String inboundCode,
String processStatus, String orderStatusSnapshot, String measureReviewStatus,
Integer version,
Long customerId, String customerName, Long warehouseId, String warehouseName,
Long deliveryWarehouseId, String deliveryWarehouseName, String channelName,
Integer totalPackageCount, BigDecimal actualGrossWeightKg, BigDecimal actualVolumeCbm,
BigDecimal chargeableWeightKg, LocalDateTime measureCompletedAt,
String loadingStatus, String containerNo, String cabinetNo, String vesselName, String voyageNo,
LocalDateTime sailingAt, LocalDateTime portArrivalAt,
String expressTrackingNo, LocalDateTime createdAt, LocalDateTime updatedAt) {}
record Preload(String status, Integer version, Integer itemCount, LocalDateTime updatedAt) {}
record Loading(String status, Integer loadingVersion, Integer pickingVersion,
Integer expectedCount, Integer pickedCount, Integer evidenceCount,
String sealNo, LocalDateTime updatedAt) {}
record Delivery(String status, LocalDateTime completedAt, String trackingNo) {}
record Timeline(String source, String eventType, String fromStatus, String toStatus,
String action, String remark, Long operatorId, String operatorName,
LocalDateTime occurredAt) {}
}
@@ -0,0 +1,45 @@
package com.baiyue.order.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.order.dto.UnifiedOrderQuery;
import com.baiyue.order.dto.UnifiedOrderResponse;
import com.baiyue.order.service.OrderScopeGuard;
import com.baiyue.order.service.UnifiedOrderQueryService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** Merchant product-level order query restricted to the authenticated customer. */
@Tag(name = "Merchant unified orders / 客户统一订单")
@RestController
@RequestMapping("/api/merchant/v1/orders")
public class MerchantUnifiedOrderController {
@Resource private UnifiedOrderQueryService service;
@GetMapping
@PreAuthorize("hasAuthority('merchant:order:list')")
@Operation(summary = "我的统一订单列表")
public ApiResponse<PageResult<UnifiedOrderResponse.Row>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid UnifiedOrderQuery query) {
return ApiResponse.success(service.page(OrderScopeGuard.merchant(user), query));
}
@GetMapping("/{orderId}")
@PreAuthorize("hasAuthority('merchant:order:view')")
@Operation(summary = "我的统一订单详情", description = "仅返回当前客户订单,不包含平台内部备注。")
public ApiResponse<UnifiedOrderResponse.Detail> detail(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long orderId) {
return ApiResponse.success(service.detail(OrderScopeGuard.merchant(user), orderId, false));
}
}
@@ -61,8 +61,8 @@ public class PlatformFirstLegOrderController {
return ApiResponse.success(service.createDraft(scopedTenant, user.userId(), request, idempotencyKey));
}
@BusinessAudit(event = "ORDER_UPDATE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "平台编辑草稿")
@Operation(summary = "平台编辑草稿", description = "在明确指定的目标租户内编辑草稿或驳回订单。")
@BusinessAudit(event = "ORDER_UPDATE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "平台编辑订单资料")
@Operation(summary = "平台编辑订单", description = "在待审核阶段编辑订单资料;编辑不会将订单退回草稿状态。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "编辑成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "无平台编辑权限"),
@@ -75,7 +75,7 @@ public class PlatformFirstLegOrderController {
@Parameter(description = "订单 ID", required = true) @PathVariable long id,
@Valid @RequestBody OrderRequests.SaveRequest request) {
long scopedTenant = OrderScopeGuard.platform(user, tenantId);
service.updateDraft(scopedTenant, user.userId(), id, request);
service.updateSubmittedOrder(scopedTenant, user.userId(), id, request);
return ApiResponse.success(null);
}
@@ -0,0 +1,50 @@
package com.baiyue.order.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.order.dto.UnifiedOrderQuery;
import com.baiyue.order.dto.UnifiedOrderResponse;
import com.baiyue.order.service.OrderScopeGuard;
import com.baiyue.order.service.UnifiedOrderQueryService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** Platform product-level order query. Legacy first-leg paths remain available. */
@Tag(name = "Platform unified orders / 平台统一订单")
@RestController
@RequestMapping("/api/platform/v1/orders")
public class PlatformUnifiedOrderController {
@Resource private UnifiedOrderQueryService service;
@GetMapping
@PreAuthorize("hasAuthority('platform:order:list')")
@Operation(summary = "统一订单列表", description = "返回七态产品主状态及测量、预装、装柜、派送摘要。")
public ApiResponse<PageResult<UnifiedOrderResponse.Row>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) Long tenantId,
@Valid UnifiedOrderQuery query) {
OrderScopeGuard.platform(user);
if (tenantId != null) OrderScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.page(tenantId, query));
}
@GetMapping("/{orderId}")
@PreAuthorize("hasAuthority('platform:order:list')")
@Operation(summary = "统一订单详情", description = "合并订单和履约时间线;平台响应包含内部备注。")
public ApiResponse<UnifiedOrderResponse.Detail> detail(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long orderId) {
return ApiResponse.success(service.detail(OrderScopeGuard.platform(user, tenantId), orderId, true));
}
}
@@ -0,0 +1,42 @@
package com.baiyue.order.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Size;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDate;
import java.time.LocalDateTime;
/** Unified product order list filters shared by platform and merchant entry points. */
public record UnifiedOrderQuery(
@Size(max = 64) String keyword,
@Size(max = 64) String orderNo,
@Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT",
"LOADING", "LOADED", "DELIVERED"})
@Size(max = 32) String mainStatus,
Long customerId,
Long receivingWarehouseId,
Long destinationWarehouseId,
@Size(max = 64) String inboundCode,
@Size(max = 32) String internalOrderStatus,
LocalDate expectedArrivalDate,
@Size(max = 32) String feeReviewStatus,
@Size(max = 128) String channelName,
@Size(max = 64) String containerNo,
@Size(max = 64) String cabinetNo,
LocalDate sailingDate,
Boolean loaded,
Boolean fullContainer,
@Size(max = 32) String measurementReviewStatus,
@Size(max = 64) String fbaCode,
LocalDateTime createdFrom,
LocalDateTime createdTo,
@Min(1) long page,
@Min(1) @Max(200) long size
) {
public UnifiedOrderQuery {
if (page == 0) page = 1;
if (size == 0) size = 20;
}
}
@@ -0,0 +1,45 @@
package com.baiyue.order.dto;
import com.baiyue.order.entity.OrderStatus;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** Stable product-level order list/detail responses. */
public final class UnifiedOrderResponse {
private UnifiedOrderResponse() {}
public record Row(
Long orderId, Long tenantId, String orderNo, String mainStatus, String nextAction,
OrderStatus internalOrderStatus,
Long customerId, String customerName, String channelName,
Long receivingWarehouseId, String receivingWarehouseName,
Long destinationWarehouseId, String destinationWarehouseName,
Long inboundId, Integer inboundVersion, String inboundCode, LocalDate expectedShipDate,
Integer totalPackageCount, BigDecimal actualGrossWeightKg, BigDecimal actualVolumeCbm,
MeasurementSummary measurement, PreloadSummary preload,
LoadingSummary loading, DeliverySummary delivery, TransportSummary transport,
LocalDateTime createdAt, LocalDateTime updatedAt
) {}
public record Detail(Row summary, OrderDetailResponse order,
List<TimelineEvent> timeline) {}
public record MeasurementSummary(String reviewStatus, BigDecimal actualGrossWeightKg,
BigDecimal actualVolumeCbm, BigDecimal chargeableWeightKg,
LocalDateTime completedAt) {}
public record PreloadSummary(String status, Integer version, Integer itemCount, LocalDateTime updatedAt) {}
/** loadingVersion currently aliases pickingVersion; inboundVersion is the C04 loading expectedVersion. */
public record LoadingSummary(String status, Integer loadingVersion, Integer pickingVersion,
Integer expectedCount, Integer pickedCount, Integer evidenceCount,
String sealNo, LocalDateTime updatedAt) {}
public record DeliverySummary(String status, LocalDateTime completedAt, String trackingNo) {}
public record TransportSummary(String loadingStatus, String containerNo, String cabinetNo,
String vesselName, String voyageNo, LocalDateTime sailingAt,
LocalDateTime portArrivalAt) {}
public record TimelineEvent(String source, String eventType, String mainStatus, String action,
String remark, Long operatorId, String operatorName,
LocalDateTime occurredAt) {}
}
@@ -5,6 +5,7 @@ import jakarta.annotation.Resource;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.dto.MerchantInboundOrderQuery;
import com.baiyue.order.dto.UnifiedOrderQuery;
import com.baiyue.order.entity.*;
import com.baiyue.order.mapper.*;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
@@ -154,6 +155,45 @@ public class OrderRepository {
return PageResult.from(orderMapper.selectPage(new Page<>(query.page(), query.size()), wrapper));
}
public PageResult<OrderEntity> unifiedPage(Long tenantId, UnifiedOrderQuery query,
List<OrderStatus> statuses) {
LambdaQueryWrapper<OrderEntity> wrapper = new LambdaQueryWrapper<OrderEntity>()
.eq(tenantId != null, OrderEntity::getTenantId, tenantId)
.in(OrderEntity::getStatus, statuses)
.like(StringUtils.hasText(query.orderNo()), OrderEntity::getOrderNo, query.orderNo())
.eq(query.customerId() != null, OrderEntity::getCustomerId, query.customerId())
.eq(query.receivingWarehouseId() != null, OrderEntity::getReceivingWarehouseId,
query.receivingWarehouseId())
.eq(query.destinationWarehouseId() != null, OrderEntity::getDestinationWarehouseId,
query.destinationWarehouseId())
.eq(StringUtils.hasText(query.internalOrderStatus()), OrderEntity::getStatus,
StringUtils.hasText(query.internalOrderStatus()) ? OrderStatus.valueOf(query.internalOrderStatus()) : null)
.eq(StringUtils.hasText(query.channelName()), OrderEntity::getChannelName, query.channelName())
.eq(query.fullContainer() != null, OrderEntity::getContainerMode,
query.fullContainer() == null ? null : query.fullContainer() ? "FULL_CONTAINER" : "LCL")
.apply(StringUtils.hasText(query.fbaCode()),
"EXISTS (SELECT 1 FROM ord_order_cargo c WHERE c.tenant_id = ord_order.tenant_id "
+ "AND c.order_id = ord_order.id AND c.deleted = 0 AND c.sku_no LIKE CONCAT('%', {0}, '%'))",
query.fbaCode())
.ge(query.createdFrom() != null, OrderEntity::getCreatedAt, query.createdFrom())
.le(query.createdTo() != null, OrderEntity::getCreatedAt, query.createdTo())
.and(StringUtils.hasText(query.keyword()), nested -> nested
.like(OrderEntity::getOrderNo, query.keyword())
.or().like(OrderEntity::getCustomerName, query.keyword()))
.orderByDesc(OrderEntity::getCreatedAt).orderByDesc(OrderEntity::getId);
return PageResult.from(orderMapper.selectPage(new Page<>(query.page(), query.size()), wrapper));
}
public List<Long> findOrderIdsBySkuNo(Long tenantId, String fbaCode) {
if (!StringUtils.hasText(fbaCode)) return List.of();
QueryWrapper<OrderCargoEntity> wrapper = new QueryWrapper<OrderCargoEntity>()
.select("DISTINCT order_id")
.eq(tenantId != null, "tenant_id", tenantId)
.eq("deleted", 0)
.like("sku_no", fbaCode);
return cargoMapper.selectObjs(wrapper).stream().map(value -> ((Number) value).longValue()).toList();
}
public List<OrderCargoEntity> findCargoByOrderIds(long tenantId, List<Long> orderIds) {
if (orderIds.isEmpty()) return List.of();
return cargoMapper.selectList(new LambdaQueryWrapper<OrderCargoEntity>()
@@ -60,16 +60,37 @@ public class OrderApplicationService {
@Transactional
public void updateDraft(long tenantId, long userId, long id, OrderRequests.SaveRequest request) {
updateOrderData(tenantId, userId, id, request, false);
}
/**
* 平台审核队列中的订单仍允许编辑。编辑只更新订单资料,不回退已提交订单的主状态。
* 商户端继续使用 {@link #updateDraft(long, long, long, OrderRequests.SaveRequest)},
* 因此不会因为该兼容能力意外获得已提交订单的编辑权限。
*/
@Transactional
public void updateSubmittedOrder(long tenantId, long userId, long id, OrderRequests.SaveRequest request) {
updateOrderData(tenantId, userId, id, request, true);
}
private void updateOrderData(long tenantId, long userId, long id, OrderRequests.SaveRequest request,
boolean allowSubmitted) {
OrderEntity order = require(tenantId, id);
validatePrototypeFields(request);
var snapshots = masterDataGuard.validate(tenantId, request);
if (order.getStatus() != OrderStatus.DRAFT && order.getStatus() != OrderStatus.REJECTED) {
throw new BizException("Only draft or rejected orders can be edited");
OrderStatus before = order.getStatus();
if (before != OrderStatus.DRAFT && before != OrderStatus.REJECTED
&& !(allowSubmitted && before == OrderStatus.SUBMITTED)) {
throw new BizException("当前订单状态不允许编辑订单资料");
}
apply(order, request, snapshots); order.setStatus(OrderStatus.DRAFT); order.setUpdatedBy(userId);
if (before == OrderStatus.SUBMITTED) {
order.setStatus(OrderStatus.SUBMITTED);
}
if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update");
repository.replaceDraftChildren(tenantId, id);
saveChildren(tenantId, userId, id, request);
record(tenantId, id, before, order.getStatus(), "UPDATE", null, userId, null);
}
@Transactional
@@ -0,0 +1,239 @@
package com.baiyue.order.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.FirstlegUnifiedOrderClient;
import com.baiyue.order.dto.OrderDetailResponse;
import com.baiyue.order.dto.UnifiedOrderQuery;
import com.baiyue.order.dto.UnifiedOrderResponse;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderStatus;
import com.baiyue.order.repository.OrderRepository;
import feign.FeignException;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
/** Product-level read façade. Existing Order and Firstleg state machines stay unchanged. */
@Service
public class UnifiedOrderQueryService {
private static final Set<String> MAIN_STATUSES = Set.of(
"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED");
private static final Set<String> REVIEW_STATUSES = Set.of("UNRECORDED", "PENDING", "APPROVED", "REJECTED");
private static final List<OrderStatus> ACTIVE_ORDER_STATUSES = List.copyOf(EnumSet.of(
OrderStatus.SUBMITTED, OrderStatus.APPROVED, OrderStatus.ACCEPTED,
OrderStatus.PROCESSING, OrderStatus.COMPLETED));
@Resource private OrderRepository orders;
@Resource private FirstlegUnifiedOrderClient firstleg;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
public PageResult<UnifiedOrderResponse.Row> page(Long tenantId, UnifiedOrderQuery query) {
validate(query);
if (requiresFirstlegQuery(query)) {
if ("PENDING_REVIEW".equals(query.mainStatus())) {
return new PageResult<>(List.of(), query.page(), query.size(), 0, 0);
}
return pageFromFirstleg(tenantId, query);
}
List<OrderStatus> statuses = "PENDING_REVIEW".equals(query.mainStatus())
? List.of(OrderStatus.SUBMITTED) : ACTIVE_ORDER_STATUSES;
PageResult<OrderEntity> source = orders.unifiedPage(tenantId, query, statuses);
Map<Long, FirstlegUnifiedOrderClient.Summary> inboundByOrder = summaries(
tenantId, source.records().stream().map(OrderEntity::getId).toList());
List<UnifiedOrderResponse.Row> rows = source.records().stream()
.map(order -> row(order, inboundByOrder.get(order.getId()))).toList();
return new PageResult<>(rows, source.page(), source.size(), source.total(), source.pages());
}
public UnifiedOrderResponse.Detail detail(long tenantId, long orderId, boolean includeInternalNotes) {
OrderEntity order = orders.findOwned(tenantId, orderId);
if (order == null) throw new BizException(ResultCode.NOT_FOUND, "订单不存在");
OrderDetailResponse orderDetail = new OrderDetailResponse(order, orders.findCargo(tenantId, orderId),
orders.findParties(tenantId, orderId), includeInternalNotes ? orders.findNotes(tenantId, orderId) : List.of(),
orders.findHistory(tenantId, orderId));
FirstlegUnifiedOrderClient.Detail inbound = firstlegDetail(tenantId, orderId);
UnifiedOrderResponse.Row summary = row(order, inbound == null ? null : inbound.summary());
List<UnifiedOrderResponse.TimelineEvent> timeline = new ArrayList<>();
orderDetail.statusHistory().forEach(item -> timeline.add(new UnifiedOrderResponse.TimelineEvent(
"ORDER", "STATUS", orderMainStatus(item.getToStatus()), item.getAction(), item.getReason(),
item.getOperatorId(), item.getOperatorName(), item.getOccurredAt())));
if (inbound != null && inbound.timeline() != null) {
inbound.timeline().forEach(item -> timeline.add(new UnifiedOrderResponse.TimelineEvent(
item.source(), item.eventType(), inboundMainStatus(item.toStatus()), item.action(), item.remark(),
item.operatorId(), item.operatorName(), item.occurredAt())));
}
timeline.sort(Comparator.comparing(UnifiedOrderResponse.TimelineEvent::occurredAt,
Comparator.nullsLast(Comparator.naturalOrder())));
return new UnifiedOrderResponse.Detail(summary, orderDetail, timeline);
}
private PageResult<UnifiedOrderResponse.Row> pageFromFirstleg(Long tenantId, UnifiedOrderQuery query) {
List<Long> candidateOrderIds = StringUtils.hasText(query.fbaCode())
? orders.findOrderIdsBySkuNo(tenantId, query.fbaCode()) : null;
if (candidateOrderIds != null && candidateOrderIds.isEmpty()) {
return new PageResult<>(List.of(), query.page(), query.size(), 0, 0);
}
FirstlegUnifiedOrderClient.Query request = new FirstlegUnifiedOrderClient.Query(
tenantId, query.keyword(), query.orderNo(), query.mainStatus(), query.customerId(),
query.receivingWarehouseId(), query.destinationWarehouseId(), query.inboundCode(),
query.internalOrderStatus(), query.expectedArrivalDate(), query.feeReviewStatus(), query.channelName(),
query.containerNo(), query.cabinetNo(), query.sailingDate(), query.loaded(), query.fullContainer(),
query.measurementReviewStatus(), candidateOrderIds, query.createdFrom(), query.createdTo(),
query.page(), query.size());
PageResult<FirstlegUnifiedOrderClient.Summary> source = require(
call(() -> firstleg.page(internalToken, request)), "统一订单履约查询响应无效");
List<UnifiedOrderResponse.Row> records = source.records().stream().map(this::row).toList();
return new PageResult<>(records, source.page(), source.size(), source.total(), source.pages());
}
private Map<Long, FirstlegUnifiedOrderClient.Summary> summaries(Long tenantId, List<Long> orderIds) {
if (orderIds.isEmpty()) return Map.of();
List<FirstlegUnifiedOrderClient.Summary> data = require(
call(() -> firstleg.summaries(internalToken,
new FirstlegUnifiedOrderClient.BatchRequest(tenantId, orderIds))),
"统一订单履约摘要响应无效");
return data.stream().collect(Collectors.toMap(item -> item.inbound().orderId(),
Function.identity(), (left, right) -> left));
}
private FirstlegUnifiedOrderClient.Detail firstlegDetail(long tenantId, long orderId) {
return requireNullable(call(() -> firstleg.detail(internalToken,
new FirstlegUnifiedOrderClient.DetailRequest(tenantId, orderId))), "统一订单履约详情响应无效");
}
private UnifiedOrderResponse.Row row(OrderEntity order, FirstlegUnifiedOrderClient.Summary summary) {
if (summary == null) {
String mainStatus = orderMainStatus(order.getStatus());
return new UnifiedOrderResponse.Row(order.getId(), order.getTenantId(), order.getOrderNo(), mainStatus,
order.getStatus() == OrderStatus.SUBMITTED ? "APPROVE_ORDER" : null,
order.getStatus(), order.getCustomerId(), order.getCustomerName(),
order.getChannelName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(),
order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), null, null, null,
order.getExpectedShipDate(), null, null, null, null, null, null, null, null,
order.getCreatedAt(), order.getUpdatedAt());
}
FirstlegUnifiedOrderClient.Inbound inbound = summary.inbound();
return new UnifiedOrderResponse.Row(order.getId(), order.getTenantId(), order.getOrderNo(),
summary.mainStatus(), summary.nextAction(), order.getStatus(), order.getCustomerId(), order.getCustomerName(),
order.getChannelName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(),
order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), inbound.id(), inbound.version(), inbound.inboundCode(),
order.getExpectedShipDate(), inbound.totalPackageCount(), inbound.actualGrossWeightKg(),
inbound.actualVolumeCbm(), measurement(inbound), preload(summary.preload()), loading(summary.loading()),
delivery(summary.delivery()), transport(inbound), order.getCreatedAt(), order.getUpdatedAt());
}
private UnifiedOrderResponse.Row row(FirstlegUnifiedOrderClient.Summary summary) {
var inbound = summary.inbound();
return new UnifiedOrderResponse.Row(inbound.orderId(), inbound.tenantId(), inbound.orderNo(),
summary.mainStatus(), summary.nextAction(), parseOrderStatus(inbound.orderStatusSnapshot()),
inbound.customerId(), inbound.customerName(), inbound.channelName(), inbound.warehouseId(),
inbound.warehouseName(), inbound.deliveryWarehouseId(), inbound.deliveryWarehouseName(),
inbound.id(), inbound.version(), inbound.inboundCode(), null, inbound.totalPackageCount(), inbound.actualGrossWeightKg(),
inbound.actualVolumeCbm(), measurement(inbound), preload(summary.preload()), loading(summary.loading()),
delivery(summary.delivery()), transport(inbound), inbound.createdAt(), inbound.updatedAt());
}
private UnifiedOrderResponse.MeasurementSummary measurement(FirstlegUnifiedOrderClient.Inbound inbound) {
return new UnifiedOrderResponse.MeasurementSummary(inbound.measureReviewStatus(),
inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(), inbound.chargeableWeightKg(),
inbound.measureCompletedAt());
}
private UnifiedOrderResponse.PreloadSummary preload(FirstlegUnifiedOrderClient.Preload value) {
return value == null ? null : new UnifiedOrderResponse.PreloadSummary(
value.status(), value.version(), value.itemCount(), value.updatedAt());
}
private UnifiedOrderResponse.LoadingSummary loading(FirstlegUnifiedOrderClient.Loading value) {
return value == null ? null : new UnifiedOrderResponse.LoadingSummary(value.status(), value.loadingVersion(),
value.pickingVersion(), value.expectedCount(), value.pickedCount(), value.evidenceCount(),
value.sealNo(), value.updatedAt());
}
private UnifiedOrderResponse.DeliverySummary delivery(FirstlegUnifiedOrderClient.Delivery value) {
return value == null ? null : new UnifiedOrderResponse.DeliverySummary(
value.status(), value.completedAt(), value.trackingNo());
}
private UnifiedOrderResponse.TransportSummary transport(FirstlegUnifiedOrderClient.Inbound inbound) {
return new UnifiedOrderResponse.TransportSummary(inbound.loadingStatus(), inbound.containerNo(),
inbound.cabinetNo(), inbound.vesselName(), inbound.voyageNo(), inbound.sailingAt(), inbound.portArrivalAt());
}
private String orderMainStatus(OrderStatus status) {
if (status == null) return null;
return status == OrderStatus.SUBMITTED ? "PENDING_REVIEW"
: ACTIVE_ORDER_STATUSES.contains(status) ? "WAIT_MEASURE" : null;
}
private String inboundMainStatus(String status) {
if (status == null) return null;
return switch (status) {
case "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED" -> "WAIT_MEASURE";
case "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT" -> "WAIT_SPLIT";
case "INBOUNDED", "LOADING", "LOADED", "DELIVERED" -> status;
default -> null;
};
}
private OrderStatus parseOrderStatus(String value) {
try { return value == null ? null : OrderStatus.valueOf(value); }
catch (IllegalArgumentException ignored) { return null; }
}
private void validate(UnifiedOrderQuery query) {
if (StringUtils.hasText(query.mainStatus()) && !MAIN_STATUSES.contains(query.mainStatus())) {
throw new BizException(ResultCode.BAD_REQUEST, "统一订单主状态不合法");
}
if (query.createdFrom() != null && query.createdTo() != null
&& query.createdFrom().isAfter(query.createdTo())) {
throw new BizException(ResultCode.BAD_REQUEST, "创建时间起点不能晚于终点");
}
if (StringUtils.hasText(query.internalOrderStatus())) {
try { OrderStatus.valueOf(query.internalOrderStatus()); }
catch (IllegalArgumentException exception) {
throw new BizException(ResultCode.BAD_REQUEST, "订单内部状态不合法");
}
}
if (StringUtils.hasText(query.feeReviewStatus()) && !REVIEW_STATUSES.contains(query.feeReviewStatus())) {
throw new BizException(ResultCode.BAD_REQUEST, "费用审核状态不合法");
}
if (StringUtils.hasText(query.measurementReviewStatus())
&& !REVIEW_STATUSES.contains(query.measurementReviewStatus())) {
throw new BizException(ResultCode.BAD_REQUEST, "测量审核状态不合法");
}
}
private boolean requiresFirstlegQuery(UnifiedOrderQuery query) {
return (StringUtils.hasText(query.mainStatus()) && !"PENDING_REVIEW".equals(query.mainStatus()))
|| StringUtils.hasText(query.inboundCode()) || query.expectedArrivalDate() != null
|| StringUtils.hasText(query.feeReviewStatus()) || StringUtils.hasText(query.containerNo())
|| StringUtils.hasText(query.cabinetNo()) || query.sailingDate() != null || query.loaded() != null
|| StringUtils.hasText(query.measurementReviewStatus());
}
private <T> ApiResponse<T> call(java.util.concurrent.Callable<ApiResponse<T>> invocation) {
try { return invocation.call(); }
catch (FeignException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程服务暂时不可用"); }
catch (Exception exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程服务查询失败"); }
}
private <T> T require(ApiResponse<T> response, String message) {
if (response == null || !response.successful() || response.data() == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, message);
}
return response.data();
}
private <T> T requireNullable(ApiResponse<T> response, String message) {
if (response == null || !response.successful()) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, message);
}
return response.data();
}
}
+186 -1
View File
@@ -2,10 +2,98 @@ openapi: 3.0.3
info:
title: 柏悦头程订单 API
description: 业务成功和失败均使用 HTTP 200;仅响应体 code 为字符串 "200" 时表示成功,其他 code 表示失败。
version: 1.1.0
version: 1.3.0
security:
- bearerAuth: []
paths:
/api/platform/v1/orders:
get:
summary: 平台统一订单列表
description: >-
统一返回七态产品主状态、下一步动作及测量/预装/装柜/派送摘要,需要 platform:order:list。
有入库单时同时返回 inboundId、inboundVersion、loadingVersion、pickingVersion;inboundVersion
才是 C04 loading/complete 的 expectedVersion,loadingVersion 当前等于 pickingVersion(兼容别名)。
tenantId 省略时跨全部客户;mainStatus 省略时按订单创建时间分页,指定 PENDING_REVIEW 时由 Order 查询,
指定其他主状态时由 Firstleg 精确分页。旧 /orders/first-leg 接口保持兼容。
parameters:
- {name: tenantId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- $ref: '#/components/parameters/UnifiedKeyword'
- $ref: '#/components/parameters/UnifiedOrderNo'
- $ref: '#/components/parameters/UnifiedMainStatus'
- {name: customerId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: receivingWarehouseId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: destinationWarehouseId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- $ref: '#/components/parameters/UnifiedInboundCode'
- $ref: '#/components/parameters/UnifiedInternalOrderStatus'
- $ref: '#/components/parameters/UnifiedExpectedArrivalDate'
- $ref: '#/components/parameters/UnifiedFeeReviewStatus'
- $ref: '#/components/parameters/UnifiedChannelName'
- $ref: '#/components/parameters/UnifiedContainerNo'
- $ref: '#/components/parameters/UnifiedCabinetNo'
- $ref: '#/components/parameters/UnifiedSailingDate'
- $ref: '#/components/parameters/UnifiedLoaded'
- $ref: '#/components/parameters/UnifiedFullContainer'
- $ref: '#/components/parameters/UnifiedMeasurementReviewStatus'
- $ref: '#/components/parameters/UnifiedFbaCode'
- $ref: '#/components/parameters/UnifiedCreatedFrom'
- $ref: '#/components/parameters/UnifiedCreatedTo'
- $ref: '#/components/parameters/UnifiedPage'
- $ref: '#/components/parameters/UnifiedSize'
responses:
'200': {description: 成功或业务失败,响应体 code 为字符串 200 时成功}
'403': {description: 需要平台身份和 platform:order:list 权限}
/api/platform/v1/orders/{orderId}:
get:
summary: 平台统一订单详情
description: 合并 Order 与 Firstleg 时间线并按发生时间升序返回;平台可见内部备注。
parameters:
- $ref: '#/components/parameters/UnifiedOrderId'
- $ref: '#/components/parameters/UnifiedTenantId'
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 需要平台身份和 platform:order:list 权限}
/api/merchant/v1/orders:
get:
summary: 客户统一订单列表
description: >-
只按登录态客户隔离查询,需要 merchant:order:list;不接受目标客户参数。
有入库单时同时返回 inboundId、inboundVersion、loadingVersion、pickingVersion;inboundVersion
才是 C04 loading/complete 的 expectedVersion,loadingVersion 当前等于 pickingVersion(兼容别名)。
parameters:
- $ref: '#/components/parameters/UnifiedKeyword'
- $ref: '#/components/parameters/UnifiedOrderNo'
- $ref: '#/components/parameters/UnifiedMainStatus'
- {name: customerId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: receivingWarehouseId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: destinationWarehouseId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- $ref: '#/components/parameters/UnifiedInboundCode'
- $ref: '#/components/parameters/UnifiedInternalOrderStatus'
- $ref: '#/components/parameters/UnifiedExpectedArrivalDate'
- $ref: '#/components/parameters/UnifiedFeeReviewStatus'
- $ref: '#/components/parameters/UnifiedChannelName'
- $ref: '#/components/parameters/UnifiedContainerNo'
- $ref: '#/components/parameters/UnifiedCabinetNo'
- $ref: '#/components/parameters/UnifiedSailingDate'
- $ref: '#/components/parameters/UnifiedLoaded'
- $ref: '#/components/parameters/UnifiedFullContainer'
- $ref: '#/components/parameters/UnifiedMeasurementReviewStatus'
- $ref: '#/components/parameters/UnifiedFbaCode'
- $ref: '#/components/parameters/UnifiedCreatedFrom'
- $ref: '#/components/parameters/UnifiedCreatedTo'
- $ref: '#/components/parameters/UnifiedPage'
- $ref: '#/components/parameters/UnifiedSize'
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 需要 merchant:order:list 权限}
/api/merchant/v1/orders/{orderId}:
get:
summary: 客户统一订单详情
description: 仅返回登录客户所属订单及客户可见时间线,不返回平台内部备注。
parameters:
- $ref: '#/components/parameters/UnifiedOrderId'
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 需要 merchant:order:view 权限}
/api/merchant/v1/orders/first-leg:
get:
summary: 客户端入库订单聚合列表
@@ -119,6 +207,103 @@ components:
scheme: bearer
bearerFormat: JWT
parameters:
UnifiedKeyword:
name: keyword
in: query
required: false
schema: {type: string, maxLength: 64}
UnifiedOrderNo:
name: orderNo
in: query
required: false
schema: {type: string, maxLength: 64}
UnifiedMainStatus:
name: mainStatus
in: query
required: false
schema:
type: string
enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED]
UnifiedInboundCode:
{name: inboundCode, in: query, required: false, schema: {type: string, maxLength: 64}}
UnifiedInternalOrderStatus:
name: internalOrderStatus
in: query
required: false
description: Order 内部生命周期状态;不是七态产品主状态
schema: {type: string, enum: [DRAFT, SUBMITTED, APPROVED, ACCEPTED, REJECTED, CANCELLED, PROCESSING, COMPLETED]}
UnifiedExpectedArrivalDate:
{name: expectedArrivalDate, in: query, required: false, schema: {type: string, format: date}}
UnifiedFeeReviewStatus:
name: feeReviewStatus
in: query
required: false
description: 当前最高版本入库费用审核状态;UNRECORDED 表示尚无费用记录
schema: {type: string, enum: [UNRECORDED, PENDING, APPROVED, REJECTED]}
UnifiedChannelName:
{name: channelName, in: query, required: false, schema: {type: string, maxLength: 128}}
UnifiedContainerNo:
{name: containerNo, in: query, required: false, schema: {type: string, maxLength: 64}}
UnifiedCabinetNo:
{name: cabinetNo, in: query, required: false, schema: {type: string, maxLength: 64}}
UnifiedSailingDate:
{name: sailingDate, in: query, required: false, schema: {type: string, format: date}}
UnifiedLoaded:
name: loaded
in: query
required: false
description: true 表示运输装载状态存在且不是 UNLOADED/PENDING;false 表示无该状态
schema: {type: boolean}
UnifiedFullContainer:
name: fullContainer
in: query
required: false
description: true 对应 FULL_CONTAINER,false 对应 LCL
schema: {type: boolean}
UnifiedMeasurementReviewStatus:
name: measurementReviewStatus
in: query
required: false
description: 测量审核状态;UNRECORDED 表示字段为空
schema: {type: string, enum: [UNRECORDED, PENDING, APPROVED, REJECTED]}
UnifiedFbaCode:
name: fbaCode
in: query
required: false
description: 兼容匹配订单货物 sku_no
schema: {type: string, maxLength: 64}
UnifiedCreatedFrom:
name: createdFrom
in: query
required: false
description: 无主状态或待审核筛选按订单创建时间;其他主状态按履约快照创建时间
schema: {type: string, format: date-time}
UnifiedCreatedTo:
name: createdTo
in: query
required: false
description: 无主状态或待审核筛选按订单创建时间;其他主状态按履约快照创建时间
schema: {type: string, format: date-time}
UnifiedPage:
name: page
in: query
required: false
schema: {type: integer, minimum: 1, default: 1}
UnifiedSize:
name: size
in: query
required: false
schema: {type: integer, minimum: 1, maximum: 200, default: 20}
UnifiedOrderId:
name: orderId
in: path
required: true
schema: {type: string, pattern: '^[1-9][0-9]*$'}
UnifiedTenantId:
name: tenantId
in: query
required: true
schema: {type: string, pattern: '^[1-9][0-9]*$'}
TenantId:
name: tenantId
in: query
@@ -3,6 +3,7 @@ package com.baiyue.order.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.dto.MerchantInboundOrderQuery;
import com.baiyue.order.dto.UnifiedOrderQuery;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -42,6 +43,18 @@ class OrderControllerPermissionTest {
AuthenticatedUser.class, long.class, long.class, String.class);
}
@Test
void unifiedOrderQueriesReuseExistingReadPermissions() throws Exception {
assertAuthority(PlatformUnifiedOrderController.class, "page", "hasAuthority('platform:order:list')",
AuthenticatedUser.class, Long.class, UnifiedOrderQuery.class);
assertAuthority(PlatformUnifiedOrderController.class, "detail", "hasAuthority('platform:order:list')",
AuthenticatedUser.class, long.class, long.class);
assertAuthority(MerchantUnifiedOrderController.class, "page", "hasAuthority('merchant:order:list')",
AuthenticatedUser.class, UnifiedOrderQuery.class);
assertAuthority(MerchantUnifiedOrderController.class, "detail", "hasAuthority('merchant:order:view')",
AuthenticatedUser.class, long.class);
}
private void assertAuthority(Class<?> controller, String methodName, String expected, Class<?>... parameterTypes)
throws NoSuchMethodException {
Method method = controller.getDeclaredMethod(methodName, parameterTypes);
@@ -78,6 +78,48 @@ class OrderApplicationServiceStateTest {
verify(repository).saveOrder(eq(10001L), argThat(saved -> saved.getStatus() == OrderStatus.DRAFT));
}
@Test
void platformCanEditSubmittedOrderWithoutRollingBackReviewStatus() {
OrderRepository repository = mock(OrderRepository.class);
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
OrderRequests.SaveRequest request = request();
OrderEntity order = new OrderEntity();
order.setId(1L);
order.setTenantId(10001L);
order.setStatus(OrderStatus.SUBMITTED);
when(repository.findOwned(10001L, 1L)).thenReturn(order);
when(masterData.validate(10001L, request)).thenReturn(snapshots());
when(repository.saveOrder(10001L, order)).thenReturn(1);
service(repository, mock(OrderEligibilityGuard.class), masterData)
.updateSubmittedOrder(10001L, 2L, 1L, request);
assertThat(order.getStatus()).isEqualTo(OrderStatus.SUBMITTED);
verify(repository).replaceDraftChildren(10001L, 1L);
verify(repository).insertHistory(argThat(history -> history.getFromStatus() == OrderStatus.SUBMITTED
&& history.getToStatus() == OrderStatus.SUBMITTED
&& "UPDATE".equals(history.getAction())));
}
@Test
void merchantDraftEditorStillCannotEditSubmittedOrder() {
OrderRepository repository = mock(OrderRepository.class);
OrderEntity order = new OrderEntity();
order.setId(1L);
order.setTenantId(10001L);
order.setStatus(OrderStatus.SUBMITTED);
when(repository.findOwned(10001L, 1L)).thenReturn(order);
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
when(masterData.validate(eq(10001L), any(OrderRequests.SaveRequest.class))).thenReturn(snapshots());
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), masterData)
.updateDraft(10001L, 2L, 1L, request()))
.isInstanceOf(BizException.class)
.hasMessageContaining("不允许编辑");
verify(repository, never()).saveOrder(anyLong(), any());
}
@Test
void unapprovedTenantStillCannotSubmitDraft() {
OrderRepository repository = mock(OrderRepository.class);
@@ -0,0 +1,141 @@
package com.baiyue.order.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.FirstlegUnifiedOrderClient;
import com.baiyue.order.dto.UnifiedOrderQuery;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderStatus;
import com.baiyue.order.repository.OrderRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(UnifiedOrderQueryService.class)
@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token")
class UnifiedOrderQueryServiceTest {
@MockBean private OrderRepository orders;
@MockBean private FirstlegUnifiedOrderClient firstleg;
@Resource private UnifiedOrderQueryService service;
@Test
void pendingReviewIsPagedFromOrderAndMappedToApproveAction() {
UnifiedOrderQuery query = query("PENDING_REVIEW");
OrderEntity order = order(OrderStatus.SUBMITTED);
when(orders.unifiedPage(eq(10001L), eq(query), eq(List.of(OrderStatus.SUBMITTED))))
.thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1));
when(firstleg.summaries(eq("internal-token"), any()))
.thenReturn(ApiResponse.success(List.of()));
var result = service.page(10001L, query);
assertThat(result.records()).singleElement().satisfies(row -> {
assertThat(row.mainStatus()).isEqualTo("PENDING_REVIEW");
assertThat(row.nextAction()).isEqualTo("APPROVE_ORDER");
});
}
@Test
void fulfillmentStatusFilterUsesFirstlegPagingWithoutPostPageFiltering() {
UnifiedOrderQuery query = query("WAIT_SPLIT");
var summary = summary("WAIT_SPLIT", "CONFIRM_PRELOAD");
when(firstleg.page(eq("internal-token"), any()))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1)));
var result = service.page(null, query);
assertThat(result.records()).singleElement().satisfies(row -> {
assertThat(row.mainStatus()).isEqualTo("WAIT_SPLIT");
assertThat(row.nextAction()).isEqualTo("CONFIRM_PRELOAD");
assertThat(row.inboundId()).isEqualTo(88L);
assertThat(row.inboundVersion()).isEqualTo(7);
assertThat(row.loading().loadingVersion()).isEqualTo(3);
assertThat(row.loading().pickingVersion()).isEqualTo(3);
assertThat(row.inboundCode()).isEqualTo("RK-77");
});
verify(orders, never()).unifiedPage(any(), any(), any());
}
@Test
void expandedFulfillmentFiltersAreForwardedBeforeDatabasePaging() {
UnifiedOrderQuery query = new UnifiedOrderQuery(
null, null, "LOADING", 30001L, 11L, 22L, "RK-77", "APPROVED",
LocalDate.of(2026, 8, 6), "APPROVED", "美森", "MSCU", "CAB-1",
LocalDate.of(2026, 8, 8), true, true, "UNRECORDED", "FBA-1",
null, null, 1, 20);
when(orders.findOrderIdsBySkuNo(10001L, "FBA-1")).thenReturn(List.of(77L));
when(firstleg.page(eq("internal-token"), any()))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(), 1, 20, 0, 0)));
service.page(10001L, query);
ArgumentCaptor<FirstlegUnifiedOrderClient.Query> captor =
ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class);
verify(firstleg).page(eq("internal-token"), captor.capture());
assertThat(captor.getValue()).satisfies(request -> {
assertThat(request.inboundCode()).isEqualTo("RK-77");
assertThat(request.internalOrderStatus()).isEqualTo("APPROVED");
assertThat(request.expectedArrivalDate()).isEqualTo(LocalDate.of(2026, 8, 6));
assertThat(request.containerNo()).isEqualTo("MSCU");
assertThat(request.sailingDate()).isEqualTo(LocalDate.of(2026, 8, 8));
assertThat(request.loaded()).isTrue();
assertThat(request.fullContainer()).isTrue();
assertThat(request.measurementReviewStatus()).isEqualTo("UNRECORDED");
assertThat(request.candidateOrderIds()).containsExactly(77L);
});
}
@Test
void rejectsUnknownReviewStatusesBeforeQueryingEitherService() {
UnifiedOrderQuery query = new UnifiedOrderQuery(
null, null, null, null, null, null, null, null,
null, "UNKNOWN", null, null, null, null, null, null, "INVALID", null,
null, null, 1, 20);
assertThatThrownBy(() -> service.page(10001L, query))
.isInstanceOf(BizException.class);
verify(orders, never()).unifiedPage(any(), any(), any());
verify(firstleg, never()).page(any(), any());
}
private UnifiedOrderQuery query(String status) {
return new UnifiedOrderQuery(null, null, status, null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null,
null, null, 1, 20);
}
private OrderEntity order(OrderStatus status) {
OrderEntity order = new OrderEntity();
order.setId(77L); order.setTenantId(10001L); order.setOrderNo("FL-77");
order.setStatus(status); order.setCustomerId(30001L); order.setCustomerName("测试客户");
order.setCreatedAt(LocalDateTime.of(2026, 8, 6, 10, 0));
return order;
}
private FirstlegUnifiedOrderClient.Summary summary(String status, String action) {
var inbound = new FirstlegUnifiedOrderClient.Inbound(
88L, 10001L, 77L, "FL-77", "RK-77", "WAIT_SPLIT", "APPROVED", "APPROVED", 7,
30001L, "测试客户", 11L, "深圳仓", 22L, "ONT8", "美森", 10, null, null, null,
null, null, null, null, null, null, null, null, null,
LocalDateTime.of(2026, 8, 6, 10, 0), LocalDateTime.of(2026, 8, 6, 11, 0));
return new FirstlegUnifiedOrderClient.Summary(inbound, status, action,
new FirstlegUnifiedOrderClient.Preload("PENDING", 1, 2, null),
new FirstlegUnifiedOrderClient.Loading("IN_PROGRESS", 3, 3, 10, 5, 1, "SEAL-1", null), null);
}
}
+9 -7
View File
@@ -2,9 +2,9 @@
## 职责边界
- 本模块是无数据库的无状态 PDF 渲染服务;调用方传入完整 `labels[]`,模块在内存中生成一次性多页 PDF 并同步返回。
- 本模块是无数据库的无状态 PDF 渲染服务;调用方传入完整 `labels[]` 或 `documents[]`,模块在内存中生成一次性多页 PDF 并同步返回。
- 不保存模板、任务、历史或文件,不连接数据库、File 服务或打印机,不反查或修正业务数据。
- 首版只支持 `INBOUND_MARK_V1` 和 `100mm x 100mm`;新尺寸或布局必须使用新模板编码,不得静默改变 V1。
- 当前支持 `INBOUND_MARK_V1`(`100mm x 100mm`)、`SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1` 和 `BILL_OF_LADING_CONFIRMATION_A4_V1`(均为 `210mm x 297mm`);新尺寸或布局必须使用新模板编码,不得静默改变既有 V1。
## 快速索引
@@ -13,20 +13,22 @@
| 调用方接入指南 | `README.md` |
| 应用入口 | `src/main/java/com/baiyue/print/PrintServiceApplication.java` |
| 健康状态 API | `controller/PrintHealthController.java`(`/api/platform/v1/print/status`) |
| 内部 PDF API | `controller/InternalPdfController.java`(`/internal/print/v1/pdfs/inbound-marks`) |
| 内部 PDF API | `controller/InternalPdfController.java`(`/internal/print/v1/pdfs/inbound-marks`、`/internal/print/v1/pdfs/freight-a4-documents`) |
| 内部令牌校验 | `controller/InternalServiceTokenGuard.java` |
| 请求与响应模型 | `dto/InboundMarkPdfDtos.java` |
| 固定模板与二维码渲染 | `renderer/InboundMarkV1Renderer.java`、`renderer/PdfQrRenderer.java`、`renderer/PdfUnits.java` |
| 请求校验与资源保护 | `controller/PrintRequestSizeFilter.java`、`service/InboundMarkPdfService.java`、`service/PdfGenerationLimiter.java` |
| 请求与响应模型 | `dto/InboundMarkPdfDtos.java`、`dto/FreightA4PdfDtos.java` |
| 固定模板与渲染 | `renderer/InboundMarkV1Renderer.java`、`renderer/FreightA4Renderer.java`、`renderer/PdfQrRenderer.java`、`renderer/PdfUnits.java` |
| 请求校验与资源保护 | `controller/PrintRequestSizeFilter.java`、`service/InboundMarkPdfService.java`、`service/FreightA4PdfService.java`、`service/PdfGenerationLimiter.java` |
| PDF 参数配置 | `config/PrintPdfProperties.java` |
| 接口契约 | `src/main/resources/openapi/print-internal-v1.yaml` |
| 字体及授权 | `src/main/resources/fonts/NotoSansSC-Bold.ttf`、`src/main/resources/fonts/OFL.txt` |
| 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/print-service.yml` |
| 端到端与架构约束测试 | `src/test/java/com/baiyue/print/acceptance/InboundMarkPdfAcceptanceTest.java`、`src/test/java/com/baiyue/print/acceptance/PrintStatelessArchitectureTest.java` |
| 端到端与架构约束测试 | `src/test/java/com/baiyue/print/acceptance/InboundMarkPdfAcceptanceTest.java`、`src/test/java/com/baiyue/print/service/FreightA4PdfServiceGenerationTest.java`、`src/test/java/com/baiyue/print/acceptance/PrintStatelessArchitectureTest.java` |
## 修改与验证
- 内部 PDF 接口不加入 Gateway 公网路由;调用方必须先完成业务权限和数据范围校验,再携带共享内部令牌调用。
- 其他模块接入时先读 `README.md` 和 OpenAPI,在调用模块内声明 DTO 并从既有业务数据自动映射;不得依赖 Print 的 Java DTO,不得新增要求用户重复填写打印字段的表单。
- A4 V1 每个 `document` 的 `cargoRows` 只允许 0 或 1 项;当前渲染器只消费首行,调用方不得发送会被静默忽略的后续行。
- 成功响应为 `application/pdf`,业务校验、鉴权和生成失败响应为 `application/json`;Controller 不得固定声明只产生 PDF,具体契约以 OpenAPI 为准。
- 不得重新引入已删除的 `src/main/resources/db/print.sql`,也不得引入数据源、MyBatis、MySQL、本地临时文件或异步任务队列;失败时整次请求失败,不返回部分 PDF。
- 中文字体必须从 classpath 加载并嵌入 PDF,不得依赖宿主机或容器系统字体;更新字体时同步归档授权文件。
+161 -7
View File
@@ -1,22 +1,25 @@
# Print PDF 服务接入指南
# Print PDF 内部调用契约
本文面向需要生成入仓标签 PDF 的业务服务。Print 是无数据库的内部渲染服务:调用方提交完整标签数据,Print 同步返回一次性 PDF,不保存任务、文件或业务数据。
本文是其他 Java 业务模块及代码生成 AI 接入 Print 的实现约束,不是终端用户操作手册。Print 是无数据库的内部渲染服务:调用方提交完整打印数据,Print 同步返回一次性 PDF,不保存任务、文件或业务数据。
完整机器可读契约见 [`src/main/resources/openapi/print-internal-v1.yaml`](src/main/resources/openapi/print-internal-v1.yaml),版式与技术决策见 [`../docs/2026-08-04-无状态PDF打印服务设计.md`](../docs/2026-08-04-无状态PDF打印服务设计.md)。
机器可读 HTTP 契约以 [`src/main/resources/openapi/print-internal-v1.yaml`](src/main/resources/openapi/print-internal-v1.yaml) 为准;本文件补充调用方必须实现、但 OpenAPI 无法完整表达的权限、字段映射、响应分支和日志约束。调用方必须自行声明 DTO,不得依赖 Print 模块的 Java 实现类。
## 1. 调用边界
- 服务发现名:`print-service`。
- 内部地址:`POST /internal/print/v1/pdfs/inbound-marks`。
- A4 海运单据内部地址:`POST /internal/print/v1/pdfs/freight-a4-documents`。
- 本地直连:`http://127.0.0.1:9207`。
- 请求必须携带 `X-Internal-Service-Token`,值使用各服务已有的 `baiyue.security.internal-service-token` 配置。
- 不把该接口加入 Gateway,也不允许 Admin 浏览器直接携带内部令牌访问 Print。
- Admin 请求业务服务;业务服务完成 JWT、客户归属、权限和数据范围校验,组装完整 `labels[]` 后调用 Print,再把 PDF 响应转发给浏览器。JWT 中的 `tenant_id` 当前是客户隔离兼容字段,Print 不解析或引入真实租户层。
- Admin 请求业务服务;业务服务完成 JWT、客户归属、权限和数据范围校验,组装完整 `labels[]` 或 `documents[]` 后调用 Print,再把 PDF 响应转发给浏览器。JWT 中的 `tenant_id` 当前是客户隔离兼容字段,Print 不解析或引入真实租户层。
调用方不得要求 Print 查询订单、补箱号、排序或修正数据。数组顺序就是 PDF 页序,每个数组元素生成一页。
调用方必须从本模块拥有的数据自动组装打印字段,不得把 Print 请求 DTO 暴露成要求用户逐项填写的表单。调用方不得要求 Print 查询订单、补箱号、排序、推导或修正数据。数组顺序就是 PDF 页序,每个数组元素生成一页。
## 2. 请求契约
### 2.1 入仓标签
```http
POST /internal/print/v1/pdfs/inbound-marks HTTP/1.1
Content-Type: application/json
@@ -69,6 +72,57 @@ X-Request-Id: <optional-correlation-id>
未传 `downloadFileName` 时,服务使用 `inbound-marks-{requestId}.pdf`。请求体最大 2 MiB,PDF 最大 32 MiB。
### 2.2 海运 A4 单据
```json
{
"templateCode": "SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1",
"pageSpec": {
"widthMm": 210,
"heightMm": 297
},
"downloadFileName": "BYSE2607044-托运书.pdf",
"documents": [
{
"fields": {
"toCompany": "宁波胜泽远航国际物流有限公司",
"shipper": "ZHEJIANG BAIYUE TRADE CO.,LIMITED\nROOM 8613...",
"consignee": "SANATRK FOR IMPORT AND TRADE OF AUTO SPARE PARTS",
"jobNumber": "BYSE2607044"
},
"cargoRows": [
{
"marksAndNos": "N/M",
"quantity": "700",
"packaging": "PACKAGES",
"descriptionOfGoods": "CAR ACCESSORIES\nACID: 4120661061019510011",
"grossWeight": "28000.00 KGS",
"measurement": "28.000 CBM"
}
]
}
]
}
```
| 字段 | 必填 | 规则 |
|---|---|---|
| `templateCode` | 是 | `SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1` 或 `BILL_OF_LADING_CONFIRMATION_A4_V1` |
| `pageSpec.widthMm` | 是 | 固定数值 `210` |
| `pageSpec.heightMm` | 是 | 固定数值 `297` |
| `downloadFileName` | 否 | 最多 120 字符,必须以 `.pdf` 结尾,不得含路径或文件系统保留字符 |
| `documents` | 是 | 1-2000 项;一个元素对应一页,按数组顺序生成 |
| `documents[].fields` | 否 | 字符串字典,字段值允许换行,单值最多 4000 字符 |
| `documents[].cargoRows` | 否 | 0-1 项;当前 V1 模板只渲染一行货物,调用方不得发送第二行 |
托运书模板支持的完整 `fields` 键:`toCompany`、`issuerNameCn`、`issuerNameEn`、`issuerAddress`、`issuerPostalCode`、`issuerTel`、`issuerFax`、`shipper`、`consignee`、`notifyParty`、`picName`、`picTel`、`picFax`、`documentName`、`documentTel`、`documentFax`、`jobNumber`、`etdDate`、`serviceTerm`、`freightTerm`、`apCode`、`apName`、`cargoQuantity`、`transshipment`、`partialShipment`、`oceanVesselVoyage`、`portOfLoading`、`portOfDischarge`、`placeOfDelivery`、`totalPackagesInWords`、`remark`、`carrier`、`bookingAgent`、`printDate`。
提单确认件模板支持的完整 `fields` 键:`shipper`、`consignee`、`notifyParty`、`bookingNumber`、`ensAmsCutoffDate`、`customsCutoffDate`、`expectedDepartureDate`、`oceanVesselVoyage`、`portOfLoading`、`portOfDischarge`、`placeOfDelivery`、`finalDestination`、`serviceTerm`、`freightTerm`、`shipperLoadCountSeal`、`containerSpec`、`shippedOnBoard`、`draftText`、`totalPackagesInWords`、`containerSummary`、`notes`、`issuePlace`、`issueDate`。
调用方 MUST 只发送所选模板支持的键。当前服务会忽略未知键而不报错,因此调用方必须通过单元测试固定字段映射,避免拼写错误生成缺字段 PDF。空值字段可以省略;不得为了填满模板要求用户重复录入订单、客户、航线或货物中已经存在的数据。
未传 `downloadFileName` 时,服务使用 `freight-a4-documents-{requestId}.pdf`。请求体最大 2 MiB,PDF 最大 32 MiB。
## 3. 响应处理
调用方必须同时判断 HTTP 状态和 `Content-Type`,不能只判断 HTTP 200:
@@ -114,6 +168,12 @@ public interface PrintPdfClient {
@RequestHeader(value = "X-Request-Id", required = false) String requestId,
@RequestBody InboundMarkPdfRequest request);
@PostMapping(value = "/freight-a4-documents", consumes = "application/json")
ResponseEntity<byte[]> generateFreightA4Documents(
@RequestHeader("X-Internal-Service-Token") String internalToken,
@RequestHeader(value = "X-Request-Id", required = false) String requestId,
@RequestBody FreightA4PdfRequest request);
record InboundMarkPdfRequest(
String templateCode,
PageSpec pageSpec,
@@ -128,6 +188,22 @@ public interface PrintPdfClient {
String cartonText,
String routeText,
String qrContent) {}
record FreightA4PdfRequest(
String templateCode,
PageSpec pageSpec,
String downloadFileName,
List<FreightDocument> documents) {}
record FreightDocument(Map<String, String> fields, List<CargoRow> cargoRows) {}
record CargoRow(
String marksAndNos,
String quantity,
String packaging,
String descriptionOfGoods,
String grossWeight,
String measurement) {}
}
```
@@ -176,13 +252,91 @@ spring:
- 不建议对该 POST 做无限或无退避自动重试;超时或 `A0429` 最多有限重试,并加入退避和抖动。
- 单次最多 2000 页。大批量请求应设置不低于 30 秒的读取超时,并避免调用方自身重复复制 PDF 字节。
- 浏览器下载场景由业务服务原样转发 PDF,不要把 PDF Base64 塞入 JSON,也不要为了“一次性下载”写入数据库。
- 日志记录模板、页数、耗时、PDF 字节数、`requestId`、`traceId` 和错误码;不要记录内部令牌、完整 `labels[]`、二维码内容或 PDF 二进制。
- 日志记录模板、页数、耗时、PDF 字节数、`requestId`、`traceId` 和错误码;不要记录内部令牌、完整 `labels[]`、完整 `documents[]`、二维码内容或 PDF 二进制。
## 6. 联调检查清单
1. `GET /actuator/health` 返回 `UP`。
2. 使用正确内部令牌提交 1 页,响应为 `application/pdf`。
3. 检查页面为严格 `100mm x 100mm`,`X-Print-Page-Count` 与 `labels.length` 一致。
3. 检查入仓标签页面为严格 `100mm x 100mm`,A4 单据页面为严格 `210mm x 297mm`,`X-Print-Page-Count` 与数组长度一致。
4. 提交多页并检查页序与数组顺序一致。
5. 分别验证错误令牌、非法尺寸、空数组、超过 2000 页和超过 2 MiB 请求体。
6. Admin 下载链路不得在浏览器请求或日志中出现 `X-Internal-Service-Token`。
## 7. AI 实施与验证清单
修改其他业务模块接入 Print 时,按以下顺序实施,不得从 Admin 浏览器直连 Print:
1. 从 OpenAPI 和本文件在调用模块内声明请求 DTO 与 Feign Client;不得新增对 `print-service` Java 包的编译依赖。
2. 在调用模块的 Service 中完成权限、客户归属和数据范围校验,再从已有订单、客户、航线及货物数据自动映射打印字段。
3. `labels[]`、`documents[]` 和 `cargoRows` 顺序必须由调用模块明确决定;A4 V1 的 `cargoRows` 只能包含 0 或 1 项。
4. 同时处理 `application/pdf`、HTTP 200 的 `application/json` 业务错误及 HTTP 413;不得把任意 HTTP 200 当作成功 PDF。
5. 只向浏览器转发安全响应头,不得转发或记录 `X-Internal-Service-Token`,不得记录完整打印请求或 PDF 二进制。
6. 为字段映射、PDF/JSON 响应分支、`A0403`、`A0422`、`A0429`、HTTP 413 和超时增加调用模块测试。
只验证 Print 本身时,从 `baiyue-firstleg/` 构建并启动单个服务;不需要启动业务服务、Nacos 或数据库:
```powershell
mvn -pl print -am package -DskipTests
$env:PRINT_PORT="9207"
$env:SPRING_PROFILES_ACTIVE="dev"
$env:INTERNAL_SERVICE_TOKEN="print-local-contract-test-token-20260806"
$env:SPRING_CLOUD_NACOS_CONFIG_ENABLED="false"
$env:NACOS_DISCOVERY_ENABLED="false"
$env:NACOS_REGISTER_ENABLED="false"
java -jar print\target\print-service-1.0.0-SNAPSHOT.jar
```
启动后向 `POST http://127.0.0.1:9207/internal/print/v1/pdfs/freight-a4-documents` 发送 `Content-Type: application/json`、`Accept: application/pdf, application/json`、上述内部令牌及任意 `X-Request-Id`。托运书验证请求体:
```json
{
"templateCode": "SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1",
"pageSpec": {
"widthMm": 210,
"heightMm": 297
},
"downloadFileName": "BYSE2607044-托运书.pdf",
"documents": [
{
"fields": {
"toCompany": "宁波胜泽远航国际物流有限公司",
"issuerNameCn": "浙江柏悦贸易有限公司(香港)",
"issuerNameEn": "ZHEJIANG BAIYUE TRADE CO.,LIMITED",
"issuerAddress": "地址:浙江省义乌市稠州北路 1399 号新光国际 8613 室",
"issuerPostalCode": "322000",
"issuerTel": "0579-85875557",
"issuerFax": "0579-85601277",
"shipper": "ZHEJIANG BAIYUE TRADE CO.,LIMITED\nROOM 8613,NO.1399,CHOUZHOU NORTH ROAD,FUTIAN STREET,YIWU CITY,ZHEJIANG PROVINCE\nTEL:0579-85695557",
"consignee": "SANATRK FOR IMPORT AND TRADE OF AUTO SPARE PARTS\n3 ATIA ELZEKRY ST- OMAR EBN ELKHTAB CITY SHOBRA\nELKHIMA QALYUBIA SECOND-OWNER/ MENA MATTA GABALLA\nMATTA\nTEL:01225672354",
"notifyParty": "SAME AS CONSIGNEE",
"picName": "苏天翔",
"jobNumber": "BYSE2607044",
"etdDate": "2026/07/24",
"serviceTerm": "CY-CY",
"freightTerm": "FREIGHT PREPAID",
"cargoQuantity": "1*20GP",
"portOfLoading": "NINGBO",
"portOfDischarge": "EL DEKHEILA",
"placeOfDelivery": "EL DEKHEILA",
"totalPackagesInWords": "SAY SIX HUNDRED AND FIFTY NINE PACKAGES ONLY.",
"carrier": "万海航运(香港)股份有限公司",
"bookingAgent": "宁波胜泽远航国际物流有限公司",
"printDate": "2026 年 07 月 16 日"
},
"cargoRows": [
{
"marksAndNos": "N/M",
"quantity": "700",
"packaging": "PACKAGES",
"descriptionOfGoods": "CAR ACCESSORIES\nACID: 4120661061019510011\n申请目的港 21 天免用箱",
"grossWeight": "28000.00 KGS",
"measurement": "28.000 CBM"
}
]
}
]
}
```
提单确认件必须把 `templateCode` 改为 `BILL_OF_LADING_CONFIRMATION_A4_V1`,并仅传上一节列出的提单确认件字段。成功响应必须是 `Content-Type: application/pdf`;`application/json` 必须按响应体 `code/message` 作为失败处理。
@@ -3,7 +3,9 @@ package com.baiyue.print.controller;
import jakarta.annotation.Resource;
import com.baiyue.common.web.CorrelationContext;
import com.baiyue.print.dto.FreightA4PdfDtos;
import com.baiyue.print.dto.InboundMarkPdfDtos;
import com.baiyue.print.service.FreightA4PdfService;
import com.baiyue.print.service.InboundMarkPdfService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
@@ -27,6 +29,7 @@ public class InternalPdfController {
static final String PAGE_COUNT_HEADER = "X-Print-Page-Count";
@Resource private InboundMarkPdfService pdfService;
@Resource private FreightA4PdfService freightA4PdfService;
@Resource private InternalServiceTokenGuard tokenGuard;
@PostMapping(value = "/inbound-marks", consumes = MediaType.APPLICATION_JSON_VALUE)
@@ -58,4 +61,34 @@ public class InternalPdfController {
return response.body(content);
}
@PostMapping(value = "/freight-a4-documents", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<byte[]> generateFreightA4Documents(
@RequestHeader(value = INTERNAL_TOKEN_HEADER, required = false) String internalToken,
@Valid @RequestBody FreightA4PdfDtos.Request request,
HttpServletRequest servletRequest) {
tokenGuard.verify(internalToken);
String requestId = CorrelationContext.requestId(servletRequest);
FreightA4PdfDtos.Request effectiveRequest = request;
if (request.downloadFileName() == null && requestId != null && !requestId.isBlank()) {
effectiveRequest = new FreightA4PdfDtos.Request(request.templateCode(), request.pageSpec(),
"freight-a4-documents-" + requestId + ".pdf", request.documents());
}
FreightA4PdfService.GeneratedPdf pdf = freightA4PdfService.generate(effectiveRequest);
byte[] content = pdf.content();
ContentDisposition disposition = ContentDisposition.attachment()
.filename(pdf.fileName(), StandardCharsets.UTF_8)
.build();
ResponseEntity.BodyBuilder response = ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.header(HttpHeaders.PRAGMA, "no-cache")
.header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
.header("X-Content-Type-Options", "nosniff")
.header(PAGE_COUNT_HEADER, Integer.toString(pdf.pageCount()))
.contentType(MediaType.APPLICATION_PDF)
.contentLength(content.length);
return response.body(content);
}
}
@@ -28,7 +28,9 @@ import org.springframework.web.filter.OncePerRequestFilter;
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 10)
public class PrintRequestSizeFilter extends OncePerRequestFilter {
private static final String PATH = "/internal/print/v1/pdfs/inbound-marks";
private static final java.util.Set<String> PATHS = java.util.Set.of(
"/internal/print/v1/pdfs/inbound-marks",
"/internal/print/v1/pdfs/freight-a4-documents");
@Resource private PrintPdfProperties properties;
@Resource private ObjectMapper objectMapper;
@@ -36,7 +38,7 @@ public class PrintRequestSizeFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
if (!"POST".equalsIgnoreCase(request.getMethod()) || !PATH.equals(request.getRequestURI())) {
if (!"POST".equalsIgnoreCase(request.getMethod()) || !PATHS.contains(request.getRequestURI())) {
filterChain.doFilter(request, response);
return;
}
@@ -0,0 +1,47 @@
package com.baiyue.print.dto;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
/** A4 sea-freight document PDF request contract. */
public final class FreightA4PdfDtos {
public static final String SHIPPING_INSTRUCTION_TEMPLATE = "SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1";
public static final String BILL_CONFIRMATION_TEMPLATE = "BILL_OF_LADING_CONFIRMATION_A4_V1";
private FreightA4PdfDtos() {
}
public record Request(
String templateCode,
PageSpec pageSpec,
String downloadFileName,
List<Document> documents
) {
}
public record PageSpec(BigDecimal widthMm, BigDecimal heightMm) {
}
public record Document(
Map<String, String> fields,
List<CargoRow> cargoRows
) {
public String field(String key) {
if (fields == null) {
return "";
}
return fields.getOrDefault(key, "");
}
}
public record CargoRow(
String marksAndNos,
String quantity,
String packaging,
String descriptionOfGoods,
String grossWeight,
String measurement
) {
}
}
@@ -0,0 +1,333 @@
package com.baiyue.print.renderer;
import com.baiyue.print.dto.FreightA4PdfDtos;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.springframework.stereotype.Component;
/** Fixed A4 renderers for sea-freight documents. */
@Component
public class FreightA4Renderer {
public static final double PAGE_WIDTH_MM = 210d;
public static final double PAGE_HEIGHT_MM = 297d;
private static final float PAGE_WIDTH_PT = PdfUnits.mmToPt(PAGE_WIDTH_MM);
private static final float PAGE_HEIGHT_PT = PdfUnits.mmToPt(PAGE_HEIGHT_MM);
private static final float SHIPPING_TEXT = 6.2f;
private static final float SHIPPING_SMALL = 5.8f;
private static final float BILL_TEXT = 7.2f;
private static final float BILL_SMALL = 6.8f;
public void renderPage(PDDocument document, PDFont font, String templateCode,
FreightA4PdfDtos.Document data) throws IOException {
PDPage page = new PDPage(new PDRectangle(PAGE_WIDTH_PT, PAGE_HEIGHT_PT));
page.setRotation(0);
document.addPage(page);
PdfUnits units = new PdfUnits(PAGE_HEIGHT_MM);
try (PDPageContentStream stream = new PDPageContentStream(document, page)) {
stream.setNonStrokingColor(0, 0, 0);
stream.setStrokingColor(0, 0, 0);
if (FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE.equals(templateCode)) {
renderShippingInstruction(stream, units, font, data);
} else if (FreightA4PdfDtos.BILL_CONFIRMATION_TEMPLATE.equals(templateCode)) {
renderBillConfirmation(stream, units, font, data);
} else {
throw new IllegalArgumentException("unsupported A4 freight template");
}
}
}
private void renderShippingInstruction(PDPageContentStream stream, PdfUnits units, PDFont font,
FreightA4PdfDtos.Document data) throws IOException {
text(stream, units, font, "Sea Freight Shipping Instructions", 12, 15, 18);
text(stream, units, font, "To: " + data.field("toCompany"), SHIPPING_TEXT, 15, 24);
rect(stream, 15, 29, 180, 250);
line(stream, 113, 29, 113, 155);
line(stream, 15, 64, 113, 64);
line(stream, 15, 99, 113, 99);
line(stream, 15, 134, 113, 134);
line(stream, 15, 147, 113, 147);
line(stream, 64, 134, 64, 155);
wrapped(stream, units, font, "Shipper(Full Name & Address)(托运人):", SHIPPING_TEXT, 17, 33, 92, 4, 5);
wrapped(stream, units, font, data.field("shipper"), SHIPPING_TEXT, 17, 39, 92, 5, 5);
wrapped(stream, units, font, "Consignee(收货人):", SHIPPING_TEXT, 17, 68, 92, 4, 5);
wrapped(stream, units, font, data.field("consignee"), SHIPPING_TEXT, 17, 74, 92, 5, 5);
wrapped(stream, units, font, "Notify Party(通知人):", SHIPPING_TEXT, 17, 103, 92, 4, 5);
wrapped(stream, units, font, data.field("notifyParty"), SHIPPING_TEXT, 17, 109, 92, 4, 5);
text(stream, units, font, "Ocean Vessel/Voy(船名航次):", SHIPPING_TEXT, 17, 139);
text(stream, units, font, data.field("oceanVesselVoyage"), SHIPPING_TEXT, 17, 145.5);
text(stream, units, font, "Port of Loading(起运港):", SHIPPING_TEXT, 66, 139);
text(stream, units, font, data.field("portOfLoading"), SHIPPING_TEXT, 66, 145.5);
text(stream, units, font, "Part of Discharge(卸货港):", SHIPPING_TEXT, 17, 150);
text(stream, units, font, data.field("portOfDischarge"), SHIPPING_TEXT, 17, 154);
text(stream, units, font, "Place of Delivery(交货地):", SHIPPING_TEXT, 66, 150);
text(stream, units, font, data.field("placeOfDelivery"), SHIPPING_TEXT, 66, 154);
renderShippingRightPanel(stream, units, font, data);
renderShippingCargo(stream, units, font, data);
}
private void renderShippingRightPanel(PDPageContentStream stream, PdfUnits units, PDFont font,
FreightA4PdfDtos.Document data) throws IOException {
line(stream, 113, 76, 195, 76);
line(stream, 113, 89, 195, 89);
line(stream, 113, 102, 195, 102);
for (double y : new double[]{109, 116, 123, 130, 137, 144, 151}) {
line(stream, 113, y, 195, y);
}
line(stream, 135, 76, 135, 102);
line(stream, 135, 82.5, 195, 82.5);
line(stream, 135, 95.5, 195, 95.5);
centered(stream, units, font, data.field("issuerNameCn"), 12, 154, 37);
centered(stream, units, font, data.field("issuerNameEn"), SHIPPING_TEXT, 154, 45);
wrapped(stream, units, font, data.field("issuerAddress"), SHIPPING_TEXT, 114, 52, 78, 2, 4.5);
text(stream, units, font, "邮编:" + data.field("issuerPostalCode"), SHIPPING_TEXT, 114, 59);
text(stream, units, font, "TEL:" + data.field("issuerTel") + " | FAX:" + data.field("issuerFax"),
5.2f, 114, 63.5);
centered(stream, units, font, "出口货物托运单", 12, 154, 72);
text(stream, units, font, "PIC(负责人):", SHIPPING_TEXT, 114, 80);
text(stream, units, font, data.field("picName"), SHIPPING_TEXT, 114, 86);
text(stream, units, font, "TEL(电话): " + data.field("picTel"), SHIPPING_TEXT, 136, 80);
text(stream, units, font, "FAX(传真): " + data.field("picFax"), SHIPPING_TEXT, 136, 87);
text(stream, units, font, "Document:", SHIPPING_TEXT, 114, 94);
text(stream, units, font, data.field("documentName"), SHIPPING_TEXT, 114, 100);
text(stream, units, font, "TEL(电话): " + data.field("documentTel"), SHIPPING_TEXT, 136, 94);
text(stream, units, font, "FAX(传真): " + data.field("documentFax"), SHIPPING_TEXT, 136, 101);
text(stream, units, font, "Job Nbr(委托单号): " + data.field("jobNumber"), SHIPPING_TEXT, 114, 107);
text(stream, units, font, "ETD(开航日期): " + data.field("etdDate"), SHIPPING_TEXT, 114, 114);
text(stream, units, font, "Service Term(运输条款):" + data.field("serviceTerm"), SHIPPING_TEXT, 114, 121);
text(stream, units, font, "Freight Term(运费条款):" + data.field("freightTerm"), SHIPPING_TEXT, 114, 128);
text(stream, units, font, "AP Code: " + data.field("apCode"), SHIPPING_TEXT, 114, 135);
text(stream, units, font, "AP Name: " + data.field("apName"), SHIPPING_TEXT, 114, 142);
text(stream, units, font, "Cargo Quantity(货量描述): " + data.field("cargoQuantity"), SHIPPING_TEXT, 114, 149);
text(stream, units, font, "T/S(可否转船): " + data.field("transshipment")
+ " Batch(可否分批): " + data.field("partialShipment"), SHIPPING_TEXT, 114, 153.5);
}
private void renderShippingCargo(PDPageContentStream stream, PdfUnits units, PDFont font,
FreightA4PdfDtos.Document data) throws IOException {
double y = 155;
line(stream, 15, y, 195, y);
line(stream, 15, 166, 195, 166);
line(stream, 15, 229, 195, 229);
line(stream, 15, 237, 195, 237);
line(stream, 130, 237, 130, 279);
line(stream, 130, 259, 195, 259);
for (double x : new double[]{50, 88, 137, 166}) {
line(stream, x, 155, x, 166);
}
centered(stream, units, font, "Marks & Nos\n(标志和号码)", SHIPPING_TEXT, 32.5, 160);
centered(stream, units, font, "Quantity & Packaging\n(件数及包装)", SHIPPING_TEXT, 69, 160);
centered(stream, units, font, "Description of Goods(品名)", SHIPPING_TEXT, 112.5, 161);
centered(stream, units, font, "Gross Weight(Kilos)\n毛重(公斤)", SHIPPING_SMALL, 151.5, 160);
centered(stream, units, font, "Measurement(CBM)\n体积(立方米)", SHIPPING_SMALL, 180.5, 160);
FreightA4PdfDtos.CargoRow row = firstCargoRow(data);
text(stream, units, font, value(row.marksAndNos()), SHIPPING_TEXT, 17, 171);
text(stream, units, font, value(row.quantity()), SHIPPING_TEXT, 55, 171);
text(stream, units, font, value(row.packaging()), SHIPPING_TEXT, 64, 171);
wrapped(stream, units, font, value(row.descriptionOfGoods()), SHIPPING_TEXT, 88, 171, 47, 5, 5);
text(stream, units, font, value(row.grossWeight()), SHIPPING_TEXT, 147, 171);
text(stream, units, font, value(row.measurement()), SHIPPING_TEXT, 177, 171);
text(stream, units, font, "Total Packages(in words)(件数大写):" + data.field("totalPackagesInWords"),
SHIPPING_TEXT, 17, 234);
text(stream, units, font, "Ramark(备注):", SHIPPING_TEXT, 20, 245);
wrapped(stream, units, font, data.field("remark"), SHIPPING_TEXT, 20, 252, 105, 5, 5);
text(stream, units, font, "Carrier(船公司):", SHIPPING_TEXT, 135, 247);
wrapped(stream, units, font, data.field("carrier"), SHIPPING_TEXT, 135, 254, 55, 2, 5);
text(stream, units, font, "Booking Agent(订舱代理):", SHIPPING_TEXT, 135, 266);
wrapped(stream, units, font, data.field("bookingAgent"), SHIPPING_TEXT, 135, 273, 55, 2, 5);
text(stream, units, font, "打印于: " + data.field("printDate"), SHIPPING_TEXT, 149, 287);
}
private void renderBillConfirmation(PDPageContentStream stream, PdfUnits units, PDFont font,
FreightA4PdfDtos.Document data) throws IOException {
centered(stream, units, font, "提单确认件", 15, 105, 40);
wrapped(stream, units, font, data.field("shipper"), BILL_TEXT, 10, 64, 72, 5, 5);
wrapped(stream, units, font, data.field("consignee"), BILL_TEXT, 10, 95, 84, 5, 5);
wrapped(stream, units, font, data.field("notifyParty"), BILL_TEXT, 10, 126, 72, 3, 5);
text(stream, units, font, data.field("bookingNumber"), BILL_TEXT, 148, 64);
text(stream, units, font, "ENS/AMS 截单: " + data.field("ensAmsCutoffDate"), BILL_TEXT, 148, 73);
text(stream, units, font, "截关日期: " + data.field("customsCutoffDate"), BILL_TEXT, 148, 82);
text(stream, units, font, "预计开航日期: " + data.field("expectedDepartureDate"), BILL_TEXT, 148, 91);
text(stream, units, font, data.field("oceanVesselVoyage"), BILL_TEXT, 10, 155);
text(stream, units, font, data.field("portOfLoading"), BILL_TEXT, 61, 161);
text(stream, units, font, data.field("portOfDischarge"), BILL_TEXT, 10, 170);
text(stream, units, font, data.field("placeOfDelivery"), BILL_TEXT, 61, 170);
text(stream, units, font, data.field("finalDestination"), BILL_TEXT, 114, 155);
text(stream, units, font, data.field("serviceTerm"), BILL_TEXT, 128, 170);
FreightA4PdfDtos.CargoRow row = firstCargoRow(data);
text(stream, units, font, value(row.quantity()) + " " + value(row.packaging()), BILL_TEXT, 44, 179);
text(stream, units, font, "SAID TO CONTAIN(S)", BILL_TEXT, 74, 179);
text(stream, units, font, value(row.grossWeight()), BILL_TEXT, 156, 179);
text(stream, units, font, value(row.measurement()), BILL_TEXT, 183, 179);
text(stream, units, font, value(row.marksAndNos()), BILL_TEXT, 9, 187);
wrapped(stream, units, font, value(row.descriptionOfGoods()), BILL_SMALL, 73, 186, 83, 8, 4.25);
text(stream, units, font, data.field("freightTerm"), BILL_SMALL, 87, 221);
text(stream, units, font, data.field("shipperLoadCountSeal"), BILL_SMALL, 72, 227);
text(stream, units, font, data.field("containerSpec"), BILL_SMALL, 72, 232);
text(stream, units, font, "SHIPPED ON BOARD:", BILL_SMALL, 163, 195);
text(stream, units, font, data.field("shippedOnBoard"), BILL_SMALL, 169, 202);
centered(stream, units, font, data.field("draftText"), 16, 158, 225);
centered(stream, units, font, data.field("totalPackagesInWords"), BILL_SMALL, 105, 238);
text(stream, units, font, data.field("containerSummary"), BILL_SMALL, 10, 247);
wrapped(stream, units, font, data.field("notes"), BILL_SMALL, 10, 278, 130, 3, 5);
text(stream, units, font, data.field("issuePlace"), BILL_SMALL, 140, 288);
text(stream, units, font, data.field("issueDate"), BILL_SMALL, 168, 288);
}
private FreightA4PdfDtos.CargoRow firstCargoRow(FreightA4PdfDtos.Document data) {
if (data.cargoRows() == null || data.cargoRows().isEmpty() || data.cargoRows().get(0) == null) {
return new FreightA4PdfDtos.CargoRow("", "", "", "", "", "");
}
return data.cargoRows().get(0);
}
private void text(PDPageContentStream stream, PdfUnits units, PDFont font, String text,
float fontSize, double xMm, double baselineMmFromTop) throws IOException {
String value = value(text).replace('\n', ' ');
if (value.isEmpty()) {
return;
}
stream.beginText();
stream.setFont(font, fontSize);
stream.newLineAtOffset(PdfUnits.mmToPt(xMm), units.topDownMmToPdfY(baselineMmFromTop));
stream.showText(value);
stream.endText();
}
private void centered(PDPageContentStream stream, PdfUnits units, PDFont font, String text,
float fontSize, double centerXmm, double baselineMmFromTop) throws IOException {
String value = value(text);
if (value.isEmpty()) {
return;
}
String[] lines = value.split("\\n", -1);
double y = baselineMmFromTop - (lines.length - 1) * 2.2;
for (String line : lines) {
float width = font.getStringWidth(line) / 1000f * fontSize;
float x = PdfUnits.mmToPt(centerXmm) - width / 2f;
stream.beginText();
stream.setFont(font, fontSize);
stream.newLineAtOffset(x, units.topDownMmToPdfY(y));
stream.showText(line);
stream.endText();
y += 5;
}
}
private void wrapped(PDPageContentStream stream, PdfUnits units, PDFont font, String text, float fontSize,
double xMm, double firstBaselineMmFromTop, double widthMm, int maxLines,
double lineHeightMm) throws IOException {
List<String> lines = wrap(font, fontSize, value(text), PdfUnits.mmToPt(widthMm), maxLines);
double y = firstBaselineMmFromTop;
for (String line : lines) {
text(stream, units, font, line, fontSize, xMm, y);
y += lineHeightMm;
}
}
private List<String> wrap(PDFont font, float fontSize, String text, float maxWidthPt, int maxLines)
throws IOException {
List<String> lines = new ArrayList<>();
if (text.isEmpty() || maxLines < 1) {
return lines;
}
for (String sourceLine : text.split("\\n", -1)) {
StringBuilder current = new StringBuilder();
for (String token : sourceLine.split("(?<=\\s)|(?=\\s)")) {
if (token.isEmpty()) {
continue;
}
String candidate = current + token;
if (!current.isEmpty() && width(font, fontSize, candidate) > maxWidthPt) {
lines.add(current.toString().stripTrailing());
if (lines.size() >= maxLines) {
return lines;
}
current.setLength(0);
candidate = token.stripLeading();
}
if (width(font, fontSize, candidate) > maxWidthPt && current.isEmpty()) {
for (String broken : breakLongToken(font, fontSize, candidate, maxWidthPt)) {
lines.add(broken);
if (lines.size() >= maxLines) {
return lines;
}
}
} else {
current.setLength(0);
current.append(candidate);
}
}
if (!current.isEmpty()) {
lines.add(current.toString().stripTrailing());
if (lines.size() >= maxLines) {
return lines;
}
}
}
return lines;
}
private List<String> breakLongToken(PDFont font, float fontSize, String token, float maxWidthPt)
throws IOException {
List<String> lines = new ArrayList<>();
StringBuilder current = new StringBuilder();
for (int offset = 0; offset < token.length(); ) {
int codePoint = token.codePointAt(offset);
String next = current + new String(Character.toChars(codePoint));
if (!current.isEmpty() && width(font, fontSize, next) > maxWidthPt) {
lines.add(current.toString());
current.setLength(0);
}
current.appendCodePoint(codePoint);
offset += Character.charCount(codePoint);
}
if (!current.isEmpty()) {
lines.add(current.toString());
}
return lines;
}
private float width(PDFont font, float fontSize, String text) throws IOException {
return font.getStringWidth(text) / 1000f * fontSize;
}
private void rect(PDPageContentStream stream, double xMm, double yMm, double widthMm, double heightMm)
throws IOException {
stream.setLineWidth(0.5f);
stream.addRect(PdfUnits.mmToPt(xMm), PdfUnits.mmToPt(PAGE_HEIGHT_MM - yMm - heightMm),
PdfUnits.mmToPt(widthMm), PdfUnits.mmToPt(heightMm));
stream.stroke();
}
private void line(PDPageContentStream stream, double x1Mm, double y1Mm, double x2Mm, double y2Mm)
throws IOException {
stream.setLineWidth(0.45f);
stream.moveTo(PdfUnits.mmToPt(x1Mm), PdfUnits.mmToPt(PAGE_HEIGHT_MM - y1Mm));
stream.lineTo(PdfUnits.mmToPt(x2Mm), PdfUnits.mmToPt(PAGE_HEIGHT_MM - y2Mm));
stream.stroke();
}
private String value(String text) {
return text == null ? "" : text;
}
}
@@ -0,0 +1,257 @@
package com.baiyue.print.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.print.config.PrintPdfProperties;
import com.baiyue.print.dto.FreightA4PdfDtos;
import com.baiyue.print.renderer.FreightA4Renderer;
import jakarta.annotation.Resource;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigDecimal;
import java.text.Normalizer;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.font.PDType0Font;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;
/** Validates A4 freight documents and renders one A4 page per request document. */
@Service
public class FreightA4PdfService {
private static final int MAX_FILE_NAME_CODE_POINTS = 120;
private static final int MAX_FIELD_KEY_CODE_POINTS = 80;
private static final int MAX_FIELD_VALUE_CODE_POINTS = 4000;
private static final int MAX_CARGO_ROWS = 50;
private static final BigDecimal A4_WIDTH_MM = new BigDecimal("210");
private static final BigDecimal A4_HEIGHT_MM = new BigDecimal("297");
private static final Set<String> SUPPORTED_TEMPLATES = Set.of(
FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE,
FreightA4PdfDtos.BILL_CONFIRMATION_TEMPLATE);
private static final Set<Character> FORBIDDEN_FILE_NAME_CHARACTERS =
Set.of('/', '\\', ':', '*', '?', '"', '<', '>', '|');
@Resource private PrintPdfProperties properties;
@Resource private PdfGenerationLimiter limiter;
@Resource private FreightA4Renderer renderer;
public GeneratedPdf generate(FreightA4PdfDtos.Request request) {
NormalizedRequest normalized = validateAndNormalize(request);
try (PdfGenerationLimiter.Permit ignored = limiter.acquire()) {
return render(normalized);
}
}
private GeneratedPdf render(NormalizedRequest request) {
try (PDDocument document = new PDDocument();
InputStream fontStream = openFont();
LimitedByteArrayOutputStream output =
new LimitedByteArrayOutputStream(properties.getMaxOutputBytes())) {
PDType0Font font = PDType0Font.load(document, fontStream, true);
for (FreightA4PdfDtos.Document documentData : request.documents()) {
renderer.renderPage(document, font, request.templateCode(), documentData);
}
document.save(output);
byte[] content = output.toByteArray();
return new GeneratedPdf(content, request.fileName(), request.documents().size());
} catch (OutputLimitExceededException exception) {
throw new BizException(ResultCode.INTERNAL_ERROR, "生成的 PDF 超过输出大小上限");
} catch (IOException | RuntimeException exception) {
if (exception instanceof BizException bizException) {
throw bizException;
}
throw new BizException(ResultCode.INTERNAL_ERROR, "PDF 生成失败");
}
}
private InputStream openFont() throws IOException {
ClassPathResource resource = new ClassPathResource(properties.getFontResource());
if (!resource.exists()) {
throw new IOException("configured PDF font resource does not exist");
}
return resource.getInputStream();
}
private NormalizedRequest validateAndNormalize(FreightA4PdfDtos.Request request) {
if (request == null) {
invalid("request is required");
}
String templateCode = normalizeRequiredLine(request.templateCode(), "templateCode");
if (!SUPPORTED_TEMPLATES.contains(templateCode)) {
invalid("templateCode is not supported");
}
if (request.pageSpec() == null) {
invalid("pageSpec is required");
}
requireSupportedDimension(request.pageSpec().widthMm(), A4_WIDTH_MM, "pageSpec.widthMm");
requireSupportedDimension(request.pageSpec().heightMm(), A4_HEIGHT_MM, "pageSpec.heightMm");
if (request.documents() == null || request.documents().isEmpty()) {
invalid("documents must contain at least one item");
}
if (request.documents().size() > properties.getMaxPages()) {
invalid("documents must not contain more than " + properties.getMaxPages() + " items");
}
List<FreightA4PdfDtos.Document> documents = new ArrayList<>(request.documents().size());
for (int index = 0; index < request.documents().size(); index++) {
documents.add(normalizeDocument(request.documents().get(index), index));
}
return new NormalizedRequest(templateCode, normalizeFileName(request.downloadFileName()),
List.copyOf(documents));
}
private FreightA4PdfDtos.Document normalizeDocument(FreightA4PdfDtos.Document document, int index) {
if (document == null) {
invalid("documents[" + index + "] is required");
}
Map<String, String> fields = new LinkedHashMap<>();
if (document.fields() != null) {
for (Map.Entry<String, String> entry : document.fields().entrySet()) {
String key = normalizeRequiredLine(entry.getKey(), field(index, "fields key"));
if (key.codePointCount(0, key.length()) > MAX_FIELD_KEY_CODE_POINTS) {
invalid(field(index, "fields." + key) + " key is too long");
}
fields.put(key, normalizeOptionalBlock(entry.getValue(), field(index, "fields." + key)));
}
}
List<FreightA4PdfDtos.CargoRow> rows = new ArrayList<>();
if (document.cargoRows() != null) {
if (document.cargoRows().size() > MAX_CARGO_ROWS) {
invalid(field(index, "cargoRows") + " must not contain more than " + MAX_CARGO_ROWS + " items");
}
for (int rowIndex = 0; rowIndex < document.cargoRows().size(); rowIndex++) {
rows.add(normalizeCargoRow(document.cargoRows().get(rowIndex), index, rowIndex));
}
}
return new FreightA4PdfDtos.Document(Map.copyOf(fields), List.copyOf(rows));
}
private FreightA4PdfDtos.CargoRow normalizeCargoRow(FreightA4PdfDtos.CargoRow row, int documentIndex,
int rowIndex) {
if (row == null) {
invalid("documents[" + documentIndex + "].cargoRows[" + rowIndex + "] is required");
}
String prefix = "documents[" + documentIndex + "].cargoRows[" + rowIndex + "].";
return new FreightA4PdfDtos.CargoRow(
normalizeOptionalBlock(row.marksAndNos(), prefix + "marksAndNos"),
normalizeOptionalBlock(row.quantity(), prefix + "quantity"),
normalizeOptionalBlock(row.packaging(), prefix + "packaging"),
normalizeOptionalBlock(row.descriptionOfGoods(), prefix + "descriptionOfGoods"),
normalizeOptionalBlock(row.grossWeight(), prefix + "grossWeight"),
normalizeOptionalBlock(row.measurement(), prefix + "measurement"));
}
private String normalizeFileName(String value) {
if (value == null) {
return "freight-a4-documents.pdf";
}
String normalized = normalizeRequiredLine(value, "downloadFileName");
if (normalized.codePointCount(0, normalized.length()) > MAX_FILE_NAME_CODE_POINTS) {
invalid("downloadFileName must not exceed 120 characters");
}
if (!normalized.toLowerCase(java.util.Locale.ROOT).endsWith(".pdf")) {
invalid("downloadFileName must end with .pdf");
}
for (int offset = 0; offset < normalized.length(); offset++) {
if (FORBIDDEN_FILE_NAME_CHARACTERS.contains(normalized.charAt(offset))) {
invalid("downloadFileName contains a reserved character");
}
}
return normalized;
}
private String normalizeRequiredLine(String value, String field) {
String normalized = normalizeOptionalBlock(value, field);
if (normalized.isBlank()) {
invalid(field + " must not be blank");
}
if (normalized.contains("\n")) {
invalid(field + " must be a single line");
}
return normalized;
}
private String normalizeOptionalBlock(String value, String field) {
if (value == null) {
return "";
}
String normalized = Normalizer.normalize(value, Normalizer.Form.NFC)
.replace("\r\n", "\n")
.replace('\r', '\n');
if (normalized.codePointCount(0, normalized.length()) > MAX_FIELD_VALUE_CODE_POINTS) {
invalid(field + " must not exceed " + MAX_FIELD_VALUE_CODE_POINTS + " characters");
}
normalized.codePoints().forEach(codePoint -> {
if (isForbiddenControl(codePoint)) {
invalid(field + " contains a control character");
}
});
return normalized.strip();
}
private boolean isForbiddenControl(int codePoint) {
int type = Character.getType(codePoint);
return codePoint != '\n' && codePoint != '\t'
&& (Character.isISOControl(codePoint)
|| type == Character.FORMAT
|| type == Character.LINE_SEPARATOR
|| type == Character.PARAGRAPH_SEPARATOR);
}
private void requireSupportedDimension(BigDecimal actual, BigDecimal expected, String field) {
if (actual == null || actual.compareTo(expected) != 0) {
invalid(field + " is not supported");
}
}
private String field(int index, String name) {
return "documents[" + index + "]." + name;
}
private void invalid(String message) {
throw new BizException(ResultCode.VALIDATION_FAILED, message);
}
public record GeneratedPdf(byte[] content, String fileName, int pageCount) {
}
private record NormalizedRequest(String templateCode, String fileName, List<FreightA4PdfDtos.Document> documents) {
}
private static final class LimitedByteArrayOutputStream extends ByteArrayOutputStream {
private final long maxBytes;
private LimitedByteArrayOutputStream(long maxBytes) {
if (maxBytes < 1 || maxBytes > Integer.MAX_VALUE) {
throw new IllegalArgumentException("max-output-bytes must be between 1 and Integer.MAX_VALUE");
}
this.maxBytes = maxBytes;
}
@Override
public synchronized void write(int value) {
ensureCapacityWithinLimit(1);
super.write(value);
}
@Override
public synchronized void write(byte[] bytes, int offset, int length) {
ensureCapacityWithinLimit(length);
super.write(bytes, offset, length);
}
private void ensureCapacityWithinLimit(int addedBytes) {
if ((long) count + addedBytes > maxBytes) {
throw new OutputLimitExceededException();
}
}
}
private static final class OutputLimitExceededException extends RuntimeException {
}
}
@@ -52,6 +52,54 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
/internal/print/v1/pdfs/freight-a4-documents:
post:
summary: Generate an A4 sea-freight document PDF
operationId: generateFreightA4DocumentPdf
security:
- internalServiceToken: []
parameters:
- $ref: '#/components/parameters/RequestId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/FreightA4PdfRequest'
responses:
'200':
description: PDF generation succeeded, or a business error was returned as ApiResponse JSON.
headers:
Content-Disposition:
description: UTF-8 attachment filename. Present only for a successful PDF.
schema:
type: string
Cache-Control:
schema:
type: string
example: no-store
X-Print-Page-Count:
schema:
type: integer
minimum: 1
maximum: 2000
X-Request-Id:
schema:
type: string
content:
application/pdf:
schema:
type: string
format: binary
application/json:
schema:
$ref: '#/components/schemas/ApiError'
'413':
description: Request body exceeds the configured limit.
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
components:
securitySchemes:
internalServiceToken:
@@ -112,6 +160,66 @@ components:
minLength: 1
maxLength: 34
pattern: '^[\\x20-\\x7E]+$'
FreightA4PdfRequest:
type: object
required: [templateCode, pageSpec, documents]
properties:
templateCode:
type: string
enum: [SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1, BILL_OF_LADING_CONFIRMATION_A4_V1]
pageSpec:
$ref: '#/components/schemas/FreightA4PageSpec'
downloadFileName:
type: string
maxLength: 120
example: BYSE2607044-托运书.pdf
documents:
type: array
minItems: 1
maxItems: 2000
description: One document item renders one A4 page in request order.
items:
$ref: '#/components/schemas/FreightA4Document'
FreightA4PageSpec:
type: object
required: [widthMm, heightMm]
properties:
widthMm:
type: number
enum: [210]
heightMm:
type: number
enum: [297]
FreightA4Document:
type: object
properties:
fields:
type: object
additionalProperties:
type: string
maxLength: 4000
description: Template field map. Known keys are documented in the print module README.
cargoRows:
type: array
maxItems: 1
description: V1 templates render at most one cargo row. Callers must not send additional rows.
items:
$ref: '#/components/schemas/FreightA4CargoRow'
FreightA4CargoRow:
type: object
properties:
marksAndNos:
type: string
quantity:
type: string
packaging:
type: string
descriptionOfGoods:
type: string
grossWeight:
type: string
measurement:
type: string
ApiError:
type: object
required: [code, message]
@@ -3,7 +3,9 @@ package com.baiyue.print.controller;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.web.GlobalExceptionHandler;
import com.baiyue.print.dto.FreightA4PdfDtos;
import com.baiyue.print.dto.InboundMarkPdfDtos;
import com.baiyue.print.service.FreightA4PdfService;
import com.baiyue.print.service.InboundMarkPdfService;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.BeforeEach;
@@ -38,6 +40,8 @@ class InternalPdfControllerTest {
private static final String TOKEN = "0123456789abcdef0123456789abcdef";
@MockBean
private InboundMarkPdfService service;
@MockBean
private FreightA4PdfService freightA4PdfService;
@Resource
private InternalPdfController controller;
private MockMvc mockMvc;
@@ -127,6 +131,28 @@ class InternalPdfControllerTest {
assertThat(captor.getValue().downloadFileName()).isEqualTo("inbound-marks-request-123.pdf");
}
@Test
void returnsFreightA4PdfWithDownloadHeaders() throws Exception {
byte[] pdf = "%PDF-a4-test".getBytes(StandardCharsets.US_ASCII);
when(freightA4PdfService.generate(any())).thenReturn(new FreightA4PdfService.GeneratedPdf(
pdf, "BYSE2607044-托运书.pdf", 1));
mockMvc.perform(post("/internal/print/v1/pdfs/freight-a4-documents")
.header(InternalPdfController.INTERNAL_TOKEN_HEADER, TOKEN)
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_PDF, MediaType.APPLICATION_JSON)
.content(validFreightA4RequestJson()))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_PDF))
.andExpect(content().bytes(pdf))
.andExpect(header().string("Cache-Control", "no-store"))
.andExpect(header().string("X-Print-Page-Count", "1"))
.andExpect(header().string("Content-Disposition",
org.hamcrest.Matchers.containsString("filename*=")));
verify(freightA4PdfService).generate(any(FreightA4PdfDtos.Request.class));
}
@Test
void endpointDoesNotPinProducedMediaTypeToPdf() throws Exception {
PostMapping mapping = InternalPdfController.class
@@ -142,6 +168,7 @@ class InternalPdfControllerTest {
.getContentAsString(StandardCharsets.UTF_8);
assertThat(contract)
.contains("/internal/print/v1/pdfs/inbound-marks:")
.contains("/internal/print/v1/pdfs/freight-a4-documents:")
.contains("application/pdf:")
.contains("application/json:")
.contains("name: X-Internal-Service-Token")
@@ -165,4 +192,29 @@ class InternalPdfControllerTest {
""";
}
private String validFreightA4RequestJson() {
return """
{
"templateCode": "SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1",
"pageSpec": {"widthMm": 210, "heightMm": 297},
"downloadFileName": "BYSE2607044-托运书.pdf",
"documents": [{
"fields": {
"toCompany": "宁波胜泽远航国际物流有限公司",
"shipper": "ZHEJIANG BAIYUE TRADE CO.,LIMITED",
"jobNumber": "BYSE2607044"
},
"cargoRows": [{
"marksAndNos": "N/M",
"quantity": "700",
"packaging": "PACKAGES",
"descriptionOfGoods": "CAR ACCESSORIES",
"grossWeight": "28000.00 KGS",
"measurement": "28.000 CBM"
}]
}]
}
""";
}
}
@@ -39,6 +39,19 @@ class PrintRequestSizeFilterTest {
assertThat(response.getStatus()).isEqualTo(200);
}
@Test
void alsoCapsFreightA4DocumentRequests() throws Exception {
MockHttpServletRequest request = request("12345678901", true,
"/internal/print/v1/pdfs/freight-a4-documents");
MockHttpServletResponse response = new MockHttpServletResponse();
AtomicReference<Boolean> called = new AtomicReference<>(false);
filter.doFilter(request, response, (ignoredRequest, ignoredResponse) -> called.set(true));
assertThat(called).hasValue(false);
assertTooLarge(response);
}
@Test
void rejectsOversizedDeclaredContentLengthWithoutCallingDownstream() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest() {
@@ -68,17 +81,25 @@ class PrintRequestSizeFilterTest {
}
private MockHttpServletRequest request(String content, boolean unknownLength) {
return request(content, unknownLength, "/internal/print/v1/pdfs/inbound-marks");
}
private MockHttpServletRequest request(String content, boolean unknownLength, String path) {
MockHttpServletRequest request = unknownLength ? new MockHttpServletRequest() {
@Override public int getContentLength() { return -1; }
@Override public long getContentLengthLong() { return -1; }
} : new MockHttpServletRequest();
initialize(request, content);
initialize(request, content, path);
return request;
}
private void initialize(MockHttpServletRequest request, String content) {
initialize(request, content, "/internal/print/v1/pdfs/inbound-marks");
}
private void initialize(MockHttpServletRequest request, String content, String path) {
request.setMethod("POST");
request.setRequestURI("/internal/print/v1/pdfs/inbound-marks");
request.setRequestURI(path);
request.setContent(content.getBytes(StandardCharsets.UTF_8));
request.setAttribute("requestId", "request-123");
request.setAttribute("traceId", "trace-123");
@@ -0,0 +1,134 @@
package com.baiyue.print.service;
import static org.assertj.core.api.Assertions.assertThat;
import com.baiyue.print.config.PrintPdfProperties;
import com.baiyue.print.dto.FreightA4PdfDtos;
import com.baiyue.print.renderer.FreightA4Renderer;
import com.baiyue.print.renderer.PdfUnits;
import jakarta.annotation.Resource;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
import org.junit.jupiter.api.Test;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
@SpringJUnitConfig(classes = {FreightA4PdfService.class, PrintPdfProperties.class,
PdfGenerationLimiter.class, FreightA4Renderer.class})
class FreightA4PdfServiceGenerationTest {
@Resource
private FreightA4PdfService service;
@Test
void generatesShippingInstructionAsStrictA4Pdf() throws IOException {
FreightA4PdfService.GeneratedPdf generated = service.generate(request(
FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE,
"BYSE2607044-shipping-instruction.pdf",
shippingDocument()));
assertThat(generated.fileName()).isEqualTo("BYSE2607044-shipping-instruction.pdf");
assertThat(generated.pageCount()).isEqualTo(1);
assertThat(generated.content()).startsWith("%PDF-".getBytes(java.nio.charset.StandardCharsets.US_ASCII));
try (PDDocument document = Loader.loadPDF(generated.content())) {
assertA4(document);
String text = new PDFTextStripper().getText(document);
assertThat(text).contains("Sea Freight Shipping Instructions", "Job Nbr",
"BYSE2607044", "CAR ACCESSORIES", "SAY SIX HUNDRED AND FIFTY NINE PACKAGES ONLY.");
}
}
@Test
void generatesBillConfirmationAsStrictA4Pdf() throws IOException {
FreightA4PdfService.GeneratedPdf generated = service.generate(request(
FreightA4PdfDtos.BILL_CONFIRMATION_TEMPLATE,
"BYSE2607044-bill-confirmation.pdf",
billConfirmationDocument()));
assertThat(generated.pageCount()).isEqualTo(1);
try (PDDocument document = Loader.loadPDF(generated.content())) {
assertA4(document);
String text = new PDFTextStripper().getText(document);
assertThat(text).contains("WHY074G504054", "D R A F T", "SAID TO CONTAIN(S)",
"WHSU2884669/WHLX730243/20GP/1028PACKAGES/28000KGS/ 28CBM");
}
}
private FreightA4PdfDtos.Request request(String templateCode, String fileName,
FreightA4PdfDtos.Document document) {
return new FreightA4PdfDtos.Request(templateCode,
new FreightA4PdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297.0")),
fileName, List.of(document));
}
private FreightA4PdfDtos.Document shippingDocument() {
return new FreightA4PdfDtos.Document(Map.ofEntries(
Map.entry("toCompany", "宁波胜泽远航国际物流有限公司"),
Map.entry("issuerNameCn", "浙江柏悦贸易有限公司(香港)"),
Map.entry("issuerNameEn", "ZHEJIANG BAIYUE TRADE CO.,LIMITED"),
Map.entry("issuerAddress", "地址:浙江省义乌市稠州北路 1399 号新光国际 8613 室"),
Map.entry("issuerPostalCode", "322000"),
Map.entry("issuerTel", "0579-85875557"),
Map.entry("issuerFax", "0579-85601277"),
Map.entry("shipper", "ZHEJIANG BAIYUE TRADE CO.,LIMITED\nROOM 8613,NO.1399,CHOUZHOU NORTH ROAD,FUTIAN STREET,YIWU CITY,ZHEJIANG PROVINCE\nTEL:0579-85695557"),
Map.entry("consignee", "SANATRK FOR IMPORT AND TRADE OF AUTO SPARE PARTS\n3 ATIA ELZEKRY ST- OMAR EBN ELKHTAB CITY SHOBRA\nELKHIMA QALYUBIA SECOND-OWNER/ MENA MATTA GABALLA\nMATTA\nTEL:01225672354"),
Map.entry("notifyParty", "SAME AS CONSIGNEE"),
Map.entry("picName", "苏天翔"),
Map.entry("jobNumber", "BYSE2607044"),
Map.entry("etdDate", "2026/07/24"),
Map.entry("serviceTerm", "CY-CY"),
Map.entry("freightTerm", "FREIGHT PREPAID"),
Map.entry("cargoQuantity", "1*20GP"),
Map.entry("portOfLoading", "NINGBO"),
Map.entry("portOfDischarge", "EL DEKHEILA"),
Map.entry("placeOfDelivery", "EL DEKHEILA"),
Map.entry("totalPackagesInWords", "SAY SIX HUNDRED AND FIFTY NINE PACKAGES ONLY."),
Map.entry("carrier", "万海航运(香港)股份有限公司"),
Map.entry("bookingAgent", "宁波胜泽远航国际物流有限公司"),
Map.entry("printDate", "2026 年 07 月 16 日")
), List.of(new FreightA4PdfDtos.CargoRow("N/M", "700", "PACKAGES",
"CAR ACCESSORIES\nACID: 4120661061019510011\n申请目的港 21 天免用箱",
"28000.00 KGS", "28.000 CBM")));
}
private FreightA4PdfDtos.Document billConfirmationDocument() {
return new FreightA4PdfDtos.Document(Map.ofEntries(
Map.entry("shipper", "ZHEJIANG BAIYUE TRADE CO.,LIMITED\nROOM 8613,NO.1399,CHOUZHOU NORTH ROAD,FUTIAN\nSTREET,YIWU CITY,ZHEJIANG PROVINCE\nTEL:0579-85695557"),
Map.entry("consignee", "SANATRK FOR IMPORT AND TRADE OF AUTO SPARE PARTS\n3 ATIA ELZEKRY ST- OMAR EBN ELKHTAB CITY SHOBRA\nELKHIMA QALYUBIA SECOND-OWNER/ MENA MATTA GABALLA\nMATTA\nTEL:01225672354"),
Map.entry("notifyParty", "SAME AS CONSIGNEE"),
Map.entry("bookingNumber", "WHY074G504054"),
Map.entry("ensAmsCutoffDate", "2026/07/20"),
Map.entry("expectedDepartureDate", "2026/07/24"),
Map.entry("oceanVesselVoyage", "WAN HAI 622 W027"),
Map.entry("portOfLoading", "NINGBO"),
Map.entry("portOfDischarge", "EL DEKHEILA"),
Map.entry("placeOfDelivery", "EL DEKHEILA"),
Map.entry("finalDestination", "EL DEKHEILA"),
Map.entry("serviceTerm", "CY-CY"),
Map.entry("freightTerm", "FREIGHT PREPAID"),
Map.entry("shipperLoadCountSeal", "SHPR'S LOAD,COUNT AND SEAL"),
Map.entry("containerSpec", "1*20GP"),
Map.entry("shippedOnBoard", "24-JUL-2026"),
Map.entry("draftText", "D R A F T"),
Map.entry("totalPackagesInWords", "SAY ONE THOUSAND AND TWENTY EIGHT PACKAGES ONLY."),
Map.entry("containerSummary", "WHSU2884669/WHLX730243/20GP/1028PACKAGES/28000KGS/ 28CBM"),
Map.entry("notes", "备注:1.多个箱子请分别提供分箱数,分箱品名,若未指出默认品名混装.\n2.请在 ENS/AMS 规定时间内确认好提单内容,否则有上了不船的风险."),
Map.entry("issuePlace", "NINGBO"),
Map.entry("issueDate", "24-JUL-2026")
), List.of(new FreightA4PdfDtos.CargoRow("N/M", "1028", "PACKAGES",
"AUTO SPARE PARTS\nHS CODE 870899\nACID: 4120661061019510011\nEGYPTIAN IMPORTER VAT NUMBER:412066106\nEXPORTER REGISTRATION\nNUMBER:CN-02-91330782MA2E62HK3Y\n申请目的港 14 天免用箱",
"28000.00 KGS", "28.000 CBM")));
}
private void assertA4(PDDocument document) {
assertThat(document.getNumberOfPages()).isEqualTo(1);
assertThat(document.getPage(0).getMediaBox().getWidth()).isCloseTo(PdfUnits.mmToPt(210),
org.assertj.core.data.Offset.offset(0.002f));
assertThat(document.getPage(0).getMediaBox().getHeight()).isCloseTo(PdfUnits.mmToPt(297),
org.assertj.core.data.Offset.offset(0.002f));
assertThat(document.getPage(0).getRotation()).isZero();
}
}
@@ -0,0 +1,64 @@
package com.baiyue.print.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.print.config.PrintPdfProperties;
import com.baiyue.print.dto.FreightA4PdfDtos;
import com.baiyue.print.renderer.FreightA4Renderer;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
@SpringJUnitConfig(classes = {FreightA4PdfService.class, PrintPdfProperties.class})
class FreightA4PdfServiceValidationTest {
@MockBean private PdfGenerationLimiter limiter;
@MockBean private FreightA4Renderer renderer;
@jakarta.annotation.Resource private FreightA4PdfService service;
@Test
void rejectsUnsupportedTemplateCode() {
assertValidationFailure(new FreightA4PdfDtos.Request("UNKNOWN",
pageSpec(), "a4.pdf", List.of(document())), "templateCode");
}
@Test
void rejectsNonA4PageSize() {
assertValidationFailure(new FreightA4PdfDtos.Request(
FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE,
new FreightA4PdfDtos.PageSpec(new BigDecimal("100"), new BigDecimal("100")),
"a4.pdf", List.of(document())), "pageSpec.widthMm");
}
@Test
void allowsMultilineDocumentFields() {
FreightA4PdfDtos.Request request = new FreightA4PdfDtos.Request(
FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE,
pageSpec(), "a4.pdf", List.of(document()));
assertThatCode(() -> service.generate(request)).doesNotThrowAnyException();
}
private void assertValidationFailure(FreightA4PdfDtos.Request request, String messagePart) {
assertThatThrownBy(() -> service.generate(request))
.isInstanceOfSatisfying(BizException.class, exception -> {
assertThat(exception.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED);
assertThat(exception.getMessage()).contains(messagePart);
});
}
private FreightA4PdfDtos.PageSpec pageSpec() {
return new FreightA4PdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297"));
}
private FreightA4PdfDtos.Document document() {
return new FreightA4PdfDtos.Document(Map.of("shipper", "A\nB"),
List.of(new FreightA4PdfDtos.CargoRow("N/M", "1", "PACKAGES", "GOODS", "1 KGS", "1 CBM")));
}
}
@@ -9,27 +9,26 @@ import com.baiyue.warehouse.service.PartnerQueryService;
import jakarta.annotation.Resource;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.beans.factory.annotation.Value;
import java.util.List;
@RestController
@RequestMapping("/api/merchant/v1/partners")
public class MerchantPartnerController {
private static final long PUBLIC_TENANT_ID = 0L;
@Resource
private PartnerQueryService service;
@Value("${baiyue.warehouse.public-tenant-id:0}")
private long publicTenantId;
@GetMapping("/candidates")
public ApiResponse<List<PartnerEntity>> candidates(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required=false) String keyword,@RequestParam(required=false) String type) {
requireMerchant(user);
return ApiResponse.success(service.candidates(publicTenantId,keyword,type));
return ApiResponse.success(service.candidates(PUBLIC_TENANT_ID,keyword,type));
}
@GetMapping("/{id}")
public ApiResponse<PartnerEntity> detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long id) {
requireMerchant(user);
return ApiResponse.success(service.active(publicTenantId,id));
return ApiResponse.success(service.active(PUBLIC_TENANT_ID,id));
}
private static void requireMerchant(AuthenticatedUser user) {
@@ -15,7 +15,6 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -23,7 +22,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 商户端公共仓库查询接口。认证用户共享平台默认租户维护的仓库配置。
* 商户端公共仓库查询接口。认证用户共享平台公共作用域维护的仓库配置。
*/
@Tag(
name = "Merchant warehouses / 商户仓库",
@@ -32,15 +31,14 @@ import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/merchant/v1/warehouses")
public class MerchantWarehouseController {
private static final long PUBLIC_TENANT_ID = 0L;
@Resource
private WarehouseApplicationService service;
@Value("${baiyue.warehouse.public-tenant-id:0}")
private long publicTenantId;
@Operation(
summary = "List available warehouses / 查询可用仓库",
description = "Returns active public warehouses maintained under the configured public tenant. / 返回平台默认租户维护的可用公共仓库。"
description = "Returns active public warehouses maintained under the platform public scope. / 返回平台公共作用域维护的可用公共仓库。"
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
@@ -51,12 +49,12 @@ public class MerchantWarehouseController {
@AuthenticationPrincipal AuthenticatedUser user,
@Valid WarehouseRequests.QueryRequest query) {
requireMerchant(user);
return ApiResponse.success(service.merchantPage(publicTenantId, query));
return ApiResponse.success(service.merchantPage(PUBLIC_TENANT_ID, query));
}
@Operation(
summary = "Get warehouse details / 查询仓库详情",
description = "Returns a public warehouse maintained under the configured public tenant. / 返回平台默认租户维护的公共仓库详情。"
description = "Returns a public warehouse maintained under the platform public scope. / 返回平台公共作用域维护的公共仓库详情。"
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
@@ -68,7 +66,7 @@ public class MerchantWarehouseController {
@Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true)
@PathVariable long id) {
requireMerchant(user);
return ApiResponse.success(service.activeDetail(publicTenantId, id));
return ApiResponse.success(service.activeDetail(PUBLIC_TENANT_ID, id));
}
private static void requireMerchant(AuthenticatedUser user) {
@@ -36,11 +36,11 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** 平台库位管理接口;分页支持跨企业查询,写操作仍以目标 tenantId 隔离。 */
/** 平台库位管理接口;统一维护平台公共作用域下的库位资源。 */
@Validated
@RestController
@RequestMapping("/api/platform/v1/locations")
@Tag(name = "平台库位管理", description = "按企业租户和仓库数据范围管理库位基础资料")
@Tag(name = "平台库位管理", description = "按平台公共作用域和仓库管理库位基础资料")
public class PlatformLocationController {
@Resource
private LocationApplicationService service;
@@ -48,14 +48,14 @@ public class PlatformLocationController {
@GetMapping
@PreAuthorize("hasAuthority('platform:location:list')")
@Operation(summary = "分页查询库位",
description = "默认分页查询全部企业库位;可按目标企业租户、仓库、库区、编码、类型和状态筛选")
description = "默认分页查询全部平台公共库位;可按仓库、库区、编码、类型和状态筛选")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:location:list 权限,且仓库须属于目标企业租户")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:location:list 权限,且仓库须属于平台公共作用域")
})
public ApiResponse<PageResult<LocationEntity>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "可选目标企业租户ID;不传时查询全部企业")
@Parameter(description = "兼容作用域;仅允许 0,不传时查询平台公共资源")
@RequestParam(required = false) @Min(0) Long tenantId,
@Valid @ParameterObject PageQuery query) {
requirePlatform(user);
@@ -65,7 +65,7 @@ public class PlatformLocationController {
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('platform:location:list')")
@Operation(summary = "查询库位详情", description = "详情查询同时校验企业租户和仓库归属")
@Operation(summary = "查询库位详情", description = "详情查询同时校验平台公共作用域和仓库归属")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:location:list 权限或仓库超出数据范围"),
@@ -98,7 +98,7 @@ public class PlatformLocationController {
@PostMapping
@PreAuthorize("hasAuthority('platform:location:create')")
@Operation(summary = "新增库位", description = "在目标企业租户的指定仓库内新增唯一编码库位")
@Operation(summary = "新增库位", description = "在平台公共仓库内新增唯一编码库位")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "新增成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:location:create 权限或仓库超出数据范围"),
@@ -32,17 +32,17 @@ import org.springframework.web.bind.annotation.RestController;
@Validated
@RestController
@RequestMapping("/api/platform/v1/pallets")
@Tag(name = "平台托盘管理", description = "按租户和仓库维护托盘主数据")
@Tag(name = "平台托盘管理", description = "按平台公共作用域和仓库维护托盘主数据")
public class PlatformPalletController {
@Resource
private PalletApplicationService service;
@GetMapping
@PreAuthorize("hasAuthority('platform:pallet:list')")
@Operation(summary = "分页查询托盘", description = "默认查询全部企业;可按企业、仓库、托盘编码和类型筛选")
@Operation(summary = "分页查询托盘", description = "默认查询全部平台公共托盘;可按仓库、托盘编码和类型筛选")
@ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:pallet:list 权限"))
public ApiResponse<PageResult<PalletRequests.View>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "目标企业租户ID;不传时查询全部企业")
@Parameter(description = "兼容作用域;仅允许 0,不传时查询平台公共资源")
@RequestParam(required = false) @Min(0) Long tenantId,
@Valid @ParameterObject PalletRequests.PageQuery query) {
requirePlatform(user);
@@ -6,8 +6,11 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.warehouse.dto.WarehouseDetailResponse;
import com.baiyue.warehouse.dto.WarehouseImportDtos.ImportMode;
import com.baiyue.warehouse.dto.WarehouseImportDtos.WarehouseImportResult;
import com.baiyue.warehouse.dto.WarehouseRequests;
import com.baiyue.warehouse.entity.WarehouseEntity;
import com.baiyue.warehouse.dto.WarehouseSummaryResponse;
import com.baiyue.warehouse.service.WarehouseImportService;
import com.baiyue.warehouse.service.WarehouseApplicationService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -16,6 +19,11 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
@@ -27,25 +35,32 @@ import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
/**
* 平台端仓库管理接口,提供跨租户管理场景下的仓库创建、修改、查询和状态维护能力。
* 平台端仓库管理接口,统一维护平台公共作用域下的仓库主数据。
*/
@Validated
@Tag(
name = "Platform warehouse administration / 平台仓库管理",
description = "Platform APIs for managing warehouses across tenants. / 平台端按租户管理仓库的接口。"
description = "Platform APIs for managing public warehouses. / 平台端管理公共仓库的接口。"
)
@RestController
@RequestMapping("/api/platform/v1/warehouse-admin")
public class PlatformWarehouseController {
@Resource
private WarehouseApplicationService service;
@Resource
private WarehouseImportService importService;
@Operation(
summary = "Create warehouse / 新建仓库",
description = "Creates a warehouse, address and service capabilities for the target tenant. / 为目标租户创建仓库、地址和服务能力。"
description = "Creates a public warehouse, address and service capabilities. / 创建平台公共仓库、地址和服务能力。"
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Created successfully / 创建成功"),
@@ -58,7 +73,7 @@ public class PlatformWarehouseController {
public ApiResponse<Long> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody WarehouseRequests.SaveRequest request) {
requirePlatform(user);
requireTenant(request.tenantId());
if (request.tenantId() != null) requireTenant(request.tenantId());
return ApiResponse.success(service.create(request));
}
@@ -69,7 +84,7 @@ public class PlatformWarehouseController {
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Updated successfully / 更新成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid request / 请求参数无效"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform warehouse edit permission required (platform:warehouse:edit), or tenant validation failed / 需要平台仓库编辑权限,或租户校验失败"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform warehouse edit permission required (platform:warehouse:edit), or scope validation failed / 需要平台仓库编辑权限,或作用域校验失败"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found / 仓库不存在"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Warehouse code already exists / 仓库编码已存在")
})
@@ -77,7 +92,7 @@ public class PlatformWarehouseController {
@PreAuthorize("hasAuthority('platform:warehouse:edit')")
public ApiResponse<Void> update(
@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true)
@Parameter(description = "Compatibility scope; only 0 is allowed / 兼容作用域,仅允许 0", example = "0", required = true)
@RequestParam @NotNull Long tenantId,
@Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true)
@PathVariable long id,
@@ -90,7 +105,7 @@ public class PlatformWarehouseController {
@Operation(
summary = "List platform warehouses / 查询平台仓库",
description = "Returns all platform warehouses by default; customer ID is an optional compatibility filter. / 默认分页查询平台全部仓库,客户 ID 仅作为兼容筛选条件。"
description = "Returns public platform warehouses; tenantId is kept only for compatibility and must be 0. / 分页查询平台公共仓库;tenantId 仅保留兼容且必须为 0。"
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
@@ -99,19 +114,64 @@ public class PlatformWarehouseController {
})
@GetMapping
@PreAuthorize("hasAuthority('platform:warehouse:list')")
public ApiResponse<PageResult<WarehouseEntity>> page(
public ApiResponse<PageResult<WarehouseSummaryResponse>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Optional customer ID / 可选客户 ID", example = "10001")
@Parameter(description = "Compatibility scope; only 0 is allowed / 兼容作用域,仅允许 0", example = "0")
@RequestParam(required = false) Long tenantId,
@Valid WarehouseRequests.QueryRequest request) {
@Valid @ParameterObject WarehouseRequests.QueryRequest request) {
requirePlatform(user);
if (tenantId != null) requireTenant(tenantId);
return ApiResponse.success(service.page(0L, request));
return ApiResponse.success(service.pageSummaries(0L, request));
}
@GetMapping("/import-template")
@PreAuthorize("hasAuthority('platform:warehouse:create')")
@Operation(summary = "Download warehouse import template / 下载仓库导入模板",
description = "Downloads the current XLSX template for warehouse and FBA warehouse import. / 下载仓库与 FBA 仓库导入的当前 XLSX 模板。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Template downloaded / 模板下载成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform warehouse create permission required (platform:warehouse:create) / 需要平台仓库新增权限")
})
public ResponseEntity<byte[]> importTemplate(@AuthenticationPrincipal AuthenticatedUser user) {
requirePlatform(user);
HttpHeaders headers = new HttpHeaders();
headers.setContentDisposition(ContentDisposition.attachment()
.filename("warehouse-import-template-v1.xlsx", StandardCharsets.UTF_8).build());
headers.setContentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"));
return ResponseEntity.ok().headers(headers).body(importService.template());
}
@PostMapping(value = "/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:warehouse:create')")
@Operation(summary = "Import warehouses / 批量导入仓库",
description = "Validates the whole XLSX file and atomically creates or optionally updates warehouses. Default mode is CREATE_ONLY. / 整表校验 XLSX 后原子新增或可选更新仓库,默认 CREATE_ONLY。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Import precheck completed / 导入预检完成"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid file or query / 文件或参数无效"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform warehouse create permission required; UPSERT also requires edit permission (platform:warehouse:create, platform:warehouse:edit) / 需要平台仓库新增权限;UPSERT 还需要编辑权限")
})
public ApiResponse<WarehouseImportResult> importWarehouses(
@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Compatibility scope; only 0 is allowed / 兼容作用域,仅允许 0")
@RequestParam(required = false) Long tenantId,
@Parameter(description = "Import mode, defaults to CREATE_ONLY / 导入模式,默认 CREATE_ONLY")
@RequestParam(required = false) ImportMode mode,
@Parameter(description = "Whether to only precheck without writing data / 是否只预检不写入", example = "true")
@RequestParam(defaultValue = "false") boolean dryRun,
@RequestPart("file") MultipartFile file) {
requirePlatform(user);
requireImportPermission(user, mode);
if (tenantId != null) requireTenant(tenantId);
try {
return ApiResponse.success(importService.importFile(0L, file.getOriginalFilename(), file.getBytes(), mode, dryRun));
} catch (IOException exception) {
throw new BizException(ResultCode.BAD_REQUEST, "导入文件读取失败");
}
}
@Operation(
summary = "Get tenant warehouse details / 查询租户仓库详情",
description = "Returns the warehouse, addresses and service capabilities in the target tenant. / 返回目标租户下的仓库、地址及服务能力。"
summary = "Get public warehouse details / 查询公共仓库详情",
description = "Returns the public warehouse, addresses and service capabilities. / 返回平台公共仓库、地址及服务能力。"
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
@@ -122,7 +182,7 @@ public class PlatformWarehouseController {
@PreAuthorize("hasAuthority('platform:warehouse:list')")
public ApiResponse<WarehouseDetailResponse> detail(
@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true)
@Parameter(description = "Compatibility scope; only 0 is allowed / 兼容作用域,仅允许 0", example = "0", required = true)
@RequestParam @NotNull Long tenantId,
@Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true)
@PathVariable long id) {
@@ -145,7 +205,7 @@ public class PlatformWarehouseController {
@PreAuthorize("hasAuthority('platform:warehouse:status')")
public ApiResponse<Void> status(
@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true)
@Parameter(description = "Compatibility scope; only 0 is allowed / 兼容作用域,仅允许 0", example = "0", required = true)
@RequestParam @NotNull Long tenantId,
@Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true)
@PathVariable long id,
@@ -169,4 +229,11 @@ public class PlatformWarehouseController {
throw new BizException(ResultCode.BAD_REQUEST, "Platform resource scope must be 0 / 平台公共资源作用域必须为0");
}
}
private static void requireImportPermission(AuthenticatedUser user, ImportMode mode) {
if (mode == ImportMode.UPSERT && !user.permissions().contains("platform:warehouse:edit")) {
throw new BizException(ResultCode.FORBIDDEN,
"UPSERT import requires platform:warehouse:edit / 新增并更新导入需要平台仓库编辑权限");
}
}
}
@@ -22,15 +22,18 @@ public final class PalletRequests {
@NotNull @Min(1)
@Schema(description = "所属仓库ID", example = "190000000000000002", requiredMode = Schema.RequiredMode.REQUIRED)
Long warehouseId,
@NotBlank @Size(max = 50)
@Schema(description = "仓库内唯一托盘编码", example = "TP-001", requiredMode = Schema.RequiredMode.REQUIRED)
String palletCode,
@NotBlank @Size(max = 32)
@Schema(description = "托盘类型", example = "木托盘", requiredMode = Schema.RequiredMode.REQUIRED)
String palletType,
@NotNull @DecimalMin(value = "0", inclusive = false) @Digits(integer = 12, fraction = 6)
@Schema(description = "托盘容积,单位m³", example = "1.200000", requiredMode = Schema.RequiredMode.REQUIRED)
BigDecimal capacityCbm,
@NotNull @DecimalMin(value = "0", inclusive = false) @Digits(integer = 12, fraction = 3)
@Schema(description = "托盘重量,单位kg", example = "8.000", requiredMode = Schema.RequiredMode.REQUIRED)
BigDecimal weightKg,
@NotBlank @Size(max = 32)
@Schema(description = "托盘颜色", example = "黄色", requiredMode = Schema.RequiredMode.REQUIRED)
String color,
@Size(max = 500)
@Schema(description = "备注", example = "标准川字托盘")
String remark
@@ -64,6 +67,8 @@ public final class PalletRequests {
String palletCode,
String palletType,
BigDecimal capacityCbm,
BigDecimal weightKg,
String color,
String remark,
LocalDateTime createdAt,
LocalDateTime updatedAt
@@ -0,0 +1,51 @@
package com.baiyue.warehouse.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
/** 仓库与 FBA 仓库 XLSX 导入契约。 */
public final class WarehouseImportDtos {
private WarehouseImportDtos() {
}
@Schema(name = "WarehouseImportMode", description = "Warehouse import mode / 仓库导入模式")
public enum ImportMode {
@Schema(description = "Only create new rows; existing rows are reported but not modified / 仅新增,现有数据只预检分类不修改")
CREATE_ONLY,
@Schema(description = "Create new rows and update existing rows without overriding contact or remark / 新增并更新,更新不覆盖联系人和备注")
UPSERT
}
@Schema(name = "WarehouseImportError", description = "Warehouse import row error / 仓库导入行错误")
public record WarehouseImportError(
@Schema(description = "Excel row number / Excel 行号") int row,
@Schema(description = "Field name / 字段") String field,
@Schema(description = "Error message / 错误原因") String message
) {
}
@Schema(name = "WarehouseImportPreviewRow", description = "Warehouse import preview row / 仓库导入预检行")
public record WarehouseImportPreviewRow(
@Schema(description = "Excel row number / Excel 行号") int row,
@Schema(description = "Warehouse code / 仓库编码") String warehouseCode,
@Schema(description = "Resolved action / 预检动作", allowableValues = {"CREATE", "UPDATE", "UNCHANGED", "ERROR"}) String action,
@Schema(description = "Reason / 原因") String reason
) {
}
@Schema(name = "WarehouseImportResult", description = "Warehouse import result / 仓库导入结果")
public record WarehouseImportResult(
@Schema(description = "Original file name / 原始文件名") String fileName,
@Schema(description = "Import mode / 导入模式") ImportMode mode,
@Schema(description = "Data row count / 数据行总数") int totalRows,
@Schema(description = "Created row count / 新增行数") int createdRows,
@Schema(description = "Updated row count / 更新行数") int updatedRows,
@Schema(description = "Unchanged row count / 无变化行数") int unchangedRows,
@Schema(description = "Whether the import passed validation and committed writable rows / 是否校验通过并写入可写行") boolean success,
@Schema(description = "Whether writable rows have been committed / 是否已经写入数据") boolean committed,
@Schema(description = "Per-row preview classifications / 逐行预检分类") List<WarehouseImportPreviewRow> previewRows,
@Schema(description = "Per-row errors / 逐行错误") List<WarehouseImportError> errors
) {
}
}
@@ -5,10 +5,10 @@ import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import jakarta.validation.constraints.NotBlank;
import java.math.BigDecimal;
import java.time.LocalTime;
@@ -23,26 +23,26 @@ public final class WarehouseRequests {
@Schema(name = "WarehouseSaveRequest", description = "Warehouse create or update request / 仓库新增或更新请求")
public record SaveRequest(
@Schema(description = "Target tenant ID / 目标租户 ID", example = "10001", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull Long tenantId,
@Schema(description = "Compatibility tenant ID; platform APIs force it to 0 / 兼容租户字段,平台接口固定为 0", example = "0")
Long tenantId,
@Schema(description = "Tenant-unique warehouse code / 租户内唯一仓库编码", example = "US-LAX-01", maxLength = 50, requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank @Size(max = 50) String warehouseCode,
@Schema(description = "Warehouse name / 仓库名称", example = "Los Angeles Main Warehouse / 洛杉矶主仓", maxLength = 120, requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank @Size(max = 120) String warehouseName,
@Schema(description = "Warehouse type / 仓库类型", example = "OVERSEAS", allowableValues = {"DOMESTIC", "FBA", "OVERSEAS"}, requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank @Pattern(regexp = "DOMESTIC|FBA|OVERSEAS") String warehouseType,
@Schema(description = "ISO 3166-1 alpha-2 country code / ISO 两位国家代码", example = "US", pattern = "[A-Z]{2}", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank @Pattern(regexp = "[A-Z]{2}") String countryCode,
@Schema(description = "IANA timezone / IANA 时区", example = "America/Los_Angeles", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank String timezone,
@Schema(description = "Warehouse type; defaults to DOMESTIC / 仓库类型,默认 DOMESTIC", example = "FBA", allowableValues = {"DOMESTIC", "FBA", "OVERSEAS"}, defaultValue = "DOMESTIC")
@Pattern(regexp = "DOMESTIC|FBA|OVERSEAS") String warehouseType,
@Schema(description = "ISO 3166-1 alpha-2 country code; DOMESTIC defaults to CN / ISO 两位国家代码,DOMESTIC 默认 CN", example = "US", pattern = "[A-Z]{2}")
@Pattern(regexp = "[A-Z]{2}") String countryCode,
@Schema(description = "UTC offset; DOMESTIC defaults to UTC+8 / UTC 偏移量,DOMESTIC 默认 UTC+8", example = "UTC-8")
String timezone,
@Schema(description = "Contact name / 联系人姓名", example = "Alex Chen", maxLength = 64)
@Size(max = 64) String contactName,
@Schema(description = "Contact phone / 联系电话", example = "+1-213-555-0100", maxLength = 32)
@Size(max = 32) String contactPhone,
@Schema(description = "Contact email / 联系邮箱", example = "warehouse@example.com")
@Email String contactEmail,
@Schema(description = "Warehouse status / 仓库状态", example = "ACTIVE", allowableValues = {"ACTIVE", "INACTIVE"}, requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank String status,
@Schema(description = "Warehouse status; defaults to ACTIVE / 仓库状态,默认 ACTIVE", example = "ACTIVE", allowableValues = {"ACTIVE", "INACTIVE"}, defaultValue = "ACTIVE")
@Pattern(regexp = "ACTIVE|INACTIVE") String status,
@Schema(description = "Remark / 备注", example = "Supports appointment receiving / 支持预约入库", maxLength = 500)
@Size(max = 500) String remark,
@Schema(description = "Primary warehouse address / 仓库主地址", requiredMode = Schema.RequiredMode.REQUIRED)
@@ -54,10 +54,10 @@ public final class WarehouseRequests {
@Schema(name = "WarehouseAddressRequest", description = "Warehouse address / 仓库地址")
public record AddressRequest(
@Schema(description = "Address type / 地址类型", example = "WAREHOUSE", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank String addressType,
@Schema(description = "ISO two-letter country code / ISO 两位国家代码", example = "US", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank @Pattern(regexp = "[A-Z]{2}") String countryCode,
@Schema(description = "Address type; defaults to WAREHOUSE / 地址类型,默认 WAREHOUSE", example = "WAREHOUSE")
String addressType,
@Schema(description = "ISO two-letter country code; defaults from warehouse country / ISO 两位国家代码,默认跟随仓库国家", example = "US")
@Pattern(regexp = "[A-Z]{2}") String countryCode,
@Schema(description = "State or province / 州或省", example = "California", maxLength = 100)
@Size(max = 100) String stateProvince,
@Schema(description = "City / 城市", example = "Los Angeles", maxLength = 100, requiredMode = Schema.RequiredMode.REQUIRED)
@@ -98,6 +98,8 @@ public final class WarehouseRequests {
@Min(1) long page,
@Schema(description = "Page size / 每页条数", example = "20", defaultValue = "20", minimum = "1", maximum = "200")
@Min(1) @Max(200) long size,
@Schema(description = "Keyword across code, name, primary city, state and address / 关键字,覆盖编码、名称、主地址城市、州和地址", example = "Los Angeles")
@Size(max = 128) String keyword,
@Schema(description = "Country filter / 国家筛选", example = "US")
String countryCode,
@Schema(description = "Enabled capability code filter / 已启用能力编码筛选", example = "STORAGE")
@@ -105,7 +107,7 @@ public final class WarehouseRequests {
@Schema(description = "Warehouse type filter / 仓库类型筛选", example = "DOMESTIC", allowableValues = {"DOMESTIC", "FBA", "OVERSEAS"})
@Pattern(regexp = "DOMESTIC|FBA|OVERSEAS") String warehouseType,
@Schema(description = "Status filter / 状态筛选", example = "ACTIVE", allowableValues = {"ACTIVE", "INACTIVE"})
String status
@Pattern(regexp = "ACTIVE|INACTIVE") String status
) {
public QueryRequest {
if (page == 0) {
@@ -0,0 +1,65 @@
package com.baiyue.warehouse.dto;
import com.baiyue.warehouse.entity.WarehouseAddressEntity;
import com.baiyue.warehouse.entity.WarehouseEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime;
/** 仓库列表响应,带主地址摘要,避免列表页逐条请求详情。 */
@Schema(name = "WarehouseSummaryResponse", description = "Warehouse list item with primary address summary / 仓库列表项及主地址摘要")
public record WarehouseSummaryResponse(
@Schema(description = "Warehouse ID / 仓库 ID") Long id,
@Schema(description = "Platform public scope ID, fixed to 0 / 平台公共作用域,固定为 0") Long tenantId,
@Schema(description = "Warehouse code / 仓库编码") String warehouseCode,
@Schema(description = "Warehouse name / 仓库名称") String warehouseName,
@Schema(description = "Warehouse type / 仓库类型", allowableValues = {"DOMESTIC", "FBA", "OVERSEAS"}) String warehouseType,
@Schema(description = "ISO two-letter country code / 国家二字码") String countryCode,
@Schema(description = "UTC offset / UTC 偏移量") String timezone,
@Schema(description = "Contact name / 联系人") String contactName,
@Schema(description = "Contact phone / 联系电话") String contactPhone,
@Schema(description = "Contact email / 联系邮箱") String contactEmail,
@Schema(description = "Warehouse status / 仓库状态", allowableValues = {"ACTIVE", "INACTIVE"}) String status,
@Schema(description = "Remark / 备注") String remark,
@Schema(description = "Creation time / 创建时间") LocalDateTime createdAt,
@Schema(description = "Last update time / 更新时间") LocalDateTime updatedAt,
@Schema(description = "Primary address summary / 主地址摘要") AddressSummary primaryAddress
) {
public static WarehouseSummaryResponse of(WarehouseEntity warehouse, WarehouseAddressEntity address) {
return new WarehouseSummaryResponse(
warehouse.getId(), warehouse.getTenantId(), warehouse.getWarehouseCode(), warehouse.getWarehouseName(),
warehouse.getWarehouseType(), warehouse.getCountryCode(), warehouse.getTimezone(),
warehouse.getContactName(), warehouse.getContactPhone(), warehouse.getContactEmail(),
warehouse.getStatus(), warehouse.getRemark(), warehouse.getCreatedAt(), warehouse.getUpdatedAt(),
address == null ? null : AddressSummary.of(address)
);
}
@Schema(name = "WarehouseAddressSummary", description = "Primary warehouse address summary / 仓库主地址摘要")
public record AddressSummary(
@Schema(description = "Address ID / 地址 ID") Long id,
@Schema(description = "Address type / 地址类型") String addressType,
@Schema(description = "Country code / 国家二字码") String countryCode,
@Schema(description = "State or province / 州或省") String stateProvince,
@Schema(description = "City / 城市") String city,
@Schema(description = "District / 区县") String district,
@Schema(description = "Postal code / 邮政编码") String postalCode,
@Schema(description = "Address line 1 / 地址第一行") String addressLine1,
@Schema(description = "Address line 2 / 地址第二行") String addressLine2,
@Schema(description = "Display address / 展示地址") String displayAddress
) {
static AddressSummary of(WarehouseAddressEntity address) {
return new AddressSummary(address.getId(), address.getAddressType(), address.getCountryCode(),
address.getStateProvince(), address.getCity(), address.getDistrict(), address.getPostalCode(),
address.getAddressLine1(), address.getAddressLine2(), displayAddress(address));
}
private static String displayAddress(WarehouseAddressEntity address) {
return java.util.stream.Stream.of(address.getStateProvince(), address.getCity(), address.getDistrict(),
address.getAddressLine1(), address.getAddressLine2())
.filter(value -> value != null && !value.isBlank())
.reduce((left, right) -> left + " " + right)
.orElse("");
}
}
}
@@ -11,7 +11,7 @@ import lombok.Setter;
import java.math.BigDecimal;
/**
* 托盘主数据实体。状态同时承载可用、预占、占用和停用四种资源状态。
* 托盘主数据实体。状态仅供内部预占、占用流程使用,不属于平台维护字段。
*/
@Getter
@Setter
@@ -37,6 +37,12 @@ public class PalletEntity extends BaseEntity {
@Schema(description = "托盘容积,单位m³", example = "1.200000")
private BigDecimal capacityCbm;
@Schema(description = "托盘重量,单位kg", example = "8.000")
private BigDecimal weightKg;
@Schema(description = "托盘颜色", example = "黄色")
private String color;
@Schema(description = "资源状态", allowableValues = {"AVAILABLE", "RESERVED", "OCCUPIED", "DISABLED"})
private String status;
@@ -31,7 +31,7 @@ public class WarehouseEntity extends BaseEntity {
private String warehouseType;
@Schema(description = "ISO two-letter country code / ISO 两位国家代码", example = "US")
private String countryCode;
@Schema(description = "IANA timezone / IANA 时区", example = "America/Los_Angeles")
@Schema(description = "UTC offset / UTC 偏移量", example = "UTC-8")
private String timezone;
@Schema(description = "Contact name / 联系人", example = "Alex Chen")
private String contactName;
@@ -2,7 +2,14 @@ package com.baiyue.warehouse.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.entity.PalletEntity;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/** 托盘主数据 Mapper。 */
public interface PalletMapper extends BaseMapper<PalletEntity> {
/** 编码序列必须包含逻辑删除记录,避免历史编码被复用。 */
@Select("SELECT pallet_code FROM wh_pallet WHERE tenant_id = #{tenantId} AND pallet_code LIKE 'TP-%'")
List<String> selectAllCodes(@Param("tenantId") long tenantId);
}
@@ -13,10 +13,14 @@ import org.springframework.util.StringUtils;
import java.util.Collection;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** 托盘持久化仓储;平台分页支持跨租户筛选,资源操作始终包含租户和仓库边界。 */
@Repository
public class PalletRepository {
private static final Pattern PALLET_CODE = Pattern.compile("TP-(\\d+)");
@Resource
private PalletMapper mapper;
@@ -59,6 +63,21 @@ public class PalletRepository {
.last("LIMIT " + limit));
}
/**
* 生成平台作用域内下一个编码。编码序列跨仓库共用,且包含逻辑删除记录,确保编码永不复用。
*/
public String nextCode(long tenantId) {
List<String> existing = mapper.selectAllCodes(tenantId);
int maxSequence = (existing == null ? List.<String>of() : existing)
.stream()
.map(PALLET_CODE::matcher)
.filter(Matcher::matches)
.mapToInt(matcher -> Integer.parseInt(matcher.group(1)))
.max()
.orElse(0);
return "TP-%03d".formatted(maxSequence + 1);
}
public void insert(PalletEntity entity) {
mapper.insert(entity);
}
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.warehouse.dto.WarehouseRequests;
import com.baiyue.warehouse.dto.WarehouseSummaryResponse;
import com.baiyue.warehouse.entity.ServiceCapabilityEntity;
import com.baiyue.warehouse.entity.WarehouseAddressEntity;
import com.baiyue.warehouse.entity.WarehouseEntity;
@@ -14,7 +15,10 @@ import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 仓库聚合持久化仓储,组合仓库、地址和服务能力 Mapper,并强制按租户范围执行查询与写入。
@@ -42,6 +46,12 @@ public class WarehouseRepository {
.eq(WarehouseEntity::getId, id));
}
public WarehouseEntity findByCode(long tenantId, String code) {
return warehouses.selectOne(new LambdaQueryWrapper<WarehouseEntity>()
.eq(WarehouseEntity::getTenantId, tenantId)
.eq(WarehouseEntity::getWarehouseCode, code));
}
public WarehouseEntity findActiveOwned(long tenantId, long id) {
return warehouses.selectOne(new LambdaQueryWrapper<WarehouseEntity>()
.eq(WarehouseEntity::getTenantId, tenantId)
@@ -57,11 +67,21 @@ public class WarehouseRepository {
}
public PageResult<WarehouseEntity> page(Long tenantId, WarehouseRequests.QueryRequest query) {
String keyword = StringUtils.hasText(query.keyword()) ? query.keyword().trim() : null;
List<Long> addressMatches = StringUtils.hasText(keyword) ? addressWarehouseIdsByKeyword(tenantId, keyword) : List.of();
LambdaQueryWrapper<WarehouseEntity> wrapper = new LambdaQueryWrapper<WarehouseEntity>()
.eq(tenantId != null, WarehouseEntity::getTenantId, tenantId)
.eq(StringUtils.hasText(query.status()), WarehouseEntity::getStatus, query.status())
.eq(StringUtils.hasText(query.warehouseType()), WarehouseEntity::getWarehouseType, query.warehouseType())
.eq(StringUtils.hasText(query.countryCode()), WarehouseEntity::getCountryCode, query.countryCode())
.and(StringUtils.hasText(keyword), condition -> {
condition.like(WarehouseEntity::getWarehouseCode, keyword)
.or()
.like(WarehouseEntity::getWarehouseName, keyword);
if (!addressMatches.isEmpty()) {
condition.or().in(WarehouseEntity::getId, addressMatches);
}
})
.orderByDesc(WarehouseEntity::getCreatedAt);
if (StringUtils.hasText(query.capabilityCode())) {
@@ -82,8 +102,19 @@ public class WarehouseRepository {
return PageResult.from(warehouses.selectPage(new Page<>(query.page(), query.size()), wrapper));
}
public PageResult<WarehouseSummaryResponse> pageSummaries(Long tenantId, WarehouseRequests.QueryRequest query) {
PageResult<WarehouseEntity> page = page(tenantId, query);
Map<Long, WarehouseAddressEntity> primaryAddresses = primaryAddresses(tenantId,
page.records().stream().map(WarehouseEntity::getId).toList());
return new PageResult<>(
page.records().stream()
.map(warehouse -> WarehouseSummaryResponse.of(warehouse, primaryAddresses.get(warehouse.getId())))
.toList(),
page.page(), page.size(), page.total(), page.pages());
}
public PageResult<WarehouseEntity> activePage(long tenantId, WarehouseRequests.QueryRequest query) {
return page(tenantId, new WarehouseRequests.QueryRequest(query.page(), query.size(),
return page(tenantId, new WarehouseRequests.QueryRequest(query.page(), query.size(), query.keyword(),
query.countryCode(), query.capabilityCode(), query.warehouseType(), "ACTIVE"));
}
@@ -104,6 +135,11 @@ public class WarehouseRepository {
capabilities.physicallyDeleteByWarehouse(tenantId, warehouseId);
}
public void replaceAddress(long tenantId, long warehouseId, WarehouseAddressEntity address) {
addresses.physicallyDeleteByWarehouse(tenantId, warehouseId);
addresses.insert(address);
}
public void insertAddress(WarehouseAddressEntity address) {
addresses.insert(address);
}
@@ -111,4 +147,40 @@ public class WarehouseRepository {
public void insertCapability(ServiceCapabilityEntity capability) {
capabilities.insert(capability);
}
private List<Long> addressWarehouseIdsByKeyword(Long tenantId, String keyword) {
return addresses.selectList(new LambdaQueryWrapper<WarehouseAddressEntity>()
.select(WarehouseAddressEntity::getWarehouseId)
.eq(tenantId != null, WarehouseAddressEntity::getTenantId, tenantId)
.and(condition -> condition
.like(WarehouseAddressEntity::getStateProvince, keyword)
.or()
.like(WarehouseAddressEntity::getCity, keyword)
.or()
.like(WarehouseAddressEntity::getAddressLine1, keyword)
.or()
.like(WarehouseAddressEntity::getAddressLine2, keyword))
.orderByAsc(WarehouseAddressEntity::getWarehouseId))
.stream()
.map(WarehouseAddressEntity::getWarehouseId)
.distinct()
.toList();
}
private Map<Long, WarehouseAddressEntity> primaryAddresses(Long tenantId, Collection<Long> warehouseIds) {
if (warehouseIds == null || warehouseIds.isEmpty()) {
return Map.of();
}
List<WarehouseAddressEntity> records = addresses.selectList(new LambdaQueryWrapper<WarehouseAddressEntity>()
.eq(tenantId != null, WarehouseAddressEntity::getTenantId, tenantId)
.in(WarehouseAddressEntity::getWarehouseId, warehouseIds)
.orderByAsc(WarehouseAddressEntity::getWarehouseId)
.orderByDesc(WarehouseAddressEntity::getAddressType)
.orderByAsc(WarehouseAddressEntity::getId));
Map<Long, WarehouseAddressEntity> grouped = new LinkedHashMap<>();
for (WarehouseAddressEntity address : records) {
grouped.putIfAbsent(address.getWarehouseId(), address);
}
return grouped;
}
}
@@ -16,6 +16,8 @@ import org.springframework.transaction.annotation.Transactional;
/** 托盘主数据应用服务。 */
@Service
public class PalletApplicationService {
private static final int CODE_INSERT_ATTEMPTS = 5;
@Resource
private PalletRepository repository;
@Resource
@@ -34,19 +36,23 @@ public class PalletApplicationService {
@Transactional
@BusinessAudit(event = "PALLET_CREATE", resourceType = "PALLET", description = "新增托盘", captureBefore = false)
public Long create(long tenantId, PalletRequests.SaveRequest request) {
requireWarehouse(tenantId, request.warehouseId());
ensureCodeUnique(tenantId, request.warehouseId(), request.palletCode(), null);
requireWarehouse(tenantId, request.warehouseId(), true);
PalletEntity entity = new PalletEntity();
entity.setTenantId(tenantId);
entity.setWarehouseId(request.warehouseId());
copy(entity, request);
entity.setStatus("AVAILABLE");
try {
repository.insert(entity);
} catch (DataIntegrityViolationException exception) {
throw new BizException(ResultCode.CONFLICT, "托盘编码已存在");
String code = repository.nextCode(tenantId);
for (int attempt = 0; attempt < CODE_INSERT_ATTEMPTS; attempt++) {
entity.setPalletCode(code);
try {
repository.insert(entity);
return entity.getId();
} catch (DataIntegrityViolationException exception) {
code = incrementCode(code);
}
}
return entity.getId();
throw new BizException(ResultCode.CONFLICT, "托盘编码生成冲突,请重试");
}
@Transactional
@@ -55,8 +61,8 @@ public class PalletApplicationService {
if (warehouseId != request.warehouseId()) {
throw new BizException(ResultCode.FORBIDDEN, "请求仓库与托盘所属仓库不一致");
}
requireWarehouse(tenantId, warehouseId, false);
PalletEntity entity = owned(tenantId, warehouseId, id);
ensureCodeUnique(tenantId, warehouseId, request.palletCode(), id);
copy(entity, request);
try {
if (repository.save(entity) != 1) {
@@ -84,28 +90,36 @@ public class PalletApplicationService {
return entity;
}
private void requireWarehouse(long tenantId, long warehouseId) {
if (warehouseRepository.findOwned(tenantId, warehouseId) == null) {
private void requireWarehouse(long tenantId, long warehouseId, boolean requireActive) {
var warehouse = warehouseRepository.findOwned(tenantId, warehouseId);
if (warehouse == null) {
throw new BizException(ResultCode.FORBIDDEN, "仓库不存在或不属于目标租户");
}
}
private void ensureCodeUnique(long tenantId, long warehouseId, String code, Long excludedId) {
if (repository.existsCode(tenantId, warehouseId, code, excludedId)) {
throw new BizException(ResultCode.CONFLICT, "托盘编码已存在");
if (!"DOMESTIC".equalsIgnoreCase(warehouse.getWarehouseType())) {
throw new BizException(ResultCode.FORBIDDEN, "托盘仅支持国内仓");
}
if (requireActive && !"ACTIVE".equalsIgnoreCase(warehouse.getStatus())) {
throw new BizException(ResultCode.FORBIDDEN, "新增托盘仅支持启用中的国内仓");
}
}
private static void copy(PalletEntity entity, PalletRequests.SaveRequest request) {
entity.setPalletCode(request.palletCode());
entity.setPalletType(request.palletType());
entity.setCapacityCbm(request.capacityCbm());
entity.setWeightKg(request.weightKg());
entity.setColor(request.color());
entity.setRemark(request.remark());
}
private static String incrementCode(String code) {
int sequence = Integer.parseInt(code.substring("TP-".length())) + 1;
return "TP-%03d".formatted(sequence);
}
private static PalletRequests.View view(PalletEntity entity) {
return new PalletRequests.View(entity.getId(), entity.getTenantId(), entity.getWarehouseId(),
entity.getPalletCode(), entity.getPalletType(), entity.getCapacityCbm(), entity.getRemark(),
entity.getPalletCode(), entity.getPalletType(), entity.getCapacityCbm(), entity.getWeightKg(),
entity.getColor(), entity.getRemark(),
entity.getCreatedAt(), entity.getUpdatedAt());
}
}

Some files were not shown because too many files have changed in this diff Show More