Compare commits

..
10 Commits
Author SHA1 Message Date
liuyihe 913eca7b7d 装柜 2026-08-06 17:48:50 +08:00
liuyihe c68eedd3f3 Merge branch 'test' into test-02huogui
# Conflicts:
#	deploy/sql/头程数据库统一增量-MySQL8.sql
2026-08-06 15:54:25 +08:00
liuyihe c3a978b497 提交 2026-08-06 15:48:21 +08:00
ouyangxq 9c4bae9f5c 优化 2026-08-06 15:43:35 +08:00
ouyangxq 3ecb8d7613 优化 2026-08-06 15:36:23 +08:00
ouyangxq 5f8b1f64da 优化 2026-08-06 15:32:11 +08:00
ouyangxq f3cfbdf59b 优化 2026-08-06 14:55:24 +08:00
ouyangxq dca3a48284 优化 2026-08-06 13:56:20 +08:00
ouyangxq e6604ed680 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-06 11:49:53 +08:00
ouyangxq 7dd92f1c84 优化 2026-08-06 11:49:51 +08:00
198 changed files with 16152 additions and 218 deletions
+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);
}
}
+1
View File
@@ -21,6 +21,7 @@
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency>
</dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>
@@ -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) {}
}
@@ -19,6 +19,14 @@ public interface OrderCargoClient {
record CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer originalPackageCount, BigDecimal originalGrossWeightKg,
BigDecimal originalVolumeCbm) {
BigDecimal originalVolumeCbm, String skuNo, String cargoNameEn,
Integer unitsPerPackage, Integer totalQuantity, BigDecimal netWeightKg,
String cargoRemark) {
public CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer originalPackageCount, BigDecimal originalGrossWeightKg,
BigDecimal originalVolumeCbm) {
this(cargoId, orderId, itemNo, cargoName, originalPackageCount,
originalGrossWeightKg, originalVolumeCbm, null, null, null, null, null, null);
}
}
}
@@ -25,6 +25,11 @@ public interface WarehousePalletLocationClient {
ApiResponse<List<ActiveResource>> release(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody ReleaseRequest request);
@PostMapping("/release-selected")
ApiResponse<List<SelectiveReleaseResult>> releaseSelected(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody SelectiveReleaseRequest request);
record ReserveRequest(long tenantId, long warehouseId, String sourceService, long businessId,
String inboundCode, String idempotencyKey, long operatorId,
BigDecimal totalVolumeCbm, BigDecimal palletCapacityCbm, String palletType,
@@ -33,10 +38,16 @@ public interface WarehousePalletLocationClient {
long operatorId, BigDecimal totalVolumeCbm, BigDecimal palletCapacityCbm) {}
record ReleaseRequest(long tenantId, String sourceService, long businessId, long operatorId,
String reason) {}
record SelectiveReleaseRequest(long tenantId, String sourceService, long businessId,
long operatorId, String reason, int totalPackageCount,
List<Long> locationIds, Integer releaseLocationCount) {}
record AllocationResult(long reservationId, String reservationNo, int allocationNo,
long palletId, String palletCode, long locationId, String locationCode,
BigDecimal allocatedVolumeCbm, String state) {}
record BatchResult(int palletCount, List<AllocationResult> allocations) {}
record ActiveResource(long reservationId, long warehouseId, long palletId, String palletCode,
long locationId, String locationCode, String state) {}
record SelectiveReleaseResult(long reservationId, long occupancyId, int allocationNo,
long palletId, long locationId, String locationCode,
int releasedPackageCount) {}
}
@@ -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, "内部服务凭证无效");
@@ -0,0 +1,63 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.Query;
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.service.ContainerService;
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 jakarta.validation.constraints.Positive;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
@Tag(name = "货柜基础资料") @Validated @RestController
@RequestMapping("/api/platform/v1/containers")
public class PlatformContainerController {
@Resource private ContainerService service;
@GetMapping @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
public ApiResponse<PageResult<ContainerBookingEntity>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid Query query) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.page(query));
}
@GetMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:view')")
public ApiResponse<ContainerBookingEntity> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(id));
}
@PostMapping @PreAuthorize("hasAuthority('platform:firstleg:container:create')")
@BusinessAudit(event="CONTAINER_CREATE", resourceType="CONTAINER", description="新增货柜基础资料", captureBefore=false)
public ApiResponse<ContainerBookingEntity> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody SaveRequest request) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.create(user.userId(), user.username(), request));
}
@PutMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
@BusinessAudit(event="CONTAINER_UPDATE", resourceType="CONTAINER", resourceId="#id", description="完整编辑货柜基础资料")
@Operation(summary="完整编辑货柜", description="仅无装柜计划或计划为EDITING时允许")
public ApiResponse<ContainerBookingEntity> updateDraft(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id,
@Valid @RequestBody FullUpdateRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.updateDraft(id, user.userId(), user.username(), request));
}
@PatchMapping("/{id}/transport") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
@BusinessAudit(event="CONTAINER_TRANSPORT_UPDATE", resourceType="CONTAINER", resourceId="#id",
description="修改货柜集装箱号和航次")
@Operation(summary="修改集装箱号和航次", description="LOADING仅开放此白名单;COMPLETED全部只读")
public ApiResponse<ContainerBookingEntity> updateTransport(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id,
@Valid @RequestBody TransportUpdateRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.updateTransport(id, user.userId(), user.username(), request));
}
}
@@ -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",
@@ -0,0 +1,88 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedDetail;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedSummary;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary;
import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.LoadingListService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Positive;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
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.RestController;
/** 基于同一装柜计划聚合提供平台预装清单和已确认装柜清单。 */
@Tag(name = "平台预装与装柜清单", description = "预装单向推进和已确认装柜清单只读查询")
@Validated
@RestController
@RequestMapping("/api/platform/v1/first-leg")
public class PlatformLoadingListController {
@Resource
private LoadingListService service;
@Operation(summary = "分页查询预装清单")
@GetMapping("/loading-plans")
@PreAuthorize("hasAuthority('platform:firstleg:preload:list')")
public ApiResponse<PageResult<PreloadSummary>> preloadPage(
@AuthenticationPrincipal AuthenticatedUser user, @Valid PreloadQuery query) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.preloadPage(query));
}
@Operation(summary = "查询装柜详情", description = "装柜中可维护未拣货明细;完成装柜后永久只读。")
@GetMapping("/loading-plans/{planId}")
@PreAuthorize("hasAuthority('platform:firstleg:preload:view')")
public ApiResponse<PreloadDetail> preloadDetail(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.preloadDetail(planId));
}
@Operation(summary = "修改装柜运输字段", description = "仅装柜中可修改集装箱号与航次。")
@PatchMapping("/loading-plans/{planId}/transport")
@PreAuthorize("hasAuthority('platform:firstleg:preload:edit')")
public ApiResponse<PreloadDetail> updateTransport(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long planId,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody TransportUpdateRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.updateTransport(
planId, user.userId(), user.username(), idempotencyKey, request));
}
@Operation(summary = "分页查询完成装柜清单", description = "只读取 COMPLETED 状态的装柜计划。")
@GetMapping("/loading-lists")
@PreAuthorize("hasAuthority('platform:firstleg:loading-list:list')")
public ApiResponse<PageResult<ConfirmedSummary>> confirmedPage(
@AuthenticationPrincipal AuthenticatedUser user, @Valid ConfirmedQuery query) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.confirmedPage(query));
}
@Operation(summary = "查询完成装柜清单明细", description = "已完成主单和明细全部只读。")
@GetMapping("/loading-lists/{planId}")
@PreAuthorize("hasAuthority('platform:firstleg:loading-list:view')")
public ApiResponse<ConfirmedDetail> confirmedDetail(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.confirmedDetail(planId));
}
}
@@ -0,0 +1,48 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.Task;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.LoadingPickService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Positive;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
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.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** PDA 按集装箱号执行装柜拣货。 */
@Validated
@RestController
@RequestMapping("/api/platform/v1/first-leg/pda-loading")
public class PlatformLoadingPdaController {
@Resource
private LoadingPickService service;
@GetMapping("/tasks")
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
public ApiResponse<Task> task(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String containerNo) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.task(containerNo));
}
@PostMapping("/tasks/{taskId}/items/{itemId}/pick")
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
public ApiResponse<Task> pick(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@PathVariable @Positive long itemId,
@Valid @RequestBody PickRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.pick(taskId, itemId, request, user.userId(), user.username()));
}
}
@@ -0,0 +1,29 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.SmartLoadingDtos.*; import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.service.SmartLoadingService;
import jakarta.validation.Valid; import jakarta.validation.constraints.Positive; import jakarta.annotation.Resource; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.util.List;
@Validated @RestController @RequestMapping("/api/platform/v1/first-leg/smart-loading")
public class PlatformSmartLoadingController {
@Resource private SmartLoadingService service;
@GetMapping("/candidates") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
public ApiResponse<List<Candidate>> candidates(@AuthenticationPrincipal AuthenticatedUser u,@RequestParam @Positive long accountId){FirstlegScopeGuard.platform(u,accountId);return ApiResponse.success(service.candidates(accountId));}
@GetMapping("/plans/by-booking/{bookingId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
public ApiResponse<Plan> draftByBooking(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long bookingId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.draftByBooking(bookingId));}
@GetMapping("/plans/{planId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
public ApiResponse<Plan> detail(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.detail(planId));}
@PostMapping("/plans") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:create')")
public ApiResponse<Plan> create(@AuthenticationPrincipal AuthenticatedUser u,@Valid @RequestBody CreateRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.create(r.bookingId(),r.remark(),u.userId(),u.username()));}
@PostMapping("/plans/{planId}/assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
public ApiResponse<Plan> assign(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignRequest r){FirstlegScopeGuard.platform(u,r.accountId());return ApiResponse.success(service.assign(planId,r,u.userId(),u.username()));}
@PostMapping("/plans/{planId}/recommend") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
public ApiResponse<Plan> recommend(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody RecommendRequest r){FirstlegScopeGuard.platform(u,r.accountId());return ApiResponse.success(service.recommend(planId,r,u.userId(),u.username()));}
@PatchMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
public ApiResponse<Plan> adjust(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody AdjustRequest r){FirstlegScopeGuard.platform(u,r.accountId());return ApiResponse.success(service.adjust(planId,itemId,r,u.userId(),u.username()));}
@DeleteMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:delete')")
public ApiResponse<Plan> delete(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody DeleteRequest r){FirstlegScopeGuard.platform(u,r.accountId());return ApiResponse.success(service.delete(planId,itemId,r,u.userId(),u.username()));}
@PostMapping("/plans/{planId}/push-preload") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:push')")
public ApiResponse<Plan> push(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@RequestHeader("Idempotency-Key") String key,@Valid @RequestBody PushRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.pushPreload(planId,key,r,u.userId(),u.username()));}
}
@@ -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,18 @@
package com.baiyue.firstleg.domain;
/** 智能装柜状态,只允许沿此方向推进。 */
public enum SmartLoadingStatus {
EDITING,
LOADING,
COMPLETED;
/** 装柜完成前可维护集装箱号和航次。 */
public boolean allowsTransportUpdate() { return this != COMPLETED; }
/** 编辑中和装柜中均允许维护尚未拣货的明细。 */
public boolean mutable() { return this != COMPLETED; }
public boolean canAdvanceTo(SmartLoadingStatus next) {
return next != null && next.ordinal() == ordinal() + 1;
}
}
@@ -0,0 +1,89 @@
package com.baiyue.firstleg.dto;
import com.baiyue.firstleg.domain.SmartLoadingStatus;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
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;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 平台级货柜基础资料请求模型。 */
public final class ContainerDtos {
private ContainerDtos() {}
@Schema(name = "ContainerQuery", description = "货柜分页筛选条件")
public record Query(
@Size(max = 64) String containerNo,
@Size(max = 64) String cabinetNo,
@Size(max = 128) String billOfLadingNo,
@Size(max = 128) String keyword,
@Positive Long warehouseId,
@Size(max = 32) String containerType,
@Positive Long shippingCompanyId,
@Size(max = 32) String departurePortCode,
@Size(max = 32) String destinationPortCode,
@Size(max = 64) String destinationWarehouseCode,
SmartLoadingStatus status,
LocalDateTime estimatedSailingFrom,
LocalDateTime estimatedSailingTo,
@Min(1) long page,
@Min(1) @Max(200) long size
) {
public Query {
if (page == 0) page = 1;
if (size == 0) size = 20;
}
}
@Schema(name = "ContainerSaveRequest", description = "货柜基础资料;智能装柜草稿阶段可完整编辑")
public record SaveRequest(
@Size(max = 64) String containerNo,
@NotBlank @Size(max = 64) String cabinetNo,
@NotNull @Positive Long warehouseId,
@NotBlank @Size(max = 128) String warehouseName,
@Size(max = 128) String billOfLadingNo,
@Size(max = 128) String vesselName,
@Size(max = 64) String voyageNo,
@NotBlank @Size(max = 32) String containerType,
@Positive Long shippingCompanyId,
@Size(max = 128) String shippingCompanyName,
@Size(max = 32) String departurePortCode,
@Size(max = 128) String departurePortName,
@Size(max = 32) String destinationPortCode,
@Size(max = 128) String destinationPortName,
@Size(max = 64) String destinationWarehouseCode,
@Size(max = 128) String destinationWarehouseName,
@DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm,
@DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg,
@Size(max = 128) String terminalTruckingCompany,
LocalDateTime loadingAt,
LocalDateTime estimatedSailingAt,
LocalDateTime actualSailingAt,
LocalDateTime estimatedPortArrivalAt,
LocalDateTime actualPortArrivalAt,
LocalDateTime estimatedWarehouseArrivalAt,
LocalDateTime actualWarehouseArrivalAt
) {}
@Schema(name = "ContainerFullUpdateRequest", description = "智能装柜 EDITING 阶段完整编辑")
public record FullUpdateRequest(
@NotNull @PositiveOrZero Integer version,
@Valid @NotNull SaveRequest data
) {}
@Schema(name = "ContainerTransportUpdateRequest",
description = "LOADING 阶段只允许修改集装箱号和航次")
public record TransportUpdateRequest(
@Size(max = 64) String containerNo,
@Size(max = 64) String voyageNo,
@NotNull @PositiveOrZero Integer version
) {}
}
@@ -0,0 +1,169 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/** 同一装柜计划聚合的预装清单和已确认装柜清单接口模型。 */
public final class LoadingListDtos {
private LoadingListDtos() {
}
public record PreloadQuery(
@Size(max = 128) String keyword,
String status,
@Min(1) Long accountId,
@Min(1) Long warehouseId,
LocalDateTime createdFrom,
LocalDateTime createdTo,
@Min(1) long page,
@Min(1) @Max(200) long size
) {
public PreloadQuery {
page = page == 0 ? 1 : page;
size = size == 0 ? 20 : size;
}
}
public record ConfirmedQuery(
@Size(max = 128) String keyword,
@Min(1) Long accountId,
@Min(1) Long warehouseId,
LocalDateTime createdFrom,
LocalDateTime createdTo,
@Min(1) long page,
@Min(1) @Max(200) long size
) {
public ConfirmedQuery {
page = page == 0 ? 1 : page;
size = size == 0 ? 20 : size;
}
}
public record TransportUpdateRequest(
@Size(max = 64) String containerNo,
@Size(max = 64) String voyageNo,
@NotNull @Min(0) Integer expectedVersion,
@Size(max = 500) String operationReason
) {
}
public record AdvanceRequest(
@NotNull @Min(0) Integer expectedVersion,
@Size(max = 500) String operationReason
) {
}
public record Customer(Long accountId, String customerName) {
}
public record PreloadSummary(
Long planId,
String preloadNo,
String containerNo,
String internalContainerNo,
List<Customer> customers,
Long warehouseId,
String warehouseName,
String vesselName,
String voyageNo,
String containerType,
String status,
long skuCount,
long totalPackageCount,
BigDecimal totalVolumeCbm,
BigDecimal totalGrossWeightKg,
LocalDateTime createdAt,
Integer version
) {
public PreloadSummary {
customers = customers == null ? List.of() : List.copyOf(customers);
}
}
public record ConfirmedSummary(
Long planId,
String loadingListNo,
Long warehouseId,
String warehouseName,
String containerNo,
String internalContainerNo,
String status,
long totalPackageCount,
BigDecimal totalGrossWeightKg,
BigDecimal totalNetWeightKg,
BigDecimal totalVolumeCbm,
long totalQuantity,
String remark,
Long createdBy,
String createdByName,
LocalDateTime createdAt,
Integer version
) {
}
public record Item(
Long itemId,
Long accountId,
String customerName,
Long inboundId,
Long inboundDetailId,
String inboundCode,
String skuCode,
String destinationWarehouse,
String chineseName,
String englishName,
String goodsImageUrl,
int packageCount,
int palletCount,
BigDecimal grossWeightKg,
BigDecimal netWeightKg,
BigDecimal volumeCbm,
BigDecimal dimensionalWeightKg,
long productQuantity,
int unitsPerPackage,
String fbaTrackingNo,
String shipmentNo,
String shippingMark,
String internalRemark,
String customerRemark,
String warehouseRemark,
int loadSequence,
LocalDateTime frozenAt
) {
}
public record History(
Long historyId,
Long itemId,
String actionType,
String fromStatus,
String toStatus,
String beforeSnapshot,
String afterSnapshot,
Long operatorId,
String operatorName,
String operationReason,
LocalDateTime occurredAt
) {
}
public record PreloadDetail(PreloadSummary summary, List<Item> items, List<History> histories) {
public PreloadDetail {
items = items == null ? List.of() : List.copyOf(items);
histories = histories == null ? List.of() : List.copyOf(histories);
}
}
public record ConfirmedDetail(ConfirmedSummary summary, List<Item> items, List<History> histories) {
public ConfirmedDetail {
items = items == null ? List.of() : List.copyOf(items);
histories = histories == null ? List.of() : List.copyOf(histories);
}
}
}
@@ -0,0 +1,32 @@
package com.baiyue.firstleg.dto;
import com.fasterxml.jackson.annotation.JsonAlias;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;
/** PDA 装柜拣货接口模型。 */
public final class LoadingPickDtos {
private LoadingPickDtos() {
}
public record PickRequest(
@NotNull @Min(1) Integer pickedPackageCount,
@JsonAlias("reservationIds") List<@Min(1) Long> locationIds,
@NotNull @Min(0) Integer version,
@NotBlank @Size(max = 128) String operationId
) {
}
public record PickItem(Long id, Long loadingPlanItemId, Long inboundId, Long inboundDetailId,
Integer plannedPackageCount, Integer pickedPackageCount,
String itemStatus, Integer version) {
}
public record Task(Long id, Long loadingPlanId, String containerNo, String taskStatus,
Integer version, List<PickItem> items) {
}
}
@@ -0,0 +1,24 @@
package com.baiyue.firstleg.dto;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.util.List;
public final class SmartLoadingDtos { private SmartLoadingDtos(){}
public record Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode,
Long warehouseId, Long destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsName, Integer availablePackageCount,
BigDecimal unitWeightKg, BigDecimal unitVolumeCbm) {
public Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode,
Long warehouseId, String destinationWarehouseCode, String customerName, String goodsName, Integer availablePackageCount,
BigDecimal unitWeightKg, BigDecimal unitVolumeCbm) {
this(accountId,inboundId,inboundDetailId,orderId,orderNo,inboundCode,warehouseId,null,destinationWarehouseCode,null,customerName,goodsName,availablePackageCount,unitWeightKg,unitVolumeCbm);
}
}
public record CreateRequest(@NotNull @Positive Long bookingId, @Size(max=500) String remark) {}
public record AssignRequest(@NotNull @Positive Long accountId,@NotNull @Positive Long inboundId,@NotNull @Positive Long inboundDetailId,
@NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedVersion,@NotBlank @Size(max=128) String operationId) {}
public record RecommendRequest(@NotNull @Positive Long accountId,@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max=128) String operationId) {}
public record AdjustRequest(@NotNull @Positive Long accountId,@NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedPlanVersion,
@NotNull @Min(0) Integer expectedItemVersion,@NotBlank @Size(max=128) String operationId) {}
public record DeleteRequest(@NotNull @Positive Long accountId,@NotNull @Min(0) Integer expectedPlanVersion,
@NotNull @Min(0) Integer expectedItemVersion,@NotBlank @Size(max=128) String operationId) {}
public record PushRequest(@NotNull @Min(0) Integer expectedVersion) {}
public record Plan(Long id,String planNo,String status,Long bookingId,String containerNo,String voyageNo,Integer version,List<LoadingPlanItemEntity> items) {}
}
@@ -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,56 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 平台级货柜预订资料;预装状态由关联装柜计划聚合。 */
@Getter @Setter
@TableName("fl_container_booking")
public class ContainerBookingEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String bookingNo;
private String containerNo;
private String cabinetNo;
private Long warehouseId;
private String warehouseName;
private String billOfLadingNo;
private String vesselName;
private String voyageNo;
private String containerType;
private Long shippingCompanyId;
private String shippingCompanyName;
private String departurePortCode;
private String departurePortName;
private String destinationPortCode;
private String destinationPortName;
private String destinationWarehouseCode;
private String destinationWarehouseName;
private BigDecimal capacityCbm;
private BigDecimal maxWeightKg;
private String terminalTruckingCompany;
private LocalDateTime loadingAt;
private LocalDateTime estimatedSailingAt;
private LocalDateTime actualSailingAt;
private LocalDateTime estimatedPortArrivalAt;
private LocalDateTime actualPortArrivalAt;
private LocalDateTime estimatedWarehouseArrivalAt;
private LocalDateTime actualWarehouseArrivalAt;
private Long createdBy;
private String createdByName;
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
private Long updatedBy;
private String updatedByName;
@TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updatedAt;
@Version @TableField(fill = FieldFill.INSERT) private Integer version;
@TableField(exist = false) private Long planId;
@TableField(exist = false) private String planStatus;
}
@@ -26,6 +26,7 @@ public class InboundPalletAllocationEntity {
private BigDecimal palletCapacityCbm;
private BigDecimal allocatedVolumeCbm;
private Integer allocatedPackageCount;
private Integer pickedPackageCount;
private Long locationId;
private String locationCode;
private String reservationNo;
@@ -0,0 +1,23 @@
package com.baiyue.firstleg.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;
/** PDA 拣货操作幂等历史。 */
@Getter
@Setter
@TableName("fl_loading_pick_history")
public class LoadingPickHistoryEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long pickTaskId;
private Long pickItemId;
private String operationId;
private String requestHash;
private Integer pickedPackageCount;
private Long operatorId;
private String operatorName;
}
@@ -0,0 +1,30 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** PDA 装柜拣货明细。 */
@Getter
@Setter
@TableName("fl_loading_pick_item")
public class LoadingPickItemEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long pickTaskId;
private Long loadingPlanItemId;
private Long inboundId;
private Long inboundDetailId;
private Integer plannedPackageCount;
private Integer pickedPackageCount;
private String itemStatus;
@Version
private Integer version;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,30 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** PDA 装柜拣货任务。 */
@Getter
@Setter
@TableName("fl_loading_pick_task")
public class LoadingPickTaskEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long loadingPlanId;
private String containerNo;
private String taskStatus;
private Long startedBy;
private String startedByName;
private LocalDateTime startedAt;
private Long completedBy;
private String completedByName;
private LocalDateTime completedAt;
@Version
private Integer version;
}
@@ -0,0 +1,10 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.*; import lombok.Getter; import lombok.Setter; import java.time.LocalDateTime;
@Getter @Setter @TableName("fl_loading_plan")
public class LoadingPlanEntity {
@TableId(type=IdType.ASSIGN_ID) private Long id; private String planNo; private Long containerBookingId; private String planStatus;
private String pushIdempotencyKey; private Long preloadPushedBy; private String preloadPushedByName; private LocalDateTime preloadPushedAt;
private Long preloadCompletedBy; private String preloadCompletedByName; private LocalDateTime preloadCompletedAt; private Long confirmedBy;
private String confirmedByName; private LocalDateTime confirmedAt; private String remark; private Long createdBy; private String createdByName;
private LocalDateTime createdAt; private Long updatedBy; private String updatedByName; private LocalDateTime updatedAt; @Version private Integer version;
}
@@ -0,0 +1,8 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.*; import lombok.Getter; import lombok.Setter; import java.time.LocalDateTime;
@Getter @Setter @TableName("fl_loading_plan_history")
public class LoadingPlanHistoryEntity {
@TableId(type=IdType.ASSIGN_ID) private Long id; private Long loadingPlanId; private Long loadingPlanItemId; private String operationId;
private String actionType; private String fromStatus; private String toStatus; private String beforeSnapshot; private String afterSnapshot;
private Long operatorId; private String operatorName; private String operationReason; private LocalDateTime createdAt;
}
@@ -0,0 +1,15 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.*; import lombok.Getter; import lombok.Setter; import java.math.BigDecimal; import java.time.LocalDateTime;
@Getter @Setter @TableName("fl_loading_plan_item")
public class LoadingPlanItemEntity {
@TableId(type=IdType.ASSIGN_ID) private Long id; private Long loadingPlanId; private Long accountId; private String customerName;
private Long inboundId; private Long inboundDetailId; private Long orderId; private String orderNo; private String inboundCode; private Long destinationWarehouseId;
private String destinationWarehouseCode; private String destinationWarehouseName; private String skuCode; private String productNameZh; private String productNameEn;
private Long productImageFileId; private String productImageUrl; private Integer packageCount; private Integer productQuantity; private Integer unitsPerCarton;
private Integer palletCount; private BigDecimal grossWeightKg; private BigDecimal netWeightKg; private BigDecimal dimensionalWeightKg; private BigDecimal volumeCbm;
private String fbaTrackingNo; private String shipmentNo; private String shippingMark; private Integer loadSequence; private String positionSnapshot;
private String internalRemark; private String customerRemark; private String warehouseRemark; private LocalDateTime frozenAt; private Long frozenBy;
private Long removedBy; private String removedByName; private LocalDateTime removedAt; @TableLogic private Boolean deleted;
@TableField(exist=false) private Long activeInboundDetailId; private Long createdBy; private String createdByName; private LocalDateTime createdAt;
private Long updatedBy; private String updatedByName; private LocalDateTime updatedAt; @Version private Integer version;
}
@@ -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, "数据已被其他人修改,请刷新后重试"); }
}
@@ -0,0 +1,82 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.firstleg.dto.ContainerDtos.Query;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
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 ContainerBookingMapper extends BaseMapper<ContainerBookingEntity> {
@Select("""
<script>
SELECT b.*, p.id AS plan_id, COALESCE(p.plan_status, 'EDITING') AS plan_status
FROM fl_container_booking b LEFT JOIN fl_loading_plan p ON p.container_booking_id = b.id
WHERE (#{query.containerNo} IS NULL OR b.container_no = #{query.containerNo})
AND (#{query.cabinetNo} IS NULL OR b.cabinet_no = #{query.cabinetNo})
AND (#{query.billOfLadingNo} IS NULL OR b.bill_of_lading_no = #{query.billOfLadingNo})
AND (#{query.warehouseId} IS NULL OR b.warehouse_id = #{query.warehouseId})
AND (#{query.containerType} IS NULL OR b.container_type = #{query.containerType})
AND (#{query.shippingCompanyId} IS NULL OR b.shipping_company_id = #{query.shippingCompanyId})
AND (#{query.departurePortCode} IS NULL OR b.departure_port_code = #{query.departurePortCode})
AND (#{query.destinationPortCode} IS NULL OR b.destination_port_code = #{query.destinationPortCode})
AND (#{query.destinationWarehouseCode} IS NULL OR b.destination_warehouse_code = #{query.destinationWarehouseCode})
AND (#{query.status} IS NULL OR COALESCE(p.plan_status, 'EDITING') = #{query.status})
AND (#{query.estimatedSailingFrom} IS NULL OR b.estimated_sailing_at &gt;= #{query.estimatedSailingFrom})
AND (#{query.estimatedSailingTo} IS NULL OR b.estimated_sailing_at &lt;= #{query.estimatedSailingTo})
AND (#{query.keyword} IS NULL OR b.container_no LIKE CONCAT('%', #{query.keyword}, '%')
OR b.cabinet_no LIKE CONCAT('%', #{query.keyword}, '%')
OR b.bill_of_lading_no LIKE CONCAT('%', #{query.keyword}, '%')
OR b.vessel_name LIKE CONCAT('%', #{query.keyword}, '%')
OR b.voyage_no LIKE CONCAT('%', #{query.keyword}, '%'))
ORDER BY b.updated_at DESC, b.id DESC
</script>
""")
Page<ContainerBookingEntity> selectBookingPage(Page<ContainerBookingEntity> page, @Param("query") Query query);
@Select("""
SELECT b.*, p.id AS plan_id, COALESCE(p.plan_status, 'EDITING') AS plan_status
FROM fl_container_booking b LEFT JOIN fl_loading_plan p ON p.container_booking_id = b.id
WHERE b.id = #{id}
""")
ContainerBookingEntity selectBooking(@Param("id") long id);
@Update("""
UPDATE fl_container_booking SET
container_no=#{value.containerNo}, cabinet_no=#{value.cabinetNo},
warehouse_id=#{value.warehouseId}, warehouse_name=#{value.warehouseName},
bill_of_lading_no=#{value.billOfLadingNo}, vessel_name=#{value.vesselName}, voyage_no=#{value.voyageNo},
container_type=#{value.containerType}, shipping_company_id=#{value.shippingCompanyId},
shipping_company_name=#{value.shippingCompanyName}, departure_port_code=#{value.departurePortCode},
departure_port_name=#{value.departurePortName}, destination_port_code=#{value.destinationPortCode},
destination_port_name=#{value.destinationPortName}, destination_warehouse_code=#{value.destinationWarehouseCode},
destination_warehouse_name=#{value.destinationWarehouseName}, capacity_cbm=#{value.capacityCbm},
max_weight_kg=#{value.maxWeightKg}, terminal_trucking_company=#{value.terminalTruckingCompany},
loading_at=#{value.loadingAt}, estimated_sailing_at=#{value.estimatedSailingAt},
actual_sailing_at=#{value.actualSailingAt}, estimated_port_arrival_at=#{value.estimatedPortArrivalAt},
actual_port_arrival_at=#{value.actualPortArrivalAt},
estimated_warehouse_arrival_at=#{value.estimatedWarehouseArrivalAt},
actual_warehouse_arrival_at=#{value.actualWarehouseArrivalAt}, updated_by=#{operatorId},
updated_by_name=#{operatorName}, updated_at=CURRENT_TIMESTAMP(3), version=version+1
WHERE id=#{value.id} AND version=#{expectedVersion}
AND NOT EXISTS (SELECT 1 FROM fl_loading_plan p
WHERE p.container_booking_id=fl_container_booking.id AND p.plan_status&lt;&gt;'EDITING')
""")
int updateDraft(@Param("value") ContainerBookingEntity value, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Update("""
UPDATE fl_container_booking SET container_no=#{containerNo}, voyage_no=#{voyageNo},
updated_by=#{operatorId}, updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3), version=version+1
WHERE id=#{id} AND version=#{expectedVersion}
AND NOT EXISTS (SELECT 1 FROM fl_loading_plan p
WHERE p.container_booking_id=fl_container_booking.id AND p.plan_status='COMPLETED')
""")
int updateTransport(@Param("id") long id, @Param("containerNo") String containerNo,
@Param("voyageNo") String voyageNo, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
}
@@ -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'
@@ -3,7 +3,38 @@ package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
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 InboundPalletAllocationMapper extends BaseMapper<InboundPalletAllocationEntity> {
@Select("""
SELECT *
FROM fl_inbound_pallet_allocation
WHERE tenant_id = #{tenantId}
AND inbound_id = #{inboundId}
AND allocation_status = 'CONFIRMED'
ORDER BY allocation_no ASC
FOR UPDATE
""")
java.util.List<InboundPalletAllocationEntity> selectConfirmedForUpdate(
@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
@Update("""
UPDATE fl_inbound_pallet_allocation
SET picked_package_count = picked_package_count + #{count},
allocation_status = CASE
WHEN picked_package_count + #{count} = allocated_package_count THEN 'RELEASED'
ELSE 'CONFIRMED'
END,
updated_at = CURRENT_TIMESTAMP(3)
WHERE id = #{id}
AND tenant_id = #{tenantId}
AND inbound_id = #{inboundId}
AND allocation_status = 'CONFIRMED'
AND picked_package_count + #{count} <= allocated_package_count
""")
int consume(@Param("id") long id, @Param("tenantId") long tenantId,
@Param("inboundId") long inboundId, @Param("count") int count);
}
@@ -0,0 +1,297 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
import lombok.Getter;
import lombok.Setter;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/** 基于共享装柜计划表提供清单查询投影和状态推进持久化操作。 */
@Mapper
public interface LoadingListQueryMapper {
String AGGREGATE_COLUMNS = """
p.id AS plan_id, p.plan_no, p.container_booking_id, p.plan_status, p.remark,
p.created_by, p.created_by_name, p.created_at, p.version,
b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name,
b.vessel_name, b.voyage_no, b.container_type,
COUNT(DISTINCT NULLIF(i.sku_code, '')) AS sku_count,
COALESCE(SUM(i.package_count), 0) AS total_package_count,
COALESCE(SUM(i.gross_weight_kg), 0) AS total_gross_weight_kg,
COALESCE(SUM(i.net_weight_kg), 0) AS total_net_weight_kg,
COALESCE(SUM(i.volume_cbm), 0) AS total_volume_cbm,
COALESCE(SUM(i.product_quantity), 0) AS total_quantity
""";
String AGGREGATE_GROUP = """
p.id, p.plan_no, p.container_booking_id, p.plan_status, p.remark,
p.created_by, p.created_by_name, p.created_at, p.version,
b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name,
b.vessel_name, b.voyage_no, b.container_type
""";
@Select("""
<script>
SELECT
""" + AGGREGATE_COLUMNS + """
FROM fl_loading_plan p
JOIN fl_container_booking b ON b.id = p.container_booking_id
LEFT JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0
WHERE p.plan_status = 'LOADING'
<if test="query.status != null and query.status != ''">AND p.plan_status = #{query.status}</if>
<if test="query.keyword != null and query.keyword != ''">
AND (p.plan_no LIKE CONCAT('%', #{query.keyword}, '%')
OR b.container_no LIKE CONCAT('%', #{query.keyword}, '%')
OR b.cabinet_no LIKE CONCAT('%', #{query.keyword}, '%'))
</if>
<if test="query.warehouseId != null">AND b.warehouse_id = #{query.warehouseId}</if>
<if test="query.accountId != null">
AND EXISTS (SELECT 1 FROM fl_loading_plan_item ai
WHERE ai.loading_plan_id = p.id AND ai.deleted = 0
AND ai.account_id = #{query.accountId})
</if>
<if test="query.createdFrom != null">AND p.created_at &gt;= #{query.createdFrom}</if>
<if test="query.createdTo != null">AND p.created_at &lt;= #{query.createdTo}</if>
GROUP BY
""" + AGGREGATE_GROUP + """
ORDER BY p.created_at DESC, p.id DESC
</script>
""")
Page<AggregateRow> selectPreloadPage(Page<?> page, @Param("query") PreloadQuery query);
@Select("""
<script>
SELECT
""" + AGGREGATE_COLUMNS + """
FROM fl_loading_plan p
JOIN fl_container_booking b ON b.id = p.container_booking_id
LEFT JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0
WHERE p.plan_status = 'COMPLETED'
<if test="query.keyword != null and query.keyword != ''">
AND (p.plan_no LIKE CONCAT('%', #{query.keyword}, '%')
OR b.container_no LIKE CONCAT('%', #{query.keyword}, '%')
OR b.cabinet_no LIKE CONCAT('%', #{query.keyword}, '%'))
</if>
<if test="query.warehouseId != null">AND b.warehouse_id = #{query.warehouseId}</if>
<if test="query.accountId != null">
AND EXISTS (SELECT 1 FROM fl_loading_plan_item ai
WHERE ai.loading_plan_id = p.id AND ai.deleted = 0
AND ai.account_id = #{query.accountId})
</if>
<if test="query.createdFrom != null">AND p.created_at &gt;= #{query.createdFrom}</if>
<if test="query.createdTo != null">AND p.created_at &lt;= #{query.createdTo}</if>
GROUP BY
""" + AGGREGATE_GROUP + """
ORDER BY p.created_at DESC, p.id DESC
</script>
""")
Page<AggregateRow> selectConfirmedPage(Page<?> page, @Param("query") ConfirmedQuery query);
@Select("""
SELECT
""" + AGGREGATE_COLUMNS + """
FROM fl_loading_plan p
JOIN fl_container_booking b ON b.id = p.container_booking_id
LEFT JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0
WHERE p.id = #{planId}
GROUP BY
""" + AGGREGATE_GROUP)
AggregateRow selectAggregate(@Param("planId") long planId);
@Select("""
SELECT id AS item_id, account_id, customer_name, inbound_id, inbound_detail_id,
inbound_code, sku_code, destination_warehouse_name, product_name_zh,
product_name_en, product_image_url, package_count, pallet_count,
gross_weight_kg, net_weight_kg, volume_cbm, dimensional_weight_kg,
product_quantity, units_per_carton, fba_tracking_no, shipment_no,
shipping_mark, internal_remark, customer_remark, warehouse_remark,
load_sequence, frozen_at
FROM fl_loading_plan_item
WHERE loading_plan_id = #{planId} AND deleted = 0
ORDER BY load_sequence, id
""")
List<ItemRow> selectItems(@Param("planId") long planId);
@Select("""
<script>
SELECT DISTINCT loading_plan_id AS plan_id, account_id, customer_name
FROM fl_loading_plan_item
WHERE deleted = 0 AND loading_plan_id IN
<foreach collection="planIds" item="id" open="(" separator="," close=")">#{id}</foreach>
ORDER BY loading_plan_id, account_id
</script>
""")
List<CustomerRow> selectCustomers(@Param("planIds") List<Long> planIds);
@Select("""
SELECT id AS history_id, loading_plan_item_id AS item_id, operation_id,
action_type, from_status, to_status, before_snapshot, after_snapshot,
operator_id, operator_name, operation_reason, created_at AS occurred_at
FROM fl_loading_plan_history
WHERE loading_plan_id = #{planId}
ORDER BY created_at DESC, id DESC
""")
List<HistoryRow> selectHistories(@Param("planId") long planId);
@Select("""
SELECT id AS history_id, loading_plan_item_id AS item_id, operation_id,
action_type, from_status, to_status, before_snapshot, after_snapshot,
operator_id, operator_name, operation_reason, created_at AS occurred_at
FROM fl_loading_plan_history WHERE operation_id = #{operationId}
""")
HistoryRow selectHistoryByOperation(@Param("operationId") String operationId);
@Update("""
UPDATE fl_loading_plan
SET updated_by = #{operatorId}, updated_by_name = #{operatorName},
updated_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE id = #{planId} AND plan_status = 'LOADING'
AND version = #{expectedVersion}
""")
int touchEditable(@Param("planId") long planId, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Update("""
UPDATE fl_container_booking b
JOIN fl_loading_plan p ON p.container_booking_id = b.id
SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo},
b.updated_by = #{operatorId}, b.updated_by_name = #{operatorName},
b.updated_at = CURRENT_TIMESTAMP(3), b.version = b.version + 1
WHERE p.id = #{planId} AND p.plan_status = 'LOADING'
""")
int updateTransport(@Param("planId") long planId, @Param("containerNo") String containerNo,
@Param("voyageNo") String voyageNo, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_loading_plan
SET plan_status = #{toStatus},
preload_completed_by = CASE WHEN #{toStatus} = 'COMPLETED' THEN #{operatorId}
ELSE preload_completed_by END,
preload_completed_by_name = CASE WHEN #{toStatus} = 'COMPLETED' THEN #{operatorName}
ELSE preload_completed_by_name END,
preload_completed_at = CASE WHEN #{toStatus} = 'COMPLETED' THEN CURRENT_TIMESTAMP(3)
ELSE preload_completed_at END,
confirmed_by = CASE WHEN #{toStatus} = 'COMPLETED' THEN #{operatorId} ELSE confirmed_by END,
confirmed_by_name = CASE WHEN #{toStatus} = 'COMPLETED' THEN #{operatorName}
ELSE confirmed_by_name END,
confirmed_at = CASE WHEN #{toStatus} = 'COMPLETED' THEN CURRENT_TIMESTAMP(3)
ELSE confirmed_at END,
updated_by = #{operatorId}, updated_by_name = #{operatorName},
updated_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE id = #{planId} AND plan_status = #{fromStatus} AND version = #{expectedVersion}
""")
int advance(@Param("planId") long planId, @Param("fromStatus") String fromStatus,
@Param("toStatus") String toStatus, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Insert("""
INSERT INTO fl_loading_plan_history
(id, loading_plan_id, loading_plan_item_id, operation_id, action_type,
from_status, to_status, before_snapshot, after_snapshot,
operator_id, operator_name, operation_reason, created_at)
VALUES
(#{id}, #{planId}, NULL, #{operationId}, #{actionType},
#{fromStatus}, #{toStatus}, #{beforeSnapshot}, #{afterSnapshot},
#{operatorId}, #{operatorName}, #{operationReason}, CURRENT_TIMESTAMP(3))
""")
int insertHistory(HistoryWrite value);
@Getter
@Setter
class AggregateRow {
private Long planId;
private String planNo;
private Long containerBookingId;
private String planStatus;
private String remark;
private Long createdBy;
private String createdByName;
private LocalDateTime createdAt;
private Integer version;
private String containerNo;
private String cabinetNo;
private Long warehouseId;
private String warehouseName;
private String vesselName;
private String voyageNo;
private String containerType;
private Long skuCount;
private Long totalPackageCount;
private BigDecimal totalGrossWeightKg;
private BigDecimal totalNetWeightKg;
private BigDecimal totalVolumeCbm;
private Long totalQuantity;
}
@Getter
@Setter
class ItemRow {
private Long itemId;
private Long accountId;
private String customerName;
private Long inboundId;
private Long inboundDetailId;
private String inboundCode;
private String skuCode;
private String destinationWarehouseName;
private String productNameZh;
private String productNameEn;
private String productImageUrl;
private Integer packageCount;
private Integer palletCount;
private BigDecimal grossWeightKg;
private BigDecimal netWeightKg;
private BigDecimal volumeCbm;
private BigDecimal dimensionalWeightKg;
private Long productQuantity;
private Integer unitsPerCarton;
private String fbaTrackingNo;
private String shipmentNo;
private String shippingMark;
private String internalRemark;
private String customerRemark;
private String warehouseRemark;
private Integer loadSequence;
private LocalDateTime frozenAt;
}
@Getter
@Setter
class HistoryRow {
private Long historyId;
private Long itemId;
private String operationId;
private String actionType;
private String fromStatus;
private String toStatus;
private String beforeSnapshot;
private String afterSnapshot;
private Long operatorId;
private String operatorName;
private String operationReason;
private LocalDateTime occurredAt;
}
@Getter
@Setter
class CustomerRow {
private Long planId;
private Long accountId;
private String customerName;
}
record HistoryWrite(long id, long planId, String operationId, String actionType,
String fromStatus, String toStatus, String beforeSnapshot,
String afterSnapshot, long operatorId, String operatorName,
String operationReason) {
}
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface LoadingPickHistoryMapper extends BaseMapper<LoadingPickHistoryEntity> {
}
@@ -0,0 +1,42 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
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 LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity> {
@Update("""
UPDATE fl_loading_pick_item
SET picked_package_count = picked_package_count + #{count},
item_status = CASE WHEN picked_package_count + #{count} = planned_package_count
THEN 'PICKED' ELSE 'PICKING' END,
version = version + 1
WHERE id = #{id} AND version = #{version}
AND picked_package_count + #{count} <= planned_package_count
""")
int pick(@Param("id") long id, @Param("count") int count, @Param("version") int version);
/**
* 以装柜计划的全部有效明细为完成口径,不能只检查当前 PDA 任务中的明细。
*/
@Select("""
SELECT COUNT(*)
FROM fl_loading_plan_item plan_item
WHERE plan_item.loading_plan_id = #{planId}
AND plan_item.deleted = 0
AND NOT EXISTS (
SELECT 1
FROM fl_loading_pick_item pick_item
JOIN fl_loading_pick_task pick_task ON pick_task.id = pick_item.pick_task_id
WHERE pick_item.loading_plan_item_id = plan_item.id
AND pick_task.loading_plan_id = #{planId}
AND pick_task.task_status = 'LOADING'
AND pick_item.picked_package_count = plan_item.package_count
)
""")
long unfinishedCount(@Param("planId") long planId);
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity> {
}
@@ -0,0 +1,3 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity; import org.apache.ibatis.annotations.Mapper;
@Mapper public interface LoadingPlanHistoryMapper extends BaseMapper<LoadingPlanHistoryEntity> {}
@@ -0,0 +1,12 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import org.apache.ibatis.annotations.*;
@Mapper public interface LoadingPlanItemMapper extends BaseMapper<LoadingPlanItemEntity> {
@Update("""
UPDATE fl_loading_plan_item SET deleted=1,removed_by=#{operatorId},removed_by_name=#{operatorName},
removed_at=CURRENT_TIMESTAMP(3),updated_by=#{operatorId},updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{itemId} AND loading_plan_id=#{planId} AND deleted=0 AND frozen_at IS NULL
AND version=#{expectedVersion}
""")
int removeEditing(@Param("planId") long planId,@Param("itemId") long itemId,@Param("expectedVersion") int expectedVersion,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
}
@@ -0,0 +1,17 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.entity.LoadingPlanEntity; import org.apache.ibatis.annotations.*;
@Mapper public interface LoadingPlanMapper extends BaseMapper<LoadingPlanEntity> {
@Update("""
UPDATE fl_loading_plan SET updated_by=#{operatorId},updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{planId} AND plan_status IN ('EDITING','LOADING') AND version=#{expectedVersion}
""")
int touchEditing(@Param("planId") long planId,@Param("expectedVersion") int expectedVersion,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_loading_plan SET plan_status='LOADING',push_idempotency_key=#{key},
preload_pushed_by=#{operatorId},preload_pushed_by_name=#{operatorName},preload_pushed_at=CURRENT_TIMESTAMP(3),
updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{planId} AND plan_status='EDITING' AND version=#{expectedVersion}
""")
int pushPreload(@Param("planId") long planId,@Param("key") String key,@Param("expectedVersion") int expectedVersion,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
}
@@ -0,0 +1,48 @@
package com.baiyue.firstleg.mapper;
import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.ibatis.annotations.*; import java.util.List;
@Mapper public interface SmartLoadingCandidateMapper {
@Select("""
SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId,
o.order_id orderId,o.order_no orderNo,o.inbound_code inboundCode,
o.warehouse_id warehouseId,o.destination_warehouse_id destinationWarehouseId,
o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName,
o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName,
i.package_count-COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li
WHERE li.account_id=o.tenant_id AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0) availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg,
COALESCE(i.unit_volume_cbm,0) unitVolumeCbm
FROM fl_inbound_order o
JOIN fl_inbound_measurement m ON m.tenant_id=o.tenant_id AND m.inbound_id=o.id
AND m.status='APPROVED'
AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2
WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED')
JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id
WHERE o.tenant_id=#{accountId} AND o.process_status='INBOUNDED'
AND i.inbound_detail_id IS NOT NULL
AND i.package_count>COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li
WHERE li.account_id=o.tenant_id AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0)
ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC
""")
List<Candidate> candidates(@Param("accountId") long accountId);
@Select("""
SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId,
o.order_id orderId,o.order_no orderNo,o.inbound_code inboundCode,
o.warehouse_id warehouseId,o.destination_warehouse_id destinationWarehouseId,
o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName,
o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName,
i.package_count-COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li
WHERE li.account_id=o.tenant_id AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0) availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg,
COALESCE(i.unit_volume_cbm,0) unitVolumeCbm
FROM fl_inbound_order o
JOIN fl_inbound_measurement m ON m.tenant_id=o.tenant_id AND m.inbound_id=o.id
AND m.status='APPROVED'
AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2
WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED')
JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id
WHERE o.tenant_id=#{accountId} AND o.id=#{inboundId} AND o.process_status='INBOUNDED'
AND i.inbound_detail_id=#{detailId}
ORDER BY m.id DESC LIMIT 1 FOR UPDATE
""")
Candidate candidate(@Param("accountId") long accountId,@Param("inboundId") long inboundId,
@Param("detailId") long detailId);
}
@@ -0,0 +1,38 @@
package com.baiyue.firstleg.repository;
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.firstleg.dto.ContainerDtos.Query;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.mapper.ContainerBookingMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
@Repository
public class ContainerRepository {
@Resource private ContainerBookingMapper mapper;
public PageResult<ContainerBookingEntity> page(Query query) {
return PageResult.from(mapper.selectBookingPage(new Page<>(query.page(), query.size()), query));
}
public ContainerBookingEntity find(long id) { return mapper.selectBooking(id); }
public boolean containerNoExists(String no, Long excludedId) {
if (no == null) return false;
return mapper.selectCount(new LambdaQueryWrapper<ContainerBookingEntity>()
.eq(ContainerBookingEntity::getContainerNo, no)
.ne(excludedId != null, ContainerBookingEntity::getId, excludedId)) > 0;
}
public boolean cabinetNoExists(String no, Long excludedId) {
return mapper.selectCount(new LambdaQueryWrapper<ContainerBookingEntity>()
.eq(ContainerBookingEntity::getCabinetNo, no)
.ne(excludedId != null, ContainerBookingEntity::getId, excludedId)) > 0;
}
public void insert(ContainerBookingEntity value) { mapper.insert(value); }
public int updateDraft(ContainerBookingEntity value, int version, long operatorId, String operatorName) {
return mapper.updateDraft(value, version, operatorId, operatorName);
}
public int updateTransport(long id, String containerNo, String voyageNo, int version,
long operatorId, String operatorName) {
return mapper.updateTransport(id, containerNo, voyageNo, version, operatorId, operatorName);
}
}
@@ -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(
@@ -12,6 +12,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -21,8 +22,7 @@ public class InboundPalletAllocationRepository {
@Transactional
public void replace(long tenantId, long inboundId, long warehouseId, long warehouseResourceTenantId,
BigDecimal capacity,
BatchResult result) {
BigDecimal capacity, int totalPackageCount, BatchResult result) {
Map<Long, String> reservationNos = mapper.selectList(new LambdaQueryWrapper<InboundPalletAllocationEntity>()
.eq(InboundPalletAllocationEntity::getTenantId, tenantId)
.eq(InboundPalletAllocationEntity::getInboundId, inboundId))
@@ -32,7 +32,11 @@ public class InboundPalletAllocationRepository {
.eq(InboundPalletAllocationEntity::getTenantId, tenantId)
.eq(InboundPalletAllocationEntity::getInboundId, inboundId));
LocalDateTime now = LocalDateTime.now();
result.allocations().forEach(source -> {
int allocationCount = result.allocations().size();
int perAllocation = allocationCount == 0 ? 0
: (int) Math.ceil((double) totalPackageCount / allocationCount);
int remaining = totalPackageCount;
for (var source : result.allocations()) {
InboundPalletAllocationEntity value = new InboundPalletAllocationEntity();
value.setTenantId(tenantId);
value.setInboundId(inboundId);
@@ -43,6 +47,10 @@ public class InboundPalletAllocationRepository {
value.setPalletCode(source.palletCode());
value.setPalletCapacityCbm(capacity);
value.setAllocatedVolumeCbm(source.allocatedVolumeCbm());
int allocatedPackages = Math.min(perAllocation, remaining);
value.setAllocatedPackageCount(allocatedPackages);
value.setPickedPackageCount(0);
remaining -= allocatedPackages;
value.setLocationId(source.locationId());
value.setLocationCode(source.locationCode());
// 仓储确认响应只返回 reservationId;沿用预占阶段的预占单号快照。
@@ -52,7 +60,17 @@ public class InboundPalletAllocationRepository {
value.setCreatedAt(now);
value.setUpdatedAt(now);
mapper.insert(value);
});
}
}
public List<InboundPalletAllocationEntity> lockConfirmed(long tenantId, long inboundId) {
return mapper.selectConfirmedForUpdate(tenantId, inboundId);
}
public void consume(long tenantId, long inboundId, long allocationId, int packageCount) {
if (mapper.consume(allocationId, tenantId, inboundId, packageCount) != 1) {
throw new IllegalStateException("库位箱数已被并发操作修改");
}
}
private static String normalize(String state) {
@@ -0,0 +1,150 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedDetail;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedSummary;
import com.baiyue.firstleg.dto.LoadingListDtos.Customer;
import com.baiyue.firstleg.dto.LoadingListDtos.History;
import com.baiyue.firstleg.dto.LoadingListDtos.Item;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.CustomerRow;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryWrite;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.ItemRow;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Repository
public class LoadingListRepository {
@Resource
private LoadingListQueryMapper mapper;
public PageResult<PreloadSummary> preloadPage(PreloadQuery query) {
Page<AggregateRow> page = mapper.selectPreloadPage(new Page<>(query.page(), query.size()), query);
Map<Long, List<Customer>> customers = customerMap(page.getRecords());
List<PreloadSummary> records = page.getRecords().stream()
.map(row -> preload(row, customers.get(row.getPlanId())))
.toList();
return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
}
public PageResult<ConfirmedSummary> confirmedPage(ConfirmedQuery query) {
Page<AggregateRow> page = mapper.selectConfirmedPage(new Page<>(query.page(), query.size()), query);
List<ConfirmedSummary> records = page.getRecords().stream().map(this::confirmed).toList();
return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
}
public AggregateRow aggregate(long planId) {
return mapper.selectAggregate(planId);
}
public PreloadDetail preloadDetail(AggregateRow row) {
List<ItemRow> items = mapper.selectItems(row.getPlanId());
List<Customer> customers = items.stream()
.collect(Collectors.toMap(ItemRow::getAccountId, ItemRow::getCustomerName,
(left, right) -> left, java.util.LinkedHashMap::new))
.entrySet().stream().map(entry -> new Customer(entry.getKey(), entry.getValue())).toList();
return new PreloadDetail(preload(row, customers), items(items), histories(row.getPlanId()));
}
public ConfirmedDetail confirmedDetail(AggregateRow row) {
return new ConfirmedDetail(confirmed(row), items(mapper.selectItems(row.getPlanId())),
histories(row.getPlanId()));
}
public HistoryRow history(String operationId) {
return mapper.selectHistoryByOperation(operationId);
}
public int updateTransport(long planId, int expectedVersion, String containerNo, String voyageNo,
long operatorId, String operatorName) {
if (mapper.touchEditable(planId, expectedVersion, operatorId, operatorName) != 1) {
return 0;
}
return mapper.updateTransport(planId, containerNo, voyageNo, operatorId, operatorName);
}
public int advance(long planId, String fromStatus, String toStatus, int expectedVersion,
long operatorId, String operatorName) {
return mapper.advance(planId, fromStatus, toStatus, expectedVersion, operatorId, operatorName);
}
public void appendHistory(long planId, String operationId, String actionType,
String fromStatus, String toStatus, String beforeSnapshot,
String afterSnapshot, long operatorId, String operatorName,
String operationReason) {
mapper.insertHistory(new HistoryWrite(IdWorker.getId(), planId, operationId, actionType,
fromStatus, toStatus, beforeSnapshot, afterSnapshot, operatorId, operatorName,
operationReason));
}
private Map<Long, List<Customer>> customerMap(List<AggregateRow> rows) {
List<Long> ids = rows.stream().map(AggregateRow::getPlanId).toList();
if (ids.isEmpty()) {
return Map.of();
}
return mapper.selectCustomers(ids).stream().collect(Collectors.groupingBy(
CustomerRow::getPlanId, java.util.LinkedHashMap::new,
Collectors.mapping(row -> new Customer(row.getAccountId(), row.getCustomerName()),
Collectors.toList())));
}
private PreloadSummary preload(AggregateRow row, List<Customer> customers) {
return new PreloadSummary(row.getPlanId(), row.getPlanNo(), row.getContainerNo(), row.getCabinetNo(),
customers, row.getWarehouseId(), row.getWarehouseName(), row.getVesselName(), row.getVoyageNo(),
row.getContainerType(), row.getPlanStatus(), value(row.getSkuCount()),
value(row.getTotalPackageCount()), decimal(row.getTotalVolumeCbm()),
decimal(row.getTotalGrossWeightKg()), row.getCreatedAt(), row.getVersion());
}
private ConfirmedSummary confirmed(AggregateRow row) {
return new ConfirmedSummary(row.getPlanId(), row.getPlanNo(), row.getWarehouseId(), row.getWarehouseName(),
row.getContainerNo(), row.getCabinetNo(), row.getPlanStatus(), value(row.getTotalPackageCount()),
decimal(row.getTotalGrossWeightKg()), decimal(row.getTotalNetWeightKg()),
decimal(row.getTotalVolumeCbm()), value(row.getTotalQuantity()), row.getRemark(),
row.getCreatedBy(), row.getCreatedByName(), row.getCreatedAt(), row.getVersion());
}
private List<Item> items(List<ItemRow> rows) {
return rows.stream().map(row -> new Item(row.getItemId(), row.getAccountId(), row.getCustomerName(),
row.getInboundId(), row.getInboundDetailId(), row.getInboundCode(), row.getSkuCode(),
row.getDestinationWarehouseName(), row.getProductNameZh(), row.getProductNameEn(),
row.getProductImageUrl(), integer(row.getPackageCount()), integer(row.getPalletCount()),
decimal(row.getGrossWeightKg()), decimal(row.getNetWeightKg()), decimal(row.getVolumeCbm()),
decimal(row.getDimensionalWeightKg()), value(row.getProductQuantity()),
integer(row.getUnitsPerCarton()), row.getFbaTrackingNo(), row.getShipmentNo(), row.getShippingMark(),
row.getInternalRemark(), row.getCustomerRemark(), row.getWarehouseRemark(),
integer(row.getLoadSequence()), row.getFrozenAt())).toList();
}
private List<History> histories(long planId) {
return mapper.selectHistories(planId).stream().map(row -> new History(row.getHistoryId(), row.getItemId(),
row.getActionType(), row.getFromStatus(), row.getToStatus(), row.getBeforeSnapshot(),
row.getAfterSnapshot(), row.getOperatorId(), row.getOperatorName(), row.getOperationReason(),
row.getOccurredAt())).toList();
}
private static long value(Long value) {
return value == null ? 0 : value;
}
private static int integer(Integer value) {
return value == null ? 0 : value;
}
private static BigDecimal decimal(BigDecimal value) {
return value == null ? BigDecimal.ZERO : value;
}
}
@@ -0,0 +1,90 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.mapper.LoadingPickHistoryMapper;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
import com.baiyue.firstleg.mapper.LoadingPickTaskMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
/** PDA 拣货任务持久化边界。 */
@Repository
public class LoadingPickRepository {
@Resource
private LoadingPickTaskMapper tasks;
@Resource
private LoadingPickItemMapper items;
@Resource
private LoadingPickHistoryMapper histories;
public LoadingPickTaskEntity task(long taskId) {
return tasks.selectById(taskId);
}
public LoadingPickTaskEntity taskByPlan(long planId) {
return tasks.selectOne(new LambdaQueryWrapper<LoadingPickTaskEntity>()
.eq(LoadingPickTaskEntity::getLoadingPlanId, planId));
}
public LoadingPickTaskEntity taskByContainer(String containerNo) {
return tasks.selectOne(new LambdaQueryWrapper<LoadingPickTaskEntity>()
.eq(LoadingPickTaskEntity::getContainerNo, containerNo)
.eq(LoadingPickTaskEntity::getTaskStatus, "LOADING"));
}
public LoadingPickItemEntity item(long taskId, long itemId) {
return items.selectOne(new LambdaQueryWrapper<LoadingPickItemEntity>()
.eq(LoadingPickItemEntity::getPickTaskId, taskId)
.eq(LoadingPickItemEntity::getId, itemId));
}
public List<LoadingPickItemEntity> items(long taskId) {
return items.selectList(new LambdaQueryWrapper<LoadingPickItemEntity>()
.eq(LoadingPickItemEntity::getPickTaskId, taskId)
.orderByAsc(LoadingPickItemEntity::getId));
}
public void insert(LoadingPickTaskEntity task) {
tasks.insert(task);
}
public void insert(LoadingPickItemEntity item) {
items.insert(item);
}
public LoadingPickItemEntity itemByPlanItem(long planItemId) {
return items.selectOne(new LambdaQueryWrapper<LoadingPickItemEntity>()
.eq(LoadingPickItemEntity::getLoadingPlanItemId, planItemId));
}
public int pick(long itemId, int count, int version) {
return items.pick(itemId, count, version);
}
public boolean allPlanItemsPicked(long planId) {
return items.unfinishedCount(planId) == 0;
}
public LoadingPickHistoryEntity history(String operationId) {
return histories.selectOne(new LambdaQueryWrapper<LoadingPickHistoryEntity>()
.eq(LoadingPickHistoryEntity::getOperationId, operationId));
}
public void insert(LoadingPickHistoryEntity history) {
histories.insert(history);
}
public void update(LoadingPickItemEntity item) {
items.updateById(item);
}
public void deleteItem(long itemId) {
items.deleteById(itemId);
}
}
@@ -0,0 +1,75 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
import com.baiyue.firstleg.mapper.ContainerBookingMapper;
import com.baiyue.firstleg.mapper.LoadingPlanHistoryMapper;
import com.baiyue.firstleg.mapper.LoadingPlanItemMapper;
import com.baiyue.firstleg.mapper.LoadingPlanMapper;
import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper;
import org.springframework.stereotype.Repository;
import jakarta.annotation.Resource;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import java.util.List;
/** 智能装柜聚合的真实 MySQL 持久化边界。 */
@Repository
@ConditionalOnProperty(name="firstleg.smart-loading.repository.enabled", havingValue="true", matchIfMissing=true)
public class SmartLoadingRepository {
@Resource private LoadingPlanMapper plans;
@Resource private LoadingPlanItemMapper items;
@Resource private LoadingPlanHistoryMapper histories;
@Resource private ContainerBookingMapper bookings;
@Resource private SmartLoadingCandidateMapper candidates;
public List<Candidate> candidates(long accountId) { return candidates.candidates(accountId); }
public Candidate candidate(long accountId, long inboundId, long detailId) {
return candidates.candidate(accountId, inboundId, detailId);
}
public LoadingPlanEntity plan(long planId) { return plans.selectById(planId); }
public LoadingPlanEntity planByBooking(long bookingId) {
return plans.selectOne(new LambdaQueryWrapper<LoadingPlanEntity>()
.eq(LoadingPlanEntity::getContainerBookingId, bookingId));
}
public ContainerBookingEntity booking(long bookingId) { return bookings.selectById(bookingId); }
public List<LoadingPlanItemEntity> items(long planId) {
return items.selectList(new LambdaQueryWrapper<LoadingPlanItemEntity>()
.eq(LoadingPlanItemEntity::getLoadingPlanId, planId)
.eq(LoadingPlanItemEntity::getDeleted, false)
.orderByAsc(LoadingPlanItemEntity::getLoadSequence).orderByAsc(LoadingPlanItemEntity::getId));
}
public LoadingPlanItemEntity item(long planId, long itemId) {
return items.selectOne(new LambdaQueryWrapper<LoadingPlanItemEntity>()
.eq(LoadingPlanItemEntity::getLoadingPlanId, planId)
.eq(LoadingPlanItemEntity::getId, itemId).eq(LoadingPlanItemEntity::getDeleted, false));
}
public LoadingPlanItemEntity activeItem(long planId, long accountId, long detailId) {
return items.selectOne(new LambdaQueryWrapper<LoadingPlanItemEntity>()
.eq(LoadingPlanItemEntity::getLoadingPlanId, planId)
.eq(LoadingPlanItemEntity::getAccountId, accountId)
.eq(LoadingPlanItemEntity::getInboundDetailId, detailId)
.eq(LoadingPlanItemEntity::getDeleted, false));
}
public LoadingPlanHistoryEntity operation(String operationId) {
return histories.selectOne(new LambdaQueryWrapper<LoadingPlanHistoryEntity>()
.eq(LoadingPlanHistoryEntity::getOperationId, operationId));
}
public void insert(LoadingPlanEntity value) { plans.insert(value); }
public void insert(LoadingPlanItemEntity value) { items.insert(value); }
public void insert(LoadingPlanHistoryEntity value) { histories.insert(value); }
public int update(LoadingPlanItemEntity value) { return items.updateById(value); }
public int touch(long planId, int version, long operatorId, String operatorName) {
return plans.touchEditing(planId, version, operatorId, operatorName);
}
public int remove(long planId, long itemId, int version, long operatorId, String operatorName) {
return items.removeEditing(planId, itemId, version, operatorId, operatorName);
}
public int push(long planId, String key, int version, long operatorId, String operatorName) {
return plans.pushPreload(planId, key, version, operatorId, operatorName);
}
}
@@ -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));
}
}
@@ -0,0 +1,134 @@
package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.domain.SmartLoadingStatus;
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.Query;
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.repository.ContainerRepository;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
@Service
public class ContainerService {
@Resource private ContainerRepository repository;
public PageResult<ContainerBookingEntity> page(Query query) {
validateRange(query.estimatedSailingFrom(), query.estimatedSailingTo(), "预计船期筛选");
return repository.page(normalize(query));
}
public ContainerBookingEntity detail(long id) { return required(id); }
@Transactional
public ContainerBookingEntity create(long operatorId, String operatorName, SaveRequest request) {
validateSchedule(request);
ensureUnique(trim(request.containerNo()), request.cabinetNo().trim(), null);
ContainerBookingEntity value = new ContainerBookingEntity();
value.setId(IdWorker.getId());
value.setBookingNo("CB" + value.getId());
copy(value, request);
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0);
try { repository.insert(value); } catch (DuplicateKeyException ex) { throw duplicate(); }
value.setPlanStatus(SmartLoadingStatus.EDITING.name());
return value;
}
@Transactional
public ContainerBookingEntity updateDraft(long id, long operatorId, String operatorName,
FullUpdateRequest request) {
ContainerBookingEntity current = required(id);
requireVersion(current, request.version());
if (status(current) != SmartLoadingStatus.EDITING) {
throw conflict("货柜已进入预装流程,只能修改集装箱号和航次");
}
validateSchedule(request.data());
ensureUnique(trim(request.data().containerNo()), request.data().cabinetNo().trim(), id);
copy(current, request.data());
try {
if (repository.updateDraft(current, request.version(), operatorId, operatorName) != 1) {
throw conflict("货柜状态或版本已变化,请刷新后重试");
}
} catch (DuplicateKeyException ex) { throw duplicate(); }
return required(id);
}
@Transactional
public ContainerBookingEntity updateTransport(long id, long operatorId, String operatorName,
TransportUpdateRequest request) {
ContainerBookingEntity current = required(id);
requireVersion(current, request.version());
if (status(current) == SmartLoadingStatus.COMPLETED) throw conflict("装柜完成后货柜全部只读,不能修改");
String containerNo = trim(request.containerNo());
if (repository.containerNoExists(containerNo, id)) throw conflict("集装箱号已存在");
try {
if (repository.updateTransport(id, containerNo, trim(request.voyageNo()), request.version(),
operatorId, operatorName) != 1) throw conflict("货柜状态或版本已变化,请刷新后重试");
} catch (DuplicateKeyException ex) { throw conflict("集装箱号已存在"); }
return required(id);
}
private ContainerBookingEntity required(long id) {
ContainerBookingEntity value = repository.find(id);
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "货柜不存在");
return value;
}
private void ensureUnique(String containerNo, String cabinetNo, Long excludedId) {
if (repository.containerNoExists(containerNo, excludedId)) throw conflict("集装箱号已存在");
if (repository.cabinetNoExists(cabinetNo, excludedId)) throw conflict("内部柜号已存在");
}
private static void requireVersion(ContainerBookingEntity value, Integer version) {
if (!version.equals(value.getVersion())) throw conflict("货柜已被其他人修改,请刷新后重试");
}
private static SmartLoadingStatus status(ContainerBookingEntity value) {
try { return SmartLoadingStatus.valueOf(value.getPlanStatus()); }
catch (RuntimeException ex) { throw conflict("装柜计划状态无效,不能修改货柜"); }
}
private static void copy(ContainerBookingEntity v, SaveRequest r) {
v.setContainerNo(trim(r.containerNo())); v.setCabinetNo(r.cabinetNo().trim());
v.setWarehouseId(r.warehouseId()); v.setWarehouseName(r.warehouseName().trim());
v.setBillOfLadingNo(trim(r.billOfLadingNo())); v.setVesselName(trim(r.vesselName()));
v.setVoyageNo(trim(r.voyageNo())); v.setContainerType(r.containerType().trim());
v.setShippingCompanyId(r.shippingCompanyId()); v.setShippingCompanyName(trim(r.shippingCompanyName()));
v.setDeparturePortCode(trim(r.departurePortCode())); v.setDeparturePortName(trim(r.departurePortName()));
v.setDestinationPortCode(trim(r.destinationPortCode())); v.setDestinationPortName(trim(r.destinationPortName()));
v.setDestinationWarehouseCode(trim(r.destinationWarehouseCode()));
v.setDestinationWarehouseName(trim(r.destinationWarehouseName()));
v.setCapacityCbm(r.capacityCbm()); v.setMaxWeightKg(r.maxWeightKg());
v.setTerminalTruckingCompany(trim(r.terminalTruckingCompany())); v.setLoadingAt(r.loadingAt());
v.setEstimatedSailingAt(r.estimatedSailingAt()); v.setActualSailingAt(r.actualSailingAt());
v.setEstimatedPortArrivalAt(r.estimatedPortArrivalAt()); v.setActualPortArrivalAt(r.actualPortArrivalAt());
v.setEstimatedWarehouseArrivalAt(r.estimatedWarehouseArrivalAt());
v.setActualWarehouseArrivalAt(r.actualWarehouseArrivalAt());
}
private static void validateSchedule(SaveRequest r) {
validateRange(r.estimatedSailingAt(), r.estimatedPortArrivalAt(), "预计船期和预计到港时间");
validateRange(r.estimatedPortArrivalAt(), r.estimatedWarehouseArrivalAt(), "预计到港和预计到库时间");
validateRange(r.actualSailingAt(), r.actualPortArrivalAt(), "实际船期和实际到港时间");
validateRange(r.actualPortArrivalAt(), r.actualWarehouseArrivalAt(), "实际到港和实际到库时间");
}
private static void validateRange(LocalDateTime from, LocalDateTime to, String label) {
if (from != null && to != null && to.isBefore(from))
throw new BizException(ResultCode.VALIDATION_FAILED, label + "先后顺序不正确");
}
private static Query normalize(Query q) {
return new Query(trim(q.containerNo()), trim(q.cabinetNo()), trim(q.billOfLadingNo()), trim(q.keyword()),
q.warehouseId(), trim(q.containerType()), q.shippingCompanyId(), trim(q.departurePortCode()),
trim(q.destinationPortCode()), trim(q.destinationWarehouseCode()), q.status(), q.estimatedSailingFrom(),
q.estimatedSailingTo(), q.page(), q.size());
}
private static String trim(String value) {
if (value == null) return null;
String result = value.trim(); return result.isEmpty() ? null : result;
}
private static BizException duplicate() { return conflict("集装箱号或内部柜号已存在"); }
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
}
@@ -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();
@@ -0,0 +1,230 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedDetail;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedSummary;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary;
import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow;
import com.baiyue.firstleg.repository.LoadingListRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
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.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
@Service
public class LoadingListService {
private static final Set<String> LOADING_STATUSES = Set.of("LOADING");
@Resource
private LoadingListRepository repository;
@Resource
private LoadingPickRepository pickRepository;
@Resource
private ObjectMapper objectMapper;
public PageResult<PreloadSummary> preloadPage(PreloadQuery query) {
validateRange(query.createdFrom(), query.createdTo());
String status = trim(query.status());
if (StringUtils.hasText(status) && !LOADING_STATUSES.contains(status)) {
throw badRequest("装柜清单状态只能是 LOADING");
}
return repository.preloadPage(new PreloadQuery(trim(query.keyword()), status, query.accountId(),
query.warehouseId(), query.createdFrom(), query.createdTo(), query.page(), query.size()));
}
public PageResult<ConfirmedSummary> confirmedPage(ConfirmedQuery query) {
validateRange(query.createdFrom(), query.createdTo());
return repository.confirmedPage(new ConfirmedQuery(trim(query.keyword()), query.accountId(),
query.warehouseId(), query.createdFrom(), query.createdTo(), query.page(), query.size()));
}
public PreloadDetail preloadDetail(long planId) {
AggregateRow row = requirePlan(planId);
if (!LOADING_STATUSES.contains(row.getPlanStatus())) {
throw notFound("装柜中清单不存在");
}
return repository.preloadDetail(row);
}
public ConfirmedDetail confirmedDetail(long planId) {
AggregateRow row = requirePlan(planId);
if (!"COMPLETED".equals(row.getPlanStatus())) {
throw notFound("装柜清单不存在");
}
return repository.confirmedDetail(row);
}
@Transactional
@BusinessAudit(event = "PRELOAD_TRANSPORT_UPDATE", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "修改预装集装箱号与航次")
public PreloadDetail updateTransport(long planId, long operatorId, String operatorName,
String idempotencyKey, TransportUpdateRequest request) {
String containerNo = nullableTrim(request.containerNo());
String voyageNo = nullableTrim(request.voyageNo());
String requestHash = sha256("TRANSPORT|" + planId + '|' + request.expectedVersion()
+ '|' + containerNo + '|' + voyageNo + '|' + trim(request.operationReason()));
String operationId = operationId("TRANSPORT", planId, idempotencyKey);
HistoryRow replay = repository.history(operationId);
if (replay != null) {
requireSameRequest(replay, requestHash);
return repository.preloadDetail(requirePlan(planId));
}
AggregateRow before = requirePlan(planId);
requireLoadingMutable(before);
if (repository.updateTransport(planId, request.expectedVersion(), containerNo, voyageNo,
operatorId, trim(operatorName)) != 1) {
throw conflict("预装计划已被其他操作修改,请刷新后重试");
}
repository.appendHistory(planId, operationId, "UPDATE_PRELOAD_TRANSPORT",
before.getPlanStatus(), before.getPlanStatus(), json(snapshot(before)),
json(values("requestHash", requestHash, "status", before.getPlanStatus(),
"containerNo", containerNo, "voyageNo", voyageNo,
"version", request.expectedVersion() + 1)), operatorId, trim(operatorName),
trim(request.operationReason()));
return repository.preloadDetail(requirePlan(planId));
}
/**
* 最后一箱 PDA 拣货完成后自动调用。服务与数据库触发器均以计划全部有效明细为口径复核。
*/
@Transactional
@BusinessAudit(event = "PDA_COMPLETE_LOADING", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "PDA拣货完成后自动生成装柜清单")
public void finishFromPda(long planId, long pickTaskId, long operatorId, String operatorName) {
String operationId = "PDA-COMPLETE-" + planId + '-' + pickTaskId;
if (repository.history(operationId) != null) {
return;
}
if (!pickRepository.allPlanItemsPicked(planId)) {
throw conflict("计划仍存在未完成PDA拣货的箱数,不能生成装柜清单");
}
AggregateRow before = requirePlan(planId);
if ("COMPLETED".equals(before.getPlanStatus())) {
return;
}
if (!"LOADING".equals(before.getPlanStatus())) {
throw conflict("当前状态为 " + before.getPlanStatus() + ",不能由PDA完成装柜");
}
if (repository.advance(planId, "LOADING", "COMPLETED", before.getVersion(),
operatorId, trim(operatorName)) != 1) {
AggregateRow current = requirePlan(planId);
if ("COMPLETED".equals(current.getPlanStatus())) {
return;
}
throw conflict("装柜计划已被其他操作修改,请刷新后重试");
}
repository.appendHistory(planId, operationId, "PDA_COMPLETE_LOADING",
"LOADING", "COMPLETED", json(snapshot(before)),
json(values("source", "PDA", "pickTaskId", pickTaskId, "status", "COMPLETED",
"version", before.getVersion() + 1)), operatorId, trim(operatorName),
"全部箱数已完成PDA拣货");
}
private AggregateRow requirePlan(long planId) {
AggregateRow row = repository.aggregate(planId);
if (row == null) {
throw notFound("装柜计划不存在");
}
return row;
}
private static void requireLoadingMutable(AggregateRow row) {
if (!LOADING_STATUSES.contains(row.getPlanStatus())) {
throw conflict("仅装柜中可修改集装箱号与航次");
}
}
private static void validateRange(java.time.LocalDateTime from, java.time.LocalDateTime to) {
if (from != null && to != null && from.isAfter(to)) {
throw badRequest("创建开始时间不能晚于结束时间");
}
}
private String operationId(String operation, long planId, String key) {
String normalized = trim(key);
if (!StringUtils.hasText(normalized)) {
throw badRequest("必须提供 Idempotency-Key");
}
if (normalized.length() > 128) {
throw badRequest("Idempotency-Key 长度不能超过128");
}
return "PL-" + sha256(operation + '|' + planId + '|' + normalized);
}
private static void requireSameRequest(HistoryRow replay, String requestHash) {
String snapshot = replay.getAfterSnapshot();
if (snapshot == null || !snapshot.contains("\"requestHash\":\"" + requestHash + "\"")) {
throw conflict("Idempotency-Key 已用于不同请求");
}
}
private Map<String, Object> snapshot(AggregateRow row) {
return values("status", row.getPlanStatus(), "containerNo", row.getContainerNo(),
"voyageNo", row.getVoyageNo(), "version", row.getVersion());
}
private static Map<String, Object> values(Object... values) {
Map<String, Object> result = new LinkedHashMap<>();
for (int index = 0; index < values.length; index += 2) {
result.put((String) values[index], values[index + 1]);
}
return result;
}
private String json(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException exception) {
throw new IllegalStateException("无法生成装柜审计快照", exception);
}
}
private static String sha256(String value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException("JVM 不支持 SHA-256", impossible);
}
}
private static String nullableTrim(String value) {
String trimmed = trim(value);
return StringUtils.hasText(trimmed) ? trimmed : null;
}
private static String trim(String value) {
return value == null ? null : value.trim();
}
private static BizException badRequest(String message) {
return new BizException(ResultCode.BAD_REQUEST, message);
}
private static BizException notFound(String message) {
return new BizException(ResultCode.NOT_FOUND, message);
}
private static BizException conflict(String message) {
return new BizException(ResultCode.CONFLICT, message);
}
}
@@ -0,0 +1,179 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.LoadingPickDtos.PickItem;
import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.Task;
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
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.security.NoSuchAlgorithmException;
import java.util.List;
/** 装柜中 PDA 拣货任务及明细同步。 */
@Service
public class LoadingPickService {
@Resource
private LoadingPickRepository repository;
@Resource
private LoadingListService loadingListService;
@Transactional
public void begin(long planId, String containerNo, long operatorId, String operatorName,
List<LoadingPlanItemEntity> planItems) {
LoadingPickTaskEntity task = repository.taskByPlan(planId);
if (task == null) {
task = new LoadingPickTaskEntity();
task.setLoadingPlanId(planId);
task.setContainerNo(containerNo);
task.setTaskStatus("LOADING");
task.setStartedBy(operatorId);
task.setStartedByName(operatorName);
task.setVersion(0);
try {
repository.insert(task);
} catch (DuplicateKeyException exception) {
task = repository.taskByPlan(planId);
}
}
if (task == null || !"LOADING".equals(task.getTaskStatus())) {
throw conflict("装柜任务已完成,不能继续同步明细");
}
for (LoadingPlanItemEntity planItem : planItems) {
syncItem(task.getId(), planItem);
}
}
@Transactional
public void sync(long planId, LoadingPlanItemEntity planItem) {
LoadingPickTaskEntity task = repository.taskByPlan(planId);
if (task != null && "LOADING".equals(task.getTaskStatus())) {
syncItem(task.getId(), planItem);
}
}
public Task task(String containerNo) {
if (!StringUtils.hasText(containerNo)) {
throw new BizException(ResultCode.BAD_REQUEST, "集装箱号不能为空");
}
LoadingPickTaskEntity task = repository.taskByContainer(containerNo.trim());
if (task == null) {
throw new BizException(ResultCode.NOT_FOUND, "未找到装柜中的PDA拣货任务");
}
return view(task);
}
@Transactional
public Task pick(long taskId, long itemId, PickRequest request, long operatorId, String operatorName) {
String operationId = request.operationId().trim();
String requestHash = hash(taskId + "|" + itemId + "|" + request.pickedPackageCount());
LoadingPickHistoryEntity replay = repository.history(operationId);
if (replay != null) {
if (!replay.getPickTaskId().equals(taskId) || !replay.getPickItemId().equals(itemId)
|| !replay.getRequestHash().equals(requestHash)) {
throw conflict("operationId 已用于不同拣货请求");
}
return view(requiredTask(taskId));
}
LoadingPickTaskEntity task = requiredTask(taskId);
if (!"LOADING".equals(task.getTaskStatus())) {
throw conflict("装柜任务已完成,不能继续拣货");
}
LoadingPickItemEntity item = repository.item(taskId, itemId);
if (item == null) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在");
}
if (repository.pick(itemId, request.pickedPackageCount(), request.version()) != 1) {
throw conflict("拣货数量超过计划箱数或明细版本已变化");
}
LoadingPickHistoryEntity history = new LoadingPickHistoryEntity();
history.setPickTaskId(taskId);
history.setPickItemId(itemId);
history.setOperationId(operationId);
history.setRequestHash(requestHash);
history.setPickedPackageCount(request.pickedPackageCount());
history.setOperatorId(operatorId);
history.setOperatorName(operatorName);
repository.insert(history);
if (repository.allPlanItemsPicked(task.getLoadingPlanId())) {
loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, operatorId, operatorName);
}
return view(requiredTask(taskId));
}
@Transactional
public void remove(long planItemId) {
LoadingPickItemEntity item = repository.itemByPlanItem(planItemId);
if (item == null) {
return;
}
if (item.getPickedPackageCount() != null && item.getPickedPackageCount() > 0) {
throw conflict("已有拣货记录的明细不能删除");
}
repository.deleteItem(item.getId());
}
private void syncItem(long taskId, LoadingPlanItemEntity planItem) {
LoadingPickItemEntity item = repository.itemByPlanItem(planItem.getId());
if (item == null) {
item = new LoadingPickItemEntity();
item.setPickTaskId(taskId);
item.setLoadingPlanItemId(planItem.getId());
item.setInboundId(planItem.getInboundId());
item.setInboundDetailId(planItem.getInboundDetailId());
item.setPlannedPackageCount(planItem.getPackageCount());
item.setPickedPackageCount(0);
item.setItemStatus("PENDING");
item.setVersion(0);
repository.insert(item);
return;
}
if (item.getPickedPackageCount() > planItem.getPackageCount()) {
throw conflict("已拣货箱数不能低于计划箱数");
}
item.setPlannedPackageCount(planItem.getPackageCount());
item.setItemStatus(item.getPickedPackageCount().equals(planItem.getPackageCount()) ? "PICKED" : "PICKING");
repository.update(item);
}
private LoadingPickTaskEntity requiredTask(long taskId) {
LoadingPickTaskEntity task = repository.task(taskId);
if (task == null) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货任务不存在");
}
return task;
}
private Task view(LoadingPickTaskEntity task) {
List<PickItem> items = repository.items(task.getId()).stream()
.map(item -> new PickItem(item.getId(), item.getLoadingPlanItemId(), item.getInboundId(),
item.getInboundDetailId(), item.getPlannedPackageCount(), item.getPickedPackageCount(),
item.getItemStatus(), item.getVersion())).toList();
return new Task(task.getId(), task.getLoadingPlanId(), task.getContainerNo(), task.getTaskStatus(),
task.getVersion(), items);
}
private static String hash(String value) {
try {
return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("JVM 不支持 SHA-256", exception);
}
}
private static BizException conflict(String message) {
return new BizException(ResultCode.CONFLICT, message);
}
}
@@ -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();
}
@@ -45,15 +45,16 @@ public class MeasurementLocationOccupationService {
properties.getDefaultPalletCapacityCbm(), "STANDARD",
LocalDateTime.now().plus(properties.getReservationTtl()), true))),
"测量完成后库位预占失败");
int packageCount = requirePackageCount(order);
allocations.replace(tenantId, inboundId, warehouseId, resourceTenantId,
properties.getDefaultPalletCapacityCbm(), reserved);
properties.getDefaultPalletCapacityCbm(), packageCount, reserved);
try {
WarehousePalletLocationClient.BatchResult occupied = requireSuccess(call(() -> warehouse.confirm(
internalToken, new WarehousePalletLocationClient.ConfirmRequest(resourceTenantId, warehouseId,
SOURCE_SERVICE, inboundId, operatorId, volume,
properties.getDefaultPalletCapacityCbm()))), "测量完成后库位占用失败");
allocations.replace(tenantId, inboundId, warehouseId, resourceTenantId,
properties.getDefaultPalletCapacityCbm(), occupied);
properties.getDefaultPalletCapacityCbm(), packageCount, occupied);
} catch (RuntimeException failure) {
try {
warehouse.release(internalToken, new WarehousePalletLocationClient.ReleaseRequest(
@@ -85,6 +86,13 @@ public class MeasurementLocationOccupationService {
return order.getActualVolumeCbm();
}
private static int requirePackageCount(InboundOrderEntity order) {
if (order.getTotalPackageCount() == null || order.getTotalPackageCount() <= 0) {
throw new BizException(ResultCode.CONFLICT, "入库单缺少有效总箱数");
}
return order.getTotalPackageCount();
}
private static <T> ApiResponse<T> call(RemoteCall<T> call) {
try { return call.invoke(); }
catch (FeignException exception) {
@@ -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,380 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.firstleg.dto.SmartLoadingDtos.*;
import com.baiyue.firstleg.client.OrderCargoClient;
import com.baiyue.firstleg.entity.*;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import feign.FeignException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.*;
/** 智能装柜的候选、推荐/人工分配、删除和不可逆推送编排。 */
@Service
public class SmartLoadingService {
@Resource private SmartLoadingRepository repository;
@Resource private OrderCargoClient orderCargoClient;
@Resource private LoadingPickService loadingPickService;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
public SmartLoadingService() { }
public List<Candidate> candidates(long accountId) {
requireAccount(accountId);
return repository.candidates(accountId);
}
public Plan draftByBooking(long bookingId) {
ContainerBookingEntity booking = requireBooking(bookingId);
LoadingPlanEntity plan = repository.planByBooking(bookingId);
return draftView(plan, booking);
}
public Plan detail(long planId) {
LoadingPlanEntity plan = repository.plan(planId);
if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在");
return draftView(plan, requireBooking(plan.getContainerBookingId()));
}
@Transactional
@BusinessAudit(event = "SMART_LOADING_PLAN_CREATE", resourceType = "LOADING_PLAN",
description = "创建智能装柜计划", captureBefore = false)
public Plan create(long bookingId, String remark, long operatorId, String operatorName) {
ContainerBookingEntity booking = requireBooking(bookingId);
LoadingPlanEntity existing = repository.planByBooking(bookingId);
if (existing != null) return draftView(existing, booking);
LoadingPlanEntity plan = new LoadingPlanEntity();
plan.setPlanNo("LP-" + UUID.randomUUID().toString().replace("-", "").substring(0, 20));
plan.setContainerBookingId(bookingId); plan.setPlanStatus("EDITING"); plan.setRemark(trim(remark));
plan.setCreatedBy(operatorId); plan.setCreatedByName(operatorName); plan.setUpdatedBy(operatorId);
plan.setUpdatedByName(operatorName); plan.setVersion(0);
try {
repository.insert(plan);
} catch (DuplicateKeyException exception) {
LoadingPlanEntity concurrent = repository.planByBooking(bookingId);
if (concurrent != null) return draftView(concurrent, booking);
throw conflict("装柜计划创建冲突,请刷新后重试");
}
history(plan.getId(), null, "CREATE_PLAN", null, "EDITING", UUID.randomUUID().toString(), operatorId, operatorName);
return view(plan, booking);
}
@Transactional
@BusinessAudit(event = "SMART_LOADING_ITEM_ASSIGN", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "分配智能装柜明细")
public Plan assign(long planId, AssignRequest request, long operatorId, String operatorName) {
String operation = operation(request.operationId());
String requestHash = sha256("ASSIGN|" + planId + '|' + request.accountId() + '|'
+ request.inboundId() + '|' + request.inboundDetailId() + '|' + request.packageCount()
+ '|' + request.expectedVersion());
Plan replay = replay(operation, planId, "ADD_ITEM", null, requestHash);
if (replay != null) return replay;
LoadingPlanEntity plan = editing(planId, request.expectedVersion());
Candidate candidate = repository.candidate(request.accountId(), request.inboundId(), request.inboundDetailId());
if (candidate == null) throw conflict("入库货物不存在或尚未完成入库");
if (!Objects.equals(candidate.accountId(), request.accountId())) throw forbidden();
OrderCargoClient.CargoBinding cargo = loadCargo(candidate);
LoadingPlanItemEntity existing = repository.activeItem(planId, request.accountId(), request.inboundDetailId());
int total = request.packageCount() + (existing == null ? 0 : existing.getPackageCount());
int availableIncludingCurrent = candidate.availablePackageCount()
+ (existing == null ? 0 : existing.getPackageCount());
if (total > availableIncludingCurrent) throw validation("累计分配箱数超过可用箱数");
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
List<LoadingPlanItemEntity> lines = new ArrayList<>(repository.items(planId));
if (existing != null) lines.removeIf(x -> Objects.equals(x.getId(), existing.getId()));
checkRouteAndCapacity(booking, lines, candidate, total);
touch(plan, operatorId, operatorName);
LoadingPlanItemEntity changed;
if (existing == null) {
LoadingPlanItemEntity item = snapshot(planId, candidate, cargo, request.packageCount(), operatorId, operatorName, lines.size() + 1);
try { repository.insert(item); } catch (DuplicateKeyException e) { throw conflict("货物明细已被并发锁定"); }
changed = item;
} else {
existing.setPackageCount(total); existing.setGrossWeightKg(unit(candidate.unitWeightKg()).multiply(BigDecimal.valueOf(total)));
existing.setVolumeCbm(unit(candidate.unitVolumeCbm()).multiply(BigDecimal.valueOf(total))); applyCargo(existing, cargo, total);
existing.setUpdatedBy(operatorId); existing.setUpdatedByName(operatorName);
if (repository.update(existing) != 1) throw conflict("装柜草稿已被并发修改");
changed = existing;
}
history(planId, changed.getId(), "ADD_ITEM", "EDITING", "EDITING", operation,
json(values("version", request.expectedVersion())),
json(values("requestHash", requestHash, "itemId", changed.getId(),
"packageCount", changed.getPackageCount())), operatorId, operatorName);
loadingPickService.sync(planId, changed);
return view(repository.plan(planId), booking);
}
@Transactional
@BusinessAudit(event = "SMART_LOADING_RECOMMEND", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "执行智能装柜推荐")
public Plan recommend(long planId, RecommendRequest request, long operatorId, String operatorName) {
String operation = operation(request.operationId());
String requestHash = sha256("RECOMMEND|" + planId + '|' + request.accountId()
+ '|' + request.expectedVersion());
Plan replay = replay(operation, planId, "SMART_RECOMMEND", null, requestHash);
if (replay != null) return replay;
LoadingPlanEntity plan = editing(planId, request.expectedVersion());
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
for (Candidate candidate : candidates(request.accountId())) {
if (!Objects.equals(candidate.warehouseId(), booking.getWarehouseId())) continue;
if (StringUtils.hasText(booking.getDestinationWarehouseCode())
&& !Objects.equals(booking.getDestinationWarehouseCode(), candidate.destinationWarehouseCode())) continue;
int count = fittingBoxes(booking, repository.items(planId), candidate);
if (count > 0) {
plan = repository.plan(planId);
assign(planId, new AssignRequest(request.accountId(), candidate.inboundId(), candidate.inboundDetailId(),
count, plan.getVersion(), "REC-" + sha256(operation + '|' + candidate.inboundId()
+ '|' + candidate.inboundDetailId())), operatorId, operatorName);
}
}
LoadingPlanEntity recommended = repository.plan(planId);
history(planId, null, "SMART_RECOMMEND", "EDITING", "EDITING", operation,
json(values("version", request.expectedVersion())),
json(values("requestHash", requestHash, "version", recommended.getVersion())),
operatorId, operatorName);
return view(recommended, requireBooking(plan.getContainerBookingId()));
}
@Transactional
@BusinessAudit(event = "SMART_LOADING_ITEM_ADJUST", resourceType = "LOADING_PLAN_ITEM",
resourceId = "#itemId", description = "调整智能装柜明细")
public Plan adjust(long planId, long itemId, AdjustRequest request, long operatorId, String operatorName) {
String operation = operation(request.operationId());
String requestHash = sha256("ADJUST|" + planId + '|' + itemId + '|' + request.accountId()
+ '|' + request.packageCount() + '|' + request.expectedPlanVersion()
+ '|' + request.expectedItemVersion());
Plan replay = replay(operation, planId, "ADJUST_ITEM", itemId, requestHash);
if (replay != null) return replay;
LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion());
LoadingPlanItemEntity item = repository.item(planId, itemId);
if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在");
if (!Objects.equals(item.getAccountId(), request.accountId())) throw forbidden();
if (!Objects.equals(item.getVersion(), request.expectedItemVersion())) throw conflict("装柜明细版本冲突");
Candidate candidate = repository.candidate(item.getAccountId(), item.getInboundId(), item.getInboundDetailId());
if (candidate == null || request.packageCount() > candidate.availablePackageCount() + item.getPackageCount()) throw validation("调整箱数超过可用箱数");
OrderCargoClient.CargoBinding cargo = loadCargo(candidate);
List<LoadingPlanItemEntity> others = new ArrayList<>(repository.items(planId));
others.removeIf(x -> Objects.equals(x.getId(), itemId));
checkRouteAndCapacity(requireBooking(plan.getContainerBookingId()), others, candidate, request.packageCount());
touch(plan, operatorId, operatorName);
item.setPackageCount(request.packageCount());
item.setGrossWeightKg(unit(candidate.unitWeightKg()).multiply(BigDecimal.valueOf(request.packageCount())));
item.setVolumeCbm(unit(candidate.unitVolumeCbm()).multiply(BigDecimal.valueOf(request.packageCount()))); applyCargo(item, cargo, request.packageCount());
item.setUpdatedBy(operatorId); item.setUpdatedByName(operatorName);
if (repository.update(item) != 1) throw conflict("装柜明细已被并发修改");
history(planId, itemId, "ADJUST_ITEM", "EDITING", "EDITING", operation,
json(values("version", request.expectedPlanVersion(), "packageCount", item.getPackageCount())),
json(values("requestHash", requestHash, "packageCount", request.packageCount())),
operatorId, operatorName);
loadingPickService.sync(planId, item);
return view(repository.plan(planId), requireBooking(plan.getContainerBookingId()));
}
@Transactional
@BusinessAudit(event = "SMART_LOADING_ITEM_DELETE", resourceType = "LOADING_PLAN_ITEM",
resourceId = "#itemId", description = "删除智能装柜明细")
public Plan delete(long planId, long itemId, DeleteRequest request, long operatorId, String operatorName) {
String operation = operation(request.operationId());
String requestHash = sha256("DELETE|" + planId + '|' + itemId + '|' + request.accountId()
+ '|' + request.expectedPlanVersion() + '|' + request.expectedItemVersion());
Plan replay = replay(operation, planId, "REMOVE_ITEM", itemId, requestHash);
if (replay != null) return replay;
LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion());
LoadingPlanItemEntity item = repository.item(planId, itemId);
if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在");
if (!Objects.equals(item.getAccountId(), request.accountId())) throw forbidden();
loadingPickService.remove(itemId);
touch(plan, operatorId, operatorName);
if (repository.remove(planId, itemId, request.expectedItemVersion(), operatorId, operatorName) != 1) throw conflict("装柜草稿已被并发修改");
history(planId, itemId, "REMOVE_ITEM", "EDITING", "EDITING", operation,
json(values("version", request.expectedPlanVersion(), "packageCount", item.getPackageCount())),
json(values("requestHash", requestHash, "deleted", true)), operatorId, operatorName);
return view(repository.plan(planId), requireBooking(plan.getContainerBookingId()));
}
@Transactional
@BusinessAudit(event = "SMART_LOADING_PUSH_PRELOAD", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "推送智能装柜计划到预装清单")
public Plan pushPreload(long planId, String idempotencyKey, PushRequest request, long operatorId, String operatorName) {
String key = trim(idempotencyKey);
if (!StringUtils.hasText(key) || key.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "必须提供有效 Idempotency-Key");
LoadingPlanEntity plan = repository.plan(planId); if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在");
if ("LOADING".equals(plan.getPlanStatus()) && key.equals(plan.getPushIdempotencyKey())) return view(plan, requireBooking(plan.getContainerBookingId()));
if (!"EDITING".equals(plan.getPlanStatus()) || !Objects.equals(plan.getVersion(), request.expectedVersion())) throw conflict("仅编辑中的计划可开始装柜且版本必须匹配");
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
if (!StringUtils.hasText(booking.getContainerNo())) throw validation("开始装柜前必须填写集装箱号");
if (repository.items(planId).isEmpty()) throw validation("装柜明细不能为空");
loadingPickService.begin(planId, booking.getContainerNo(), operatorId, operatorName, repository.items(planId));
try {
if (repository.push(planId, key, request.expectedVersion(), operatorId, operatorName) != 1) throw conflict("装柜计划已被并发修改");
} catch (DuplicateKeyException e) { throw conflict("幂等键已用于其他推送"); }
history(planId, null, "START_LOADING", "EDITING", "LOADING", key, operatorId, operatorName);
LoadingPlanEntity pushed = repository.plan(planId);
return view(pushed, requireBooking(pushed.getContainerBookingId()));
}
private LoadingPlanItemEntity snapshot(long planId, Candidate c, OrderCargoClient.CargoBinding cargo, int count, long op, String name, int seq) {
LoadingPlanItemEntity i = new LoadingPlanItemEntity(); i.setLoadingPlanId(planId); i.setAccountId(c.accountId()); i.setCustomerName(c.customerName());
i.setInboundId(c.inboundId()); i.setInboundDetailId(c.inboundDetailId()); i.setOrderId(c.orderId()); i.setOrderNo(c.orderNo()); i.setInboundCode(c.inboundCode());
i.setDestinationWarehouseId(c.destinationWarehouseId()); i.setDestinationWarehouseCode(c.destinationWarehouseCode());
i.setDestinationWarehouseName(c.destinationWarehouseName()); i.setProductNameZh(cargo.cargoName()); i.setPackageCount(count);
i.setGrossWeightKg(unit(c.unitWeightKg()).multiply(BigDecimal.valueOf(count))); i.setVolumeCbm(unit(c.unitVolumeCbm()).multiply(BigDecimal.valueOf(count)));
applyCargo(i, cargo, count); i.setLoadSequence(seq); i.setDeleted(false); i.setCreatedBy(op); i.setCreatedByName(name); i.setUpdatedBy(op); i.setUpdatedByName(name); i.setVersion(0); return i;
}
private void applyCargo(LoadingPlanItemEntity item, OrderCargoClient.CargoBinding cargo, int boxes) {
item.setSkuCode(cargo.skuNo());
item.setProductNameEn(cargo.cargoNameEn());
item.setUnitsPerCarton(cargo.unitsPerPackage());
item.setInternalRemark(cargo.cargoRemark());
int original = cargo.originalPackageCount() == null || cargo.originalPackageCount() <= 0
? boxes : cargo.originalPackageCount();
if (cargo.unitsPerPackage() != null) {
item.setProductQuantity(Math.multiplyExact(cargo.unitsPerPackage(), boxes));
} else if (cargo.totalQuantity() != null) {
item.setProductQuantity(BigDecimal.valueOf(cargo.totalQuantity())
.multiply(BigDecimal.valueOf(boxes))
.divide(BigDecimal.valueOf(original), 0, RoundingMode.HALF_UP).intValueExact());
}
if (cargo.netWeightKg() != null) {
item.setNetWeightKg(cargo.netWeightKg().multiply(BigDecimal.valueOf(boxes))
.divide(BigDecimal.valueOf(original), 6, RoundingMode.HALF_UP));
}
}
private OrderCargoClient.CargoBinding loadCargo(Candidate candidate) {
try {
ApiResponse<OrderCargoClient.CargoBinding> response = orderCargoClient.detail(internalToken,
candidate.accountId(), candidate.orderId(), candidate.inboundDetailId());
if (response == null) {
throw unavailable();
}
if (!response.successful()) {
throw new BizException(ResultCode.of(response.code()), response.message());
}
OrderCargoClient.CargoBinding cargo = response.data();
if (cargo == null || !Objects.equals(cargo.cargoId(), candidate.inboundDetailId())
|| !Objects.equals(cargo.orderId(), candidate.orderId())) {
throw conflict("订单货物明细返回与入库单不匹配");
}
return cargo;
} catch (BizException exception) {
throw exception;
} catch (FeignException exception) {
throw unavailable();
} catch (RuntimeException exception) {
throw unavailable();
}
}
private void checkRouteAndCapacity(ContainerBookingEntity b,List<LoadingPlanItemEntity> lines,Candidate c,int count){
if (!Objects.equals(b.getWarehouseId(), c.warehouseId())) throw validation("装柜仓库不一致");
if (StringUtils.hasText(b.getDestinationWarehouseCode()) && !Objects.equals(b.getDestinationWarehouseCode(), c.destinationWarehouseCode())) throw validation("目的仓/港不一致");
BigDecimal v=lines.stream().map(x->unit(x.getVolumeCbm())).reduce(BigDecimal.ZERO,BigDecimal::add).add(unit(c.unitVolumeCbm()).multiply(BigDecimal.valueOf(count)));
BigDecimal w=lines.stream().map(x->unit(x.getGrossWeightKg())).reduce(BigDecimal.ZERO,BigDecimal::add).add(unit(c.unitWeightKg()).multiply(BigDecimal.valueOf(count)));
if (b.getCapacityCbm()==null || b.getMaxWeightKg()==null) throw validation("货柜容量和最大承重未配置");
if (v.compareTo(b.getCapacityCbm())>0 || w.compareTo(b.getMaxWeightKg())>0) throw validation("超过货柜容积或重量上限");
}
private int fittingBoxes(ContainerBookingEntity b,List<LoadingPlanItemEntity> lines,Candidate c){
if(b.getCapacityCbm()==null||b.getMaxWeightKg()==null)return 0;
BigDecimal usedV=lines.stream().map(x->unit(x.getVolumeCbm())).reduce(BigDecimal.ZERO,BigDecimal::add);
BigDecimal usedW=lines.stream().map(x->unit(x.getGrossWeightKg())).reduce(BigDecimal.ZERO,BigDecimal::add);
BigDecimal leftV=b.getCapacityCbm().subtract(usedV),leftW=b.getMaxWeightKg().subtract(usedW);
int byV=unit(c.unitVolumeCbm()).signum()==0?c.availablePackageCount():leftV.max(BigDecimal.ZERO).divideToIntegralValue(c.unitVolumeCbm()).intValue();
int byW=unit(c.unitWeightKg()).signum()==0?c.availablePackageCount():leftW.max(BigDecimal.ZERO).divideToIntegralValue(c.unitWeightKg()).intValue();
return Math.max(0,Math.min(c.availablePackageCount(),Math.min(byV,byW)));
}
private Plan view(LoadingPlanEntity p,ContainerBookingEntity b){return new Plan(p.getId(),p.getPlanNo(),p.getPlanStatus(),p.getContainerBookingId(),b.getContainerNo(),b.getVoyageNo(),p.getVersion(),repository.items(p.getId()));}
private Plan draftView(LoadingPlanEntity plan, ContainerBookingEntity booking) {
if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "该货柜尚未创建智能装柜草稿");
if (!"EDITING".equals(plan.getPlanStatus())) throw conflict("装柜计划已推送预装,请前往预装清单查看");
return view(plan, booking);
}
private LoadingPlanEntity editing(long id,int version){LoadingPlanEntity p=repository.plan(id);if(p==null)throw new BizException(ResultCode.NOT_FOUND,"装柜计划不存在");if(!Set.of("EDITING", "LOADING").contains(p.getPlanStatus())||!Objects.equals(p.getVersion(),version))throw conflict("计划已完成或版本冲突");return p;}
private ContainerBookingEntity requireBooking(long id){ContainerBookingEntity b=repository.booking(id);if(b==null)throw new BizException(ResultCode.NOT_FOUND,"货柜预订不存在");return b;}
private void touch(LoadingPlanEntity p,long op,String name){if(repository.touch(p.getId(),p.getVersion(),op,name)!=1)throw conflict("计划已被并发修改");}
private Plan replay(String operation, long planId, String action, Long itemId, String requestHash) {
LoadingPlanHistoryEntity history = repository.operation(operation);
if (history == null) return null;
if (!Objects.equals(history.getLoadingPlanId(), planId)
|| !Objects.equals(history.getActionType(), action)
|| itemId != null && !Objects.equals(history.getLoadingPlanItemId(), itemId)
|| history.getAfterSnapshot() == null
|| !history.getAfterSnapshot().contains("\"requestHash\":\"" + requestHash + "\"")) {
throw conflict("operationId 已用于不同请求");
}
LoadingPlanEntity plan = repository.plan(planId);
if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在");
return view(plan, requireBooking(plan.getContainerBookingId()));
}
private void history(long planId, Long itemId, String action, String from, String to,
String operation, long operatorId, String operatorName) {
history(planId, itemId, action, from, to, operation, null, null, operatorId, operatorName);
}
private void history(long planId, Long itemId, String action, String from, String to,
String operation, String before, String after,
long operatorId, String operatorName) {
LoadingPlanHistoryEntity history = new LoadingPlanHistoryEntity();
history.setLoadingPlanId(planId); history.setLoadingPlanItemId(itemId);
history.setActionType(action); history.setFromStatus(from); history.setToStatus(to);
history.setOperationId(operation); history.setBeforeSnapshot(before); history.setAfterSnapshot(after);
history.setOperatorId(operatorId); history.setOperatorName(operatorName);
history.setCreatedAt(LocalDateTime.now()); repository.insert(history);
}
private static String operation(String value) {
String normalized = trim(value);
if (!StringUtils.hasText(normalized)) throw new BizException(ResultCode.BAD_REQUEST, "必须提供 operationId");
if (normalized.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "operationId 长度不能超过128");
return normalized;
}
private String json(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException exception) {
throw new IllegalStateException("无法生成智能装柜审计快照", exception);
}
}
private static Map<String, Object> values(Object... values) {
Map<String, Object> result = new LinkedHashMap<>();
for (int index = 0; index < values.length; index += 2) {
result.put((String) values[index], values[index + 1]);
}
return result;
}
private static String sha256(String value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException("JVM 不支持 SHA-256", impossible);
}
}
private static BizException unavailable() {
return new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单货物服务暂不可用");
}
private static String trim(String x){return x==null?null:x.trim();} private static BigDecimal unit(BigDecimal x){return x==null?BigDecimal.ZERO:x.max(BigDecimal.ZERO);}
private static void requireAccount(long x){if(x<=0)throw new BizException(ResultCode.BAD_REQUEST,"accountId必须大于零");} private static BizException conflict(String x){return new BizException(ResultCode.CONFLICT,x);} private static BizException validation(String x){return new BizException(ResultCode.VALIDATION_FAILED,x);} private static BizException forbidden(){return new BizException(ResultCode.FORBIDDEN,"客户归属不匹配");}
}
@@ -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,539 @@
SET NAMES utf8mb4;
USE baiyue_firstleg;
/*
* 三期主子入仓单结构迁移。
* 仅建立结构和数据库保护,不自动拆分或回填存量业务数据。
* tenant_id 继续表示客户隔离键;数量统一使用 package_count。
*/
DROP PROCEDURE IF EXISTS phase3_inbound_add_column;
DELIMITER $$
CREATE PROCEDURE phase3_inbound_add_column(
IN p_table_name VARCHAR(64),
IN p_column_name VARCHAR(64),
IN p_definition TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = p_table_name
AND column_name = p_column_name
) THEN
SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD COLUMN `',
p_column_name, '` ', p_definition);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END IF;
END$$
DELIMITER ;
CALL phase3_inbound_add_column(
'fl_inbound_order', 'inbound_role',
"VARCHAR(16) NOT NULL DEFAULT 'MASTER' COMMENT '入仓单层级:MASTER主单、CHILD子单' AFTER inbound_code");
CALL phase3_inbound_add_column(
'fl_inbound_order', 'parent_inbound_id',
"BIGINT UNSIGNED NULL COMMENT '直属主入仓单ID;主单为空' AFTER inbound_role");
CALL phase3_inbound_add_column(
'fl_inbound_order', 'root_inbound_id',
"BIGINT UNSIGNED NULL COMMENT '根主入仓单ID;存量主单可为空,按自身ID解释' AFTER parent_inbound_id");
CALL phase3_inbound_add_column(
'fl_inbound_order', 'split_sequence',
"INT UNSIGNED NULL COMMENT '同一主单下的子单顺序号' AFTER root_inbound_id");
CALL phase3_inbound_add_column(
'fl_inbound_order', 'split_status',
"VARCHAR(24) NOT NULL DEFAULT 'NOT_SPLIT' COMMENT '拆分状态:NOT_SPLIT、PARTIALLY_SPLIT、FULLY_SPLIT' AFTER split_sequence");
CALL phase3_inbound_add_column(
'fl_inbound_order', 'package_count',
"INT UNSIGNED NULL COMMENT '入仓单箱数;三期唯一数量口径' AFTER total_package_count");
CALL phase3_inbound_add_column(
'fl_inbound_order', 'master_order_id',
"BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN inbound_role = 'MASTER' AND deleted = 0 THEN order_id ELSE NULL END) STORED COMMENT '仅用于约束有效主单订单唯一性'");
DROP PROCEDURE IF EXISTS phase3_inbound_add_column;
DROP PROCEDURE IF EXISTS phase3_inbound_add_index;
DELIMITER $$
CREATE PROCEDURE phase3_inbound_add_index(
IN p_table_name VARCHAR(64),
IN p_index_name VARCHAR(64),
IN p_definition TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = p_table_name
AND index_name = p_index_name
) THEN
SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD ', p_definition);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END IF;
END$$
DELIMITER ;
CALL phase3_inbound_add_index(
'fl_inbound_order', 'uk_inbound_master_order',
'UNIQUE KEY `uk_inbound_master_order` (`tenant_id`, `master_order_id`)');
CALL phase3_inbound_add_index(
'fl_inbound_order', 'uk_inbound_parent_sequence',
'UNIQUE KEY `uk_inbound_parent_sequence` (`tenant_id`, `parent_inbound_id`, `split_sequence`)');
CALL phase3_inbound_add_index(
'fl_inbound_order', 'idx_inbound_root_role',
'KEY `idx_inbound_root_role` (`tenant_id`, `root_inbound_id`, `inbound_role`, `created_at`, `id`)');
DROP PROCEDURE IF EXISTS phase3_inbound_drop_legacy_index;
DELIMITER $$
CREATE PROCEDURE phase3_inbound_drop_legacy_index()
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'fl_inbound_order'
AND index_name = 'uk_inbound_tenant_order'
) THEN
ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_tenant_order;
END IF;
END$$
DELIMITER ;
CALL phase3_inbound_drop_legacy_index();
DROP PROCEDURE IF EXISTS phase3_inbound_drop_legacy_index;
DROP PROCEDURE IF EXISTS phase3_inbound_add_index;
CREATE TABLE IF NOT EXISTS fl_inbound_split_batch (
id BIGINT UNSIGNED NOT NULL COMMENT '拆分批次ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
parent_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '主入仓单ID',
operation_id VARCHAR(64) NOT NULL COMMENT '业务操作幂等号',
idempotency_key VARCHAR(128) NOT NULL COMMENT '请求幂等键',
expected_parent_version INT NOT NULL COMMENT '操作时主单乐观锁版本',
package_count INT UNSIGNED NOT NULL COMMENT '本批次拆分箱数',
status VARCHAR(16) NOT NULL DEFAULT 'PREPARED' COMMENT 'PREPARED、APPLIED、CANCELLED',
remark VARCHAR(500) NULL COMMENT '拆分原因或备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
applied_at DATETIME(3) NULL COMMENT '批次生效时间',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
PRIMARY KEY (id),
UNIQUE KEY uk_inbound_split_operation (tenant_id, operation_id),
UNIQUE KEY uk_inbound_split_idempotency (tenant_id, parent_inbound_id, idempotency_key),
KEY idx_inbound_split_parent (tenant_id, parent_inbound_id, created_at, id),
CONSTRAINT chk_inbound_split_package CHECK (package_count > 0),
CONSTRAINT chk_inbound_split_status CHECK (status IN ('PREPARED', 'APPLIED', 'CANCELLED')),
CONSTRAINT fk_inbound_split_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='主入仓单拆分批次;数量口径仅为箱数';
CREATE TABLE IF NOT EXISTS fl_inbound_split_item (
id BIGINT UNSIGNED NOT NULL COMMENT '拆分明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
split_batch_id BIGINT UNSIGNED NOT NULL COMMENT '拆分批次ID',
parent_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '主入仓单ID',
child_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '子入仓单ID',
source_inbound_detail_id BIGINT UNSIGNED NULL COMMENT '订单货物明细逻辑ID',
source_measurement_id BIGINT UNSIGNED NULL COMMENT '来源测量批次ID',
source_measurement_item_id BIGINT UNSIGNED NULL COMMENT '来源测量箱规明细ID',
package_count INT UNSIGNED NOT NULL COMMENT '分配给子单的箱数',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_inbound_split_child (tenant_id, child_inbound_id),
UNIQUE KEY uk_inbound_split_batch_child (tenant_id, split_batch_id, child_inbound_id),
KEY idx_inbound_split_detail (tenant_id, source_inbound_detail_id, created_at),
CONSTRAINT chk_inbound_split_item_package CHECK (package_count > 0),
CONSTRAINT fk_inbound_split_item_batch FOREIGN KEY (split_batch_id) REFERENCES fl_inbound_split_batch (id),
CONSTRAINT fk_inbound_split_item_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id),
CONSTRAINT fk_inbound_split_item_child FOREIGN KEY (child_inbound_id) REFERENCES fl_inbound_order (id),
CONSTRAINT fk_inbound_split_item_measurement FOREIGN KEY (source_measurement_id) REFERENCES fl_inbound_measurement (id),
CONSTRAINT fk_inbound_split_item_measure_item FOREIGN KEY (source_measurement_item_id) REFERENCES fl_inbound_measurement_item (id)
) ENGINE=InnoDB COMMENT='主子入仓单拆分数量台账';
CREATE TABLE IF NOT EXISTS fl_inbound_split_history (
id BIGINT UNSIGNED NOT NULL COMMENT '拆分历史ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
split_batch_id BIGINT UNSIGNED NOT NULL COMMENT '拆分批次ID',
operation_id VARCHAR(64) NOT NULL COMMENT '历史操作幂等号',
action_type VARCHAR(24) NOT NULL COMMENT 'CREATE、APPLY、CANCEL',
before_snapshot JSON NULL COMMENT '操作前快照',
after_snapshot JSON NOT NULL COMMENT '操作后快照',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
operation_reason VARCHAR(500) NULL COMMENT '操作原因',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_inbound_split_history_operation (tenant_id, operation_id),
KEY idx_inbound_split_history (tenant_id, split_batch_id, created_at, id),
CONSTRAINT chk_inbound_split_action CHECK (action_type IN ('CREATE', 'APPLY', 'CANCEL')),
CONSTRAINT fk_inbound_split_history_batch FOREIGN KEY (split_batch_id) REFERENCES fl_inbound_split_batch (id)
) ENGINE=InnoDB COMMENT='不可变主子入仓单拆分审计历史';
DROP PROCEDURE IF EXISTS phase3_inbound_add_column;
DELIMITER $$
CREATE PROCEDURE phase3_inbound_add_column(
IN p_table_name VARCHAR(64),
IN p_column_name VARCHAR(64),
IN p_definition TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = p_table_name
AND column_name = p_column_name
) THEN
SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD COLUMN `',
p_column_name, '` ', p_definition);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END IF;
END$$
DELIMITER ;
CALL phase3_inbound_add_column(
'fl_inbound_measurement', 'split_batch_id',
"BIGINT UNSIGNED NULL COMMENT '产生该测量批次的入仓拆分批次ID' AFTER inbound_id");
CALL phase3_inbound_add_column(
'fl_inbound_measurement', 'source_measurement_id',
"BIGINT UNSIGNED NULL COMMENT '子单继承或复核时的来源测量批次ID' AFTER split_batch_id");
CALL phase3_inbound_add_column(
'fl_inbound_measurement_item', 'split_item_id',
"BIGINT UNSIGNED NULL COMMENT '产生该子单测量明细的拆分台账ID' AFTER measurement_id");
CALL phase3_inbound_add_column(
'fl_inbound_measurement_item', 'source_measurement_item_id',
"BIGINT UNSIGNED NULL COMMENT '被拆分继承的来源测量箱规明细ID' AFTER split_item_id");
DROP PROCEDURE IF EXISTS phase3_inbound_add_column;
DROP PROCEDURE IF EXISTS phase3_inbound_add_relation;
DELIMITER $$
CREATE PROCEDURE phase3_inbound_add_relation(
IN p_table_name VARCHAR(64),
IN p_constraint_name VARCHAR(64),
IN p_definition TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_schema = DATABASE()
AND table_name = p_table_name
AND constraint_name = p_constraint_name
) THEN
SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD CONSTRAINT `',
p_constraint_name, '` ', p_definition);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END IF;
END$$
DELIMITER ;
CALL phase3_inbound_add_relation(
'fl_inbound_order', 'fk_inbound_parent_order',
'FOREIGN KEY (`parent_inbound_id`) REFERENCES `fl_inbound_order` (`id`)');
CALL phase3_inbound_add_relation(
'fl_inbound_order', 'fk_inbound_root_order',
'FOREIGN KEY (`root_inbound_id`) REFERENCES `fl_inbound_order` (`id`)');
CALL phase3_inbound_add_relation(
'fl_inbound_measurement', 'fk_measure_split_batch',
'FOREIGN KEY (`split_batch_id`) REFERENCES `fl_inbound_split_batch` (`id`)');
CALL phase3_inbound_add_relation(
'fl_inbound_measurement', 'fk_measure_source_batch',
'FOREIGN KEY (`source_measurement_id`) REFERENCES `fl_inbound_measurement` (`id`)');
CALL phase3_inbound_add_relation(
'fl_inbound_measurement_item', 'fk_measure_item_split_item',
'FOREIGN KEY (`split_item_id`) REFERENCES `fl_inbound_split_item` (`id`)');
CALL phase3_inbound_add_relation(
'fl_inbound_measurement_item', 'fk_measure_item_source_item',
'FOREIGN KEY (`source_measurement_item_id`) REFERENCES `fl_inbound_measurement_item` (`id`)');
DROP PROCEDURE IF EXISTS phase3_inbound_add_relation;
DROP PROCEDURE IF EXISTS phase3_inbound_add_index;
DELIMITER $$
CREATE PROCEDURE phase3_inbound_add_index(
IN p_table_name VARCHAR(64),
IN p_index_name VARCHAR(64),
IN p_definition TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = p_table_name
AND index_name = p_index_name
) THEN
SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD ', p_definition);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END IF;
END$$
DELIMITER ;
CALL phase3_inbound_add_index(
'fl_inbound_measurement', 'idx_measure_split_source',
'KEY `idx_measure_split_source` (`tenant_id`, `split_batch_id`, `source_measurement_id`, `created_at`)');
CALL phase3_inbound_add_index(
'fl_inbound_measurement_item', 'idx_measure_item_split_source',
'KEY `idx_measure_item_split_source` (`tenant_id`, `split_item_id`, `source_measurement_item_id`)');
DROP PROCEDURE IF EXISTS phase3_inbound_add_index;
DROP TRIGGER IF EXISTS trg_inbound_order_parent_before_insert;
DROP TRIGGER IF EXISTS trg_inbound_order_parent_before_update;
DROP TRIGGER IF EXISTS trg_inbound_split_item_before_insert;
DROP TRIGGER IF EXISTS trg_inbound_split_batch_before_insert;
DROP TRIGGER IF EXISTS trg_inbound_split_batch_before_update;
DROP TRIGGER IF EXISTS trg_inbound_split_item_before_update;
DROP TRIGGER IF EXISTS trg_inbound_split_item_before_delete;
DROP TRIGGER IF EXISTS trg_inbound_split_history_before_update;
DROP TRIGGER IF EXISTS trg_inbound_split_history_before_delete;
DELIMITER $$
CREATE TRIGGER trg_inbound_order_parent_before_insert
BEFORE INSERT ON fl_inbound_order
FOR EACH ROW
BEGIN
IF NEW.inbound_role NOT IN ('MASTER', 'CHILD') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单层级非法';
END IF;
IF NEW.split_status NOT IN ('NOT_SPLIT', 'PARTIALLY_SPLIT', 'FULLY_SPLIT') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单拆分状态非法';
END IF;
IF NEW.package_count IS NOT NULL AND NEW.package_count = 0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单箱数必须大于0';
END IF;
IF NEW.inbound_role = 'MASTER' AND NEW.parent_inbound_id IS NOT NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主入仓单不能绑定父单';
END IF;
IF NEW.inbound_role = 'CHILD' THEN
IF NEW.parent_inbound_id IS NULL OR NEW.root_inbound_id IS NULL
OR NEW.split_sequence IS NULL OR NEW.package_count IS NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单缺少主单、顺序号或箱数';
END IF;
IF NEW.split_status <> 'NOT_SPLIT' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子仅两级,子入仓单不可继续拆分';
END IF;
IF NOT EXISTS (
SELECT 1 FROM fl_inbound_order parent_order
WHERE parent_order.id = NEW.parent_inbound_id
AND parent_order.tenant_id = NEW.tenant_id
AND parent_order.inbound_role = 'MASTER'
AND parent_order.deleted = 0
) OR NEW.root_inbound_id <> NEW.parent_inbound_id THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单必须绑定同一客户的有效主单';
END IF;
END IF;
END$$
CREATE TRIGGER trg_inbound_order_parent_before_update
BEFORE UPDATE ON fl_inbound_order
FOR EACH ROW
BEGIN
IF NOT (OLD.inbound_role <=> NEW.inbound_role)
OR NOT (OLD.parent_inbound_id <=> NEW.parent_inbound_id)
OR NOT (OLD.root_inbound_id <=> NEW.root_inbound_id)
OR NOT (OLD.split_sequence <=> NEW.split_sequence) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单主子归属创建后不可修改';
END IF;
IF NEW.inbound_role = 'CHILD'
AND (NEW.package_count IS NULL OR NEW.package_count = 0) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单箱数必须大于0';
END IF;
IF NEW.split_status NOT IN ('NOT_SPLIT', 'PARTIALLY_SPLIT', 'FULLY_SPLIT') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单拆分状态非法';
END IF;
IF NEW.inbound_role = 'CHILD' AND NEW.split_status <> 'NOT_SPLIT' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子仅两级,子入仓单不可继续拆分';
END IF;
IF NEW.inbound_role = 'MASTER'
AND ((NEW.package_count = 0 AND NEW.split_status <> 'FULLY_SPLIT')
OR (NEW.split_status = 'FULLY_SPLIT' AND COALESCE(NEW.package_count, 0) <> 0)) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主单箱数归零必须与完全拆分状态同时提交';
END IF;
END$$
CREATE TRIGGER trg_inbound_split_item_before_insert
BEFORE INSERT ON fl_inbound_split_item
FOR EACH ROW
BEGIN
IF NOT EXISTS (
SELECT 1
FROM fl_inbound_split_batch batch
JOIN fl_inbound_order parent_order ON parent_order.id = batch.parent_inbound_id
JOIN fl_inbound_order child_order ON child_order.id = NEW.child_inbound_id
WHERE batch.id = NEW.split_batch_id
AND batch.tenant_id = NEW.tenant_id
AND batch.parent_inbound_id = NEW.parent_inbound_id
AND batch.status = 'PREPARED'
AND parent_order.tenant_id = NEW.tenant_id
AND parent_order.inbound_role = 'MASTER'
AND child_order.tenant_id = NEW.tenant_id
AND child_order.inbound_role = 'CHILD'
AND child_order.parent_inbound_id = NEW.parent_inbound_id
AND child_order.package_count = NEW.package_count
AND child_order.deleted = 0
AND (NEW.source_measurement_item_id IS NULL OR EXISTS (
SELECT 1
FROM fl_inbound_measurement_item source_item
JOIN fl_inbound_measurement source_batch ON source_batch.id = source_item.measurement_id
WHERE source_item.id = NEW.source_measurement_item_id
AND source_item.tenant_id = NEW.tenant_id
AND source_item.measurement_id = NEW.source_measurement_id
AND source_batch.inbound_id = NEW.parent_inbound_id
))
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分明细与批次、主单或子单不一致';
END IF;
END$$
CREATE TRIGGER trg_inbound_split_batch_before_insert
BEFORE INSERT ON fl_inbound_split_batch
FOR EACH ROW
BEGIN
IF NEW.status <> 'PREPARED' OR NEW.applied_at IS NOT NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次必须从PREPARED创建';
END IF;
IF NOT EXISTS (
SELECT 1 FROM fl_inbound_order parent_order
WHERE parent_order.id = NEW.parent_inbound_id
AND parent_order.tenant_id = NEW.tenant_id
AND parent_order.inbound_role = 'MASTER'
AND parent_order.deleted = 0
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次主单不存在或客户归属不一致';
END IF;
END$$
CREATE TRIGGER trg_inbound_split_batch_before_update
BEFORE UPDATE ON fl_inbound_split_batch
FOR EACH ROW
BEGIN
IF OLD.status IN ('APPLIED', 'CANCELLED') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已结束拆分批次不可修改';
END IF;
IF NEW.status = 'APPLIED' THEN
IF NEW.applied_at IS NULL
OR NOT EXISTS (
SELECT 1 FROM fl_inbound_order parent_order
WHERE parent_order.id = NEW.parent_inbound_id
AND parent_order.tenant_id = NEW.tenant_id
AND parent_order.inbound_role = 'MASTER'
AND parent_order.package_count = 0
AND parent_order.split_status = 'FULLY_SPLIT'
AND parent_order.version > NEW.expected_parent_version
) OR
(SELECT COALESCE(SUM(item.package_count), 0)
FROM fl_inbound_split_item item
WHERE item.tenant_id = NEW.tenant_id
AND item.split_batch_id = NEW.id) <> NEW.package_count THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主单可用箱数未归零或子单箱数合计不一致';
END IF;
IF EXISTS (
SELECT 1
FROM fl_inbound_split_item split_item
JOIN fl_inbound_measurement_item source_item
ON source_item.id = split_item.source_measurement_item_id
WHERE split_item.tenant_id = NEW.tenant_id
AND split_item.split_batch_id = NEW.id
GROUP BY split_item.source_measurement_item_id, source_item.package_count
HAVING SUM(split_item.package_count) > source_item.package_count
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子单承接箱数超过来源测量箱规箱数';
END IF;
ELSEIF NEW.status = 'CANCELLED' THEN
IF EXISTS (
SELECT 1 FROM fl_inbound_split_item item
WHERE item.tenant_id = NEW.tenant_id
AND item.split_batch_id = NEW.id
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有子单明细的拆分批次不可取消';
END IF;
ELSEIF NEW.status <> 'PREPARED' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次状态流转非法';
END IF;
END$$
CREATE TRIGGER trg_inbound_split_item_before_update
BEFORE UPDATE ON fl_inbound_split_item
FOR EACH ROW
BEGIN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分数量台账不可修改';
END$$
CREATE TRIGGER trg_inbound_split_item_before_delete
BEFORE DELETE ON fl_inbound_split_item
FOR EACH ROW
BEGIN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分数量台账不可删除';
END$$
CREATE TRIGGER trg_inbound_split_history_before_update
BEFORE UPDATE ON fl_inbound_split_history
FOR EACH ROW
BEGIN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分历史不可修改';
END$$
CREATE TRIGGER trg_inbound_split_history_before_delete
BEFORE DELETE ON fl_inbound_split_history
FOR EACH ROW
BEGIN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分历史不可删除';
END$$
DELIMITER ;
DROP PROCEDURE IF EXISTS phase3_assert_inbound_parent_child_schema;
DELIMITER $$
CREATE PROCEDURE phase3_assert_inbound_parent_child_schema()
BEGIN
IF (SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name IN ('fl_inbound_split_batch', 'fl_inbound_split_item',
'fl_inbound_split_history')) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单拆分台账未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'fl_inbound_order'
AND column_name IN ('inbound_role', 'parent_inbound_id', 'root_inbound_id',
'split_sequence', 'split_status', 'package_count',
'master_order_id')) <> 7 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单关系字段未完整创建';
END IF;
IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND index_name IN ('uk_inbound_master_order', 'uk_inbound_parent_sequence',
'uk_inbound_split_operation', 'uk_inbound_split_idempotency',
'uk_inbound_split_child', 'uk_inbound_split_history_operation')) <> 6 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单唯一约束未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.table_constraints
WHERE constraint_schema = DATABASE()
AND constraint_name IN ('fk_inbound_parent_order', 'fk_inbound_root_order',
'fk_measure_split_batch', 'fk_measure_source_batch',
'fk_measure_item_split_item', 'fk_measure_item_source_item')) <> 6 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单与测量关联约束未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.triggers
WHERE trigger_schema = DATABASE()
AND trigger_name IN ('trg_inbound_order_parent_before_insert',
'trg_inbound_order_parent_before_update',
'trg_inbound_split_item_before_insert',
'trg_inbound_split_batch_before_insert',
'trg_inbound_split_batch_before_update',
'trg_inbound_split_item_before_update',
'trg_inbound_split_item_before_delete',
'trg_inbound_split_history_before_update',
'trg_inbound_split_history_before_delete')) <> 9 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单数据库保护触发器未完整创建';
END IF;
IF EXISTS (
SELECT 1 FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'fl_inbound_order'
AND index_name = 'uk_inbound_tenant_order'
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '旧订单唯一索引仍阻止创建子入仓单';
END IF;
END$$
DELIMITER ;
CALL phase3_assert_inbound_parent_child_schema();
DROP PROCEDURE IF EXISTS phase3_assert_inbound_parent_child_schema;
@@ -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,776 @@
SET NAMES utf8mb4;
CREATE DATABASE IF NOT EXISTS baiyue_firstleg
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_container_booking (
id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)',
booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号',
container_no VARCHAR(64) NULL COMMENT '集装箱号;确认前允许修订',
cabinet_no VARCHAR(64) NOT NULL COMMENT '平台唯一内部柜号',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '装柜仓库ID;平台公共资源',
warehouse_name VARCHAR(128) NOT NULL COMMENT '装柜仓库名称快照',
bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号',
vessel_name VARCHAR(128) NULL COMMENT '船名快照',
voyage_no VARCHAR(64) NULL COMMENT '航次;确认前允许修订',
container_type VARCHAR(32) NOT NULL COMMENT '箱型,例如20GP、40GP、40HQ',
shipping_company_id BIGINT UNSIGNED NULL COMMENT '船司ID快照',
shipping_company_name VARCHAR(128) NULL COMMENT '船司名称快照',
departure_port_code VARCHAR(32) NULL COMMENT '起运港编码快照',
departure_port_name VARCHAR(128) NULL COMMENT '起运港名称快照',
destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照',
destination_port_name VARCHAR(128) NULL COMMENT '目的港名称快照',
destination_warehouse_code VARCHAR(64) NULL COMMENT '目的仓编码快照',
destination_warehouse_name VARCHAR(128) NULL COMMENT '目的仓名称快照',
capacity_cbm DECIMAL(18,4) NULL COMMENT '货柜容量m3',
max_weight_kg DECIMAL(18,3) NULL COMMENT '货柜最大承重kg',
terminal_trucking_company VARCHAR(128) NULL COMMENT '码头拖车公司',
loading_at DATETIME(3) NULL COMMENT '计划或实际装柜时间',
estimated_sailing_at DATETIME(3) NULL COMMENT '预计开船时间',
actual_sailing_at DATETIME(3) NULL COMMENT '实际开船时间',
estimated_port_arrival_at DATETIME(3) NULL COMMENT '预计到港时间',
actual_port_arrival_at DATETIME(3) NULL COMMENT '实际到港时间',
estimated_warehouse_arrival_at DATETIME(3) NULL COMMENT '预计到仓时间',
actual_warehouse_arrival_at DATETIME(3) NULL COMMENT '实际到仓时间',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
COMMENT '更新时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_container_booking_no (booking_no),
UNIQUE KEY uk_container_booking_cabinet (cabinet_no),
KEY idx_container_booking_container (container_no),
KEY idx_container_booking_warehouse (warehouse_id, updated_at, id),
KEY idx_container_booking_voyage (voyage_no, updated_at),
CONSTRAINT chk_container_booking_capacity CHECK (capacity_cbm IS NULL OR capacity_cbm > 0),
CONSTRAINT chk_container_booking_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0)
) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划';
CREATE TABLE IF NOT EXISTS fl_loading_plan (
id BIGINT UNSIGNED NOT NULL COMMENT '智能装柜计划ID(雪花ID)',
plan_no VARCHAR(40) NOT NULL COMMENT '平台唯一装柜计划号',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID',
plan_status VARCHAR(24) NOT NULL DEFAULT 'EDITING'
COMMENT '唯一装柜状态:EDITING、LOADING、COMPLETED',
push_idempotency_key VARCHAR(128) NULL COMMENT '推送预装幂等键',
preload_pushed_by BIGINT UNSIGNED NULL COMMENT '推送预装操作人ID',
preload_pushed_by_name VARCHAR(64) NULL COMMENT '推送预装操作人名称快照',
preload_pushed_at DATETIME(3) NULL COMMENT '开始装柜并进入LOADING的时间',
preload_completed_by BIGINT UNSIGNED NULL COMMENT '预装完成操作人ID',
preload_completed_by_name VARCHAR(64) NULL COMMENT '预装完成操作人名称快照',
preload_completed_at DATETIME(3) NULL COMMENT '预装完成时间',
confirmed_by BIGINT UNSIGNED NULL COMMENT '确认操作人ID',
confirmed_by_name VARCHAR(64) NULL COMMENT '确认操作人名称快照',
confirmed_at DATETIME(3) NULL COMMENT '确认时间',
remark VARCHAR(500) NULL COMMENT '平台装柜计划备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
COMMENT '更新时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_plan_no (plan_no),
UNIQUE KEY uk_loading_plan_booking (container_booking_id),
UNIQUE KEY uk_loading_plan_push_key (push_idempotency_key),
KEY idx_loading_plan_status (plan_status, updated_at, id),
CONSTRAINT fk_loading_plan_booking FOREIGN KEY (container_booking_id)
REFERENCES fl_container_booking (id),
CONSTRAINT chk_loading_plan_status
CHECK (plan_status IN ('EDITING', 'LOADING', 'COMPLETED'))
) ENGINE=InnoDB COMMENT='平台智能装柜计划与唯一预装状态源';
CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划明细ID(雪花ID)',
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '智能装柜计划ID',
account_id BIGINT UNSIGNED NOT NULL COMMENT '客户账号ID;装柜业务唯一客户归属字段',
customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT 'Firstleg入库单ID',
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物或入库货物明细ID,不建立跨库外键',
order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务订单ID快照,不建立跨库外键',
order_no VARCHAR(32) NOT NULL COMMENT '订单号快照',
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
destination_warehouse_id BIGINT UNSIGNED NULL COMMENT '目的仓ID快照',
destination_warehouse_code VARCHAR(64) NULL COMMENT '目的仓编码快照',
destination_warehouse_name VARCHAR(128) NULL COMMENT '目的仓名称快照',
sku_code VARCHAR(128) NULL COMMENT 'SKU编码快照',
product_name_zh VARCHAR(255) NULL COMMENT '中文品名快照',
product_name_en VARCHAR(255) NULL COMMENT '英文品名快照',
product_image_file_id BIGINT UNSIGNED NULL COMMENT '产品图片File对象ID',
product_image_url VARCHAR(1000) NULL COMMENT '产品图片访问地址快照',
package_count INT UNSIGNED NOT NULL COMMENT '本次装柜箱数',
product_quantity INT UNSIGNED NULL COMMENT '产品总数量',
units_per_carton INT UNSIGNED NULL COMMENT '每箱产品数',
pallet_count INT UNSIGNED NULL COMMENT '托盘数',
gross_weight_kg DECIMAL(18,3) NULL COMMENT '本次装柜毛重kg',
net_weight_kg DECIMAL(18,3) NULL COMMENT '本次装柜净重kg',
dimensional_weight_kg DECIMAL(18,3) NULL COMMENT '本次装柜材积重kg',
volume_cbm DECIMAL(18,4) NULL COMMENT '本次装柜体积m3',
fba_tracking_no VARCHAR(128) NULL COMMENT 'FBA追踪号快照',
shipment_no VARCHAR(128) NULL COMMENT '货件编号快照',
shipping_mark VARCHAR(500) NULL COMMENT '唛头快照',
load_sequence INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '装柜顺序',
position_snapshot JSON NULL COMMENT '智能装柜位置与摆放结果快照',
internal_remark VARCHAR(1000) NULL COMMENT '平台内部备注',
customer_remark VARCHAR(1000) NULL COMMENT '客户备注快照',
warehouse_remark VARCHAR(1000) NULL COMMENT '仓库备注快照',
frozen_at DATETIME(3) NULL COMMENT '装柜完成后的永久冻结时间',
frozen_by BIGINT UNSIGNED NULL COMMENT '冻结操作人ID',
removed_by BIGINT UNSIGNED NULL COMMENT '推送前逻辑删除操作人ID',
removed_by_name VARCHAR(64) NULL COMMENT '推送前逻辑删除操作人名称快照',
removed_at DATETIME(3) NULL COMMENT '推送前逻辑删除时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '推送前逻辑删除标记;冻结后不可修改',
active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS (
CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END
) STORED COMMENT '未删除明细唯一锁;删除后返回NULL释放分配资格',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
COMMENT '更新时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_item_plan_detail (loading_plan_id, active_inbound_detail_id),
KEY idx_loading_item_active_detail (account_id, active_inbound_detail_id),
KEY idx_loading_item_plan (loading_plan_id, deleted, load_sequence, id),
KEY idx_loading_item_account (account_id, created_at, id),
KEY idx_loading_item_inbound (account_id, inbound_id, deleted),
KEY idx_loading_item_order (account_id, order_id, deleted),
CONSTRAINT fk_loading_item_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id),
CONSTRAINT chk_loading_item_account CHECK (account_id > 0),
CONSTRAINT chk_loading_item_package CHECK (package_count > 0),
CONSTRAINT chk_loading_item_product_quantity CHECK (product_quantity IS NULL OR product_quantity > 0),
CONSTRAINT chk_loading_item_units CHECK (units_per_carton IS NULL OR units_per_carton > 0),
CONSTRAINT chk_loading_item_pallet CHECK (pallet_count IS NULL OR pallet_count >= 0),
CONSTRAINT chk_loading_item_weight CHECK (
(gross_weight_kg IS NULL OR gross_weight_kg >= 0)
AND (net_weight_kg IS NULL OR net_weight_kg >= 0)
AND (dimensional_weight_kg IS NULL OR dimensional_weight_kg >= 0)
),
CONSTRAINT chk_loading_item_volume CHECK (volume_cbm IS NULL OR volume_cbm >= 0),
CONSTRAINT chk_loading_item_deleted CHECK (deleted IN (0, 1))
) ENGINE=InnoDB COMMENT='智能装柜客户货物明细;客户归属仅在明细层';
CREATE TABLE IF NOT EXISTS fl_container_document (
id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID',
document_type VARCHAR(32) NOT NULL COMMENT 'CUSTOMS_CLEARANCE、DELIVERY_ORDER、PICKUP_ORDER',
file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID,不建立跨库外键',
file_name VARCHAR(255) NOT NULL COMMENT '文件名称快照',
file_url VARCHAR(1000) NULL COMMENT '文件访问地址快照',
uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID',
uploaded_by_name VARCHAR(64) NULL COMMENT '上传操作人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
COMMENT '更新时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_container_document_type (container_booking_id, document_type),
KEY idx_container_document_file (file_id),
CONSTRAINT fk_container_document_booking FOREIGN KEY (container_booking_id)
REFERENCES fl_container_booking (id),
CONSTRAINT chk_container_document_type
CHECK (document_type IN ('CUSTOMS_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER'))
) ENGINE=InnoDB COMMENT='平台货柜清关、DO与PU文件关联';
CREATE TABLE IF NOT EXISTS fl_loading_plan_history (
id BIGINT UNSIGNED NOT NULL COMMENT '装柜操作历史ID(雪花ID)',
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '智能装柜计划ID',
loading_plan_item_id BIGINT UNSIGNED NULL COMMENT '装柜计划明细ID',
operation_id VARCHAR(128) NOT NULL COMMENT '操作幂等ID',
action_type VARCHAR(32) NOT NULL
COMMENT 'CREATE_PLAN、ADD_ITEM、ADJUST_ITEM、REMOVE_ITEM、START_LOADING、COMPLETE_LOADING、PICK_ITEM',
from_status VARCHAR(24) NULL COMMENT '变更前状态',
to_status VARCHAR(24) NULL COMMENT '变更后状态',
before_snapshot JSON NULL COMMENT '变更前快照',
after_snapshot JSON NULL COMMENT '变更后快照',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
operation_reason VARCHAR(500) NULL COMMENT '操作原因',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_history_operation (operation_id),
KEY idx_loading_history_plan (loading_plan_id, created_at, id),
KEY idx_loading_history_item (loading_plan_item_id, created_at, id),
CONSTRAINT fk_loading_history_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id),
CONSTRAINT fk_loading_history_item FOREIGN KEY (loading_plan_item_id)
REFERENCES fl_loading_plan_item (id)
) ENGINE=InnoDB COMMENT='智能装柜追加式不可变操作历史';
CREATE TABLE IF NOT EXISTS fl_loading_pick_task (
id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID',
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID',
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号快照',
task_status VARCHAR(16) NOT NULL DEFAULT 'LOADING' COMMENT 'LOADING、COMPLETED',
started_by BIGINT UNSIGNED NOT NULL COMMENT '开始装柜人',
started_by_name VARCHAR(64) NULL COMMENT '开始装柜人名称',
started_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '开始时间',
completed_by BIGINT UNSIGNED NULL COMMENT '完成装柜人',
completed_by_name VARCHAR(64) NULL COMMENT '完成装柜人名称',
completed_at DATETIME(3) NULL COMMENT '完成时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_pick_task_plan (loading_plan_id),
KEY idx_loading_pick_container (container_no, task_status, started_at),
CONSTRAINT fk_loading_pick_task_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id),
CONSTRAINT chk_loading_pick_task_status CHECK (task_status IN ('LOADING', 'COMPLETED'))
) ENGINE=InnoDB COMMENT='PDA装柜拣货任务';
CREATE TABLE IF NOT EXISTS fl_loading_pick_item (
id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID',
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID',
loading_plan_item_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划明细ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '父入库单ID',
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID',
planned_package_count INT UNSIGNED NOT NULL COMMENT '计划拣货箱数;上层拆分按业务规则向上取整',
picked_package_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已拣货箱数',
item_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、PICKING、PICKED',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
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_item_plan_item (loading_plan_item_id),
KEY idx_loading_pick_item_task (pick_task_id, item_status, id),
CONSTRAINT fk_loading_pick_item_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id),
CONSTRAINT fk_loading_pick_item_plan_item FOREIGN KEY (loading_plan_item_id) REFERENCES fl_loading_plan_item (id),
CONSTRAINT chk_loading_pick_item_planned CHECK (planned_package_count > 0),
CONSTRAINT chk_loading_pick_item_count CHECK (picked_package_count <= planned_package_count),
CONSTRAINT chk_loading_pick_item_status CHECK (item_status IN ('PENDING', 'PICKING', 'PICKED'))
) ENGINE=InnoDB COMMENT='PDA装柜拣货明细';
CREATE TABLE IF NOT EXISTS fl_loading_pick_history (
id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货操作历史ID',
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID',
pick_item_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID',
operation_id VARCHAR(128) NOT NULL COMMENT '客户端幂等操作号',
request_hash CHAR(64) NOT NULL COMMENT '请求指纹',
picked_package_count INT UNSIGNED NOT NULL COMMENT '本次拣货箱数',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人',
operator_name VARCHAR(64) NULL COMMENT '操作人名称',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_loading_pick_history_operation (operation_id),
KEY idx_loading_pick_history_item (pick_item_id, created_at),
CONSTRAINT fk_loading_pick_history_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id),
CONSTRAINT fk_loading_pick_history_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id)
) ENGINE=InnoDB COMMENT='PDA拣货幂等和操作审计历史';
DROP TRIGGER IF EXISTS trg_container_booking_before_update;
DELIMITER $$
CREATE TRIGGER trg_container_booking_before_update
BEFORE UPDATE ON fl_container_booking
FOR EACH ROW
BEGIN
DECLARE current_plan_status VARCHAR(24);
SELECT MAX(plan_status) INTO current_plan_status
FROM fl_loading_plan WHERE container_booking_id = OLD.id;
IF current_plan_status = 'COMPLETED' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读,不允许修改';
END IF;
IF current_plan_status = 'LOADING' AND (
NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no)
OR NOT (OLD.cabinet_no <=> NEW.cabinet_no)
OR NOT (OLD.warehouse_id <=> NEW.warehouse_id)
OR NOT (OLD.warehouse_name <=> NEW.warehouse_name)
OR NOT (OLD.bill_of_lading_no <=> NEW.bill_of_lading_no)
OR NOT (OLD.vessel_name <=> NEW.vessel_name)
OR NOT (OLD.container_type <=> NEW.container_type)
OR NOT (OLD.shipping_company_id <=> NEW.shipping_company_id)
OR NOT (OLD.shipping_company_name <=> NEW.shipping_company_name)
OR NOT (OLD.departure_port_code <=> NEW.departure_port_code)
OR NOT (OLD.departure_port_name <=> NEW.departure_port_name)
OR NOT (OLD.destination_port_code <=> NEW.destination_port_code)
OR NOT (OLD.destination_port_name <=> NEW.destination_port_name)
OR NOT (OLD.destination_warehouse_code <=> NEW.destination_warehouse_code)
OR NOT (OLD.destination_warehouse_name <=> NEW.destination_warehouse_name)
OR NOT (OLD.capacity_cbm <=> NEW.capacity_cbm)
OR NOT (OLD.max_weight_kg <=> NEW.max_weight_kg)
OR NOT (OLD.terminal_trucking_company <=> NEW.terminal_trucking_company)
OR NOT (OLD.loading_at <=> NEW.loading_at)
OR NOT (OLD.estimated_sailing_at <=> NEW.estimated_sailing_at)
OR NOT (OLD.actual_sailing_at <=> NEW.actual_sailing_at)
OR NOT (OLD.estimated_port_arrival_at <=> NEW.estimated_port_arrival_at)
OR NOT (OLD.actual_port_arrival_at <=> NEW.actual_port_arrival_at)
OR NOT (OLD.estimated_warehouse_arrival_at <=> NEW.estimated_warehouse_arrival_at)
OR NOT (OLD.actual_warehouse_arrival_at <=> NEW.actual_warehouse_arrival_at)
OR NOT (OLD.created_by <=> NEW.created_by)
OR NOT (OLD.created_by_name <=> NEW.created_by_name)
OR NOT (OLD.created_at <=> NEW.created_at)
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '装柜中仅允许修改集装箱号与航次';
END IF;
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_loading_plan_before_update;
DELIMITER $$
CREATE TRIGGER trg_loading_plan_before_update
BEFORE UPDATE ON fl_loading_plan
FOR EACH ROW
BEGIN
IF OLD.plan_status = 'COMPLETED' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已完成装柜计划全部只读';
END IF;
IF NOT (
(OLD.plan_status = 'EDITING' AND NEW.plan_status IN ('EDITING', 'LOADING'))
OR (OLD.plan_status = 'LOADING'
AND NEW.plan_status IN ('LOADING', 'COMPLETED'))
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜状态只允许单向推进';
END IF;
IF OLD.plan_status = 'LOADING' AND (
NOT (OLD.id <=> NEW.id) OR NOT (OLD.plan_no <=> NEW.plan_no)
OR NOT (OLD.container_booking_id <=> NEW.container_booking_id)
OR NOT (OLD.push_idempotency_key <=> NEW.push_idempotency_key)
OR NOT (OLD.preload_pushed_by <=> NEW.preload_pushed_by)
OR NOT (OLD.preload_pushed_by_name <=> NEW.preload_pushed_by_name)
OR NOT (OLD.preload_pushed_at <=> NEW.preload_pushed_at)
OR NOT (OLD.remark <=> NEW.remark)
OR NOT (OLD.created_by <=> NEW.created_by)
OR NOT (OLD.created_by_name <=> NEW.created_by_name)
OR NOT (OLD.created_at <=> NEW.created_at)
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '装柜中计划仅允许乐观锁触碰或完成装柜';
END IF;
IF OLD.plan_status = NEW.plan_status
AND OLD.plan_status = 'LOADING' AND (
NOT (OLD.preload_completed_by <=> NEW.preload_completed_by)
OR NOT (OLD.preload_completed_by_name <=> NEW.preload_completed_by_name)
OR NOT (OLD.preload_completed_at <=> NEW.preload_completed_at)
OR NOT (OLD.confirmed_by <=> NEW.confirmed_by)
OR NOT (OLD.confirmed_by_name <=> NEW.confirmed_by_name)
OR NOT (OLD.confirmed_at <=> NEW.confirmed_at)
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '同状态乐观锁触碰不得修改阶段审计字段';
END IF;
IF OLD.plan_status = 'EDITING' AND NEW.plan_status = 'LOADING' THEN
IF NEW.push_idempotency_key IS NULL OR CHAR_LENGTH(TRIM(NEW.push_idempotency_key)) = 0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '开始装柜必须提供幂等键';
END IF;
SET NEW.preload_pushed_at = COALESCE(NEW.preload_pushed_at, CURRENT_TIMESTAMP(3));
ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN
IF EXISTS (SELECT 1 FROM fl_loading_plan_item item
LEFT JOIN fl_loading_pick_item pick_item
ON pick_item.loading_plan_item_id = item.id
LEFT JOIN fl_loading_pick_task pick_task
ON pick_task.id = pick_item.pick_task_id
AND pick_task.loading_plan_id = OLD.id
WHERE item.loading_plan_id = OLD.id AND item.deleted = 0
AND (pick_task.id IS NULL
OR pick_item.picked_package_count <> pick_item.planned_package_count)) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仍有未完成拣货明细,不能完成装柜';
END IF;
SET NEW.confirmed_at = COALESCE(NEW.confirmed_at, CURRENT_TIMESTAMP(3));
END IF;
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_loading_plan_after_update;
DELIMITER $$
CREATE TRIGGER trg_loading_plan_after_update
AFTER UPDATE ON fl_loading_plan
FOR EACH ROW
BEGIN
IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN
UPDATE fl_loading_plan_item
SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3)),
frozen_by = COALESCE(frozen_by, NEW.updated_by),
updated_by = NEW.updated_by,
updated_by_name = NEW.updated_by_name,
version = version + 1
WHERE loading_plan_id = NEW.id AND deleted = 0;
UPDATE fl_loading_pick_task
SET task_status = 'COMPLETED', completed_by = NEW.updated_by,
completed_by_name = NEW.updated_by_name, completed_at = CURRENT_TIMESTAMP(3),
version = version + 1
WHERE loading_plan_id = NEW.id AND task_status = 'LOADING';
END IF;
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_loading_item_before_insert;
DELIMITER $$
CREATE TRIGGER trg_loading_item_before_insert
BEFORE INSERT ON fl_loading_plan_item
FOR EACH ROW
BEGIN
DECLARE current_plan_status VARCHAR(24);
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE id = NEW.loading_plan_id;
IF current_plan_status IS NULL OR current_plan_status NOT IN ('EDITING', 'LOADING') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仅编辑中或装柜中的计划允许新增明细';
END IF;
IF NEW.deleted <> 0 OR NEW.frozen_at IS NOT NULL OR NEW.frozen_by IS NOT NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '新增装柜明细必须为未删除且未冻结状态';
END IF;
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_loading_item_before_update;
DELIMITER $$
CREATE TRIGGER trg_loading_item_before_update
BEFORE UPDATE ON fl_loading_plan_item
FOR EACH ROW
BEGIN
DECLARE current_plan_status VARCHAR(24);
SELECT MAX(plan_status) INTO current_plan_status
FROM fl_loading_plan WHERE id = OLD.loading_plan_id;
IF current_plan_status = 'LOADING' AND EXISTS (
SELECT 1 FROM fl_loading_pick_item
WHERE loading_plan_item_id = OLD.id AND picked_package_count > 0) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细不可修改或删除';
END IF;
IF OLD.frozen_at IS NOT NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后的明细永久冻结';
END IF;
IF OLD.deleted = 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已删除装柜明细不可恢复或修改';
END IF;
IF NEW.loading_plan_id <> OLD.loading_plan_id OR NEW.account_id <> OLD.account_id
OR NEW.inbound_detail_id <> OLD.inbound_detail_id THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜明细归属键不可修改,请删除后重新分配';
END IF;
IF NEW.deleted = 1 THEN
IF NEW.removed_by IS NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '删除装柜明细必须记录操作人';
END IF;
SET NEW.removed_at = COALESCE(NEW.removed_at, CURRENT_TIMESTAMP(3));
ELSEIF NEW.removed_by IS NOT NULL OR NEW.removed_by_name IS NOT NULL OR NEW.removed_at IS NOT NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '删除审计字段只能在逻辑删除时写入';
END IF;
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_loading_pick_task_before_update;
DELIMITER $$
CREATE TRIGGER trg_loading_pick_task_before_update
BEFORE UPDATE ON fl_loading_pick_task
FOR EACH ROW
BEGIN
IF OLD.task_status = 'COMPLETED' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已完成PDA拣货任务不可修改';
END IF;
IF NEW.loading_plan_id <> OLD.loading_plan_id OR NOT (NEW.container_no <=> OLD.container_no) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务归属不可修改';
END IF;
IF NOT (OLD.task_status = 'LOADING' AND NEW.task_status IN ('LOADING', 'COMPLETED')) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务状态只允许完成';
END IF;
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_loading_pick_item_before_update;
DELIMITER $$
CREATE TRIGGER trg_loading_pick_item_before_update
BEFORE UPDATE ON fl_loading_pick_item
FOR EACH ROW
BEGIN
DECLARE current_task_status VARCHAR(16);
SELECT MAX(task_status) INTO current_task_status
FROM fl_loading_pick_task WHERE id = OLD.pick_task_id;
IF current_task_status = 'COMPLETED' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后PDA拣货明细不可修改';
END IF;
IF NEW.pick_task_id <> OLD.pick_task_id
OR NEW.loading_plan_item_id <> OLD.loading_plan_item_id
OR NEW.inbound_id <> OLD.inbound_id
OR NEW.inbound_detail_id <> OLD.inbound_detail_id THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货明细归属不可修改';
END IF;
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_loading_pick_item_before_delete;
DELIMITER $$
CREATE TRIGGER trg_loading_pick_item_before_delete
BEFORE DELETE ON fl_loading_pick_item
FOR EACH ROW
BEGIN
DECLARE current_task_status VARCHAR(16);
SELECT MAX(task_status) INTO current_task_status
FROM fl_loading_pick_task WHERE id = OLD.pick_task_id;
IF current_task_status = 'COMPLETED' OR OLD.picked_package_count > 0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已拣货或已完成的PDA明细不可删除';
END IF;
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_loading_pick_history_before_update;
DELIMITER $$
CREATE TRIGGER trg_loading_pick_history_before_update
BEFORE UPDATE ON fl_loading_pick_history
FOR EACH ROW
BEGIN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货历史不可修改';
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_loading_pick_history_before_delete;
DELIMITER $$
CREATE TRIGGER trg_loading_pick_history_before_delete
BEFORE DELETE ON fl_loading_pick_history
FOR EACH ROW
BEGIN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货历史不可删除';
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_loading_item_before_delete;
DELIMITER $$
CREATE TRIGGER trg_loading_item_before_delete
BEFORE DELETE ON fl_loading_plan_item
FOR EACH ROW
BEGIN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜明细只允许推送前逻辑删除';
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_container_document_before_insert;
DELIMITER $$
CREATE TRIGGER trg_container_document_before_insert
BEFORE INSERT ON fl_container_document
FOR EACH ROW
BEGIN
DECLARE current_plan_status VARCHAR(24);
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
WHERE container_booking_id = NEW.container_booking_id;
IF current_plan_status = 'COMPLETED' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增';
END IF;
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_container_document_before_update;
DELIMITER $$
CREATE TRIGGER trg_container_document_before_update
BEFORE UPDATE ON fl_container_document
FOR EACH ROW
BEGIN
DECLARE current_plan_status VARCHAR(24);
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
WHERE container_booking_id = OLD.container_booking_id;
IF current_plan_status = 'COMPLETED' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改';
END IF;
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_container_document_before_delete;
DELIMITER $$
CREATE TRIGGER trg_container_document_before_delete
BEFORE DELETE ON fl_container_document
FOR EACH ROW
BEGIN
DECLARE current_plan_status VARCHAR(24);
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
WHERE container_booking_id = OLD.container_booking_id;
IF current_plan_status = 'COMPLETED' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可删除';
END IF;
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_loading_history_before_update;
DELIMITER $$
CREATE TRIGGER trg_loading_history_before_update
BEFORE UPDATE ON fl_loading_plan_history
FOR EACH ROW
BEGIN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜操作历史不可修改';
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_loading_history_before_delete;
DELIMITER $$
CREATE TRIGGER trg_loading_history_before_delete
BEFORE DELETE ON fl_loading_plan_history
FOR EACH ROW
BEGIN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜操作历史不可删除';
END$$
DELIMITER ;
-- 三期智能装柜、预装清单和装柜清单平台权限;与一期平台根菜单幂等合并。
USE baiyue_iam;
INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code,
permission_name, route_path, http_method, sort_no, status, built_in, deleted)
VALUES
(5102090, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:container:list',
'货柜', '/first-leg/containers', 'GET', 40, 'ACTIVE', 1, 0),
(5102091, 0, 'PLATFORM', 'BUTTON', 5102090, 'platform:firstleg:container:view',
'查看货柜', NULL, 'GET', 10, 'ACTIVE', 1, 0),
(5102092, 0, 'PLATFORM', 'BUTTON', 5102090, 'platform:firstleg:container:create',
'建立货柜', NULL, 'POST', 20, 'ACTIVE', 1, 0),
(5102093, 0, 'PLATFORM', 'BUTTON', 5102090, 'platform:firstleg:container:edit',
'编辑货柜', NULL, 'PUT', 30, 'ACTIVE', 1, 0),
(5102094, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:smart-loading:list',
'智能装柜', '/first-leg/smart-loading', 'GET', 50, 'ACTIVE', 1, 0),
(5102096, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:create',
'创建智能装柜计划', NULL, 'POST', 20, 'ACTIVE', 1, 0),
(5102097, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:assign',
'分配装柜明细', NULL, 'POST', 30, 'ACTIVE', 1, 0),
(5102098, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:delete',
'删除装柜明细', NULL, 'DELETE', 40, 'ACTIVE', 1, 0),
(5102099, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:push',
'推送预装', NULL, 'POST', 50, 'ACTIVE', 1, 0),
(5102100, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:preload:list',
'预装清单', '/first-leg/preload-list', 'GET', 60, 'ACTIVE', 1, 0),
(5102102, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:view',
'查看预装详情', NULL, 'GET', 20, 'ACTIVE', 1, 0),
(5102103, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:edit',
'编辑预装运输字段', NULL, 'PUT', 30, 'ACTIVE', 1, 0),
(5102104, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:complete',
'完成预装', NULL, 'POST', 40, 'ACTIVE', 1, 0),
(5102105, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:confirm',
'确认预装', NULL, 'POST', 50, 'ACTIVE', 1, 0),
(5102106, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:loading-list:list',
'装柜清单', '/first-leg/loading-list', 'GET', 70, 'ACTIVE', 1, 0),
(5102108, 0, 'PLATFORM', 'BUTTON', 5102106, 'platform:firstleg:loading-list:view',
'查看装柜详情', NULL, 'GET', 20, 'ACTIVE', 1, 0)
ON DUPLICATE KEY UPDATE
permission_type = VALUES(permission_type), parent_id = VALUES(parent_id),
permission_name = VALUES(permission_name), route_path = VALUES(route_path),
http_method = VALUES(http_method), sort_no = VALUES(sort_no), status = 'ACTIVE',
built_in = 1, deleted = 0;
INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT role.id, permission.id, role.tenant_id
FROM iam_role role
JOIN iam_permission permission
ON permission.tenant_id = 0
AND permission.permission_code IN (
'platform:firstleg:container:list', 'platform:firstleg:container:view',
'platform:firstleg:container:create',
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign',
'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push',
'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list',
'platform:firstleg:loading-list:view')
WHERE role.tenant_id = 0
AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR');
USE baiyue_firstleg;
DROP PROCEDURE IF EXISTS assert_phase3_smart_loading_schema;
DELIMITER $$
CREATE PROCEDURE assert_phase3_smart_loading_schema()
BEGIN
IF (SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = 'baiyue_firstleg'
AND table_name IN ('fl_container_booking', 'fl_loading_plan', 'fl_loading_plan_item',
'fl_container_document', 'fl_loading_plan_history',
'fl_loading_pick_task', 'fl_loading_pick_item',
'fl_loading_pick_history')) <> 8 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:核心表未完整创建';
END IF;
IF EXISTS (SELECT 1 FROM fl_loading_plan
WHERE plan_status NOT IN ('EDITING', 'LOADING', 'COMPLETED')) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:存在旧装柜状态';
END IF;
IF (SELECT COUNT(*) FROM information_schema.check_constraints
WHERE constraint_schema = 'baiyue_firstleg'
AND constraint_name = 'chk_loading_plan_status'
AND check_clause LIKE '%''EDITING''%'
AND check_clause LIKE '%''LOADING''%'
AND check_clause LIKE '%''COMPLETED''%') <> 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:装柜状态约束不是最终三状态';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_container_booking'
AND column_name IN ('booking_no', 'cabinet_no', 'warehouse_id', 'bill_of_lading_no',
'vessel_name', 'voyage_no', 'container_type', 'capacity_cbm',
'max_weight_kg', 'terminal_trucking_company')) <> 10 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:货柜原型字段缺失';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_plan_item'
AND column_name IN ('account_id', 'customer_name', 'active_inbound_detail_id', 'frozen_at',
'destination_warehouse_name', 'product_name_zh', 'product_name_en',
'product_image_file_id', 'pallet_count', 'net_weight_kg',
'dimensional_weight_kg', 'product_quantity', 'units_per_carton',
'fba_tracking_no', 'shipment_no', 'shipping_mark', 'version')) <> 17 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:明细归属、清单快照或冻结字段缺失';
END IF;
IF (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_plan_item'
AND index_name = 'uk_loading_item_plan_detail' AND non_unique = 0) <> 2 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:计划内唯一活动明细锁缺失';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_loading_pick_item'
AND column_name IN ('pick_task_id', 'loading_plan_item_id', 'inbound_id',
'inbound_detail_id', 'planned_package_count',
'picked_package_count', 'item_status', 'version')) <> 8 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:PDA拣货明细字段缺失';
END IF;
IF (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg' AND non_unique = 0
AND index_name IN ('uk_loading_pick_task_plan',
'uk_loading_pick_item_plan_item',
'uk_loading_pick_history_operation')) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:PDA唯一索引缺失';
END IF;
IF (SELECT COUNT(*) FROM information_schema.triggers
WHERE trigger_schema = 'baiyue_firstleg'
AND trigger_name IN ('trg_container_booking_before_update',
'trg_loading_plan_before_update', 'trg_loading_plan_after_update',
'trg_loading_item_before_insert', 'trg_loading_item_before_update',
'trg_loading_item_before_delete', 'trg_container_document_before_insert',
'trg_container_document_before_update', 'trg_container_document_before_delete',
'trg_loading_history_before_update', 'trg_loading_history_before_delete',
'trg_loading_pick_task_before_update',
'trg_loading_pick_item_before_update',
'trg_loading_pick_item_before_delete',
'trg_loading_pick_history_before_update',
'trg_loading_pick_history_before_delete')) <> 16 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:不可逆状态或冻结触发器缺失';
END IF;
IF (SELECT COUNT(DISTINCT 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 (
'platform:firstleg:container:list', 'platform:firstleg:container:view',
'platform:firstleg:container:create', 'platform:firstleg:container:edit',
'platform:firstleg:smart-loading:list', 'platform:firstleg:smart-loading:create',
'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete',
'platform:firstleg:smart-loading:push', 'platform:firstleg:preload:list',
'platform:firstleg:preload:view', 'platform:firstleg:preload:edit',
'platform:firstleg:preload:complete', 'platform:firstleg:preload:confirm',
'platform:firstleg:loading-list:list', 'platform:firstleg:loading-list:view')) <> 16 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台权限资源未完整创建';
END IF;
IF (SELECT COUNT(DISTINCT CONCAT(role.role_code, ':', permission.permission_code))
FROM baiyue_iam.iam_role role
JOIN baiyue_iam.iam_role_permission grant_row ON grant_row.role_id = role.id
JOIN baiyue_iam.iam_permission permission ON permission.id = grant_row.permission_id
WHERE role.tenant_id = 0 AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')
AND permission.tenant_id = 0
AND permission.permission_code IN (
'platform:firstleg:container:list', 'platform:firstleg:container:view',
'platform:firstleg:container:create',
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign',
'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push',
'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list',
'platform:firstleg:loading-list:view')) <> 32 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台角色授权未完整写入';
END IF;
END$$
DELIMITER ;
CALL assert_phase3_smart_loading_schema();
DROP PROCEDURE IF EXISTS assert_phase3_smart_loading_schema;
@@ -0,0 +1,272 @@
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='分柜操作幂等与审计索引';
/*
* 部分装柜拆单契约:
* 1. parent_inbound_id 为空的是主单,非空的是唯一二级子单;
* 2. 锁定阶段只记录箱数,完成阶段绑定由剩余箱数生成的子单;
* 3. 子单不能再作为父单,锁定数量创建后不可修改。
*/
DROP PROCEDURE IF EXISTS phase3_split_add_column;
DELIMITER $$
CREATE PROCEDURE phase3_split_add_column(
IN p_table_name VARCHAR(64), IN p_column_name VARCHAR(64), IN p_definition TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = p_table_name AND column_name = p_column_name
) THEN
SET @phase3_split_ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD COLUMN `',
p_column_name, '` ', p_definition);
PREPARE phase3_split_stmt FROM @phase3_split_ddl;
EXECUTE phase3_split_stmt;
DEALLOCATE PREPARE phase3_split_stmt;
END IF;
END$$
DELIMITER ;
CALL phase3_split_add_column('fl_inbound_order', 'parent_inbound_id',
'BIGINT UNSIGNED NULL COMMENT ''生成本单的主入库单ID;仅二级子单有值'' AFTER id');
CALL phase3_split_add_column('fl_inbound_order', 'split_level',
'TINYINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN parent_inbound_id IS NULL THEN 0 ELSE 1 END) STORED COMMENT ''拆单层级:0主单、1二级子单'' AFTER parent_inbound_id');
CALL phase3_split_add_column('fl_inbound_order', 'split_status',
'VARCHAR(24) NOT NULL DEFAULT ''NOT_SPLIT'' COMMENT ''NOT_SPLIT/PARTIALLY_SPLIT/FULLY_SPLIT'' AFTER split_level');
DROP PROCEDURE IF EXISTS phase3_split_add_column;
SET @phase3_split_index_exists = (
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order'
AND index_name = 'uk_inbound_one_secondary'
);
SET @phase3_split_ddl = IF(@phase3_split_index_exists = 0,
'ALTER TABLE fl_inbound_order ADD UNIQUE KEY uk_inbound_one_secondary (tenant_id, parent_inbound_id)',
'SELECT 1');
PREPARE phase3_split_stmt FROM @phase3_split_ddl;
EXECUTE phase3_split_stmt;
DEALLOCATE PREPARE phase3_split_stmt;
SET @phase3_split_fk_exists = (
SELECT COUNT(*) FROM information_schema.table_constraints
WHERE constraint_schema = DATABASE() AND table_name = 'fl_inbound_order'
AND constraint_name = 'fk_inbound_parent' AND constraint_type = 'FOREIGN KEY'
);
SET @phase3_split_ddl = IF(@phase3_split_fk_exists = 0,
'ALTER TABLE fl_inbound_order ADD CONSTRAINT fk_inbound_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id)',
'SELECT 1');
PREPARE phase3_split_stmt FROM @phase3_split_ddl;
EXECUTE phase3_split_stmt;
DEALLOCATE PREPARE phase3_split_stmt;
CREATE TABLE IF NOT EXISTS fl_inbound_split_detail (
id BIGINT UNSIGNED NOT NULL COMMENT '部分锁定明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
parent_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '主入库单ID',
child_inbound_id BIGINT UNSIGNED NULL COMMENT '完成拆单后生成的二级子入库单ID',
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '原订单货物明细ID,不建立跨库外键',
source_package_count INT UNSIGNED NOT NULL COMMENT '拆分前该明细总箱数快照',
locked_package_count INT UNSIGNED NOT NULL COMMENT '本次装柜锁定箱数',
remaining_package_count INT UNSIGNED GENERATED ALWAYS AS (
source_package_count - locked_package_count
) STORED COMMENT '生成二级子单的剩余箱数',
status VARCHAR(16) NOT NULL DEFAULT 'LOCKED' COMMENT 'LOCKED/COMPLETED',
operation_id VARCHAR(128) NOT NULL COMMENT '部分锁定操作幂等号',
created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL,
completed_by BIGINT UNSIGNED NULL,
completed_by_name VARCHAR(64) NULL,
completed_at DATETIME(3) 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),
version INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY uk_inbound_split_parent_detail (tenant_id, parent_inbound_id, inbound_detail_id),
UNIQUE KEY uk_inbound_split_child_detail (tenant_id, child_inbound_id, inbound_detail_id),
UNIQUE KEY uk_inbound_split_operation (operation_id),
KEY idx_inbound_split_status (tenant_id, parent_inbound_id, status),
CONSTRAINT fk_inbound_split_detail_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id),
CONSTRAINT fk_inbound_split_detail_child FOREIGN KEY (child_inbound_id) REFERENCES fl_inbound_order (id),
CONSTRAINT chk_inbound_split_partial CHECK (
source_package_count > 1 AND locked_package_count > 0
AND locked_package_count < source_package_count
),
CONSTRAINT chk_inbound_split_status CHECK (status IN ('LOCKED', 'COMPLETED')),
CONSTRAINT chk_inbound_split_completion CHECK (
(status = 'LOCKED' AND child_inbound_id IS NULL AND completed_at IS NULL)
OR (status = 'COMPLETED' AND child_inbound_id IS NOT NULL AND completed_at IS NOT NULL)
)
) ENGINE=InnoDB COMMENT='按箱数部分锁定及剩余箱数生成二级子入库单的不可变契约';
DROP TRIGGER IF EXISTS trg_inbound_split_before_insert;
DROP TRIGGER IF EXISTS trg_inbound_split_before_update;
DROP TRIGGER IF EXISTS trg_inbound_split_detail_before_insert;
DROP TRIGGER IF EXISTS trg_inbound_split_detail_before_update;
DROP TRIGGER IF EXISTS trg_inbound_split_detail_before_delete;
DELIMITER $$
CREATE TRIGGER trg_inbound_split_before_insert
BEFORE INSERT ON fl_inbound_order
FOR EACH ROW
BEGIN
DECLARE v_parent_tenant BIGINT UNSIGNED;
DECLARE v_parent_parent BIGINT UNSIGNED;
IF NEW.parent_inbound_id IS NOT NULL THEN
SELECT tenant_id, parent_inbound_id INTO v_parent_tenant, v_parent_parent
FROM fl_inbound_order WHERE id = NEW.parent_inbound_id AND deleted = 0;
IF v_parent_tenant <> NEW.tenant_id THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二级子入库单与主单客户归属不一致';
END IF;
IF v_parent_parent IS NOT NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二级子入库单不能继续拆分';
END IF;
IF NEW.split_status <> 'NOT_SPLIT' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二级子入库单拆单状态必须为NOT_SPLIT';
END IF;
END IF;
END$$
CREATE TRIGGER trg_inbound_split_before_update
BEFORE UPDATE ON fl_inbound_order
FOR EACH ROW
BEGIN
IF NOT (NEW.parent_inbound_id <=> OLD.parent_inbound_id) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入库单主子关系创建后不可修改';
END IF;
IF OLD.parent_inbound_id IS NOT NULL AND NEW.split_status <> 'NOT_SPLIT' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二级子入库单不能继续拆分';
END IF;
IF OLD.parent_inbound_id IS NULL
AND NEW.split_status NOT IN ('NOT_SPLIT', 'PARTIALLY_SPLIT', 'FULLY_SPLIT') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主入库单拆单状态非法';
END IF;
IF OLD.split_status = 'FULLY_SPLIT' AND NEW.split_status <> 'FULLY_SPLIT' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已完成拆单不能回退';
END IF;
IF NEW.split_status = 'FULLY_SPLIT' AND (
(SELECT COUNT(*) FROM fl_inbound_order child
WHERE child.tenant_id = NEW.tenant_id AND child.parent_inbound_id = NEW.id
AND child.deleted = 0) <> 1
OR (SELECT COUNT(*) FROM fl_inbound_split_detail detail
WHERE detail.tenant_id = NEW.tenant_id AND detail.parent_inbound_id = NEW.id) = 0
OR EXISTS (SELECT 1 FROM fl_inbound_split_detail detail
WHERE detail.tenant_id = NEW.tenant_id AND detail.parent_inbound_id = NEW.id
AND detail.status <> 'COMPLETED')
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '剩余箱数二级子单及拆分明细未完成';
END IF;
END$$
CREATE TRIGGER trg_inbound_split_detail_before_insert
BEFORE INSERT ON fl_inbound_split_detail
FOR EACH ROW
BEGIN
DECLARE v_parent_tenant BIGINT UNSIGNED;
DECLARE v_parent_parent BIGINT UNSIGNED;
SELECT tenant_id, parent_inbound_id INTO v_parent_tenant, v_parent_parent
FROM fl_inbound_order WHERE id = NEW.parent_inbound_id AND deleted = 0;
IF v_parent_tenant <> NEW.tenant_id OR v_parent_parent IS NOT NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分锁定只能发生在客户所属主入库单';
END IF;
IF NEW.status <> 'LOCKED' OR NEW.child_inbound_id IS NOT NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分锁定创建时不能提前绑定二级子入库单';
END IF;
END$$
CREATE TRIGGER trg_inbound_split_detail_before_update
BEFORE UPDATE ON fl_inbound_split_detail
FOR EACH ROW
BEGIN
DECLARE v_child_tenant BIGINT UNSIGNED;
DECLARE v_child_parent BIGINT UNSIGNED;
IF NEW.tenant_id <> OLD.tenant_id OR NEW.parent_inbound_id <> OLD.parent_inbound_id
OR NEW.inbound_detail_id <> OLD.inbound_detail_id
OR NEW.source_package_count <> OLD.source_package_count
OR NEW.locked_package_count <> OLD.locked_package_count
OR NEW.operation_id <> OLD.operation_id THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分锁定的归属和箱数创建后不可修改';
END IF;
IF OLD.status <> 'LOCKED' OR NEW.status <> 'COMPLETED' OR NEW.child_inbound_id IS NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分锁定只允许完成并绑定二级子入库单';
END IF;
SELECT tenant_id, parent_inbound_id INTO v_child_tenant, v_child_parent
FROM fl_inbound_order WHERE id = NEW.child_inbound_id AND deleted = 0;
IF v_child_tenant <> NEW.tenant_id OR v_child_parent <> NEW.parent_inbound_id THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '剩余箱数必须绑定主单唯一二级子入库单';
END IF;
END$$
CREATE TRIGGER trg_inbound_split_detail_before_delete
BEFORE DELETE ON fl_inbound_split_detail
FOR EACH ROW
BEGIN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分锁定与拆单明细不可删除';
END$$
DELIMITER ;
DROP PROCEDURE IF EXISTS phase3_split_assert_contract;
DELIMITER $$
CREATE PROCEDURE phase3_split_assert_contract()
BEGIN
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order'
AND column_name IN ('parent_inbound_id', 'split_level', 'split_status')) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆单迁移失败:入库单主子关系字段未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_split_detail') <> 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆单迁移失败:部分锁定明细表未创建';
END IF;
IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND index_name IN ('uk_inbound_one_secondary', 'uk_inbound_split_parent_detail',
'uk_inbound_split_child_detail', 'uk_inbound_split_operation')) <> 4 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆单迁移失败:唯一子单或部分锁定幂等索引缺失';
END IF;
IF (SELECT COUNT(*) FROM information_schema.triggers
WHERE trigger_schema = DATABASE()
AND trigger_name IN ('trg_inbound_split_before_insert', 'trg_inbound_split_before_update',
'trg_inbound_split_detail_before_insert',
'trg_inbound_split_detail_before_update',
'trg_inbound_split_detail_before_delete')) <> 5 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆单迁移失败:主子层级或部分锁定触发器未完整创建';
END IF;
END$$
DELIMITER ;
CALL phase3_split_assert_contract();
DROP PROCEDURE IF EXISTS phase3_split_assert_contract;
@@ -67,6 +67,90 @@ paths:
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 无分配权限}
/api/platform/v1/containers:
get:
summary: 分页查询平台货柜
description: 需要 platform:firstleg:container:list 权限;货柜为平台级数据,不传 tenant/account
parameters:
- {$ref: '#/components/parameters/Page'}
- {$ref: '#/components/parameters/Size'}
- {name: keyword, in: query, schema: {type: string, maxLength: 128}}
- {name: containerNo, in: query, schema: {type: string, maxLength: 64}}
- {name: cabinetNo, in: query, schema: {type: string, maxLength: 64}}
- {name: warehouseId, in: query, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: status, in: query, schema: {type: string, enum: [EDITING, LOADING, COMPLETED]}}
responses:
'200':
description: 成功或业务失败,按响应体 code 判断
content:
application/json:
schema: {$ref: '#/components/schemas/ContainerBookingPageEnvelope'}
'403': {description: 无货柜查询权限}
post:
summary: 新增平台货柜
description: 需要 platform:firstleg:container:create 权限;新增后聚合状态为 EDITING
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ContainerSaveRequest'}
responses:
'200': {description: 成功或重复号、参数校验等业务失败}
'403': {description: 无新增按钮权限}
/api/platform/v1/containers/{id}:
get:
summary: 查询平台货柜详情
description: 需要 platform:firstleg:container:view 权限
parameters:
- {$ref: '#/components/parameters/ContainerBookingId'}
responses:
'200': {description: 成功或货柜不存在}
'403': {description: 无货柜查询权限}
put:
summary: 草稿阶段完整编辑货柜
description: 需要 platform:firstleg:container:edit 权限;仅无装柜计划或计划为 EDITING 时允许
parameters:
- {$ref: '#/components/parameters/ContainerBookingId'}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ContainerFullUpdateRequest'}
responses:
'200': {description: 成功或状态、版本、重复号冲突}
'403': {description: 无货柜编辑权限}
/api/platform/v1/containers/{id}/transport:
patch:
summary: 修改集装箱号和航次
description: 需要 platform:firstleg:container:edit 权限;LOADING 仅开放此白名单,COMPLETED 全部只读
parameters:
- {$ref: '#/components/parameters/ContainerBookingId'}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ContainerTransportUpdateRequest'}
responses:
'200': {description: 成功或状态、版本、重复号冲突}
'403': {description: 无货柜编辑权限}
/api/platform/v1/first-leg/smart-loading/plans/by-booking/{bookingId}:
get:
summary: 按货柜恢复智能装柜草稿
description: 需要 platform:firstleg:smart-loading:list 权限;仅返回 EDITING 草稿
parameters:
- {name: bookingId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses:
'200': {description: 成功、草稿不存在或计划已进入预装流程}
'403': {description: 无智能装柜查询权限}
/api/platform/v1/first-leg/smart-loading/plans/{planId}:
get:
summary: 查询智能装柜草稿详情
description: 需要 platform:firstleg:smart-loading:list 权限;返回草稿及当前未删除明细
parameters:
- {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses:
'200': {description: 成功、计划不存在或计划已进入预装流程}
'403': {description: 无智能装柜查询权限}
/openapi/v1/inbound-measurements:
post:
summary: 提交一条第三方箱规测量结果和一张图片
@@ -258,7 +342,103 @@ components:
name: size
in: query
schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20}
ContainerBookingId:
name: id
in: path
required: true
schema: {type: string, pattern: '^[1-9][0-9]*$'}
schemas:
ContainerBookingPageEnvelope:
type: object
properties:
code: {type: string, example: '200'}
message: {type: string}
data:
type: object
properties:
records: {type: array, items: {$ref: '#/components/schemas/ContainerBooking'}}
page: {type: string, pattern: '^[1-9][0-9]*$'}
size: {type: string, pattern: '^[1-9][0-9]*$'}
total: {type: string, pattern: '^[0-9]+$'}
pages: {type: string, pattern: '^[0-9]+$'}
ContainerBooking:
type: object
required: [id, bookingNo, cabinetNo, warehouseId, warehouseName, containerType, planStatus, version]
properties:
id: {type: string, pattern: '^[1-9][0-9]*$'}
planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 关联智能装柜计划ID;未创建计划时为空}
bookingNo: {type: string, maxLength: 40}
containerNo: {type: string, maxLength: 64, nullable: true}
cabinetNo: {type: string, maxLength: 64}
warehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
warehouseName: {type: string, maxLength: 128}
billOfLadingNo: {type: string, maxLength: 128, nullable: true}
vesselName: {type: string, maxLength: 128, nullable: true}
voyageNo: {type: string, maxLength: 64, nullable: true}
containerType: {type: string, maxLength: 32}
shippingCompanyId: {type: string, nullable: true}
shippingCompanyName: {type: string, maxLength: 128, nullable: true}
departurePortCode: {type: string, maxLength: 32, nullable: true}
departurePortName: {type: string, maxLength: 128, nullable: true}
destinationPortCode: {type: string, maxLength: 32, nullable: true}
destinationPortName: {type: string, maxLength: 128, nullable: true}
destinationWarehouseCode: {type: string, maxLength: 64, nullable: true}
destinationWarehouseName: {type: string, maxLength: 128, nullable: true}
capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true}
maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true}
terminalTruckingCompany: {type: string, maxLength: 128, nullable: true}
loadingAt: {type: string, format: date-time, nullable: true}
estimatedSailingAt: {type: string, format: date-time, nullable: true}
actualSailingAt: {type: string, format: date-time, nullable: true}
estimatedPortArrivalAt: {type: string, format: date-time, nullable: true}
actualPortArrivalAt: {type: string, format: date-time, nullable: true}
estimatedWarehouseArrivalAt: {type: string, format: date-time, nullable: true}
actualWarehouseArrivalAt: {type: string, format: date-time, nullable: true}
planStatus: {type: string, enum: [EDITING, LOADING, COMPLETED]}
version: {type: integer, minimum: 0}
ContainerSaveRequest:
type: object
required: [cabinetNo, warehouseId, warehouseName, containerType]
properties:
containerNo: {type: string, maxLength: 64, nullable: true}
cabinetNo: {type: string, maxLength: 64}
warehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
warehouseName: {type: string, maxLength: 128}
billOfLadingNo: {type: string, maxLength: 128, nullable: true}
vesselName: {type: string, maxLength: 128, nullable: true}
voyageNo: {type: string, maxLength: 64, nullable: true}
containerType: {type: string, maxLength: 32}
shippingCompanyId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
shippingCompanyName: {type: string, maxLength: 128, nullable: true}
departurePortCode: {type: string, maxLength: 32, nullable: true}
departurePortName: {type: string, maxLength: 128, nullable: true}
destinationPortCode: {type: string, maxLength: 32, nullable: true}
destinationPortName: {type: string, maxLength: 128, nullable: true}
destinationWarehouseCode: {type: string, maxLength: 64, nullable: true}
destinationWarehouseName: {type: string, maxLength: 128, nullable: true}
capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true}
maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true}
terminalTruckingCompany: {type: string, maxLength: 128, nullable: true}
loadingAt: {type: string, format: date-time, nullable: true}
estimatedSailingAt: {type: string, format: date-time, nullable: true}
actualSailingAt: {type: string, format: date-time, nullable: true}
estimatedPortArrivalAt: {type: string, format: date-time, nullable: true}
actualPortArrivalAt: {type: string, format: date-time, nullable: true}
estimatedWarehouseArrivalAt: {type: string, format: date-time, nullable: true}
actualWarehouseArrivalAt: {type: string, format: date-time, nullable: true}
ContainerFullUpdateRequest:
type: object
required: [version, data]
properties:
version: {type: integer, minimum: 0}
data: {$ref: '#/components/schemas/ContainerSaveRequest'}
ContainerTransportUpdateRequest:
type: object
required: [version]
properties:
containerNo: {type: string, maxLength: 64, nullable: true}
voyageNo: {type: string, maxLength: 64, nullable: true}
version: {type: integer, minimum: 0}
MerchantInboundBillingEnvelope:
type: object
properties:
@@ -0,0 +1,340 @@
openapi: 3.0.3
info:
title: 头程装柜与完成装柜清单接口
version: 1.0.0
description: 装柜计划仅允许 EDITING 到 LOADING 再到 COMPLETED 单向推进;EDITING 和 LOADING 均可维护未拣货明细,COMPLETED 后计划、明细与货柜运输信息永久只读,不提供回退或撤回接口。
paths:
/api/platform/v1/first-leg/loading-plans:
get:
summary: 分页查询预装清单
description: 仅返回预装柜中或预装柜完成的计划,计划为平台作业数据,客户账号仅作为明细筛选条件。
x-permission: platform:firstleg:preload:list
parameters:
- {$ref: '#/components/parameters/Page'}
- {$ref: '#/components/parameters/Size'}
- {$ref: '#/components/parameters/Keyword'}
- name: status
in: query
description: 预装状态
schema: {type: string, enum: [LOADING]}
- {$ref: '#/components/parameters/AccountId'}
- {$ref: '#/components/parameters/WarehouseId'}
- {$ref: '#/components/parameters/CreatedFrom'}
- {$ref: '#/components/parameters/CreatedTo'}
responses:
'200': {$ref: '#/components/responses/PreloadPageResponse'}
/api/platform/v1/first-leg/loading-plans/{planId}:
get:
summary: 查询预装清单详情
description: 返回同一装柜计划的汇总、冻结明细和完整操作历史。
x-permission: platform:firstleg:preload:view
parameters: [{$ref: '#/components/parameters/PlanId'}]
responses:
'200': {$ref: '#/components/responses/PreloadDetailResponse'}
/api/platform/v1/first-leg/loading-plans/{planId}/transport:
patch:
summary: 修改预装运输信息
description: 仅 LOADING 状态允许修改集装箱号与航次;装柜中可以维护未拣货明细。
x-permission: platform:firstleg:preload:edit
parameters:
- {$ref: '#/components/parameters/PlanId'}
- {$ref: '#/components/parameters/IdempotencyKey'}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/TransportUpdateRequest'}
responses:
'200': {$ref: '#/components/responses/PreloadDetailResponse'}
/api/platform/v1/first-leg/loading-plans/{planId}/complete:
post:
summary: 完成预装
description: 仅允许将 LOADING 单向推进为 COMPLETED;完成前必须完成全部 PDA 拣货。
x-permission: platform:firstleg:preload:complete
parameters:
- {$ref: '#/components/parameters/PlanId'}
- {$ref: '#/components/parameters/IdempotencyKey'}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/AdvanceRequest'}
responses:
'200': {$ref: '#/components/responses/PreloadDetailResponse'}
/api/platform/v1/first-leg/loading-lists:
get:
summary: 分页查询装柜清单
description: 读取同一个装柜计划聚合,仅返回 COMPLETED 状态记录。
x-permission: platform:firstleg:loading-list:list
parameters:
- {$ref: '#/components/parameters/Page'}
- {$ref: '#/components/parameters/Size'}
- {$ref: '#/components/parameters/Keyword'}
- {$ref: '#/components/parameters/AccountId'}
- {$ref: '#/components/parameters/WarehouseId'}
- {$ref: '#/components/parameters/CreatedFrom'}
- {$ref: '#/components/parameters/CreatedTo'}
responses:
'200': {$ref: '#/components/responses/ConfirmedPageResponse'}
/api/platform/v1/first-leg/loading-lists/{planId}:
get:
summary: 查询装柜清单详情
description: 返回已确认计划的汇总、冻结明细和操作历史,全部只读。
x-permission: platform:firstleg:loading-list:view
parameters: [{$ref: '#/components/parameters/PlanId'}]
responses:
'200': {$ref: '#/components/responses/ConfirmedDetailResponse'}
components:
parameters:
PlanId:
name: planId
in: path
required: true
description: 装柜计划 ID
schema: {$ref: '#/components/schemas/LongId'}
IdempotencyKey:
name: Idempotency-Key
in: header
required: true
description: 写操作幂等键,同一业务操作重试时必须保持不变
schema: {type: string, minLength: 1, maxLength: 128}
Page:
name: page
in: query
description: 页码,从 1 开始
schema: {type: integer, format: int64, minimum: 1, default: 1}
Size:
name: size
in: query
description: 每页条数
schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20}
Keyword:
name: keyword
in: query
description: 按预装单号、集装箱号或柜号模糊查询
schema: {type: string, maxLength: 128}
AccountId:
name: accountId
in: query
description: 客户账号 ID 筛选条件,计划本身仍属于平台作业数据
schema: {$ref: '#/components/schemas/LongId'}
WarehouseId:
name: warehouseId
in: query
description: 仓库 ID
schema: {$ref: '#/components/schemas/LongId'}
CreatedFrom:
name: createdFrom
in: query
description: 创建时间起点,包含该时间
schema: {type: string, format: date-time}
CreatedTo:
name: createdTo
in: query
description: 创建时间终点,包含该时间
schema: {type: string, format: date-time}
responses:
PreloadPageResponse:
description: 预装清单分页查询成功或业务失败,按响应体 code 判断
content: {application/json: {schema: {$ref: '#/components/schemas/PreloadPageEnvelope'}}}
PreloadDetailResponse:
description: 预装清单详情查询或操作成功,也可能返回业务失败
content: {application/json: {schema: {$ref: '#/components/schemas/PreloadDetailEnvelope'}}}
ConfirmedPageResponse:
description: 装柜清单分页查询成功或业务失败,按响应体 code 判断
content: {application/json: {schema: {$ref: '#/components/schemas/ConfirmedPageEnvelope'}}}
ConfirmedDetailResponse:
description: 装柜清单详情查询或确认成功,也可能返回业务失败
content: {application/json: {schema: {$ref: '#/components/schemas/ConfirmedDetailEnvelope'}}}
schemas:
LongId:
type: string
pattern: '^[1-9][0-9]*$'
example: '1900000000000000001'
description: 长整型 ID,JSON 中使用字符串避免前端精度丢失
NullableLongId:
allOf: [{$ref: '#/components/schemas/LongId'}]
nullable: true
ApiEnvelope:
type: object
required: [code, message, timestamp]
properties:
code: {type: string, description: 业务结果码,仅字符串 200 表示成功, example: '200'}
message: {type: string, description: 结果说明}
requestId: {type: string, nullable: true, description: 请求标识}
traceId: {type: string, nullable: true, description: 链路标识}
timestamp: {type: string, format: date-time, description: 服务端响应时间}
PreloadPageEnvelope:
allOf:
- {$ref: '#/components/schemas/ApiEnvelope'}
- type: object
properties:
data:
allOf: [{$ref: '#/components/schemas/PreloadPage'}]
nullable: true
PreloadDetailEnvelope:
allOf:
- {$ref: '#/components/schemas/ApiEnvelope'}
- type: object
properties:
data:
allOf: [{$ref: '#/components/schemas/PreloadDetail'}]
nullable: true
ConfirmedPageEnvelope:
allOf:
- {$ref: '#/components/schemas/ApiEnvelope'}
- type: object
properties:
data:
allOf: [{$ref: '#/components/schemas/ConfirmedPage'}]
nullable: true
ConfirmedDetailEnvelope:
allOf:
- {$ref: '#/components/schemas/ApiEnvelope'}
- type: object
properties:
data:
allOf: [{$ref: '#/components/schemas/ConfirmedDetail'}]
nullable: true
PreloadPage:
type: object
required: [records, page, size, total, pages]
properties:
records: {type: array, items: {$ref: '#/components/schemas/PreloadSummary'}}
page: {type: string, pattern: '^[1-9][0-9]*$', description: 当前页码}
size: {type: string, pattern: '^[1-9][0-9]*$', description: 每页条数}
total: {type: string, pattern: '^[0-9]+$', description: 总记录数}
pages: {type: string, pattern: '^[0-9]+$', description: 总页数}
ConfirmedPage:
type: object
required: [records, page, size, total, pages]
properties:
records: {type: array, items: {$ref: '#/components/schemas/ConfirmedSummary'}}
page: {type: string, pattern: '^[1-9][0-9]*$', description: 当前页码}
size: {type: string, pattern: '^[1-9][0-9]*$', description: 每页条数}
total: {type: string, pattern: '^[0-9]+$', description: 总记录数}
pages: {type: string, pattern: '^[0-9]+$', description: 总页数}
Customer:
type: object
required: [accountId, customerName]
properties:
accountId: {$ref: '#/components/schemas/LongId'}
customerName: {type: string, nullable: true, description: 客户名称}
PreloadSummary:
type: object
required: [planId, preloadNo, customers, status, skuCount, totalPackageCount, totalVolumeCbm, totalGrossWeightKg, createdAt, version]
properties:
planId: {$ref: '#/components/schemas/LongId'}
preloadNo: {type: string, description: 预装单号}
containerNo: {type: string, nullable: true, description: 集装箱号}
internalContainerNo: {type: string, nullable: true, description: 内部柜号}
customers: {type: array, description: 计划包含的客户, items: {$ref: '#/components/schemas/Customer'}}
warehouseId: {$ref: '#/components/schemas/NullableLongId'}
warehouseName: {type: string, nullable: true, description: 仓库名称}
vesselName: {type: string, nullable: true, description: 船名}
voyageNo: {type: string, nullable: true, description: 航次}
containerType: {type: string, nullable: true, description: 货柜类型}
status: {type: string, enum: [LOADING], description: 装柜状态}
skuCount: {type: string, pattern: '^[0-9]+$', description: SKU 种类数}
totalPackageCount: {type: string, pattern: '^[0-9]+$', description: 总箱数}
totalVolumeCbm: {type: number, description: 总体积,单位立方米}
totalGrossWeightKg: {type: number, description: 总毛重,单位千克}
createdAt: {type: string, format: date-time, description: 创建时间}
version: {type: integer, format: int32, minimum: 0, description: 乐观锁版本}
ConfirmedSummary:
type: object
required: [planId, loadingListNo, status, totalPackageCount, totalGrossWeightKg, totalNetWeightKg, totalVolumeCbm, totalQuantity, createdAt, version]
properties:
planId: {$ref: '#/components/schemas/LongId'}
loadingListNo: {type: string, description: 装柜清单号}
warehouseId: {$ref: '#/components/schemas/NullableLongId'}
warehouseName: {type: string, nullable: true, description: 仓库名称}
containerNo: {type: string, nullable: true, description: 集装箱号}
internalContainerNo: {type: string, nullable: true, description: 内部柜号}
status: {type: string, enum: [COMPLETED], description: 装柜清单状态}
totalPackageCount: {type: string, pattern: '^[0-9]+$', description: 总箱数}
totalGrossWeightKg: {type: number, description: 总毛重,单位千克}
totalNetWeightKg: {type: number, description: 总净重,单位千克}
totalVolumeCbm: {type: number, description: 总体积,单位立方米}
totalQuantity: {type: string, pattern: '^[0-9]+$', description: 产品总数量}
remark: {type: string, nullable: true, description: 备注}
createdBy: {$ref: '#/components/schemas/NullableLongId'}
createdByName: {type: string, nullable: true, description: 创建人姓名}
createdAt: {type: string, format: date-time, description: 创建时间}
version: {type: integer, format: int32, minimum: 0, description: 乐观锁版本}
LoadingItem:
type: object
required: [itemId, accountId, inboundId, inboundDetailId, packageCount, palletCount, grossWeightKg, netWeightKg, volumeCbm, dimensionalWeightKg, productQuantity, unitsPerPackage, loadSequence]
properties:
itemId: {$ref: '#/components/schemas/LongId'}
accountId: {$ref: '#/components/schemas/LongId'}
customerName: {type: string, nullable: true, description: 客户名称快照}
inboundId: {$ref: '#/components/schemas/LongId'}
inboundDetailId: {$ref: '#/components/schemas/LongId'}
inboundCode: {type: string, nullable: true, description: 入仓码}
skuCode: {type: string, nullable: true, description: SKU 编码快照}
destinationWarehouse: {type: string, nullable: true, description: 目的仓名称快照}
chineseName: {type: string, nullable: true, description: 中文品名快照}
englishName: {type: string, nullable: true, description: 英文品名快照}
goodsImageUrl: {type: string, nullable: true, description: 产品图片地址快照}
packageCount: {type: integer, format: int32, minimum: 0, description: 箱数}
palletCount: {type: integer, format: int32, minimum: 0, description: 托盘数}
grossWeightKg: {type: number, description: 毛重,单位千克}
netWeightKg: {type: number, description: 净重,单位千克}
volumeCbm: {type: number, description: 体积,单位立方米}
dimensionalWeightKg: {type: number, description: 材积重,单位千克}
productQuantity: {type: string, pattern: '^[0-9]+$', description: 产品数量}
unitsPerPackage: {type: integer, format: int32, minimum: 0, description: 每箱数量}
fbaTrackingNo: {type: string, nullable: true, description: FBA 跟踪编号}
shipmentNo: {type: string, nullable: true, description: 货件号}
shippingMark: {type: string, nullable: true, description: 唛头}
internalRemark: {type: string, nullable: true, description: 内部备注}
customerRemark: {type: string, nullable: true, description: 客户备注}
warehouseRemark: {type: string, nullable: true, description: 仓库备注}
loadSequence: {type: integer, format: int32, minimum: 0, description: 装柜顺序}
frozenAt: {type: string, format: date-time, nullable: true, description: 明细冻结时间}
OperationHistory:
type: object
required: [historyId, actionType, operatorId, occurredAt]
properties:
historyId: {$ref: '#/components/schemas/LongId'}
itemId: {$ref: '#/components/schemas/NullableLongId'}
actionType: {type: string, description: 操作类型}
fromStatus: {type: string, nullable: true, description: 操作前状态}
toStatus: {type: string, nullable: true, description: 操作后状态}
beforeSnapshot: {type: string, nullable: true, description: 操作前快照}
afterSnapshot: {type: string, nullable: true, description: 操作后快照}
operatorId: {$ref: '#/components/schemas/LongId'}
operatorName: {type: string, nullable: true, description: 操作人姓名}
operationReason: {type: string, nullable: true, description: 操作原因}
occurredAt: {type: string, format: date-time, description: 操作时间}
PreloadDetail:
type: object
required: [summary, items, histories]
properties:
summary: {$ref: '#/components/schemas/PreloadSummary'}
items: {type: array, description: 装柜中明细,可维护未拣货项, items: {$ref: '#/components/schemas/LoadingItem'}}
histories: {type: array, description: 计划操作历史, items: {$ref: '#/components/schemas/OperationHistory'}}
ConfirmedDetail:
type: object
required: [summary, items, histories]
properties:
summary: {$ref: '#/components/schemas/ConfirmedSummary'}
items: {type: array, description: 已完成且永久只读的装柜明细, items: {$ref: '#/components/schemas/LoadingItem'}}
histories: {type: array, description: 计划操作历史, items: {$ref: '#/components/schemas/OperationHistory'}}
TransportUpdateRequest:
type: object
required: [expectedVersion]
additionalProperties: false
properties:
containerNo: {type: string, maxLength: 64, nullable: true, description: 集装箱号}
voyageNo: {type: string, maxLength: 64, nullable: true, description: 航次}
expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前乐观锁版本}
operationReason: {type: string, maxLength: 500, nullable: true, description: 操作原因}
AdvanceRequest:
type: object
required: [expectedVersion]
additionalProperties: false
properties:
expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前乐观锁版本}
operationReason: {type: string, maxLength: 500, nullable: true, description: 操作原因}
@@ -0,0 +1,51 @@
package com.baiyue.firstleg.controller;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.core.io.ClassPathResource;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("货柜基础资料接口契约")
class ContainerControllerContractTest {
@Test
@DisplayName("查询、新增和编辑按钮权限彼此独立")
void separatesButtonPermissions() {
assertPermission("page", "platform:firstleg:container:list");
assertPermission("detail", "platform:firstleg:container:view");
assertPermission("create", "platform:firstleg:container:create");
assertPermission("updateDraft", "platform:firstleg:container:edit");
assertPermission("updateTransport", "platform:firstleg:container:edit");
}
@Test
@DisplayName("OpenAPI声明平台级货柜路径和冻结规则")
void documentsContainerContract() throws Exception {
String openapi = new ClassPathResource("openapi/firstleg-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
String section = openapi.substring(openapi.indexOf(" /api/platform/v1/containers:"),
openapi.indexOf(" /openapi/v1/inbound-measurements:"));
assertThat(section)
.contains("/api/platform/v1/containers/{id}/transport:")
.contains("platform:firstleg:container:list")
.contains("EDITING")
.contains("LOADING")
.contains("COMPLETED")
.doesNotContain("tenantId")
.doesNotContain("accountId");
}
private static void assertPermission(String methodName, String authority) {
Method method = Arrays.stream(PlatformContainerController.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals(methodName))
.findFirst().orElseThrow();
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('" + authority + "')");
}
}
@@ -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,121 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest;
import com.baiyue.firstleg.service.LoadingListService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.access.prepost.PreAuthorize;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("预装与装柜清单控制器契约")
class LoadingListControllerContractTest {
@Test
void permissionsMatch() throws Exception {
authority("preloadPage", "platform:firstleg:preload:list", AuthenticatedUser.class, PreloadQuery.class);
authority("preloadDetail", "platform:firstleg:preload:view", AuthenticatedUser.class, long.class);
authority("updateTransport", "platform:firstleg:preload:edit", AuthenticatedUser.class,
long.class, String.class, TransportUpdateRequest.class);
authority("confirmedPage", "platform:firstleg:loading-list:list", AuthenticatedUser.class,
ConfirmedQuery.class);
authority("confirmedDetail", "platform:firstleg:loading-list:view", AuthenticatedUser.class, long.class);
}
@Test
void writesAudited() throws Exception {
audit("updateTransport", "PRELOAD_TRANSPORT_UPDATE", long.class, long.class, String.class,
String.class, TransportUpdateRequest.class);
}
@Test
void openApiComplete() throws Exception {
String yaml = new ClassPathResource("openapi/loading-lists-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(yaml)
.contains("EDITING 到 LOADING 再到 COMPLETED")
.contains("Idempotency-Key")
.contains("expectedVersion")
.contains("createdFrom")
.contains("createdTo")
.contains("platform:firstleg:loading-list:view")
.contains("PreloadPageEnvelope")
.contains("ConfirmedPageEnvelope")
.contains("PreloadSummary")
.contains("ConfirmedSummary")
.contains("LoadingItem")
.contains("OperationHistory")
.contains("customers")
.contains("productQuantity")
.contains("beforeSnapshot")
.contains("containerNo")
.contains("voyageNo")
.doesNotContain("rollback")
.doesNotContain("delete:")
.doesNotContain("/items/{itemId}");
}
@Test
void doesNotExposeManualCompletion() {
assertThat(PlatformLoadingListController.class.getDeclaredMethods())
.noneMatch(method -> method.getName().equals("complete"));
}
@Test
void yamlParses() throws Exception {
String yaml = new ClassPathResource("openapi/loading-lists-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
Map<String, Object> document = stringMap(new Yaml().load(yaml));
assertThat(document.get("openapi")).isEqualTo("3.0.3");
assertThat((Map<?, ?>) document.get("paths")).hasSize(6);
Map<String, Object> components = stringMap(document.get("components"));
Map<String, Object> schemas = stringMap(components.get("schemas"));
assertThat(schemas).containsKeys(
"LongId", "PreloadPage", "ConfirmedPage", "Customer", "PreloadSummary",
"ConfirmedSummary", "LoadingItem", "OperationHistory", "PreloadDetail",
"ConfirmedDetail", "TransportUpdateRequest", "AdvanceRequest");
assertThat(stringMap(schemas.get("LongId"))).containsEntry("type", "string");
assertThat(properties(schemas, "PreloadPage")).containsKeys("records", "page", "size", "total", "pages");
assertThat(properties(schemas, "PreloadSummary")).containsKeys("planId", "customers", "skuCount");
assertThat(properties(schemas, "LoadingItem")).containsKeys(
"itemId", "accountId", "inboundId", "inboundDetailId", "skuCode",
"destinationWarehouse", "productQuantity", "frozenAt");
assertThat(properties(schemas, "OperationHistory")).containsKeys(
"historyId", "itemId", "actionType", "fromStatus", "toStatus",
"beforeSnapshot", "afterSnapshot", "operatorId", "occurredAt");
assertThat(properties(schemas, "TransportUpdateRequest")).containsOnlyKeys(
"containerNo", "voyageNo", "expectedVersion", "operationReason");
}
private static Map<String, Object> properties(Map<String, Object> schemas, String schemaName) {
return stringMap(stringMap(schemas.get(schemaName)).get("properties"));
}
@SuppressWarnings("unchecked")
private static Map<String, Object> stringMap(Object value) {
return (Map<String, Object>) value;
}
private static void authority(String name, String permission, Class<?>... parameterTypes) throws Exception {
Method method = PlatformLoadingListController.class.getDeclaredMethod(name, parameterTypes);
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('" + permission + "')");
}
private static void audit(String name, String event, Class<?>... parameterTypes) throws Exception {
Method method = LoadingListService.class.getDeclaredMethod(name, parameterTypes);
BusinessAudit audit = method.getAnnotation(BusinessAudit.class);
assertThat(audit.event()).isEqualTo(event);
assertThat(audit.resourceType()).isEqualTo("LOADING_PLAN");
assertThat(audit.resourceId()).isEqualTo("#planId");
}
}
@@ -0,0 +1,23 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.SmartLoadingDtos.*;
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 PlatformSmartLoadingControllerContractTest {
@Test void draftQueriesUseListPermission() throws Exception {
authority("draftByBooking","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class);
authority("detail","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class);
}
@Test void everyMutationDeclaresDedicatedPermission() throws Exception {
authority("assign","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,AssignRequest.class);
authority("recommend","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,RecommendRequest.class);
authority("adjust","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,long.class,AdjustRequest.class);
authority("delete","platform:firstleg:smart-loading:delete",AuthenticatedUser.class,long.class,long.class,DeleteRequest.class);
authority("push","platform:firstleg:smart-loading:push",AuthenticatedUser.class,long.class,String.class,PushRequest.class);
}
private void authority(String method,String permission,Class<?>...types)throws Exception{Method m=PlatformSmartLoadingController.class.getDeclaredMethod(method,types);assertThat(m.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('"+permission+"')");}
}
@@ -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());
}
}
@@ -0,0 +1,46 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.mapper.ContainerBookingMapper;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("货柜数据库映射契约")
class ContainerBookingMapperContractTest {
@Test
@DisplayName("货柜映射平台级最终表且状态只从装柜计划读取")
void mapsFinalBookingContract() throws Exception {
assertThat(ContainerBookingEntity.class.getAnnotation(TableName.class).value())
.isEqualTo("fl_container_booking");
String select = String.join("\n", ContainerBookingMapper.class
.getDeclaredMethod("selectBooking", long.class).getAnnotation(Select.class).value());
assertThat(select)
.contains("fl_loading_plan")
.contains("p.id AS plan_id")
.contains("plan_status")
.doesNotContain("tenant_id")
.doesNotContain("account_id");
}
@Test
@DisplayName("草稿完整更新和预装运输白名单都带版本与状态门禁")
void guardsUpdatesInSql() {
String allSql = Arrays.stream(ContainerBookingMapper.class.getDeclaredMethods())
.map(method -> method.getAnnotation(Update.class))
.filter(annotation -> annotation != null)
.flatMap(annotation -> Arrays.stream(annotation.value()))
.reduce("", (left, right) -> left + "\n" + right);
assertThat(allSql)
.contains("capacity_cbm")
.contains("p.plan_status&lt;&gt;'EDITING'")
.contains("p.plan_status='COMPLETED'")
.contains("version=#{expectedVersion}");
}
}

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