Compare commits

...
47 changed files with 1510 additions and 258 deletions
+2
View File
@@ -1,5 +1,7 @@
# 单测试环境数据库空库初始化
本土散板简化下单的当前结构同步于 Lastleg 源基线及海外统一空库基线:移除散板柜型与旧要求日期,增加商品名称、申报预计托数及当地取货/送达时间,移除运输方式,增加起运地及目的地各自的联系人和联系电话;四项联系信息、取货及送货时间均必填;打盘与运输安排允许未知。预计托数不代表实收。字段语义唯一见工作区 `docs/海外仓业务/README.md`,不回填测试存量,不提供自动重置或启动时 SQL;需与匹配 Lastleg、Admin 和 Client 一起发布。本说明不表示 SQL 已执行。
`migrations/overseas/baiyue_lastleg/V23__align_return_movement_collation.sql` 将退货进销存 UNION 涉及的八张表、22 个文本字段及表默认规则显式统一为 `utf8mb4_unicode_ci`,同步 Lastleg 两份源基线与海外统一空库基线。执行前核对字段长度、默认值、可空性及目标规则下唯一索引无冲突;只修改规则,不回填或删除数据,不重放空库基线。DDL 非事务,失败时停止并复查实际已完成项;可按最终定义重跑。脚本存在不表示测试库已执行,执行后用原始计数/分页查询验证,无需发布查询转换代码。
`migrations/overseas/baiyue_lastleg/V22__selfop_warehouse_daily_sales.sql` 新增自营仓库每日汇总表及销量版本 `warehouse_daily_ready` 标记。与匹配 Lastleg 一起发布;汇总随 SKU 日事实批次原子构建,完整性验证后方可发布。已有未构建汇总的销量版本须重新构建发布,不回填旧版本、不回退旧查询;空版本同样必须有就绪证据。源基线与海外空库基线同步。
@@ -1487,22 +1487,21 @@ CREATE TABLE ll_local_looseboard_order (
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
lastleg_order_id bigint DEFAULT NULL COMMENT '关联尾程主订单ID',
order_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本土散板订单号',
transport_mode varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '运输方式:拼车或优先拼载',
pallet_required varchar(8) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '是否需要打盘:YES或NO;拼车来源未提供时为空',
transport_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '运输类型',
transport_type VARCHAR(32) NULL DEFAULT NULL COMMENT '接单运输安排;下单不预设',
origin varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '起运地',
origin_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '起运详细地址',
destination varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目的地',
destination_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的详细地址、码头或仓号',
container_type varchar(32) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '柜型;拼车来源未提供时为空,不推断为整柜',
product_name VARCHAR(255) NULL COMMENT '商品名称', estimated_pallet_count INT NULL COMMENT '客户申报预计托数,不代表实际收货',
package_count int NOT NULL DEFAULT '0' COMMENT '货物件数',
total_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '总重量(千克)',
total_volume_cbm decimal(18,6) NOT NULL DEFAULT '0.000000' COMMENT '总体积(立方米)',
budget_amount decimal(18,2) DEFAULT NULL COMMENT '预算总价',
currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'USD' COMMENT '预算币种',
contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '联系人姓名',
contact_info varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '联系人联系方式',
requested_date date DEFAULT NULL COMMENT '要求服务日期',
origin_contact_name VARCHAR(128) NULL COMMENT '起运地联系人', origin_contact_phone VARCHAR(128) NULL COMMENT '起运地联系电话',
destination_contact_name VARCHAR(128) NULL COMMENT '目的地联系人', destination_contact_phone VARCHAR(128) NULL COMMENT '目的地联系电话',
pickup_at DATETIME(3) NULL COMMENT '取货时间(当地时间,不转换时区)', delivery_at DATETIME(3) NULL COMMENT '送货时间(当地时间,不转换时区)',
status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_ACCEPTANCE' COMMENT '本土散板订单状态',
remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建幂等键',
@@ -1516,7 +1515,7 @@ CREATE TABLE ll_local_looseboard_order (
UNIQUE KEY uk_ll_local_looseboard_key (customer_id,idempotency_key),
UNIQUE KEY uk_ll_local_looseboard_lastleg (lastleg_order_id),
KEY idx_ll_local_looseboard_list (customer_id,status,created_at),
KEY idx_ll_local_looseboard_date (customer_id,requested_date,created_at)
KEY idx_ll_local_looseboard_date (customer_id,pickup_at,created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土散板订单';
CREATE TABLE ll_monthly_exchange_rate (
@@ -9,6 +9,8 @@ import java.util.Arrays;
enum BusinessFileCategory {
FLEET_DRIVER_LICENSE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
FLEET_VEHICLE_RECORD_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
DELIVERY_LOADING_PHOTO(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
DELIVERY_BOL(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
@@ -67,7 +69,12 @@ enum BusinessFileCategory {
.orElseThrow(() -> new BizException(ResultCode.BAD_REQUEST, "不支持的附件业务类型"));
}
/** 校验文件归属;跨客户排车证据固定为平台所有,不随货物客户变化。 */
void validateOwner(long tenantId) {
if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE)
&& tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "排车车辆及司机证据必须归属平台");
}
if (this == FLEET_TRIP_OPERATION_EVIDENCE && tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "出车实测凭证必须归属平台");
}
@@ -0,0 +1,167 @@
openapi: 3.0.3
info:
title: 平台排车车辆与司机证据内部文件契约
version: 1.0.0
description: >-
复用 InternalBusinessFileController 的现有通用内部入口,不新增公开文件路由。
两类证据均固定平台 tenantId=0,businessId 为排车内部正数 Long ID 的十进制表示,
不使用车辆 ID、司机 ID、车牌或客户 ID。车辆与司机证据分别使用
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_EVIDENCE,不可互换。
File 校验内部服务令牌、平台归属、正数业务 ID、文件非空、最大 10MiB(10485760 字节,包含边界)、
PDF/JPG/PNG MIME 及对应内容签名。元数据和下载按 tenantId、businessType、businessId、fileId
联合查询 ACTIVE 且未删除文件,错车或错类别均视为归属不匹配,下载前完成归属校验。
上传生成不可变文件和真实元数据,不自动绑定证据或通过审核,不返回公开 URL 或存储路径。
Lastleg 负责排车存在性、仓库与角色权限、当前及历史附件引用和审核状态;File 不查询 Lastleg 业务表。
本文只描述排车网关实际消费的上传、元数据和内容三个操作,不改变通用文件接口的其他用途。
统一异常处理使用 HTTP 200 JSON 业务错误,调用方必须检查 code;内容接口错误也返回 JSON,不能当作文件。
security:
- InternalServiceToken: []
paths:
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}:
parameters:
- {$ref: '#/components/parameters/TenantId'}
- {$ref: '#/components/parameters/BusinessType'}
- {$ref: '#/components/parameters/BusinessId'}
post:
operationId: uploadDispatchEvidence
summary: 上传本排车的车辆或司机证据
parameters:
- name: X-Operator-Id
in: header
required: true
description: Lastleg 已认证操作者的正数 Long ID
schema: {$ref: '#/components/schemas/PositiveLongString'}
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file:
type: string
format: binary
description: 非空且不超过10485760字节;application/pdf、image/jpeg或image/png,并校验对应内容签名
responses:
'200': {$ref: '#/components/responses/MetadataOrError'}
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}:
parameters:
- {$ref: '#/components/parameters/TenantId'}
- {$ref: '#/components/parameters/BusinessType'}
- {$ref: '#/components/parameters/BusinessId'}
- {$ref: '#/components/parameters/FileId'}
get:
operationId: readDispatchEvidenceMetadata
summary: 按本排车与证据类别联合校验并读取元数据
responses:
'200': {$ref: '#/components/responses/MetadataOrError'}
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content:
parameters:
- {$ref: '#/components/parameters/TenantId'}
- {$ref: '#/components/parameters/BusinessType'}
- {$ref: '#/components/parameters/BusinessId'}
- {$ref: '#/components/parameters/FileId'}
get:
operationId: downloadDispatchEvidence
summary: 按完整复合归属读取文件内容
responses:
'200':
description: >-
成功返回真实文件;错误返回 application/json 统一业务错误。
A0400 表示作用域、ID或文件格式无效,A0403 表示内部令牌无效,
A0404 表示文件不存在、非有效文件或复合归属不匹配。
headers:
Cache-Control: {schema: {type: string, enum: [no-store]}}
Content-Disposition: {schema: {type: string}, description: 成功时为attachment及UTF-8文件名}
Content-Length: {schema: {type: integer, format: int64}, description: 成功时为真实文件字节数}
content:
application/pdf: {schema: {type: string, format: binary}}
image/jpeg: {schema: {type: string, format: binary}}
image/png: {schema: {type: string, format: binary}}
application/json: {schema: {$ref: '#/components/schemas/ErrorEnvelope'}}
components:
securitySchemes:
InternalServiceToken: {type: apiKey, in: header, name: X-Internal-Service-Token}
parameters:
TenantId:
name: tenantId
in: path
required: true
description: 两类排车证据只属于平台,客户作用域一律拒绝
schema: {type: string, enum: ['0']}
BusinessType:
name: businessType
in: path
required: true
schema: {$ref: '#/components/schemas/DispatchEvidenceType'}
BusinessId:
name: businessId
in: path
required: true
description: 排车内部正数Long ID,上限9223372036854775807;Lastleg以标准十进制字符串传入
schema: {$ref: '#/components/schemas/PositiveLongString'}
FileId:
name: fileId
in: path
required: true
schema: {$ref: '#/components/schemas/PositiveLongString'}
responses:
MetadataOrError:
description: >-
统一JSON响应,业务成功code为200。A0400为无效平台作用域、ID、操作者或文件;
A0403为内部服务令牌无效;A0404为文件不存在、非ACTIVE、已删除或复合归属不匹配;
S0503为存储不可用,S0500为元数据保存失败等内部异常。
content:
application/json:
schema:
oneOf:
- {$ref: '#/components/schemas/MetadataEnvelope'}
- {$ref: '#/components/schemas/ErrorEnvelope'}
schemas:
PositiveLongString:
type: string
pattern: '^[1-9][0-9]*$'
maxLength: 19
description: 正数Long的十进制字符串,值不得超过9223372036854775807
DispatchEvidenceType:
type: string
enum: [OVERSEAS_DISPATCH_VEHICLE_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_EVIDENCE]
MetadataEnvelope:
type: object
required: [code, data]
properties:
code: {type: string, enum: ['200']}
message: {type: string}
data: {$ref: '#/components/schemas/Metadata'}
requestId: {type: string, nullable: true}
traceId: {type: string, nullable: true}
timestamp: {type: string, format: date-time}
Metadata:
type: object
required: [fileId, ownerScope, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes, createdAt]
properties:
fileId: {$ref: '#/components/schemas/PositiveLongString'}
ownerScope: {type: string, enum: [PLATFORM]}
tenantId: {type: string, enum: ['0']}
businessType: {$ref: '#/components/schemas/DispatchEvidenceType'}
businessId: {$ref: '#/components/schemas/PositiveLongString'}
fileName: {type: string, minLength: 1, description: 清理路径和控制字符后的原始文件名}
sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
contentType: {type: string, enum: [application/pdf, image/jpeg, image/png]}
sizeBytes:
type: string
pattern: '^[1-9][0-9]*$'
description: 实际字节数,范围1到10485760;公共Jackson配置将Long输出为字符串
example: '1024'
createdAt: {type: string, description: File真实上传时间,LocalDateTime的ISO本地时间表示,无时区偏移}
ErrorEnvelope:
type: object
required: [code, data]
properties:
code: {type: string, enum: [A0400, A0403, A0404, A0422, S0500, S0503]}
message: {type: string}
data: {type: object, nullable: true, description: 业务错误为null;参数校验错误可携带字段信息}
requestId: {type: string, nullable: true}
traceId: {type: string, nullable: true}
timestamp: {type: string, format: date-time}
@@ -0,0 +1,245 @@
package com.baiyue.file.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.controller.InternalBusinessFileController;
import com.baiyue.file.entity.FileObjectEntity;
import com.baiyue.file.mapper.FileObjectMapper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.concurrent.atomic.AtomicLong;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class DispatchEvidenceFileTest {
private static final String VEHICLE = "OVERSEAS_DISPATCH_VEHICLE_EVIDENCE";
private static final String DRIVER = "OVERSEAS_DISPATCH_DRIVER_EVIDENCE";
private static final int MAX_BYTES = 10 * 1024 * 1024;
@TempDir Path root;
/** 初始化真实 Lambda 查询列映射,使测试能逐列核对复合归属条件。 */
@BeforeAll static void metadata() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "dispatch-evidence"),
FileObjectEntity.class);
}
/** 两类证据只接受平台与有效排车内部 ID,非法输入不得访问数据库或磁盘。 */
@ParameterizedTest
@ValueSource(strings = {VEHICLE, DRIVER})
void requiresPlatformAndPositiveDispatchIdBeforeAccess(String type) {
var files = mock(FileObjectMapper.class);
var storage = mock(BusinessFileStorage.class);
var service = service(files, storage);
var upload = pdf();
for (long customer : new long[]{-1, 1, 900}) {
assertThatThrownBy(() -> service.upload(customer, type, "71", 7, upload)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.metadata(customer, type, "71", 501)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.download(customer, type, "71", 501)).isInstanceOf(BizException.class);
}
for (String id : new String[]{"0", "-1", "DISPATCH-71", "../71", "9223372036854775808"}) {
assertThatThrownBy(() -> service.upload(0, type, id, 7, upload)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.metadata(0, type, id, 501)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.download(0, type, id, 501)).isInstanceOf(BizException.class);
}
assertThatThrownBy(() -> service.upload(0, type, "71", 0, upload)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.metadata(0, type, "71", 0)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.download(0, type, "71", 0)).isInstanceOf(BizException.class);
verifyNoInteractions(files, storage);
}
/** 真实 PDF、JPG、PNG 均可写入并保持内容;上传不以扩展名代替 MIME 和签名。 */
@ParameterizedTest
@ValueSource(strings = {VEHICLE, DRIVER})
void storesPdfJpegPngAndRejectsEmptyUnsupportedAndSpoofedContent(String type) throws Exception {
var storage = storage();
var category = BusinessFileCategory.require(type);
for (var file : new MockMultipartFile[]{pdf(),
new MockMultipartFile("file", "vehicle.jpg", "image/jpeg", image("jpeg")),
new MockMultipartFile("file", "driver.png", "image/png", image("png"))}) {
var saved = storage.store(0, "71", file, category);
assertThat(storage.read(saved.objectKey())).isEqualTo(file.getBytes());
assertThat(saved.contentType()).isEqualTo(file.getContentType());
assertThat(saved.size()).isEqualTo(file.getSize());
assertThat(saved.sha256()).matches("[0-9a-f]{64}");
}
for (var file : new MockMultipartFile[]{
new MockMultipartFile("file", "empty.pdf", "application/pdf", new byte[0]),
new MockMultipartFile("file", "fake.pdf", "application/pdf", "plain text".getBytes(StandardCharsets.UTF_8)),
new MockMultipartFile("file", "fake.jpg", "image/jpeg", image("png")),
new MockMultipartFile("file", "fake.png", "image/png", image("jpeg")),
new MockMultipartFile("file", "fake.pdf", "text/plain", pdf().getBytes()),
new MockMultipartFile("file", "fake.pdf", "application/octet-stream", pdf().getBytes()),
new MockMultipartFile("file", "missing.pdf", null, pdf().getBytes())}) {
assertThatThrownBy(() -> storage.store(0, "71", file, category)).isInstanceOf(BizException.class);
}
assertThatThrownBy(() -> storage.store(0, "71", null, category)).isInstanceOf(BizException.class);
}
/** 10MiB 为包含边界,超限在读取内容或落盘前拒绝。 */
@ParameterizedTest
@ValueSource(strings = {VEHICLE, DRIVER})
void acceptsExactlyTenMibAndRejectsOneByteOverBeforeReading(String type) throws Exception {
var storage = storage();
var category = BusinessFileCategory.require(type);
byte[] content = Arrays.copyOf(pdf().getBytes(), MAX_BYTES);
var boundary = new MockMultipartFile("file", "boundary.pdf", "application/pdf", content);
var stored = storage.store(0, "71", boundary, category);
assertThat(Files.size(root.resolve(stored.objectKey()))).isEqualTo(MAX_BYTES);
var oversized = mock(MultipartFile.class);
when(oversized.isEmpty()).thenReturn(false);
when(oversized.getSize()).thenReturn((long) MAX_BYTES + 1);
assertThatThrownBy(() -> storage.store(0, "71", oversized, category))
.isInstanceOf(BizException.class).hasMessageContaining("10MB");
verify(oversized, never()).getInputStream();
verify(oversized, never()).transferTo(any(Path.class));
}
/** 上传生成独立不可变对象和真实元数据;替换引用不覆盖原文件。 */
@ParameterizedTest
@ValueSource(strings = {VEHICLE, DRIVER})
void uploadsKeepPlatformDispatchScopeAndImmutableObjects(String type) throws Exception {
var files = mock(FileObjectMapper.class);
var storage = storage();
var service = service(files, storage);
var ids = new AtomicLong(500);
when(files.insert(any(FileObjectEntity.class))).thenAnswer(call -> {
FileObjectEntity row = call.getArgument(0);
row.setId(ids.incrementAndGet());
return 1;
});
var first = service.upload(0, type, "71", 7, pdf());
var secondFile = new MockMultipartFile("file", "updated.pdf", "application/pdf",
"%PDF-1.7\nupdated".getBytes(StandardCharsets.US_ASCII));
var second = service.upload(0, type, "71", 7, secondFile);
var rows = org.mockito.ArgumentCaptor.forClass(FileObjectEntity.class);
verify(files, times(2)).insert(rows.capture());
assertThat(first.ownerScope()).isEqualTo("PLATFORM");
assertThat(first.tenantId()).isZero();
assertThat(first.businessType()).isEqualTo(type);
assertThat(first.businessId()).isEqualTo("71");
assertThat(first.fileId()).isEqualTo(501L);
assertThat(second.fileId()).isEqualTo(502L);
assertThat(first.fileName()).isEqualTo("evidence.pdf");
assertThat(first.contentType()).isEqualTo("application/pdf");
assertThat(first.sizeBytes()).isEqualTo(pdf().getSize());
assertThat(first.sha256()).matches("[0-9a-f]{64}");
assertThat(first.createdAt()).isNotNull().isEqualTo(rows.getAllValues().get(0).getCreatedAt());
assertThat(rows.getAllValues().get(0).getObjectKey()).isNotEqualTo(rows.getAllValues().get(1).getObjectKey());
assertThat(storage.read(rows.getAllValues().get(0).getObjectKey())).isEqualTo(pdf().getBytes());
assertThat(storage.read(rows.getAllValues().get(1).getObjectKey())).isEqualTo(secondFile.getBytes());
verify(files, never()).updateById(any(FileObjectEntity.class));
}
/** 元数据和下载均逐列带全复合键;错车、错类别、错文件不得读取存储内容。 */
@ParameterizedTest
@ValueSource(strings = {VEHICLE, DRIVER})
void metadataAndDownloadEnforceEveryScopeKeyBeforeReading(String type) throws Exception {
var files = mock(FileObjectMapper.class);
var storage = mock(BusinessFileStorage.class);
var service = service(files, storage);
var row = new FileObjectEntity();
row.setId(501L); row.setTenantId(0L); row.setBusinessType(type); row.setBusinessId("71");
row.setOriginalName("evidence.pdf"); row.setContentType("application/pdf");
row.setObjectKey("business-files/0/71/evidence.pdf"); row.setSizeBytes(pdf().getSize());
row.setSha256("a".repeat(64)); row.setCreatedAt(java.time.LocalDateTime.now());
when(files.selectOne(any())).thenAnswer(call -> {
LambdaQueryWrapper<FileObjectEntity> query = call.getArgument(0);
assertThat(boundValue(query, "status")).isEqualTo("ACTIVE");
assertThat(boundValue(query, "deleted")).isEqualTo(false);
return Long.valueOf(0).equals(boundValue(query, "tenant_id"))
&& type.equals(boundValue(query, "business_type"))
&& "71".equals(boundValue(query, "business_id"))
&& Long.valueOf(501).equals(boundValue(query, "id")) ? row : null;
});
when(storage.read(row.getObjectKey())).thenReturn(pdf().getBytes());
var metadata = service.metadata(0, type, "71", 501);
assertThat(metadata.fileId()).isEqualTo(501L);
assertThat(metadata.ownerScope()).isEqualTo("PLATFORM");
assertThat(service.download(0, type, "71", 501).content()).isEqualTo(pdf().getBytes());
verify(storage).read(row.getObjectKey());
clearInvocations(storage);
String otherType = VEHICLE.equals(type) ? DRIVER : VEHICLE;
for (String candidate : new String[]{otherType, "FLEET_DRIVER_LICENSE", "FLEET_TRIP_OPERATION_EVIDENCE"}) {
assertThatThrownBy(() -> service.metadata(0, candidate, "71", 501))
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
assertThatThrownBy(() -> service.download(0, candidate, "71", 501))
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
}
assertThatThrownBy(() -> service.metadata(0, type, "72", 501)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.download(0, type, "72", 501)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.metadata(0, type, "71", 502)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.download(0, type, "71", 502)).isInstanceOf(BizException.class);
verifyNoInteractions(storage);
}
/** 内部服务令牌在业务访问前校验,不对未授权请求暴露元数据或文件。 */
@ParameterizedTest
@ValueSource(strings = {VEHICLE, DRIVER})
void invalidInternalTokenCannotUploadReadMetadataOrDownload(String type) {
var service = mock(InternalBusinessFileService.class);
var controller = new InternalBusinessFileController();
ReflectionTestUtils.setField(controller, "expected", "dispatch-test-token");
ReflectionTestUtils.setField(controller, "service", service);
for (String token : new String[]{null, "", "wrong-token"}) {
assertThatThrownBy(() -> controller.upload(token, 7, 0, type, "71", pdf())).isInstanceOf(BizException.class);
assertThatThrownBy(() -> controller.metadata(token, 0, type, "71", 501)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> controller.download(token, 0, type, "71", 501)).isInstanceOf(BizException.class);
}
verifyNoInteractions(service);
}
/** 从实际 SQL 绑定中取指定列的等值条件,缺少任一隔离条件直接失败。 */
private static Object boundValue(LambdaQueryWrapper<FileObjectEntity> query, String column) {
var matcher = Pattern.compile("(?<![A-Za-z0-9_])" + column
+ " = #\\{ew\\.paramNameValuePairs\\.([^}]+)}").matcher(query.getSqlSegment());
assertThat(matcher.find()).as("复合归属条件 %s", column).isTrue();
return query.getParamNameValuePairs().get(matcher.group(1));
}
/** 创建隔离临时目录中的真实存储,不访问运行环境。 */
private BusinessFileStorage storage() {
var storage = new BusinessFileStorage();
ReflectionTestUtils.setField(storage, "configuredRoot", root);
storage.initializeRoot();
return storage;
}
/** 复用生产服务,数据库边界由 Mapper 替身记录实际查询。 */
private InternalBusinessFileService service(FileObjectMapper files, BusinessFileStorage storage) {
var service = new InternalBusinessFileService();
ReflectionTestUtils.setField(service, "files", files);
ReflectionTestUtils.setField(service, "storage", storage);
return service;
}
/** 提供具有真实 PDF 文件头的最小上传内容。 */
private static MockMultipartFile pdf() {
return new MockMultipartFile("file", "evidence.pdf", "application/pdf",
"%PDF-1.7\nevidence".getBytes(StandardCharsets.US_ASCII));
}
/** 使用图像编码器生成真实 JPG、PNG,避免测试只验证扩展名。 */
private static byte[] image(String format) throws java.io.IOException {
var output = new ByteArrayOutputStream();
var image = new java.awt.image.BufferedImage(2, 2, java.awt.image.BufferedImage.TYPE_INT_RGB);
assertThat(javax.imageio.ImageIO.write(image, format, output)).isTrue();
return output.toByteArray();
}
}
@@ -35,6 +35,28 @@ public class PlatformDropshipPalletizationController {
@Resource private DropshipPalletizationService service;
@Resource private InboundCartonLabelPrintService printService;
/** 跨票识别箱码只读预览,清点仍按原任务领取并提交。 */
@GetMapping("/cartons/resolve")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ApiResponse<DropshipPalletizationDtos.ScanContext> resolveWorkspaceCarton(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Positive long warehouseId,
@RequestParam @jakarta.validation.constraints.NotBlank @Size(max = 80) String boxCode) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.resolveWorkspaceCarton(warehouseId, boxCode, user.userId()));
}
/** 获取当前实物托盘及客户组成,不要求托盘已与当前票绑定。 */
@GetMapping("/pallets/resolve")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ApiResponse<DropshipPalletizationDtos.PalletWorkspace> workspacePallet(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Positive long warehouseId,
@RequestParam @jakarta.validation.constraints.Pattern(regexp = "DP-[0-9]{6}-[0-9]{4}") String palletNo) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.workspacePallet(warehouseId, palletNo));
}
@GetMapping("/tasks/pending")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ApiResponse<List<DropshipPalletizationDtos.PendingTask>> pending(
@@ -18,6 +18,15 @@ public final class DropshipPalletizationDtos {
public record ClaimRequest(@Positive long warehouseId) {}
/** 跨票只读识别结果;确认清点前仍须领取对应任务。 */
public record ScanContext(Task task, Carton carton) {}
/** 当前实物托盘及逐客户、逐票组成。 */
public record PalletWorkspace(Pallet pallet, List<PalletMember> members) {}
public record PalletMember(Long taskId, Long inboundId, String inboundCode,
Long customerId, String customerName, Integer cartonCount) {}
public enum DiscrepancyAction { MATCH, ACCEPT_VARIANCE }
public record CountCartonRequest(@Positive long warehouseId,
@@ -15,6 +15,45 @@ import java.util.List;
/** 一件代发逐箱清点、待绑托与封托事实。 */
@Mapper
public interface DropshipPalletizationMapper {
/** 在指定仓库按稳定箱码识别任务,多个匹配由业务层拒绝。 */
@Select("""
SELECT DISTINCT t.id FROM fl_dropship_pallet_task t
JOIN fl_inbound_order o ON o.id=t.inbound_id AND o.tenant_id=t.tenant_id AND o.deleted=0
JOIN fl_inbound_carton_label l ON l.inbound_id=t.inbound_id AND l.tenant_id=t.tenant_id
WHERE t.warehouse_id=#{warehouseId} AND l.box_code=#{boxCode} AND l.batch_status='ACTIVE'
AND o.fulfillment_type='ONE_CLICK_DROPSHIP'
""")
List<Long> workspaceTaskIds(@Param("warehouseId") long warehouseId,@Param("boxCode") String boxCode);
/** 扫码预览为只读查询,不能把未知或已封托托盘默认成开放。 */
@Select("SELECT * FROM fl_dropship_pallet WHERE warehouse_id=#{warehouseId} AND pallet_no=#{palletNo}")
PalletRow workspacePallet(@Param("warehouseId") long warehouseId,@Param("palletNo") String palletNo);
/** 按实际成员汇总客户与票,空的预生成托盘不虚构成员。 */
@Select("""
SELECT c.task_id,c.inbound_id,o.inbound_code,c.tenant_id customer_id,o.customer_name,COUNT(*) carton_count
FROM fl_dropship_pallet_carton c
JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id
WHERE c.pallet_id=#{palletId}
GROUP BY c.task_id,c.inbound_id,o.inbound_code,c.tenant_id,o.customer_name
ORDER BY o.inbound_code,c.task_id
""")
List<com.baiyue.firstleg.dto.DropshipPalletizationDtos.PalletMember> workspaceMembers(@Param("palletId") long palletId);
/** 混托成员必须与当前票同渠道、同目的仓,客户可以不同。 */
@Select("""
SELECT COUNT(*) FROM fl_dropship_pallet_carton c
JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id
JOIN fl_dropship_pallet_task t ON t.id=#{taskId}
JOIN fl_inbound_order target ON target.id=t.inbound_id AND target.tenant_id=t.tenant_id
WHERE c.pallet_id=#{palletId} AND (
NOT (o.channel_code <=> target.channel_code)
OR NOT (o.destination_type <=> target.destination_type)
OR NOT (o.destination_warehouse_id <=> target.destination_warehouse_id)
OR NOT (o.destination_code <=> target.destination_code))
""")
int workspaceIncompatibleMembers(@Param("taskId") long taskId,@Param("palletId") long palletId);
@Insert("""
INSERT IGNORE INTO fl_dropship_pallet_task
(id,tenant_id,inbound_id,warehouse_id,task_status,total_cartons,counted_cartons,allocated_cartons,discrepancy_cartons,version,created_at,updated_at)
@@ -0,0 +1,39 @@
package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.dto.DropshipPalletizationDtos.PalletMember;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.PalletRow;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 仓内跨票扫码与实物托盘组成查询,客户归属始终从箱唛事实取得。 */
@Repository
public class DropshipWorkspaceRepository {
@Resource private DropshipPalletizationMapper mapper;
/** 按仓库和稳定箱码查找作业任务,不接受客户端指定客户归属。 */
public List<Long> taskIdsForBox(long warehouseId, String boxCode) {
return mapper.workspaceTaskIds(warehouseId, boxCode);
}
/** 只读查询托盘,不因扫描托盘产生绑定或状态变化。 */
public PalletRow pallet(long warehouseId, String palletNo) {
return mapper.workspacePallet(warehouseId, palletNo);
}
/** 保留每票客户与任务身份,供混托核对和按原任务移箱。 */
public List<PalletMember> members(long palletId) {
return mapper.workspaceMembers(palletId);
}
/** 归托前锁定实物托盘,与封托互斥。 */
public PalletRow lockPallet(String palletNo) {
return mapper.palletByNo(palletNo);
}
/** 按原始入仓快照检查运输兼容性,不接受客户端声明。 */
public boolean compatible(long taskId, long palletId) {
return mapper.workspaceIncompatibleMembers(taskId, palletId) == 0;
}
}
@@ -21,6 +21,34 @@ import java.util.*;
public class DropshipPalletizationService {
@Resource private DropshipPalletizationMapper mapper;
@Resource private FirstlegDocumentNumberGenerator documentNumbers;
@Resource private com.baiyue.firstleg.repository.DropshipWorkspaceRepository workspaceRepository;
/** 只读识别其他客户的箱码;不自动领取、清点或绑定托盘。 */
public ScanContext resolveWorkspaceCarton(long warehouseId,String boxCode,long operatorId){
String code=normalize(boxCode);
if(code==null) throw invalid("箱码不能为空");
List<Long> ids=workspaceRepository.taskIdsForBox(warehouseId,code);
if(ids.isEmpty()) throw new BizException(ResultCode.NOT_FOUND,"当前仓库未找到该一件代发箱码");
if(ids.size()!=1) throw conflict("箱码匹配多个任务,请联系后台核对");
Task target=task(ids.get(0),warehouseId,operatorId);
Carton carton=resolveCarton(target.taskId(),warehouseId,code,operatorId);
// 未领取任务仅允许预览,员工确认后由正式领取与清点接口再次检查权限和状态。
if("PENDING".equals(target.taskStatus()) && Boolean.TRUE.equals(target.palletSetupReady())
&& "请先领取任务".equals(carton.blockReason())){
carton=new Carton(carton.cartonId(),carton.boxCode(),carton.inboundId(),carton.inboundCode(),
carton.skuLines(),carton.countStatus(),carton.palletId(),carton.palletNo(),true,null);
}
return new ScanContext(target,carton);
}
/** 按仓库返回真实托盘状态及全部成员,跨客户不改变每箱的原归属。 */
public PalletWorkspace workspacePallet(long warehouseId,String palletNo){
String code=normalize(palletNo);
if(code==null) throw invalid("托盘码不能为空");
PalletRow row=workspaceRepository.pallet(warehouseId,code);
if(row==null) throw new BizException(ResultCode.NOT_FOUND,"当前仓库未找到该托盘码");
return new PalletWorkspace(palletView(row,null),workspaceRepository.members(row.getId()));
}
@Transactional
public void createTask(InboundOrderEntity inbound,long operatorId,String operatorName) {
@@ -195,11 +223,13 @@ public class DropshipPalletizationService {
.map(p->palletView(p,task.getInboundCode())).toList();
}
/** 跨客户归托仍须同仓、同渠道、同目的仓,并锁定开放托盘。 */
private PalletRow requireAssignablePallet(TaskRow task,String palletNo){
PalletRow existing=mapper.palletByNo(palletNo);
PalletRow existing=workspaceRepository.lockPallet(palletNo);
if(existing==null) throw new BizException(ResultCode.NOT_FOUND,"托盘码不存在,请扫描测量审核后生成的 DP 托盘码");
if(!Objects.equals(existing.getWarehouseId(),task.getWarehouseId())) throw conflict("托盘码不属于当前作业仓库");
if(!"OPEN".equals(existing.getPalletStatus())) throw conflict("托盘已封托,不能继续绑定箱");
if(!workspaceRepository.compatible(task.getId(),existing.getId())) throw conflict("托内货物与当前票的渠道或目的仓不同,不能混托");
return existing;
}
@@ -4,6 +4,26 @@ info:
version: 3.0.0
description: 测量环节录入预计托数,审核通过后预生成全部 DP 托盘码并可合并打印。PDA 支持先扫箱或先扫托,同一国内作业仓可跨票共用开放托盘,实物装满后再封托。
paths:
/api/platform/v1/first-leg/pda-dropship/cartons/resolve:
get:
summary: 同仓跨客户识别箱码并预览原任务
description: 只读,不领取或清点;返回 task 与 carton,确认时仍须领取原任务后按原 taskId 清点。拒绝其他作业员已领取任务及箱码歧义。
x-required-authority: platform:firstleg:pda:view
parameters:
- {$ref: '#/components/parameters/WarehouseId'}
- {name: boxCode, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 80}}
responses:
'200': {description: 原任务和箱内 SKU 预览}
/api/platform/v1/first-leg/pda-dropship/pallets/resolve:
get:
summary: 查询当前仓库真实托盘状态与逐客户逐票组成
description: 扫描不产生归托;返回 pallet 与 members,成员保留 taskId、inboundId、inboundCode、customerId、customerName、cartonCount。不存在或跨仓的托盘拒绝,已封托返回真实状态。
x-required-authority: platform:firstleg:pda:view
parameters:
- {$ref: '#/components/parameters/WarehouseId'}
- {name: palletNo, in: query, required: true, schema: {type: string, pattern: 'DP-[0-9]{6}-[0-9]{4}'}}
responses:
'200': {description: 实物托盘状态和成员汇总}
/api/platform/v1/first-leg/pda-dropship/tasks/pending:
get:
summary: 查询待领取或本人已领取任务
@@ -24,7 +24,56 @@ class DropshipPalletizationServiceTest {
private final FirstlegDocumentNumberGenerator numbers=mock(FirstlegDocumentNumberGenerator.class);
private final DropshipPalletizationMapper mapper=mock(DropshipPalletizationMapper.class);
private final DropshipPalletizationService service=new DropshipPalletizationService();
DropshipPalletizationServiceTest(){ ReflectionTestUtils.setField(service,"mapper",mapper); ReflectionTestUtils.setField(service,"documentNumbers",numbers); when(mapper.labelSkus(anyLong(),anyLong())).thenAnswer(call -> java.util.List.of(labelSku())); when(mapper.cartonSkus(anyLong())).thenAnswer(call -> java.util.List.of(cartonSku())); }
private final com.baiyue.firstleg.repository.DropshipWorkspaceRepository workspace=mock(com.baiyue.firstleg.repository.DropshipWorkspaceRepository.class);
/** 使用正式仓储入口的等价锁定查询,保留既有扫码测试的事实准备。 */
DropshipPalletizationServiceTest(){ ReflectionTestUtils.setField(service,"mapper",mapper); ReflectionTestUtils.setField(service,"documentNumbers",numbers); ReflectionTestUtils.setField(service,"workspaceRepository",workspace); when(workspace.lockPallet(anyString())).thenAnswer(call -> mapper.palletByNo(call.getArgument(0))); when(workspace.compatible(anyLong(),anyLong())).thenReturn(true); when(mapper.labelSkus(anyLong(),anyLong())).thenAnswer(call -> java.util.List.of(labelSku())); when(mapper.cartonSkus(anyLong())).thenAnswer(call -> java.util.List.of(cartonSku())); }
/** 跨客户预览不领取任务,不写入清点事实。 */
@Test void workspaceResolvesPendingCustomerWithoutMutating(){
ReflectionTestUtils.setField(service,"workspaceRepository",workspace);
TaskRow other=task(); other.setTenantId(2L); other.setTaskStatus("PENDING"); other.setAssigneeId(null);
when(workspace.taskIdsForBox(100L,"BOX-1")).thenReturn(java.util.List.of(10L));
when(mapper.task(10L,100L)).thenReturn(other);
when(mapper.label(10L,"BOX-1")).thenReturn(label());
when(mapper.palletCountByInbound(2L,30L)).thenReturn(other.getExpectedPalletCount());
var result=service.resolveWorkspaceCarton(100L," BOX-1 ",7L);
assertThat(result.task().tenantId()).isEqualTo(2L);
assertThat(result.carton().canCount()).isTrue();
verify(mapper,never()).claim(anyLong(),anyLong(),anyLong(),anyString());
verify(mapper,never()).addCounted(anyLong(),anyLong(),anyInt(),anyInt());
}
/** 不允许从跨票扫码接管其他作业员的任务。 */
@Test void workspaceRejectsOtherOperatorsTaskAndAmbiguousBox(){
ReflectionTestUtils.setField(service,"workspaceRepository",workspace);
when(workspace.taskIdsForBox(100L,"BOX-1")).thenReturn(java.util.List.of(10L));
TaskRow other=task(); other.setAssigneeId(9L); when(mapper.task(10L,100L)).thenReturn(other);
assertThatThrownBy(()->service.resolveWorkspaceCarton(100L,"BOX-1",7L)).isInstanceOf(BizException.class).hasMessageContaining("其他人员");
when(workspace.taskIdsForBox(100L,"BOX-1")).thenReturn(java.util.List.of(10L,11L));
assertThatThrownBy(()->service.resolveWorkspaceCarton(100L,"BOX-1",7L)).isInstanceOf(BizException.class).hasMessageContaining("多个任务");
}
/** 托盘预览必须返回已封托的真实状态,跨仓不存在时拒绝。 */
@Test void workspacePalletNeverInventsOpenState(){
ReflectionTestUtils.setField(service,"workspaceRepository",workspace);
when(workspace.pallet(100L,"DP-260911-0001")).thenReturn(sealedPallet());
when(workspace.members(20L)).thenReturn(java.util.List.of());
when(mapper.cartons(20L)).thenReturn(java.util.List.of());
assertThat(service.workspacePallet(100L,"DP-260911-0001").pallet().status()).isEqualTo("SEALED");
assertThatThrownBy(()->service.workspacePallet(101L,"DP-260911-0001")).isInstanceOf(BizException.class).hasMessageContaining("未找到");
}
/** 即使不同客户允许混托,运输渠道或目的仓不同仍须拒绝。 */
@Test void incompatibleDestinationCannotBeMixed(){
when(mapper.task(10L,100L)).thenReturn(task());
when(mapper.palletCountByInbound(1L,30L)).thenReturn(2);
when(mapper.label(10L,"BOX-1")).thenReturn(label());
when(mapper.palletByNo("DP-260911-0001")).thenReturn(pallet());
when(workspace.compatible(10L,20L)).thenReturn(false);
assertThatThrownBy(()->service.countCarton(10L,new CountCartonRequest(100L,"BOX-1",java.util.List.of(new CountSkuLine(40L,"SKU-1",12,DiscrepancyAction.MATCH,null)),"DP-260911-0001","wrong-destination"),7L,"张三"))
.isInstanceOf(BizException.class).hasMessageContaining("渠道或目的仓不同");
verify(mapper,never()).addCounted(anyLong(),anyLong(),anyInt(),anyInt());
}
@Test void sharedPalletCountsDistinctSkuCodesWithoutMergingInboundDetails(){
when(mapper.inboundCode(1L,30L)).thenReturn("IN-1");
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.delivery.repository;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.HashSet;
/** 出库查询按实物箱汇总,退货混装 SKU 不重复计箱。 */
@Repository
public class OutboundQuantityRepository {
@Resource private OutboundPalletItemMapper pallets;
/** 废弃退货来源依据已冻结箱唛计数,读取已有单据也不会把件数当箱数。 */
public Integer boxes(OutboundOrder outbound) {
if (!"DISCARD_OUTBOUND".equals(outbound.getOutboundType())) return outbound.getPackageCount();
var lines = pallets.findByOutbound(outbound.getCustomerId(), outbound.getId());
if (lines.isEmpty()) return outbound.getPackageCount();
var returnBoxes = new HashSet<String>();
int count = 0;
for (var line : lines) {
if (line.getReturnSkuQuantity() != null && line.getCartonMarks() != null && !line.getCartonMarks().isBlank()) {
returnBoxes.add(line.getInboundCode() + ":" + line.getCartonMarks());
} else {
count = Math.addExact(count, java.util.Objects.requireNonNullElse(line.getPackageCount(), 0));
}
}
return Math.addExact(count, returnBoxes.size());
}
}
@@ -94,6 +94,7 @@ import java.util.stream.Collectors;
@Service
public class DeliveryTransferService {
@Resource private com.baiyue.lastleg.delivery.repository.OutboundQuantityRepository outboundQuantities;
@Resource private DispatchOrderMapper dispatchOrders;
@Resource private DispatchItemMapper dispatchItems;
@Resource private OutboundOrderMapper outboundOrders;
@@ -1631,7 +1632,8 @@ public class DeliveryTransferService {
d.getDeliveryNo(), d.getStatus(), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(),
d.getDestinationWarehouseName(), value(d.getPalletCount()), value(d.getPackageCount()))).toList(), replayed);
}
private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? null : o.getLxCodes(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getGrossWeightKg(), o.getVolumeWeightKg(), o.getVolumeCbm(), o.getCreatedAt(), o.getOutboundType()); }
/** 出库列表及详情头部按物理箱数汇总,退货件数不充当箱数。 */
private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? null : o.getLxCodes(), o.getStatus(), o.getPalletCount(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? outboundQuantities.boxes(o) : o.getPackageCount(), o.getGrossWeightKg(), o.getVolumeWeightKg(), o.getVolumeCbm(), o.getCreatedAt(), o.getOutboundType()); }
private DeliveryResponses.OutboundPalletRow outboundPalletRow(OutboundPalletItem item) {
return new DeliveryResponses.OutboundPalletRow(item.getId(), item.getPalletNo(), item.getCartonMarks(),
item.getInboundCode(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(),
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.discard.model;
import com.baiyue.lastleg.discard.entity.CargoDiscardItem;
import java.util.HashSet;
import java.util.List;
/** 废弃单汇总使用物理箱数,退货明细的数量仍保留为 SKU 件数。 */
public final class CargoDiscardQuantities {
/** 数量规则不需要实例化。 */
private CargoDiscardQuantities() { }
/** 退货混装箱按箱 ID 去重,其余来源累加已选择的物理箱数。 */
public static int boxes(List<CargoDiscardItem> lines, boolean actual) {
var returnBoxes = new HashSet<Long>();
int total = 0;
for (var line : lines) {
int quantity = actual ? java.util.Objects.requireNonNullElse(line.getActualDiscardPackages(), 0) : line.getDiscardPackages();
if (line.getReturnCartonId() != null) {
if (quantity > 0) returnBoxes.add(line.getReturnCartonId());
} else {
total = Math.addExact(total, quantity);
}
}
return Math.addExact(total, returnBoxes.size());
}
}
@@ -19,8 +19,9 @@ public final class PdaCargoDiscardDtos {
List<@NotBlank String> scannedCartonMarks, Boolean confirmAllCartons) {
public Confirm(List<ActualLine> lines, List<Long> photoFileIds) { this(lines, photoFileIds, null, false); }
}
/** 明细数量按来源标识单位:退货库存为件,其余箱级库存为箱。 */
public record Sku(Long itemId, String skuCode, String productName, int plannedPackages,
Integer actualPackages, String status) { }
Integer actualPackages, String status, String quantityUnit) { }
public record PalletTask(String discardNo, String customerId, String overseasWarehouseId,
String palletNo, Long profileId, String status, List<Sku> skus, String outboundNo,
String scanMode, List<String> cartonMarks) { }
@@ -117,8 +117,8 @@ public class PdaCargoDiscardService {
.forEach(cartonId -> adjustments.finishReturnCarton(application.getCustomerId(), cartonId));
if (items.listApplication(application.getCustomerId(), application.getId()).stream()
.allMatch(i -> "COMPLETED".equals(i.getPdaStatus()))) {
int actualTotal = items.listApplication(application.getCustomerId(), application.getId()).stream()
.mapToInt(i -> i.getActualDiscardPackages() == null ? 0 : i.getActualDiscardPackages()).sum();
int actualTotal = com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(
items.listApplication(application.getCustomerId(), application.getId()), true);
application.setTotalPackages(actualTotal);
application.setTotalFee(application.getFeePerPackage().multiply(java.math.BigDecimal.valueOf(actualTotal)));
discardService.updateLinkedStatus(application.getCustomerId(), application, CargoDiscardService.APPROVED,
@@ -160,16 +160,20 @@ public class PdaCargoDiscardService {
}
private static final String LOOSE = "散箱";
/** 按实托汇总本单所选箱唛,退货件数与物理箱数分别呈现。 */
private List<PdaCargoDiscardDtos.PalletTask> palletTasks(CargoDiscardApplication application) {
var allItems = items.listApplication(application.getCustomerId(), application.getId());
var grouped = allItems.stream().collect(Collectors.groupingBy(i -> physicalCode(application, i), LinkedHashMap::new, Collectors.toList()));
return grouped.entrySet().stream().map(entry -> {
CargoDiscardItem first = entry.getValue().get(0);
var skus = entry.getValue().stream().map(i -> new PdaCargoDiscardDtos.Sku(i.getId(), i.getSkuCode(), i.getProductName(),
i.getDiscardPackages(), i.getActualDiscardPackages(), i.getPdaStatus() == null ? "PENDING" : i.getPdaStatus())).toList();
i.getDiscardPackages(), i.getActualDiscardPackages(), i.getPdaStatus() == null ? "PENDING" : i.getPdaStatus(),
isReturnItem(i) ? "件" : "箱")).toList();
String status = skus.stream().allMatch(i -> "COMPLETED".equals(i.status())) ? "COMPLETED" : "PENDING";
var marks = entry.getValue().stream().flatMap(i -> cartonMarks(application, i).stream()).distinct().sorted().toList();
boolean cartonScan = LOOSE.equals(entry.getKey()) || entry.getValue().stream().anyMatch(i -> Boolean.TRUE.equals(i.getCartonScanRequired()));
// 退货箱只代表本单选择的箱子,不能因为有托盘号就当成整托废弃。
boolean cartonScan = LOOSE.equals(entry.getKey()) || entry.getValue().stream()
.anyMatch(i -> isReturnItem(i) || Boolean.TRUE.equals(i.getCartonScanRequired()));
if (cartonScan && marks.isEmpty()) throw conflict("散箱废弃任务缺少箱唛");
return new PdaCargoDiscardDtos.PalletTask(application.getDiscardNo(), application.getCustomerId(), application.getOverseasWarehouseId(),
entry.getKey(), first.getInventoryPositionId() != null ? first.getInventoryPositionId() : isReturnItem(first) ? first.getReturnCartonId() : first.getProfileId(),
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.discard.repository;
import com.baiyue.lastleg.discard.entity.CargoDiscardApplication;
import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper;
import com.baiyue.lastleg.discard.model.CargoDiscardQuantities;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
/** 从申请明细读取物理箱汇总,不修改历史库存或件数。 */
@Repository
public class CargoDiscardQuantityRepository {
@Resource private CargoDiscardItemMapper items;
/** 列表和详情均按所选实物箱展示;空明细保留申请自身快照。 */
public int boxes(CargoDiscardApplication application) {
var lines = items.listApplication(application.getCustomerId(), application.getId());
return lines.isEmpty() ? application.getTotalPackages() : CargoDiscardQuantities.boxes(lines, false);
}
}
@@ -39,7 +39,7 @@ public class CargoDiscardOutboundService {
return outbound == null ? null : outbound.getOutboundNo();
}
// The caller holds the application lock and owns the approval transaction.
/** 审核事务持有申请锁时创建出库投影,汇总箱数与退货件数分开保存。 */
public void create(CargoDiscardApplication application, String operatorId) {
if (outbounds.findByIdempotency(application.getCustomerId(), key(application)) != null) return;
var lines = items.listApplication(application.getCustomerId(), application.getId());
@@ -51,12 +51,13 @@ public class CargoDiscardOutboundService {
outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", application.getOverseasWarehouseId(), outbound.getId()));
outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType(TYPE);
outbound.setLxCodes(null); outbound.setRecipientInfo("废弃单 " + application.getDiscardNo());
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(application.getTotalPackages());
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, false));
outbound.setIdempotencyKey(key(application)); outbound.setRequestHash(key(application));
outbound.setVolumeWeightKg(BigDecimal.ZERO); outbound.setVolumeCbm(BigDecimal.ZERO);
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(at); outbound.setUpdatedAt(at);
var snapshots = new java.util.ArrayList<OutboundPalletItem>();
var palletCodes = new java.util.HashSet<String>();
var returnBoxIds = new java.util.HashSet<Long>();
for (var line : lines) {
boolean returned = line.getReturnCartonId() != null || "RETURN_CARTON".equals(line.getSourceType());
String palletNo;
@@ -83,11 +84,12 @@ public class CargoDiscardOutboundService {
}
if (palletNo != null && !palletNo.isBlank()) palletCodes.add(palletNo);
OutboundPalletItem snapshot = new OutboundPalletItem();
// Stable one-to-one projection key in a separate table, never used to deduct stock.
// 出库明细与申请明细一一对应,仅作投影,不通过此表扣减库存。
snapshot.setId(line.getId()); snapshot.setCustomerId(application.getCustomerId()); snapshot.setOutboundOrderId(outbound.getId());
snapshot.setPalletNo(palletNo); snapshot.setSkuCode(line.getSkuCode()); snapshot.setInboundCode(line.getInboundCode());
snapshot.setOrderRefs(application.getDiscardNo()); snapshot.setCartonMarks(cartonMarks);
snapshot.setPackageCount(line.getDiscardPackages()); snapshot.setPickedPackageCount(0); snapshot.setStatus("WAIT_PICK");
snapshot.setPackageCount(returned ? (returnBoxIds.add(line.getReturnCartonId()) ? 1 : 0) : line.getDiscardPackages());
snapshot.setPickedPackageCount(0); snapshot.setStatus("WAIT_PICK");
if (returned) snapshot.setReturnSkuQuantity((long) line.getDiscardPackages());
snapshot.setVolumeWeightKg(BigDecimal.ZERO); snapshot.setVolumeCbm(BigDecimal.ZERO); snapshot.setCreatedAt(at);
snapshots.add(snapshot);
@@ -96,15 +98,21 @@ public class CargoDiscardOutboundService {
snapshots.forEach(pallets::insert);
}
/** 同步实物箱的拣货进度,退货 SKU 件数不进入箱数汇总。 */
public void sync(CargoDiscardApplication application) {
OutboundOrder outbound = outbounds.lockByIdempotency(application.getCustomerId(), key(application));
if (outbound == null) throw conflict("废弃出库单不存在,请联系平台核对审核结果");
var lines = items.listApplication(application.getCustomerId(), application.getId());
var returnBoxIds = new java.util.HashSet<Long>();
for (var snapshot : pallets.findByOutbound(application.getCustomerId(), outbound.getId())) {
var line = lines.stream().filter(i -> Objects.equals(i.getId(), snapshot.getId())).findFirst()
.orElseThrow(() -> conflict("废弃出库明细关联失效"));
boolean returned = line.getReturnCartonId() != null;
int boxes = returned ? (returnBoxIds.add(line.getReturnCartonId()) ? 1 : 0) : line.getDiscardPackages();
snapshot.setPackageCount(boxes);
if ("COMPLETED".equals(line.getPdaStatus())) {
snapshot.setPickedPackageCount(Objects.requireNonNullElse(line.getActualDiscardPackages(), 0));
snapshot.setPickedPackageCount(returned ? (Objects.requireNonNullElse(line.getActualDiscardPackages(), 0) > 0 ? boxes : 0)
: Objects.requireNonNullElse(line.getActualDiscardPackages(), 0));
snapshot.setStatus("PICKED");
}
if (CargoDiscardService.CANCELLED.equals(application.getStatus())) snapshot.setStatus("CANCELLED");
@@ -117,7 +125,7 @@ public class CargoDiscardOutboundService {
};
outbound.setStatus(status);
outbound.setLxCodes(null);
if ("OUTBOUNDED".equals(status)) outbound.setPackageCount(application.getTotalPackages());
outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, "OUTBOUNDED".equals(status)));
outbound.setUpdatedAt(LocalDateTime.now()); outbounds.updateById(outbound);
}
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
@@ -50,6 +50,7 @@ import java.util.Set;
@Service
public class CargoDiscardService {
@Resource private com.baiyue.lastleg.discard.repository.CargoDiscardQuantityRepository quantities;
@Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator;
public static final String APPROVAL_PERMISSION = "platform:overseas-warehouse:discard:approve";
public static final String PENDING_REVIEW = "PENDING_REVIEW";
@@ -138,6 +139,7 @@ public class CargoDiscardService {
supported ? null : "当前库存缺少完整箱唛,可按全部库存废弃");
}
/** 校验并预留所选库存,物理箱汇总与退货 SKU 件数分别保留。 */
@Transactional
public CargoDiscardResponses.Detail create(String customerId, String operatorId,
CargoDiscardRequests.Create request) {
@@ -198,7 +200,8 @@ public class CargoDiscardService {
} else if (!warehouseId.equals(itemWarehouseId)) {
throw conflict("One cargo discard application cannot span operating warehouses");
}
total = Math.addExact(total, input.discardPackages());
// 退货明细为件数,整箱只在全部 SKU 校验完成后按箱 ID 计一次。
if (!returnSource) total = Math.addExact(total, input.discardPackages());
}
for (Long cartonId : returnCartonCache.keySet()) {
@@ -209,6 +212,7 @@ public class CargoDiscardService {
.map(i -> i.skuCode().trim()).collect(java.util.stream.Collectors.toSet());
if (!availableSkus.equals(requestedSkus)) throw conflict("目前不支持部分废弃,必须选择退货箱内全部 SKU 库存");
}
total = Math.addExact(total, returnCartonCache.size());
LocalDateTime now = LocalDateTime.now();
if (!StringUtils.hasText(warehouseName)) warehouseName = warehouseNames().get(warehouseId);
CargoDiscardApplication application = new CargoDiscardApplication();
@@ -506,10 +510,12 @@ public class CargoDiscardService {
return names;
}
/** 废弃列表与详情按物理箱汇总,并按每箱单价计算应展示费用。 */
private CargoDiscardResponses.Summary summary(CargoDiscardApplication value, Map<String, String> names) {
int boxes = quantities.boxes(value);
return new CargoDiscardResponses.Summary(value.getId(), value.getCustomerId(), value.getDiscardNo(), value.getInboundCodes(),
value.getOverseasWarehouseId(), StringUtils.hasText(value.getOverseasWarehouseName()) ? value.getOverseasWarehouseName() : names.get(value.getOverseasWarehouseId()), value.getTotalPackages(),
value.getTotalFee(), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(),
value.getOverseasWarehouseId(), StringUtils.hasText(value.getOverseasWarehouseName()) ? value.getOverseasWarehouseName() : names.get(value.getOverseasWarehouseId()), boxes,
value.getFeePerPackage() == null ? value.getTotalFee() : value.getFeePerPackage().multiply(BigDecimal.valueOf(boxes)), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(),
value.getReviewedAt(), outboundService.outboundNo(value), value.getCustomerConfirmedAt());
}
@@ -3,9 +3,7 @@ package com.baiyue.lastleg.fleet.business;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.looseboard.entity.*;
import com.baiyue.lastleg.looseboard.mapper.*;
import com.baiyue.lastleg.trucking.entity.*;
import com.baiyue.lastleg.trucking.mapper.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus;
@@ -22,34 +20,32 @@ import static com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.*;
/** Same-database bridge. Never creates inventory, publishes fees, or locks a parent trip. */
@Service
public class FleetPoolOrderBridge {
private final FleetPoolOrderMapper links;
private final LocalLooseboardOrderMapper loose;
private final LocalLooseboardHistoryMapper looseHistory;
private final UsTruckOrderMapper trucks;
private final UsTruckOrderStatusHistoryMapper truckHistory;
private final FleetPoolOrderRepository repository;
private final FleetPoolOrderNumberGenerator numbers;
private final ObjectMapper json;
private final FleetPoolDeliveryEvidence deliveryEvidence;
private final com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources;
public FleetPoolOrderBridge(FleetPoolOrderMapper links,LocalLooseboardOrderMapper loose,
LocalLooseboardHistoryMapper looseHistory,UsTruckOrderMapper trucks,
UsTruckOrderStatusHistoryMapper truckHistory,FleetPoolOrderNumberGenerator numbers,ObjectMapper json,FleetPoolDeliveryEvidence deliveryEvidence,
/** 注入同库商业委托仓储及履约协作服务。 */
public FleetPoolOrderBridge(FleetPoolOrderRepository repository,
FleetPoolOrderNumberGenerator numbers,ObjectMapper json,FleetPoolDeliveryEvidence deliveryEvidence,
com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources) {
this.links=links;this.loose=loose;this.looseHistory=looseHistory;this.trucks=trucks;
this.truckHistory=truckHistory;this.numbers=numbers;this.json=json;this.deliveryEvidence=deliveryEvidence;
this.repository=repository;
this.numbers=numbers;this.json=json;this.deliveryEvidence=deliveryEvidence;
this.appointmentSources=appointmentSources;
}
/** 批量读取预订对应的当前商业履约阶段。 */
@Transactional(readOnly=true)
public Map<Long,String> fulfillmentStages(List<Long> bookingIds) {
if(bookingIds.isEmpty())return Map.of();
var result=new HashMap<Long,String>();
for(var link:links.bookings(bookingIds))result.put(link.getBookingId(),link.getFulfillmentStage());
for(var link:repository.bookings(bookingIds))result.put(link.getBookingId(),link.getFulfillmentStage());
return Map.copyOf(result);
}
/** 在承运确认事务内创建唯一商业订单,重放时核对来源哈希。 */
@Transactional(propagation=Propagation.MANDATORY)
public BusinessOrderRef confirm(ConfirmedBooking in,String operator) {
validate(in);
String hash=hash(in); var prior=links.lockBooking(in.bookingId());
String hash=hash(in); var prior=repository.lockBooking(in.bookingId());
if(prior!=null){if(!hash.equals(prior.getCreationRequestHash()))throw conflict("同一承运来源不能创建不同商业订单");return ref(prior);}
var now=LocalDateTime.now();var row=new FleetPoolOrderLink();
row.setId(IdWorker.getId());row.setBookingId(in.bookingId());row.setBookingNo(in.bookingNo());
@@ -62,15 +58,16 @@ public class FleetPoolOrderBridge {
String transport=in.destinationKey().startsWith("FBA:")?"WAREHOUSE_TO_WAREHOUSE":"WAREHOUSE_TO_DOOR";
if("LOCAL_LOOSEBOARD".equals(in.businessType())) {
var order=new LocalLooseboardOrder();order.setId(row.getBusinessOrderId());order.setCustomerId(in.customerId());order.setOrderNo(row.getBusinessOrderNo());
order.setTransportMode("SHARED_TRUCK");order.setTransportType(transport);
// A pallet count/space is not a palletizing service request or a container specification.
order.setPalletRequired(null);order.setContainerType(null);
order.setTransportType(transport);
// 货名与预计托数沿真实询价保存;不从数量推断打盘服务,也不预设取送时间。
order.setPalletRequired(null);order.setProductName(in.cargoName());order.setEstimatedPalletCount(in.expectedPalletCount());
order.setOrigin(in.warehouseName());order.setDestination(in.destinationLabel());order.setPackageCount(in.packageCount());
order.setTotalWeightKg(in.weightKg());order.setTotalVolumeCbm(in.volumeCbm());order.setCurrency(in.currency());
order.setContactName(in.contactName());order.setContactInfo(in.contactPhone());order.setRequestedDate(in.plannedDepartureAt().toLocalDate());
order.setOriginContactName(in.contactName());order.setOriginContactPhone(in.contactPhone());
order.setPickupAt(in.plannedDepartureAt());
order.setStatus(row.getFulfillmentStage());order.setRemark(in.remark());order.setIdempotencyKey("FLEET_POOL:"+in.bookingNo());
order.setRequestHash(hash);order.setCreatedBy(operator);order.setVersion(0L);order.setCreatedAt(now);order.setUpdatedAt(now);
loose.insert(order); // No placeholder ll_order: incoming owns the one physical tail order.
repository.insertLoose(order); // No placeholder ll_order: incoming owns the one physical tail order.
} else {
var order=new UsTruckOrder();order.setId(row.getBusinessOrderId());order.setCustomerId(in.customerId());order.setOrderNo(row.getBusinessOrderNo());
order.setOrigin(in.warehouseName());order.setDestination(in.destinationLabel());
@@ -80,33 +77,36 @@ public class FleetPoolOrderBridge {
order.setOriginContactName(in.contactName());order.setOriginContactPhone(in.contactPhone());order.setPickupAt(in.plannedDepartureAt());
order.setStatus(row.getFulfillmentStage());order.setRemark(in.remark());order.setIdempotencyKey("FLEET_POOL:"+in.bookingNo());
order.setRequestHash(hash);order.setCreatedBy(operator);order.setVersion(0L);order.setCreatedAt(now);order.setUpdatedAt(now);
trucks.insert(order); // No simulated ll_us_truck_order_pallet rows.
repository.insertTruck(order); // No simulated ll_us_truck_order_pallet rows.
}
links.insert(row);history(row,null,row.getFulfillmentStage(),operator,"确认承运并建立商业委托",now);
repository.insertLink(row);history(row,null,row.getFulfillmentStage(),operator,"确认承运并建立商业委托",now);
return ref(row);
}
/** 锁定预订并关联唯一真实来货,不创建第二份实物来源。 */
@Transactional(propagation=Propagation.MANDATORY)
public void bindIncoming(String bookingNo,String customerId,String incomingNo,long lastlegOrderId,
String warehouseId,String destinationKey,String operator) {
var row=links.lockBookingNo(bookingNo);
if(row==null){if(links.poolBooking(bookingNo)>0)throw conflict("池来源预订缺少唯一商业订单关联");return;}
var row=repository.lockBookingNo(bookingNo);
if(row==null){if(repository.poolBooking(bookingNo)>0)throw conflict("池来源预订缺少唯一商业订单关联");return;}
owned(row,customerId);
if(!Objects.equals(row.getWarehouseId(),warehouseId)||!Objects.equals(row.getDestinationKey(),destinationKey))throw conflict("来货仓库或目的地与池来源商业委托不一致");
if(row.getIncomingNo()!=null){if(!row.getIncomingNo().equals(incomingNo)||!Objects.equals(row.getLastlegOrderId(),lastlegOrderId))throw conflict("商业委托已绑定其他来货");return;}
if(!"WAITING_INBOUND".equals(row.getFulfillmentStage()))throw conflict("商业委托当前不能登记来货");
row.setIncomingNo(incomingNo);row.setLastlegOrderId(lastlegOrderId);touch(row,LocalDateTime.now());links.updateById(row);
row.setIncomingNo(incomingNo);row.setLastlegOrderId(lastlegOrderId);touch(row,LocalDateTime.now());repository.updateLink(row);
if("LOCAL_LOOSEBOARD".equals(row.getBusinessType())) {
var order=loose.lockNo(customerId,row.getBusinessOrderNo());requireOrder(order!=null);
var order=repository.lockLoose(customerId,row.getBusinessOrderNo());requireOrder(order!=null);
if(order.getLastlegOrderId()!=null&&!order.getLastlegOrderId().equals(lastlegOrderId))throw conflict("散板商业单已存在另一尾程来源");
order.setLastlegOrderId(lastlegOrderId);order.setVersion(order.getVersion()+1);order.setUpdatedAt(row.getUpdatedAt());loose.updateById(order);
order.setLastlegOrderId(lastlegOrderId);order.setVersion(order.getVersion()+1);order.setUpdatedAt(row.getUpdatedAt());repository.updateLoose(order);
}
appointmentSources.materialized(customerId, bookingNo, lastlegOrderId);
}
/** 按真实来货开始事件推进收货状态。 */
@Transactional(propagation=Propagation.MANDATORY)
public void receiving(String customerId,String incomingNo,String operator) {
var row=links.lockIncoming(customerId,incomingNo);if(row==null)return;
var row=repository.lockIncoming(customerId,incomingNo);if(row==null)return;
if("WAITING_INBOUND".equals(row.getFulfillmentStage()))move(row,"RECEIVING",operator,"PDA开始实际收货 "+incomingNo,LocalDateTime.now());
}
/** 沿已确认实托及实占托位保存实际收货。 */
@Transactional(propagation=Propagation.MANDATORY)
public void acceptedReceipt(long bookingId,Receipt evidence,int palletSpaces,String operator) {
var row=requiredBooking(bookingId);
@@ -123,6 +123,7 @@ public class FleetPoolOrderBridge {
row.setActualPackageCount(packages);row.setActualPalletCount(unique.size());row.setActualPalletSpaces(palletSpaces);
move(row,"WAITING_CONSOLIDATION",operator,"实收核量确认 "+evidence.incomingNo(),LocalDateTime.now());
}
/** 沿真实排车关联推进商业履约。 */
@Transactional(propagation=Propagation.MANDATORY)
public void boundDispatch(long bookingId,String customerId,String dispatchNo,String operator) {
var row=requiredBooking(bookingId);owned(row,customerId);
@@ -130,6 +131,7 @@ public class FleetPoolOrderBridge {
if(!"WAITING_CONSOLIDATION".equals(row.getFulfillmentStage())||dispatchNo==null||dispatchNo.isBlank())throw conflict("商业单仅实收核量后可关联发运");
row.setDispatchNo(dispatchNo);move(row,"PENDING_DISPATCH",operator,"关联实际排车 "+dispatchNo,LocalDateTime.now());
}
/** 校验商业履约边界或组装真实业务引用。 */
@Transactional(propagation=Propagation.MANDATORY)
public void unboundDispatch(long bookingId,String customerId,String dispatchNo,String operator) {
var row=requiredBooking(bookingId);owned(row,customerId);
@@ -137,6 +139,7 @@ public class FleetPoolOrderBridge {
if(!Objects.equals(row.getDispatchNo(),dispatchNo)||!"PENDING_DISPATCH".equals(row.getFulfillmentStage()))throw conflict("商业单当前不能解除发运关联");
row.setDispatchNo(null);move(row,"WAITING_CONSOLIDATION",operator,"解除未执行排车 "+dispatchNo,LocalDateTime.now());
}
/** 校验商业履约边界或组装真实业务引用。 */
@Transactional(propagation=Propagation.MANDATORY)
public void cancelBeforeIncoming(long bookingId,String customerId,String operator,String reason) {
var row=requiredBooking(bookingId);owned(row,customerId);
@@ -144,19 +147,21 @@ public class FleetPoolOrderBridge {
if(row.getIncomingNo()!=null||!"WAITING_INBOUND".equals(row.getFulfillmentStage()))throw conflict("池来源已登记来货,首期不支持取消或跨车改配");
move(row,"CANCELLED",operator,reason,LocalDateTime.now());
}
/** 沿真实整车发车事实推进运输状态。 */
@Transactional(propagation=Propagation.MANDATORY)
public void departed(String customerId,String dispatchNo,Map<Long,Integer> sourcePackages,String operator,LocalDateTime at) {
for(var row:links.lockDispatch(customerId,dispatchNo)) {
for(var row:repository.lockDispatch(customerId,dispatchNo)) {
if(row.getActualPackageCount()==null||!Objects.equals(sourcePackages.get(row.getLastlegOrderId()),row.getActualPackageCount()))throw conflict("商业单实际出库来源或完整货量不匹配");
if("IN_TRANSIT".equals(row.getFulfillmentStage()))continue;
if(!"PENDING_DISPATCH".equals(row.getFulfillmentStage()))throw conflict("商业单未关联实际发运");
move(row,"IN_TRANSIT",operator,"整车实际出库 "+dispatchNo,at);
}
}
/** 核对全部签收证据后推进商业完成。 */
@Transactional(propagation=Propagation.MANDATORY)
public void signed(SignedDelivery delivery,Map<Long,Long> actualSourcePackages,String operator,LocalDateTime at) {
if(delivery.dispatchNo()==null)return;
for(var row:links.lockDispatch(delivery.customerId(),delivery.dispatchNo())) {
for(var row:repository.lockDispatch(delivery.customerId(),delivery.dispatchNo())) {
if(!actualSourcePackages.containsKey(row.getLastlegOrderId()))continue;
if(row.getActualPackageCount()==null||actualSourcePackages.get(row.getLastlegOrderId())!=row.getActualPackageCount().longValue())throw conflict("商业单签收实际来源数量不完整");
if("COMPLETED".equals(row.getFulfillmentStage()))continue;
@@ -165,12 +170,14 @@ public class FleetPoolOrderBridge {
move(row,"COMPLETED",operator,"真实全部签收 "+delivery.deliveryNo(),at);
}
}
/** 校验商业履约边界或组装真实业务引用。 */
private boolean wholeSourceSigned(FleetPoolOrderLink row) {
return deliveryEvidence.fullySigned(row.getCustomerId(),row.getIncomingNo(),row.getLastlegOrderId(),row.getActualPackageCount());
}
/** 批量读取预订的业务编号。 */
public Map<Long,String> businessOrderNumbers(Collection<Long> ids) {
if(ids==null||ids.isEmpty())return Map.of();var result=new HashMap<Long,String>();
links.bookings(ids).forEach(row->result.put(row.getBookingId(),row.getBusinessOrderNo()));return result;
repository.bookings(ids).forEach(row->result.put(row.getBookingId(),row.getBusinessOrderNo()));return result;
}
static void validate(ConfirmedBooking in) {
if(in==null||in.bookingId()<=0||in.businessType()==null||!Set.of("LOCAL_LOOSEBOARD","US_TRUCK").contains(in.businessType())
@@ -183,37 +190,47 @@ public class FleetPoolOrderBridge {
||("US_TRUCK".equals(in.businessType())&&(in.expectedPalletCount()==null||in.expectedPalletCount()<1)))
throw conflict("池来源商业委托缺少完整预报、独立实托数或运输身份");
}
/** 在同一事务更新商业阶段与不可变历史。 */
private void move(FleetPoolOrderLink row,String stage,String operator,String reason,LocalDateTime at) {
String from=row.getFulfillmentStage();if(stage.equals(from))return;
row.setFulfillmentStage(stage);row.setStageOccurredAt(at);touch(row,at);links.updateById(row);
row.setFulfillmentStage(stage);row.setStageOccurredAt(at);touch(row,at);repository.updateLink(row);
if("LOCAL_LOOSEBOARD".equals(row.getBusinessType())) {
var order=loose.lockNo(row.getCustomerId(),row.getBusinessOrderNo());requireOrder(order!=null);
order.setStatus(stage);order.setVersion(order.getVersion()+1);order.setUpdatedAt(at);loose.updateById(order);
var order=repository.lockLoose(row.getCustomerId(),row.getBusinessOrderNo());requireOrder(order!=null);
order.setStatus(stage);order.setVersion(order.getVersion()+1);order.setUpdatedAt(at);repository.updateLoose(order);
} else {
var order=trucks.lockNo(row.getCustomerId(),row.getBusinessOrderNo());requireOrder(order!=null);
var order=repository.lockTruck(row.getCustomerId(),row.getBusinessOrderNo());requireOrder(order!=null);
order.setStatus(stage);order.setVersion(order.getVersion()+1);order.setUpdatedAt(at);
if(row.getActualPalletCount()!=null)order.setReceivedPalletCount(row.getActualPalletCount());
if(row.getActualPalletCount()!=null)order.setAllocatedPalletCount(row.getDispatchNo()==null?0:row.getActualPalletCount());
trucks.updateById(order);
repository.updateTruck(order);
}
history(row,from,stage,operator,reason,at);
}
/** 保存来源业务号和履约事件的不可变记录。 */
private void history(FleetPoolOrderLink row,String from,String to,String operator,String reason,LocalDateTime at) {
String key="FLEET_POOL:"+row.getBookingNo()+":"+to;
if("LOCAL_LOOSEBOARD".equals(row.getBusinessType())) {
var h=new LocalLooseboardHistory();h.setId(IdWorker.getId());h.setOrderId(row.getBusinessOrderId());h.setCustomerId(row.getCustomerId());
h.setOperationType("FLEET_FULFILLMENT");h.setFromStatus(from);h.setToStatus(to);h.setOperatorId(operator);h.setIdempotencyKey(key);h.setRemark(reason);h.setCreatedAt(at);looseHistory.insert(h);
h.setOperationType("FLEET_FULFILLMENT");h.setFromStatus(from);h.setToStatus(to);h.setOperatorId(operator);h.setIdempotencyKey(key);h.setRemark(reason);h.setCreatedAt(at);repository.insertLooseHistory(h);
} else {
var h=new UsTruckOrderStatusHistory();h.setId(IdWorker.getId());h.setOrderId(row.getBusinessOrderId());h.setCustomerId(row.getCustomerId());
h.setOperationType("FLEET_FULFILLMENT");h.setFromStatus(from);h.setToStatus(to);h.setOperatorId(operator);h.setIdempotencyKey(key);h.setRemark(reason);h.setCreatedAt(at);truckHistory.insert(h);
h.setOperationType("FLEET_FULFILLMENT");h.setFromStatus(from);h.setToStatus(to);h.setOperatorId(operator);h.setIdempotencyKey(key);h.setRemark(reason);h.setCreatedAt(at);repository.insertTruckHistory(h);
}
}
/** 校验商业履约边界或组装真实业务引用。 */
private String hash(Object input){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsString(input).getBytes(StandardCharsets.UTF_8)));}catch(Exception e){throw new IllegalStateException(e);}}
private FleetPoolOrderLink requiredBooking(long bookingId){var row=links.lockBooking(bookingId);if(row==null)throw conflict("池来源预订缺少唯一商业订单关联");return row;}
/** 锁定并读取必需的商业委托。 */
private FleetPoolOrderLink requiredBooking(long bookingId){var row=repository.lockBooking(bookingId);if(row==null)throw conflict("池来源预订缺少唯一商业订单关联");return row;}
/** 校验商业履约边界或组装真实业务引用。 */
private static void touch(FleetPoolOrderLink row,LocalDateTime at){row.setRecordVersion(row.getRecordVersion()+1);row.setUpdatedAt(at);}
/** 校验商业履约边界或组装真实业务引用。 */
private static BusinessOrderRef ref(FleetPoolOrderLink row){return new BusinessOrderRef(row.getBusinessType(),row.getBusinessOrderNo());}
/** 校验商业履约边界或组装真实业务引用。 */
private static void owned(FleetPoolOrderLink row,String customer){if(!Objects.equals(row.getCustomerId(),customer))throw conflict("商业委托客户归属不一致");}
/** 校验商业履约边界或组装真实业务引用。 */
private static boolean blank(String value){return value==null||value.isBlank();}
/** 校验商业履约边界或组装真实业务引用。 */
private static void requireOrder(boolean ok){if(!ok)throw conflict("池来源商业订单不存在");}
/** 校验商业履约边界或组装真实业务引用。 */
private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);}
}
@@ -0,0 +1,56 @@
package com.baiyue.lastleg.fleet.business;
import com.baiyue.lastleg.looseboard.entity.*;
import com.baiyue.lastleg.looseboard.mapper.*;
import com.baiyue.lastleg.trucking.entity.*;
import com.baiyue.lastleg.trucking.mapper.*;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Collection;
@Repository
public class FleetPoolOrderRepository {
private final FleetPoolOrderMapper links;
private final LocalLooseboardOrderMapper loose;
private final LocalLooseboardHistoryMapper looseHistory;
private final UsTruckOrderMapper trucks;
private final UsTruckOrderStatusHistoryMapper truckHistory;
/** 注入同库商业委托及历史 Mapper,事务由服务统一管理。 */
public FleetPoolOrderRepository(FleetPoolOrderMapper links, LocalLooseboardOrderMapper loose,
LocalLooseboardHistoryMapper looseHistory, UsTruckOrderMapper trucks, UsTruckOrderStatusHistoryMapper truckHistory) {
this.links=links;this.loose=loose;this.looseHistory=looseHistory;this.trucks=trucks;this.truckHistory=truckHistory;
}
/** 批量读取预订关联。 */
public List<FleetPoolOrderLink> bookings(Collection<Long> ids) { return links.bookings(ids); }
/** 锁定预订关联。 */
public FleetPoolOrderLink lockBooking(long id) { return links.lockBooking(id); }
/** 按预订业务号锁定关联。 */
public FleetPoolOrderLink lockBookingNo(String number) { return links.lockBookingNo(number); }
/** 查询预订是否属于运力池。 */
public int poolBooking(String number) { return links.poolBooking(number); }
/** 按客户和来货号锁定关联。 */
public FleetPoolOrderLink lockIncoming(String customer, String number) { return links.lockIncoming(customer, number); }
/** 按客户和排车号锁定关联。 */
public List<FleetPoolOrderLink> lockDispatch(String customer, String number) { return links.lockDispatch(customer, number); }
/** 保存唯一商业关联。 */
public void insertLink(FleetPoolOrderLink row) { links.insert(row); }
/** 更新已锁定商业关联。 */
public void updateLink(FleetPoolOrderLink row) { links.updateById(row); }
/** 保存散板商业主单。 */
public void insertLoose(LocalLooseboardOrder order) { loose.insert(order); }
/** 锁定同客户散板主单。 */
public LocalLooseboardOrder lockLoose(String customer, String number) { return loose.lockNo(customer, number); }
/** 保存已锁定散板事实。 */
public void updateLoose(LocalLooseboardOrder order) { loose.updateById(order); }
/** 保存卡车商业主单。 */
public void insertTruck(UsTruckOrder order) { trucks.insert(order); }
/** 锁定同客户卡车主单。 */
public UsTruckOrder lockTruck(String customer, String number) { return trucks.lockNo(customer, number); }
/** 保存已锁定卡车事实。 */
public void updateTruck(UsTruckOrder order) { trucks.updateById(order); }
/** 保存散板履约历史。 */
public void insertLooseHistory(LocalLooseboardHistory history) { looseHistory.insert(history); }
/** 保存卡车履约历史。 */
public void insertTruckHistory(UsTruckOrderStatusHistory history) { truckHistory.insert(history); }
}
@@ -10,49 +10,72 @@ import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import jakarta.validation.constraints.AssertTrue;
import com.fasterxml.jackson.annotation.JsonIgnore;
public final class LocalLooseboardRequests {
/** 仅作为散板请求或响应模型的命名空间。 */
private LocalLooseboardRequests() { }
public record Create(
@NotBlank @Pattern(regexp = "SHARED_TRUCK|PRIORITY_CONSOLIDATION") String transportMode,
@NotBlank @Pattern(regexp = "YES|NO") String palletRequired,
@NotBlank @Pattern(regexp = "PORT_PICKUP|WAREHOUSE_TO_WAREHOUSE|WAREHOUSE_TO_DOOR") String transportType,
@NotBlank @Size(max = 255) String origin,
@Size(max = 500) String originAddress,
@NotBlank @Size(max = 255) String destination,
@Size(max = 500) String destinationAddress,
@Size(max = 32) String containerType,
@Size(max = 255) String productName,
@NotNull @Min(0) Integer estimatedPalletCount,
@NotNull @Min(0) Integer packageCount,
@NotNull @DecimalMin("0.00") BigDecimal totalWeightKg,
@NotNull @DecimalMin("0.00") BigDecimal totalVolumeCbm,
@DecimalMin("0.00") BigDecimal budgetAmount,
@Size(min = 3, max = 3) String currency,
@Size(max = 128) String contactName,
@Size(max = 128) String contactInfo,
LocalDate requestedDate,
@Pattern(regexp = "USD|CNY") String currency,
@NotBlank @Size(max = 128) String originContactName,
@NotBlank @Size(max = 128) String originContactPhone,
@NotBlank @Size(max = 128) String destinationContactName,
@NotBlank @Size(max = 128) String destinationContactPhone,
@NotNull LocalDateTime pickupAt,
@NotNull LocalDateTime deliveryAt,
@Size(max = 1000) String remark,
@NotBlank @Size(max = 255) String idempotencyKey) { }
@NotBlank @Size(max = 255) String idempotencyKey) {
/** 取送时间均须填写,送货不能早于取货。 */
@JsonIgnore @AssertTrue(message = "送货时间不能早于取货时间")
public boolean isScheduleValid() {
return validSchedule(pickupAt, deliveryAt);
}
}
public record Update(
@NotNull @Min(0) Long version,
@NotBlank @Pattern(regexp = "SHARED_TRUCK|PRIORITY_CONSOLIDATION") String transportMode,
@NotBlank @Pattern(regexp = "YES|NO") String palletRequired,
@NotBlank @Pattern(regexp = "PORT_PICKUP|WAREHOUSE_TO_WAREHOUSE|WAREHOUSE_TO_DOOR") String transportType,
@NotBlank @Size(max = 255) String origin,
@Size(max = 500) String originAddress,
@NotBlank @Size(max = 255) String destination,
@Size(max = 500) String destinationAddress,
@Size(max = 32) String containerType,
@Size(max = 255) String productName,
@NotNull @Min(0) Integer estimatedPalletCount,
@NotNull @Min(0) Integer packageCount,
@NotNull @DecimalMin("0.00") BigDecimal totalWeightKg,
@NotNull @DecimalMin("0.00") BigDecimal totalVolumeCbm,
@DecimalMin("0.00") BigDecimal budgetAmount,
@Size(min = 3, max = 3) String currency,
@Size(max = 128) String contactName,
@Size(max = 128) String contactInfo,
LocalDate requestedDate,
@Size(max = 1000) String remark) { }
@Pattern(regexp = "USD|CNY") String currency,
@NotBlank @Size(max = 128) String originContactName,
@NotBlank @Size(max = 128) String originContactPhone,
@NotBlank @Size(max = 128) String destinationContactName,
@NotBlank @Size(max = 128) String destinationContactPhone,
@NotNull LocalDateTime pickupAt,
@NotNull LocalDateTime deliveryAt,
@Size(max = 1000) String remark) {
/** 取送时间均须填写,送货不能早于取货。 */
@JsonIgnore @AssertTrue(message = "送货时间不能早于取货时间")
public boolean isScheduleValid() {
return validSchedule(pickupAt, deliveryAt);
}
}
/** 统一校验平台及客户新增、编辑的需求时间。 */
public static boolean validSchedule(LocalDateTime pickup, LocalDateTime delivery) {
return pickup != null && delivery != null && !delivery.isBefore(pickup);
}
public record Query(
@Min(1) long page,
@@ -61,7 +84,6 @@ public final class LocalLooseboardRequests {
String orderNo,
String routeKeyword,
String status,
String transportMode,
LocalDate requestedFrom,
LocalDate requestedTo) { }
@@ -71,7 +93,6 @@ public final class LocalLooseboardRequests {
String orderNo,
String routeKeyword,
String status,
String transportMode,
LocalDate requestedFrom,
LocalDate requestedTo) { }
@@ -1,19 +1,19 @@
package com.baiyue.lastleg.looseboard.dto;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
public final class LocalLooseboardResponses {
/** 仅作为散板请求或响应模型的命名空间。 */
private LocalLooseboardResponses() { }
/** Pool orders retain null containerType/palletRequired when the inquiry did not specify these facts. */
/** 需求与内部安排分别返回;未知联系人、商品及时间不补造。 */
public record Summary(
Long id, String customerId, String orderNo, String transportMode, String palletRequired,
String transportType, String origin, String destination, String containerType, int packageCount,
Long id, String customerId, String orderNo, String palletRequired,
String transportType, String origin, String destination, String productName, Integer estimatedPalletCount, int packageCount,
BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount, String currency,
String contactName, String contactInfo, LocalDate requestedDate, String status, long version,
String originContactName, String originContactPhone, String destinationContactName, String destinationContactPhone, LocalDateTime pickupAt, LocalDateTime deliveryAt, String status, long version,
LocalDateTime createdAt, LocalDateTime updatedAt, com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement fleetArrangement) { }
public record History(
@@ -1,12 +1,13 @@
package com.baiyue.lastleg.looseboard.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@@ -16,24 +17,27 @@ public class LocalLooseboardOrder {
private String customerId;
private Long lastlegOrderId;
private String orderNo;
private String transportMode;
private String palletRequired;
private String transportType;
private String origin;
private String originAddress;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String originAddress;
private String destination;
private String destinationAddress;
private String containerType;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String destinationAddress;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String productName;
private Integer estimatedPalletCount;
private Integer packageCount;
private BigDecimal totalWeightKg;
private BigDecimal totalVolumeCbm;
private BigDecimal budgetAmount;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private BigDecimal budgetAmount;
private String currency;
private String contactName;
private String contactInfo;
private LocalDate requestedDate;
private String originContactName;
private String originContactPhone;
private String destinationContactName;
private String destinationContactPhone;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDateTime pickupAt;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDateTime deliveryAt;
private String status;
private String remark;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String remark;
private String idempotencyKey;
private String requestHash;
private String createdBy;
@@ -0,0 +1,67 @@
package com.baiyue.lastleg.looseboard.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.lastleg.looseboard.entity.*;
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
import org.springframework.util.StringUtils;
import com.baiyue.lastleg.looseboard.mapper.*;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public class LocalLooseboardRepository {
@Resource private LocalLooseboardOrderMapper orders;
@Resource private LocalLooseboardHistoryMapper histories;
@Resource private LocalLooseboardOperationMapper operations;
@Resource private LastlegOrderMapper lastlegOrders;
@Resource private LastlegOrderStatusHistoryMapper lastlegHistories;
/** 按客户和创建幂等键读取订单。 */
public LocalLooseboardOrder findKey(String customer, String key) { return orders.findKey(customer, key); }
/** 按客户和业务单号读取订单。 */
public LocalLooseboardOrder findNo(String customer, String number) { return orders.findNo(customer, number); }
/** 锁定同客户订单保护编辑及状态推进。 */
public LocalLooseboardOrder lockNo(String customer, String number) { return orders.lockNo(customer, number); }
/** 在客户范围内组合路线、状态与取货日期筛选并返回分页需求。 */
public Page<LocalLooseboardOrder> page(LocalLooseboardRequests.Query query) {
LambdaQueryWrapper<LocalLooseboardOrder> wrapper = new LambdaQueryWrapper<LocalLooseboardOrder>()
.eq(StringUtils.hasText(query.customerId()), LocalLooseboardOrder::getCustomerId, trim(query.customerId()))
.like(StringUtils.hasText(query.orderNo()), LocalLooseboardOrder::getOrderNo, trim(query.orderNo()))
.and(StringUtils.hasText(query.routeKeyword()), value -> value
.like(LocalLooseboardOrder::getOrigin, trim(query.routeKeyword()))
.or().like(LocalLooseboardOrder::getDestination, trim(query.routeKeyword())))
.eq(StringUtils.hasText(query.status()), LocalLooseboardOrder::getStatus,
StringUtils.hasText(query.status()) ? query.status().trim().toUpperCase() : null)
.ge(query.requestedFrom() != null, LocalLooseboardOrder::getPickupAt, query.requestedFrom() == null ? null : query.requestedFrom().atStartOfDay())
.lt(query.requestedTo() != null, LocalLooseboardOrder::getPickupAt, query.requestedTo() == null ? null : query.requestedTo().plusDays(1).atStartOfDay())
.orderByDesc(LocalLooseboardOrder::getCreatedAt).orderByDesc(LocalLooseboardOrder::getId);
return orders.selectPage(Page.of(query.page(), query.size()), wrapper);
}
/** 规范查询文字,缺失值不参与筛选。 */
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
/** 保存散板需求。 */
public void insertOrder(LocalLooseboardOrder order) { orders.insert(order); }
/** 更新已锁定需求。 */
public void updateOrder(LocalLooseboardOrder order) { orders.updateById(order); }
/** 读取同客户订单历史。 */
public List<LocalLooseboardHistory> histories(String customer, Long id) { return histories.listOrder(customer, id); }
/** 保存不可变需求历史。 */
public void insertHistory(LocalLooseboardHistory history) { histories.insert(history); }
/** 读取状态命令幂等记录。 */
public LocalLooseboardOperation findOperation(String customer, Long id, String key) { return operations.findKey(customer, id, key); }
/** 保存命令幂等记录。 */
public void insertOperation(LocalLooseboardOperation operation) { operations.insert(operation); }
/** 保存唯一尾程来源。 */
public void insertTailOrder(LastlegOrder order) { lastlegOrders.insert(order); }
/** 读取关联尾程来源。 */
public LastlegOrder tailOrder(Long id) { return lastlegOrders.selectById(id); }
/** 更新关联尾程事实。 */
public void updateTailOrder(LastlegOrder order) { lastlegOrders.updateById(order); }
/** 保存尾程状态历史。 */
public void insertTailHistory(LastlegOrderStatusHistory history) { lastlegHistories.insert(history); }
}
@@ -1,23 +1,17 @@
package com.baiyue.lastleg.looseboard.service;
import com.baiyue.common.core.container.ContainerSpecificationCatalog;
import com.baiyue.lastleg.looseboard.repository.LocalLooseboardRepository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
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.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardResponses;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardHistory;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOperation;
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardHistoryMapper;
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper;
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOperationMapper;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -48,36 +42,32 @@ public class LocalLooseboardService {
public static final String COMPLETED = "COMPLETED";
public static final String CANCELLED = "CANCELLED";
private static final Set<String> STATUSES = Set.of(PENDING_ACCEPTANCE, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED, "WAITING_INBOUND", "RECEIVING", "WAITING_CONSOLIDATION", "PENDING_DISPATCH");
private static final Set<String> MODES = Set.of("SHARED_TRUCK", "PRIORITY_CONSOLIDATION");
private static final Map<String, Set<String>> TRANSITIONS = Map.of(
PENDING_ACCEPTANCE, Set.of(DISPATCHED, CANCELLED),
DISPATCHED, Set.of(IN_TRANSIT, CANCELLED),
IN_TRANSIT, Set.of(COMPLETED));
@Resource private LocalLooseboardOrderMapper orders;
@Resource private LocalLooseboardHistoryMapper histories;
@Resource private LocalLooseboardOperationMapper operations;
@Resource private LastlegOrderMapper lastlegOrders;
@Resource private LastlegOrderStatusHistoryMapper lastlegHistories;
@Resource private LocalLooseboardRepository repository;
@Resource private LastlegOrderNoGenerator lastlegOrderNoGenerator;
@Resource private ObjectMapper objectMapper;
/** 在同一事务保存客户需求、唯一尾程来源及创建历史,创建键重复时核对请求哈希。 */
@Transactional
public LocalLooseboardResponses.Detail create(String customerId, String operatorId,
LocalLooseboardRequests.Create request) {
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
LocalLooseboardOrder replay = orders.findKey(customerId, key);
LocalLooseboardOrder replay = repository.findKey(customerId, key);
if (replay != null) return replay(customerId, replay, requestHash);
LocalDateTime now = LocalDateTime.now();
LocalLooseboardOrder order = new LocalLooseboardOrder();
order.setId(IdWorker.getId());
order.setCustomerId(customerId);
order.setOrderNo(lastlegOrderNoGenerator.next("LOOSEBOARD", request.destination(), order.getId()));
apply(order, request.transportMode(), request.palletRequired(), request.transportType(), request.origin(),
request.originAddress(), request.destination(), request.destinationAddress(), request.containerType(),
apply(order, request.origin(),
request.originAddress(), request.destination(), request.destinationAddress(), request.productName(), request.estimatedPalletCount(),
request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(),
request.currency(), request.contactName(), request.contactInfo(), request.requestedDate(), request.remark());
request.currency(), request.originContactName(), request.originContactPhone(), request.destinationContactName(), request.destinationContactPhone(), request.pickupAt(), request.deliveryAt(), request.remark());
order.setStatus(PENDING_ACCEPTANCE);
order.setIdempotencyKey(key);
order.setRequestHash(requestHash);
@@ -88,9 +78,9 @@ public class LocalLooseboardService {
LastlegOrder lastlegOrder = createLastlegOrder(order, requestHash, now);
order.setLastlegOrderId(lastlegOrder.getId());
try {
orders.insert(order);
repository.insertOrder(order);
} catch (DuplicateKeyException exception) {
LocalLooseboardOrder concurrent = orders.findKey(customerId, key);
LocalLooseboardOrder concurrent = repository.findKey(customerId, key);
if (concurrent != null) return replay(customerId, concurrent, requestHash);
throw exception;
}
@@ -101,44 +91,34 @@ public class LocalLooseboardService {
return detail(customerId, order.getOrderNo());
}
/** 按客户、状态及取货日期分页查询需求,并批量装配承运摘要。 */
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public PageResult<LocalLooseboardResponses.Summary> page(LocalLooseboardRequests.Query query) {
validateQuery(query);
LambdaQueryWrapper<LocalLooseboardOrder> wrapper = new LambdaQueryWrapper<LocalLooseboardOrder>()
.eq(StringUtils.hasText(query.customerId()), LocalLooseboardOrder::getCustomerId, trim(query.customerId()))
.like(StringUtils.hasText(query.orderNo()), LocalLooseboardOrder::getOrderNo, trim(query.orderNo()))
.and(StringUtils.hasText(query.routeKeyword()), value -> value
.like(LocalLooseboardOrder::getOrigin, trim(query.routeKeyword()))
.or().like(LocalLooseboardOrder::getDestination, trim(query.routeKeyword())))
.eq(StringUtils.hasText(query.status()), LocalLooseboardOrder::getStatus,
StringUtils.hasText(query.status()) ? norm(query.status()) : null)
.eq(StringUtils.hasText(query.transportMode()), LocalLooseboardOrder::getTransportMode,
StringUtils.hasText(query.transportMode()) ? norm(query.transportMode()) : null)
.ge(query.requestedFrom() != null, LocalLooseboardOrder::getRequestedDate, query.requestedFrom())
.le(query.requestedTo() != null, LocalLooseboardOrder::getRequestedDate, query.requestedTo())
.orderByDesc(LocalLooseboardOrder::getCreatedAt).orderByDesc(LocalLooseboardOrder::getId);
Page<LocalLooseboardOrder> result = orders.selectPage(Page.of(query.page(), query.size()), wrapper);
Page<LocalLooseboardOrder> result = repository.page(query);
var arrangements = fleetPool.arrangements(result.getRecords().stream().map(this::poolKey).toList());
return new PageResult<>(result.getRecords().stream().map(o -> summary(o, arrangements.get(poolKey(o)))).toList(), result.getCurrent(),
result.getSize(), result.getTotal(), result.getPages());
}
/** Customer-scoped query that ignores any client-supplied customer filter. */
/** 客户查询强制使用认证客户范围,沿同一需求筛选合同分页。 */
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public PageResult<LocalLooseboardResponses.Summary> page(String customerId,
LocalLooseboardRequests.MerchantQuery query) {
return page(new LocalLooseboardRequests.Query(query.page(), query.size(), customerId,
query.orderNo(), query.routeKeyword(), query.status(), query.transportMode(),
query.orderNo(), query.routeKeyword(), query.status(),
query.requestedFrom(), query.requestedTo()));
}
/** 按客户与业务单号读取需求及历史。 */
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public LocalLooseboardResponses.Detail detail(String customerId, String orderNo) {
LocalLooseboardOrder order = orders.findNo(customerId, orderNo);
LocalLooseboardOrder order = repository.findNo(customerId, orderNo);
if (order == null) throw notFound();
return response(order);
}
/** 锁定待接单需求并校验版本,保存变更前后快照。 */
@Transactional
public LocalLooseboardResponses.Detail update(String customerId, String orderNo, String operatorId,
LocalLooseboardRequests.Update request) {
@@ -148,27 +128,28 @@ public class LocalLooseboardService {
throw conflict("Only pending-acceptance looseboard orders can be edited");
}
Map<String, Object> before = snapshot(order);
apply(order, request.transportMode(), request.palletRequired(), request.transportType(), request.origin(),
request.originAddress(), request.destination(), request.destinationAddress(), request.containerType(),
apply(order, request.origin(),
request.originAddress(), request.destination(), request.destinationAddress(), request.productName(), request.estimatedPalletCount(),
request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(),
request.currency(), request.contactName(), request.contactInfo(), request.requestedDate(), request.remark());
request.currency(), request.originContactName(), request.originContactPhone(), request.destinationContactName(), request.destinationContactPhone(), request.pickupAt(), request.deliveryAt(), request.remark());
Map<String, Object> after = snapshot(order);
if (before.equals(after)) return response(order);
LocalDateTime now = LocalDateTime.now();
touch(order, now);
orders.updateById(order);
repository.updateOrder(order);
history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId,
null, changeDetail(before, after), null, now);
return response(order);
}
/** 按版本和独立幂等键推进允许的状态,同时记录不可变历史。 */
@Transactional
public LocalLooseboardResponses.Detail changeStatus(String customerId, String orderNo, String operatorId,
LocalLooseboardRequests.ChangeStatus request) {
LocalLooseboardOrder order = lock(customerId, orderNo);
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
LocalLooseboardOperation existing = operations.findKey(customerId, order.getId(), key);
LocalLooseboardOperation existing = repository.findOperation(customerId, order.getId(), key);
if (existing != null) {
if (!"STATUS".equals(existing.getOperationType()) || !requestHash.equals(existing.getRequestHash())) {
throw conflict("Idempotency key was reused with a different looseboard status payload");
@@ -186,81 +167,95 @@ public class LocalLooseboardService {
LocalDateTime now = LocalDateTime.now();
order.setStatus(next);
touch(order, now);
orders.updateById(order);
repository.updateOrder(order);
syncLastlegOrder(order, next, now);
LocalLooseboardOperation operation = new LocalLooseboardOperation();
operation.setId(IdWorker.getId()); operation.setOrderId(order.getId());
operation.setCustomerId(customerId); operation.setOperationType("STATUS");
operation.setIdempotencyKey(key); operation.setRequestHash(requestHash);
operation.setResultVersion(order.getVersion()); operation.setCreatedBy(operatorId);
operation.setCreatedAt(now); operations.insert(operation);
operation.setCreatedAt(now); repository.insertOperation(operation);
history(order, "STATUS", from, next, operatorId, key, null, request.remark(), now);
return response(order);
}
private void apply(LocalLooseboardOrder order, String mode, String palletRequired, String transportType,
/** 保存申报货物与当地取送时间,不预设内部安排或实际收货。 */
private void apply(LocalLooseboardOrder order,
String origin, String originAddress, String destination, String destinationAddress,
String containerType, Integer packageCount, java.math.BigDecimal totalWeightKg,
String productName, Integer estimatedPalletCount, Integer packageCount, java.math.BigDecimal totalWeightKg,
java.math.BigDecimal totalVolumeCbm, java.math.BigDecimal budgetAmount, String currency,
String contactName, String contactInfo, java.time.LocalDate requestedDate, String remark) {
order.setTransportMode(norm(mode));
order.setPalletRequired(norm(palletRequired));
order.setTransportType(norm(transportType));
String originContactName, String originContactPhone, String destinationContactName, String destinationContactPhone, LocalDateTime pickupAt, LocalDateTime deliveryAt, String remark) {
if (!StringUtils.hasText(originContactName) || !StringUtils.hasText(originContactPhone)
|| !StringUtils.hasText(destinationContactName) || !StringUtils.hasText(destinationContactPhone)) {
throw badRequest("请填写起运地和目的地联系人及联系电话");
}
if (!LocalLooseboardRequests.validSchedule(pickupAt, deliveryAt)) {
throw badRequest("取货和送货时间必填,送货时间不能早于取货时间");
}
order.setProductName(trim(productName));
order.setEstimatedPalletCount(estimatedPalletCount);
order.setOrigin(origin.trim());
order.setOriginAddress(trim(originAddress));
order.setDestination(destination.trim());
order.setDestinationAddress(trim(destinationAddress));
order.setContainerType(StringUtils.hasText(containerType)
? ContainerSpecificationCatalog.require(containerType).containerType()
: ContainerSpecificationCatalog.DEFAULT_CONTAINER_TYPE);
order.setPackageCount(packageCount);
order.setTotalWeightKg(totalWeightKg);
order.setTotalVolumeCbm(totalVolumeCbm);
order.setBudgetAmount(budgetAmount);
order.setCurrency(StringUtils.hasText(currency) ? norm(currency) : "USD");
order.setContactName(trim(contactName));
order.setContactInfo(trim(contactInfo));
order.setRequestedDate(requestedDate);
order.setOriginContactName(originContactName.trim());
order.setOriginContactPhone(originContactPhone.trim());
order.setDestinationContactName(destinationContactName.trim());
order.setDestinationContactPhone(destinationContactPhone.trim());
order.setPickupAt(pickupAt);
order.setDeliveryAt(deliveryAt);
order.setRemark(trim(remark));
}
/** 拒绝幂等键绑定不同内容,重复请求返回原需求。 */
private LocalLooseboardResponses.Detail replay(String customerId, LocalLooseboardOrder order, String hash) {
if (!hash.equals(order.getRequestHash())) throw conflict("Idempotency key was reused with different looseboard data");
return detail(customerId, order.getOrderNo());
}
/** 锁定客户订单并拒绝池来源的普通编辑。 */
private LocalLooseboardOrder lock(String customerId, String orderNo) {
LocalLooseboardOrder order = orders.lockNo(customerId, orderNo);
LocalLooseboardOrder order = repository.lockNo(customerId, orderNo);
if (order == null) throw notFound();
fleetPool.requireStandalone(customerId, "LOCAL_LOOSEBOARD", order.getId());
return order;
}
/** 装配需求详情、真实承运安排与不可变历史。 */
private LocalLooseboardResponses.Detail response(LocalLooseboardOrder order) {
return new LocalLooseboardResponses.Detail(summary(order), order.getOriginAddress(),
order.getDestinationAddress(), order.getRemark(), order.getCreatedBy(),
histories.listOrder(order.getCustomerId(), order.getId()).stream()
repository.histories(order.getCustomerId(), order.getId()).stream()
.map(value -> new LocalLooseboardResponses.History(value.getOperationType(), value.getFromStatus(),
value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(),
value.getChangeDetail(), value.getRemark(),
value.getCreatedAt())).toList());
}
/** 构造同客户商业订单的承运查询身份。 */
private com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.OrderKey poolKey(LocalLooseboardOrder order) {
return new com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.OrderKey(order.getCustomerId(), "LOCAL_LOOSEBOARD", order.getId());
}
/** 装配原值需求摘要,预计托数与实际履约保持独立。 */
private LocalLooseboardResponses.Summary summary(LocalLooseboardOrder order) {
return summary(order, fleetPool.arrangement(poolKey(order)));
}
/** 装配原值需求摘要,预计托数与实际履约保持独立。 */
private LocalLooseboardResponses.Summary summary(LocalLooseboardOrder order, com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement arrangement) {
return new LocalLooseboardResponses.Summary(order.getId(), order.getCustomerId(), order.getOrderNo(),
order.getTransportMode(), order.getPalletRequired(), order.getTransportType(), order.getOrigin(),
order.getDestination(), order.getContainerType(), Objects.requireNonNullElse(order.getPackageCount(), 0),
order.getPalletRequired(), order.getTransportType(), order.getOrigin(),
order.getDestination(), order.getProductName(), order.getEstimatedPalletCount(), Objects.requireNonNullElse(order.getPackageCount(), 0),
order.getTotalWeightKg(), order.getTotalVolumeCbm(), order.getBudgetAmount(), order.getCurrency(),
order.getContactName(), order.getContactInfo(), order.getRequestedDate(), order.getStatus(),
order.getOriginContactName(), order.getOriginContactPhone(), order.getDestinationContactName(), order.getDestinationContactPhone(), order.getPickupAt(), order.getDeliveryAt(), order.getStatus(),
value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt(), arrangement);
}
/** 写入带操作者和变更快照的订单历史。 */
private void history(LocalLooseboardOrder order, String type, String from, String to, String operator,
String idempotencyKey, String changeDetail, String remark, LocalDateTime now) {
LocalLooseboardHistory history = new LocalLooseboardHistory();
@@ -275,9 +270,10 @@ public class LocalLooseboardService {
history.setChangeDetail(changeDetail);
history.setRemark(trim(remark));
history.setCreatedAt(now);
histories.insert(history);
repository.insertHistory(history);
}
/** 为独立下单创建唯一尾程来源,不按预计托数生成实物托盘。 */
private LastlegOrder createLastlegOrder(LocalLooseboardOrder source, String requestHash, LocalDateTime now) {
LastlegOrder order = new LastlegOrder();
order.setId(IdWorker.getId());
@@ -302,21 +298,23 @@ public class LocalLooseboardService {
order.setSourcePayloadHash("sha256:" + requestHash);
order.setCreatedAt(now);
order.setUpdatedAt(now);
lastlegOrders.insert(order);
repository.insertTailOrder(order);
return order;
}
/** 在同客户边界内同步关联尾程的商业状态。 */
private void syncLastlegOrder(LocalLooseboardOrder source, String status, LocalDateTime now) {
if (source.getLastlegOrderId() == null) return;
LastlegOrder order = lastlegOrders.selectById(source.getLastlegOrderId());
LastlegOrder order = repository.tailOrder(source.getLastlegOrderId());
if (order == null || !source.getCustomerId().equals(order.getCustomerId())) {
throw conflict("Linked tail-leg order is missing");
}
order.setSubOrderStatus("LOCAL_LOOSEBOARD:" + status);
order.setUpdatedAt(now);
lastlegOrders.updateById(order);
repository.updateTailOrder(order);
}
/** 追加尾程状态历史。 */
private void lastlegHistory(String customerId, Long orderId, String from, String to, String remark,
LocalDateTime now) {
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory();
@@ -327,44 +325,47 @@ public class LocalLooseboardService {
history.setEventId("LOCAL_LOOSEBOARD");
history.setRemark(remark);
history.setCreatedAt(now);
lastlegHistories.insert(history);
repository.insertTailHistory(history);
}
/** 校验状态与取货日期筛选边界。 */
private void validateQuery(LocalLooseboardRequests.Query query) {
if (StringUtils.hasText(query.status()) && !STATUSES.contains(norm(query.status()))) {
throw badRequest("Unsupported looseboard order status");
}
if (StringUtils.hasText(query.transportMode()) && !MODES.contains(norm(query.transportMode()))) {
throw badRequest("Unsupported looseboard transport mode");
}
if (query.requestedFrom() != null && query.requestedTo() != null
&& query.requestedFrom().isAfter(query.requestedTo())) {
throw badRequest("requestedFrom must not be after requestedTo");
}
}
/** 冻结本次编辑的业务字段用于差异审计。 */
private Map<String, Object> snapshot(LocalLooseboardOrder order) {
Map<String, Object> value = new LinkedHashMap<>();
value.put("transportMode", order.getTransportMode());
value.put("palletRequired", order.getPalletRequired());
value.put("transportType", order.getTransportType());
value.put("origin", order.getOrigin());
value.put("originAddress", order.getOriginAddress());
value.put("destination", order.getDestination());
value.put("destinationAddress", order.getDestinationAddress());
value.put("containerType", order.getContainerType());
value.put("productName", order.getProductName());
value.put("estimatedPalletCount", order.getEstimatedPalletCount());
value.put("packageCount", order.getPackageCount());
value.put("totalWeightKg", order.getTotalWeightKg());
value.put("totalVolumeCbm", order.getTotalVolumeCbm());
value.put("budgetAmount", order.getBudgetAmount());
value.put("currency", order.getCurrency());
value.put("contactName", order.getContactName());
value.put("contactInfo", order.getContactInfo());
value.put("requestedDate", order.getRequestedDate());
value.put("originContactName", order.getOriginContactName());
value.put("originContactPhone", order.getOriginContactPhone());
value.put("destinationContactName", order.getDestinationContactName());
value.put("destinationContactPhone", order.getDestinationContactPhone());
value.put("pickupAt", order.getPickupAt());
value.put("deliveryAt", order.getDeliveryAt());
value.put("remark", order.getRemark());
return value;
}
/** 生成字段前后值的不可变变更快照。 */
private String changeDetail(Map<String, Object> before, Map<String, Object> after) {
Map<String, Object> changes = new LinkedHashMap<>();
before.forEach((field, oldValue) -> {
@@ -383,6 +384,7 @@ public class LocalLooseboardService {
}
}
/** 计算规范请求哈希以约束创建和状态命令幂等。 */
private String hash(Object value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
@@ -392,20 +394,27 @@ public class LocalLooseboardService {
}
}
/** 递增锁定订单的版本并更新时间。 */
private static void touch(LocalLooseboardOrder order, LocalDateTime now) {
order.setVersion(value(order.getVersion()) + 1);
order.setUpdatedAt(now);
}
/** 将未初始化的版本视作初始版本。 */
private static long value(Long value) { return value == null ? 0 : value; }
/** 规范选填文字,空白保存为未知。 */
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
/** 统一内部枚举和币种大小写。 */
private static String norm(String value) { return value.trim().toUpperCase(); }
/** 返回明确的输入校验失败。 */
private static ResponseStatusException badRequest(String message) {
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
}
/** 返回版本、幂等或状态冲突。 */
private static ResponseStatusException conflict(String message) {
return new ResponseStatusException(HttpStatus.CONFLICT, message);
}
/** 隐藏客户范围外订单并返回未找到。 */
private static ResponseStatusException notFound() {
return new ResponseStatusException(HttpStatus.NOT_FOUND, "本土散板订单不存在或无权查看");
}
@@ -190,7 +190,9 @@ public class WarehouseTransferService {
private List<WarehouseTransferResponses.SourceCarton> unreservedSourceCartons(String customerId,
String warehouseId, String source, List<WarehouseTransferResponses.SourceCarton> cartons) {
if (inventorySources == null) return cartons;
Set<String> occupied = inventorySources.occupiedMarks(customerId, warehouseId, source).stream()
var occupiedMarks = inventorySources.occupiedMarks(customerId, warehouseId, source);
if (occupiedMarks == null || occupiedMarks.isEmpty()) return cartons;
Set<String> occupied = occupiedMarks.stream()
.filter(StringUtils::hasText).map(mark -> mark.trim().toUpperCase(java.util.Locale.ROOT))
.collect(Collectors.toSet());
return cartons.stream().filter(box -> !occupied.contains(box.cartonMark().trim()
@@ -483,7 +485,6 @@ public class WarehouseTransferService {
// 所有同源提交锁定同一入仓记录;取锁后按最新可用箱校验并保存逐箱占用快照。
if (inventorySources != null && inventorySources.lockSource(customerId, transfer.getInboundOrderId()) == null)
throw conflict("库存来源已失效,请重新选择来源单");
if (inventorySources != null) reserveSourceCartons(customerId, transfer);
}
if (Boolean.TRUE.equals(transfer.getNeedRelabel())) {
@@ -499,6 +500,11 @@ public class WarehouseTransferService {
}
}
// 来源箱归属校验通过后再写入占用快照,保证错误箱号返回明确提示。
if (!transfer.getTransferNo().equals(transfer.getInboundCode()) && inventorySources != null) {
reserveSourceCartons(customerId, transfer);
}
LastlegOrder order = requireTailOrder(customerId, transfer);
LocalDateTime now = LocalDateTime.now();
transfer.setStatus(PENDING_REVIEW);
@@ -2865,16 +2865,17 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order_status_history (
CREATE TABLE IF NOT EXISTS ll_local_looseboard_order (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
lastleg_order_id BIGINT NULL COMMENT '关联尾程主订单ID',
order_no VARCHAR(64) NOT NULL COMMENT '本土散板订单号', transport_mode VARCHAR(32) NOT NULL COMMENT '运输方式:拼车或优先拼载',
pallet_required VARCHAR(8) NULL DEFAULT NULL COMMENT '是否需要打盘:YES或NO;拼车来源未提供时为空', transport_type VARCHAR(32) NOT NULL COMMENT '运输类型',
order_no VARCHAR(64) NOT NULL COMMENT '本土散板订单号',
pallet_required VARCHAR(8) NULL DEFAULT NULL COMMENT '是否需要打盘:YES或NO;拼车来源未提供时为空', transport_type VARCHAR(32) NULL DEFAULT NULL COMMENT '接单运输安排;下单不预设',
origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运详细地址',
destination VARCHAR(255) NOT NULL COMMENT '目的地', destination_address VARCHAR(500) NULL COMMENT '目的详细地址、码头或仓号',
container_type VARCHAR(32) NULL DEFAULT NULL COMMENT '柜型;拼车来源未提供时为空,不推断为整柜', package_count INT NOT NULL DEFAULT 0 COMMENT '货物件数',
product_name VARCHAR(255) NULL COMMENT '商品名称', estimated_pallet_count INT NULL COMMENT '客户申报预计托数,不代表实际收货', package_count INT NOT NULL DEFAULT 0 COMMENT '货物件数',
total_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '总重量(千克)',
total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)',
budget_amount DECIMAL(18,2) NULL COMMENT '预算总价', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '预算币种',
contact_name VARCHAR(128) NULL COMMENT '联系人姓名', contact_info VARCHAR(128) NULL COMMENT '联系人联系方式',
requested_date DATE NULL COMMENT '要求服务日期', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ACCEPTANCE' COMMENT '本土散板订单状态',
origin_contact_name VARCHAR(128) NULL COMMENT '起运地联系人', origin_contact_phone VARCHAR(128) NULL COMMENT '起运地联系电话',
destination_contact_name VARCHAR(128) NULL COMMENT '目的地联系人', destination_contact_phone VARCHAR(128) NULL COMMENT '目的地联系电话',
pickup_at DATETIME(3) NULL COMMENT '取货时间(当地时间,不转换时区)', delivery_at DATETIME(3) NULL COMMENT '送货时间(当地时间,不转换时区)', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ACCEPTANCE' COMMENT '本土散板订单状态',
remark VARCHAR(1000) NULL COMMENT '备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键',
request_hash CHAR(64) NOT NULL COMMENT '创建请求内容哈希', created_by VARCHAR(128) NULL COMMENT '创建人ID',
version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
@@ -2883,7 +2884,7 @@ CREATE TABLE IF NOT EXISTS ll_local_looseboard_order (
UNIQUE KEY uk_ll_local_looseboard_lastleg(lastleg_order_id),
UNIQUE KEY uk_ll_local_looseboard_key(customer_id,idempotency_key),
KEY idx_ll_local_looseboard_list(customer_id,status,created_at),
KEY idx_ll_local_looseboard_date(customer_id,requested_date,created_at)
KEY idx_ll_local_looseboard_date(customer_id,pickup_at,created_at)
) ENGINE=InnoDB COMMENT='本土散板订单';
CREATE TABLE IF NOT EXISTS ll_local_looseboard_history (
@@ -1455,7 +1455,6 @@ paths:
- { name: orderNo, in: query, schema: { type: string } }
- { name: routeKeyword, in: query, schema: { type: string } }
- { name: status, in: query, schema: { $ref: '#/components/schemas/LooseboardStatus' } }
- { name: transportMode, in: query, schema: { type: string, enum: [SHARED_TRUCK, PRIORITY_CONSOLIDATION] } }
- { name: requestedFrom, in: query, schema: { type: string, format: date } }
- { name: requestedTo, in: query, schema: { type: string, format: date } }
responses: { '200': { description: 当前客户本土散板订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardPageResponse' } } } } }
@@ -3664,25 +3663,28 @@ components:
LooseboardSummary:
type: object
description: 散板商业主单摘要;池来源附带同客户fleetArrangement,独立订单该字段为null。池来源不创建第二张占位尾程来源。
required: [id, customerId, orderNo, transportMode, transportType, origin, destination, packageCount, totalWeightKg, totalVolumeCbm, status, version, createdAt, updatedAt]
required: [id, customerId, orderNo, origin, destination, estimatedPalletCount, packageCount, totalWeightKg, totalVolumeCbm, status, version, createdAt, updatedAt]
properties:
id: { type: string, pattern: '^[0-9]+$' }
customerId: { type: string }
orderNo: { type: string }
transportMode: { type: string, enum: [SHARED_TRUCK, PRIORITY_CONSOLIDATION] }
palletRequired: { type: string, nullable: true, enum: [YES, NO], description: 是否委托打盘服务;池询价未提供时未知,不从实托数或托位推断YES }
transportType: { type: string }
transportType: { type: string, nullable: true }
origin: { type: string }
destination: { type: string }
containerType: { type: string, nullable: true, enum: [40GP, 40HQ, 45HQ], description: 已知时为平台统一柜型;池询价未提供时未知,不默认40HQ }
productName: { type: string, nullable: true, maxLength: 255 }
estimatedPalletCount: { type: integer, minimum: 0, description: 申报预计托数,独立于实际收货 }
packageCount: { type: integer }
totalWeightKg: { type: number }
totalVolumeCbm: { type: number }
budgetAmount: { type: number, nullable: true }
currency: { type: string, nullable: true }
contactName: { type: string, nullable: true }
contactInfo: { type: string, nullable: true }
requestedDate: { type: string, format: date, nullable: true }
originContactName: { type: string, nullable: true }
originContactPhone: { type: string, nullable: true }
destinationContactName: { type: string, nullable: true }
destinationContactPhone: { type: string, nullable: true }
pickupAt: { type: string, format: date-time, nullable: true, description: 当地日期时间不带时区;按分钟输入 }
deliveryAt: { type: string, format: date-time, nullable: true, description: 当地日期时间,不能早于取货时间 }
status: { $ref: '#/components/schemas/LooseboardStatus' }
version: { type: integer, format: int64 }
createdAt: { type: string, format: date-time }
@@ -4086,47 +4088,49 @@ components:
remark: { type: string, nullable: true, maxLength: 1000 }
LooseboardCreate:
type: object
required: [transportMode, palletRequired, transportType, origin, destination, packageCount, totalWeightKg, totalVolumeCbm, idempotencyKey]
required: [originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, origin, destination, estimatedPalletCount, packageCount, totalWeightKg, totalVolumeCbm, idempotencyKey]
properties:
transportMode: { type: string, enum: [SHARED_TRUCK, PRIORITY_CONSOLIDATION] }
palletRequired: { type: string, enum: [YES, NO] }
transportType: { type: string, enum: [PORT_PICKUP, WAREHOUSE_TO_WAREHOUSE, WAREHOUSE_TO_DOOR] }
origin: { type: string, maxLength: 255 }
originAddress: { type: string, nullable: true, maxLength: 500 }
destination: { type: string, maxLength: 255 }
destinationAddress: { type: string, nullable: true, maxLength: 500 }
containerType: { type: string, enum: [40GP, 40HQ, 45HQ], default: 40HQ, maxLength: 32 }
productName: { type: string, nullable: true, maxLength: 255 }
estimatedPalletCount: { type: integer, minimum: 0, description: 申报预计托数,独立于实际收货 }
packageCount: { type: integer, minimum: 0 }
totalWeightKg: { type: number, minimum: 0 }
totalVolumeCbm: { type: number, minimum: 0 }
budgetAmount: { type: number, nullable: true, minimum: 0 }
currency: { type: string, minLength: 3, maxLength: 3, default: USD }
contactName: { type: string, nullable: true, maxLength: 128 }
contactInfo: { type: string, nullable: true, maxLength: 128 }
requestedDate: { type: string, format: date, nullable: true }
originContactName: { type: string, minLength: 1, maxLength: 128 }
originContactPhone: { type: string, minLength: 1, maxLength: 128 }
destinationContactName: { type: string, minLength: 1, maxLength: 128 }
destinationContactPhone: { type: string, minLength: 1, maxLength: 128 }
pickupAt: { type: string, format: date-time, description: 当地日期时间不带时区;按分钟输入 }
deliveryAt: { type: string, format: date-time, description: 当地日期时间,不能早于取货时间 }
remark: { type: string, nullable: true, maxLength: 1000 }
idempotencyKey: { type: string, maxLength: 255 }
LooseboardUpdate:
type: object
required: [version, transportMode, palletRequired, transportType, origin, destination, packageCount, totalWeightKg, totalVolumeCbm]
required: [originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, version, origin, destination, estimatedPalletCount, packageCount, totalWeightKg, totalVolumeCbm]
properties:
version: { type: integer, format: int64, minimum: 0 }
transportMode: { type: string, enum: [SHARED_TRUCK, PRIORITY_CONSOLIDATION] }
palletRequired: { type: string, enum: [YES, NO] }
transportType: { type: string, enum: [PORT_PICKUP, WAREHOUSE_TO_WAREHOUSE, WAREHOUSE_TO_DOOR] }
origin: { type: string, maxLength: 255 }
originAddress: { type: string, nullable: true, maxLength: 500 }
destination: { type: string, maxLength: 255 }
destinationAddress: { type: string, nullable: true, maxLength: 500 }
containerType: { type: string, enum: [40GP, 40HQ, 45HQ], maxLength: 32 }
productName: { type: string, nullable: true, maxLength: 255 }
estimatedPalletCount: { type: integer, minimum: 0, description: 申报预计托数,独立于实际收货 }
packageCount: { type: integer, minimum: 0 }
totalWeightKg: { type: number, minimum: 0 }
totalVolumeCbm: { type: number, minimum: 0 }
budgetAmount: { type: number, nullable: true, minimum: 0 }
currency: { type: string, minLength: 3, maxLength: 3, default: USD }
contactName: { type: string, nullable: true, maxLength: 128 }
contactInfo: { type: string, nullable: true, maxLength: 128 }
requestedDate: { type: string, format: date, nullable: true }
originContactName: { type: string, minLength: 1, maxLength: 128 }
originContactPhone: { type: string, minLength: 1, maxLength: 128 }
destinationContactName: { type: string, minLength: 1, maxLength: 128 }
destinationContactPhone: { type: string, minLength: 1, maxLength: 128 }
pickupAt: { type: string, format: date-time, description: 当地日期时间不带时区;按分钟输入 }
deliveryAt: { type: string, format: date-time, description: 当地日期时间,不能早于取货时间 }
remark: { type: string, nullable: true, maxLength: 1000 }
securitySchemes:
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
@@ -134,6 +134,9 @@ class DeliveryTransferServiceTest {
@BeforeEach
void setUp() {
service = new DeliveryTransferService();
var outboundQuantities = new com.baiyue.lastleg.delivery.repository.OutboundQuantityRepository();
ReflectionTestUtils.setField(outboundQuantities, "pallets", outboundPallets);
ReflectionTestUtils.setField(service, "outboundQuantities", outboundQuantities);
ReflectionTestUtils.setField(service, "deliveryExecution", new DeliveryExecutionGuard(new com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository(outboundOrders, deliveryOrders)));
var actualSources = mock(DispatchCargoSourceQuery.class);
ReflectionTestUtils.setField(service, "cargoSources", actualSources);
@@ -731,6 +734,20 @@ class DeliveryTransferServiceTest {
assertEquals("DISCARD_OUTBOUND".equals(type) ? null : "LX-REAL-001", detail.outbound().lxCodes());
}
/** 已有废弃出库即便快照误存五件,列表及详情汇总也应显示一箱。 */
@Test
void discardOutboundSummaryCountsReturnCartonOnceAcrossSkuLines() {
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "outbound-301");
outbound.setOutboundType("DISCARD_OUTBOUND"); outbound.setPackageCount(5);
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
var first = pallet(501L, "PALLET-1", null, null, "RT-1", 2);
first.setCartonMarks("BOX-1"); first.setReturnSkuQuantity(2L);
var second = pallet(502L, "PALLET-1", null, null, "RT-1", 3);
second.setCartonMarks("BOX-1"); second.setReturnSkuQuantity(3L);
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(first, second));
assertEquals(1, service.outboundDetail("OUT-301").outbound().packageCount());
}
@Test
void dispatchOutboundStillRequiresItsDeliveryDocument() {
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "dispatch-301");
@@ -189,6 +189,28 @@ class PdaCargoDiscardServiceTest {
verify(photoService).confirm(same(application), eq("散箱"), eq(List.of(101L)), eq(false));
}
/** 退货一箱五件按指定箱作业,不得按五箱或整托处理。 */
@Test void returnCartonUsesPieceUnitAndRequiresSelectedCartonEvenWhenBoundToPallet() {
item.setSourceType("RETURN_CARTON"); item.setReturnCartonId(31L); item.setDiscardPackages(5);
var carton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton();
carton.setCustomerId("CUSTOMER"); carton.setPalletCode("PALLET-1"); carton.setCartonMark("RETURN-BOX-1");
when(returnCartons.selectById(31L)).thenReturn(carton);
when(applications.selectList(any())).thenReturn(List.of(application));
var task = service.resolve(identity, "PALLET-1", "CD-1");
assertEquals("CARTON", task.scanMode());
assertEquals(List.of("RETURN-BOX-1"), task.cartonMarks());
assertEquals(5, task.skus().get(0).plannedPackages());
assertEquals("件", task.skus().get(0).quantityUnit());
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(5)));
verifyNoInteractions(adjustments);
var confirm = new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, 5)),
List.of(101L), List.of("RETURN-BOX-1"), false);
service.confirm(identity, "CD-1", "PALLET-1", confirm);
assertEquals(1, application.getTotalPackages());
assertEquals(new BigDecimal("5.00"), application.getTotalFee());
verify(adjustments).discardPalletItem(eq("CUSTOMER"), same(application), same(item), anyList(), eq(5), eq("OP"), any());
}
@Test void partialBoundPalletOnlyExposesSelectedBoxesAndRequiresTheirScan() {
looseTask(); item.setCartonScanRequired(true);
var stockService = (com.baiyue.lastleg.discard.service.CargoDiscardStockService) ReflectionTestUtils.getField(service, "stockService");
@@ -104,4 +104,23 @@ class CargoDiscardOutboundServiceTest {
assertThrows(ResponseStatusException.class, () -> service.create(application, "OP"));
verify(outbounds, never()).insert(any(OutboundOrder.class)); verify(pallets, never()).insert(any(OutboundPalletItem.class));
}
/** 同一退货箱含多个 SKU 时箱数只记一次,件数仍逐 SKU 保留。 */
@Test void returnCartonProjectionSeparatesOneBoxFromFivePiecesAndSyncsCompletion() {
first.setReturnCartonId(31L); first.setSourceType("RETURN_CARTON"); first.setDiscardPackages(2);
second.setReturnCartonId(31L); second.setSourceType("RETURN_CARTON"); second.setDiscardPackages(3);
var carton = new ReturnInventoryCarton(); carton.setCustomerId("C1"); carton.setOverseasWarehouseId("99");
carton.setCartonMark("BOX-31"); carton.setPalletCode("PALLET-1");
when(returnCartons.selectById(31L)).thenReturn(carton);
service.create(application, "OP");
assertEquals(1, document.getPackageCount());
assertEquals(1, snapshots.stream().mapToInt(OutboundPalletItem::getPackageCount).sum());
assertEquals(5, snapshots.stream().mapToLong(OutboundPalletItem::getReturnSkuQuantity).sum());
first.setPdaStatus("COMPLETED"); first.setActualDiscardPackages(2);
second.setPdaStatus("COMPLETED"); second.setActualDiscardPackages(3);
application.setStatus("PDA_COMPLETED");
service.sync(application);
assertEquals(1, document.getPackageCount());
assertEquals(1, snapshots.stream().mapToInt(OutboundPalletItem::getPickedPackageCount).sum());
}
}
@@ -99,6 +99,9 @@ class CargoDiscardServiceTest {
@BeforeEach
void setUp() {
service = new CargoDiscardService();
var quantities = new com.baiyue.lastleg.discard.repository.CargoDiscardQuantityRepository();
ReflectionTestUtils.setField(quantities, "items", items);
ReflectionTestUtils.setField(service, "quantities", quantities);
ReflectionTestUtils.setField(service, "warehouseDirectory", warehouseDirectory);
lenient().when(warehouseDirectory.receivingWarehouses(any())).thenReturn(com.baiyue.common.core.api.ApiResponse.success(List.of(
new com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient.ReceivingWarehouse(99L, "LAX", "洛杉矶仓", "US", "ACTIVE"))));
@@ -227,6 +230,32 @@ class CargoDiscardServiceTest {
assertEquals("SKU-RETURN", result.get(0).skus().get(0).skuCode());
}
/** 创建和已有单据查询均按退货箱去重,五件不可计为五箱。 */
@Test void returnMixedSkuCartonCreatesOneBoxAndExistingSummaryReadsOneBox() {
var carton = new ReturnInventoryCarton(); carton.setId(901L); carton.setCustomerId("100");
carton.setOverseasWarehouseId("99"); carton.setSourceReturnNo("RT-1"); carton.setCartonMark("BOX-1");
carton.setStatus("AVAILABLE");
var skuA = new ReturnInventoryCartonItem(); skuA.setSkuCode("A"); skuA.setQuantity(2L);
var skuB = new ReturnInventoryCartonItem(); skuB.setSkuCode("B"); skuB.setQuantity(3L);
when(returnCartons.lockId(901L)).thenReturn(carton);
when(returnCartons.selectById(901L)).thenReturn(carton);
when(returnCartonItems.lockSku(901L, "A")).thenReturn(skuA);
when(returnCartonItems.lockSku(901L, "B")).thenReturn(skuB);
when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(skuA, skuB));
when(returnCartons.markDiscardPending(901L, "100")).thenReturn(1);
var detail = service.create("100", "OP", new CargoDiscardRequests.Create(List.of(
new CargoDiscardRequests.Item(null, 901L, "A", 2),
new CargoDiscardRequests.Item(null, 901L, "B", 3)), "return-five-pieces", null));
assertEquals(1, storedApplication.get().getTotalPackages());
assertEquals(new BigDecimal("5.00"), storedApplication.get().getTotalFee());
assertEquals(1, detail.summary().totalPackages());
assertEquals(5, storedItems.stream().mapToInt(CargoDiscardItem::getDiscardPackages).sum());
storedApplication.get().setTotalPackages(5); storedApplication.get().setTotalFee(new BigDecimal("25.00"));
var existing = service.detail("100", storedApplication.get().getDiscardNo());
assertEquals(1, existing.summary().totalPackages());
assertEquals(new BigDecimal("5.00"), existing.summary().totalFee());
}
@Test
void approvalPushesToPdaWithoutDeductingInventory() {
CargoDiscardApplication application = pendingApplication();
@@ -28,7 +28,7 @@ class FleetPoolOrderBridgeTest {
FleetPoolOrderBridge service;
FleetPoolOrderLink row; LocalLooseboardOrder looseOrder; UsTruckOrder truckOrder;
@BeforeEach void setup(){
service=new FleetPoolOrderBridge(links,loose,looseHistory,trucks,truckHistory,numbers,new ObjectMapper().findAndRegisterModules(),evidence,appointmentSources);
service=new FleetPoolOrderBridge(new FleetPoolOrderRepository(links,loose,looseHistory,trucks,truckHistory),numbers,new ObjectMapper().findAndRegisterModules(),evidence,appointmentSources);
when(numbers.next(anyString(),any(),anyLong())).thenReturn("LOCAL-001");
when(links.lockBooking(7L)).thenAnswer(a->row);when(links.lockBookingNo("FB-7")).thenAnswer(a->row);
@@ -86,15 +86,16 @@ class FleetPoolOrderBridgeTest {
}
@Test void looseboardInquiryDoesNotInventContainerOrPalletizingServiceFromForecastPallets(){
var source=input("LOCAL_LOOSEBOARD");service.confirm(source,"op");
assertThat(looseOrder.getContainerType()).isNull();assertThat(looseOrder.getPalletRequired()).isNull();
assertThat(looseOrder.getTransportMode()).isEqualTo("SHARED_TRUCK");
assertThat(looseOrder.getEstimatedPalletCount()).isEqualTo(source.expectedPalletCount());assertThat(looseOrder.getPalletRequired()).isNull();
assertThat(looseOrder.getOriginContactName()).isEqualTo(source.contactName());
assertThat(looseOrder.getDestinationContactName()).isNull();
assertThat(looseOrder.getTransportType()).isEqualTo("WAREHOUSE_TO_WAREHOUSE");
assertThat(looseOrder.getOrigin()).isEqualTo(source.warehouseName());assertThat(looseOrder.getDestination()).isEqualTo(source.destinationLabel());
assertThat(looseOrder.getPackageCount()).isEqualTo(source.packageCount());
assertThat(looseOrder.getTotalWeightKg()).isEqualByComparingTo(source.weightKg());assertThat(looseOrder.getTotalVolumeCbm()).isEqualByComparingTo(source.volumeCbm());
assertThat(looseOrder.getOriginAddress()).isNull();assertThat(looseOrder.getDestinationAddress()).isNull();assertThat(looseOrder.getBudgetAmount()).isNull();
incoming();service.receiving("9","WT-7","pda");service.acceptedReceipt(7,receipt(),3,"op");
assertThat(looseOrder.getPalletRequired()).isNull();assertThat(looseOrder.getContainerType()).isNull();
assertThat(looseOrder.getPalletRequired()).isNull();assertThat(looseOrder.getEstimatedPalletCount()).isEqualTo(source.expectedPalletCount());
}
@Test void truckInquiryPreservesUnknownVehicleWhileZeroActualCountsRemainRealUntilReceipt(){
var source=input("US_TRUCK");service.confirm(source,"op");
@@ -0,0 +1,62 @@
package com.baiyue.lastleg.looseboard;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
import com.baiyue.lastleg.looseboard.mapper.*;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
class LocalLooseboardPersistenceTest {
/** 使用模块基线和真实 Mapper 验证四项联系人、取送时间保存回读、更新及客户隔离。 */
@Test
void contactsAndLocalMinuteTimesRoundTripThroughActualMapper() throws Exception {
var ds = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
String baseline;
try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) {
baseline = new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
int start = baseline.indexOf("CREATE TABLE IF NOT EXISTS ll_local_looseboard_order (");
String ddl = baseline.substring(start, baseline.indexOf(';', start));
// H2 使用真实字段定义,去掉 MySQL 专属的索引和表选项。
ddl = ddl.replaceAll("(?m)^\\s*(?:UNIQUE KEY|KEY) [^\\n]*\\n?", "");
ddl = ddl.substring(0, ddl.indexOf(") ENGINE=")) + ")";
ddl = ddl.replaceAll(",\\s*\\)$", ")");
new JdbcTemplate(ds).execute(ddl);
var config = new MybatisConfiguration();
config.setMapUnderscoreToCamelCase(true);
config.addMapper(LocalLooseboardOrderMapper.class);
var factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(ds); factory.setConfiguration(config);
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(LocalLooseboardOrderMapper.class);
var repository = new com.baiyue.lastleg.looseboard.repository.LocalLooseboardRepository();
org.springframework.test.util.ReflectionTestUtils.setField(repository, "orders", mapper);
var order = new LocalLooseboardOrder();
order.setId(1L); order.setCustomerId("C-1"); order.setOrderNo("TRUCK-TEST-1");
order.setOrigin("Los Angeles"); order.setDestination("Ontario");
order.setOriginContactName("发货人"); order.setOriginContactPhone("+1 (555) 0100");
order.setDestinationContactName("收货人"); order.setDestinationContactPhone("+1-555-0101");
order.setPickupAt(LocalDateTime.of(2026, 10, 20, 9, 30)); order.setDeliveryAt(LocalDateTime.of(2026, 10, 20, 15, 45));
order.setTotalWeightKg(BigDecimal.ZERO); order.setTotalVolumeCbm(BigDecimal.ZERO);
order.setCurrency("USD"); order.setStatus("PENDING_ACCEPTANCE"); order.setIdempotencyKey("roundtrip"); order.setRequestHash("0".repeat(64));
repository.insertOrder(order);
var saved = repository.findNo("C-1", order.getOrderNo());
assertEquals("发货人", saved.getOriginContactName()); assertEquals("+1 (555) 0100", saved.getOriginContactPhone());
assertEquals("收货人", saved.getDestinationContactName()); assertEquals("+1-555-0101", saved.getDestinationContactPhone());
assertEquals(order.getPickupAt(), saved.getPickupAt()); assertEquals(order.getDeliveryAt(), saved.getDeliveryAt());
assertNull(repository.findNo("C-2", order.getOrderNo())); assertNull(repository.findKey("C-2", "roundtrip"));
saved.setDestinationContactPhone("+1-555-0102"); saved.setDeliveryAt(saved.getDeliveryAt().plusMinutes(15));
repository.updateOrder(saved);
var updated = repository.findKey("C-1", "roundtrip");
assertEquals("+1-555-0102", updated.getDestinationContactPhone());
assertEquals(LocalDateTime.of(2026, 10, 20, 16, 0), updated.getDeliveryAt());
}
}
@@ -0,0 +1,51 @@
package com.baiyue.lastleg.looseboard;
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import jakarta.validation.Validation;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class LocalLooseboardRequestsTest {
/** 验证真实 Bean Validation:新增和编辑均要求四项联系人及两个时间。 */
@Test void requiresContactsAndBothLocalTimes() throws Exception {
try (var factory = Validation.buildDefaultValidatorFactory()) {
var validator = factory.getValidator();
var json = new ObjectMapper().findAndRegisterModules();
ObjectNode valid = json.valueToTree(request());
assertTrue(validator.validate(request()).isEmpty());
for (String field : List.of("originContactName", "originContactPhone", "destinationContactName", "destinationContactPhone", "pickupAt", "deliveryAt")) {
ObjectNode missing = valid.deepCopy(); missing.remove(field);
assertFalse(validator.validate(json.treeToValue(missing, LocalLooseboardRequests.Create.class)).isEmpty(), field);
missing.remove("idempotencyKey"); missing.put("version", 0);
assertFalse(validator.validate(json.treeToValue(missing, LocalLooseboardRequests.Update.class)).isEmpty(), field);
}
for (String field : List.of("originContactName", "originContactPhone", "destinationContactName", "destinationContactPhone")) {
ObjectNode blank = valid.deepCopy(); blank.put(field, " ");
assertFalse(validator.validate(json.treeToValue(blank, LocalLooseboardRequests.Create.class)).isEmpty(), field);
}
ObjectNode reversed = valid.deepCopy(); reversed.put("deliveryAt", "2030-01-02T10:29:00");
assertFalse(validator.validate(json.treeToValue(reversed, LocalLooseboardRequests.Create.class)).isEmpty());
}
}
/** JSON 往返保持当地分钟,不包含已删除字段。 */
@Test void roundTripsLocalTimesAndSplitContacts() throws Exception {
var json = new ObjectMapper().findAndRegisterModules();
var valid = request(); var tree = json.valueToTree(valid);
var restored = json.treeToValue(tree, LocalLooseboardRequests.Create.class);
assertEquals(valid, restored);
for (String field : List.of("transportMode", "containerType", "contactName", "contactInfo", "requestedDate", "scheduleValid")) assertFalse(tree.has(field));
}
/** 创建具有独立预计托数和件数的最小完整需求。 */
private LocalLooseboardRequests.Create request() {
return new LocalLooseboardRequests.Create("LA", null, "ONT", null, "货物", 3, 8,
BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", "发货人", "1", "收货人", "2",
LocalDateTime.of(2030, 1, 2, 10, 30), LocalDateTime.of(2030, 1, 2, 15, 45), null, "create-1");
}
}
@@ -45,7 +45,7 @@ class LocalLooseboardSchemaContractTest {
}
@Test
void poolUnknownContainerAndPalletizingSurviveOmittedNullInsertColumnsInBothBaselines() throws Exception {
void demandFieldsAndUnknownPalletizingRemainSeparateInBothBaselines() throws Exception {
String baseline;
try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) {
baseline = new String(input.readAllBytes(), StandardCharsets.UTF_8);
@@ -53,8 +53,13 @@ class LocalLooseboardSchemaContractTest {
String unified = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8);
for (String sql : java.util.List.of(baseline, unified)) {
String block = tableBlock(sql.replace("CREATE TABLE IF NOT EXISTS", "CREATE TABLE"), "ll_local_looseboard_order").toLowerCase();
assertTrue(java.util.regex.Pattern.compile("(?:^|,)\\s*container_type\\s+varchar\\(32\\)\\s+(?:collate\\s+\\w+\\s+)?(?:null\\s+)?default\\s+null\\b").matcher(block).find(),
"Unknown container must remain null when MyBatis omits its insert column");
assertTrue(block.contains("product_name varchar(255)"));
assertTrue(block.contains("estimated_pallet_count int"));
assertTrue(block.contains("pickup_at datetime(3)"));
assertTrue(block.contains("delivery_at datetime(3)"));
for (String field : java.util.List.of("origin_contact_name", "origin_contact_phone", "destination_contact_name", "destination_contact_phone")) assertTrue(block.contains(field));
for (String field : java.util.List.of("transport_mode", "contact_name varchar", "contact_info", "requested_date")) org.junit.jupiter.api.Assertions.assertFalse(block.matches("(?s).*[,\\n]\\s*" + field + ".*"));
org.junit.jupiter.api.Assertions.assertFalse(block.contains("container_type"));
assertTrue(java.util.regex.Pattern.compile("(?:^|,)\\s*pallet_required\\s+varchar\\(8\\)\\s+(?:collate\\s+\\w+\\s+)?(?:null\\s+)?default\\s+null\\b").matcher(block).find(),
"Forecast pallets must not invent YES or NO palletizing service");
}
@@ -40,18 +40,15 @@ class PlatformLocalLooseboardControllerTest {
@Test
void updateRequiresIndependentEditPermission() {
var request = new LocalLooseboardRequests.Update(0L, "SHARED_TRUCK", "NO", "PORT_PICKUP",
"LA", null, "Ontario", null, null, 1, BigDecimal.ZERO, BigDecimal.ZERO,
null, "USD", null, null, null, null);
var v = create();
var request = new LocalLooseboardRequests.Update(0L, v.origin(), v.originAddress(), v.destination(), v.destinationAddress(), v.productName(), v.estimatedPalletCount(), v.packageCount(), v.totalWeightKg(), v.totalVolumeCbm(), v.budgetAmount(), v.currency(), v.originContactName(), v.originContactPhone(), v.destinationContactName(), v.destinationContactPhone(), v.pickupAt(), v.deliveryAt(), v.remark());
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> controller.update(user(Set.of(OverseasPermissions.LOOSEBOARD_CREATE)), "100", "LB1", request));
assertEquals(403, error.getStatusCode().value());
}
private LocalLooseboardRequests.Create create() {
return new LocalLooseboardRequests.Create("SHARED_TRUCK", "NO", "PORT_PICKUP", "LA", null,
"Ontario", null, null, 1, BigDecimal.ZERO, BigDecimal.ZERO, null, "USD",
null, null, LocalDate.now(), null, "key-1");
return new LocalLooseboardRequests.Create("LA", null, "Ontario", null, "货物", 1, 1, BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", "A", "1", "B", "2", java.time.LocalDateTime.of(2030, 1, 2, 9, 30), java.time.LocalDateTime.of(2030, 1, 2, 15, 30), null, "key-1");
}
private AuthenticatedUser user(Set<String> permissions) {
@@ -24,6 +24,7 @@ import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.LocalDate;
import com.baiyue.lastleg.looseboard.repository.LocalLooseboardRepository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.HashMap;
@@ -54,11 +55,13 @@ class LocalLooseboardServiceTest {
void setUp() {
service = new LocalLooseboardService();
org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries.class));
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "histories", histories);
ReflectionTestUtils.setField(service, "operations", operations);
ReflectionTestUtils.setField(service, "lastlegOrders", lastlegOrders);
ReflectionTestUtils.setField(service, "lastlegHistories", lastlegHistories);
var repository = new LocalLooseboardRepository();
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(repository, "orders", orders);
ReflectionTestUtils.setField(repository, "histories", histories);
ReflectionTestUtils.setField(repository, "operations", operations);
ReflectionTestUtils.setField(repository, "lastlegOrders", lastlegOrders);
ReflectionTestUtils.setField(repository, "lastlegHistories", lastlegHistories);
ReflectionTestUtils.setField(service, "lastlegOrderNoGenerator", lastlegOrderNoGenerator);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
org.mockito.Mockito.lenient().when(lastlegOrderNoGenerator.next(anyString(), any(), anyLong()))
@@ -86,31 +89,29 @@ class LocalLooseboardServiceTest {
assertEquals("100", detail.summary().customerId());
assertEquals(LocalLooseboardService.PENDING_ACCEPTANCE, detail.summary().status());
assertEquals("SHARED_TRUCK", detail.summary().transportMode());
assertEquals("40HQ", detail.summary().containerType());
assertEquals("Alice", detail.summary().originContactName());
assertEquals("Bob", detail.summary().destinationContactName());
assertEquals("家居用品", detail.summary().productName());
assertEquals(3, detail.summary().estimatedPalletCount());
assertEquals(LocalDateTime.of(2030, 1, 2, 9, 30), detail.summary().pickupAt());
verify(lastlegOrders).insert(org.mockito.ArgumentMatchers.<LastlegOrder>argThat(value ->
"LOCAL_LOOSEBOARD".equals(value.getOrderType())
&& value.getSourceOrderNo().equals("LB-WH-20260901-000001")
&& value.getInboundCode() == null
&& value.getPalletCount() == 0
&& "LOCAL_LOOSEBOARD:PENDING_ACCEPTANCE".equals(value.getSubOrderStatus())));
verify(histories).insert(org.mockito.ArgumentMatchers.<LocalLooseboardHistory>argThat(value ->
"CREATE".equals(value.getOperationType())));
}
@Test
void createDefaultsTheContainerTypeAndRejectsRemovedTypes() {
AtomicReference<LocalLooseboardOrder> saved = new AtomicReference<>();
when(orders.insert(any(LocalLooseboardOrder.class))).thenAnswer(call -> {
saved.set(call.getArgument(0));
return 1;
});
when(orders.findNo(anyString(), anyString())).thenAnswer(call -> saved.get());
when(histories.listOrder(anyString(), any())).thenReturn(List.of());
var detail = service.create("100", "7", create("default-type", null));
assertEquals("40HQ", detail.summary().containerType());
assertThrows(BizException.class, () -> service.create("100", "7", create("retired-type", "40HC")));
void validatesRequiredTimesAndChronologyWithoutInventingActualPallets() {
var pickup = LocalDateTime.of(2030, 1, 2, 10, 30);
org.junit.jupiter.api.Assertions.assertFalse(LocalLooseboardRequests.validSchedule(null, null));
org.junit.jupiter.api.Assertions.assertFalse(LocalLooseboardRequests.validSchedule(pickup, null));
org.junit.jupiter.api.Assertions.assertFalse(LocalLooseboardRequests.validSchedule(null, null));
org.junit.jupiter.api.Assertions.assertFalse(LocalLooseboardRequests.validSchedule(pickup, pickup.minusMinutes(1)));
org.junit.jupiter.api.Assertions.assertTrue(LocalLooseboardRequests.validSchedule(pickup, pickup.plusMinutes(1)));
}
@Test
@@ -193,27 +194,22 @@ class LocalLooseboardServiceTest {
}
private LocalLooseboardRequests.Create create(String key) {
return create(key, "40HQ");
}
private LocalLooseboardRequests.Create create(String key, String containerType) {
return new LocalLooseboardRequests.Create("SHARED_TRUCK", "YES", "WAREHOUSE_TO_WAREHOUSE",
"Los Angeles", "Address A", "Ontario", "Address B", containerType, 12,
new BigDecimal("100.000"), new BigDecimal("2.500000"), new BigDecimal("500.00"),
"USD", "Alice", "+1-555-0100", LocalDate.now().plusDays(2), null, key);
return new LocalLooseboardRequests.Create("Los Angeles", "Address A", "Ontario", "Address B",
"家居用品", 3, 12, new BigDecimal("100.000"), new BigDecimal("2.500000"), new BigDecimal("500.00"),
"USD", "Alice", "+1-555-0100", "Bob", "+1-555-0101", LocalDateTime.of(2030, 1, 2, 9, 30), LocalDateTime.of(2030, 1, 2, 10, 30), null, key);
}
private LocalLooseboardRequests.Update update(long version) {
return new LocalLooseboardRequests.Update(version, "SHARED_TRUCK", "YES", "WAREHOUSE_TO_WAREHOUSE",
"Los Angeles", "Address A", "Ontario", "Address B", "40HQ", 12,
new BigDecimal("100.000"), new BigDecimal("2.500000"), new BigDecimal("500.00"),
"USD", "Alice", "+1-555-0100", LocalDate.now().plusDays(2), null);
var v = create("unused");
return new LocalLooseboardRequests.Update(version, v.origin(), v.originAddress(), v.destination(), v.destinationAddress(),
v.productName(), v.estimatedPalletCount(), v.packageCount(), v.totalWeightKg(), v.totalVolumeCbm(), v.budgetAmount(), v.currency(),
v.originContactName(), v.originContactPhone(), v.destinationContactName(), v.destinationContactPhone(), v.pickupAt(), v.deliveryAt(), v.remark());
}
private LocalLooseboardOrder order(String status, long version) {
LocalLooseboardOrder order = new LocalLooseboardOrder();
order.setId(1L); order.setCustomerId("100"); order.setOrderNo("LB1");
order.setTransportMode("SHARED_TRUCK"); order.setPalletRequired("YES");
order.setPalletRequired("YES");
order.setTransportType("WAREHOUSE_TO_WAREHOUSE"); order.setOrigin("Los Angeles");
order.setDestination("Ontario"); order.setPackageCount(12); order.setCurrency("USD");
order.setStatus(status); order.setVersion(version); order.setCreatedAt(LocalDateTime.now());
@@ -107,7 +107,7 @@ class MerchantOverseasCustomerApiContractTest {
void looseboardAndInboundQueriesUseAuthenticatedCustomerScope() {
AuthenticatedUser merchant = user(101L, "MERCHANT");
LocalLooseboardRequests.MerchantQuery looseboardQuery = new LocalLooseboardRequests.MerchantQuery(1, 20,
null, null, null, null, null, null);
null, null, null, null, null);
InboundPlanRequests.Query inboundQuery = new InboundPlanRequests.Query(1, 20, null, null, null, null,
null, null, null, null, null, null, null, null);
@@ -60,7 +60,7 @@ class TaillegRealConsumerAcceptanceTest {
@EnableAutoConfiguration(excludeName = "com.baiyue.common.security.BaiyueSecurityAutoConfiguration")
@Import({LoadingCompletedConsumer.class, LastlegOrderNoGenerator.class, DropshipOrderService.class,
InboundPlanService.class, InventoryCommandService.class, RelabelApplicationService.class,
WarehouseTransferService.class, LocalLooseboardService.class})
WarehouseTransferService.class, LocalLooseboardService.class, com.baiyue.lastleg.looseboard.repository.LocalLooseboardRepository.class})
@MapperScan({"com.baiyue.lastleg.mapper", "com.baiyue.lastleg.dropship.mapper",
"com.baiyue.lastleg.inboundplan.mapper", "com.baiyue.lastleg.inboundplan.pda.mapper", "com.baiyue.lastleg.inventory.mapper",
"com.baiyue.lastleg.inventory.command.mapper", "com.baiyue.lastleg.relabel.mapper",
@@ -117,9 +117,9 @@ class TaillegRealConsumerAcceptanceTest {
if (!exists("ll_local_looseboard_order", MARKER + "-LOOSEBOARD-KEY")) {
looseboard.create(CUSTOMER, "real-acceptance", new LocalLooseboardRequests.Create(
"SHARED_TRUCK", "YES", "WAREHOUSE_TO_WAREHOUSE", "洛杉矶海外仓", "Los Angeles",
"ONT8", "FBA ONT8", "40HQ", 3, new BigDecimal("30.00"), new BigDecimal("0.40"),
new BigDecimal("120.00"), "USD", "验收联系人", "13800000000", REQUESTED_DATE,
"洛杉矶海外仓", "Los Angeles",
"ONT8", "FBA ONT8", "验收货物", 2, 3, new BigDecimal("30.00"), new BigDecimal("0.40"),
new BigDecimal("120.00"), "USD", "验收联系人", "13800000000", "验收收货人", "13800000001", REQUESTED_DATE.atTime(9, 30), REQUESTED_DATE.atTime(15, 30),
MARKER, MARKER + "-LOOSEBOARD-KEY"));
}
@@ -513,12 +513,19 @@ class WarehouseTransferServiceTest {
verifyNoInteractions(transfers);
}
/** 来源箱快照先通过占用校验,再单独拒绝草稿中的外部换标箱。 */
@Test
void submittingOldDraftRechecksRelabelCartonOwnership() {
var transfer = transfer(WarehouseTransferService.DRAFT);
transfer.setOperatingWarehouseId("WH-1");
transfer.setInboundCode("IN-1");
transfer.setNeedRelabel(true);
transfer.setPackageCount(1);
var selected = new WarehouseTransferCarton();
selected.setId(51L); selected.setCustomerCartonMark("CURRENT-BOX");
when(transferCartons.listOrder("TENANT-A", transfer.getId())).thenReturn(List.of(selected));
when(inboundSnapshots.sourceCartons("TENANT-A", "WH-1", "IN-1"))
.thenReturn(List.of(new InboundSnapshotMapper.SourceCarton("CURRENT-BOX", "SKU-1", null, null, null, null, null)));
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
var binding = new com.baiyue.lastleg.transfer.entity.WarehouseTransferRelabelBinding();
binding.setCustomerCartonMark("FOREIGN-BOX");
+2 -1
View File
@@ -11,7 +11,7 @@ const OPTIONAL_MODULES = ['phase5-root-fulfillment', 'phase4-overseas-return-fol
'manual-pda-ready-fixture', 'manual-pda-ready-verify',
'manual-pda-batch-fixture', 'manual-pda-batch-verify',
'manual-loading-complete-fixture', 'manual-loading-complete-verify',
'coload-groups', 'dropship-pallet-matrix', 'dropship-physical-carton-chain', 'dropship-browser-fixture', 'mixed-container-receiving', 'pda-loading-link-regression', 'fleet-vehicle-care', 'fleet-driver-qualification']
'coload-groups', 'dropship-pallet-matrix', 'dropship-mixed-palletization', 'dropship-physical-carton-chain', 'dropship-browser-fixture', 'mixed-container-receiving', 'pda-loading-link-regression', 'fleet-vehicle-care', 'fleet-driver-qualification']
const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES])
const A0_LOCATION_PAGE_SIZE = 200
const A0_MAX_LOCATION_MUTATIONS = 8
@@ -61,6 +61,7 @@ const MODULE_DEPENDENCIES = new Map([
['pda-loading-link-regression', ['merchant-qualification-fixture']],
['phase4-overseas-return-followups', ['phase4-overseas-return-receipt']],
['dropship-pallet-matrix', ['merchant-qualification-fixture']],
['dropship-mixed-palletization', ['merchant-qualification-fixture']],
['dropship-physical-carton-chain', ['merchant-qualification-fixture']],
['dropship-browser-fixture', ['merchant-qualification-fixture']],
['mixed-container-receiving', ['merchant-qualification-fixture']],
+2
View File
@@ -172,6 +172,8 @@ export async function run(context) {
`dropship-browser-fixture.mjs` 通过总入口 `--modules dropship-browser-fixture` 执行,自动依赖客户资质准备;复用矩阵生产者创建一票四箱两托,完成国内装柜、船节点和按柜生成海外入库计划,明确停在 `READY_FOR_BROWSER_RECEIVING`。只准备新测试数据,不调用海外 PDA 收货或出库、不创建海外操作员;后续由浏览器页面验收。报告仍保存于 `createdResources.dropshipPalletMatrix`,准备通过不代表全流程通过,不自动删除数据。
`dropship-mixed-palletization.mjs` 通过总入口 `--modules dropship-mixed-palletization` 执行,自动依赖客户资质准备。经正式 HTTP 创建两客户三票一件代发(2/3/1 个物理箱,每箱两个 SKU),完成下单审核和测量,交替调用跨客户箱码预览、领取、清点、先箱后托批绑、移回/归回、六箱混托封托、解封和重新封托。核对客户/入仓单/SKU/数量守恒、只读识别无写入、同键重放、未知码/跨仓拒绝及逐票待分柜;结果保存在 `createdResources.dropshipMixedPalletization`。使用显式目标环境文件,开发机获授权时 Gateway 必须显式指向本机。会创建并保留客户、订单、测量与托盘数据,不自动清理;失败必须检查报告,重跑创建新批次。终点为 `MIXED_PALLET_SEALED_VERIFIED`,不代表装柜、海外履约或 Android 真机验收通过。
`dropship-pallet-matrix.mjs` 验证下述四场景整托手工加入、整托移除、智能推荐、独立订舱与文件放舱、真实柜号关联、预装确认、PDA 整托拣货和五类证据上传;异步装柜完成必须等到任务 `COMPLETED` 并逐票回查 `LOADED`。智能装柜的托盘汇总与装柜详情逐票组成分别核对,不能把共托汇总行的首票编号当作全托归属。随后经正式报关、国内离仓及人工船节点接口推进至 `PICKUP_READY`,再按柜生成逐票海外入库计划,检查来源托盘完整绑定、共享托盘身份一致和同键重放。海外收货使用本轮创建并绑定接柜仓的 PDA 操作员,上传三类照片、签收、逐实物托盘收货、逐票箱数核平和完成入库,同键重放及同托新键扫码均不得重复入账。随后从本轮逐票库存候选创建一件代发专用出库,携带原始箱唛由 PDA 拣货、完成,核对终态、幂等和可用库存扣减;finally 停用本轮操作员。出库重放按业务身份和数据库毫秒时间精度核对。离线合同为 `dropship-pallet-matrix-contract.test.mjs`;脚本覆盖不等于真实 HTTP 已通过,须检查当轮报告和库存审计。
`dropship-physical-carton-chain.mjs` 复用同一条正式链路,专门覆盖多 SKU 共用物理箱:20 箱×2 SKU/2 托、5 箱×3 SKU/1 托、7 箱×2 SKU/预计 2 托后合为 1 托。每个场景从新订单、测量、箱唛冻结和 PDA 清点开始,按去重箱唛核对预装、装柜、尾程计划、海外整托收货、库存候选和一件代发出库;每箱必须保留全部 SKU 明细,但任何汇总不得按 SKU 行重复累计箱数。使用 `--modules dropship-physical-carton-chain`,会保留本轮业务数据和 JSON 报告,不自动清理。
@@ -0,0 +1,134 @@
import { run as createOrder } from './orders.mjs'
import { run as measure } from './inbound-measurement.mjs'
import { run as createCustomer } from './merchant-qualification-fixture.mjs'
const MODULE = 'dropship-mixed-palletization'
const BASE = '/api/platform/v1/first-leg/pda-dropship'
const OPTIONS = { scenario: MODULE, deploymentBlocker: true }
// 正式下单和测量产生两客户三票,再用 PDA 公网接口逐箱混托并保留全部证据。
export async function run(context) {
const originalMarker = context.marker
const savedContext = { ...context.context }
const originalManualCount = context.options.prepareDropshipManualCount
const records = []
const result = { marker: originalMarker, stage: 'PREPARING', records, fullFlowVerified: false }
context.createdResources.dropshipMixedPalletization = result
function check(name, response, passed, details = '') {
context.assert(MODULE, name, response, passed, details, OPTIONS)
if (!passed) throw new Error(name)
}
async function command(name, method, path, body, headers = {}) {
const response = await context.request(method, path, body, undefined, headers)
const value = context.apiSuccess(MODULE, name, response, '', OPTIONS)
if (String(response?.body?.code) !== '200') throw new Error(`${name}: ${response?.body?.code}: ${response?.body?.message}`)
return value
}
async function rejected(name, method, path, body) {
const response = await context.request(method, path, body)
check(name, response, response.http === 200 && ['A0404', 'A0409'].includes(String(response.body?.code)), response.body?.message)
}
async function readTask(record) {
return command(`${record.label}回查任务`, 'GET', `${BASE}/tasks/${record.source.taskId}?warehouseId=${record.source.warehouseId}`)
}
try {
check('存在本轮已审核客户', null, Boolean(context.context.merchantQualificationFixture?.tenantId))
for (const [index, count] of [2, 3, 1].entries()) {
const label = ['A-2BOX', 'B-3BOX', 'B-1BOX'][index]
context.marker = `${originalMarker}-${label}`
if (index === 1) await createCustomer(context, { publishEnvironment: false })
for (const key of ['orders', 'inboundMeasurement', 'dropshipManualCount']) delete context.createdResources[key]
for (const key of ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']) delete context.context[key]
context.context.orderDestinationType = 'OVERSEAS'
context.context.orderFulfillmentType = 'ONE_CLICK_DROPSHIP'
context.context.orderProfile = { taxMode: 'BUY_DECLARATION', pickupMethod: 'SELF_EXPRESS', containerMode: 'LCL' }
context.context.waitSplitVariant = { skuCount: 2, packageCountPerSku: count, packageCounts: [count, count],
totalPackageCount: count, palletCount: 1, skuCodes: [`MIX-SAME-${originalMarker}`.slice(-60), `MIX-SECOND-${originalMarker}`.slice(-60)] }
if (records.length) context.context.waitSplitRoute = { originWarehouseName: records[0].order.receivingWarehouseName,
channelCode: records[0].order.channelCode, destinationIndex: 0 }
context.options.prepareDropshipManualCount = true
const start = context.steps.length
await createOrder(context)
await measure(context)
const source = context.createdResources.dropshipManualCount
check(`${label}真实下单审核测量全部通过`, null, Boolean(source?.taskId)
&& !context.steps.slice(start).some((step) => !step.passed))
records.push({ label, order: { ...context.createdResources.orders }, measurement: { ...context.createdResources.inboundMeasurement }, source: { ...source } })
check(`${label}实测物理箱数正确`, null, source.totalCartons === count)
}
check('两客户三票保持独立归属', null, records.length === 3 && new Set(records.map((r) => r.order.tenantId)).size === 2)
check('三票同仓同渠道同目的仓', null, records.every((r) => r.source.warehouseId === records[0].source.warehouseId
&& r.order.channelCode === records[0].order.channelCode && r.order.destinationWarehouseId === records[0].order.destinationWarehouseId))
context.marker = originalMarker
const warehouseId = records[0].source.warehouseId
const query = `warehouseId=${warehouseId}`
const generated = await command('读取第一票预生成托盘', 'GET', `${BASE}/inbound-orders/${records[0].source.inboundId}/pallets?accountId=${records[0].order.tenantId}`)
const palletNo = generated?.[0]?.palletNo
check('取得空的预生成DP托盘', null, Boolean(palletNo) && generated[0].status === 'OPEN' && generated[0].cartonCount === 0)
result.palletNo = palletNo; result.warehouseId = warehouseId
const palletPath = `${BASE}/pallets/resolve?${query}&palletNo=${encodeURIComponent(palletNo)}`
const initial = await command('先扫空托盘只读识别', 'GET', palletPath)
check('空托盘不会虚构客户组成', null, initial.pallet.cartonCount === 0 && initial.members.length === 0)
await rejected('无效托盘码被拒绝', 'GET', `${BASE}/pallets/resolve?${query}&palletNo=DP-000000-0000`)
await rejected('跨仓托盘码被拒绝', 'GET', `${BASE}/pallets/resolve?warehouseId=1&palletNo=${encodeURIComponent(palletNo)}`)
await rejected('无效箱码被拒绝', 'GET', `${BASE}/cartons/resolve?${query}&boxCode=NO-SUCH-${originalMarker}`)
result.stage = 'COUNTING'
const counted = []
// 交替扫描客户 A/B,证明页面无需先手工切换入仓单。
const scans = records.flatMap((record) => Array.from({ length: record.source.totalCartons }, (_, index) => ({ record, boxCode: `${record.source.inboundCode}#${record.source.totalCartons}-${index + 1}` })))
scans.sort((a, b) => Number(a.boxCode.split('-').at(-1)) - Number(b.boxCode.split('-').at(-1)))
for (const [index, { record, boxCode }] of scans.entries()) {
const resolved = await command('跨客户只读扫码预览', 'GET', `${BASE}/cartons/resolve?${query}&boxCode=${encodeURIComponent(boxCode)}`)
check('箱码返回原客户和原入仓任务', null, String(resolved.task.taskId) === record.source.taskId
&& String(resolved.task.tenantId) === record.order.tenantId && resolved.carton.skuLines.length === 2)
const before = await readTask(record)
check('读取箱码未改变清点和归托事实', null, before.countedCartons === counted.filter((x) => x.taskId === record.source.taskId).length)
if (before.taskStatus === 'PENDING') await command('确认时领取原任务', 'POST', `${BASE}/tasks/${record.source.taskId}/claim`, { warehouseId })
const body = { warehouseId, boxCode, palletNo: index === 0 ? null : palletNo,
skuLines: resolved.carton.skuLines.map((line) => ({ inboundDetailId: line.inboundDetailId, skuCode: line.skuCode,
actualQuantity: line.expectedQuantity, discrepancyAction: 'MATCH' })), operationId: `${originalMarker}-count-${index}` }
const submitted = await command('按原任务确认逐箱实收', 'POST', `${BASE}/tasks/${record.source.taskId}/cartons/count`, body)
const replayed = await command('清点同键重放不重复入账', 'POST', `${BASE}/tasks/${record.source.taskId}/cartons/count`, body)
check('重放保持箱身份和清点数量', null, submitted.carton.cartonId === replayed.carton.cartonId && submitted.countedCartons === replayed.countedCartons)
counted.push({ taskId: record.source.taskId, boxCode, cartonId: submitted.carton.cartonId, inboundId: record.source.inboundId, skuLines: body.skuLines })
if (index === 0) await command('先扫箱再扫托盘批量归托', 'POST', `${BASE}/tasks/${record.source.taskId}/pallets/bind-pending`, { warehouseId, palletNo, operationId: `${originalMarker}-bind` })
}
const beforeSeal = await command('封托前逐客户汇总核对', 'GET', palletPath)
check('六个实物箱两客户三票汇总守恒', null, beforeSeal.pallet.cartonCount === 6 && beforeSeal.members.length === 3
&& new Set(beforeSeal.members.map((m) => String(m.customerId))).size === 2
&& beforeSeal.members.every((m) => m.cartonCount === records.find((r) => r.source.taskId === String(m.taskId))?.source.totalCartons))
const moved = counted.find((item) => item.taskId === records[1].source.taskId)
await command('按客户B原任务移回待绑托', 'POST', `${BASE}/tasks/${moved.taskId}/cartons/${moved.cartonId}/move`, { warehouseId, targetPalletNo: null, operationId: `${originalMarker}-move-out` })
const movedTask = await readTask(records[1])
check('只改变原票归托数量', null, movedTask.unboundCartons === 1 && movedTask.countedCartons === 3)
await command('客户B已点箱归回当前混托', 'POST', `${BASE}/tasks/${moved.taskId}/cartons/${moved.cartonId}/move`, { warehouseId, targetPalletNo: palletNo, operationId: `${originalMarker}-move-back` })
const open = await command('归回后读取权威托盘', 'GET', palletPath)
const sealPath = `${BASE}/tasks/${records[0].source.taskId}/pallets/${open.pallet.palletId}/seal`
await command('混托整托封托', 'POST', sealPath, { warehouseId, expectedVersion: open.pallet.version, operationId: `${originalMarker}-seal` })
let sealed = await command('回查已封托状态', 'GET', palletPath)
check('托盘已封托且六箱完整', null, sealed.pallet.status === 'SEALED' && sealed.pallet.cartonCount === 6)
for (const record of records) {
record.task = await readTask(record)
const inbound = await command('封托后回查原票待分柜', 'GET', `/api/platform/inbounds/${record.source.inboundId}?tenantId=${record.order.tenantId}`)
check('参与票全部完成且进入待分柜', null, record.task.taskStatus === 'COMPLETED' && inbound.summary.processStatus === 'WAIT_SPLIT')
}
const actual = sealed.pallet.cartons
check('每箱客户归属和双SKU实收未合并', null, actual.length === counted.length && actual.every((carton) => {
const source = counted.find((item) => item.boxCode === carton.boxCode)
return source && String(carton.inboundId) === source.inboundId && carton.skuLines.length === 2
&& carton.skuLines.every((line) => source.skuLines.some((expected) => String(line.inboundDetailId) === String(expected.inboundDetailId) && line.actualQuantity === expected.actualQuantity))
}))
await rejected('已封托禁止移箱', 'POST', `${BASE}/tasks/${moved.taskId}/cartons/${moved.cartonId}/move`, { warehouseId, targetPalletNo: null, operationId: `${originalMarker}-sealed-move` })
await command('未入装柜计划允许解除封托', 'POST', `${BASE}/tasks/${records[0].source.taskId}/pallets/${sealed.pallet.palletId}/unseal`, { warehouseId, expectedVersion: sealed.pallet.version, operationId: `${originalMarker}-unseal` })
for (const record of records) check('解封同步恢复所有参与任务', null, (await readTask(record)).taskStatus === 'CLAIMED')
const reopened = await command('回查解封后开放状态', 'GET', palletPath)
await command('核对后重新封托', 'POST', sealPath, { warehouseId, expectedVersion: reopened.pallet.version, operationId: `${originalMarker}-reseal` })
sealed = await command('最终托盘事实', 'GET', palletPath)
result.pallet = sealed.pallet; result.members = sealed.members; result.counted = counted
result.stage = 'MIXED_PALLET_SEALED_VERIFIED'
for (const record of records) record.task = await readTask(record)
} finally {
context.marker = originalMarker; context.context = savedContext
context.options.prepareDropshipManualCount = originalManualCount
}
}
@@ -282,11 +282,11 @@ function truckPayload(context) {
function looseboardPayload(context) {
const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-16)
return {
transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE',
productName: '散板验收货物', estimatedPalletCount: 2,
origin: `散板起运-${suffix}`, originAddress: 'Los Angeles', destination: `散板目的-${suffix}`,
destinationAddress: 'Ontario', containerType: '40GP', packageCount: 3, totalWeightKg: 80,
totalVolumeCbm: 1.8, budgetAmount: 66, currency: 'USD', contactName: '散板验收联系人',
contactInfo: '13800000001', requestedDate: new Date(Date.now() + 8 * 86400000).toISOString().slice(0, 10),
destinationAddress: 'Ontario', packageCount: 3, totalWeightKg: 80,
totalVolumeCbm: 1.8, budgetAmount: 66, currency: 'USD', originContactName: '散板验收联系人', originContactPhone: '13800000001',
destinationContactName: '散板验收收货人', destinationContactPhone: '13800000002', pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:30:00',
remark: context.marker, idempotencyKey: `${context.marker}-phase4-looseboard`.slice(0, 255),
}
}