Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b455c0756a | ||
|
|
a338d924c6 | ||
|
|
e0677e7ae7 | ||
|
|
44c76d8953 |
@@ -9,6 +9,10 @@ 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),
|
||||
OVERSEAS_DISPATCH_LOADING_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_DISPATCH_FUEL_PHOTO(false, FormatPolicy.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 +71,17 @@ enum BusinessFileCategory {
|
||||
.orElseThrow(() -> new BizException(ResultCode.BAD_REQUEST, "不支持的附件业务类型"));
|
||||
}
|
||||
|
||||
/** 校验文件归属;跨客户排车证据固定为平台所有,不随货物客户变化。 */
|
||||
void validateOwner(long tenantId) {
|
||||
// 整车运营照片归原排车单,不随同车客户变化,也不能串用装车和油耗类别。
|
||||
if ((this == OVERSEAS_DISPATCH_LOADING_PHOTO || this == OVERSEAS_DISPATCH_FUEL_PHOTO)
|
||||
&& tenantId != 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "排车运营照片必须归属平台");
|
||||
}
|
||||
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,14 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 自有业务排车运营照片归属契约
|
||||
version: 1.0.0
|
||||
description: >-
|
||||
自有业务消费原排车事实,仅补充运营照片,不创建另一套排车或装车状态。
|
||||
复用InternalBusinessFileController,不增加公网File路径。
|
||||
businessType分为OVERSEAS_DISPATCH_LOADING_PHOTO和OVERSEAS_DISPATCH_FUEL_PHOTO,
|
||||
tenantId固定0,businessId固定原Lastleg排车内部主键,不接受车次主键、车牌或客户ID替代。
|
||||
仅非空JPG/PNG,最大10MiB,同时校验MIME与真实内容签名;两类照片不能交叉引用。
|
||||
Lastleg按公开dispatchNo解析原排车,负责车辆归属、可见阶段、读写权限、附加记录版本、
|
||||
分类附件引用和历史。文件上传不代表装车确认、签收、实到或运营信息保存。
|
||||
File不读取Lastleg表,只按客户范围、业务类别、业务ID和文件ID核对文件复合归属。
|
||||
paths: {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
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.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.file.Path;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class OwnedBusinessPhotoFileTest {
|
||||
private static final String LOADING = "OVERSEAS_DISPATCH_LOADING_PHOTO";
|
||||
private static final String FUEL = "OVERSEAS_DISPATCH_FUEL_PHOTO";
|
||||
@TempDir Path root;
|
||||
|
||||
@BeforeAll static void columns() {
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "owned-business-photo"), FileObjectEntity.class);
|
||||
}
|
||||
|
||||
@ParameterizedTest @ValueSource(strings = {LOADING, FUEL})
|
||||
void rejectsNonPlatformOrInvalidDispatchIdentityBeforeAnyStorage(String type) {
|
||||
var files = mock(FileObjectMapper.class);
|
||||
var storage = mock(BusinessFileStorage.class);
|
||||
var service = service(files, storage);
|
||||
var file = new MockMultipartFile("file", "photo.png", "image/png", new byte[]{1});
|
||||
for (long owner : new long[]{-1, 1, 900})
|
||||
assertThatThrownBy(() -> service.upload(owner, type, "71", 7, file)).isInstanceOf(BizException.class);
|
||||
for (String businessId : new String[]{"0", "-1", "DS-71", "../71", ""})
|
||||
assertThatThrownBy(() -> service.upload(0, type, businessId, 7, file)).isInstanceOf(BizException.class);
|
||||
verifyNoInteractions(files, storage);
|
||||
}
|
||||
|
||||
@ParameterizedTest @ValueSource(strings = {LOADING, FUEL})
|
||||
void storesRealImagesButRejectsPdfSpoofedEmptyAndOversizedFiles(String type) throws Exception {
|
||||
var storage = new BusinessFileStorage();
|
||||
ReflectionTestUtils.setField(storage, "configuredRoot", root);
|
||||
storage.initializeRoot();
|
||||
var category = BusinessFileCategory.require(type);
|
||||
for (String format : new String[]{"jpeg", "png"}) {
|
||||
byte[] bytes = image(format);
|
||||
var upload = new MockMultipartFile("file", "photo." + format, "image/" + format, bytes);
|
||||
var saved = storage.store(0, "71", upload, category);
|
||||
assertThat(storage.read(saved.objectKey())).isEqualTo(bytes);
|
||||
}
|
||||
for (var upload : new MockMultipartFile[]{
|
||||
new MockMultipartFile("file", "empty.png", "image/png", new byte[0]),
|
||||
new MockMultipartFile("file", "spoof.png", "image/png", "not-image".getBytes()),
|
||||
new MockMultipartFile("file", "receipt.pdf", "application/pdf", "%PDF-1.7\nreceipt".getBytes())})
|
||||
assertThatThrownBy(() -> storage.store(0, "71", upload, category)).isInstanceOf(BizException.class);
|
||||
MultipartFile oversized = mock(MultipartFile.class);
|
||||
when(oversized.isEmpty()).thenReturn(false);
|
||||
when(oversized.getSize()).thenReturn(10L * 1024 * 1024 + 1);
|
||||
assertThatThrownBy(() -> storage.store(0, "71", oversized, category)).isInstanceOf(BizException.class);
|
||||
verify(oversized, never()).getInputStream();
|
||||
}
|
||||
|
||||
/** 用实际Mapper查询条件核对四元组,错车、错类别和错文件必须先拒绝再读内容。 */
|
||||
@ParameterizedTest @ValueSource(strings = {LOADING, FUEL})
|
||||
void metadataAndDownloadRequireExactDispatchCategoryAndFile(String type) throws Exception {
|
||||
var files = mock(FileObjectMapper.class);
|
||||
var storage = mock(BusinessFileStorage.class);
|
||||
var service = service(files, storage);
|
||||
byte[] bytes = image("png");
|
||||
var row = new FileObjectEntity();
|
||||
row.setId(501L); row.setTenantId(0L); row.setBusinessType(type); row.setBusinessId("71");
|
||||
row.setOriginalName("photo.png"); row.setContentType("image/png"); row.setSizeBytes((long) bytes.length);
|
||||
row.setObjectKey("owned-photo"); row.setSha256("a".repeat(64)); row.setCreatedAt(java.time.LocalDateTime.now());
|
||||
when(files.selectOne(any())).thenAnswer(call -> {
|
||||
LambdaQueryWrapper<FileObjectEntity> query = call.getArgument(0);
|
||||
assertThat(bound(query, "status")).isEqualTo("ACTIVE");
|
||||
assertThat(bound(query, "deleted")).isEqualTo(false);
|
||||
return Long.valueOf(0).equals(bound(query, "tenant_id")) && type.equals(bound(query, "business_type"))
|
||||
&& "71".equals(bound(query, "business_id")) && Long.valueOf(501).equals(bound(query, "id")) ? row : null;
|
||||
});
|
||||
when(storage.read("owned-photo")).thenReturn(bytes);
|
||||
assertThat(service.metadata(0, type, "71", 501).businessType()).isEqualTo(type);
|
||||
assertThat(service.download(0, type, "71", 501).content()).isEqualTo(bytes);
|
||||
clearInvocations(storage);
|
||||
for (String other : new String[]{LOADING.equals(type) ? FUEL : LOADING,
|
||||
"FLEET_VEHICLE_RECORD_PHOTO", "OVERSEAS_DISPATCH_VEHICLE_EVIDENCE", "DELIVERY_LOADING_PHOTO"}) {
|
||||
assertThatThrownBy(() -> service.metadata(0, other, "71", 501)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.download(0, other, "71", 501)).isInstanceOf(BizException.class);
|
||||
}
|
||||
assertThatThrownBy(() -> service.download(0, type, "72", 501)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.download(0, type, "71", 502)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.download(1, type, "71", 501)).isInstanceOf(BizException.class);
|
||||
verifyNoInteractions(storage);
|
||||
}
|
||||
|
||||
private static Object bound(LambdaQueryWrapper<FileObjectEntity> query, String column) {
|
||||
var matcher = Pattern.compile("(?<![A-Za-z0-9_])" + column + " = #\\{ew\\.paramNameValuePairs\\.([^}]+)}")
|
||||
.matcher(query.getSqlSegment());
|
||||
assertThat(matcher.find()).as("scope column %s", column).isTrue();
|
||||
return query.getParamNameValuePairs().get(matcher.group(1));
|
||||
}
|
||||
|
||||
private static InternalBusinessFileService service(FileObjectMapper files, BusinessFileStorage storage) {
|
||||
var service = new InternalBusinessFileService();
|
||||
ReflectionTestUtils.setField(service, "files", files);
|
||||
ReflectionTestUtils.setField(service, "storage", storage);
|
||||
return service;
|
||||
}
|
||||
|
||||
private static byte[] image(String format) throws Exception {
|
||||
var bytes = new ByteArrayOutputStream();
|
||||
assertThat(javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(2, 2,
|
||||
java.awt.image.BufferedImage.TYPE_INT_RGB), format, bytes)).isTrue();
|
||||
return bytes.toByteArray();
|
||||
}
|
||||
}
|
||||
+22
@@ -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)
|
||||
|
||||
+39
@@ -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;
|
||||
}
|
||||
}
|
||||
+31
-1
@@ -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: 查询待领取或本人已领取任务
|
||||
|
||||
+50
-1
@@ -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");
|
||||
|
||||
+30
@@ -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());
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -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(),
|
||||
|
||||
+26
@@ -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());
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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) { }
|
||||
|
||||
+8
-4
@@ -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(),
|
||||
|
||||
+19
@@ -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);
|
||||
}
|
||||
}
|
||||
+14
-6
@@ -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); }
|
||||
|
||||
+9
-3
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
+8
-2
@@ -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);
|
||||
|
||||
+17
@@ -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");
|
||||
|
||||
+22
@@ -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");
|
||||
|
||||
+19
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+29
@@ -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();
|
||||
|
||||
@@ -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']],
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user