Compare commits

..
Author SHA1 Message Date
ouyangxq f4901d155c fix: unify receiving discrepancy confirmation 2026-11-06 20:13:41 +08:00
ouyangxq 87bb905283 fix: unify receiving discrepancy confirmation 2026-11-06 20:08:45 +08:00
admin 1611c42d28 fix(lastleg): 合并客户散板生产查询修复
完整客户标识按字节关联,定向测试与生产只读 SQL 验证通过。
2026-11-06 17:25:31 +08:00
ouyangxq e86bdcd623 fix(lastleg): match looseboard pickup customer identity across collations 2026-11-06 17:24:53 +08:00
admin 3cc748974f release: 合并最新 TEST 并发布阿里云灰度
按本轮授权合并经过验证的发布候选,保留 main 已有修复。
2026-11-06 15:10:51 +08:00
ouyangxq 6d8a69b995 test(integration): align provider dispatch with canonical container contract 2026-11-06 15:10:37 +08:00
admin 99f896848e release: 合并最新 TEST 并发布阿里云灰度
按本轮授权合并经过验证的发布候选,保留 main 已有修复。
2026-11-06 14:59:38 +08:00
ouyangxq 08b28c72e7 test(lastleg): align trip reporting fixture with dispatch history 2026-11-06 14:59:23 +08:00
admin f571e107e4 release: 合并最新 TEST 并发布阿里云灰度
按本轮授权合并经过验证的发布候选,保留 main 已有修复。
2026-11-06 14:16:54 +08:00
ouyangxq 51ea89e399 test(firstleg): align release contracts with current evidence and fee states 2026-11-06 14:16:33 +08:00
admin d37522081b release: 合并最新 TEST 并发布阿里云灰度
按本轮授权合并经过验证的发布候选,保留 main 已有修复。
2026-11-06 14:07:01 +08:00
ouyangxq cc9bcbed95 release: include latest TEST revision memory guard 2026-11-06 14:05:09 +08:00
ouyangxq 7cd764e14a release: merge all current TEST changes into main 2026-11-06 14:04:24 +08:00
ouyangxq f4362f6dfd fix(ci): reject TEST revisions without memory protection 2026-11-06 13:59:34 +08:00
liuyihe 9c8273d9a0 Merge remote-tracking branch 'origin/test' into test 2026-11-06 13:34:06 +08:00
liuyihe e995a87b0e 提交 2026-11-06 13:33:37 +08:00
ouyangxq 51c3132195 fix(deploy): enforce TEST service memory budgets 2026-11-06 13:31:25 +08:00
admin c6bbb22a02 Merge pull request 'release: 晋级阿里云 TEST 后端并修复出车统计 UTC 合并回归' (#40) from release/aliyun-gray-20261106 into main 2026-11-06 12:08:48 +08:00
ouyangxq d722bcae49 release: merge gray history and preserve UTC trip reporting 2026-11-06 11:53:51 +08:00
liuyihe 9e99658bfa 提交 2026-11-06 10:41:49 +08:00
liuyihe 44202a7bff 提交 2026-11-05 17:46:30 +08:00
liuyihe ef2ac1eafd Merge remote-tracking branch 'origin/test' into test 2026-11-05 16:52:53 +08:00
liuyihe 08ea663334 提交 2026-11-05 16:52:35 +08:00
ouyangxq 8809801070 Merge remote-tracking branch 'origin/test' into test 2026-11-05 16:30:27 +08:00
ouyangxq 497544e9fd fix: restore customer visit uploads and guard fee publication 2026-11-05 16:30:27 +08:00
liuyihe 83c3b02d96 Merge remote-tracking branch 'origin/test' into test 2026-11-05 16:04:51 +08:00
liuyihe d1ade7e9dd 提交 2026-11-05 16:04:10 +08:00
admin 00120df2eb Merge pull request 'fix(lastleg): 修复取货车次导致出车记录 S0500' (#39) from hotfix-trip-operations into main 2026-10-26 18:00:51 +08:00
ouyangxq 9af1eba876 fix(lastleg): scope departure reporting to delivery trips 2026-10-26 17:59:59 +08:00
115 changed files with 4298 additions and 250 deletions
+8
View File
@@ -125,6 +125,14 @@ pipeline {
git cat-file -e "${RESOLVED_SOURCE_COMMIT}^{commit}" git cat-file -e "${RESOLVED_SOURCE_COMMIT}^{commit}"
git checkout --detach "${RESOLVED_SOURCE_COMMIT}" git checkout --detach "${RESOLVED_SOURCE_COMMIT}"
test "$(git rev-parse HEAD)" = "$(git rev-parse "${RESOLVED_SOURCE_COMMIT}^{commit}")" test "$(git rev-parse HEAD)" = "$(git rev-parse "${RESOLVED_SOURCE_COMMIT}^{commit}")"
# TEST 重发旧提交也须包含限额文件及部署入口,避免重建时恢复无限制容器。
if [ "$RESOLVED_ENVIRONMENT" = "test" ]; then
if ! git cat-file -e "${RESOLVED_SOURCE_COMMIT}:deploy/docker-compose.test-limits.yml" 2>/dev/null ||
! git grep -q -F 'docker-compose.test-limits.yml' "$RESOLVED_SOURCE_COMMIT" -- deploy/scripts/deploy-built-service.sh; then
echo "目标 TEST 提交缺少完整内存保护,请选择包含限额修复的 test 提交" >&2
exit 1
fi
fi
if [ "$RESOLVED_ENVIRONMENT" = "production" ]; then if [ "$RESOLVED_ENVIRONMENT" = "production" ]; then
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
git merge-base --is-ancestor "$RESOLVED_SOURCE_COMMIT" origin/main || { git merge-base --is-ancestor "$RESOLVED_SOURCE_COMMIT" origin/main || {
+65
View File
@@ -0,0 +1,65 @@
# 仅由 TEST 部署入口叠加,固定 JVM 堆并为线程、元空间和直接内存保留余量。
x-test-limits: &test-limits
mem_limit: 1536m
memswap_limit: 1536m
environment: &test-java-environment
JAVA_OPTS: "-XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8"
JAVA_TOOL_OPTIONS: "-Xms128m -Xmx768m"
services:
gateway:
<<: *test-limits
auth:
<<: *test-limits
iam:
<<: *test-limits
order:
<<: *test-limits
firstleg:
<<: *test-limits
mem_limit: 2g
memswap_limit: 2g
environment:
<<: *test-java-environment
JAVA_TOOL_OPTIONS: "-Xms256m -Xmx1g"
finance:
<<: *test-limits
warehouse:
<<: *test-limits
warehouse-overseas-service:
<<: *test-limits
integration-service:
<<: *test-limits
audit-service:
<<: *test-limits
file:
<<: *test-limits
mem_limit: 3g
memswap_limit: 3g
environment:
<<: *test-java-environment
JAVA_TOOL_OPTIONS: "-Xms1g -Xmx2g"
print:
<<: *test-limits
environment:
<<: *test-java-environment
JAVA_TOOL_OPTIONS: "-Xms512m -Xmx1g"
lastleg-service:
<<: *test-limits
mem_limit: 3g
memswap_limit: 3g
environment:
<<: *test-java-environment
JAVA_TOOL_OPTIONS: "-Xms512m -Xmx1536m"
+8
View File
@@ -12,6 +12,7 @@ IMAGE_TAG="$3"
SERVICE="$4" SERVICE="$4"
ENV_FILE="$5" ENV_FILE="$5"
COMPOSE_FILE="deploy/docker-compose.yml" COMPOSE_FILE="deploy/docker-compose.yml"
TEST_LIMITS_FILE="deploy/docker-compose.test-limits.yml"
IMAGE="$IMAGE_REPOSITORY/$SERVICE:$IMAGE_TAG" IMAGE="$IMAGE_REPOSITORY/$SERVICE:$IMAGE_TAG"
REGISTRY_HOST="${IMAGE_REPOSITORY%%/*}" REGISTRY_HOST="${IMAGE_REPOSITORY%%/*}"
PROJECT_NAME="firstleg-$DEPLOY_ENV" PROJECT_NAME="firstleg-$DEPLOY_ENV"
@@ -40,6 +41,9 @@ case "${ALLOW_SAME_CONTRACT_ROLLBACK:-false}" in true|false) ;; *) exit 2 ;; esa
if [ "$DEPLOY_ENV" = "test" ] && [ "${TARGET_IS_LOCAL:-false}" != "true" ]; then if [ "$DEPLOY_ENV" = "test" ] && [ "${TARGET_IS_LOCAL:-false}" != "true" ]; then
echo "TEST deploy must run on the target host" >&2; exit 2 echo "TEST deploy must run on the target host" >&2; exit 2
fi fi
if [ "$DEPLOY_ENV" = "test" ] && [ ! -f "$TEST_LIMITS_FILE" ]; then
echo "TEST memory limits file missing: $TEST_LIMITS_FILE" >&2; exit 2
fi
[ -f "$ENV_FILE" ] || { echo "Environment file missing" >&2; exit 2; } [ -f "$ENV_FILE" ] || { echo "Environment file missing" >&2; exit 2; }
env_file_value() { sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r'; } env_file_value() { sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r'; }
for key in NACOS_SERVER_ADDR NACOS_NAMESPACE NACOS_USERNAME NACOS_PASSWORD REDIS_HOST REDIS_PORT REDIS_DATABASE REDIS_TOKEN_KEY_PREFIX INTERNAL_SERVICE_TOKEN JWT_RSA_JWK_SET JWT_CURRENT_KID ROCKETMQ_NAME_SERVER; do for key in NACOS_SERVER_ADDR NACOS_NAMESPACE NACOS_USERNAME NACOS_PASSWORD REDIS_HOST REDIS_PORT REDIS_DATABASE REDIS_TOKEN_KEY_PREFIX INTERNAL_SERVICE_TOKEN JWT_RSA_JWK_SET JWT_CURRENT_KID ROCKETMQ_NAME_SERVER; do
@@ -74,7 +78,11 @@ local_registry_logout() {
if [ -n "${deploy_docker_config:-}" ]; then rm -rf -- "$deploy_docker_config"; fi if [ -n "${deploy_docker_config:-}" ]; then rm -rf -- "$deploy_docker_config"; fi
} }
# TEST 的查询、部署和回滚共用同一份内存限额,缺失时由入口提前拒绝。
local_compose() { local_compose() {
if [ "$DEPLOY_ENV" = "test" ]; then
set -- -f "$TEST_LIMITS_FILE" "$@"
fi
docker compose \ docker compose \
--project-name "$PROJECT_NAME" \ --project-name "$PROJECT_NAME" \
--env-file "$ENV_FILE" \ --env-file "$ENV_FILE" \
+2
View File
@@ -1,5 +1,7 @@
# 单测试环境数据库空库初始化 # 单测试环境数据库空库初始化
`migrations/overseas/baiyue_lastleg/V38__allow_multi_order_delivery_returns.sql` 移除派送退回的旧全历史唯一索引,保留普通来源索引,支持同派送一次选择多张原订单各建异常。配套 Lastleg 批次行锁、总量校验及 Admin 多选;可重复执行,不改业务记录。已在本机应用并核对行数,远程 TEST 尚未执行。
`20261104-unify-utf8mb4-0900-ai-ci.sql` 是本机 MySQL 8.4.11 五个业务库的排序规则汇总脚本,覆盖 `baiyue_event_bridge`、`baiyue_event_bridge_overseas`、`baiyue_firstleg`、`baiyue_lastleg`、`baiyue_order`。脚本已在本机执行并核验 209 张表、1,644 个文本字段;其他环境执行前必须核对实际字段定义和唯一索引冲突,不能默认部署。脚本只收敛旧的 Unicode 不区分大小写排序规则,保留有意使用的 `utf8mb4_bin`。 `20261104-unify-utf8mb4-0900-ai-ci.sql` 是本机 MySQL 8.4.11 五个业务库的排序规则汇总脚本,覆盖 `baiyue_event_bridge`、`baiyue_event_bridge_overseas`、`baiyue_firstleg`、`baiyue_lastleg`、`baiyue_order`。脚本已在本机执行并核验 209 张表、1,644 个文本字段;其他环境执行前必须核对实际字段定义和唯一索引冲突,不能默认部署。脚本只收敛旧的 Unicode 不区分大小写排序规则,保留有意使用的 `utf8mb4_bin`。
退货仓租改为原托整托计费、余一箱仍计一托,现有阶梯不变。已有 Lastleg 库先执行 [整托计费结构增量](return-storage-rent-whole-pallet.sql) 增加 `ll_storage_rent_lot.charged_days`,再发布匹配的 Lastleg;源基线和海外空库基线已同步。实际出库事务推进已生成费用的截止库龄日,同托同计租日不重复收费。切换期间暂停相关出库、取消未消费预占,并逐单核对旧比例费用;脚本不修改既有费用、库存或资金。本次仅准备代码及脚本,未执行环境 SQL 或部署。 退货仓租改为原托整托计费、余一箱仍计一托,现有阶梯不变。已有 Lastleg 库先执行 [整托计费结构增量](return-storage-rent-whole-pallet.sql) 增加 `ll_storage_rent_lot.charged_days`,再发布匹配的 Lastleg;源基线和海外空库基线已同步。实际出库事务推进已生成费用的截止库龄日,同托同计租日不重复收费。切换期间暂停相关出库、取消未消费预占,并逐单核对旧比例费用;脚本不修改既有费用、库存或资金。本次仅准备代码及脚本,未执行环境 SQL 或部署。
@@ -0,0 +1,10 @@
-- 同一派送可按多张原订单生成退回异常;活动批次由派送行锁和来源数量校验保护。
-- 只调整索引,不修改异常、签收、库存或资金事实;重复执行跳过已完成项。
USE baiyue_lastleg;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'll_exception_order' AND index_name = 'idx_ll_exception_delivery_return'),
'ALTER TABLE ll_exception_order ADD KEY idx_ll_exception_delivery_return (customer_id, delivery_order_id, exception_type)', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'll_exception_order' AND index_name = 'uk_ll_exception_delivery_return'),
'ALTER TABLE ll_exception_order DROP INDEX uk_ll_exception_delivery_return', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -49,6 +49,7 @@ enum BusinessFileCategory {
OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_RELABEL_ITEM_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_RELABEL_ITEM_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_EXCEPTION_EVIDENCE(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_EXCEPTION_EVIDENCE(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_CUSTOMER_VISIT(true, FormatPolicy.CUSTOMER_VISIT, Limits.CUSTOMER_VISIT, BusinessIdPolicy.POSITIVE_LONG),
LOADING_LIST_CUSTOMS_SOURCE(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), LOADING_LIST_CUSTOMS_SOURCE(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
LOADING_LIST_CUSTOMS_RECEIPT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), LOADING_LIST_CUSTOMS_RECEIPT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
LOADING_LIST_CUSTOMS_RESULT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), LOADING_LIST_CUSTOMS_RESULT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
@@ -135,11 +136,12 @@ enum BusinessFileCategory {
return Long.toString(value); return Long.toString(value);
} }
enum FormatPolicy { STANDARD, ARBITRARY, XLSX, PDF, PDF_IMAGE, IMAGE } enum FormatPolicy { STANDARD, ARBITRARY, XLSX, PDF, PDF_IMAGE, IMAGE, CUSTOMER_VISIT }
private enum BusinessIdPolicy { POSITIVE_LONG, ORDER_BATCH_NO } private enum BusinessIdPolicy { POSITIVE_LONG, ORDER_BATCH_NO }
private static final class Limits { private static final class Limits {
private static final long STANDARD = 10L * 1024 * 1024; private static final long STANDARD = 10L * 1024 * 1024;
private static final long CUSTOMS = 50L * 1024 * 1024; private static final long CUSTOMS = 50L * 1024 * 1024;
private static final long ORDER_IMPORT = 5L * 1024 * 1024; private static final long ORDER_IMPORT = 5L * 1024 * 1024;
private static final long CUSTOMER_VISIT = 20L * 1024 * 1024;
} }
} }
@@ -18,6 +18,7 @@ import java.security.NoSuchAlgorithmException;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
@Component @Component
@@ -27,6 +28,8 @@ public class BusinessFileStorage {
private static final Map<String, String> TYPES = Map.of( private static final Map<String, String> TYPES = Map.of(
"text/plain", ".txt", "application/pdf", ".pdf", "text/plain", ".txt", "application/pdf", ".pdf",
"image/jpeg", ".jpg", "image/png", ".png"); "image/jpeg", ".jpg", "image/png", ".png");
private static final Set<String> CUSTOMER_VISIT_EXTENSIONS = Set.of(
"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "jpg", "jpeg", "png", "webp");
@Value("${baiyue.file.storage-root}") @Value("${baiyue.file.storage-root}")
private Path configuredRoot; private Path configuredRoot;
@@ -46,6 +49,7 @@ public class BusinessFileStorage {
return store(tenantId, businessId, file, category.maxSizeBytes(), category.formatPolicy()); return store(tenantId, businessId, file, category.maxSizeBytes(), category.formatPolicy());
} }
/** 按业务类别检查大小和格式,保存真实文件并返回存储结果。 */
private Stored store(long tenantId, String businessId, MultipartFile file, long maxSize, private Stored store(long tenantId, String businessId, MultipartFile file, long maxSize,
BusinessFileCategory.FormatPolicy formatPolicy) { BusinessFileCategory.FormatPolicy formatPolicy) {
if (file == null || file.isEmpty()) throw bad("附件不能为空"); if (file == null || file.isEmpty()) throw bad("附件不能为空");
@@ -55,6 +59,8 @@ public class BusinessFileStorage {
String extension; String extension;
if (formatPolicy == BusinessFileCategory.FormatPolicy.ARBITRARY) { if (formatPolicy == BusinessFileCategory.FormatPolicy.ARBITRARY) {
extension = ""; extension = "";
} else if (formatPolicy == BusinessFileCategory.FormatPolicy.CUSTOMER_VISIT) {
extension = customerVisitExtension(file);
} else if (formatPolicy == BusinessFileCategory.FormatPolicy.PDF) { } else if (formatPolicy == BusinessFileCategory.FormatPolicy.PDF) {
if (!"application/pdf".equals(type)) throw bad("附件仅支持PDF"); if (!"application/pdf".equals(type)) throw bad("附件仅支持PDF");
verifyContent(file, type); verifyContent(file, type);
@@ -85,6 +91,17 @@ public class BusinessFileStorage {
} }
} }
/** 与回访入口保持相同扩展名约束,接受浏览器未提供 MIME 的 Office 资料。 */
private static String customerVisitExtension(MultipartFile file) {
String name = safeName(file);
int dot = name.lastIndexOf('.');
String extension = dot < 0 ? "" : name.substring(dot + 1).toLowerCase(Locale.ROOT);
if (!CUSTOMER_VISIT_EXTENSIONS.contains(extension)) {
throw bad("仅支持PDF、Office、JPG、PNG和WEBP文件");
}
return "." + extension;
}
private static void verifyXlsx(MultipartFile file) { private static void verifyXlsx(MultipartFile file) {
try (InputStream input = file.getInputStream()) { try (InputStream input = file.getInputStream()) {
byte[] prefix = input.readNBytes(4); byte[] prefix = input.readNBytes(4);
@@ -0,0 +1,161 @@
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.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.multipart.MultipartFile;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class CustomerVisitBusinessFileTest {
private static final String TYPE = "OVERSEAS_CUSTOMER_VISIT";
private static final long MAX_SIZE = 20L * 1024 * 1024;
@TempDir Path root;
private BusinessFileStorage storage;
private FileObjectMapper files;
private InternalBusinessFileService service;
/** 初始化文件元数据映射,供真实归属查询使用。 */
@BeforeAll
static void initializeMapping() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "visit-files"),
FileObjectEntity.class);
}
/** 使用真实本地存储,隔离数据库写入。 */
@BeforeEach
void setUp() {
storage = new BusinessFileStorage();
ReflectionTestUtils.setField(storage, "configuredRoot", root);
storage.initializeRoot();
files = mock(FileObjectMapper.class);
service = new InternalBusinessFileService();
ReflectionTestUtils.setField(service, "files", files);
ReflectionTestUtils.setField(service, "storage", storage);
}
/** 页面允许的全部回访资料均可保存,并保持客户及回访记录的精确归属。 */
@ParameterizedTest
@CsvSource(delimiter = '|', value = {
"pdf|application/pdf", "doc|application/msword", "docx|application/octet-stream",
"xls|application/vnd.ms-excel", "xlsx|application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"ppt|application/vnd.ms-powerpoint", "pptx|application/vnd.openxmlformats-officedocument.presentationml.presentation",
"jpg|image/jpeg", "jpeg|image/jpeg", "png|image/png", "webp|image/webp"
})
void uploadsAllSupportedFormatsWithCustomerAndVisitOwnership(String extension, String contentType) {
var file = new MockMultipartFile("file", "回访资料." + extension, contentType, new byte[]{1, 2, 3});
when(files.insert(any(FileObjectEntity.class))).thenAnswer(call -> {
FileObjectEntity row = call.getArgument(0);
row.setId(801L);
return 1;
});
var metadata = service.upload(1001L, TYPE, "71", 9L, file);
assertThat(metadata.ownerScope()).isEqualTo("CUSTOMER");
assertThat(metadata.tenantId()).isEqualTo(1001L);
assertThat(metadata.businessType()).isEqualTo(TYPE);
assertThat(metadata.businessId()).isEqualTo("71");
assertThat(metadata.fileId()).isEqualTo(801L);
assertThat(metadata.fileName()).isEqualTo(file.getOriginalFilename());
assertThat(metadata.contentType()).isEqualTo(contentType);
assertThat(metadata.sizeBytes()).isEqualTo(3L);
var persisted = ArgumentCaptor.forClass(FileObjectEntity.class);
verify(files).insert(persisted.capture());
assertThat(storage.read(persisted.getValue().getObjectKey())).containsExactly(1, 2, 3);
}
/** MIME 缺失或扩展名大写不应阻断有效的 Office 回访资料。 */
@Test
void acceptsOfficeFileWithoutBrowserMimeType() {
var file = new MockMultipartFile("file", "回访记录.DOCX", null, new byte[]{1});
var stored = storage.store(1001L, "71", file, BusinessFileCategory.require(TYPE));
assertThat(stored.contentType()).isEqualTo("application/octet-stream");
assertThat(stored.objectKey()).endsWith(".docx");
}
/** 回访资料单独使用 20MiB 限制,不放宽其他业务附件的限制。 */
@Test
void enforcesNonemptyAndTwentyMibBoundary() {
var category = BusinessFileCategory.require(TYPE);
assertThatThrownBy(() -> storage.store(1001L, "71", new MockMultipartFile("file", new byte[0]), category))
.isInstanceOf(BizException.class).hasMessageContaining("不能为空");
var oversized = mock(MultipartFile.class);
when(oversized.isEmpty()).thenReturn(false);
when(oversized.getSize()).thenReturn(MAX_SIZE + 1);
assertThatThrownBy(() -> storage.store(1001L, "71", oversized, category))
.isInstanceOf(BizException.class).hasMessageContaining("20MB");
var boundary = new MockMultipartFile("file", "回访.doc", "application/msword", new byte[(int) MAX_SIZE]);
assertThat(storage.store(1001L, "71", boundary, category).size()).isEqualTo(MAX_SIZE);
assertThat(BusinessFileCategory.CUSTOMS_DOCUMENT.maxSizeBytes()).isEqualTo(10L * 1024 * 1024);
}
/** 不接受回访入口约定之外的文件或双扩展名可执行文件。 */
@ParameterizedTest
@ValueSource(strings = {"readme.txt", "archive.zip", "visit.pdf.exe", "visit"})
void rejectsUnsupportedFormatsBeforeMetadataIsWritten(String name) {
var file = new MockMultipartFile("file", name, "application/octet-stream", new byte[]{1});
assertThatThrownBy(() -> service.upload(1001L, TYPE, "71", 9L, file))
.isInstanceOf(BizException.class).hasMessageContaining("仅支持");
verifyNoInteractions(files);
}
/** 回访资料必须归属明确客户和有效回访记录。 */
@Test
void rejectsPlatformScopeAndInvalidVisitId() {
var file = new MockMultipartFile("file", "visit.pdf", "application/pdf", new byte[]{1});
assertThatThrownBy(() -> service.upload(0L, TYPE, "71", 9L, file))
.isInstanceOf(BizException.class).hasMessageContaining("必须归属明确客户");
assertThatThrownBy(() -> service.upload(1001L, TYPE, "0", 9L, file))
.isInstanceOf(BizException.class).hasMessageContaining("业务ID必须有效");
verifyNoInteractions(files);
}
/** 保存回访时按客户、类型、回访记录和文件 ID 校验,不能串用其他资料。 */
@Test
void metadataRequiresEveryOwnershipKey() {
var value = new FileObjectEntity();
value.setId(801L);
value.setTenantId(1001L);
value.setBusinessType(TYPE);
value.setBusinessId("71");
when(files.selectOne(any())).thenAnswer(call -> {
LambdaQueryWrapper<FileObjectEntity> query = call.getArgument(0);
assertThat(query.getSqlSegment()).contains("tenant_id", "business_type", "business_id", "id", "status", "deleted");
return query.getParamNameValuePairs().values().containsAll(
java.util.List.of(1001L, TYPE, "71", 801L, "ACTIVE", false)) ? value : null;
});
assertThat(service.metadata(1001L, TYPE, "71", 801L).fileId()).isEqualTo(801L);
assertThatThrownBy(() -> service.metadata(1002L, TYPE, "71", 801L))
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
assertThatThrownBy(() -> service.metadata(1001L, TYPE, "72", 801L))
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
assertThatThrownBy(() -> service.metadata(1001L, "OVERSEAS_EXCEPTION_EVIDENCE", "71", 801L))
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
assertThatThrownBy(() -> service.metadata(1001L, TYPE, "71", 802L))
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
}
}
@@ -87,11 +87,12 @@ public class PlatformFbaPricingController {
return ApiResponse.success(service.confirm(scope, user.userId(), inboundId, idempotencyKey)); return ApiResponse.success(service.confirm(scope, user.userId(), inboundId, idempotencyKey));
} }
/** 按费用发送权限发布已确认费用,审核门禁由服务端校验且不推进履约状态。 */
@PostMapping("/send") @PostMapping("/send")
@PreAuthorize("hasAnyAuthority('firstleg:inbound:confirm-send-fee'," @PreAuthorize("hasAnyAuthority('firstleg:inbound:confirm-send-fee',"
+ " 'firstleg:inbound:send-fee')") + " 'firstleg:inbound:send-fee')")
@BusinessAudit(event = "FBA_FEE_SEND", resourceType = "INBOUND", resourceId = "#inboundId", @BusinessAudit(event = "FBA_FEE_SEND", resourceType = "INBOUND", resourceId = "#inboundId",
description = "发送FBA费用快照并进入待托盘状态") description = "发送FBA费用快照")
public ApiResponse<FeeSnapshotResponse> send( public ApiResponse<FeeSnapshotResponse> send(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String idempotencyKey) { @PathVariable long inboundId, @RequestHeader("Idempotency-Key") String idempotencyKey) {
@@ -100,8 +100,8 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
WHERE departure.container_booking_id=b.id WHERE departure.container_booking_id=b.id
AND departure.confirmed_at IS NOT NULL AND departure.reversed_at IS NULL AND departure.confirmed_at IS NOT NULL AND departure.reversed_at IS NULL
AND departure.status='WAITING_FOR_LOADING') AND departure.status='WAITING_FOR_LOADING')
AND ((#{query.scope}='ACTIVE' AND b.actual_empty_reported_at IS NULL) AND ((#{query.scope}='ACTIVE' AND NOT EXISTS (SELECT 1 FROM fl_terminal_drayage returned WHERE returned.container_booking_id=b.id AND returned.actual_empty_return_at IS NOT NULL UNION ALL SELECT 1 FROM fl_carrier_schedule_node_fact returned_node WHERE returned_node.container_booking_id=b.id AND returned_node.standard_node='EMPTY_RETURNED' AND returned_node.actual_at IS NOT NULL))
OR (#{query.scope}='HISTORY' AND b.actual_empty_reported_at IS NOT NULL)))) OR (#{query.scope}='HISTORY' AND EXISTS (SELECT 1 FROM fl_terminal_drayage returned WHERE returned.container_booking_id=b.id AND returned.actual_empty_return_at IS NOT NULL UNION ALL SELECT 1 FROM fl_carrier_schedule_node_fact returned_node WHERE returned_node.container_booking_id=b.id AND returned_node.standard_node='EMPTY_RETURNED' AND returned_node.actual_at IS NOT NULL)))))
AND (#{query.keyword} IS NULL OR b.container_no LIKE CONCAT('%', #{query.keyword}, '%') AND (#{query.keyword} IS NULL OR b.container_no LIKE CONCAT('%', #{query.keyword}, '%')
OR b.cabinet_no LIKE CONCAT('%', #{query.keyword}, '%') OR b.cabinet_no LIKE CONCAT('%', #{query.keyword}, '%')
OR b.bill_of_lading_no LIKE CONCAT('%', #{query.keyword}, '%') OR b.bill_of_lading_no LIKE CONCAT('%', #{query.keyword}, '%')
@@ -379,6 +379,26 @@ public interface DropshipPalletizationMapper {
ORDER BY inbound_detail_id ORDER BY inbound_detail_id
""") List<PalletLine> sealedPalletLines(@Param("palletId") long palletId); """) List<PalletLine> sealedPalletLines(@Param("palletId") long palletId);
/** 未装托盘标签按物理箱身份转移,保留原箱码不重新编号。 */
@Update("""
<script>UPDATE fl_inbound_carton_label SET inbound_id=#{child},order_id=#{order},inbound_code=#{code},
total_carton_count=#{packages},detail_package_count=LEAST(detail_package_count,#{packages})
WHERE tenant_id=#{tenant} AND inbound_id=#{source} AND batch_status='ACTIVE'
AND id IN (SELECT c.carton_label_id FROM fl_dropship_pallet_carton c
WHERE c.tenant_id=#{tenant} AND c.inbound_id=#{source} AND c.pallet_id IS NOT NULL
AND c.pallet_id NOT IN <foreach collection="picked" item="id" open="(" separator="," close=")">#{id}</foreach>)
</script>""")
int transferRemainderLabels(@Param("tenant") long tenant,@Param("source") long source,@Param("child") long child,
@Param("order") long order,@Param("code") String code,@Param("packages") int packages,@Param("picked") List<Long> picked);
/** 实箱随余量单转移,已装托与跨客户托内其他成员保持原归属。 */
@Update("""
<script>UPDATE fl_dropship_pallet_carton SET inbound_id=#{child}
WHERE tenant_id=#{tenant} AND inbound_id=#{source} AND pallet_id IS NOT NULL
AND pallet_id NOT IN <foreach collection="picked" item="id" open="(" separator="," close=")">#{id}</foreach>
</script>""")
int transferRemainderCartons(@Param("tenant") long tenant,@Param("source") long source,
@Param("child") long child,@Param("picked") List<Long> picked);
@Getter @Setter class TaskRow { private Long id,tenantId,inboundId,warehouseId,assigneeId; private String inboundCode,customerName,warehouseName,taskStatus,assigneeName,processStatus,fulfillmentType; private Integer totalCartons,countedCartons,allocatedCartons,discrepancyCartons,expectedPalletCount,version; } @Getter @Setter class TaskRow { private Long id,tenantId,inboundId,warehouseId,assigneeId; private String inboundCode,customerName,warehouseName,taskStatus,assigneeName,processStatus,fulfillmentType; private Integer totalCartons,countedCartons,allocatedCartons,discrepancyCartons,expectedPalletCount,version; }
@Getter @Setter class PalletRow { private Long id,taskId,tenantId,inboundId,warehouseId; private String palletNo,palletStatus,sealOperationId; private Integer version; private LocalDateTime completedAt; } @Getter @Setter class PalletRow { private Long id,taskId,tenantId,inboundId,warehouseId; private String palletNo,palletStatus,sealOperationId; private Integer version; private LocalDateTime completedAt; }
@Getter @Setter class PlanPalletMemberRow { private Long palletId,accountId,inboundId,orderId; private String orderNo,inboundCode,customerName,orderStatusSnapshot; private Integer availablePackageCount; private LocalDateTime expectedArrivalAt; } @Getter @Setter class PlanPalletMemberRow { private Long palletId,accountId,inboundId,orderId; private String orderNo,inboundCode,customerName,orderStatusSnapshot; private Integer availablePackageCount; private LocalDateTime expectedArrivalAt; }
@@ -0,0 +1,23 @@
package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ResultCode;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 分托装柜余量的实箱归属迁移。 */
@Repository
public class DropshipSplitRepository {
private final DropshipPalletizationMapper mapper;
/** 复用一件代发实箱账本,不另建库存。 */
public DropshipSplitRepository(DropshipPalletizationMapper mapper) { this.mapper = mapper; }
/** 排除本柜已拣托盘,逐箱迁移到余量入仓单;数量不一致回滚本地结算。 */
public void transferRemainder(long tenant, long source, long child, long order, String code,
int packages, List<Long> pickedPallets) {
if (pickedPallets.isEmpty()) throw new BizException(ResultCode.CONFLICT, "缺少已装整托事实");
if (mapper.transferRemainderLabels(tenant, source, child, order, code, packages, pickedPallets) != packages
|| mapper.transferRemainderCartons(tenant, source, child, pickedPallets) != packages)
throw new BizException(ResultCode.CONFLICT, "未装托盘箱数与余量不一致,请刷新后重试");
}
}
@@ -331,6 +331,7 @@ public class FbaPricingRepository {
return inbounds.update(null, update); return inbounds.update(null, update);
} }
/** 保存试算及计费量,保留测量、审核与仓库作业的主状态。 */
public int markInboundCalculated(long tenantId, long inboundId, String processStatus, String chargeMode, public int markInboundCalculated(long tenantId, long inboundId, String processStatus, String chargeMode,
java.math.BigDecimal selectedWeightKg, java.math.BigDecimal selectedWeightKg,
java.math.BigDecimal selectedVolumeCbm, java.math.BigDecimal selectedVolumeCbm,
@@ -341,10 +342,6 @@ public class FbaPricingRepository {
.eq(InboundPricingEntity::getProcessStatus, processStatus) .eq(InboundPricingEntity::getProcessStatus, processStatus)
.in(InboundPricingEntity::getFeeStatus, List.of("UNPRICED", "CALCULATED", "SENT")) .in(InboundPricingEntity::getFeeStatus, List.of("UNPRICED", "CALCULATED", "SENT"))
.set(InboundPricingEntity::getFeeStatus, "CALCULATED"); .set(InboundPricingEntity::getFeeStatus, "CALCULATED");
if (!List.of("WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED")
.contains(processStatus)) {
update.set(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING");
}
applyChargeableSnapshot(update, selectedWeightKg, selectedVolumeCbm, chargeMode, chargeable); applyChargeableSnapshot(update, selectedWeightKg, selectedVolumeCbm, chargeMode, chargeable);
return inbounds.update(null, update); return inbounds.update(null, update);
} }
@@ -383,12 +380,14 @@ public class FbaPricingRepository {
.set(InboundFeeEntity::getConfirmedAt, now)); .set(InboundFeeEntity::getConfirmedAt, now));
} }
/** 确认费用时保留物流主状态,一件代发待组托也允许确认。 */ /** 仅确认已通过测量审核的费用,并保留当前履约状态。 */
public int markInboundConfirmed(long tenantId, long inboundId) { public int markInboundConfirmed(long tenantId, long inboundId) {
return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>() return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId) .eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
.in(InboundPricingEntity::getProcessStatus, .in(InboundPricingEntity::getProcessStatus,
List.of("FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED")) List.of("WAIT_PRICING", "FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT",
"LOADING", "LOADED", "DELIVERED"))
.eq(InboundPricingEntity::getMeasureReviewStatus, "APPROVED")
.eq(InboundPricingEntity::getFeeStatus, "CALCULATED") .eq(InboundPricingEntity::getFeeStatus, "CALCULATED")
.set(InboundPricingEntity::getFeeStatus, "CONFIRMED")); .set(InboundPricingEntity::getFeeStatus, "CONFIRMED"));
} }
@@ -436,15 +435,14 @@ public class FbaPricingRepository {
.set(InboundPricingEntity::getFeeStatus, "VOIDED")); .set(InboundPricingEntity::getFeeStatus, "VOIDED"));
} }
/** 发送仅更新费用状态,并原子校验测量已审核及履约状态未变化。 */
public int markInboundFeeSent(long tenantId, long inboundId, String processStatus) { public int markInboundFeeSent(long tenantId, long inboundId, String processStatus) {
LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>() LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId) .eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
.eq(InboundPricingEntity::getProcessStatus, processStatus) .eq(InboundPricingEntity::getProcessStatus, processStatus)
.eq(InboundPricingEntity::getMeasureReviewStatus, "APPROVED")
.eq(InboundPricingEntity::getFeeStatus, "CONFIRMED") .eq(InboundPricingEntity::getFeeStatus, "CONFIRMED")
.set(InboundPricingEntity::getFeeStatus, "SENT"); .set(InboundPricingEntity::getFeeStatus, "SENT");
if ("FEE_CONFIRMING".equals(processStatus)) {
update.set(InboundPricingEntity::getProcessStatus, "WAIT_PALLET");
}
return inbounds.update(null, update); return inbounds.update(null, update);
} }
@@ -196,9 +196,8 @@ public class FbaPricingService {
} }
feeDetails.materializeCalculatedFreight(tenantId, inboundId, fee.getId(), operatorId, now); feeDetails.materializeCalculatedFreight(tenantId, inboundId, fee.getId(), operatorId, now);
complete(token, fee.getId(), now); complete(token, fee.getId(), now);
String nextStatus = preserveProcessStatusDuringPricing(inbound.getProcessStatus()) // 费用是独立状态,提前试算不得覆盖待测量或待审核等履约进度。
? inbound.getProcessStatus() : "FEE_CONFIRMING"; return response(inbound, fee, rate.getUnitPrice(), inbound.getProcessStatus());
return response(inbound, fee, rate.getUnitPrice(), nextStatus);
} }
/** 价格未命中时按人工单价生成正式费用,计费量与只读预览保持一致。 */ /** 价格未命中时按人工单价生成正式费用,计费量与只读预览保持一致。 */
@@ -290,9 +289,7 @@ public class FbaPricingService {
weight, volume, quantity) != 1) throw conflict("入库单状态已变化,费用未保存"); weight, volume, quantity) != 1) throw conflict("入库单状态已变化,费用未保存");
feeDetails.materializeCalculatedFreight(tenantId, inboundId, fee.getId(), operatorId, now); feeDetails.materializeCalculatedFreight(tenantId, inboundId, fee.getId(), operatorId, now);
complete(token, fee.getId(), now); complete(token, fee.getId(), now);
String nextStatus = preserveProcessStatusDuringPricing(inbound.getProcessStatus()) return response(inbound, fee, request.unitPrice(), inbound.getProcessStatus());
? inbound.getProcessStatus() : "FEE_CONFIRMING";
return response(inbound, fee, request.unitPrice(), nextStatus);
} }
/** 预览和正式计价共用价格匹配与柜级阶梯分摊,避免前端复制计价公式。 */ /** 预览和正式计价共用价格匹配与柜级阶梯分摊,避免前端复制计价公式。 */
@@ -577,10 +574,14 @@ public class FbaPricingService {
public record ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus status, public record ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus status,
int recalculatedCount) { } int recalculatedCount) { }
/** 测量审核通过后才允许确认费用,避免提前冻结费用阻断审核后的重新计价。 */
@Transactional @Transactional
public FeeSnapshotResponse confirm(long tenantId, long operatorId, long inboundId, public FeeSnapshotResponse confirm(long tenantId, long operatorId, long inboundId,
String idempotencyKey) { String idempotencyKey) {
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId); InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
if (!"APPROVED".equals(inbound.getMeasureReviewStatus())) {
throw conflict("测量数据尚未审核通过,请先完成测量审核后再确认费用");
}
if (!feeRevisionStatusSupported(inbound.getProcessStatus())) { if (!feeRevisionStatusSupported(inbound.getProcessStatus())) {
throw conflict("当前入库单状态不允许确认费用"); throw conflict("当前入库单状态不允许确认费用");
} }
@@ -599,10 +600,14 @@ public class FbaPricingService {
return response(tenantId, current.getId(), null); return response(tenantId, current.getId(), null);
} }
/** 仅发布已通过测量审核的费用,发送不改变测量与仓库作业进度。 */
@Transactional @Transactional
public FeeSnapshotResponse send(long tenantId, long operatorId, long inboundId, public FeeSnapshotResponse send(long tenantId, long operatorId, long inboundId,
String idempotencyKey) { String idempotencyKey) {
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId); InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
if (!"APPROVED".equals(inbound.getMeasureReviewStatus())) {
throw conflict("测量数据尚未审核通过,请先完成测量审核后再发送客户");
}
if (!feeRevisionStatusSupported(inbound.getProcessStatus())) { if (!feeRevisionStatusSupported(inbound.getProcessStatus())) {
throw conflict("当前入库单状态不允许发送费用"); throw conflict("当前入库单状态不允许发送费用");
} }
@@ -611,8 +616,7 @@ public class FbaPricingService {
String hash = hash("SEND", inboundId, current.getId().toString()); String hash = hash("SEND", inboundId, current.getId().toString());
InboundPricingIdempotencyEntity token = beginIdempotency( InboundPricingIdempotencyEntity token = beginIdempotency(
tenantId, operatorId, inboundId, "FEE_SEND", idempotencyKey, hash); tenantId, operatorId, inboundId, "FEE_SEND", idempotencyKey, hash);
String nextProcessStatus = preserveProcessStatusDuringPricing(inbound.getProcessStatus()) String nextProcessStatus = inbound.getProcessStatus();
? inbound.getProcessStatus() : "WAIT_PALLET";
if ("COMPLETED".equals(token.getStatus())) { if ("COMPLETED".equals(token.getStatus())) {
feeDetails.publishFreight(tenantId, inboundId, token.getResultResourceId(), feeDetails.publishFreight(tenantId, inboundId, token.getResultResourceId(),
operatorId, null, LocalDateTime.now(clock)); operatorId, null, LocalDateTime.now(clock));
@@ -812,11 +816,6 @@ public class FbaPricingService {
return inbound; return inbound;
} }
private static boolean preserveProcessStatusDuringPricing(String processStatus) {
return List.of("WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED")
.contains(processStatus);
}
private static boolean feeRevisionStatusSupported(String processStatus) { private static boolean feeRevisionStatusSupported(String processStatus) {
return List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", return List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
"WAIT_PRICING", "FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "WAIT_PRICING", "FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING",
@@ -169,24 +169,23 @@ public class LoadingCompletedOutboxService {
traceId, eventKey, null, payloadNode, hash).requireValid(); traceId, eventKey, null, payloadNode, hash).requireValid();
} }
/** 国内同类照片可追加,交接按每类最新有效照片冻结一张对照图。 */
private List<FirstLegLoadingPlanCompletedV3.LoadingEvidence> loadingEvidence(Long containerBookingId) { private List<FirstLegLoadingPlanCompletedV3.LoadingEvidence> loadingEvidence(Long containerBookingId) {
if (containerBookingId == null || containerBookingId <= 0) { if (containerBookingId == null || containerBookingId <= 0) {
throw new BizException(ResultCode.CONFLICT, "装柜计划缺少柜次,不能冻结装柜照片"); throw new BizException(ResultCode.CONFLICT, "装柜计划缺少柜次,不能冻结装柜照片");
} }
List<FirstLegLoadingPlanCompletedV3.LoadingEvidence> result = cabinetEvidenceMapper Map<String, FirstLegLoadingPlanCompletedV3.LoadingEvidence> latestByType = new java.util.TreeMap<>();
.byContainer(containerBookingId).stream() // Mapper 按类型、证据 ID 升序返回;同类后到的有效照片覆盖前一张,原始证据全部保留。
cabinetEvidenceMapper.byContainer(containerBookingId).stream()
.filter(value -> Integer.valueOf(1).equals(value.getActiveFlag()) && "ACTIVE".equals(value.getStatus())) .filter(value -> Integer.valueOf(1).equals(value.getActiveFlag()) && "ACTIVE".equals(value.getStatus()))
.map(value -> new FirstLegLoadingPlanCompletedV3.LoadingEvidence( .forEach(value -> latestByType.put(value.getEvidenceType(),
value.getEvidenceType(), value.getFileId(), value.getFileName())) new FirstLegLoadingPlanCompletedV3.LoadingEvidence(
.sorted(Comparator.comparing(FirstLegLoadingPlanCompletedV3.LoadingEvidence::evidenceType)) value.getEvidenceType(), value.getFileId(), value.getFileName())));
.toList(); if (!latestByType.keySet().equals(Set.of(
Set<String> types = result.stream().map(FirstLegLoadingPlanCompletedV3.LoadingEvidence::evidenceType)
.collect(java.util.stream.Collectors.toSet());
if (result.size() != 5 || !types.equals(Set.of(
"PHOTO_STEP_1", "PHOTO_STEP_2", "PHOTO_STEP_3", "PHOTO_STEP_4", "SEAL"))) { "PHOTO_STEP_1", "PHOTO_STEP_2", "PHOTO_STEP_3", "PHOTO_STEP_4", "SEAL"))) {
throw new BizException(ResultCode.CONFLICT, "国内装柜五类照片不完整,不能生成尾程交接事件"); throw new BizException(ResultCode.CONFLICT, "国内装柜五类照片不完整,不能生成尾程交接事件");
} }
return result; return List.copyOf(latestByType.values());
} }
private FirstLegLoadingPlanCompletedV2.Order order(List<Row> values, List<PalletRow> palletRows, private FirstLegLoadingPlanCompletedV2.Order order(List<Row> values, List<PalletRow> palletRows,
@@ -53,6 +53,8 @@ public class PdaPickSettlementService {
private InboundHoldGuard inboundHoldGuard; private InboundHoldGuard inboundHoldGuard;
@Resource @Resource
private DropshipPalletizationMapper dropshipPallets; private DropshipPalletizationMapper dropshipPallets;
@Resource
private com.baiyue.firstleg.repository.DropshipSplitRepository dropshipSplits;
@Value("${baiyue.security.internal-service-token}") @Value("${baiyue.security.internal-service-token}")
private String internalToken; private String internalToken;
@@ -121,6 +123,7 @@ public class PdaPickSettlementService {
} }
} }
/** 按柜次实拣数量结算,一件代发保留未装整托供后续柜次使用。 */
private boolean settleInbound(long parentInboundId, int pickedPackageCount, private boolean settleInbound(long parentInboundId, int pickedPackageCount,
List<LoadingPickItemEntity> taskItems, long taskId, List<LoadingPickItemEntity> taskItems, long taskId,
long operatorId, String operatorName) { long operatorId, String operatorName) {
@@ -184,9 +187,7 @@ public class PdaPickSettlementService {
&& pickedByDetail.values().stream().mapToInt(Integer::intValue).sum() != pickedPackageCount) { && pickedByDetail.values().stream().mapToInt(Integer::intValue).sum() != pickedPackageCount) {
throw conflict("PDA 已拣明细与入库单已拣箱数不一致"); throw conflict("PDA 已拣明细与入库单已拣箱数不一致");
} }
if (dropshipWholePallet && childPackageCount != 0) {
throw conflict("一件代发现场托盘必须整票装柜,不能生成余量分片");
}
List<MeasurementSplitLine> measurementLines = dropshipWholePallet List<MeasurementSplitLine> measurementLines = dropshipWholePallet
? wholePalletMeasurementLines(measurements, pickedPackageCount, parentAvailable) ? wholePalletMeasurementLines(measurements, pickedPackageCount, parentAvailable)
: measurementLines(measurements, pickedByDetail); : measurementLines(measurements, pickedByDetail);
@@ -219,8 +220,17 @@ public class PdaPickSettlementService {
requireAffected(splits.cloneChildWithPurpose(parent.tenantId(), parentInboundId, rootInboundId, remainderChildId, requireAffected(splits.cloneChildWithPurpose(parent.tenantId(), parentInboundId, rootInboundId, remainderChildId,
orderSplit.remainderChild().orderId(), orderSplit.remainderChild().orderNo(), orderSplit.remainderChild().orderId(), orderSplit.remainderChild().orderNo(),
remainderChildCode, childPackageCount, sequence, "REMAINDER", operatorId, operatorName), "余量子入库单"); remainderChildCode, childPackageCount, sequence, "REMAINDER", operatorId, operatorName), "余量子入库单");
if (dropshipWholePallet) {
var pickedPalletIds = taskItems.stream().filter(item -> "PICKED".equals(item.getItemStatus())
&& "SEALED_PALLET".equals(item.getUnitType())).map(LoadingPickItemEntity::getPalletId)
.filter(Objects::nonNull).distinct().toList();
// 托内箱按实际箱唛移动,不能按箱序号截取,否则会移错已装托盘。
dropshipSplits.transferRemainder(parent.tenantId(), parentInboundId, remainderChildId,
orderSplit.remainderChild().orderId(), remainderChildCode, childPackageCount, pickedPalletIds);
} else {
splits.transferRemainingCartonLabels(parent.tenantId(), parentInboundId, remainderChildId, splits.transferRemainingCartonLabels(parent.tenantId(), parentInboundId, remainderChildId,
orderSplit.remainderChild().orderId(), remainderChildCode, pickedPackageCount, childPackageCount); orderSplit.remainderChild().orderId(), remainderChildCode, pickedPackageCount, childPackageCount);
}
splits.normalizeLoadedCartonLabels(parent.tenantId(), parentInboundId, pickedPackageCount); splits.normalizeLoadedCartonLabels(parent.tenantId(), parentInboundId, pickedPackageCount);
} }
requireAffected(splits.insertBatchWithChildren(batchId, parent.tenantId(), rootInboundId, operationId, parentInboundId, requireAffected(splits.insertBatchWithChildren(batchId, parent.tenantId(), rootInboundId, operationId, parentInboundId,
@@ -408,17 +418,24 @@ public class PdaPickSettlementService {
return result; return result;
} }
/** 一件代发逐箱封托已经冻结 SKU 归属;测量批次只承担整票体积重量,不再冒充 SKU 明细。 */ /** 整托箱数守恒,测量行按箱数分摊重量体积;真实SKU组成仍由封托箱账本冻结。 */
private static List<MeasurementSplitLine> wholePalletMeasurementLines(List<MeasurementItemRow> measurements, private static List<MeasurementSplitLine> wholePalletMeasurementLines(List<MeasurementItemRow> measurements,
int pickedPackageCount, int pickedPackageCount,
int parentAvailable) { int parentAvailable) {
int measuredPackages = measurements.stream() int measuredPackages = measurements.stream()
.mapToInt(item -> count(item.packageCount(), "测量明细箱数")).sum(); .mapToInt(item -> count(item.packageCount(), "测量明细箱数")).sum();
if (pickedPackageCount != parentAvailable || measuredPackages != parentAvailable) { if (pickedPackageCount <= 0 || pickedPackageCount > parentAvailable || measuredPackages != parentAvailable) {
throw conflict("一件代发整托箱数与入库单、测量批次不守恒"); throw conflict("一件代发整托箱数与入库单、测量批次不守恒");
} }
return measurements.stream().map(item -> new MeasurementSplitLine(item, int left = pickedPackageCount;
count(item.packageCount(), "测量明细箱数"), 0)).toList(); List<MeasurementSplitLine> result = new ArrayList<>();
for (var item : measurements) {
int total = count(item.packageCount(), "测量明细箱数");
int picked = Math.min(total, left);
result.add(new MeasurementSplitLine(item, picked, total - picked));
left -= picked;
}
return List.copyOf(result);
} }
private static Map<Long, Integer> measurementQuantities(List<MeasurementSplitLine> lines) { private static Map<Long, Integer> measurementQuantities(List<MeasurementSplitLine> lines) {
@@ -72,7 +72,7 @@ public class SplitPreloadService {
@Transactional @Transactional
public void ensurePendingForLoadingAllocation(long tenantId, long inboundId, long operatorId, public void ensurePendingForLoadingAllocation(long tenantId, long inboundId, long operatorId,
String operatorName) { String operatorName) {
ensureLoadingAllocationLifecycle(tenantId, inboundId, operatorId, operatorName, false); ensureLoadingAllocationLifecycle(tenantId, inboundId, operatorId, operatorName);
} }
/** Creates a provisional preload lifecycle for an order selected before it reaches WAIT_SPLIT. */ /** Creates a provisional preload lifecycle for an order selected before it reaches WAIT_SPLIT. */
@@ -113,16 +113,17 @@ public class SplitPreloadService {
@Transactional @Transactional
public void ensureForRunningLoadingAllocation(long tenantId, long inboundId, long operatorId, public void ensureForRunningLoadingAllocation(long tenantId, long inboundId, long operatorId,
String operatorName) { String operatorName) {
ensureLoadingAllocationLifecycle(tenantId, inboundId, operatorId, operatorName, true); ensureLoadingAllocationLifecycle(tenantId, inboundId, operatorId, operatorName);
} }
/** 复用既有预装计划;同一入仓单其他未分配整托可继续进入不同柜次。 */
private void ensureLoadingAllocationLifecycle(long tenantId, long inboundId, long operatorId, private void ensureLoadingAllocationLifecycle(long tenantId, long inboundId, long operatorId,
String operatorName, boolean running) { String operatorName) {
InboundSnapshot inbound = requireInbound(tenantId, inboundId); InboundSnapshot inbound = requireInbound(tenantId, inboundId);
PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId); PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId);
boolean waitingSplit = isWaitingSplit(inbound.processStatus()); boolean waitingSplit = isWaitingSplit(inbound.processStatus());
boolean loading = "LOADING".equals(inbound.processStatus()); boolean loading = "LOADING".equals(inbound.processStatus());
if (!waitingSplit && !(running && loading)) { if (!waitingSplit && !loading) {
throw new BizException(ResultCode.CONFLICT, "仅待分柜订单可以建立智能装柜预装计划"); throw new BizException(ResultCode.CONFLICT, "仅待分柜订单可以建立智能装柜预装计划");
} }
if (loading && plan == null) { if (loading && plan == null) {
@@ -27,7 +27,9 @@ public class WorkflowTabService {
if(facts.stream().noneMatch(f->f.tenantId()==fee.tenantId() && f.orderId()==fee.orderId())) throw unavailable("费用确认事实不属于本批入仓来源"); if(facts.stream().noneMatch(f->f.tenantId()==fee.tenantId() && f.orderId()==fee.orderId())) throw unavailable("费用确认事实不属于本批入仓来源");
result.putIfAbsent(fee.orderId()+":"+fee.mainStatus(),fee); result.putIfAbsent(fee.orderId()+":"+fee.mainStatus(),fee);
} }
var sources=facts.stream().filter(WorkflowFacts.Fact::departed).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList(); // 出库单由尾程审核建单触发,不能因国内开船节点尚未补录而跳过事实查询。
var sources=facts.stream().map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList();
var requiredSources=facts.stream().filter(WorkflowFacts.Fact::departed).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList();
Map<String,WorkflowFacts.Overseas> overseas=new HashMap<>(); Map<String,WorkflowFacts.Overseas> overseas=new HashMap<>();
if(!sources.isEmpty()) { if(!sources.isEmpty()) {
try { try {
@@ -40,8 +42,10 @@ public class WorkflowTabService {
if(fact==null || !allowed.contains(new WorkflowFacts.Source(fact.tenantId(),fact.inboundId()))) throw unavailable("尾程事实包含空记录或本批以外的客户/入仓来源"); if(fact==null || !allowed.contains(new WorkflowFacts.Source(fact.tenantId(),fact.inboundId()))) throw unavailable("尾程事实包含空记录或本批以外的客户/入仓来源");
overseas.merge(fact.tenantId()+":"+fact.inboundId(),fact,(a,b)->new WorkflowFacts.Overseas(a.tenantId(),a.inboundId(),a.outboundExists()||b.outboundExists(),a.pendingOutbound()||b.pendingOutbound(),a.delivering()||b.delivering(),a.unloaded()&&b.unloaded())); overseas.merge(fact.tenantId()+":"+fact.inboundId(),fact,(a,b)->new WorkflowFacts.Overseas(a.tenantId(),a.inboundId(),a.outboundExists()||b.outboundExists(),a.pendingOutbound()||b.pendingOutbound(),a.delivering()||b.delivering(),a.unloaded()&&b.unloaded()));
} }
if(overseas.size()!=sources.size()) throw unavailable("尾程事实缺少来源 expected="+sources.size()+" actual="+overseas.size() // 未开船来源可能尚未产生尾程订单;已开船来源仍须完整返回,避免依赖缺项被误当作零。
+" missing="+sources.stream().filter(s->!overseas.containsKey(s.tenantId()+":"+s.inboundId())).limit(20).toList()); var missing=requiredSources.stream().filter(s->!overseas.containsKey(s.tenantId()+":"+s.inboundId())).toList();
if(!missing.isEmpty()) throw unavailable("尾程事实缺少来源 expected="+requiredSources.size()+" actual="+(requiredSources.size()-missing.size())
+" missing="+missing.stream().limit(20).toList());
} catch(BizException e) { throw e; } catch(Exception e) { } catch(BizException e) { throw e; } catch(Exception e) {
throw (BizException) new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试").initCause(e); throw (BizException) new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试").initCause(e);
} }
@@ -61,6 +65,11 @@ public class WorkflowTabService {
Set<String> keys=new LinkedHashSet<>(); Set<String> keys=new LinkedHashSet<>();
if("MEASURE_PENDING".equals(process) && "PENDING".equals(f.reviewStatus())) keys.add("MEASUREMENT_REVIEW"); if("MEASURE_PENDING".equals(process) && "PENDING".equals(f.reviewStatus())) keys.add("MEASUREMENT_REVIEW");
boolean signed="DELIVERED".equals(process); boolean signed="DELIVERED".equals(process);
// 有效出库与派送事实独立于船期、清关资料;签收后不再保留待出库或派送任务。
if(!signed && o!=null) {
if(o.pendingOutbound()) keys.add("OUTBOUND_DOCUMENT");
if(o.delivering()) keys.add("DELIVERING");
}
// 人工补录或船司查询的实际开船节点才推进阶段,报关完成和提前上传资料不能代替开船。 // 人工补录或船司查询的实际开船节点才推进阶段,报关完成和提前上传资料不能代替开船。
boolean clearanceStage=f.departed(); boolean clearanceStage=f.departed();
if(!clearanceStage && !signed) { if(!clearanceStage && !signed) {
@@ -75,8 +84,6 @@ public class WorkflowTabService {
if(!o.outboundExists() && !o.unloaded()) keys.add("WAIT_UNLOAD"); if(!o.outboundExists() && !o.unloaded()) keys.add("WAIT_UNLOAD");
// 取消的出库不构成有效覆盖,读取事实后自动回显,不能保存永久隐藏标志。 // 取消的出库不构成有效覆盖,读取事实后自动回显,不能保存永久隐藏标志。
if(!o.outboundExists()) keys.add("WAIT_DISPATCH"); if(!o.outboundExists()) keys.add("WAIT_DISPATCH");
if(o.pendingOutbound()) keys.add("OUTBOUND_DOCUMENT");
if(o.delivering()) keys.add("DELIVERING");
} }
} }
return keys; return keys;
@@ -171,13 +171,13 @@ paths:
/api/platform/v1/inbounds/{inboundId}/fees/confirm: /api/platform/v1/inbounds/{inboundId}/fees/confirm:
post: post:
summary: 确认最新费用快照 summary: 确认最新费用快照
description: 需要 firstleg:inbound:confirm-send-fee 或兼容权限 firstleg:fba-pricing:confirm。确认费用只更新费用状态;主流程已到 WAIT_SPLIT 时保持不变。 description: 需要 firstleg:inbound:confirm-send-fee 或兼容权限 firstleg:fba-pricing:confirm。测量审核通过后才可确认费用;未通过时拒绝且不冻结费用。确认只更新费用状态,不改变履约主状态。
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}] parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}} responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}}
/api/platform/v1/inbounds/{inboundId}/fees/send: /api/platform/v1/inbounds/{inboundId}/fees/send:
post: post:
summary: 发送已确认费用快照 summary: 发送已确认费用快照
description: 需要 firstleg:inbound:confirm-send-fee 或兼容权限 firstleg:inbound:send-fee。人工发布当前已确认版本;仅此动作将费用置为 SENT、生成 PUBLISHED 的 FBA_PRICING 只读派生明细并写入 Finance Outbox。入库单已到 WAIT_SPLIT 时不得回退或阻塞主流程。 description: 需要 firstleg:inbound:confirm-send-fee 或兼容权限 firstleg:inbound:send-fee,无需额外的测量查看权限。服务端校验测量审核通过后才可发布当前已确认版本;仅此动作将费用置为 SENT、生成 PUBLISHED 的 FBA_PRICING 只读派生明细并写入 Finance Outbox,不改变履约主状态。
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}] parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}} responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}}
/api/platform/v1/inbounds/{inboundId}/fees/latest: /api/platform/v1/inbounds/{inboundId}/fees/latest:
@@ -1679,7 +1679,7 @@ components:
type: string type: string
enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, WAIT_SPLIT, LOADING, LOADED, DELIVERED, WAIT_WEIGHT_CONFIRM, WEIGHT_CONFIRMED, WAIT_CUSTOMS_DOCS, EXPORT_CUSTOMS, SAILING_CLEARANCE, WAIT_UNLOAD, WAIT_DISPATCH, OUTBOUND_DOCUMENT, DELIVERING, SIGNED, MEASUREMENT_REVIEW] enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, WAIT_SPLIT, LOADING, LOADED, DELIVERED, WAIT_WEIGHT_CONFIRM, WEIGHT_CONFIRMED, WAIT_CUSTOMS_DOCS, EXPORT_CUSTOMS, SAILING_CLEARANCE, WAIT_UNLOAD, WAIT_DISPATCH, OUTBOUND_DOCUMENT, DELIVERING, SIGNED, MEASUREMENT_REVIEW]
nullable: true nullable: true
description: WAIT_SPLIT 为唯一待分柜筛选值;WAIT_CUSTOMS_DOCS 为实际装柜后缺少本票客户报关资料;EXPORT_CUSTOMS 按本票资料已上传且实际装柜完成汇总,买单无需客户退税资料,不依赖点击开始报关,报关完成或实际开船后移出;SAILING_CLEARANCE 在整柜报关完成后进入,也接受实际离港事实;清关资料保存成功后才进入 WAIT_UNLOAD/WAIT_DISPATCH。WAIT_UNLOAD 持续到实际卸柜或生成有效出库单;生成出库单后进入 OUTBOUND_DOCUMENT,实际出库后进入 DELIVERING,派送完成进入 SIGNED。 description: WAIT_SPLIT 为唯一待分柜筛选值;WAIT_CUSTOMS_DOCS 为实际装柜后缺少本票客户报关资料;EXPORT_CUSTOMS 按本票资料已上传且实际装柜完成汇总,买单无需客户退税资料,不依赖点击开始报关,报关完成或实际开船后移出;SAILING_CLEARANCE 在整柜报关完成后进入,也接受实际离港事实;清关资料保存成功后才进入 WAIT_UNLOAD/WAIT_DISPATCH。WAIT_UNLOAD 持续到实际卸柜或生成有效出库单;尾程排车理货审核通过并生成有效出库单后进入 OUTBOUND_DOCUMENT,不以前置开船节点或清关资料为条件;取消或实际出库后移出,实际出库并发车后进入 DELIVERING,派送完成进入 SIGNED。
customerId: {type: integer, format: int64, nullable: true} customerId: {type: integer, format: int64, nullable: true}
receivingWarehouseId: {type: integer, format: int64, nullable: true} receivingWarehouseId: {type: integer, format: int64, nullable: true}
destinationWarehouseId: {type: integer, format: int64, nullable: true} destinationWarehouseId: {type: integer, format: int64, nullable: true}
@@ -11,14 +11,15 @@ import static org.assertj.core.api.Assertions.assertThat;
class LoadingEvidenceSqlContractTest { class LoadingEvidenceSqlContractTest {
@Test @Test
void schemaOwnsEvidenceByCabinetWithOneActiveFactPerType() throws Exception { void schemaOwnsMultipleEvidencePhotosByCabinetAndKeepsOperationIdempotency() throws Exception {
String sql = new ClassPathResource("db/phase3-loading-delivery-schema.sql") String sql = new ClassPathResource("db/phase3-loading-delivery-schema.sql")
.getContentAsString(StandardCharsets.UTF_8); .getContentAsString(StandardCharsets.UTF_8);
assertThat(sql) assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS fl_loading_cabinet_evidence") .contains("CREATE TABLE IF NOT EXISTS fl_loading_cabinet_evidence")
.contains("container_booking_id BIGINT UNSIGNED NOT NULL") .contains("container_booking_id BIGINT UNSIGNED NOT NULL")
.contains("source_task_id BIGINT UNSIGNED NOT NULL") .contains("source_task_id BIGINT UNSIGNED NOT NULL")
.contains("UNIQUE KEY uk_cabinet_evidence_active (container_booking_id,evidence_type,active_flag)") .contains("KEY idx_cabinet_evidence_active (container_booking_id,evidence_type,active_flag)")
.doesNotContain("UNIQUE KEY uk_cabinet_evidence_active")
.contains("UNIQUE KEY uk_cabinet_evidence_operation (source_task_id,operation_key)") .contains("UNIQUE KEY uk_cabinet_evidence_operation (source_task_id,operation_key)")
.contains("DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci") .contains("DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci")
.contains("FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id)") .contains("FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id)")
@@ -31,7 +32,8 @@ class LoadingEvidenceSqlContractTest {
Path unified = Files.exists(direct) ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); Path unified = Files.exists(direct) ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
String sql = Files.readString(unified, StandardCharsets.UTF_8); String sql = Files.readString(unified, StandardCharsets.UTF_8);
assertThat(sql).contains("CREATE TABLE fl_loading_cabinet_evidence") assertThat(sql).contains("CREATE TABLE fl_loading_cabinet_evidence")
.contains("uk_cabinet_evidence_active") .contains("idx_cabinet_evidence_active")
.doesNotContain("uk_cabinet_evidence_active")
.contains("uk_cabinet_evidence_operation") .contains("uk_cabinet_evidence_operation")
.contains("FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking (id)") .contains("FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking (id)")
.doesNotContain("CREATE TABLE fl_loading_evidence") .doesNotContain("CREATE TABLE fl_loading_evidence")
@@ -28,6 +28,29 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("货柜数据库映射契约") @DisplayName("货柜数据库映射契约")
class ContainerBookingMapperContractTest { class ContainerBookingMapperContractTest {
/** 卸货不等于还柜;人工还柜及船司实际还柜都进入历史页签,两个集合互斥。 */
@Test void completedScopeUsesActualEmptyReturnInsteadOfUnloadingReport() throws Exception {
String page = String.join("\n", ContainerBookingMapper.class
.getDeclaredMethod("selectBookingPage", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class,
com.baiyue.firstleg.dto.ContainerDtos.Query.class, String.class)
.getAnnotation(Select.class).value());
int start = page.indexOf("SELECT 1 FROM fl_terminal_drayage returned");
String returned = page.substring(start, page.indexOf("))", start));
var data = new JdbcDataSource();
data.setURL("jdbc:h2:mem:return-scope-" + java.util.UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1");
var db = new JdbcTemplate(data);
db.execute("CREATE TABLE fl_container_booking(id BIGINT,actual_empty_reported_at TIMESTAMP)");
db.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,actual_empty_return_at TIMESTAMP)");
db.execute("CREATE TABLE fl_carrier_schedule_node_fact(container_booking_id BIGINT,standard_node VARCHAR(40),actual_at TIMESTAMP)");
db.execute("INSERT INTO fl_container_booking VALUES(1,CURRENT_TIMESTAMP),(2,NULL),(3,NULL),(4,NULL)");
db.execute("INSERT INTO fl_terminal_drayage VALUES(2,CURRENT_TIMESTAMP)");
db.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(3,'EMPTY_RETURNED',CURRENT_TIMESTAMP),(4,'EMPTY_RETURNED',NULL)");
assertThat(db.queryForList("SELECT b.id FROM fl_container_booking b WHERE EXISTS ("+returned+") ORDER BY b.id",Long.class))
.containsExactly(2L,3L);
assertThat(db.queryForList("SELECT b.id FROM fl_container_booking b WHERE NOT EXISTS ("+returned+") ORDER BY b.id",Long.class))
.containsExactly(1L,4L);
}
@Test @Test
@DisplayName("货柜映射平台级最终表且状态只从装柜计划读取") @DisplayName("货柜映射平台级最终表且状态只从装柜计划读取")
void mapsFinalBookingContract() throws Exception { void mapsFinalBookingContract() throws Exception {
@@ -11,7 +11,7 @@ import static org.assertj.core.api.Assertions.assertThat;
class ContainerWorkbenchSqlContractTest { class ContainerWorkbenchSqlContractTest {
@Test @Test
void pagesDomesticAndOverseasScopesFromOwnedActualFactsWithoutCopyingShipNodeSql() throws Exception { void pagesDomesticAndOverseasScopesFromDispatchAndEmptyReturnFacts() throws Exception {
String sql = String.join("\n", ContainerBookingMapper.class String sql = String.join("\n", ContainerBookingMapper.class
.getMethod("selectBookingPage", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, .getMethod("selectBookingPage", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class,
com.baiyue.firstleg.dto.ContainerDtos.Query.class, String.class) com.baiyue.firstleg.dto.ContainerDtos.Query.class, String.class)
@@ -19,9 +19,10 @@ class ContainerWorkbenchSqlContractTest {
assertThat(sql).contains("#{workbench}='DOMESTIC'", "#{workbench}='OVERSEAS'", assertThat(sql).contains("#{workbench}='DOMESTIC'", "#{workbench}='OVERSEAS'",
"#{query.scope}='ACTIVE'", "#{query.scope}='HISTORY'", "fl_outbound_dispatch", "#{query.scope}='ACTIVE'", "#{query.scope}='HISTORY'", "fl_outbound_dispatch",
"confirmed_at IS NOT NULL", "b.actual_empty_reported_at IS NULL", "confirmed_at IS NOT NULL", "fl_terminal_drayage returned",
"b.actual_empty_reported_at IS NOT NULL", "outbound_dispatch_status") "returned.actual_empty_return_at IS NOT NULL", "returned_node.standard_node='EMPTY_RETURNED'",
.doesNotContain("fl_carrier_schedule_query", "fl_carrier_schedule_node_fact"); "returned_node.actual_at IS NOT NULL", "outbound_dispatch_status")
.doesNotContain("fl_carrier_schedule_query", "b.actual_empty_reported_at");
} }
@Test @Test
@@ -0,0 +1,50 @@
package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/** 分托结算直接验证实际Mapper,箱序号不代表物理已装托盘。 */
class DropshipSplitRepositoryTest {
/** 已装箱位于序号末尾时,仍只迁移另一托的箱,跨客户同码不受影响。 */
@Test void transfersOnlyUnloadedPhysicalCartons() throws Exception {
var data = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
var db = new JdbcTemplate(data);
db.execute("CREATE TABLE fl_inbound_carton_label(id BIGINT,tenant_id BIGINT,inbound_id BIGINT,order_id BIGINT,inbound_code VARCHAR(50),batch_status VARCHAR(20),total_carton_count INT,detail_package_count INT,box_code VARCHAR(50))");
db.execute("CREATE TABLE fl_dropship_pallet_carton(id BIGINT,tenant_id BIGINT,inbound_id BIGINT,pallet_id BIGINT,carton_label_id BIGINT)");
db.update("INSERT INTO fl_inbound_carton_label VALUES(1,10,31,100,'IN-1','ACTIVE',4,4,'BOX-1'),(2,10,31,100,'IN-1','ACTIVE',4,4,'BOX-2'),(3,10,31,100,'IN-1','ACTIVE',4,4,'BOX-3'),(4,10,31,100,'IN-1','ACTIVE',4,4,'BOX-4'),(5,20,31,200,'IN-2','ACTIVE',1,1,'BOX-1')");
db.update("INSERT INTO fl_dropship_pallet_carton VALUES(11,10,31,802,1),(12,10,31,802,2),(13,10,31,801,3),(14,10,31,801,4),(15,20,31,802,5)");
var config = new MybatisConfiguration(); config.addMapper(DropshipPalletizationMapper.class);
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(data); factory.setConfiguration(config);
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(DropshipPalletizationMapper.class);
new DropshipSplitRepository(mapper).transferRemainder(10,31,32,101,"IN-1-1",2,List.of(801L));
assertThat(db.queryForList("SELECT id FROM fl_inbound_carton_label WHERE inbound_id=32 ORDER BY id",Long.class)).containsExactly(1L,2L);
assertThat(db.queryForList("SELECT id FROM fl_dropship_pallet_carton WHERE inbound_id=32 ORDER BY id",Long.class)).containsExactly(11L,12L);
assertThat(db.queryForObject("SELECT inbound_id FROM fl_inbound_carton_label WHERE id=5",Long.class)).isEqualTo(31L);
assertThat(db.queryForList("SELECT box_code FROM fl_inbound_carton_label WHERE inbound_id=32 ORDER BY id",String.class)).containsExactly("BOX-1","BOX-2");
// 下一柜重新读取封托内容时,货物身份必须归到余量子入库单和余量子订单。
db.execute("CREATE TABLE fl_dropship_pallet(id BIGINT,pallet_no VARCHAR(50),pallet_status VARCHAR(20))");
db.execute("INSERT INTO fl_dropship_pallet VALUES(802,'DP-802','SEALED')");
db.execute("ALTER TABLE fl_dropship_pallet_carton ADD inbound_detail_id BIGINT DEFAULT 41");
db.execute("ALTER TABLE fl_dropship_pallet_carton ADD sku_code VARCHAR(30) DEFAULT 'SKU-1'");
db.execute("ALTER TABLE fl_dropship_pallet_carton ADD cargo_name VARCHAR(30) DEFAULT '货物'");
db.execute("ALTER TABLE fl_dropship_pallet_carton ADD actual_sku_quantity INT DEFAULT 1");
db.execute("CREATE TABLE fl_dropship_pallet_carton_sku(carton_id BIGINT,inbound_detail_id BIGINT,sku_code VARCHAR(30),cargo_name VARCHAR(30),actual_quantity INT)");
db.execute("CREATE TABLE fl_inbound_order(id BIGINT,tenant_id BIGINT,order_id BIGINT,order_no VARCHAR(50),inbound_code VARCHAR(50),warehouse_id BIGINT,channel_code VARCHAR(30),channel_name VARCHAR(30),destination_warehouse_id BIGINT,destination_code VARCHAR(30),destination_name VARCHAR(30),customer_name VARCHAR(30),actual_gross_weight_kg DECIMAL,actual_net_weight_kg DECIMAL,actual_volume_cbm DECIMAL,total_package_count INT)");
db.execute("INSERT INTO fl_inbound_order(id,tenant_id,order_id,order_no,inbound_code,total_package_count) VALUES(32,10,101,'ORDER-1-1','IN-1-1',2)");
var remainder = mapper.sealedPalletLines(802);
assertThat(remainder).hasSize(1);
assertThat(remainder.get(0).getInboundId()).isEqualTo(32L);
assertThat(remainder.get(0).getOrderId()).isEqualTo(101L);
assertThat(remainder.get(0).getInboundDetailId()).isEqualTo(41L);
assertThat(remainder.get(0).getCartonCount()).isEqualTo(2);
}
}
@@ -37,7 +37,8 @@ class FbaPricingFeeTransitionTest {
session.getConnection(); session.getConnection();
sql = new JdbcTemplate(ds); sql = new JdbcTemplate(ds);
sql.execute("CREATE TABLE fl_inbound_order(id BIGINT PRIMARY KEY,tenant_id BIGINT," + sql.execute("CREATE TABLE fl_inbound_order(id BIGINT PRIMARY KEY,tenant_id BIGINT," +
"process_status VARCHAR(32),fee_status VARCHAR(32),deleted BOOLEAN DEFAULT FALSE)"); "process_status VARCHAR(32),fee_status VARCHAR(32),measure_review_status VARCHAR(32) DEFAULT 'APPROVED'," +
"chargeable_weight_kg DECIMAL(20,6),chargeable_volume_cbm DECIMAL(20,6),deleted BOOLEAN DEFAULT FALSE)");
repository = new FbaPricingRepository(); repository = new FbaPricingRepository();
ReflectionTestUtils.setField(repository, "inbounds", session.getMapper(InboundPricingMapper.class)); ReflectionTestUtils.setField(repository, "inbounds", session.getMapper(InboundPricingMapper.class));
} }
@@ -50,7 +51,8 @@ class FbaPricingFeeTransitionTest {
/** 费用确认和发送不得提前完成一件代发组托或回退已有履约进度。 */ /** 费用确认和发送不得提前完成一件代发组托或回退已有履约进度。 */
@ParameterizedTest @ParameterizedTest
@ValueSource(strings = {"FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"}) @ValueSource(strings = {"WAIT_PRICING",
"FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"})
void confirmsAndSendsWithoutAdvancingPalletization(String processStatus) { void confirmsAndSendsWithoutAdvancingPalletization(String processStatus) {
insertInbound(processStatus, "CALCULATED"); insertInbound(processStatus, "CALCULATED");
@@ -58,7 +60,7 @@ class FbaPricingFeeTransitionTest {
assertState(processStatus, "CONFIRMED"); assertState(processStatus, "CONFIRMED");
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero(); assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
assertThat(repository.markInboundFeeSent(10001L, 88L, processStatus)).isEqualTo(1); assertThat(repository.markInboundFeeSent(10001L, 88L, processStatus)).isEqualTo(1);
assertState("FEE_CONFIRMING".equals(processStatus) ? "WAIT_PALLET" : processStatus, "SENT"); assertState(processStatus, "SENT");
assertThat(repository.markInboundFeeSent(10001L, 88L, processStatus)).isZero(); assertThat(repository.markInboundFeeSent(10001L, 88L, processStatus)).isZero();
} }
@@ -71,12 +73,10 @@ class FbaPricingFeeTransitionTest {
assertState("WAIT_PALLETIZE", feeStatus); assertState("WAIT_PALLETIZE", feeStatus);
} }
/** 非确认阶段、其他客户和已删除入库单仍不可确认。 */ /** 其他客户和已删除入库单仍不可确认。 */
@Test @Test
void preservesProcessCustomerAndDeletionGuards() { void preservesProcessCustomerAndDeletionGuards() {
insertInbound("WAIT_MEASURE", "CALCULATED"); insertInbound("WAIT_PALLETIZE", "CALCULATED");
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
sql.update("UPDATE fl_inbound_order SET process_status='WAIT_PALLETIZE' WHERE id=88");
assertThat(repository.markInboundConfirmed(20002L, 88L)).isZero(); assertThat(repository.markInboundConfirmed(20002L, 88L)).isZero();
sql.update("UPDATE fl_inbound_order SET deleted=TRUE WHERE id=88"); sql.update("UPDATE fl_inbound_order SET deleted=TRUE WHERE id=88");
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero(); assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
@@ -91,6 +91,64 @@ class FbaPricingFeeTransitionTest {
assertState("WAIT_SPLIT", "CONFIRMED"); assertState("WAIT_SPLIT", "CONFIRMED");
} }
/** 提前试算保留测量进度,但审核前不能确认和冻结费用。 */
@ParameterizedTest
@ValueSource(strings = {"WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", "WAIT_SPLIT"})
void calculationPreservesMeasurementWorkflow(String processStatus) {
insertInbound(processStatus, "UNPRICED");
boolean approved = "WAIT_SPLIT".equals(processStatus);
sql.update("UPDATE fl_inbound_order SET measure_review_status=? WHERE id=88",
approved ? "APPROVED" : "PENDING");
assertThat(repository.markInboundCalculated(10001L, 88L, processStatus, "KG",
new java.math.BigDecimal("570.5"), new java.math.BigDecimal("3.6894"),
new java.math.BigDecimal("616.13"))).isEqualTo(1);
assertState(processStatus, "CALCULATED");
assertThat(repository.markInboundConfirmed(10001L, 88L)).isEqualTo(approved ? 1 : 0);
assertState(processStatus, approved ? "CONFIRMED" : "CALCULATED");
}
/** 即使主状态已经升级,确认也须原子校验实际测量审核结果。 */
@ParameterizedTest
@ValueSource(strings = {"NOT_SUBMITTED", "PENDING", "REJECTED"})
void rejectsConfirmationWhenMeasurementIsNotApproved(String reviewStatus) {
insertInbound("WAIT_SPLIT", "CALCULATED");
sql.update("UPDATE fl_inbound_order SET measure_review_status=? WHERE id=88", reviewStatus);
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
assertState("WAIT_SPLIT", "CALCULATED");
}
/** 提前试算被拒绝确认后,审核通过仍可按正常费用链重新试算、确认和发送。 */
@Test
void pendingCalculationCanBeRepricedAndPublishedAfterMeasurementApproval() {
insertInbound("MEASURE_PENDING", "UNPRICED");
sql.update("UPDATE fl_inbound_order SET measure_review_status='PENDING' WHERE id=88");
assertThat(repository.markInboundCalculated(10001L, 88L, "MEASURE_PENDING", "KG",
new java.math.BigDecimal("570.5"), new java.math.BigDecimal("3.6894"),
new java.math.BigDecimal("616.13"))).isEqualTo(1);
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
assertState("MEASURE_PENDING", "CALCULATED");
sql.update("UPDATE fl_inbound_order SET process_status='WAIT_SPLIT',measure_review_status='APPROVED' WHERE id=88");
assertThat(repository.markInboundCalculated(10001L, 88L, "WAIT_SPLIT", "KG",
new java.math.BigDecimal("580"), new java.math.BigDecimal("4"),
new java.math.BigDecimal("668"))).isEqualTo(1);
assertThat(repository.markInboundConfirmed(10001L, 88L)).isEqualTo(1);
assertThat(repository.markInboundFeeSent(10001L, 88L, "WAIT_SPLIT")).isEqualTo(1);
assertState("WAIT_SPLIT", "SENT");
assertThat(sql.queryForObject("SELECT chargeable_weight_kg FROM fl_inbound_order WHERE id=88",
java.math.BigDecimal.class)).isEqualByComparingTo("668");
}
/** 即使确认费用已完成,测量审核未通过也不能在持久化阶段发送。 */
@ParameterizedTest
@ValueSource(strings = {"NOT_SUBMITTED", "PENDING", "REJECTED"})
void rejectsSendingUnapprovedMeasurement(String reviewStatus) {
insertInbound("MEASURE_PENDING", "CONFIRMED");
sql.update("UPDATE fl_inbound_order SET measure_review_status=? WHERE id=88", reviewStatus);
assertThat(repository.markInboundFeeSent(10001L, 88L, "MEASURE_PENDING")).isZero();
assertState("MEASURE_PENDING", "CONFIRMED");
}
/** 准备单个客户的入库单,不调用共享环境。 */ /** 准备单个客户的入库单,不调用共享环境。 */
private void insertInbound(String processStatus, String feeStatus) { private void insertInbound(String processStatus, String feeStatus) {
sql.update("INSERT INTO fl_inbound_order(id,tenant_id,process_status,fee_status) VALUES(88,10001,?,?)", sql.update("INSERT INTO fl_inbound_order(id,tenant_id,process_status,fee_status) VALUES(88,10001,?,?)",
@@ -32,9 +32,6 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
@@ -178,7 +175,7 @@ class FbaPricingRepositoryRuleListTest {
} }
@Test @Test
void feeSendFromLegacyFlowAdvancesToWaitingPallet() { void feeSendPreservesFulfillmentAndRequiresApprovedMeasurement() {
when(pricing.update(org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.any())) when(pricing.update(org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.any()))
.thenReturn(1); .thenReturn(1);
@@ -188,25 +185,32 @@ class FbaPricingRepositoryRuleListTest {
ArgumentCaptor<LambdaUpdateWrapper<InboundPricingEntity>> wrapper = ArgumentCaptor<LambdaUpdateWrapper<InboundPricingEntity>> wrapper =
ArgumentCaptor.forClass(LambdaUpdateWrapper.class); ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
verify(pricing).update(org.mockito.ArgumentMatchers.isNull(), wrapper.capture()); verify(pricing).update(org.mockito.ArgumentMatchers.isNull(), wrapper.capture());
assertThat(wrapper.getValue().getSqlSet()).contains("feeStatus").contains("processStatus"); assertThat(wrapper.getValue().getSqlSet()).contains("feeStatus").doesNotContain("processStatus");
assertThat(wrapper.getValue().getCustomSqlSegment()).contains("tenantId", "id", "processStatus", "feeStatus"); assertThat(wrapper.getValue().getCustomSqlSegment())
.contains("tenantId", "id", "processStatus", "measureReviewStatus", "feeStatus");
assertThat(params(wrapper.getValue()).values()) assertThat(params(wrapper.getValue()).values())
.contains(10001L, 88L, "FEE_CONFIRMING", "CONFIRMED", "SENT", "WAIT_PALLET"); .contains(10001L, 88L, "FEE_CONFIRMING", "APPROVED", "CONFIRMED", "SENT")
.doesNotContain("WAIT_PALLET");
} }
/** 费用确认状态集覆盖待组托及后续履约阶段。 */ /** 费用确认状态集覆盖待组托及后续履约阶段。 */
@Test @Test
void feeConfirmationAllowsPalletizationAndPostLoadingInboundStatuses() { void feeConfirmationAllowsPalletizationAndPostLoadingInboundStatuses() {
String source; when(pricing.update(org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.any()))
try { .thenReturn(1);
source = Files.readString(Path.of(
"src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java"), assertThat(repository.markInboundConfirmed(10001L, 88L)).isEqualTo(1);
StandardCharsets.UTF_8);
} catch (java.io.IOException exception) { @SuppressWarnings("unchecked")
throw new AssertionError(exception); ArgumentCaptor<LambdaUpdateWrapper<InboundPricingEntity>> wrapper =
} ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
assertThat(source) verify(pricing).update(org.mockito.ArgumentMatchers.isNull(), wrapper.capture());
.contains("List.of(\"FEE_CONFIRMING\", \"WAIT_PALLET\", \"WAIT_PALLETIZE\", \"WAIT_SPLIT\", \"LOADING\", \"LOADED\", \"DELIVERED\")"); assertThat(wrapper.getValue().getSqlSet()).contains("feeStatus").doesNotContain("processStatus");
assertThat(wrapper.getValue().getCustomSqlSegment())
.contains("processStatus", "measureReviewStatus", "feeStatus");
assertThat(params(wrapper.getValue()).values())
.contains("WAIT_PRICING", "FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT",
"LOADING", "LOADED", "DELIVERED", "APPROVED", "CALCULATED", "CONFIRMED");
} }
@Test @Test
@@ -45,7 +45,8 @@ class Phase3FinalUnifiedSqlContractTest {
void unifiedEntryKeepsFinalConstraintsAndImmutableTriggers() throws Exception { void unifiedEntryKeepsFinalConstraintsAndImmutableTriggers() throws Exception {
String sql = sql(); String sql = sql();
assertThat(sql) assertThat(sql)
.contains("UNIQUE KEY uk_cabinet_evidence_active (container_booking_id,evidence_type,active_flag)") .contains("KEY idx_cabinet_evidence_active (container_booking_id,evidence_type,active_flag)")
.doesNotContain("UNIQUE KEY uk_cabinet_evidence_active")
.contains("UNIQUE KEY uk_cabinet_evidence_operation (source_task_id,operation_key)") .contains("UNIQUE KEY uk_cabinet_evidence_operation (source_task_id,operation_key)")
.contains("UNIQUE KEY uk_inbound_one_active_remainder") .contains("UNIQUE KEY uk_inbound_one_active_remainder")
.contains("UNIQUE KEY uk_inbound_split_source_once") .contains("UNIQUE KEY uk_inbound_split_source_once")
@@ -165,6 +165,7 @@ class FbaPricingServiceTest {
} }
@Test @Test
/** 试算生成费用但保留原履约进度。 */
@DisplayName("包税KG按生效价格表计费且不依赖客户规则或承运商") @DisplayName("包税KG按生效价格表计费且不依赖客户规则或承运商")
void calculatesTaxIncludedKgWithoutCustomerRuleOrCarrier() { void calculatesTaxIncludedKgWithoutCustomerRuleOrCarrier() {
readyRepository(); readyRepository();
@@ -186,7 +187,7 @@ class FbaPricingServiceTest {
assertThat(response.conversionFactor()).isEqualByComparingTo("167"); assertThat(response.conversionFactor()).isEqualByComparingTo("167");
assertThat(response.roundingScale()).isEqualTo(6); assertThat(response.roundingScale()).isEqualTo(6);
assertThat(response.roundingMode()).isEqualTo("HALF_UP"); assertThat(response.roundingMode()).isEqualTo("HALF_UP");
assertThat(response.nextProcessStatus()).isEqualTo("FEE_CONFIRMING"); assertThat(response.nextProcessStatus()).isEqualTo("WAIT_PRICING");
ArgumentCaptor<InboundFeeEntity> fee = ArgumentCaptor.forClass(InboundFeeEntity.class); ArgumentCaptor<InboundFeeEntity> fee = ArgumentCaptor.forClass(InboundFeeEntity.class);
verify(repository).insertFee(fee.capture()); verify(repository).insertFee(fee.capture());
assertThat(fee.getValue().getTaxMode()).isEqualTo("BUY_DECLARATION"); assertThat(fee.getValue().getTaxMode()).isEqualTo("BUY_DECLARATION");
@@ -630,7 +631,7 @@ class FbaPricingServiceTest {
/** 待组托发送费用保留主状态,重复请求不重复生成费用事件。 */ /** 待组托发送费用保留主状态,重复请求不重复生成费用事件。 */
@ParameterizedTest @ParameterizedTest
@CsvSource({"FEE_CONFIRMING,WAIT_PALLET", "WAIT_PALLETIZE,WAIT_PALLETIZE"}) @CsvSource({"FEE_CONFIRMING,FEE_CONFIRMING", "WAIT_PALLETIZE,WAIT_PALLETIZE"})
@DisplayName("重复发送只返回首次结果且不会重复写Outbox") @DisplayName("重复发送只返回首次结果且不会重复写Outbox")
void sendIsIdempotentAndDoesNotDuplicateOutbox(String processStatus, String expectedStatus) { void sendIsIdempotentAndDoesNotDuplicateOutbox(String processStatus, String expectedStatus) {
InboundPricingEntity inbound = inbound("10", "1"); InboundPricingEntity inbound = inbound("10", "1");
@@ -687,6 +688,39 @@ class FbaPricingServiceTest {
eq(TENANT), eq(INBOUND), eq(501L), eq(1), eq(NOW)); eq(TENANT), eq(INBOUND), eq(501L), eq(1), eq(NOW));
} }
/** 测量未审核前拒绝单独确认,不留下阻断审核后初算的已确认费用。 */
@ParameterizedTest
@ValueSource(strings = {"NOT_SUBMITTED", "PENDING", "REJECTED"})
void rejectsConfirmingBeforeMeasurementApproval(String reviewStatus) {
InboundPricingEntity inbound = inbound("570.500", "3.6894");
inbound.setProcessStatus("MEASURE_PENDING");
inbound.setMeasureReviewStatus(reviewStatus);
inbound.setFeeStatus("CALCULATED");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
assertThatThrownBy(() -> service.confirm(TENANT, OPERATOR, INBOUND, "confirm-pending"))
.isInstanceOf(BizException.class).hasMessageContaining("先完成测量审核");
verify(repository, never()).confirmFee(anyLong(), anyLong(), anyLong(), any());
verify(repository, never()).markInboundConfirmed(anyLong(), anyLong());
verify(repository, never()).insertIdempotency(any());
}
/** 测量未审核前拒绝发布,不能产生已发送快照或财务事件。 */
@ParameterizedTest
@ValueSource(strings = {"NOT_SUBMITTED", "PENDING", "REJECTED"})
void rejectsSendingBeforeMeasurementApproval(String reviewStatus) {
InboundPricingEntity inbound = inbound("570.500", "3.6894");
inbound.setProcessStatus("MEASURE_PENDING");
inbound.setMeasureReviewStatus(reviewStatus);
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
assertThatThrownBy(() -> service.send(TENANT, OPERATOR, INBOUND, "send-pending"))
.isInstanceOf(BizException.class).hasMessageContaining("先完成测量审核");
verify(repository, never()).sendFee(anyLong(), anyLong(), anyLong(), any());
verify(repository, never()).insertIdempotency(any());
verify(financeOutbox, never()).publishInbound(anyString(), anyLong(), anyLong(), anyLong(), anyInt(), any());
}
@Test @Test
@DisplayName("同一幂等键用于不同试算时点会冲突") @DisplayName("同一幂等键用于不同试算时点会冲突")
void rejectsReusedIdempotencyKeyWithDifferentRequest() { void rejectsReusedIdempotencyKeyWithDifferentRequest() {
@@ -101,6 +101,61 @@ class LoadingCompletedOutboxServiceTest {
assertThat(first.eventVersion()).isEqualTo(3); assertThat(first.eventVersion()).isEqualTo(3);
} }
/** 同类追加照片不阻断预检和发布,每类冻结最新有效照片并保持重试快照稳定。 */
@Test
void freezesLatestActivePhotoPerTypeDuringPreflightAndPublication() throws Exception {
Row value = row(11, 100, 101);
value.setPlannedPackageCount(1);
value.setPickedPackageCount(1);
when(snapshots.previewRows(90L)).thenReturn(List.of(value));
when(snapshots.rows(90L)).thenReturn(List.of(value));
CabinetLoadingEvidenceEntity voided = evidence("PHOTO_STEP_3", 7);
voided.setStatus("VOID");
CabinetLoadingEvidenceEntity inactive = evidence("PHOTO_STEP_3", 8);
inactive.setActiveFlag(null);
// 与 Mapper 的 evidence_type、id 升序一致,后到的作废或失效照片不能替代有效照片。
when(cabinetEvidence.byContainer(91L)).thenReturn(List.of(
evidence("PHOTO_STEP_1", 1), evidence("PHOTO_STEP_2", 2),
evidence("PHOTO_STEP_3", 3), evidence("PHOTO_STEP_3", 6), voided, inactive,
evidence("PHOTO_STEP_4", 4), evidence("SEAL", 5)));
service.requirePublishable(90L);
verify(outbox, org.mockito.Mockito.never()).insert(
org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class));
service.publish(90L);
service.publish(90L);
ArgumentCaptor<InboundOutboxEntity> captured = ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(outbox, org.mockito.Mockito.times(2)).insert(captured.capture());
EventEnvelope first = mapper.readValue(captured.getAllValues().get(0).getPayloadJson(), EventEnvelope.class);
EventEnvelope replay = mapper.readValue(captured.getAllValues().get(1).getPayloadJson(), EventEnvelope.class);
assertThat(first.payload().path("loadingEvidence")).hasSize(5);
assertThat(first.payload().at("/loadingEvidence/2/evidenceType").asText()).isEqualTo("PHOTO_STEP_3");
assertThat(first.payload().at("/loadingEvidence/2/fileId").asLong()).isEqualTo(6L);
assertThat(replay.payloadHash()).isEqualTo(first.payloadHash());
}
/** 多张同类照片不能替代缺失类别,作废照片仍不计入五类完整性。 */
@Test
void rejectsMissingPhotoTypeEvenWhenMoreThanFivePhotosRemain() {
Row value = row(11, 100, 101);
value.setPlannedPackageCount(1);
value.setPickedPackageCount(1);
when(snapshots.previewRows(90L)).thenReturn(List.of(value));
when(snapshots.rows(90L)).thenReturn(List.of(value));
CabinetLoadingEvidenceEntity voided = evidence("PHOTO_STEP_4", 4);
voided.setStatus("VOID");
when(cabinetEvidence.byContainer(91L)).thenReturn(List.of(
evidence("PHOTO_STEP_1", 1), evidence("PHOTO_STEP_2", 2),
evidence("PHOTO_STEP_3", 3), evidence("PHOTO_STEP_3", 6), evidence("PHOTO_STEP_3", 7),
voided, evidence("SEAL", 5)));
assertThatThrownBy(() -> service.requirePublishable(90L)).hasMessageContaining("五类照片不完整");
assertThatThrownBy(() -> service.publish(90L)).hasMessageContaining("五类照片不完整");
verify(outbox, org.mockito.Mockito.never()).insert(
org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class));
}
/** 非 ISO 柜号能生成当前 V3 事件,并保持冻结柜号原值。 */ /** 非 ISO 柜号能生成当前 V3 事件,并保持冻结柜号原值。 */
@org.junit.jupiter.params.ParameterizedTest @org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"JZXH10231043", "MSCU6639871", "ABC", "LOCAL-2026-001"}) @org.junit.jupiter.params.provider.ValueSource(strings = {"JZXH10231043", "MSCU6639871", "ABC", "LOCAL-2026-001"})
@@ -46,6 +46,7 @@ class PdaPickSettlementServiceTest {
@MockBean private OrderFulfillmentSplitClient orderSplits; @MockBean private OrderFulfillmentSplitClient orderSplits;
@MockBean private InboundHoldGuard inboundHoldGuard; @MockBean private InboundHoldGuard inboundHoldGuard;
@MockBean private DropshipPalletizationMapper dropshipPallets; @MockBean private DropshipPalletizationMapper dropshipPallets;
@MockBean private com.baiyue.firstleg.repository.DropshipSplitRepository dropshipSplits;
@Resource private PdaPickSettlementService service; @Resource private PdaPickSettlementService service;
@Test @Test
@@ -266,6 +267,29 @@ class PdaPickSettlementServiceTest {
&& request.lines().size() == 1 && request.lines().get(0).packageCount() == 20)); && request.lines().size() == 1 && request.lines().get(0).packageCount() == 20));
} }
@Test
void settlesOneSealedPalletAndMovesOnlyUnloadedPalletsToRemainder() {
var picked = item(11L, 21L, 31L, 41L, 2, 2, "PICKED");
picked.setUnitType("SEALED_PALLET"); picked.setPalletId(801L); picked.setPalletNo("DP-1");
resolved(List.of(picked));
var physical = new DropshipPalletizationMapper.PlanPalletMemberRow();
physical.setPalletId(801L); physical.setInboundId(31L); physical.setAccountId(10001L);
physical.setAvailablePackageCount(2);
when(dropshipPallets.palletInboundMembersByPalletIds(any())).thenReturn(List.of(physical));
when(splits.lockParent(31L)).thenReturn(parent(4));
when(splits.fulfillmentType(10001L, 31L)).thenReturn("ONE_CLICK_DROPSHIP");
when(splits.lockLatestApprovedItemsForInbound(10001L, 31L))
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 4)));
when(allocations.lockConfirmed(10001L, 31L)).thenReturn(List.of(allocation(1L, 1, 501L, 4)));
writes(2, 2);
service.settle(1L, 9L, "operator");
verify(dropshipSplits).transferRemainder(eq(10001L), eq(31L), anyLong(), eq(1002L),
eq("IN-001-1"), eq(2), eq(List.of(801L)));
verify(splits, never()).transferRemainingCartonLabels(anyLong(), anyLong(), anyLong(), anyLong(), anyString(), anyInt(), anyInt());
verify(splits).updateMeasurementItemForLoaded(10001L, 51L, 2);
verify(splits).markSourceLoaded(10001L, 31L, 2, 9L, 3);
}
@Test @Test
void onlyPureSkippedInboundRestoresCandidate() { void onlyPureSkippedInboundRestoresCandidate() {
LoadingPickItemEntity item = item(12L, 22L, 32L, 42L, 8, 0, "SKIPPED"); LoadingPickItemEntity item = item(12L, 22L, 32L, 42L, 8, 0, "SKIPPED");
@@ -148,14 +148,13 @@ class SplitPreloadServiceTest {
} }
@Test @Test
void ordinaryLoadingAllocationStillRequiresWaitingSplit() { void otherCabinetMayReuseExistingPreloadWhileSourceIsLoading() {
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 3)); when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 3));
PreloadPlanEntity plan = new PreloadPlanEntity(); PreloadPlanEntity plan = new PreloadPlanEntity();
plan.setStatus("PENDING"); plan.setStatus("PENDING");
when(repository.findPlan(10, 20)).thenReturn(plan); when(repository.findPlan(10, 20)).thenReturn(plan);
assertThatThrownBy(() -> service.ensurePendingForLoadingAllocation(10, 20, 7, "admin")) service.ensurePendingForLoadingAllocation(10, 20, 7, "admin");
.hasMessageContaining("仅待分柜订单");
verify(repository, never()).createPlan(any()); verify(repository, never()).createPlan(any());
} }
@@ -36,7 +36,7 @@ class WorkflowBoundaryTest {
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class).hasCause(failure); assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class).hasCause(failure);
} }
/** 报关完成或提前上传资料仍留在出口报关,实际开船后才查询精确海外来源。 */ /** 未开船也查询尾程建单事实,未产生尾程来源时仍保留国内阶段。 */
@Test void completedCustomsAndClearanceUploadWaitForActualDeparture() { @Test void completedCustomsAndClearanceUploadWaitForActualDeparture() {
var repository=mock(WorkflowFactRepository.class); var repository=mock(WorkflowFactRepository.class);
var downstream=mock(LastlegWorkflowClient.class); var downstream=mock(LastlegWorkflowClient.class);
@@ -45,12 +45,15 @@ class WorkflowBoundaryTest {
inbound.setOrderId(101L);inbound.setProcessStatus("LOADED"); inbound.setOrderId(101L);inbound.setProcessStatus("LOADED");
when(repository.facts(List.of(1L))).thenReturn(List.of( when(repository.facts(List.of(1L))).thenReturn(List.of(
new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true,true))); new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true,true)));
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of()));
assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("EXPORT_CUSTOMS"); assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("EXPORT_CUSTOMS");
verifyNoInteractions(downstream); verify(downstream).read(any(),any());
clearInvocations(downstream);
when(repository.facts(List.of(1L))).thenReturn(List.of( when(repository.facts(List.of(1L))).thenReturn(List.of(
new WorkflowFacts.Fact(11,1,101,"APPROVED",false,true,false,true,true))); new WorkflowFacts.Fact(11,1,101,"APPROVED",false,true,false,true,true)));
assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("EXPORT_CUSTOMS"); assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("EXPORT_CUSTOMS");
verifyNoInteractions(downstream); verify(downstream).read(any(),any());
clearInvocations(downstream);
when(repository.facts(List.of(1L))).thenReturn(List.of( when(repository.facts(List.of(1L))).thenReturn(List.of(
new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,true,true))); new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,true,true)));
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of( when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of(
@@ -64,4 +67,18 @@ class WorkflowBoundaryTest {
verify(downstream).read(any(),eq(new WorkflowFacts.Request(List.of(new WorkflowFacts.Source(11,1))))); verify(downstream).read(any(),eq(new WorkflowFacts.Request(List.of(new WorkflowFacts.Source(11,1)))));
} }
/** 理货审核建单后,未补录开船与清关资料也必须返回出库待办。 */
@Test void outboundBeforeDepartureIsReadAndClassified() {
var repository=mock(WorkflowFactRepository.class);
var downstream=mock(LastlegWorkflowClient.class);
var service=new WorkflowTabService(repository,downstream);
var inbound=new InboundOrderEntity();inbound.setId(1L);inbound.setTenantId(11L);
inbound.setOrderId(101L);inbound.setProcessStatus("LOADED");
when(repository.facts(List.of(1L))).thenReturn(List.of(
new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,false,false)));
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of(
new WorkflowFacts.Overseas(11,1,true,true,false,true))));
assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("OUTBOUND_DOCUMENT");
verify(downstream).read(any(),eq(new WorkflowFacts.Request(List.of(new WorkflowFacts.Source(11,1)))));
}
} }
@@ -30,12 +30,12 @@ class WorkflowBusinessTest {
assertThat(WorkflowTabService.classify(uploaded,"DELIVERED",unloaded)) assertThat(WorkflowTabService.classify(uploaded,"DELIVERED",unloaded))
.contains("SIGNED").doesNotContain("EXPORT_CUSTOMS","WAIT_CUSTOMS_DOCS"); .contains("SIGNED").doesNotContain("EXPORT_CUSTOMS","WAIT_CUSTOMS_DOCS");
} }
/** 清关前不能被海外建单事实提前推进,清关后出库与派送依次替换待办。 */ /** 出库与派送按尾程事实展示,不受清关资料限制。 */
@Test void downstreamFactsCannotBypassClearanceAndOutboundReplacesQueues() { @Test void downstreamFactsCannotBypassClearanceAndOutboundReplacesQueues() {
var pending=new WorkflowFacts.Overseas(11,1,true,true,false,false); var pending=new WorkflowFacts.Overseas(11,1,true,true,false,false);
var shipped=new WorkflowFacts.Overseas(11,1,true,false,true,false); var shipped=new WorkflowFacts.Overseas(11,1,true,false,true,false);
assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",pending)).containsExactly("SAILING_CLEARANCE"); assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",pending)).containsExactly("OUTBOUND_DOCUMENT","SAILING_CLEARANCE");
assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",shipped)).containsExactly("SAILING_CLEARANCE"); assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",shipped)).containsExactly("DELIVERING","SAILING_CLEARANCE");
assertThat(WorkflowTabService.classify(fact(1,true),"LOADED",pending)).containsExactly("OUTBOUND_DOCUMENT"); assertThat(WorkflowTabService.classify(fact(1,true),"LOADED",pending)).containsExactly("OUTBOUND_DOCUMENT");
assertThat(WorkflowTabService.classify(fact(1,true),"LOADED",shipped)).containsExactly("DELIVERING"); assertThat(WorkflowTabService.classify(fact(1,true),"LOADED",shipped)).containsExactly("DELIVERING");
assertThat(WorkflowTabService.classify(fact(1,true),"DELIVERED",shipped)).containsExactly("SIGNED"); assertThat(WorkflowTabService.classify(fact(1,true),"DELIVERED",shipped)).containsExactly("SIGNED");
@@ -7,9 +7,12 @@ import com.baiyue.integration.dto.CarrierScheduleDtos.ExceptionObservation;
import com.baiyue.integration.provider.CarrierScheduleProviderAdapter; import com.baiyue.integration.provider.CarrierScheduleProviderAdapter;
import com.baiyue.integration.provider.CompliantCarrierScheduleProviderAdapter; import com.baiyue.integration.provider.CompliantCarrierScheduleProviderAdapter;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Locale;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -25,21 +28,22 @@ class CarrierScheduleProviderServiceTest {
assertThat(result.exceptions()).isEmpty(); assertThat(result.exceptions()).isEmpty();
} }
@Test @ParameterizedTest
void onlyDispatchesNormalizedContainerNumberTracking() { @ValueSource(strings = {"ZCSU7881970", "ZCSU7881971", "LOCAL-2026-001"})
void onlyDispatchesNormalizedContainerNumberTracking(String containerNo) {
CarrierScheduleProviderAdapter capturing = new CarrierScheduleProviderAdapter() { CarrierScheduleProviderAdapter capturing = new CarrierScheduleProviderAdapter() {
@Override public boolean supports(String carrierCode) { return true; } @Override public boolean supports(String carrierCode) { return true; }
@Override public QueryResult query(QueryRequest request) { @Override public QueryResult query(QueryRequest request) {
assertThat(request.trackingType()).isEqualTo("CONTAINER_NO"); assertThat(request.trackingType()).isEqualTo("CONTAINER_NO");
assertThat(request.trackingNo()).isEqualTo("ZCSU7881970"); assertThat(request.trackingNo()).isEqualTo(containerNo);
return new QueryResult("TEST", "NO_CHANGE", List.of(), List.of(), null, null, LocalDateTime.now()); return new QueryResult("TEST", "NO_CHANGE", List.of(), List.of(), null, null, LocalDateTime.now());
} }
}; };
var service = new CarrierScheduleProviderService(List.of(capturing)); var service = new CarrierScheduleProviderService(List.of(capturing));
service.query(new QueryRequest("ZIM", "CONTAINER_NO", "ZCSU7881970")); service.query(new QueryRequest("ZIM", "CONTAINER_NO", containerNo));
assertThatThrownBy(() -> service.query(new QueryRequest("ZIM", "CONTAINER_NO", "zcsu7881970"))) assertThatThrownBy(() -> service.query(new QueryRequest("ZIM", "CONTAINER_NO", containerNo.toLowerCase(Locale.ROOT))))
.hasMessageContaining("规范化真实集装箱号"); .hasMessageContaining("规范化真实集装箱号");
assertThatThrownBy(() -> service.query(new QueryRequest("ZIM", "CONTAINER_NO", "ZCSU7881971"))) assertThatThrownBy(() -> service.query(new QueryRequest("ZIM", "CONTAINER_NO", " " + containerNo + " ")))
.hasMessageContaining("规范化真实集装箱号"); .hasMessageContaining("规范化真实集装箱号");
assertThatThrownBy(() -> service.query(new QueryRequest("ZIM", "BILL_OF_LADING", "BL-1"))) assertThatThrownBy(() -> service.query(new QueryRequest("ZIM", "BILL_OF_LADING", "BL-1")))
.hasMessageContaining("CONTAINER_NO"); .hasMessageContaining("CONTAINER_NO");
+1
View File
@@ -37,6 +37,7 @@
| FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` | | FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` |
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`;普通 FBA/OVERSEAS 分别使用 bulk/carton 收货,一件代发只允许 `/current/receipts/pallet` 按国内冻结来源托盘整托收货。普通 OVERSEAS 允许在本票已预留托盘间按现场实际分配箱数,逐托建议箱数不作为收货上限,仍校验本票总箱数、单托66箱、目的仓及客户归属;库存按已扫箱码的SKU和真实托盘归属入账。合托只作现场可选提示;全部计划完整绑定真实 pallet/location/lock 前不得查看、准备或签收该柜,托盘校验和提交均只确认本票已预留绑定。Admin 入库单详情可按行打印已预留真实托盘标签,复用海外托盘列表权限和标签PDF接口,不生成第二个托盘码 | | PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`;普通 FBA/OVERSEAS 分别使用 bulk/carton 收货,一件代发只允许 `/current/receipts/pallet` 按国内冻结来源托盘整托收货。普通 OVERSEAS 允许在本票已预留托盘间按现场实际分配箱数,逐托建议箱数不作为收货上限,仍校验本票总箱数、单托66箱、目的仓及客户归属;库存按已扫箱码的SKU和真实托盘归属入账。合托只作现场可选提示;全部计划完整绑定真实 pallet/location/lock 前不得查看、准备或签收该柜,托盘校验和提交均只确认本票已预留绑定。Admin 入库单详情可按行打印已预留真实托盘标签,复用海外托盘列表权限和标签PDF接口,不生成第二个托盘码 |
| 普通散货收货短少确认 | `inboundplan/controller/InboundPlanController.java` 的 `/{planNo}/confirm-shortage` → `InboundPdaWorkflowService.confirmShortage`;仅平台入库编辑权限指定客户操作,要求待差异核对、原版本及应收/实收快照、原因和幂等键。FBA仅同柜次同目的仓收货组单票且冻结申报单SKU可按总数确认,多SKU及跨票自动分摊拒绝;普通OVERSEAS按已扫箱码精确核对SKU与逐托实收,可处理多SKU短少,一件代发整托货物拒绝此操作。逐SKU实收不得超原申报,确认后保留应收与现场结束时间,按实收生成库存和原计租批次,OVERSEAS已收箱转AVAILABLE、未收箱保留原状态,计划/PDA任务进入 `PARTIAL_INBOUND`,尾程订单完成入库;同事务交接原预排,明确零实收的托盘同步移除预排明细、释放本车占用及预约分配并重算整车货量;缺少零实收证据或已有拣货拒绝。不重复发布卸柜事件。无实收、超收或逐SKU事实不一致拒绝;空预留托盘不能再次作为计划货源排车。 | | 普通散货收货短少确认 | `inboundplan/controller/InboundPlanController.java` 的 `/{planNo}/confirm-shortage` → `InboundPdaWorkflowService.confirmShortage`;仅平台入库编辑权限指定客户操作,要求待差异核对、原版本及应收/实收快照、原因和幂等键。FBA仅同柜次同目的仓收货组单票且冻结申报单SKU可按总数确认,多SKU及跨票自动分摊拒绝;普通OVERSEAS按已扫箱码精确核对SKU与逐托实收,可处理多SKU短少,一件代发整托货物拒绝此操作。逐SKU实收不得超原申报,确认后保留应收与现场结束时间,按实收生成库存和原计租批次,OVERSEAS已收箱转AVAILABLE、未收箱保留原状态,计划/PDA任务进入 `PARTIAL_INBOUND`,尾程订单完成入库;同事务交接原预排,明确零实收的托盘同步移除预排明细、释放本车占用及预约分配并重算整车货量;缺少零实收证据或已有拣货拒绝。不重复发布卸柜事件。无实收、超收或逐SKU事实不一致拒绝;空预留托盘不能再次作为计划货源排车。 |
| 收货异常统一核对 | Admin 主入口为 `/{planNo}/receiving-reconciliation` GET/POST → `InboundReceivingReconciliationService`,契约 `inbound-reconciliation-v1.yaml`;按来源柜次、柜号和实际收货仓核对整柜,独立展示实盘与已归属数量。普通FBA支持明确逐托逐票SKU确认短少、超收、零实收和误录更正,每托守恒,原申报和现场时间保留;完成票据及普通地址逐箱身份只读,一件代发独立分组。旧生产 `confirm-shortage` 保留原语义。确认采用当前事实摘要、原因和幂等键,库存、实际体积、托盘、原预排和原始/确认后审计同事务提交,逐SKU短少不能被其他SKU超收抵消。 |
| PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` | | PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` |
| PDA 国内装柜照片对照 | `ReceivingContainerController.java` 的 `/current/domestic-loading-photos/{evidenceType}/content`、`ContainerLoadingEvidenceMapper.java`、`DomesticLoadingEvidenceGateway.java`;元数据按物理柜投影,不保存客户,下载必须经当前 PDA 会话鉴权并复用 `FILE_SERVICE_URL` 只读访问 File 服务,单图不可用不改变拆柜状态 | | PDA 国内装柜照片对照 | `ReceivingContainerController.java` 的 `/current/domestic-loading-photos/{evidenceType}/content`、`ContainerLoadingEvidenceMapper.java`、`DomesticLoadingEvidenceGateway.java`;元数据按物理柜投影,不保存客户,下载必须经当前 PDA 会话鉴权并复用 `FILE_SERVICE_URL` 只读访问 File 服务,单图不可用不改变拆柜状态 |
| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` | | 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` |
@@ -11,8 +11,8 @@ public interface DispatchWholePdaReadMapper {
record Picked(Long itemId,long packages) { } record Picked(Long itemId,long packages) { }
record Invalid(Long dispatchId,long quantity) { } record Invalid(Long dispatchId,long quantity) { }
record Carton(Long itemId,Long cartonId,String cartonMark,String status,long productQuantity) { } record Carton(Long itemId,Long cartonId,String cartonMark,String status,long productQuantity) { }
/** 精确单号或有界阶段列表均先在SQL限制绑定仓。 */ /** 绑定仓仅下发仓内作业;本土卡车与到仓取货排车继续在后台执行。 */
@Select("<script>SELECT * FROM ll_dispatch_order WHERE overseas_warehouse_id=#{warehouse} AND status='ACTIVE' " @Select("<script>SELECT * FROM ll_dispatch_order WHERE overseas_warehouse_id=#{warehouse} AND status='ACTIVE' AND COALESCE(task_type,'DELIVERY') NOT IN ('LOCAL_TRUCK','PICKUP') "
+"<choose><when test='number != null'>AND dispatch_no=#{number} </when><when test='stage != null'>AND stage=#{stage} </when>" +"<choose><when test='number != null'>AND dispatch_no=#{number} </when><when test='stage != null'>AND stage=#{stage} </when>"
+"<otherwise>AND (stage IN('LOADING','AFTER_LOADING','DELIVERING') OR (stage='BEFORE_LOADING' AND review_state='LOADING_PENDING')) </otherwise></choose>ORDER BY updated_at DESC,id DESC LIMIT 201</script>") +"<otherwise>AND (stage IN('LOADING','AFTER_LOADING','DELIVERING') OR (stage='BEFORE_LOADING' AND review_state='LOADING_PENDING')) </otherwise></choose>ORDER BY updated_at DESC,id DESC LIMIT 201</script>")
List<DispatchAggregate> headers(@Param("warehouse")String warehouse,@Param("number")String number,@Param("stage")String stage); List<DispatchAggregate> headers(@Param("warehouse")String warehouse,@Param("number")String number,@Param("stage")String stage);
@@ -54,10 +54,11 @@ public final class DispatchViews {
List<DispatchDeliveryModels.DeliveryReference> deliveries, List<DispatchDeliveryModels.DeliveryReference> deliveries,
Instant createdAt, Instant updatedAt, String remark) { } Instant createdAt, Instant updatedAt, String remark) { }
/** 散板按客户和商业订单汇总;来源键集合仅供只读明细关联,不替代作业来源身份。 */
public record OrderDetail(String customerId, String customerName, String orderNo, String inboundCode, public record OrderDetail(String customerId, String customerName, String orderNo, String inboundCode,
BigDecimal grossWeightKg, Integer palletCount, String destinationFbaWarehouse, BigDecimal grossWeightKg, Integer palletCount, String destinationFbaWarehouse,
BigDecimal volumeCbm, String containerNo, String sourceKey, BigDecimal volumeCbm, String containerNo, String sourceKey,
String sourceBusinessType, String sourceBusinessNo) { } String sourceBusinessType, String sourceBusinessNo, List<String> sourceKeys) { }
/** 页头元数据和四个内容对象固定,附加能力不扩展为新页签。 */ /** 页头元数据和四个内容对象固定,附加能力不扩展为新页签。 */
public record Detail(String dispatchNo, String taskType, Status status, Stage stage, long recordVersion, public record Detail(String dispatchNo, String taskType, Status status, Stage stage, long recordVersion,
@@ -6,11 +6,12 @@ import java.util.List;
/** 客户照片仅查询已核准客户与原单的有效承运关联。 */ /** 客户照片仅查询已核准客户与原单的有效承运关联。 */
@Repository @Repository
public class MerchantTransportPhotoRepository { public class MerchantTransportPhotoRepository implements com.baiyue.lastleg.looseboard.port.LooseboardDispatches {
private final MerchantTransportPhotoMapper mapper; private final MerchantTransportPhotoMapper mapper;
/** 注入本域查询入口。 */ /** 注入本域查询入口。 */
public MerchantTransportPhotoRepository(MerchantTransportPhotoMapper mapper){this.mapper=mapper;} public MerchantTransportPhotoRepository(MerchantTransportPhotoMapper mapper){this.mapper=mapper;}
/** 返回原散板单实际关联的排车身份。 */ /** 返回原散板单实际关联的排车身份。 */
@Override
public List<String> looseboardDispatches(String customer,String number){return mapper.looseboardDispatches(customer,number);} public List<String> looseboardDispatches(String customer,String number){return mapper.looseboardDispatches(customer,number);}
/** 返回原卡车单有效运输任务。 */ /** 返回原卡车单有效运输任务。 */
public List<String> truckDispatches(String customer,String number){return mapper.truckDispatches(customer,number);} public List<String> truckDispatches(String customer,String number){return mapper.truckDispatches(customer,number);}
@@ -962,8 +962,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
return batches.stream().collect(Collectors.toMap( return batches.stream().collect(Collectors.toMap(
b -> List.of(b.batchKey(),b.customerId(),b.destinationKey()),b->b,(a,b)->a,LinkedHashMap::new)).values().stream().map(b -> { b -> List.of(b.batchKey(),b.customerId(),b.destinationKey()),b->b,(a,b)->a,LinkedHashMap::new)).values().stream().map(b -> {
var confirmed=confirmedAppointments.stream().filter(w->w.batchKey().equals(b.batchKey()) && w.customerId().equals(b.customerId()) && w.destinationKey().equals(b.destinationKey())).findFirst().orElse(null); var confirmed=confirmedAppointments.stream().filter(w->w.batchKey().equals(b.batchKey()) && w.customerId().equals(b.customerId()) && w.destinationKey().equals(b.destinationKey())).findFirst().orElse(null);
return new DispatchViews.FbaAppointment(b.batchKey(),b.batchNo(),confirmed==null?null:confirmed.confirmationNo(),b.destinationKey(),b.warehouseCode(), return DispatchDestinationAppointments.booked(b,confirmed);
confirmed==null?"PENDING":"SUCCESS",confirmed==null?null:confirmed.startAt(),confirmed==null?"尚无当前有效收货确认":null,b.customerId(),List.of());
}).toList(); }).toList();
} }
@@ -1230,9 +1229,9 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
reviews(row),read(row.getResourcesJson(),Resources.class),row.getOverseasWarehouseId(),row.getOverseasWarehouseCode(),row.getOverseasWarehouseName(),instant(row.getCreatedAt()),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),counts(row),reconciled(row),row.getArrangementKey()!=null,actions(actor,row),reviewAppointments(row)); reviews(row),read(row.getResourcesJson(),Resources.class),row.getOverseasWarehouseId(),row.getOverseasWarehouseCode(),row.getOverseasWarehouseName(),instant(row.getCreatedAt()),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),counts(row),reconciled(row),row.getArrangementKey()!=null,actions(actor,row),reviewAppointments(row));
} }
/** 混托多订单无法真实分摊重量体积时保持未知,不能给每票复制整托总量。 */ /** 散板同客户同商业订单只展示一行,逐托来源仍保留用于原作业和运营明细关联。 */
private List<DispatchViews.OrderDetail> orderDetails(List<DispatchItem> cargo) { private List<DispatchViews.OrderDetail> orderDetails(List<DispatchItem> cargo) {
var result=new ArrayList<DispatchViews.OrderDetail>(); var groups=new LinkedHashMap<List<String>,List<OrderDisplayContribution>>();
var businessSources=looseboardDisplay.sources(cargo.stream().map(i->new com.baiyue.lastleg.dispatch.mapper.LooseboardCargoMapper.Reference(i.getCustomerId(),i.getOrderNo(),i.getPlanPalletId())).toList()); var businessSources=looseboardDisplay.sources(cargo.stream().map(i->new com.baiyue.lastleg.dispatch.mapper.LooseboardCargoMapper.Reference(i.getCustomerId(),i.getOrderNo(),i.getPlanPalletId())).toList());
for(var item:cargo) { for(var item:cargo) {
var source=read(item.getSourceSnapshotJson(),SourceDisplay.class); var source=read(item.getSourceSnapshotJson(),SourceDisplay.class);
@@ -1240,11 +1239,46 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
if(orders.isEmpty()) orders=Collections.singletonList(null); if(orders.isEmpty()) orders=Collections.singletonList(null);
for(var order:orders) { for(var order:orders) {
var business=looseboardDisplay.find(businessSources,item.getCustomerId(),order,item.getPlanPalletId()); var business=looseboardDisplay.find(businessSources,item.getCustomerId(),order,item.getPlanPalletId());
result.add(new DispatchViews.OrderDetail(item.getCustomerId(),item.getCustomerName(),business==null?order:business.orderNo(),business==null?item.getInboundCode():null,orders.size()==1?item.getGrossWeightKg():null, var detail=new DispatchViews.OrderDetail(item.getCustomerId(),item.getCustomerName(),business==null?order:business.orderNo(),business==null?item.getInboundCode():null,orders.size()==1?item.getGrossWeightKg():null,
orders.size()==1?1:null,item.getFbaWarehouseCode(),orders.size()==1?item.getVolumeCbm():null,business==null?source.containerNo():null,item.getSourceKey(),business==null?item.getSourceBusinessType():"LOCAL_LOOSEBOARD",business==null?item.getSourceBusinessNo():business.orderNo())); orders.size()==1?1:null,item.getFbaWarehouseCode(),orders.size()==1?item.getVolumeCbm():null,business==null?source.containerNo():null,item.getSourceKey(),business==null?item.getSourceBusinessType():"LOCAL_LOOSEBOARD",business==null?item.getSourceBusinessNo():business.orderNo(),item.getSourceKey()==null?List.of():List.of(item.getSourceKey()));
// 商业订单必须来自权威关联,不能按LB前缀或跨客户同号合并。
var key=business==null?List.of("SOURCE",Integer.toString(groups.size())):List.of("LOOSEBOARD",item.getCustomerId(),business.orderNo());
String pallet=item.getWarehousePalletId()!=null?"WAREHOUSE:"+item.getWarehousePalletId():item.getPlanPalletId()!=null?"PLAN:"+item.getPlanPalletId():"SOURCE:"+Objects.toString(item.getSourceKey(),Objects.toString(item.getId(),Integer.toString(cargo.indexOf(item))));
var members=groups.computeIfAbsent(key,ignored->new ArrayList<>());
if(members.stream().noneMatch(member->member.item()==item))members.add(new OrderDisplayContribution(item,detail,pallet));
} }
} }
return List.copyOf(result); var palletOrders=new HashMap<String,Set<List<String>>>();
groups.forEach((key,members)->members.forEach(member->palletOrders.computeIfAbsent(member.palletKey(),ignored->new HashSet<>()).add(key)));
var sharedPallets=palletOrders.entrySet().stream().filter(entry->entry.getValue().size()>1).map(Map.Entry::getKey).collect(Collectors.toSet());
return groups.entrySet().stream().map(entry->"LOOSEBOARD".equals(entry.getKey().get(0))?looseboardOrderDetail(entry.getValue(),sharedPallets):entry.getValue().get(0).detail()).toList();
}
/** 仅在展示层保存原来源与实托身份,不改写排车货物或库存。 */
private record OrderDisplayContribution(DispatchItem item,DispatchViews.OrderDetail detail,String palletKey) { }
/** 同托只计一次;混托无法分摊、缺测量或同托测量冲突均保持未知。 */
private DispatchViews.OrderDetail looseboardOrderDetail(List<OrderDisplayContribution> members,Set<String> sharedPallets) {
var first=members.get(0).detail();
var pallets=members.stream().collect(Collectors.groupingBy(OrderDisplayContribution::palletKey,LinkedHashMap::new,Collectors.toList()));
Integer palletCount=members.stream().anyMatch(m->m.detail().palletCount()==null)?null:pallets.size();
var destinations=members.stream().map(m->m.detail().destinationFbaWarehouse()).filter(Objects::nonNull).filter(s->!s.isBlank()).distinct().toList();
return new DispatchViews.OrderDetail(first.customerId(),first.customerName(),first.orderNo(),null,
orderMeasurement(pallets.values(),sharedPallets,DispatchViews.OrderDetail::grossWeightKg),palletCount,destinations.isEmpty()?null:String.join("、",destinations),
orderMeasurement(pallets.values(),sharedPallets,DispatchViews.OrderDetail::volumeCbm),null,first.sourceKey(),first.sourceBusinessType(),first.sourceBusinessNo(),
members.stream().flatMap(m->m.detail().sourceKeys().stream()).distinct().toList());
}
/** 每个实托必须有一致测量才能汇总,不能把未知量当零或重复累加整托量。 */
private java.math.BigDecimal orderMeasurement(Collection<List<OrderDisplayContribution>> pallets,Set<String> sharedPallets,java.util.function.Function<DispatchViews.OrderDetail,java.math.BigDecimal> measurement) {
var total=java.math.BigDecimal.ZERO;
for(var members:pallets) {
if(sharedPallets.contains(members.get(0).palletKey()))return null;
var value=measurement.apply(members.get(0).detail());
if(value==null || members.stream().anyMatch(m->measurement.apply(m.detail())==null || value.compareTo(measurement.apply(m.detail()))!=0))return null;
total=total.add(value);
}
return total;
} }
/** 合并Fleet权威资源、本趟照片和维护资料,不写入车辆或司机主档。 */ /** 合并Fleet权威资源、本趟照片和维护资料,不写入车辆或司机主档。 */
@@ -1308,14 +1342,14 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
public long pickedRemotePackages(DispatchAggregate row){return looseboardFacts==null?0:looseboardFacts.pickedRemotePackages(row);} public long pickedRemotePackages(DispatchAggregate row){return looseboardFacts==null?0:looseboardFacts.pickedRemotePackages(row);}
/** 仓内实物作业范围,普通未收货计划仍保留并按原规则阻断。 */ /** 仓内实物作业范围,普通未收货计划仍保留并按原规则阻断。 */
public List<DispatchItem> warehouseItems(DispatchAggregate row){return looseboardFacts==null?repository.cargo(row.getId()):looseboardFacts.warehouseItems(row.getId());} public List<DispatchItem> warehouseItems(DispatchAggregate row){return looseboardFacts==null?repository.cargo(row.getId()):looseboardFacts.warehouseItems(row.getId());}
/** 异地取货仍占用本趟容量;使用仓内实测加散板申报,不补造实托或实测。 */ /** 混排核对已知占用;未知重量体积不阻塞仓内出库,也不补造实测。 */
public void requireRemoteCapacity(DispatchAggregate row,List<LoadingMeasurement> measured){ public void requireRemoteCapacity(DispatchAggregate row,List<LoadingMeasurement> measured){
var ids=remoteItemIds(row);if(ids.isEmpty())return; var ids=remoteItemIds(row);if(ids.isEmpty())return;
var remote=repository.cargo(row.getId()).stream().filter(i->ids.contains(i.getId())).toList(); var remote=repository.cargo(row.getId()).stream().filter(i->ids.contains(i.getId())).toList();
var capacity=read(row.getResourcesJson(),Resources.class).vehicle().capacity(); var capacity=read(row.getResourcesJson(),Resources.class).vehicle().capacity();
if(remote.stream().anyMatch(i->i.getGrossWeightKg()==null || i.getVolumeCbm()==null) || measured.stream().anyMatch(m->m.weightKg()==null || m.volumeCbm()==null))throw conflict("混排实测与异地取货申报货量未明确"); // 只累加已知值作为占用下限;某项未知不能掩盖其余已知货量已经超载的事实。
var weight=remote.stream().map(DispatchItem::getGrossWeightKg).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add).add(measured.stream().map(LoadingMeasurement::weightKg).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add)); var weight=remote.stream().map(DispatchItem::getGrossWeightKg).filter(Objects::nonNull).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add).add(measured.stream().map(LoadingMeasurement::weightKg).filter(Objects::nonNull).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add));
var volume=remote.stream().map(DispatchItem::getVolumeCbm).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add).add(measured.stream().map(LoadingMeasurement::volumeCbm).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add)); var volume=remote.stream().map(DispatchItem::getVolumeCbm).filter(Objects::nonNull).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add).add(measured.stream().map(LoadingMeasurement::volumeCbm).filter(Objects::nonNull).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add));
if(remote.size()+measured.stream().mapToInt(LoadingMeasurement::palletSpaces).sum()>capacity.palletSpaces() || weight.compareTo(capacity.maxLoadKg())>0 || volume.compareTo(capacity.maxVolumeCbm())>0)throw conflict("仓内装载与异地取货合计超过本车容量"); if(remote.size()+measured.stream().mapToInt(LoadingMeasurement::palletSpaces).sum()>capacity.palletSpaces() || weight.compareTo(capacity.maxLoadKg())>0 || volume.compareTo(capacity.maxVolumeCbm())>0)throw conflict("仓内装载与异地取货合计超过本车容量");
} }
/** 取货照片保存事务复验已确认仓内实载与当前异地申报。 */ /** 取货照片保存事务复验已确认仓内实载与当前异地申报。 */
@@ -26,13 +26,13 @@ final class DispatchDestinationAppointments {
if (maintenance != null) for (var entry : maintenance.destinationAppointments()) entered.put(entry.destinationKey(),entry.appointmentNo()); if (maintenance != null) for (var entry : maintenance.destinationAppointments()) entered.put(entry.destinationKey(),entry.appointmentNo());
var result = new ArrayList<FbaAppointment>(); var result = new ArrayList<FbaAppointment>();
destinations.forEach((key,code) -> { destinations.forEach((key,code) -> {
var confirmed = booked.stream().filter(a -> key.equals(a.destinationKey()) && "SUCCESS".equals(a.status()) && a.appointmentNo() != null && !a.appointmentNo().isBlank()).toList(); var confirmed = booked.stream().filter(a -> key.equals(a.destinationKey()) && ("SUCCESS".equals(a.status()) || "REGISTERED".equals(a.status())) && a.appointmentNo() != null && !a.appointmentNo().isBlank()).toList();
var numbers = confirmed.stream().map(FbaAppointment::appointmentNo).distinct().toList(); var numbers = confirmed.stream().map(FbaAppointment::appointmentNo).distinct().toList();
// 草稿暂未填写不能遮挡后来取得的唯一正式确认号,已填写值仍保留。 // 草稿暂未填写不能遮挡后来取得的唯一正式确认号,已填写值仍保留。
var enteredNumber = entered.get(key); var enteredNumber = entered.get(key);
var number = enteredNumber != null ? enteredNumber : numbers.size() == 1 ? numbers.get(0) : null; var number = enteredNumber != null ? enteredNumber : numbers.size() == 1 ? numbers.get(0) : null;
var matching = confirmed.stream().filter(a -> Objects.equals(number,a.appointmentNo())).toList(); var matching = confirmed.stream().filter(a -> Objects.equals(number,a.appointmentNo())).toList();
var at = matching.stream().map(FbaAppointment::confirmedAt).filter(Objects::nonNull).min(Comparator.naturalOrder()).orElse(null); var at = matching.stream().filter(a -> "SUCCESS".equals(a.status())).map(FbaAppointment::confirmedAt).filter(Objects::nonNull).min(Comparator.naturalOrder()).orElse(null);
var shipments = cargoShipments.stream().filter(a -> key.equals(a.destinationKey())).map(com.baiyue.lastleg.dispatch.mapper.DispatchReadMapper.CargoShipment::shipmentNo).filter(Objects::nonNull).map(String::trim).filter(s -> !s.isEmpty()).distinct().sorted().toList(); var shipments = cargoShipments.stream().filter(a -> key.equals(a.destinationKey())).map(com.baiyue.lastleg.dispatch.mapper.DispatchReadMapper.CargoShipment::shipmentNo).filter(Objects::nonNull).map(String::trim).filter(s -> !s.isEmpty()).distinct().sorted().toList();
result.add(new FbaAppointment(null,null,number,key,code,number == null ? "PENDING" : at == null ? "REGISTERED" : "SUCCESS",at, result.add(new FbaAppointment(null,null,number,key,code,number == null ? "PENDING" : at == null ? "REGISTERED" : "SUCCESS",at,
number == null ? "请填写目的仓 " + code + " 的预约号" : null,null,shipments)); number == null ? "请填写目的仓 " + code + " 的预约号" : null,null,shipments));
@@ -40,6 +40,15 @@ final class DispatchDestinationAppointments {
return List.copyOf(result); return List.copyOf(result);
} }
/** 已约批次可回显已登记编号;只有当前有效收货确认才能提供审核用确认时间。 */
static FbaAppointment booked(com.baiyue.lastleg.dispatch.mapper.DispatchReadMapper.BatchRow batch, ReceivingWindow confirmed) {
var registered = "SUCCESS".equals(batch.status()) && batch.confirmationNo() != null && !batch.confirmationNo().isBlank();
var number = confirmed != null ? confirmed.confirmationNo() : registered ? batch.confirmationNo() : null;
return new FbaAppointment(batch.batchKey(),batch.batchNo(),number,batch.destinationKey(),batch.warehouseCode(),
confirmed != null ? "SUCCESS" : number != null ? "REGISTERED" : "PENDING",confirmed == null ? null : confirmed.startAt(),
confirmed == null ? "尚无当前有效收货确认" : null,batch.customerId(),List.of());
}
/** 保存允许暂缺编号,但不能把别的目的仓写入本车。 */ /** 保存允许暂缺编号,但不能把别的目的仓写入本车。 */
static void validate(Maintenance maintenance, List<FbaAppointment> destinations) { static void validate(Maintenance maintenance, List<FbaAppointment> destinations) {
if (maintenance == null) return; if (maintenance == null) return;
@@ -57,7 +57,7 @@ public class DispatchLooseboardExecutionService {
if(!(phase==Phase.PICKUP?item.canPickup():item.canArrive()))throw conflict("当前阶段不能上传该节点照片"); if(!(phase==Phase.PICKUP?item.canPickup():item.canArrive()))throw conflict("当前阶段不能上传该节点照片");
return files.upload(required(number).getId(),kind(phase),actor.operatorId(),file); return files.upload(required(number).getId(),kind(phase),actor.operatorId(),file);
} }
/** 父锁、公共版本、不可变命令和照片推进在同一事务保存;失败不留下假发车。 */ /** 同客户散板原单的各托照片原子保存,公共版本与命令身份仍由整车统一控制。 */
@Transactional(isolation=Isolation.READ_COMMITTED) @Transactional(isolation=Isolation.READ_COMMITTED)
public Detail save(Actor actor,String number,long itemId,Phase phase,Save request){ public Detail save(Actor actor,String number,long itemId,Phase phase,Save request){
workflow.lockForArrangement(actor,number);var row=required(number); workflow.lockForArrangement(actor,number);var row=required(number);
@@ -69,7 +69,9 @@ public class DispatchLooseboardExecutionService {
} }
if(row.getRecordVersion().longValue()!=request.recordVersion())throw conflict("排车版本已变化,请刷新后重试"); if(row.getRecordVersion().longValue()!=request.recordVersion())throw conflict("排车版本已变化,请刷新后重试");
var cargo=facts.cargo(row.getId()).stream().filter(c->c.item().getId()==itemId).findFirst().orElseThrow(()->conflict("散板明细不属于本车")); var cargo=facts.cargo(row.getId()).stream().filter(c->c.item().getId()==itemId).findFirst().orElseThrow(()->conflict("散板明细不属于本车"));
var current=detail(row);var item=current.items().stream().filter(i->i.itemId().equals(Long.toString(itemId))).findFirst().orElseThrow(); var members=facts.cargo(row.getId()).stream().filter(c->Objects.equals(c.item().getCustomerId(),cargo.item().getCustomerId()) && Objects.equals(c.orderNo(),cargo.orderNo())).toList();
var memberIds=members.stream().map(c->c.item().getId().toString()).collect(java.util.stream.Collectors.toSet());
var current=detail(row);var item=current.items().stream().filter(i->memberIds.contains(i.itemId())).findFirst().orElseThrow();
if(!(phase==Phase.PICKUP?item.canPickup():item.canArrive()))throw conflict("当前阶段不能保存该节点照片"); if(!(phase==Phase.PICKUP?item.canPickup():item.canArrive()))throw conflict("当前阶段不能保存该节点照片");
if(request.fileIds().size()>20 || new HashSet<>(request.fileIds()).size()!=request.fileIds().size())throw conflict("每个节点最多保存20张不重复照片"); if(request.fileIds().size()>20 || new HashSet<>(request.fileIds()).size()!=request.fileIds().size())throw conflict("每个节点最多保存20张不重复照片");
var previous=phase==Phase.PICKUP?item.pickupPhotos():item.arrivalPhotos(); var previous=phase==Phase.PICKUP?item.pickupPhotos():item.arrivalPhotos();
@@ -77,15 +79,18 @@ public class DispatchLooseboardExecutionService {
if(request.fileIds().isEmpty() && ("DELIVERED".equals(row.getStage()) || phase==Phase.PICKUP && (facts.pickupConfirmed(row) || !item.arrivalPhotos().isEmpty())))throw conflict("已确认取货或送达的节点至少保留一张凭证,可先追加新照片再删除旧照片"); if(request.fileIds().isEmpty() && ("DELIVERED".equals(row.getStage()) || phase==Phase.PICKUP && (facts.pickupConfirmed(row) || !item.arrivalPhotos().isEmpty())))throw conflict("已确认取货或送达的节点至少保留一张凭证,可先追加新照片再删除旧照片");
var old=facts.history(row.getId()); var old=facts.history(row.getId());
boolean pickupRecorded=old.stream().anyMatch(e->e.phase()==Phase.PICKUP && matches(e,cargo) && Objects.equals(e.cargoHash(),row.getCargoHash()) && e.reviewRound()==row.getReviewRound() && !e.files().isEmpty()); boolean pickupRecorded=old.stream().anyMatch(e->e.phase()==Phase.PICKUP && matches(e,cargo) && Objects.equals(e.cargoHash(),row.getCargoHash()) && e.reviewRound()==row.getReviewRound() && !e.files().isEmpty());
boolean arrivalRecorded=old.stream().anyMatch(e->e.phase()==Phase.ARRIVAL && matches(e,cargo) && Objects.equals(e.cargoHash(),row.getCargoHash()) && e.reviewRound()==row.getReviewRound() && !e.files().isEmpty()); boolean arrivalRecorded=members.stream().allMatch(c->old.stream().anyMatch(e->e.phase()==Phase.ARRIVAL && matches(e,c) && Objects.equals(e.cargoHash(),row.getCargoHash()) && e.reviewRound()==row.getReviewRound() && !e.files().isEmpty()));
if(old.stream().filter(e->!matches(e,cargo) || e.phase()!=phase || !Objects.equals(e.cargoHash(),row.getCargoHash()) || e.reviewRound()!=row.getReviewRound()).flatMap(e->e.files().stream()).anyMatch(f->request.fileIds().contains(f.fileId())))throw conflict("照片已绑定其他节点或明细,不能重复使用"); if(old.stream().filter(e->!memberIds.contains(e.itemId()) || !Objects.equals(e.customerId(),cargo.item().getCustomerId()) || !Objects.equals(e.orderNo(),cargo.orderNo()) || e.phase()!=phase || !Objects.equals(e.cargoHash(),row.getCargoHash()) || e.reviewRound()!=row.getReviewRound()).flatMap(e->e.files().stream()).anyMatch(f->request.fileIds().contains(f.fileId())))throw conflict("照片已绑定其他节点或明细,不能重复使用");
var photos=request.fileIds().stream().map(id->files.metadata(row.getId(),kind(phase),id)).toList(); var photos=request.fileIds().stream().map(id->files.metadata(row.getId(),kind(phase),id)).toList();
if(photos.stream().anyMatch(f->!Set.of("image/jpeg","image/png").contains(f.contentType())))throw conflict("取货和送达凭证必须为照片"); if(photos.stream().anyMatch(f->!Set.of("image/jpeg","image/png").contains(f.contentType())))throw conflict("取货和送达凭证必须为照片");
var at=Instant.now().truncatedTo(ChronoUnit.MILLIS); var at=Instant.now().truncatedTo(ChronoUnit.MILLIS);
var evidence=new Evidence(Long.toString(itemId),cargo.item().getCustomerId(),cargo.orderNo(),phase,row.getCargoHash(),row.getReviewRound(),photos,at,actor.operatorId(),request.idempotencyKey(),hash);
long original=row.getRecordVersion();row.setRecordVersion(original+1);row.setUpdatedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC)); long original=row.getRecordVersion();row.setRecordVersion(original+1);row.setUpdatedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC));
if(repository.saveState(row,original)!=1)throw conflict("排车版本竞争失败"); if(repository.saveState(row,original)!=1)throw conflict("排车版本竞争失败");
// 一次订单操作保留逐托来源证据,原送达量统计和结案校验继续按明细精确核对。
for(var member:members){
var evidence=new Evidence(member.item().getId().toString(),member.item().getCustomerId(),member.orderNo(),phase,row.getCargoHash(),row.getReviewRound(),photos,at,actor.operatorId(),request.idempotencyKey(),hash);
if(repository.history(IdWorker.getId(),row,"LOOSEBOARD_PHOTO",actor.operatorId(),phase.name(),write(evidence),row.getUpdatedAt())!=1)throw conflict("散板照片保存失败"); if(repository.history(IdWorker.getId(),row,"LOOSEBOARD_PHOTO",actor.operatorId(),phase.name(),write(evidence),row.getUpdatedAt())!=1)throw conflict("散板照片保存失败");
}
if(phase==Phase.ARRIVAL && !photos.isEmpty()){ if(phase==Phase.ARRIVAL && !photos.isEmpty()){
if(!arrivalRecorded)orders.progress(cargo.item().getCustomerId(),cargo.orderNo(),actor.operatorId(),facts.deliveredPackages(cargo.item().getCustomerId(),cargo.orderNo())); if(!arrivalRecorded)orders.progress(cargo.item().getCustomerId(),cargo.orderNo(),actor.operatorId(),facts.deliveredPackages(cargo.item().getCustomerId(),cargo.orderNo()));
if(current.direct() && "DELIVERING".equals(row.getStage()) && facts.complete(row,Phase.PICKUP) && facts.complete(row,Phase.ARRIVAL)){ if(current.direct() && "DELIVERING".equals(row.getStage()) && facts.complete(row,Phase.PICKUP) && facts.complete(row,Phase.ARRIVAL)){
@@ -171,7 +176,15 @@ public class DispatchLooseboardExecutionService {
boolean canArrive=active && (mixedPhotoStage || Set.of("DELIVERING","DELIVERED").contains(row.getStage())) && (c.item().getWarehousePalletId()!=null || confirmed && !pickup.isEmpty()); boolean canArrive=active && (mixedPhotoStage || Set.of("DELIVERING","DELIVERED").contains(row.getStage())) && (c.item().getWarehousePalletId()!=null || confirmed && !pickup.isEmpty());
return new Item(c.item().getId().toString(),c.item().getCustomerId(),c.orderNo(),Objects.toString(c.item().getDestinationWarehouseCode(),c.item().getDeliveryAddress()),c.item().getPackageCount(),pickup,arrival,canPickup,canArrive,pendingInbound.contains(c.item().getId())?c.orderNo()+":还没拣货入库":null,remote.contains(c.item().getId())); return new Item(c.item().getId().toString(),c.item().getCustomerId(),c.orderNo(),Objects.toString(c.item().getDestinationWarehouseCode(),c.item().getDeliveryAddress()),c.item().getPackageCount(),pickup,arrival,canPickup,canArrive,pendingInbound.contains(c.item().getId())?c.orderNo()+":还没拣货入库":null,remote.contains(c.item().getId()));
}).toList();boolean ready=active && !confirmed && (direct?"LOADING".equals(row.getStage()) && "APPROVED".equals(row.getReviewState()):!remote.isEmpty() && Set.of("AFTER_LOADING","DELIVERING").contains(row.getStage())) && facts.complete(row,Phase.PICKUP); }).toList();boolean ready=active && !confirmed && (direct?"LOADING".equals(row.getStage()) && "APPROVED".equals(row.getReviewState()):!remote.isEmpty() && Set.of("AFTER_LOADING","DELIVERING").contains(row.getStage())) && facts.complete(row,Phase.PICKUP);
return new Detail(row.getDispatchNo(),row.getRecordVersion(),row.getStage(),direct,items,ready,confirmed); var groups=new LinkedHashMap<List<String>,List<Item>>();
for(var item:items)groups.computeIfAbsent(List.of(item.customerId(),item.orderNo()),key->new ArrayList<>()).add(item);
var orders=groups.values().stream().map(group->{
var first=group.get(0);
return new Item(first.itemId(),first.customerId(),first.orderNo(),first.destination(),group.stream().mapToInt(Item::packages).sum(),
group.stream().flatMap(i->i.pickupPhotos().stream()).distinct().toList(),group.stream().flatMap(i->i.arrivalPhotos().stream()).distinct().toList(),
group.stream().allMatch(Item::canPickup),group.stream().allMatch(Item::canArrive),group.stream().map(Item::warehouseNotice).filter(Objects::nonNull).findFirst().orElse(null),group.stream().anyMatch(Item::pickupRequired));
}).toList();
return new Detail(row.getDispatchNo(),row.getRecordVersion(),row.getStage(),direct,orders,ready,confirmed);
} }
/** 业务原单范围取主流程认证后的本车,不接受客户端指定归属。 */ /** 业务原单范围取主流程认证后的本车,不接受客户端指定归属。 */
private DispatchAggregate required(String number){var row=repository.find(number);if(row==null)throw conflict("排车单不存在");return row;} private DispatchAggregate required(String number){var row=repository.find(number);if(row==null)throw conflict("排车单不存在");return row;}
@@ -26,7 +26,7 @@ public class DispatchPdaQueryService {
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public DispatchViews.PdaTask task(Actor actor,String number) { public DispatchViews.PdaTask task(Actor actor,String number) {
require(actor);if(number==null || number.isBlank())throw new BizException(ResultCode.BAD_REQUEST,"请输入排车单号"); require(actor);if(number==null || number.isBlank())throw new BizException(ResultCode.BAD_REQUEST,"请输入排车单号");
var rows=repository.headers(actor.boundWarehouseId(),number,null);if(rows.size()!=1)throw new BizException(ResultCode.NOT_FOUND,"当前绑定仓无此排车作业");return assemble(actor,rows).get(0); var rows=repository.headers(actor.boundWarehouseId(),number,null);if(rows.size()!=1)throw new BizException(ResultCode.NOT_FOUND,"当前绑定仓无此排车作业");var tasks=assemble(actor,rows);if(tasks.isEmpty())throw new BizException(ResultCode.NOT_FOUND,"当前绑定仓无此排车作业");return tasks.get(0);
} }
/** 按收货已确认的实托批量读取;未交接的原PLAN不推算实收仓位或箱数。 */ /** 按收货已确认的实托批量读取;未交接的原PLAN不推算实收仓位或箱数。 */
private List<DispatchViews.PdaTask> assemble(Actor actor,List<DispatchAggregate> roots) { private List<DispatchViews.PdaTask> assemble(Actor actor,List<DispatchAggregate> roots) {
@@ -42,6 +42,8 @@ public class DispatchPdaQueryService {
var consumed=hasRelabel?sourceOrigins.pickedAll(ids).stream().collect(Collectors.toMap(p->p.sourceId(),p->p.packages())):Map.<Long,Long>of(); var consumed=hasRelabel?sourceOrigins.pickedAll(ids).stream().collect(Collectors.toMap(p->p.sourceId(),p->p.packages())):Map.<Long,Long>of();
for(var root:roots) { for(var root:roots) {
var remote=workflow.remoteItemIds(root);var items=cargo.getOrDefault(root.getId(),List.of()).stream().filter(i->!remote.contains(i.getId())).toList();var detail=new ArrayList<DispatchViews.PdaItem>(); var remote=workflow.remoteItemIds(root);var items=cargo.getOrDefault(root.getId(),List.of()).stream().filter(i->!remote.contains(i.getId())).toList();var detail=new ArrayList<DispatchViews.PdaItem>();
// 纯异地取货没有仓内拣货,不向PDA推送空任务。
if(items.isEmpty())continue;
boolean sourceValid=true;var expectedHold=new HashMap<String,Long>(); boolean sourceValid=true;var expectedHold=new HashMap<String,Long>();
for(var source:frozen)if(Objects.equals(source.getDispatchOrderId(),root.getId())) { for(var source:frozen)if(Objects.equals(source.getDispatchOrderId(),root.getId())) {
long used=consumed.getOrDefault(source.getId(),0L),left=n(source.getPackageCount())-used; long used=consumed.getOrDefault(source.getId(),0L),left=n(source.getPackageCount())-used;
@@ -7,8 +7,8 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.*;
import java.util.Set; import static com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.*;
/** 已发布日常派送排车复用正式加货命令,不创建第二车次或库存。 */ /** 已发布日常派送排车复用正式加货命令,不创建第二车次或库存。 */
@Service @Service
@@ -16,8 +16,10 @@ public class LooseboardCargoService {
private final LooseboardCargoRepository cargo; private final LooseboardCargoRepository cargo;
private final DispatchAggregateRepository dispatches; private final DispatchAggregateRepository dispatches;
private final DispatchAggregateService service; private final DispatchAggregateService service;
public LooseboardCargoService(LooseboardCargoRepository cargo, DispatchAggregateRepository dispatches, DispatchAggregateService service) { private final DispatchCommandHasher hashes;
this.cargo=cargo; this.dispatches=dispatches; this.service=service; /** 加货候选复用权威来源及版本摘要,不在客户端拼造来源。 */
public LooseboardCargoService(LooseboardCargoRepository cargo, DispatchAggregateRepository dispatches, DispatchAggregateService service,DispatchCommandHasher hashes) {
this.cargo=cargo; this.dispatches=dispatches; this.service=service;this.hashes=hashes;
} }
private String warehouse(String number) { private String warehouse(String number) {
var row=dispatches.find(number); var row=dispatches.find(number);
@@ -25,12 +27,37 @@ public class LooseboardCargoService {
throw new ResponseStatusException(HttpStatus.CONFLICT,"该排车已退出散板需求池,请刷新列表"); throw new ResponseStatusException(HttpStatus.CONFLICT,"该排车已退出散板需求池,请刷新列表");
return row.getOverseasWarehouseId(); return row.getOverseasWarehouseId();
} }
/** 按完整散板订单分页;同单各托组成一次选择,不能因托盘跨页漏选。 */
@Transactional(readOnly=true) @Transactional(readOnly=true)
public DispatchPlanningModels.CandidateList candidates(DispatchContracts.Actor actor,String number,int page,int size) { public DispatchPlanningModels.CandidateList candidates(DispatchContracts.Actor actor,String number,int page,int size) {
String warehouse=warehouse(number); var plans=cargo.readyPlans(warehouse); String warehouse=warehouse(number); var plans=cargo.readyPlans(warehouse);
if(plans.isEmpty())return new DispatchPlanningModels.CandidateList(List.of(),Instant.now(),true,0,page,size,0); if(plans.isEmpty())return new DispatchPlanningModels.CandidateList(List.of(),Instant.now(),true,0,page,size,0);
if(plans.size()>500)throw new ResponseStatusException(HttpStatus.CONFLICT,"同仓待排散板过多,请先在排车工作台处理"); if(plans.size()>500)throw new ResponseStatusException(HttpStatus.CONFLICT,"同仓待排散板过多,请先在排车工作台处理");
return service.candidates(actor,new DispatchPlanningModels.CandidateQuery(warehouse,plans,null,null,null,null,page,size,"ALL")); var sources=new ArrayList<Candidate>();
for(int sourcePage=1;;sourcePage++){
var result=service.candidates(actor,new CandidateQuery(warehouse,plans,null,null,null,null,sourcePage,50,"ALL"));
sources.addAll(result.candidates());
if(sourcePage>=result.pages() || result.candidates().isEmpty())break;
}
var groups=new LinkedHashMap<List<String>,List<Candidate>>();
for(var source:sources)groups.computeIfAbsent(List.of(source.customerId(),Objects.requireNonNullElse(source.orderNo(),source.sourceKey())),key->new ArrayList<>()).add(source);
var orders=groups.values().stream().map(this::orderCandidate).toList();
long offset=(long)(page-1)*size;
return new CandidateList(orders.stream().skip(offset).limit(size).toList(),Instant.now(),orders.size()<=size,orders.size(),page,size,(orders.size()+size-1L)/size);
}
/** 合并真实来源闭包和版本,显示箱数按本单来源相加,提交仍逐来源复验。 */
private Candidate orderCandidate(List<Candidate> rows){
var first=rows.get(0);var members=new LinkedHashMap<String,SelectionGroupSource>();
var pallets=new LinkedHashMap<String,SelectionGroupPallet>();var reasons=new LinkedHashSet<String>();boolean complete=true;
for(var row:rows){
reasons.addAll(row.blockReasons());var group=row.selectionGroup();
if(group==null){complete=false;reasons.add("订单货物来源尚未完整,请重新读取");continue;}
complete&=group.complete();reasons.addAll(group.blockReasons());
for(var member:group.sources())members.putIfAbsent(member.sourceKey(),member);
for(var pallet:group.pallets())pallets.putIfAbsent(pallet.warehousePalletId()==null?group.groupKey()+":"+pallet.palletNo():pallet.warehousePalletId().toString(),pallet);
}
var group=new SelectionGroup("LOOSEBOARD_ORDER:"+hashes.source(List.of(first.customerId(),Objects.requireNonNullElse(first.orderNo(),first.sourceKey()))),hashes.source(rows.stream().map(Candidate::selectionGroup).toList()),List.copyOf(members.values()),List.copyOf(pallets.values()),complete,List.copyOf(reasons));
return new Candidate(first.selection(),first.sourceKey(),first.sourceVersion(),first.customerId(),first.customerName(),first.warehouseId(),first.warehouseCode(),first.warehouseName(),first.orderNo(),first.inboundCode(),first.planNo(),null,first.containerNo(),first.destination(),rows.stream().mapToLong(Candidate::availablePackages).sum(),null,null,"ORDER_SOURCES",rows.stream().flatMap(r->r.appointmentBatches().stream()).distinct().toList(),List.copyOf(reasons),group);
} }
@Transactional @Transactional
public DispatchContracts.CommandResult add(DispatchContracts.Actor actor,String number,DispatchCommands.AddCargo request) { public DispatchContracts.CommandResult add(DispatchContracts.Actor actor,String number,DispatchCommands.AddCargo request) {
@@ -14,6 +14,10 @@ public interface ExceptionWorkflowMapper {
/** 本车可登记的派送单,客户跟随真实派送记录。 */ /** 本车可登记的派送单,客户跟随真实派送记录。 */
@Select("SELECT customer_id,delivery_no FROM ll_delivery_order WHERE dispatch_order_id=#{id} AND status IN ('DELIVERING','REJECTED','DELIVERED') ORDER BY id") @Select("SELECT customer_id,delivery_no FROM ll_delivery_order WHERE dispatch_order_id=#{id} AND status IN ('DELIVERING','REJECTED','DELIVERED') ORDER BY id")
List<Delivery> deliveries(long id); List<Delivery> deliveries(long id);
/** 取消本单时核对同一客户及派送是否仍有其他活动退回。 */
@Select("SELECT COUNT(*) FROM ll_exception_order WHERE customer_id=#{customer} AND delivery_order_id=#{delivery} " +
"AND exception_type='RETURN' AND id<>#{excluded} AND status IN ('PENDING','PROCESSING')")
long countOtherActiveReturns(@Param("customer") String customer, @Param("delivery") long delivery, @Param("excluded") long excluded);
/** 以异常号和冻结仓锁定现场任务。 */ /** 以异常号和冻结仓锁定现场任务。 */
@Select("SELECT * FROM ll_exception_order WHERE exception_no=#{number} AND overseas_warehouse_id=#{warehouse} LIMIT 1 FOR UPDATE") @Select("SELECT * FROM ll_exception_order WHERE exception_no=#{number} AND overseas_warehouse_id=#{warehouse} LIMIT 1 FOR UPDATE")
ExceptionOrder lockWarehouse(@Param("warehouse") String warehouse, @Param("number") String number); ExceptionOrder lockWarehouse(@Param("warehouse") String warehouse, @Param("number") String number);
@@ -24,6 +24,10 @@ public class ExceptionWorkflowRepository {
} }
/** 查询本车派送归属。 */ /** 查询本车派送归属。 */
public List<ExceptionWorkflowMapper.Delivery> deliveries(long id) { return workflow.deliveries(id); } public List<ExceptionWorkflowMapper.Delivery> deliveries(long id) { return workflow.deliveries(id); }
/** 取消单据不释放同派送其他仍在处理的退回批次。 */
public boolean hasOtherActiveReturns(String customer, long delivery, long excluded) {
return workflow.countOtherActiveReturns(customer,delivery,excluded)>0;
}
/** 在客户范围锁定异常。 */ /** 在客户范围锁定异常。 */
public ExceptionOrder lock(String customer, String number) { return exceptions.lockByNo(customer,number); } public ExceptionOrder lock(String customer, String number) { return exceptions.lockByNo(customer,number); }
/** 保存人工完结后的异常状态,保留关联单据和实际库存事实。 */ /** 保存人工完结后的异常状态,保留关联单据和实际库存事实。 */
@@ -36,17 +36,24 @@ public class DispatchExceptionAdapter implements DispatchExceptionPort {
if(candidates.isEmpty())throw new ResponseStatusException(HttpStatus.CONFLICT,"本车没有可关联的派送来源"); if(candidates.isEmpty())throw new ResponseStatusException(HttpStatus.CONFLICT,"本车没有可关联的派送来源");
if(warehouse) { if(warehouse) {
var seen=new HashSet<String>(); var seen=new HashSet<String>();
var grouped=new LinkedHashMap<Candidate,List<ExceptionOrderRequests.MarkReturn>>();
for(int index=0;index<selected.size();index++) { for(int index=0;index<selected.size();index++) {
var selection=selected.get(index); var selection=selected.get(index);
if(selection==null || selection.items()==null || selection.items().isEmpty() || selection.items().size()>200 || !seen.add(selection.deliveryNo())) if(selection==null || selection.items()==null || selection.items().isEmpty() || selection.items().size()>200
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"每张派送单请选择一张来源订单及有效退回货物"); || !seen.add(selection.deliveryNo()+":"+selection.lastMileOrderId()))
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"请选择不重复的来源订单及有效退回货物");
var source=candidates.stream().filter(c->Objects.equals(c.deliveryNo(),selection.deliveryNo()) && Objects.equals(c.lastMileOrderId(),selection.lastMileOrderId())).findFirst() var source=candidates.stream().filter(c->Objects.equals(c.deliveryNo(),selection.deliveryNo()) && Objects.equals(c.lastMileOrderId(),selection.lastMileOrderId())).findFirst()
.orElseThrow(()->new ResponseStatusException(HttpStatus.CONFLICT,"所选来源不属于当前排车")); .orElseThrow(()->new ResponseStatusException(HttpStatus.CONFLICT,"所选来源不属于当前排车"));
if(selection.items().stream().anyMatch(i->i==null || i.outboundPalletItemId()==null || i.packageCount()<1))throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"退回明细无效"); if(selection.items().stream().anyMatch(i->i==null || i.outboundPalletItemId()==null || i.packageCount()<1))throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"退回明细无效");
service.markReturn(source.customerId(),operator,operator,source.deliveryNo(),new ExceptionOrderRequests.MarkReturn(source.lastMileOrderId(),reason,"OVERSEAS_WAREHOUSE",null, // 同派送的多张原订单一次锁定并整批冻结,不能让首单的退回中状态拦住后续订单。
var group=grouped.keySet().stream().filter(row->Objects.equals(row.customerId(),source.customerId())
&& Objects.equals(row.deliveryNo(),source.deliveryNo())).findFirst().orElse(source);
grouped.computeIfAbsent(group,ignored->new ArrayList<>()).add(new ExceptionOrderRequests.MarkReturn(source.lastMileOrderId(),reason,"OVERSEAS_WAREHOUSE",null,
selection.items().stream().map(i->new ExceptionOrderRequests.ReturnItem(i.outboundPalletItemId(),i.inventoryPositionId(),i.packageCount(),i.returnCartonId())).toList(), selection.items().stream().map(i->new ExceptionOrderRequests.ReturnItem(i.outboundPalletItemId(),i.inventoryPositionId(),i.packageCount(),i.returnCartonId())).toList(),
"dispatch:"+dispatchId+":"+java.util.UUID.nameUUIDFromBytes(request.idempotencyKey().getBytes(java.nio.charset.StandardCharsets.UTF_8))+":"+index)); "dispatch:"+dispatchId+":"+java.util.UUID.nameUUIDFromBytes(request.idempotencyKey().getBytes(java.nio.charset.StandardCharsets.UTF_8))+":"+index));
} }
for(var entry:grouped.entrySet()) service.markReturns(entry.getKey().customerId(),operator,operator,
entry.getKey().deliveryNo(),entry.getValue());
} else for(var source:candidates) service.create(source.customerId(),operator,operator, } else for(var source:candidates) service.create(source.customerId(),operator,operator,
new ExceptionOrderRequests.Create(source.customerId(),null,source.lastMileOrderNo(),"REFUSED".equals(request.exceptionType().name())?"REJECT":"OTHER",reason,null,null)); new ExceptionOrderRequests.Create(source.customerId(),null,source.lastMileOrderNo(),"REFUSED".equals(request.exceptionType().name())?"REJECT":"OTHER",reason,null,null));
} }
@@ -216,7 +216,7 @@ public class ExceptionOrderService {
ExceptionOrderResponses.Relation relation = relation(scopedCustomerId, request.customerId(), ExceptionOrderResponses.Relation relation = relation(scopedCustomerId, request.customerId(),
request.inboundCode(), request.lastMileOrderNo()); request.inboundCode(), request.lastMileOrderNo());
return insert(relation, null, normalizeType(request.exceptionType()), request.reason(), request.packageCount(), return insert(relation, null, normalizeType(request.exceptionType()), request.reason(), request.packageCount(),
request.skuCode(), null, null, "MANUAL", operatorId, operatorName); request.skuCode(), null, null, "MANUAL", operatorId, operatorName, null);
} }
/** 按原出库冻结普通仓位或原箱来源,整箱引用与数量在同一派送锁内复核。 */ /** 按原出库冻结普通仓位或原箱来源,整箱引用与数量在同一派送锁内复核。 */
@@ -227,7 +227,44 @@ public class ExceptionOrderService {
ExceptionOrderCommand replay = commands.findByKey(customerId, "MARK_RETURN", request.idempotencyKey().trim()); ExceptionOrderCommand replay = commands.findByKey(customerId, "MARK_RETURN", request.idempotencyKey().trim());
if (replay != null) return replayCreated(replay, requestHash); if (replay != null) return replayCreated(replay, requestHash);
DeliveryOrder delivery = lockForReturn(customerId, deliveryNo); DeliveryOrder delivery = lockForReturn(customerId, deliveryNo);
return markReturnLocked(customerId, operatorId, operatorName, delivery, request, requestHash, delivery.getStatus());
}
/** 同一派送可一次选择多张原订单;整批共用派送行锁,任一来源失败全部回滚。 */
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public List<ExceptionOrderResponses.Created> markReturns(String customerId, String operatorId, String operatorName,
String deliveryNo, List<ExceptionOrderRequests.MarkReturn> requests) {
if (requests == null || requests.isEmpty() || requests.size() > 200 || requests.stream().anyMatch(request -> request == null
|| request.lastMileOrderId() == null || request.idempotencyKey() == null || request.idempotencyKey().isBlank()
|| request.items() == null || request.items().isEmpty() || request.items().size() > 200
|| request.items().stream().anyMatch(item -> item == null || item.packageCount() < 1))
|| requests.stream().map(ExceptionOrderRequests.MarkReturn::idempotencyKey).distinct().count() != requests.size()
|| requests.stream().map(ExceptionOrderRequests.MarkReturn::lastMileOrderId).distinct().count() != requests.size())
throw badRequest("请选择不重复的来源订单及有效退回货物");
var replayed = new ArrayList<ExceptionOrderResponses.Created>();
for (var request : requests) {
var replay = commands.findByKey(customerId, "MARK_RETURN", request.idempotencyKey().trim());
if (replay != null) replayed.add(replayCreated(replay, hash(request)));
}
if (replayed.size() == requests.size()) return List.copyOf(replayed);
if (!replayed.isEmpty()) throw conflict("退回批次已有部分命令,请刷新后核对原结果");
DeliveryOrder delivery = lockForReturn(customerId, deliveryNo);
long packages = requests.stream().flatMap(request -> request.items().stream())
.mapToLong(ExceptionOrderRequests.ReturnItem::packageCount).reduce(0L, Math::addExact);
long signed = Objects.requireNonNullElse(delivery.getSignedPackages(), 0);
if (signed < 0 || delivery.getPackageCount() == null || packages > delivery.getPackageCount() - signed)
throw conflict("退回总箱数超过派送未签收数量");
var created = new ArrayList<ExceptionOrderResponses.Created>();
String originalStatus = delivery.getStatus();
for (var request : requests) created.add(markReturnLocked(customerId, operatorId, operatorName, delivery, request, hash(request), originalStatus));
return List.copyOf(created);
}
/** 调用方已锁定派送并校验批次总量,这里按原订单冻结真实来源并保存独立异常。 */
private ExceptionOrderResponses.Created markReturnLocked(String customerId, String operatorId, String operatorName,
DeliveryOrder delivery, ExceptionOrderRequests.MarkReturn request,
String requestHash, String originalDeliveryStatus) {
String deliveryNo = delivery.getDeliveryNo();
List<ExceptionOrderResponses.Relation> candidates = returnCandidates(delivery); List<ExceptionOrderResponses.Relation> candidates = returnCandidates(delivery);
ExceptionOrderResponses.Relation selected = candidates.stream() ExceptionOrderResponses.Relation selected = candidates.stream()
.filter(candidate -> Objects.equals(candidate.lastMileOrderId(), request.lastMileOrderId())) .filter(candidate -> Objects.equals(candidate.lastMileOrderId(), request.lastMileOrderId()))
@@ -248,7 +285,7 @@ public class ExceptionOrderService {
} }
ExceptionOrderResponses.Created created = insert(selected, delivery, "RETURN", request.reason(), ExceptionOrderResponses.Created created = insert(selected, delivery, "RETURN", request.reason(),
packages, sku, returnDirection, request.returnLogisticsNo(), packages, sku, returnDirection, request.returnLogisticsNo(),
"DELIVERY_RETURN", operatorId, operatorName); "DELIVERY_RETURN", operatorId, operatorName, originalDeliveryStatus);
for (ExceptionOrderRequests.ReturnItem requestedItem : requested) { for (ExceptionOrderRequests.ReturnItem requestedItem : requested) {
ExceptionOrderResponses.ReturnSource source = available.stream() ExceptionOrderResponses.ReturnSource source = available.stream()
@@ -284,7 +321,7 @@ public class ExceptionOrderService {
return created; return created;
} }
/** Caller keeps the delivery row locked until return creation commits. */ /** 调用方持有派送行锁,禁止活动退回批次之间重叠冻结。 */
DeliveryOrder lockForReturn(String customerId, String deliveryNo) { DeliveryOrder lockForReturn(String customerId, String deliveryNo) {
DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo); DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo);
if (delivery == null) throw notFound("派送单不存在"); if (delivery == null) throw notFound("派送单不存在");
@@ -461,7 +498,7 @@ public class ExceptionOrderService {
private ExceptionOrderResponses.Created insert(ExceptionOrderResponses.Relation relation, DeliveryOrder delivery, private ExceptionOrderResponses.Created insert(ExceptionOrderResponses.Relation relation, DeliveryOrder delivery,
String type, String reason, Integer packages, String sku, String type, String reason, Integer packages, String sku,
String direction, String logisticsNo, String sourceType, String direction, String logisticsNo, String sourceType,
String operatorId, String operatorName) { String operatorId, String operatorName, String originalDeliveryStatus) {
if (packages != null && relation.availablePackages() != null && packages > relation.availablePackages()) { if (packages != null && relation.availablePackages() != null && packages > relation.availablePackages()) {
throw conflict("异常箱数不能大于订单箱数 " + relation.availablePackages()); throw conflict("异常箱数不能大于订单箱数 " + relation.availablePackages());
} }
@@ -482,7 +519,8 @@ public class ExceptionOrderService {
value.setReturnLogisticsNo(trim(logisticsNo)); value.setReturnLogisticsNo(trim(logisticsNo));
if (delivery != null) { if (delivery != null) {
LastlegOrder tail = relation.lastMileOrderId() == null ? null : lastMileOrders.selectById(relation.lastMileOrderId()); LastlegOrder tail = relation.lastMileOrderId() == null ? null : lastMileOrders.selectById(relation.lastMileOrderId());
value.setReturnContextJson(write(new ReturnContext(delivery.getStatus(), tail == null ? null : tail.getId(), // 同批各单冻结批次开始前的派送状态,支持任意顺序取消并恢复。
value.setReturnContextJson(write(new ReturnContext(originalDeliveryStatus, tail == null ? null : tail.getId(),
tail == null ? null : tail.getSubOrderStatus(), tail == null ? null : tail.getAbnormal()))); tail == null ? null : tail.getSubOrderStatus(), tail == null ? null : tail.getAbnormal())));
} }
if (StringUtils.hasText(direction)) { if (StringUtils.hasText(direction)) {
@@ -695,6 +733,7 @@ public class ExceptionOrderService {
private record ReturnContext(String deliveryStatus, Long tailId, String tailStatus, Boolean abnormal) { } private record ReturnContext(String deliveryStatus, Long tailId, String tailStatus, Boolean abnormal) { }
/** 取消只恢复本原单;同批仍有未取消退回时继续保留派送退回中状态。 */
private void restoreCancelledReturn(ExceptionOrder order) { private void restoreCancelledReturn(ExceptionOrder order) {
if (!"RETURN".equals(order.getExceptionType()) || !StringUtils.hasText(order.getDeliveryNo())) return; if (!"RETURN".equals(order.getExceptionType()) || !StringUtils.hasText(order.getDeliveryNo())) return;
ReturnContext previous; ReturnContext previous;
@@ -706,7 +745,8 @@ public class ExceptionOrderService {
if (delivery == null || !"RETURN_PENDING".equals(delivery.getStatus())) throw conflict("关联派送状态已变化,不能取消退回"); if (delivery == null || !"RETURN_PENDING".equals(delivery.getStatus())) throw conflict("关联派送状态已变化,不能取消退回");
if (returnItems.findByException(order.getCustomerId(), order.getId()).stream().anyMatch(item -> ReturnReceiptPolicy.received(item) > 0)) if (returnItems.findByException(order.getCustomerId(), order.getId()).stream().anyMatch(item -> ReturnReceiptPolicy.received(item) > 0))
throw conflict("已有实际退回入仓,不能取消退回事实"); throw conflict("已有实际退回入仓,不能取消退回事实");
delivery.setStatus(previous.deliveryStatus()); delivery.setUpdatedAt(LocalDateTime.now()); boolean otherReturns = workflow.hasOtherActiveReturns(order.getCustomerId(), delivery.getId(), order.getId());
delivery.setStatus(otherReturns ? "RETURN_PENDING" : previous.deliveryStatus()); delivery.setUpdatedAt(LocalDateTime.now());
delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(), 0L) + 1); deliveryOrders.updateById(delivery); delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(), 0L) + 1); deliveryOrders.updateById(delivery);
if (previous.tailId() != null) { if (previous.tailId() != null) {
LastlegOrder tail = lastMileOrders.selectById(previous.tailId()); LastlegOrder tail = lastMileOrders.selectById(previous.tailId());
@@ -34,29 +34,32 @@ public class FleetHandoverPdaQuery {
public record Status(String dispatchNo, String tripNo, String tripStatus, String tripRecordVersion, public record Status(String dispatchNo, String tripNo, String tripStatus, String tripRecordVersion,
FleetTransitEvidence.Transport originalDeparture, List<Delivery> deliveries, Handover latestHandover) { } FleetTransitEvidence.Transport originalDeparture, List<Delivery> deliveries, Handover latestHandover) { }
/** 交接只读范围按原发车仓内实载核对,异地取货量不冒充仓库派送。 */
public Status status(long warehouseId, String dispatchNo) { public Status status(long warehouseId, String dispatchNo) {
var binding = bindings.findByDispatchNo(dispatchNo); var binding = bindings.findByDispatchNo(dispatchNo);
if (binding == null) throw notFound(); if (binding == null) throw notFound();
var trip = trips.findByNo(binding.getTripNo()); var trip = trips.findByNo(binding.getTripNo());
// Check token-bound warehouse before reading any customer's transport or historical data. // 先校验令牌绑定仓,再读取客户运输及历史资料。
if (trip == null || !Objects.equals(trip.getWarehouseId(), warehouseId) || !Objects.equals(binding.getTripId(), trip.getId())) throw notFound(); if (trip == null || !Objects.equals(trip.getWarehouseId(), warehouseId) || !Objects.equals(binding.getTripId(), trip.getId())) throw notFound();
if (!List.of("IN_TRANSIT", "COMPLETED").contains(trip.getStatus())) if (!List.of("IN_TRANSIT", "COMPLETED").contains(trip.getStatus()))
throw new ResponseStatusException(HttpStatus.CONFLICT, "尚未发车,请按原拣货发车流程作业"); throw new ResponseStatusException(HttpStatus.CONFLICT, "尚未发车,请按原拣货发车流程作业");
var evidence = dispatches.transitEvidence(dispatchNo, false); var evidence = dispatches.transitEvidence(dispatchNo, false);
if (!Long.toString(warehouseId).equals(evidence.warehouseId()) if (!Long.toString(warehouseId).equals(evidence.warehouseId())
|| !dispatchNo.equals(evidence.dispatchNo()) || !Objects.equals(binding.getPackageCount(), evidence.shippedPackages())) throw notFound(); || !dispatchNo.equals(evidence.dispatchNo()) || evidence.shippedPackages()<=0) throw notFound();
var deliveryNumbers=new java.util.HashSet<String>(); var deliveryNumbers=new java.util.HashSet<String>();
if(evidence.deliveries()==null || evidence.deliveries().isEmpty() || evidence.deliveries().stream().anyMatch(row -> if(evidence.deliveries()==null || evidence.deliveries().isEmpty() || evidence.deliveries().stream().anyMatch(row ->
row==null || row.cargo()==null || row.cargo().customerId()==null || row.cargo().customerId().isBlank() row==null || row.cargo()==null || row.cargo().customerId()==null || row.cargo().customerId().isBlank()
|| !dispatchNo.equals(row.cargo().dispatchNo()) || row.cargo().deliveryNo()==null || !dispatchNo.equals(row.cargo().dispatchNo()) || row.cargo().deliveryNo()==null
|| !deliveryNumbers.add(row.cargo().deliveryNo()) || row.cargo().packages()<=0) || !deliveryNumbers.add(row.cargo().deliveryNo()) || row.cargo().packages()<=0)
|| evidence.deliveries().stream().mapToLong(row->row.cargo().packages()).sum()!=evidence.shippedPackages())throw notFound(); )throw notFound();
var original = history.selectList(new LambdaQueryWrapper<FleetTripHistory>().eq(FleetTripHistory::getTripId, trip.getId()) var original = history.selectList(new LambdaQueryWrapper<FleetTripHistory>().eq(FleetTripHistory::getTripId, trip.getId())
.eq(FleetTripHistory::getAction, "DEPART")); .eq(FleetTripHistory::getAction, "DEPART"));
if (original.size() != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "缺少唯一原始发车记录"); if (original.size() != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "缺少唯一原始发车记录");
try { try {
var departed = json.readValue(original.get(0).getSnapshotJson(), FleetTripModels.AuditSnapshot.class).trip(); var originalSnapshot = json.readValue(original.get(0).getSnapshotJson(), FleetTripModels.AuditSnapshot.class);
var departed = originalSnapshot.trip();
if (departed == null || !trip.getTripNo().equals(departed.tripNo()) || !Long.toString(warehouseId).equals(departed.warehouseId())) throw notFound(); if (departed == null || !trip.getTripNo().equals(departed.tripNo()) || !Long.toString(warehouseId).equals(departed.warehouseId())) throw notFound();
requireWarehouseCargo(dispatchNo,binding,evidence,originalSnapshot);
var originalTransport = new FleetTransitEvidence.Transport(departed.vehiclePlate(), departed.driverName(), departed.driverPhone()); var originalTransport = new FleetTransitEvidence.Transport(departed.vehiclePlate(), departed.driverName(), departed.driverPhone());
var rows = evidence.deliveries().stream().map(row -> new Delivery(row.cargo().deliveryNo(), row.status(), row.cargo().packages(), var rows = evidence.deliveries().stream().map(row -> new Delivery(row.cargo().deliveryNo(), row.status(), row.cargo().packages(),
row.cargo().terminalAt() != null ? 0 : row.cargo().packages() - row.cargo().returnedCargo().stream().mapToInt(FleetHandoverPolicy.ReturnedCargo::packages).sum(), row.cargo().terminalAt() != null ? 0 : row.cargo().packages() - row.cargo().returnedCargo().stream().mapToInt(FleetHandoverPolicy.ReturnedCargo::packages).sum(),
@@ -71,6 +74,27 @@ public class FleetHandoverPdaQuery {
return new Status(dispatchNo, trip.getTripNo(), trip.getStatus(), trip.getRecordVersion().toString(), originalTransport, rows, handover); return new Status(dispatchNo, trip.getTripNo(), trip.getStatus(), trip.getRecordVersion().toString(), originalTransport, rows, handover);
} catch (JsonProcessingException error) { throw new IllegalStateException("交接记录读取失败", error); } } catch (JsonProcessingException error) { throw new IllegalStateException("交接记录读取失败", error); }
} }
/** 用原始逐来源实载证明客户、目的地及箱数,不能仅放宽整车数量相等判断。 */
private static void requireWarehouseCargo(String number,FleetTripDispatch binding,FleetTransitEvidence evidence,FleetTripModels.AuditSnapshot snapshot){
long packages=evidence.deliveries().stream().mapToLong(d->d.cargo().packages()).sum();
if(snapshot.confirmedLoad()==null){
if(!Objects.equals(binding.getPackageCount(),evidence.shippedPackages()) || packages!=evidence.shippedPackages())throw notFound();
return;
}
var expected=new java.util.HashMap<List<String>,Long>();
if(snapshot.confirmedLoad().cargo()==null)throw notFound();
for(var cargo:snapshot.confirmedLoad().cargo())if(number.equals(cargo.dispatchNo())){
if(cargo.customerId()==null || cargo.destinationKey()==null || cargo.warehousePalletId()==null || cargo.packages()<=0)throw notFound();
expected.merge(List.of(cargo.customerId(),cargo.destinationKey()),(long)cargo.packages(),Long::sum);
}
var actual=new java.util.HashMap<List<String>,Long>();
for(var delivery:evidence.deliveries()){
var cargo=delivery.cargo();if(cargo.destinationKey()==null)throw notFound();
actual.merge(List.of(cargo.customerId(),cargo.destinationKey()),(long)cargo.packages(),Long::sum);
}
if(expected.isEmpty() || !expected.equals(actual) || binding.getPackageCount()==null || packages>evidence.shippedPackages() || evidence.shippedPackages()>binding.getPackageCount())throw notFound();
if(snapshot.dispatches()==null || snapshot.dispatches().stream().noneMatch(d->number.equals(d.dispatchNo()) && d.packageCount()==binding.getPackageCount()))throw notFound();
}
private static FleetTransitEvidence.Transport transport(FleetHandoverModels.Resource resource) { private static FleetTransitEvidence.Transport transport(FleetHandoverModels.Resource resource) {
return new FleetTransitEvidence.Transport(resource.vehiclePlate(), resource.driverName(), resource.driverPhone()); return new FleetTransitEvidence.Transport(resource.vehiclePlate(), resource.driverName(), resource.driverPhone());
} }
@@ -56,8 +56,9 @@ public final class OwnedBusinessModels {
String recordVersion,String dispatchRecordVersion,PhotoSummary photoSummary,Actions actions,List<String> sourceIssues,String taskType) {} String recordVersion,String dispatchRecordVersion,PhotoSummary photoSummary,Actions actions,List<String> sourceIssues,String taskType) {}
public record Summary(int dispatchCount,int afterLoadingCount,int deliveringCount,int deliveredCount) {} public record Summary(int dispatchCount,int afterLoadingCount,int deliveringCount,int deliveredCount) {}
public record ListResult(List<Row> records,int page,int size,int total,Summary summary) {} public record ListResult(List<Row> records,int page,int size,int total,Summary summary) {}
/** 散板按商业订单展示,关联托盘与目的地完整保留,箱数只累加真实来源贡献。 */
public record Cargo(String sourceKey,String customerId,String customerName,String orderNo, public record Cargo(String sourceKey,String customerId,String customerName,String orderNo,
String inboundCode,String palletNo,String destinationKey,String packages) {} String inboundCode,String palletNo,String destinationKey,String packages,List<String> destinationKeys) {}
public record Detail(Row row,DispatchViews.Detail dispatch,List<Cargo> cargo,List<Photo> photos) {} public record Detail(Row row,DispatchViews.Detail dispatch,List<Cargo> cargo,List<Photo> photos) {}
public record History(String eventSource,String recordVersion,String dispatchRecordVersion,String action,String operatorName,String occurredAt,String reason, public record History(String eventSource,String recordVersion,String dispatchRecordVersion,String action,String operatorName,String occurredAt,String reason,
Operations operations,List<Photo> photos) {} Operations operations,List<Photo> photos) {}
@@ -5,6 +5,7 @@ import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Search; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Search;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.CargoRow;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository; import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import com.baiyue.lastleg.dispatch.port.DispatchDetailReader; import com.baiyue.lastleg.dispatch.port.DispatchDetailReader;
import com.baiyue.lastleg.dispatch.port.DispatchContracts; import com.baiyue.lastleg.dispatch.port.DispatchContracts;
@@ -68,14 +69,37 @@ public class OwnedBusinessQueryService {
public Detail detailForWrite(Actor actor,Source source) { public Detail detailForWrite(Actor actor,Source source) {
return detailForWrite(actor,source,OwnedBusinessScope.OWNED); return detailForWrite(actor,source,OwnedBusinessScope.OWNED);
} }
/** 写回执也执行固定归属校验,调用者已核对本域独立写权限。 */ /** 写回执也执行固定归属校验;散板明细复用原排车公开的商业订单归属。 */
public Detail detailForWrite(Actor actor,Source source,OwnedBusinessScope scope) { public Detail detailForWrite(Actor actor,Source source,OwnedBusinessScope scope) {
eligible(source,scope);var ids=List.of(source.getId());var cargo=repository.cargo(ids); eligible(source,scope);var ids=List.of(source.getId());var cargo=repository.cargo(ids);
var row=projection.row(source,repository.record(source.getId()),cargo,repository.batches(ids),actor,scope); var row=projection.row(source,repository.record(source.getId()),cargo,repository.batches(ids),actor,scope);
var original=dispatch.detail(new DispatchContracts.Actor(actor.operatorId(),ActorMode.PLATFORM,null),source.getDispatchNo()); var original=dispatch.detail(new DispatchContracts.Actor(actor.operatorId(),ActorMode.PLATFORM,null),source.getDispatchNo());
return new Detail(row,original,cargo.stream().map(c->new Cargo(c.getSourceKey(),c.getCustomerId(),c.getCustomerName(), var looseboardOrders=new HashMap<List<String>,String>();
c.getOrderNo(),c.getInboundCode(),c.getPalletNo(),projection.destinationKey(c),String.valueOf(c.getPackageCount()))).toList(), if(original!=null && original.orderDetails()!=null)for(var order:original.orderDetails()) {
projection.photos(repository.record(source.getId()))); if(!"LOCAL_LOOSEBOARD".equals(order.sourceBusinessType()) || order.sourceBusinessNo()==null)continue;
for(var key:order.sourceKeys())looseboardOrders.put(List.of(order.customerId(),key),order.sourceBusinessNo());
}
return new Detail(row,original,cargoDetails(cargo,looseboardOrders),projection.photos(repository.record(source.getId())));
}
/** 商业订单由排车公开契约确认,普通货物保持原行,不能按编号前缀猜测散板。 */
private List<Cargo> cargoDetails(List<CargoRow> cargo,Map<List<String>,String> looseboardOrders) {
var groups=new LinkedHashMap<List<String>,List<CargoRow>>();
for(var item:cargo) {
var order=looseboardOrders.get(List.of(item.getCustomerId(),item.getSourceKey()));
var key=order==null?List.of("SOURCE",item.getCustomerId(),item.getSourceKey()):List.of("LOOSEBOARD",item.getCustomerId(),order);
groups.computeIfAbsent(key,ignored->new ArrayList<>()).add(item);
}
return groups.entrySet().stream().map(entry->{
var rows=entry.getValue();var first=rows.get(0);boolean looseboard="LOOSEBOARD".equals(entry.getKey().get(0));
var destinations=rows.stream().map(projection::destinationKey).distinct().toList();
var pallets=rows.stream().map(CargoRow::getPalletNo).filter(Objects::nonNull).distinct().toList();
// 同一来源只贡献一次箱数,实托码展示完整集合,不复制整托箱数。
var sources=rows.stream().collect(Collectors.toMap(CargoRow::getSourceKey,c->c,(a,b)->a,LinkedHashMap::new)).values();
String packages=sources.stream().anyMatch(c->c.getPackageCount()==null)?null:Long.toString(sources.stream().mapToLong(CargoRow::getPackageCount).sum());
return new Cargo(first.getSourceKey(),first.getCustomerId(),first.getCustomerName(),looseboard?entry.getKey().get(2):first.getOrderNo(),
looseboard?null:first.getInboundCode(),pallets.isEmpty()?null:String.join("、",pallets),destinations.get(0),packages,destinations);
}).toList();
} }
/** 缺少历史归属必须明确拒绝,不能按当前车辆主档降级猜测。 */ /** 缺少历史归属必须明确拒绝,不能按当前车辆主档降级猜测。 */
public Source requireOwned(String number) { public Source requireOwned(String number) {
@@ -6,6 +6,15 @@ import java.util.List;
/** 每趟只取首次真实发车快照;取货动作记录不能冒充仓库装车。 */ /** 每趟只取首次真实发车快照;取货动作记录不能冒充仓库装车。 */
@Mapper @Mapper
public interface TripOperationsQueryMapper { public interface TripOperationsQueryMapper {
/** 发车边界和完成时间显式按 UTC DATETIME 读写,避免默认 Instant 处理器引入连接时区偏移。 */
@ConstructorArgs({
@Arg(column = "trip_no", javaType = String.class),
@Arg(column = "status", javaType = String.class),
@Arg(column = "completed_at", javaType = java.time.Instant.class, typeHandler = TripOperationsUtcInstantTypeHandler.class),
@Arg(column = "snapshot", javaType = String.class),
@Arg(column = "measured", javaType = String.class),
@Arg(column = "transport_facts", javaType = String.class)
})
@Select(""" @Select("""
<script> <script>
SELECT t.trip_no,t.status,t.completed_at, SELECT t.trip_no,t.status,t.completed_at,
@@ -25,9 +34,20 @@ public interface TripOperationsQueryMapper {
(SELECT d.package_count FROM ll_dispatch_order d WHERE d.trip_id=t.id AND d.task_type='LOCAL_TRUCK' LIMIT 1) (SELECT d.package_count FROM ll_dispatch_order d WHERE d.trip_id=t.id AND d.task_type='LOCAL_TRUCK' LIMIT 1)
ELSE (SELECT SUM(p.actual_packages) FROM ll_fleet_pickup_task p ELSE (SELECT SUM(p.actual_packages) FROM ll_fleet_pickup_task p
WHERE CAST(p.trip_no AS BINARY(512))=CAST(t.trip_no AS BINARY(512)) AND p.status IN ('PICKED','ARRIVED')) END) WHERE CAST(p.trip_no AS BINARY(512))=CAST(t.trip_no AS BINARY(512)) AND p.status IN ('PICKED','ARRIVED')) END)
ELSE NULL END AS transport_facts ELSE (SELECT JSON_OBJECT('taskType','PHOTO_LOOSEBOARD','packages',
JSON_EXTRACT(dh.snapshot_json,'$.shippedPackageCount'))
FROM ll_dispatch_order d JOIN ll_dispatch_history dh ON dh.dispatch_id=d.id
WHERE d.trip_id=t.id AND dh.action='LOOSEBOARD_DELIVERING'
AND JSON_EXTRACT(dh.snapshot_json,'$.departedAt') IS NOT NULL
AND JSON_UNQUOTE(JSON_EXTRACT(dh.snapshot_json,'$.departedAt'))!='null'
AND (JSON_EXTRACT(dh.snapshot_json,'$.loadingCompletedAt') IS NULL
OR JSON_UNQUOTE(JSON_EXTRACT(dh.snapshot_json,'$.loadingCompletedAt'))='null')
AND JSON_EXTRACT(dh.snapshot_json,'$.pickedPackageCount')=0
AND JSON_EXTRACT(dh.snapshot_json,'$.shippedPackageCount') &gt; 0
ORDER BY dh.record_version,dh.id LIMIT 1) END AS transport_facts
FROM ll_fleet_trip t FROM ll_fleet_trip t
WHERE t.departed_at &gt;= #{q.from} AND t.departed_at &lt; #{q.to} WHERE t.departed_at &gt;= #{q.from,typeHandler=com.baiyue.lastleg.fleet.reporting.TripOperationsUtcInstantTypeHandler}
AND t.departed_at &lt; #{q.to,typeHandler=com.baiyue.lastleg.fleet.reporting.TripOperationsUtcInstantTypeHandler}
<if test='q.warehouseId != null'>AND t.warehouse_id=#{q.warehouseId}</if> <if test='q.warehouseId != null'>AND t.warehouse_id=#{q.warehouseId}</if>
<if test='q.status != null and q.status != ""'>AND t.status=#{q.status}</if> <if test='q.status != null and q.status != ""'>AND t.status=#{q.status}</if>
ORDER BY t.departed_at DESC,t.id DESC LIMIT 5001 ORDER BY t.departed_at DESC,t.id DESC LIMIT 5001
@@ -55,6 +55,10 @@ public class TripOperationsService {
var trip=snapshot.trip(); var load=snapshot.confirmedLoad(); var trip=snapshot.trip(); var load=snapshot.confirmedLoad();
if(trip!=null && Set.of("LOCAL_TRUCK","PICKUP").contains(Objects.toString(trip.tripPurpose(),""))) if(trip!=null && Set.of("LOCAL_TRUCK","PICKUP").contains(Objects.toString(trip.tripPurpose(),"")))
return transport(fact,trip); return transport(fact,trip);
// 纯散板照片发运没有仓内实载,只接受查询取得的不可变照片发运依据。
if(trip!=null && load==null && fact.transportFacts()!=null
&& "PHOTO_LOOSEBOARD".equals(json.readTree(fact.transportFacts()).path("taskType").asText()))
return transport(fact,trip);
if (trip==null || !fact.tripNo().equals(trip.tripNo()) || trip.departedAt()==null || load==null || load.cargo()==null || load.pallets()==null || load.cargo().isEmpty() || load.pallets().isEmpty()) if (trip==null || !fact.tripNo().equals(trip.tripNo()) || trip.departedAt()==null || load==null || load.cargo()==null || load.pallets()==null || load.cargo().isEmpty() || load.pallets().isEmpty())
throw new IllegalStateException("发车历史缺少装载或实际时间,请核对车次"+fact.tripNo()); throw new IllegalStateException("发车历史缺少装载或实际时间,请核对车次"+fact.tripNo());
var externalByPallet=new HashMap<String,Long>(); var money=new TreeMap<String,BigDecimal>(); var bookingNumbers=new HashSet<String>(); var externalByPallet=new HashMap<String,Long>(); var money=new TreeMap<String,BigDecimal>(); var bookingNumbers=new HashSet<String>();
@@ -111,9 +115,11 @@ public class TripOperationsService {
if(!fact.tripNo().equals(trip.tripNo()) || trip.departedAt()==null || trip.stops()==null || trip.stops().size()<2) if(!fact.tripNo().equals(trip.tripNo()) || trip.departedAt()==null || trip.stops()==null || trip.stops().size()<2)
throw new IllegalStateException("运输发车历史缺少行程或实际时间"); throw new IllegalStateException("运输发车历史缺少行程或实际时间");
var facts=fact.transportFacts()==null?null:json.readTree(fact.transportFacts()); var facts=fact.transportFacts()==null?null:json.readTree(fact.transportFacts());
if(facts==null || !trip.tripPurpose().equals(facts.path("taskType").asText()))throw new IllegalStateException("运输出车缺少来源依据"); boolean photo=facts!=null && "PHOTO_LOOSEBOARD".equals(facts.path("taskType").asText());
if(facts==null || (!photo && !Objects.equals(trip.tripPurpose(),facts.path("taskType").asText())))throw new IllegalStateException("运输出车缺少来源依据");
// 箱数统计仅累计已登记实提,未登记不计入,不能用预报补出运量。 // 箱数统计仅累计已登记实提,未登记不计入,不能用预报补出运量。
var packages=facts.get("packages");long quantity=packages==null || packages.isNull()?0:packages.longValue(); var packages=facts.get("packages");long quantity=packages==null || packages.isNull()?0:packages.longValue();
if(photo && (packages==null || !packages.isIntegralNumber() || quantity<=0))throw new IllegalStateException("散板照片发运缺少实际箱数");
if(quantity<0)throw new IllegalStateException("运输箱数不合法"); if(quantity<0)throw new IllegalStateException("运输箱数不合法");
var destinations=new ArrayList<TripOperationsModels.Destination>(); var destinations=new ArrayList<TripOperationsModels.Destination>();
for(int i=1;i<trip.stops().size();i++) { for(int i=1;i<trip.stops().size();i++) {
@@ -0,0 +1,45 @@
package com.baiyue.lastleg.fleet.reporting;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
/** 出车统计的 DATETIME 保存 UTC 本地时间,读写均不使用连接或进程的默认时区。 */
public class TripOperationsUtcInstantTypeHandler extends BaseTypeHandler<Instant> {
/** 查询边界按 UTC 墙钟时间绑定,与发车事实的入库语义一致。 */
@Override
public void setNonNullParameter(PreparedStatement statement, int index, Instant value, JdbcType jdbcType)
throws SQLException {
statement.setObject(index, LocalDateTime.ofInstant(value, ZoneOffset.UTC));
}
/** 按列名读取 UTC 完成时间,保留尚未完成的空值。 */
@Override
public Instant getNullableResult(ResultSet results, String column) throws SQLException {
return toInstant(results.getObject(column, LocalDateTime.class));
}
/** 构造器按列序读取时沿用相同的 UTC 时间语义。 */
@Override
public Instant getNullableResult(ResultSet results, int column) throws SQLException {
return toInstant(results.getObject(column, LocalDateTime.class));
}
/** 存储过程读取同样将 DATETIME 解释为 UTC,不补默认时间。 */
@Override
public Instant getNullableResult(CallableStatement statement, int column) throws SQLException {
return toInstant(statement.getObject(column, LocalDateTime.class));
}
/** 将数据库墙钟值还原为 UTC 时刻。 */
private Instant toInstant(LocalDateTime value) {
return value == null ? null : value.toInstant(ZoneOffset.UTC);
}
}
@@ -39,6 +39,39 @@ public class InboundPlanController {
@Resource private ContainerInboundPlanConversionCoordinator containerConversion; @Resource private ContainerInboundPlanConversionCoordinator containerConversion;
@Resource private InboundFbaAppointmentService appointmentService; @Resource private InboundFbaAppointmentService appointmentService;
@Resource private com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService inboundWorkflow; @Resource private com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService inboundWorkflow;
@Resource private com.baiyue.lastleg.inboundplan.pda.service.InboundReceivingReconciliationService receivingReconciliation;
/** 平台读取同一来源柜次的现场清点与逐票商品归属。 */
@GetMapping("/{planNo}/receiving-reconciliation")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')")
@Operation(summary = "查询收货异常核对", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST)
public ApiResponse<com.baiyue.lastleg.inboundplan.dto.InboundReceivingReconciliation.Snapshot> receivingReconciliation(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo,
@RequestParam String customerId) {
requireReconciliationPlatform(user);
return ApiResponse.success(receivingReconciliation.snapshot(convertScope(user, customerId), planNo));
}
/** 逐托逐商品确认短少、超收或误录,保留原申报和完整核对证据。 */
@PostMapping("/{planNo}/receiving-reconciliation")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')")
@Operation(summary = "确认收货异常", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_EDIT)
public ApiResponse<com.baiyue.lastleg.inboundplan.dto.InboundReceivingReconciliation.Result> confirmReceivingReconciliation(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody com.baiyue.lastleg.inboundplan.dto.InboundReceivingReconciliation.Confirm request) {
requireReconciliationPlatform(user);
String customerId = convertScope(user, request.customerId());
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
return ApiResponse.success(receivingReconciliation.confirm(customerId, planNo, operatorId, idempotencyKey, request));
}
/** 共托核对可能涉及多个客户,仅平台作业域允许读取和确认。 */
private void requireReconciliationPlatform(AuthenticatedUser user) {
if (user == null || user.tenantId() == null || user.tenantId() != 0L || !"PLATFORM".equals(user.appCode())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台账号可核对收货异常");
}
}
/** 平台核对现场短少后按实收入库,不修改原应收数量。 */ /** 平台核对现场短少后按实收入库,不修改原应收数量。 */
@PostMapping("/{planNo}/confirm-shortage") @PostMapping("/{planNo}/confirm-shortage")
@@ -0,0 +1,41 @@
package com.baiyue.lastleg.inboundplan.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.util.List;
/** 平台按同柜收货事实核对异常,申报数量与确认实收分别保留。 */
public final class InboundReceivingReconciliation {
/** 仅承载请求和响应类型。 */
private InboundReceivingReconciliation() { }
public record Snapshot(String planNo, String containerNo, String snapshotHash,
int expectedPackageCount, int countedPackageCount, int attributedPackageCount,
boolean canConfirm, String blockedReason, List<Plan> plans,
List<Pallet> pallets, List<Receipt> receipts) { }
public record Plan(String customerId, String planNo, String customerName, String status,
int expectedPackageCount, int attributedPackageCount) { }
public record Pallet(Long palletId, String palletNo, String groupKey,
int countedPackageCount, int attributedPackageCount, boolean editable) { }
public record Receipt(String customerId, String planNo, Long lineId, String inboundCode,
Long palletId, String palletNo, String skuCode, String productName,
int expectedPackageCount, int receivedPackageCount, boolean editable) { }
public record Confirm(@NotBlank String customerId, @NotBlank String snapshotHash,
@NotBlank @Size(max = 200) String reason,
@NotNull List<@NotNull @Valid Count> pallets,
@NotNull List<@NotNull @Valid Quantity> receipts) { }
public record Count(@NotNull Long palletId,
@NotNull @Min(0) Integer countedPackageCount) { }
public record Quantity(@NotBlank String customerId, @NotBlank String planNo,
@NotNull Long lineId, @NotNull Long palletId, @NotBlank String skuCode,
@NotNull @Min(0) Integer receivedPackageCount) { }
public record Result(String planNo, String status, int confirmedPackageCount,
List<String> reconciledPlanNos) { }
}
@@ -15,7 +15,7 @@ public interface InboundPlanQueryMapper {
String SCHEDULED_DISPATCH = "EXISTS (SELECT 1 FROM ll_dispatch_item di" + String SCHEDULED_DISPATCH = "EXISTS (SELECT 1 FROM ll_dispatch_item di" +
" JOIN ll_dispatch_order dispatch ON dispatch.id=di.dispatch_order_id" + " JOIN ll_dispatch_order dispatch ON dispatch.id=di.dispatch_order_id" +
" WHERE di.customer_id=p.customer_id AND di.order_id=l.order_id" + " WHERE di.customer_id=p.customer_id AND di.order_id=l.order_id" +
" AND dispatch.status&lt;&gt;'CANCELLED')"; " AND dispatch.status&lt;&gt;'CANCELLED' AND COALESCE(dispatch.task_type,'DELIVERY') NOT IN ('PICKUP','LOCAL_TRUCK'))";
String FROM_AND_FILTER = " FROM ll_inbound_plan_line l" + String FROM_AND_FILTER = " FROM ll_inbound_plan_line l" +
" JOIN ll_inbound_plan p ON p.id=l.plan_id" + " JOIN ll_inbound_plan p ON p.id=l.plan_id" +
@@ -0,0 +1,47 @@
package com.baiyue.lastleg.inboundplan.pda.mapper;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingFbaPalletCount;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** 异常核对严格使用来源柜次、柜号与实际接柜仓共同确定作业范围。 */
@Mapper
public interface InboundReceivingReconciliationMapper {
/** 同柜共托需要一起核对,平台确认时按计划主键顺序锁定。 */
@Select("""
<script>
SELECT p.* FROM ll_inbound_plan selected
JOIN ll_order source ON source.id=selected.source_order_id AND source.customer_id=selected.customer_id
JOIN ll_inbound_plan p ON p.container_no=selected.container_no
AND p.overseas_warehouse_id=selected.overseas_warehouse_id
JOIN ll_order o ON o.id=p.source_order_id AND o.customer_id=p.customer_id
AND CAST(o.source_container_booking_id AS BINARY(512))=CAST(source.source_container_booking_id AS BINARY(512))
WHERE selected.customer_id=#{customerId} AND selected.plan_no=#{planNo}
AND NULLIF(TRIM(source.source_container_booking_id),'') IS NOT NULL
ORDER BY p.id <if test='lock'>FOR UPDATE</if>
</script>
""")
@Options(flushCache = Options.FlushCachePolicy.TRUE, useCache = false)
List<InboundPlan> scopePlans(@Param("customerId") String customerId,
@Param("planNo") String planNo, @Param("lock") boolean lock);
/** 完成会话已释放活动键,仍按完整来源身份定位并锁定原会话。 */
@Select("""
<script>
SELECT * FROM ll_receiving_container_session
WHERE CAST(source_container_booking_id AS BINARY(512))=CAST(#{bookingId} AS BINARY(512))
AND container_no=#{containerNo} AND overseas_warehouse_id=#{warehouseId}
ORDER BY created_at DESC,id DESC LIMIT 1 <if test='lock'>FOR UPDATE</if>
</script>
""")
@Options(flushCache = Options.FlushCachePolicy.TRUE, useCache = false)
ReceivingContainerSession session(@Param("bookingId") String bookingId,
@Param("containerNo") String containerNo, @Param("warehouseId") Long warehouseId,
@Param("lock") boolean lock);
/** 现场实盘与货件分配独立读取,不能将申报内分配数当作完整实盘。 */
@Select("SELECT * FROM ll_receiving_fba_pallet_count WHERE session_id=#{sessionId} ORDER BY group_key,pallet_code,id")
List<ReceivingFbaPalletCount> physicalCounts(Long sessionId);
}
@@ -0,0 +1,71 @@
package com.baiyue.lastleg.inboundplan.pda.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.inboundplan.pda.entity.*;
import com.baiyue.lastleg.inboundplan.pda.mapper.*;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Collection;
/** 将异常核对的持久化访问集中到 Mapper,业务确认规则由服务负责。 */
@Repository
public class InboundReceivingReconciliationRepository {
@Resource private InboundReceivingReconciliationMapper queries;
@Resource private ReceivingFbaPalletCountMapper physical;
@Resource private ReceivingSkuReceiptMapper skus;
@Resource private ReceivingContainerSessionMapper sessions;
@Resource private InboundStockReceiptMapper stockReceipts;
@Resource private LastlegOrderMapper orders;
/** 查询或顺序锁定同一来源柜次的全部计划。 */
public List<InboundPlan> plans(String customerId, String planNo, boolean lock) {
return queries.scopePlans(customerId, planNo, lock);
}
/** 精确读取已结束的原收货会话。 */
public ReceivingContainerSession session(String bookingId, String containerNo, Long warehouseId, boolean lock) {
return queries.session(bookingId, containerNo, warehouseId, lock);
}
/** 读取独立保存的现场实盘。 */
public List<ReceivingFbaPalletCount> physicalCounts(Long sessionId) {
return queries.physicalCounts(sessionId);
}
/** 已完成票据采用真实入库凭证还原 SKU,不以空收货行推断零实收。 */
public List<InboundStockReceipt> stockReceipts(String customerId, Long taskId) {
return stockReceipts.listTask(customerId, taskId);
}
/** 异常入库复用原订单快照,业务服务继续复核每条客户归属。 */
public List<LastlegOrder> orders(Collection<Long> orderIds) {
return orders.selectBatchIds(orderIds);
}
/** 查询本票已经明确保存的逐托 SKU 实收。 */
public List<ReceivingSkuReceipt> skuReceipts(String customerId, Long planId) {
return skus.listPlan(customerId, planId);
}
/** 仅替换本客户本计划待核对的 SKU 归属,原操作日记保留完整前后证据。 */
public void replaceSkuReceipts(String customerId, Long planId, List<ReceivingSkuReceipt> confirmed) {
skus.delete(new LambdaQueryWrapper<ReceivingSkuReceipt>()
.eq(ReceivingSkuReceipt::getCustomerId, customerId).eq(ReceivingSkuReceipt::getPlanId, planId));
confirmed.forEach(skus::insert);
}
/** 确认实盘时更新当前清点,历史原始输入保留在收货与核对操作日记。 */
public void savePhysical(ReceivingFbaPalletCount count, boolean existing) {
if (!existing) physical.insert(count);
else if (physical.updateById(count) != 1) throw new IllegalStateException("现场清点记录已变化");
}
/** 会话版本使并发核对和旧页面快照失效。 */
public void updateSession(ReceivingContainerSession session) {
if (sessions.updateById(session) != 1) throw new IllegalStateException("收货会话已变化");
}
}
@@ -0,0 +1,472 @@
package com.baiyue.lastleg.inboundplan.pda.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.inboundplan.dto.InboundReceivingReconciliation;
import com.baiyue.lastleg.inboundplan.dto.InboundReceivingReconciliation.*;
import com.baiyue.lastleg.inboundplan.entity.*;
import com.baiyue.lastleg.inboundplan.pda.entity.*;
import com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper.FrozenSku;
import com.baiyue.lastleg.inboundplan.pda.repository.InboundReceivingReconciliationRepository;
import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
/** 平台显式核对逐托、逐票和逐 SKU 实收,统一处理短少、超收及清点误录。 */
@Service
public class InboundReceivingReconciliationService {
@Resource private InboundReceivingReconciliationRepository reconciliation;
@Resource private PdaWorkflowRepository repository;
@Resource private InboundStockReceiptService stockReceipts;
@Resource private InboundDispatchReceiptGateway dispatchReceipts;
@Resource private ObjectMapper objectMapper;
private static final String ACTION = "CONFIRM_RECEIVING_DIFFERENCE";
/** 展示独立实盘和已归属数量,共托来源在同一快照中逐项核对。 */
@Transactional(readOnly = true)
public Snapshot snapshot(String customerId, String planNo) {
List<InboundPlan> plans = reconciliation.plans(customerId, planNo, false);
return present(planNo, load(customerId, planNo, plans, false));
}
/** 运输父锁先于会话和来源锁;库存、清点更正、状态和审计同事务提交。 */
@Transactional(isolation = Isolation.READ_COMMITTED)
public Result confirm(String customerId, String planNo, String operatorId, String idempotencyKey, Confirm request) {
if (!Objects.equals(customerId, request.customerId()) || operatorId == null || operatorId.isBlank()
|| idempotencyKey == null || idempotencyKey.isBlank() || idempotencyKey.length() > 128
|| request.reason() == null || request.reason().isBlank()) {
throw conflict("异常确认必须提供客户、操作人、确认原因及有效请求键");
}
List<InboundPlan> original = reconciliation.plans(customerId, planNo, false);
requireSelected(original, customerId, planNo);
List<Long> planIds = original.stream().map(InboundPlan::getId).toList();
dispatchReceipts.lock(planIds);
Scope scope = load(customerId, planNo, original, true);
Map<String, Object> command = Map.of("planNo", planNo, "operatorId", operatorId, "request", request);
InboundPdaOperation replay = repository.operation(customerId, idempotencyKey);
if (replay != null) {
if (!ACTION.equals(replay.getAction()) || !hash(command).equals(replay.getRequestHash())) {
throw conflict("请求键已用于其他异常确认,请刷新后重新操作");
}
return read(replay.getResultJson(), Result.class);
}
Snapshot before = present(planNo, scope);
if (!before.canConfirm()) throw conflict(before.blockedReason());
if (!before.snapshotHash().equals(request.snapshotHash())) throw conflict("收货记录已变化,请刷新后重新核对");
validate(before, request);
String sourceFacts = write(scope);
LocalDateTime now = LocalDateTime.now();
applyPhysical(scope, before, request, now);
for (PlanState state : scope.states()) {
if (pending(state.plan())) applyReceipts(scope, state, request, now);
}
for (Pallet row : before.pallets()) {
if (!row.editable()) continue;
InboundPlanPallet pallet = scope.pallets().get(row.palletId());
int count = request.pallets().stream().filter(value -> value.palletId().equals(row.palletId()))
.findFirst().orElseThrow().countedPackageCount();
pallet.setActualPackageCount(count);
if (count > 0 && row.groupKey().startsWith("FBA:")) pallet.setAssignmentStatus("ASSIGNED");
BigDecimal actualVolume = scope.states().stream().flatMap(state -> repository.allocations(state.plan().getId()).stream())
.filter(allocation -> row.palletId().equals(allocation.getPalletId()))
.map(allocation -> Objects.requireNonNullElse(allocation.getActualVolumeCbm(), BigDecimal.ZERO))
.reduce(BigDecimal.ZERO, BigDecimal::add).setScale(4, RoundingMode.HALF_UP);
pallet.setActualVolumeCbm(actualVolume);
repository.update(pallet);
}
List<String> completed = new ArrayList<>();
for (PlanState state : scope.states()) {
if (!pending(state.plan())) continue;
boolean shortage = hasShortage(state, request.receipts());
stockReceipts.stockInReconciled(state.plan().getCustomerId(), state.plan(), state.task(),
state.lines(), repository.allocations(state.plan().getId()),
new ArrayList<>(scope.pallets().values()), state.task().getCompletedAt());
state.task().setStatus(shortage ? "PARTIAL_INBOUND" : "COMPLETED");
state.task().setUpdatedAt(now);
state.task().setVersion(value(state.task().getVersion()) + 1);
repository.update(state.task());
state.plan().setStatus(state.task().getStatus());
state.plan().setUpdatedAt(now);
repository.update(state.plan());
completed.add(state.plan().getPlanNo());
}
dispatchReceipts.complete(scope.states().stream().filter(state -> completed.contains(state.plan().getPlanNo()))
.map(state -> state.plan().getId()).toList(), operatorId, now);
scope.session().setUpdatedAt(now);
reconciliation.updateSession(scope.session());
InboundPlan selected = requireSelected(scope.states().stream().map(PlanState::plan).toList(), customerId, planNo);
int confirmed = request.receipts().stream().filter(row -> planNo.equals(row.planNo()))
.mapToInt(Quantity::receivedPackageCount).sum();
Result result = new Result(planNo, selected.getStatus(), confirmed, List.copyOf(completed));
InboundPdaOperation audit = new InboundPdaOperation();
audit.setId(IdWorker.getId()); audit.setCustomerId(customerId);
audit.setTaskId(scope.states().stream().filter(state -> state.plan().getId().equals(selected.getId()))
.findFirst().orElseThrow().task().getId());
audit.setAction(ACTION); audit.setIdempotencyKey(idempotencyKey); audit.setRequestHash(hash(command));
audit.setRequestJson(write(Map.of("command", command, "before", before, "sourceFacts", sourceFacts)));
audit.setResultJson(write(result)); audit.setCreatedAt(now); repository.insert(audit);
return result;
}
/** 先锁原会话,再按主键锁齐计划、任务和托盘来源,阻断并发重新分配。 */
private Scope load(String customerId, String planNo, List<InboundPlan> original, boolean lock) {
InboundPlan selected = requireSelected(original, customerId, planNo);
LastlegOrder source = repository.order(selected.getSourceOrderId());
if (source == null || source.getSourceContainerBookingId() == null) throw conflict("来源柜次信息缺失");
ReceivingContainerSession session = reconciliation.session(source.getSourceContainerBookingId(),
selected.getContainerNo(), selected.getOverseasWarehouseId(), lock);
List<InboundPlan> plans = lock ? reconciliation.plans(customerId, planNo, true) : original;
if (!plans.stream().map(InboundPlan::getId).toList().equals(original.stream().map(InboundPlan::getId).toList())) {
throw conflict("本柜入库计划已变化,请刷新后重新核对");
}
List<PlanState> states = new ArrayList<>();
Map<Long, InboundPlanPallet> pallets = new LinkedHashMap<>();
for (InboundPlan plan : plans) {
InboundPdaTask task = lock ? repository.lockTask(plan.getCustomerId(), plan.getId())
: repository.task(plan.getCustomerId(), plan.getId());
List<InboundPlanLine> lines = repository.lines(plan.getId()).stream()
.sorted(Comparator.comparing(InboundPlanLine::getId)).toList();
List<InboundPlanPalletAllocation> allocations = repository.allocations(plan.getId()).stream()
.sorted(Comparator.comparing(InboundPlanPalletAllocation::getId)).toList();
if (lock) allocations = allocations.stream().map(row -> repository.lockAllocation(plan.getId(), row.getId())).toList();
for (InboundPlanPallet pallet : repository.pallets(plan.getId()).stream()
.sorted(Comparator.comparing(InboundPlanPallet::getId)).toList()) {
pallets.put(pallet.getId(), lock ? repository.lockPallet(plan.getId(), pallet.getId()) : pallet);
}
Map<Long, List<FrozenSku>> frozen = new LinkedHashMap<>();
for (InboundPlanLine line : lines) frozen.put(line.getId(), repository
.frozenSkus(plan.getCustomerId(), plan.getId(), line.getId()).stream()
.sorted(Comparator.comparing(row -> normalize(row.skuCode()))).toList());
LastlegOrder order = repository.order(plan.getSourceOrderId());
List<ReceivingSkuReceipt> skus = readReceipts(plan, task, allocations);
if (!pending(plan) && order != null && "ONE_CLICK_DROPSHIP".equals(order.getOrderType())) {
for (InboundPlanLine line : lines) frozen.put(line.getId(), skus.stream()
.filter(row -> line.getId().equals(row.getLineId())).collect(Collectors.groupingBy(
row -> normalize(row.getSkuCode()), TreeMap::new, Collectors.summingLong(row -> value(row.getReceivedPackages()))))
.entrySet().stream().map(entry -> new FrozenSku(entry.getKey(), entry.getValue())).toList());
}
states.add(new PlanState(plan, order, task, lines, allocations, skus, frozen));
}
List<ReceivingFbaPalletCount> physical = session == null ? List.of() : reconciliation.physicalCounts(session.getId());
return new Scope(session, states, pallets, physical);
}
/** 单票单 SKU 的归属唯一时展示完整实盘,其余组合保留分配值供人工逐项核实。 */
private Snapshot present(String planNo, Scope scope) {
List<Plan> plans = scope.states().stream().map(state -> new Plan(state.plan().getCustomerId(),
state.plan().getPlanNo(), state.order() == null ? null : state.order().getCustomerName(), state.plan().getStatus(),
state.lines().stream().mapToInt(line -> value(line.getRequiredPackageCount())).sum(),
state.allocations().stream().mapToInt(row -> value(row.getPickedPackageCount())).sum())).toList();
List<Pallet> pallets = new ArrayList<>();
List<Receipt> receipts = new ArrayList<>();
for (InboundPlanPallet pallet : scope.pallets().values()) {
String groupKey = groupKey(scope, pallet);
List<PlanState> members = scope.states().stream().filter(state -> state.lines().stream()
.anyMatch(line -> groupKey(scope, line, state.order()).equals(groupKey))).toList();
int attributed = scope.states().stream().flatMap(state -> state.allocations().stream())
.filter(row -> pallet.getId().equals(row.getPalletId()))
.mapToInt(row -> value(row.getPickedPackageCount())).sum();
ReceivingFbaPalletCount physical = scope.physical().stream()
.filter(row -> groupKey.equals(row.getGroupKey()) && Objects.equals(pallet.getPalletNo(), row.getPalletCode()))
.findFirst().orElse(null);
int counted = physical == null ? attributed : value(physical.getActualPackages());
boolean editable = groupKey.startsWith("FBA:") && members.stream().anyMatch(state -> pending(state.plan()));
pallets.add(new Pallet(pallet.getId(), pallet.getPalletNo(), groupKey, counted, attributed, editable));
int identities = members.stream().mapToInt(state -> state.lines().stream()
.filter(line -> groupKey.equals(groupKey(scope, line, state.order())))
.mapToInt(line -> state.frozen().get(line.getId()).size()).sum()).sum();
for (PlanState state : members) for (InboundPlanLine line : state.lines()) {
if (!groupKey.equals(groupKey(scope, line, state.order()))) continue;
for (FrozenSku sku : state.frozen().get(line.getId())) {
String code = normalize(sku.skuCode());
int allocated = state.skus().stream().filter(row -> line.getId().equals(row.getLineId())
&& pallet.getId().equals(row.getPalletId()) && code.equals(normalize(row.getSkuCode())))
.mapToInt(row -> value(row.getReceivedPackages())).sum();
boolean receiptEditable = pending(state.plan()) && groupKey.startsWith("FBA:");
int received = identities == 1 && receiptEditable ? counted : allocated;
String product = state.skus().stream().filter(row -> code.equals(normalize(row.getSkuCode())))
.map(ReceivingSkuReceipt::getProductName).filter(Objects::nonNull).findFirst().orElse(null);
receipts.add(new Receipt(state.plan().getCustomerId(), state.plan().getPlanNo(), line.getId(),
line.getInboundCode(), pallet.getId(), pallet.getPalletNo(), code, product,
Math.toIntExact(sku.packages()), received, receiptEditable));
}
}
}
String blocked = blocker(scope, pallets, receipts);
return new Snapshot(planNo, scope.states().get(0).plan().getContainerNo(), hash(scope),
plans.stream().mapToInt(Plan::expectedPackageCount).sum(),
pallets.stream().mapToInt(Pallet::countedPackageCount).sum(),
plans.stream().mapToInt(Plan::attributedPackageCount).sum(), blocked == null, blocked,
plans, pallets, receipts);
}
/** 证据缺失明确提示,避免把未知清点、未知 SKU 或其他作业来源默认为零。 */
private String blocker(Scope scope, List<Pallet> pallets, List<Receipt> receipts) {
if (scope.session() == null || scope.session().getCompletedAt() == null
|| !List.of("COMPLETED", "COMPLETED_WITH_DIFFERENCE").contains(scope.session().getStatus())) {
return "本柜现场收货尚未结束,请先完成现场收货";
}
if (scope.states().stream().noneMatch(state -> pending(state.plan()))) return "本柜收货异常已经核对完成";
for (PlanState state : scope.states()) {
if (!List.of("PENDING_RECONCILIATION", "PARTIAL_INBOUND", "COMPLETED").contains(state.plan().getStatus())) {
return "本柜仍有未结束的入库作业,请先完成现场收货";
}
if (!pending(state.plan())) continue;
if (state.order() != null && "ONE_CLICK_DROPSHIP".equals(state.order().getOrderType())) {
return "一件代发须按冻结来源整托核对,请先核实原托盘及实物一致性";
}
if (state.task() == null || !"PENDING_RECONCILIATION".equals(state.task().getStatus())
|| state.task().getCompletedAt() == null || state.task().getUnloadPhotoUploadedAt() == null
|| state.task().getUnloadPhotoFileIds() == null || "[]".equals(state.task().getUnloadPhotoFileIds())) {
return "待核对入库单缺少现场结束或卸柜凭证";
}
if (state.lines().isEmpty() || state.frozen().values().stream().anyMatch(rows -> rows.isEmpty()
|| rows.stream().anyMatch(row -> normalize(row.skuCode()).isEmpty() || row.packages() < 0))) {
return "冻结商品资料缺失,请先核实每票货物的 SKU 归属";
}
if (state.lines().stream().anyMatch(line -> value(line.getRequiredPackageCount()) <= 0)) {
return "原应收箱数缺失,请先核实来源入库资料";
}
if (state.lines().stream().anyMatch(line -> state.frozen().get(line.getId()).stream()
.mapToLong(FrozenSku::packages).sum() != value(line.getRequiredPackageCount()))) {
return "冻结 SKU 箱数与原应收不一致,请先核实来源商品资料";
}
}
for (ReceivingFbaPalletCount physical : scope.physical()) {
if (pallets.stream().noneMatch(pallet -> Objects.equals(pallet.palletNo(), physical.getPalletCode())
&& Objects.equals(pallet.groupKey(), physical.getGroupKey()))) {
return "存在未关联计划托盘的现场清点记录,请先核实托盘归属";
}
}
if (pallets.isEmpty() || receipts.isEmpty()) return "收货托盘或商品明细缺失,请先核实来源";
return null;
}
/** 请求必须覆盖当前全部明细,每托数量守恒,已完成票据保持原库存事实。 */
private void validate(Snapshot before, Confirm request) {
Map<Long, Count> counts = new LinkedHashMap<>();
for (Count row : request.pallets()) {
if (row.palletId() == null || row.countedPackageCount() == null || row.countedPackageCount() < 0
|| counts.put(row.palletId(), row) != null) {
throw conflict("逐托清点数量必须为 0 至 66 的整数且不能重复");
}
}
Map<String, Quantity> quantities = new LinkedHashMap<>();
for (Quantity row : request.receipts()) {
if (row.receivedPackageCount() == null || row.receivedPackageCount() < 0
|| quantities.put(key(row.customerId(), row.planNo(), row.lineId(), row.palletId(), row.skuCode()), row) != null) {
throw conflict("逐票 SKU 数量必须为 0 至 66 的整数且不能重复");
}
}
if (counts.size() != before.pallets().size() || quantities.size() != before.receipts().size()) {
throw conflict("核对明细不完整,请刷新后逐项核对");
}
for (Receipt row : before.receipts()) {
Quantity submitted = quantities.get(key(row.customerId(), row.planNo(), row.lineId(), row.palletId(), row.skuCode()));
if (submitted == null || (row.editable() && submitted.receivedPackageCount() > 66)
|| (!row.editable() && submitted.receivedPackageCount() != row.receivedPackageCount())) {
throw conflict("收货归属已变化或已入库票据被修改,请刷新后重新核对");
}
}
for (Pallet pallet : before.pallets()) {
Count count = counts.get(pallet.palletId());
if (count == null || (pallet.editable() && count.countedPackageCount() > 66)
|| (!pallet.editable() && count.countedPackageCount() != pallet.countedPackageCount())) {
throw conflict("已完成的托盘清点不能在本次异常确认中修改");
}
int total = request.receipts().stream().filter(row -> pallet.palletId().equals(row.palletId()))
.mapToInt(Quantity::receivedPackageCount).sum();
if (total != count.countedPackageCount()) throw conflict("托盘 " + pallet.palletNo() + " 的 SKU 归属合计必须等于核实清点箱数");
}
}
/** 现场原始输入已有收货日记,本次审计另保留更新前快照和明确更正原因。 */
private void applyPhysical(Scope scope, Snapshot before, Confirm request, LocalDateTime now) {
for (Pallet pallet : before.pallets()) {
if (!pallet.editable() || !pallet.groupKey().startsWith("FBA:")) continue;
int count = request.pallets().stream().filter(row -> row.palletId().equals(pallet.palletId()))
.findFirst().orElseThrow().countedPackageCount();
ReceivingFbaPalletCount physical = scope.physical().stream().filter(row -> row.getGroupKey().equals(pallet.groupKey())
&& row.getPalletCode().equals(pallet.palletNo())).findFirst().orElse(null);
boolean existing = physical != null;
if (!existing) {
physical = new ReceivingFbaPalletCount(); physical.setId(IdWorker.getId());
physical.setSessionId(scope.session().getId()); physical.setGroupKey(pallet.groupKey());
physical.setPalletCode(pallet.palletNo()); physical.setCreatedAt(now);
}
physical.setActualPackages(count); physical.setUpdatedAt(now);
reconciliation.savePhysical(physical, existing);
}
}
/** 将已明确确认的逐 SKU 事实和逐托合计同时写入,原申报分配保持可追溯。 */
private void applyReceipts(Scope scope, PlanState state, Confirm request, LocalDateTime now) {
List<Quantity> rows = request.receipts().stream().filter(row -> row.planNo().equals(state.plan().getPlanNo())
&& row.customerId().equals(state.plan().getCustomerId())).toList();
List<ReceivingSkuReceipt> confirmed = new ArrayList<>();
for (InboundPlanLine line : state.lines()) for (InboundPlanPallet pallet : scope.pallets().values()) {
List<Quantity> linePallet = rows.stream().filter(row -> row.lineId().equals(line.getId())
&& row.palletId().equals(pallet.getId())).toList();
if (linePallet.isEmpty()) continue;
int packages = linePallet.stream().mapToInt(Quantity::receivedPackageCount).sum();
if (packages > 0 && (pallet.getWarehousePalletId() == null || pallet.getWarehouseLocationId() == null
|| pallet.getWarehouseResourceLockId() == null)) throw conflict("托盘 " + pallet.getPalletNo() + " 缺少实际仓储绑定");
InboundPlanPalletAllocation allocation = state.allocations().stream()
.filter(row -> line.getId().equals(row.getLineId()) && pallet.getId().equals(row.getPalletId())).findFirst().orElse(null);
if (allocation == null && packages == 0 && !state.plan().getId().equals(pallet.getPlanId())) continue;
if (allocation == null) {
allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId());
allocation.setCustomerId(state.plan().getCustomerId()); allocation.setPlanId(state.plan().getId());
allocation.setLineId(line.getId()); allocation.setPalletId(pallet.getId()); allocation.setPlannedPackageCount(0);
allocation.setAllocatedVolumeCbm(BigDecimal.ZERO); allocation.setActualVolumeCbm(BigDecimal.ZERO);
allocation.setCountedPackageCount(0); allocation.setPickedPackageCount(0); allocation.setStatus("PICKED");
allocation.setVersion(0); repository.insert(allocation);
}
allocation.setCountedPackageCount(packages); allocation.setPickedPackageCount(packages);
allocation.setActualVolumeCbm(Objects.requireNonNullElse(line.getVolumeCbm(), BigDecimal.ZERO)
.divide(BigDecimal.valueOf(line.getRequiredPackageCount()), 8, RoundingMode.HALF_UP)
.multiply(BigDecimal.valueOf(packages)).setScale(4, RoundingMode.HALF_UP));
allocation.setStatus("PICKED"); allocation.setVersion(value(allocation.getVersion()) + 1); repository.update(allocation);
for (Quantity row : linePallet) {
if (row.receivedPackageCount() == 0) continue;
ReceivingSkuReceipt sku = new ReceivingSkuReceipt(); sku.setId(IdWorker.getId());
sku.setCustomerId(state.plan().getCustomerId()); sku.setSessionId(scope.session().getId());
sku.setPlanId(state.plan().getId()); sku.setLineId(line.getId()); sku.setPalletId(pallet.getId());
sku.setOrderId(line.getOrderId()); sku.setInboundCode(line.getInboundCode()); sku.setSkuCode(normalize(row.skuCode()));
sku.setProductName(state.skus().stream().filter(old -> normalize(old.getSkuCode()).equals(sku.getSkuCode()))
.map(ReceivingSkuReceipt::getProductName).filter(Objects::nonNull).findFirst().orElse(null));
sku.setReceivedPackages(row.receivedPackageCount()); sku.setCreatedAt(state.task().getCompletedAt());
sku.setUpdatedAt(now); sku.setVersion(0); confirmed.add(sku);
}
}
reconciliation.replaceSkuReceipts(state.plan().getCustomerId(), state.plan().getId(), confirmed);
for (InboundPlanCarton carton : repository.cartons(state.plan().getId())) {
if ("ON_PALLET".equals(carton.getStatus())) {
carton.setStatus("AVAILABLE"); carton.setReceivedAt(state.task().getCompletedAt());
carton.setUpdatedAt(now); carton.setVersion(value(carton.getVersion()) + 1); repository.update(carton);
}
}
}
/** 后台核对复用已入库凭证和逐箱扫描身份,不按申报顺序猜测普通地址货物。 */
private List<ReceivingSkuReceipt> readReceipts(InboundPlan plan, InboundPdaTask task,
List<InboundPlanPalletAllocation> allocations) {
List<ReceivingSkuReceipt> rows = repository.skuReceipts(plan.getCustomerId(), plan.getId());
if (!rows.isEmpty()) return rows;
List<ReceivingSkuReceipt> restored = new ArrayList<>();
if (!pending(plan) && task != null) {
for (InboundStockReceipt stock : reconciliation.stockReceipts(plan.getCustomerId(), task.getId())) {
InboundPlanPalletAllocation allocation = allocations.stream()
.filter(row -> Objects.equals(row.getId(), stock.getAllocationId())).findFirst().orElse(null);
if (allocation == null) throw conflict("已入库凭证缺少原托盘来源,请核实库存事实");
ReceivingSkuReceipt row = new ReceivingSkuReceipt(); row.setLineId(stock.getLineId());
row.setPalletId(allocation.getPalletId()); row.setSkuCode(stock.getSkuCode());
row.setProductName(stock.getProductNameZh()); row.setReceivedPackages(Math.toIntExact(stock.getReceivedPackages()));
restored.add(row);
}
} else {
for (InboundPlanCarton carton : repository.cartons(plan.getId())) {
if (!"ON_PALLET".equals(carton.getStatus())) continue;
ReceivingSkuReceipt row = new ReceivingSkuReceipt(); row.setLineId(carton.getLineId());
row.setPalletId(carton.getPlanPalletId()); row.setSkuCode(carton.getSkuCode()); row.setReceivedPackages(1);
restored.add(row);
}
}
return restored;
}
/** SKU 短少单独判定,不能被另一商品超收抵消。 */
private boolean hasShortage(PlanState state, List<Quantity> rows) {
for (InboundPlanLine line : state.lines()) for (FrozenSku sku : state.frozen().get(line.getId())) {
long actual = rows.stream().filter(row -> row.planNo().equals(state.plan().getPlanNo())
&& row.customerId().equals(state.plan().getCustomerId()) && row.lineId().equals(line.getId())
&& normalize(row.skuCode()).equals(normalize(sku.skuCode())))
.mapToLong(Quantity::receivedPackageCount).sum();
if (actual < sku.packages()) return true;
}
return false;
}
/** 托盘所属目的组来自原计划,实物记录不能跨目的仓或来源柜次归属。 */
private String groupKey(Scope scope, InboundPlanPallet pallet) {
PlanState owner = scope.states().stream().filter(state -> state.plan().getId().equals(pallet.getPlanId())).findFirst().orElseThrow();
if (owner.order() != null && "ONE_CLICK_DROPSHIP".equals(owner.order().getOrderType())) {
return "DROPSHIP:" + owner.plan().getContainerNo() + ":" + owner.plan().getPlanNo();
}
InboundPlanLine line = owner.lines().stream().filter(row -> owner.allocations().stream()
.anyMatch(a -> a.getLineId().equals(row.getId()) && a.getPalletId().equals(pallet.getId()))).findFirst().orElse(null);
if (line != null) return groupKey(scope, line, owner.order());
String destination = normalize(pallet.getDestinationWarehouseCode());
return ("FBA".equals(normalize(pallet.getDestinationType())) ? "FBA:" : "OVERSEAS:")
+ owner.plan().getContainerNo() + ":" + destination;
}
/** 普通 FBA 共用目的仓组,私人地址按原目的身份保留独立归属。 */
private String groupKey(Scope scope, InboundPlanLine line, LastlegOrder order) {
if (order != null && "ONE_CLICK_DROPSHIP".equals(order.getOrderType())) {
String planNo = scope.states().stream().filter(state -> state.lines().contains(line))
.findFirst().orElseThrow().plan().getPlanNo();
return "DROPSHIP:" + scope.states().get(0).plan().getContainerNo() + ":" + planNo;
}
String type = normalize(line.getDestinationType());
if (type.isEmpty() && order != null) type = normalize(order.getDestinationType());
if (type.isEmpty()) type = normalize(line.getFbaWarehouseCode()).isEmpty() ? "OVERSEAS" : "FBA";
String destination = "FBA".equals(type) ? normalize(line.getFbaWarehouseCode())
: normalize(Objects.toString(line.getDestinationWarehouseId(), line.getDestinationWarehouseCode()));
return type + ":" + scope.states().get(0).plan().getContainerNo() + ":" + destination;
}
/** 先校验请求客户与计划,再允许展开该计划所属的同柜范围。 */
private InboundPlan requireSelected(List<InboundPlan> plans, String customerId, String planNo) {
return plans.stream().filter(plan -> customerId.equals(plan.getCustomerId()) && planNo.equals(plan.getPlanNo()))
.findFirst().orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "入库单不存在或来源柜次信息缺失"));
}
/** 仅待核对票据可以更新收货归属。 */
private static boolean pending(InboundPlan plan) { return "PENDING_RECONCILIATION".equals(plan.getStatus()); }
/** 空计数用于读取汇总,提交数量必须另行显式校验。 */
private static int value(Integer value) { return value == null ? 0 : value; }
/** SKU 和目的仓采用与现场分组一致的标准化形式。 */
private static String normalize(String value) { return value == null ? "" : value.trim().toUpperCase(Locale.ROOT); }
/** 同时限定客户、票、明细、托盘和 SKU,防止跨来源替换。 */
private static String key(String customer, String plan, Long line, Long pallet, String sku) {
return customer + "/" + plan + "/" + line + "/" + pallet + "/" + normalize(sku);
}
/** 核对条件变化作为业务冲突返回,事务整体撤回。 */
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
/** 全部版本及现场数量参与摘要,过期页面不能覆盖新的事实。 */
private String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(value).getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); }
}
/** 持久化原始事实、操作原因和回执,保证可追溯。 */
private String write(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException exception) { throw new IllegalStateException("异常核对证据序列化失败", exception); }
}
/** 同键重放直接读取原回执,避免再次记库存。 */
private <T> T read(String json, Class<T> type) {
try { return objectMapper.readValue(json, type); }
catch (JsonProcessingException exception) { throw new IllegalStateException("异常核对回执读取失败", exception); }
}
private record PlanState(InboundPlan plan, LastlegOrder order, InboundPdaTask task,
List<InboundPlanLine> lines, List<InboundPlanPalletAllocation> allocations,
List<ReceivingSkuReceipt> skus, Map<Long, List<FrozenSku>> frozen) { }
private record Scope(ReceivingContainerSession session, List<PlanState> states,
Map<Long, InboundPlanPallet> pallets, List<ReceivingFbaPalletCount> physical) { }
}
@@ -48,6 +48,7 @@ public class InboundStockReceiptService {
@Resource private InboundStockReceiptMapper receipts; @Resource private InboundStockReceiptMapper receipts;
@Resource private ReceivingSkuReceiptMapper receivingSkuReceipts; @Resource private ReceivingSkuReceiptMapper receivingSkuReceipts;
@Resource private PdaWorkflowRepository workflowRepository; @Resource private PdaWorkflowRepository workflowRepository;
@Resource private com.baiyue.lastleg.inboundplan.pda.repository.InboundReceivingReconciliationRepository reconciliationRepository;
@Resource private InventoryCommandService inventory; @Resource private InventoryCommandService inventory;
@Resource private WarehouseResourceGateway warehouseGateway; @Resource private WarehouseResourceGateway warehouseGateway;
@Resource private DropshipOrderService dropshipOrders; @Resource private DropshipOrderService dropshipOrders;
@@ -61,6 +62,99 @@ public class InboundStockReceiptService {
stockIn(customerId, plan, task, lines, allocations, pallets, receivedAt, false); stockIn(customerId, plan, task, lines, allocations, pallets, receivedAt, false);
} }
/** 平台核对异常后按明确的逐托、逐 SKU 实收入库,保留原申报和现场收货时间。 */
@Transactional
public void stockInReconciled(String customerId, InboundPlan plan, InboundPdaTask task,
List<InboundPlanLine> lines, List<InboundPlanPalletAllocation> allocations,
List<InboundPlanPallet> pallets, LocalDateTime receivedAt) {
if (lines.isEmpty() || receivedAt == null) throw conflict("异常收货必须保留入库明细和现场收货时间");
Map<Long, LastlegOrder> orderById = reconciliationRepository.orders(lines.stream()
.map(InboundPlanLine::getOrderId).collect(Collectors.toSet())).stream()
.filter(order -> customerId.equals(order.getCustomerId()))
.collect(Collectors.toMap(LastlegOrder::getId, order -> order));
Map<Long, InboundPlanPallet> palletById = pallets.stream()
.collect(Collectors.toMap(InboundPlanPallet::getId, pallet -> pallet));
Map<Long, InboundPlanPalletAllocation> allocationById = allocations.stream()
.collect(Collectors.toMap(InboundPlanPalletAllocation::getId, allocation -> allocation));
Map<Long, List<ReceivingSkuReceipt>> exactByLine = reconciliationRepository.skuReceipts(customerId, plan.getId())
.stream().collect(Collectors.groupingBy(ReceivingSkuReceipt::getLineId));
Set<Long> lineIds = lines.stream().map(InboundPlanLine::getId).collect(Collectors.toSet());
if (!lineIds.containsAll(exactByLine.keySet()) || allocations.stream()
.anyMatch(allocation -> !lineIds.contains(allocation.getLineId())
|| allocation.getPickedPackageCount() == null || allocation.getCountedPackageCount() == null
|| value(allocation.getPickedPackageCount()) < 0
|| !Objects.equals(allocation.getPickedPackageCount(), allocation.getCountedPackageCount()))) {
throw conflict("异常收货的逐票、清点和收货数量不一致");
}
List<ReconciledReceipt> confirmed = new java.util.ArrayList<>();
Map<ReceiptKey, Long> expectedReceipts = new java.util.HashMap<>();
Map<Long, Boolean> shortages = new java.util.HashMap<>();
for (InboundPlanLine line : lines) {
LastlegOrder order = orderById.get(line.getOrderId());
if (order == null || "ONE_CLICK_DROPSHIP".equals(order.getOrderType())) {
throw conflict("异常收货只支持归属明确的普通入库订单");
}
Map<String, SkuRemainder> frozenSkus = skuRemainders(order.getId(), value(line.getRequiredPackageCount()))
.stream().collect(Collectors.toMap(sku -> sku.skuCode.toUpperCase(java.util.Locale.ROOT),
sku -> sku, (first, next) -> {
first.remaining = Math.addExact(first.remaining, next.remaining);
return first;
}));
Map<Long, InboundPlanPalletAllocation> lineAllocations = allocations.stream()
.filter(allocation -> Objects.equals(allocation.getLineId(), line.getId()))
.collect(Collectors.toMap(InboundPlanPalletAllocation::getPalletId, allocation -> allocation));
Map<Long, Long> packagesByPallet = new java.util.HashMap<>();
Map<String, Long> packagesBySku = new java.util.HashMap<>();
for (ReceivingSkuReceipt exact : exactByLine.getOrDefault(line.getId(), List.of())) {
String skuCode = required(exact.getSkuCode(), "异常收货必须明确实收 SKU").toUpperCase(java.util.Locale.ROOT);
SkuRemainder frozen = frozenSkus.get(skuCode);
InboundPlanPalletAllocation allocation = lineAllocations.get(exact.getPalletId());
long packages = value(exact.getReceivedPackages());
if (frozen == null || allocation == null || packages <= 0) {
throw conflict("异常收货必须使用原申报 SKU、真实托盘和正数箱数");
}
ReceiptKey key = new ReceiptKey(allocation.getId(), skuCode);
if (expectedReceipts.putIfAbsent(key, packages) != null) throw conflict("同托同 SKU 的实收记录重复");
packagesByPallet.merge(exact.getPalletId(), packages, Math::addExact);
packagesBySku.merge(skuCode, packages, Math::addExact);
confirmed.add(new ReconciledReceipt(line, allocation, requirePallet(palletById, allocation), order,
new SkuSnapshot(frozen.skuCode, frozen.productNameZh, frozen.productNameEn, packages)));
}
if (lineAllocations.values().stream().anyMatch(allocation ->
value(allocation.getPickedPackageCount()) != packagesByPallet.getOrDefault(allocation.getPalletId(), 0L))) {
throw conflict("异常收货的逐托 SKU 箱数必须等于该托确认实收箱数");
}
boolean shortage = frozenSkus.entrySet().stream()
.anyMatch(entry -> packagesBySku.getOrDefault(entry.getKey(), 0L) < entry.getValue().remaining);
shortages.merge(order.getId(), shortage, (previous, current) -> previous || current);
}
// 重试只能重放同一份逐托 SKU 凭证,已入账数量发生变化时禁止二次入账或静默跳过。
List<InboundStockReceipt> existing = reconciliationRepository.stockReceipts(customerId, task.getId());
Map<ReceiptKey, Long> existingReceipts = new java.util.HashMap<>();
for (InboundStockReceipt receipt : existing) {
InboundPlanPalletAllocation allocation = allocationById.get(receipt.getAllocationId());
ReceiptKey key = new ReceiptKey(receipt.getAllocationId(),
required(receipt.getSkuCode(), "原库存凭证缺少 SKU").toUpperCase(java.util.Locale.ROOT));
if (allocation == null || !Objects.equals(receipt.getLineId(), allocation.getLineId())
|| existingReceipts.putIfAbsent(key, receipt.getReceivedPackages()) != null) {
throw conflict("原库存凭证与确认实收归属不一致");
}
}
if (!existing.isEmpty() && !existingReceipts.equals(expectedReceipts)) {
throw conflict("原库存凭证与确认实收数量不一致,不能重复入账");
}
if (existing.isEmpty()) {
for (ReconciledReceipt receipt : confirmed) {
persist(customerId, plan, task, receipt.line(), receipt.allocation(), receipt.pallet(), receipt.order(),
receipt.sku(), receivedAt);
}
}
// 零实收不产生零数量库存或计租凭证;逐 SKU 短少独立决定每票完成状态。
for (LastlegOrder order : orderById.values()) {
completeOrders(customerId, plan, Map.of(order.getId(), order), receivedAt, shortages.get(order.getId()));
}
}
/** 按逐 SKU 实收生成凭证;普通海外仓货物以已扫描箱码和实托归属为准。 */ /** 按逐 SKU 实收生成凭证;普通海外仓货物以已扫描箱码和实托归属为准。 */
@Transactional @Transactional
public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task, public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task,
@@ -435,5 +529,8 @@ public class InboundStockReceiptService {
} }
} }
private record ReceiptKey(Long allocationId, String skuCode) { }
private record ReconciledReceipt(InboundPlanLine line, InboundPlanPalletAllocation allocation,
InboundPlanPallet pallet, LastlegOrder order, SkuSnapshot sku) { }
private record SkuSnapshot(String skuCode, String productNameZh, String productNameEn, long packages) {} private record SkuSnapshot(String skuCode, String productNameZh, String productNameEn, long packages) {}
} }
@@ -44,11 +44,12 @@ public class MerchantLocalLooseboardController {
return ApiResponse.success(service.page(customer(user), query)); return ApiResponse.success(service.page(customer(user), query));
} }
/** 客户详情使用与列表一致的运输进度。 */
@GetMapping("/{orderNo}") @GetMapping("/{orderNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_LOOSEBOARD_LIST + "')") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_LOOSEBOARD_LIST + "')")
public ApiResponse<LocalLooseboardResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<LocalLooseboardResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String orderNo) { @PathVariable String orderNo) {
return ApiResponse.success(service.detail(customer(user), orderNo)); return ApiResponse.success(service.merchantDetail(customer(user), orderNo));
} }
@PutMapping("/{orderNo}") @PutMapping("/{orderNo}")
@@ -22,7 +22,8 @@ public final class LocalLooseboardResponses {
String operationType, String fromStatus, String toStatus, String operatorId, String operationType, String fromStatus, String toStatus, String operatorId,
String idempotencyKey, String changeDetail, String remark, LocalDateTime createdAt) { } String idempotencyKey, String changeDetail, String remark, LocalDateTime createdAt) { }
/** 详情独立返回全部有效排车,同车多托合并为一个单号。 */
public record Detail( public record Detail(
Summary summary, String originAddress, String destinationAddress, String remark, Summary summary, String originAddress, String destinationAddress, String remark,
String createdBy, List<History> histories) { } String createdBy, List<History> histories, List<String> dispatchNos) { }
} }
@@ -8,6 +8,42 @@ import org.apache.ibatis.annotations.Select;
@Mapper @Mapper
public interface LocalLooseboardOrderMapper extends BaseMapper<LocalLooseboardOrder> { public interface LocalLooseboardOrderMapper extends BaseMapper<LocalLooseboardOrder> {
// 客户状态按正式排车与取货事实投影;不改变收货、测量等内部状态门禁。
String DELIVERY = " FROM ll_inbound_plan p"
+ " JOIN ll_inbound_plan_pallet pp ON pp.plan_id=p.id AND pp.customer_id=p.customer_id"
+ " JOIN ll_dispatch_item i ON i.plan_pallet_id=pp.id AND i.customer_id=p.customer_id"
+ " JOIN ll_dispatch_order d ON d.id=i.dispatch_order_id AND d.status='ACTIVE'"
+ " WHERE p.source_order_id=ll_local_looseboard_order.lastleg_order_id"
+ " AND p.customer_id=ll_local_looseboard_order.customer_id";
String PICKUP = " FROM ll_warehouse_transfer_order w"
// 两表客户标识使用各自排序规则,按完整字节身份关联,512 字节覆盖 VARCHAR(128) 的 utf8mb4 上限。
+ " JOIN ll_fleet_pickup_task t ON t.transfer_order_id=w.id"
+ " AND CAST(t.customer_id AS BINARY(512))=CAST(w.customer_id AS BINARY(512))"
+ " JOIN ll_fleet_trip f ON f.trip_no=t.trip_no AND f.status IN ('PLANNED','IN_TRANSIT')"
+ " WHERE w.customer_id=ll_local_looseboard_order.customer_id"
+ " AND w.transfer_no=ll_local_looseboard_order.transfer_no";
String MERCHANT_STATUS = "(CASE"
+ " WHEN ll_local_looseboard_order.status IN ('FUNDING_PENDING','FUNDING_FAILED','PENDING_ACCEPTANCE','COMPLETED','CANCELLED')"
+ " THEN ll_local_looseboard_order.status"
+ " WHEN ll_local_looseboard_order.status='IN_TRANSIT' OR EXISTS (SELECT 1" + DELIVERY
+ " AND d.stage IN ('DELIVERING','DELIVERED')) THEN 'IN_TRANSIT'"
+ " WHEN EXISTS (SELECT 1" + PICKUP
+ " AND (t.status='PICKED' OR (t.status='ASSIGNED' AND f.status='IN_TRANSIT'))) THEN 'PICKING_UP'"
+ " WHEN EXISTS (SELECT 1" + DELIVERY + ") OR EXISTS (SELECT 1" + PICKUP
+ " AND t.status='ASSIGNED') THEN 'DISPATCHED'"
+ " WHEN ll_local_looseboard_order.status='DISPATCHED' THEN 'DISPATCHED'"
+ " ELSE 'PENDING_DISPATCH' END)";
/** 批量读取同客户散板的运输状态,与分页筛选使用同一表达式。 */
@Select("<script>SELECT id," + MERCHANT_STATUS + " AS status FROM ll_local_looseboard_order"
+ " WHERE customer_id=#{customerId} AND id IN"
+ " <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
java.util.List<MerchantStatus> merchantStatuses(@Param("customerId") String customerId,
@Param("ids") java.util.List<Long> ids);
/** 客户列表与详情共享的只读状态。 */
record MerchantStatus(Long id, String status) { }
@Select("SELECT COUNT(*) FROM ll_local_looseboard_order WHERE customer_id=#{customerId} AND transfer_no=#{transferNo} AND lastleg_order_id IS NOT NULL") @Select("SELECT COUNT(*) FROM ll_local_looseboard_order WHERE customer_id=#{customerId} AND transfer_no=#{transferNo} AND lastleg_order_id IS NOT NULL")
int linkedReceiving(@Param("customerId") String customerId,@Param("transferNo") String transferNo); int linkedReceiving(@Param("customerId") String customerId,@Param("transferNo") String transferNo);
@org.apache.ibatis.annotations.Update("UPDATE ll_local_looseboard_order SET status='PENDING_DISPATCH',updated_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE customer_id=#{customerId} AND transfer_no=#{transferNo} AND status='WAITING_INBOUND'") @org.apache.ibatis.annotations.Update("UPDATE ll_local_looseboard_order SET status='PENDING_DISPATCH',updated_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE customer_id=#{customerId} AND transfer_no=#{transferNo} AND status='WAITING_INBOUND'")
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.looseboard.port;
import java.util.List;
/** 散板原单的排车关联只读入口,不向客户暴露整车货物。 */
public interface LooseboardDispatches {
/** 按真实客户和散板原单返回去重后的有效排车单号。 */
List<String> looseboardDispatches(String customerId, String orderNo);
}
@@ -33,18 +33,35 @@ public class LocalLooseboardRepository {
public LocalLooseboardOrder lockNo(String customer, String number) { return orders.lockNo(customer, number); } public LocalLooseboardOrder lockNo(String customer, String number) { return orders.lockNo(customer, number); }
/** 在客户范围内组合路线、状态与取货日期筛选并返回分页需求。 */ /** 在客户范围内组合路线、状态与取货日期筛选并返回分页需求。 */
public Page<LocalLooseboardOrder> page(LocalLooseboardRequests.Query query) { public Page<LocalLooseboardOrder> page(LocalLooseboardRequests.Query query) {
return orders.selectPage(Page.of(query.page(), query.size()), queryWrapper(query, false));
}
/** 客户按运输状态筛选后分页,确保总数和返回记录使用相同口径。 */
public Page<LocalLooseboardOrder> merchantPage(LocalLooseboardRequests.Query query) {
return orders.selectPage(Page.of(query.page(), query.size()), queryWrapper(query, true));
}
/** 按认证客户批量装配状态,空页不查询排车事实。 */
public java.util.Map<Long, String> merchantStatuses(String customer, List<Long> ids) {
if (ids.isEmpty()) return java.util.Map.of();
return orders.merchantStatuses(customer, ids).stream().collect(java.util.stream.Collectors.toMap(
LocalLooseboardOrderMapper.MerchantStatus::id, LocalLooseboardOrderMapper.MerchantStatus::status));
}
/** 路线与时间筛选共用,仅客户查询采用实际运输进度。 */
private LambdaQueryWrapper<LocalLooseboardOrder> queryWrapper(LocalLooseboardRequests.Query query, boolean merchant) {
LambdaQueryWrapper<LocalLooseboardOrder> wrapper = new LambdaQueryWrapper<LocalLooseboardOrder>() LambdaQueryWrapper<LocalLooseboardOrder> wrapper = new LambdaQueryWrapper<LocalLooseboardOrder>()
.eq(StringUtils.hasText(query.customerId()), LocalLooseboardOrder::getCustomerId, trim(query.customerId())) .eq(StringUtils.hasText(query.customerId()), LocalLooseboardOrder::getCustomerId, trim(query.customerId()))
.like(StringUtils.hasText(query.orderNo()), LocalLooseboardOrder::getOrderNo, trim(query.orderNo())) .like(StringUtils.hasText(query.orderNo()), LocalLooseboardOrder::getOrderNo, trim(query.orderNo()))
.and(StringUtils.hasText(query.routeKeyword()), value -> value .and(StringUtils.hasText(query.routeKeyword()), value -> value
.like(LocalLooseboardOrder::getOrigin, trim(query.routeKeyword())) .like(LocalLooseboardOrder::getOrigin, trim(query.routeKeyword()))
.or().like(LocalLooseboardOrder::getDestination, trim(query.routeKeyword()))) .or().like(LocalLooseboardOrder::getDestination, trim(query.routeKeyword())))
.eq(StringUtils.hasText(query.status()), LocalLooseboardOrder::getStatus, .eq(!merchant && StringUtils.hasText(query.status()), LocalLooseboardOrder::getStatus,
StringUtils.hasText(query.status()) ? query.status().trim().toUpperCase() : null) StringUtils.hasText(query.status()) ? query.status().trim().toUpperCase() : null)
.ge(query.requestedFrom() != null, LocalLooseboardOrder::getPickupAt, query.requestedFrom() == null ? null : query.requestedFrom().atStartOfDay()) .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()) .lt(query.requestedTo() != null, LocalLooseboardOrder::getPickupAt, query.requestedTo() == null ? null : query.requestedTo().plusDays(1).atStartOfDay())
.orderByDesc(LocalLooseboardOrder::getCreatedAt).orderByDesc(LocalLooseboardOrder::getId); .orderByDesc(LocalLooseboardOrder::getCreatedAt).orderByDesc(LocalLooseboardOrder::getId);
return orders.selectPage(Page.of(query.page(), query.size()), wrapper); if (merchant && StringUtils.hasText(query.status())) {
wrapper.apply(LocalLooseboardOrderMapper.MERCHANT_STATUS + "={0}", query.status().trim().toUpperCase(java.util.Locale.ROOT));
}
return wrapper;
} }
/** 规范查询文字,缺失值不参与筛选。 */ /** 规范查询文字,缺失值不参与筛选。 */
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
@@ -48,26 +48,43 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
history(order,"TRANSPORT_PHOTO",from,next,operatorId,null,null,"按排车取货/送达照片更新,累计送达"+deliveredPackages+"箱",now); history(order,"TRANSPORT_PHOTO",from,next,operatorId,null,null,"按排车取货/送达照片更新,累计送达"+deliveredPackages+"箱",now);
} }
@Resource private com.baiyue.lastleg.looseboard.port.LooseboardReceiving receiving; @Resource private com.baiyue.lastleg.looseboard.port.LooseboardReceiving receiving;
@Resource private com.baiyue.lastleg.looseboard.port.LooseboardDispatches dispatches;
@Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries fleetPool; @Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries fleetPool;
public static final String PENDING_ACCEPTANCE = "PENDING_ACCEPTANCE"; public static final String PENDING_ACCEPTANCE = "PENDING_ACCEPTANCE";
public static final String FUNDING_PENDING = "FUNDING_PENDING";
public static final String FUNDING_FAILED = "FUNDING_FAILED";
public static final String DISPATCHED = "DISPATCHED"; public static final String DISPATCHED = "DISPATCHED";
public static final String IN_TRANSIT = "IN_TRANSIT"; public static final String IN_TRANSIT = "IN_TRANSIT";
public static final String COMPLETED = "COMPLETED"; public static final String COMPLETED = "COMPLETED";
public static final String CANCELLED = "CANCELLED"; 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> STATUSES = Set.of(FUNDING_PENDING, FUNDING_FAILED, PENDING_ACCEPTANCE, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED, "WAITING_INBOUND", "RECEIVING", "WAITING_CONSOLIDATION", "PENDING_DISPATCH");
private static final Set<String> MERCHANT_STATUSES = Set.of(FUNDING_PENDING, FUNDING_FAILED, PENDING_ACCEPTANCE,
"PENDING_DISPATCH", DISPATCHED, "PICKING_UP", IN_TRANSIT, COMPLETED, CANCELLED);
private static final Map<String, Set<String>> TRANSITIONS = Map.of( private static final Map<String, Set<String>> TRANSITIONS = Map.of(
PENDING_ACCEPTANCE, Set.of(DISPATCHED, CANCELLED), PENDING_ACCEPTANCE, Set.of(DISPATCHED, CANCELLED),
FUNDING_PENDING, Set.of(CANCELLED),
FUNDING_FAILED, Set.of(CANCELLED),
DISPATCHED, Set.of(IN_TRANSIT, CANCELLED), DISPATCHED, Set.of(IN_TRANSIT, CANCELLED),
IN_TRANSIT, Set.of(COMPLETED)); IN_TRANSIT, Set.of(COMPLETED));
@Resource private LocalLooseboardRepository repository; @Resource private LocalLooseboardRepository repository;
@Resource private LastlegOrderNoGenerator lastlegOrderNoGenerator; @Resource private LastlegOrderNoGenerator lastlegOrderNoGenerator;
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
@Resource private com.baiyue.lastleg.wallet.budget.OrderBudgetService orderBudget;
/** 在同一事务保存客户需求及创建历史,等待需求池匹配后登记真实来货,创建键重复时核对请求哈希。 */ /** 平台代客和客户自助下单共用预算冻结,冻结成功后才进入待审核。 */
@Transactional @Transactional
public LocalLooseboardResponses.Detail create(String customerId, String operatorId, public LocalLooseboardResponses.Detail create(String customerId, String operatorId,
LocalLooseboardRequests.Create request) { LocalLooseboardRequests.Create request) {
var result = orderBudget.submit(customerId, "looseboard", request.idempotencyKey().trim(), request.budgetAmount(),
StringUtils.hasText(request.currency()) ? norm(request.currency()) : "USD",
() -> createPending(customerId, operatorId, request), value -> value.summary().orderNo());
return detail(customerId, result.summary().orderNo());
}
/** 业务单与冻结指令同事务保存,任何创建失败均不留下独立预占。 */
private LocalLooseboardResponses.Detail createPending(String customerId, String operatorId,
LocalLooseboardRequests.Create request) {
String key = request.idempotencyKey().trim(); String key = request.idempotencyKey().trim();
String requestHash = hash(request); String requestHash = hash(request);
LocalLooseboardOrder replay = repository.findKey(customerId, key); LocalLooseboardOrder replay = repository.findKey(customerId, key);
@@ -81,7 +98,7 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
request.originAddress(), request.destination(), request.destinationAddress(), request.productName(), request.estimatedPalletCount(), request.originAddress(), request.destination(), request.destinationAddress(), request.productName(), request.estimatedPalletCount(),
request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(), request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(),
request.currency(), request.originContactName(), request.originContactPhone(), request.destinationContactName(), request.destinationContactPhone(), request.pickupAt(), request.deliveryAt(), request.remark()); request.currency(), request.originContactName(), request.originContactPhone(), request.destinationContactName(), request.destinationContactPhone(), request.pickupAt(), request.deliveryAt(), request.remark());
order.setStatus(PENDING_ACCEPTANCE); order.setStatus(FUNDING_PENDING);
order.setIdempotencyKey(key); order.setIdempotencyKey(key);
order.setRequestHash(requestHash); order.setRequestHash(requestHash);
order.setCreatedBy(operatorId); order.setCreatedBy(operatorId);
@@ -96,7 +113,7 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
if (concurrent != null) return replay(customerId, concurrent, requestHash); if (concurrent != null) return replay(customerId, concurrent, requestHash);
throw exception; throw exception;
} }
history(order, "CREATE", null, PENDING_ACCEPTANCE, operatorId, history(order, "CREATE", null, FUNDING_PENDING, operatorId,
key, null, request.remark(), now); key, null, request.remark(), now);
return detail(customerId, order.getOrderNo()); return detail(customerId, order.getOrderNo());
@@ -112,13 +129,28 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
result.getSize(), result.getTotal(), result.getPages()); result.getSize(), result.getTotal(), result.getPages());
} }
/** 客户查询强制使用认证客户范围,沿同一需求筛选合同分页。 */ /** 客户查询使用认证范围,筛选、分页及显示统一按真实运输进度。 */
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) @Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public PageResult<LocalLooseboardResponses.Summary> page(String customerId, public PageResult<LocalLooseboardResponses.Summary> page(String customerId,
LocalLooseboardRequests.MerchantQuery query) { LocalLooseboardRequests.MerchantQuery query) {
return page(new LocalLooseboardRequests.Query(query.page(), query.size(), customerId, var scoped = new LocalLooseboardRequests.Query(query.page(), query.size(), customerId,
query.orderNo(), query.routeKeyword(), query.status(), query.orderNo(), query.routeKeyword(), query.status(), query.requestedFrom(), query.requestedTo());
query.requestedFrom(), query.requestedTo())); validateQuery(scoped, MERCHANT_STATUSES);
var result = repository.merchantPage(scoped);
var statuses = repository.merchantStatuses(customerId, result.getRecords().stream().map(LocalLooseboardOrder::getId).toList());
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)), Objects.requireNonNull(statuses.get(o.getId())))).toList(),
result.getCurrent(), result.getSize(), result.getTotal(), result.getPages());
}
/** 客户详情与列表共用运输状态投影,不改写作业用原始状态。 */
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public LocalLooseboardResponses.Detail merchantDetail(String customerId, String orderNo) {
var order = repository.findNo(customerId, orderNo);
if (order == null) throw notFound();
var status = repository.merchantStatuses(customerId, java.util.List.of(order.getId())).get(order.getId());
return response(order, Objects.requireNonNull(status));
} }
/** 按客户与业务单号读取需求及历史。 */ /** 按客户与业务单号读取需求及历史。 */
@@ -133,6 +165,14 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
@Transactional @Transactional
public LocalLooseboardResponses.Detail update(String customerId, String orderNo, String operatorId, public LocalLooseboardResponses.Detail update(String customerId, String orderNo, String operatorId,
LocalLooseboardRequests.Update request) { LocalLooseboardRequests.Update request) {
return orderBudget.update(customerId, "looseboard", orderNo, request.budgetAmount(),
StringUtils.hasText(request.currency()) ? norm(request.currency()) : "USD",
() -> updateLocked(customerId, orderNo, operatorId, request));
}
/** 已冻结预算不允许通过普通编辑改价,业务字段仍按订单版本校验。 */
private LocalLooseboardResponses.Detail updateLocked(String customerId, String orderNo, String operatorId,
LocalLooseboardRequests.Update request) {
LocalLooseboardOrder order = lock(customerId, orderNo); LocalLooseboardOrder order = lock(customerId, orderNo);
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry"); if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
if (!PENDING_ACCEPTANCE.equals(order.getStatus())) { if (!PENDING_ACCEPTANCE.equals(order.getStatus())) {
@@ -157,6 +197,13 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
@Transactional @Transactional
public LocalLooseboardResponses.Detail changeStatus(String customerId, String orderNo, String operatorId, public LocalLooseboardResponses.Detail changeStatus(String customerId, String orderNo, String operatorId,
LocalLooseboardRequests.ChangeStatus request) { LocalLooseboardRequests.ChangeStatus request) {
return orderBudget.withOrder(customerId, "looseboard", orderNo,
() -> changeStatusLocked(customerId, orderNo, operatorId, request));
}
/** 取消与释放意图同事务,冻结在途回执到达后也会自动释放。 */
private LocalLooseboardResponses.Detail changeStatusLocked(String customerId, String orderNo, String operatorId,
LocalLooseboardRequests.ChangeStatus request) {
LocalLooseboardOrder order = lock(customerId, orderNo); LocalLooseboardOrder order = lock(customerId, orderNo);
String key = request.idempotencyKey().trim(); String key = request.idempotencyKey().trim();
String requestHash = hash(request); String requestHash = hash(request);
@@ -181,6 +228,7 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
touch(order, now); touch(order, now);
repository.updateOrder(order); repository.updateOrder(order);
syncLastlegOrder(order, next, now); syncLastlegOrder(order, next, now);
if (CANCELLED.equals(next)) orderBudget.releaseAfterReview(customerId, "looseboard", orderNo);
LocalLooseboardOperation operation = new LocalLooseboardOperation(); LocalLooseboardOperation operation = new LocalLooseboardOperation();
operation.setId(IdWorker.getId()); operation.setOrderId(order.getId()); operation.setId(IdWorker.getId()); operation.setOrderId(order.getId());
operation.setCustomerId(customerId); operation.setOperationType("STATUS"); operation.setCustomerId(customerId); operation.setOperationType("STATUS");
@@ -195,6 +243,13 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
@Transactional @Transactional
public LocalLooseboardResponses.Detail review(String customerId, String orderNo, String operatorId, public LocalLooseboardResponses.Detail review(String customerId, String orderNo, String operatorId,
LocalLooseboardRequests.Review request) { LocalLooseboardRequests.Review request) {
return orderBudget.withOrder(customerId, "looseboard", orderNo,
() -> reviewLocked(customerId, orderNo, operatorId, request));
}
/** 冻结与审核串行核对,通过后只释放预算,实际费用继续走原费用链。 */
private LocalLooseboardResponses.Detail reviewLocked(String customerId, String orderNo, String operatorId,
LocalLooseboardRequests.Review request) {
LocalLooseboardOrder order = lock(customerId, orderNo); LocalLooseboardOrder order = lock(customerId, orderNo);
String requestHash = hash(request); String requestHash = hash(request);
var previous = repository.findOperation(customerId, order.getId(), request.idempotencyKey()); var previous = repository.findOperation(customerId, order.getId(), request.idempotencyKey());
@@ -204,16 +259,19 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
} }
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry"); if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
String decision = request.decision().trim().toUpperCase(); String decision = request.decision().trim().toUpperCase();
if (!PENDING_ACCEPTANCE.equals(order.getStatus())) throw conflict("散板订单已审核,请刷新后重试"); if (!PENDING_ACCEPTANCE.equals(order.getStatus())) throw conflict("散板订单须资金冻结成功且待审核,请刷新后重试");
if ("REJECT".equals(decision)) { if ("REJECT".equals(decision)) {
if (!StringUtils.hasText(request.remark())) throw badRequest("驳回须填写原因"); if (!StringUtils.hasText(request.remark())) throw badRequest("驳回须填写原因");
return changeStatus(customerId, orderNo, operatorId, new LocalLooseboardRequests.ChangeStatus(order.getVersion(), "CANCELLED", request.idempotencyKey(), request.remark())); order.setStatus(CANCELLED);
} } else {
if (!"APPROVE".equals(decision)) throw badRequest("审核结果无效"); if (!"APPROVE".equals(decision)) throw badRequest("审核结果无效");
if (order.getOperatingWarehouseId()==null || order.getDeliveryMode()==null) throw conflict("旧散板订单缺少收货安排,请先编辑补齐"); if (!orderBudget.readOrder(customerId, "looseboard", orderNo).usable()) throw conflict("散板订单资金尚未冻结成功,暂不能审核");
if (order.getOperatingWarehouseId()==null || order.getDeliveryMode()==null) throw conflict("散板订单缺少收货安排,请先编辑补齐");
var created = receiving.approve(order, operatorId); var created = receiving.approve(order, operatorId);
order.setTransferNo(created.transferNo()); order.setLastlegOrderId(created.lastlegOrderId()); order.setStatus("WAITING_INBOUND"); order.setTransferNo(created.transferNo()); order.setLastlegOrderId(created.lastlegOrderId()); order.setStatus("WAITING_INBOUND");
}
touch(order, LocalDateTime.now()); repository.updateOrder(order); touch(order, LocalDateTime.now()); repository.updateOrder(order);
orderBudget.releaseAfterReview(customerId, "looseboard", orderNo);
history(order, "REVIEW", PENDING_ACCEPTANCE, order.getStatus(), operatorId, request.idempotencyKey(), null, request.remark(), LocalDateTime.now()); history(order, "REVIEW", PENDING_ACCEPTANCE, order.getStatus(), operatorId, request.idempotencyKey(), null, request.remark(), LocalDateTime.now());
var operation=new LocalLooseboardOperation(); operation.setId(IdWorker.getId()); operation.setOrderId(order.getId()); operation.setCustomerId(customerId); var operation=new LocalLooseboardOperation(); operation.setId(IdWorker.getId()); operation.setOrderId(order.getId()); operation.setCustomerId(customerId);
operation.setOperationType("REVIEW"); operation.setIdempotencyKey(request.idempotencyKey()); operation.setRequestHash(requestHash); operation.setResultVersion(order.getVersion()); operation.setCreatedBy(operatorId); operation.setCreatedAt(LocalDateTime.now()); repository.insertOperation(operation); operation.setOperationType("REVIEW"); operation.setIdempotencyKey(request.idempotencyKey()); operation.setRequestHash(requestHash); operation.setResultVersion(order.getVersion()); operation.setCreatedBy(operatorId); operation.setCreatedAt(LocalDateTime.now()); repository.insertOperation(operation);
@@ -278,15 +336,20 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
return order; return order;
} }
/** 装配需求详情、真实承运安排与不可变历史。 */ /** 装配需求详情、真实承运安排、全部有效排车与不可变历史。 */
private LocalLooseboardResponses.Detail response(LocalLooseboardOrder order) { private LocalLooseboardResponses.Detail response(LocalLooseboardOrder order) {
return new LocalLooseboardResponses.Detail(summary(order), order.getOriginAddress(), return response(order, order.getStatus());
}
/** 展示状态仅用于响应,原单及历史保持真实作业状态。 */
private LocalLooseboardResponses.Detail response(LocalLooseboardOrder order, String status) {
return new LocalLooseboardResponses.Detail(summary(order, fleetPool.arrangement(poolKey(order)), status), order.getOriginAddress(),
order.getDestinationAddress(), order.getRemark(), order.getCreatedBy(), order.getDestinationAddress(), order.getRemark(), order.getCreatedBy(),
repository.histories(order.getCustomerId(), order.getId()).stream() repository.histories(order.getCustomerId(), order.getId()).stream()
.map(value -> new LocalLooseboardResponses.History(value.getOperationType(), value.getFromStatus(), .map(value -> new LocalLooseboardResponses.History(value.getOperationType(), value.getFromStatus(),
value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(), value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(),
value.getChangeDetail(), value.getRemark(), value.getChangeDetail(), value.getRemark(),
value.getCreatedAt())).toList()); value.getCreatedAt())).toList(), dispatches.looseboardDispatches(order.getCustomerId(), order.getOrderNo()));
} }
/** 构造同客户商业订单的承运查询身份。 */ /** 构造同客户商业订单的承运查询身份。 */
@@ -294,16 +357,18 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
return new com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.OrderKey(order.getCustomerId(), "LOCAL_LOOSEBOARD", order.getId()); 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) { private LocalLooseboardResponses.Summary summary(LocalLooseboardOrder order, com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement arrangement) {
return summary(order, arrangement, order.getStatus());
}
/** 按调用方所需口径装配状态,其余原单与承运事实保持一致。 */
private LocalLooseboardResponses.Summary summary(LocalLooseboardOrder order,
com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement arrangement, String status) {
return new LocalLooseboardResponses.Summary(order.getId(), order.getCustomerId(), order.getOrderNo(), return new LocalLooseboardResponses.Summary(order.getId(), order.getCustomerId(), order.getOrderNo(),
order.getPalletRequired(), order.getDeliveryMode(), order.getOperatingWarehouseId(), order.getDestinationType(), order.getDestinationPostalCode(), order.getTransferNo(), order.getDeliveryContactName(), order.getDeliveryContactPhone(), order.getEstimatedArrivalAt(), order.getTrackingNo(), order.getTransportType(), order.getOrigin(), order.getPalletRequired(), order.getDeliveryMode(), order.getOperatingWarehouseId(), order.getDestinationType(), order.getDestinationPostalCode(), order.getTransferNo(), order.getDeliveryContactName(), order.getDeliveryContactPhone(), order.getEstimatedArrivalAt(), order.getTrackingNo(), order.getTransportType(), order.getOrigin(),
order.getDestination(), order.getProductName(), order.getEstimatedPalletCount(), Objects.requireNonNullElse(order.getPackageCount(), 0), order.getDestination(), order.getProductName(), order.getEstimatedPalletCount(), Objects.requireNonNullElse(order.getPackageCount(), 0),
order.getTotalWeightKg(), order.getTotalVolumeCbm(), order.getBudgetAmount(), order.getCurrency(), order.getTotalWeightKg(), order.getTotalVolumeCbm(), order.getBudgetAmount(), order.getCurrency(),
order.getOriginContactName(), order.getOriginContactPhone(), order.getDestinationContactName(), order.getDestinationContactPhone(), order.getPickupAt(), order.getDeliveryAt(), order.getStatus(), order.getOriginContactName(), order.getOriginContactPhone(), order.getDestinationContactName(), order.getDestinationContactPhone(), order.getPickupAt(), order.getDeliveryAt(), status,
value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt(), arrangement); value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt(), arrangement);
} }
@@ -382,7 +447,12 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
/** 校验状态与取货日期筛选边界。 */ /** 校验状态与取货日期筛选边界。 */
private void validateQuery(LocalLooseboardRequests.Query query) { private void validateQuery(LocalLooseboardRequests.Query query) {
if (StringUtils.hasText(query.status()) && !STATUSES.contains(norm(query.status()))) { validateQuery(query, STATUSES);
}
/** 分别限制客户可见运输状态和平台内部作业状态,并校验时间范围。 */
private void validateQuery(LocalLooseboardRequests.Query query, Set<String> statuses) {
if (StringUtils.hasText(query.status()) && !statuses.contains(norm(query.status()))) {
throw badRequest("Unsupported looseboard order status"); throw badRequest("Unsupported looseboard order status");
} }
if (query.requestedFrom() != null && query.requestedTo() != null if (query.requestedFrom() != null && query.requestedTo() != null
@@ -51,14 +51,15 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
String externalBookingNo, boolean poolSource) { } String externalBookingNo, boolean poolSource) { }
// Manual node snapshots replace container nodes permanently for the order. // Manual node snapshots replace container nodes permanently for the order.
String SAILING_STAGE_FILTER_SQL = "(ll_order.order_type='FIRST_LEG' AND " + String SAILING_STAGE_VALUE_SQL = "(ll_order.order_type='FIRST_LEG' AND " +
"(CASE WHEN COALESCE(ll_order.manual_node_override,FALSE)=TRUE THEN " + "(CASE WHEN COALESCE(ll_order.manual_node_override,FALSE)=TRUE THEN " +
"CASE WHEN EXISTS (SELECT 1 FROM ll_order_manual_node n WHERE n.order_id=ll_order.id " + "CASE WHEN EXISTS (SELECT 1 FROM ll_order_manual_node n WHERE n.order_id=ll_order.id " +
"AND n.customer_id=ll_order.customer_id AND n.node_code='ACTUAL_DEPARTURE' AND n.node_at IS NOT NULL) " + "AND n.customer_id=ll_order.customer_id AND n.node_code='ACTUAL_DEPARTURE' AND n.node_at IS NOT NULL) " +
"THEN 1 ELSE 0 END ELSE " + "THEN 1 ELSE 0 END ELSE " +
"CASE WHEN EXISTS (SELECT 1 FROM ll_container_shipping_gate g " + "CASE WHEN EXISTS (SELECT 1 FROM ll_container_shipping_gate g " +
"WHERE g.container_booking_id=ll_order.source_container_booking_id " + "WHERE g.container_booking_id=ll_order.source_container_booking_id " +
"AND g.node_sequence>=40 AND g.actual_at IS NOT NULL) THEN 1 ELSE 0 END END)={0})"; "AND g.node_sequence>=40 AND g.actual_at IS NOT NULL) THEN 1 ELSE 0 END END)";
String SAILING_STAGE_FILTER_SQL = SAILING_STAGE_VALUE_SQL + "={0})";
@Select("<script>SELECT r.lastleg_order_id,r.relabel_no,r.status,r.notified_package_count AS quantity, " + @Select("<script>SELECT r.lastleg_order_id,r.relabel_no,r.status,r.notified_package_count AS quantity, " +
"CASE WHEN NOT EXISTS (SELECT 1 FROM ll_relabel_item ri WHERE ri.application_id=r.id AND ri.operation_type&lt;&gt;'CARTON_LABEL') THEN '箱' ELSE '件' END AS quantity_unit,r.created_at " + "CASE WHEN NOT EXISTS (SELECT 1 FROM ll_relabel_item ri WHERE ri.application_id=r.id AND ri.operation_type&lt;&gt;'CARTON_LABEL') THEN '箱' ELSE '件' END AS quantity_unit,r.created_at " +
@@ -71,11 +72,18 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
@Select("<script>SELECT o.id AS orderId,CASE WHEN EXISTS (SELECT 1 FROM ll_dispatch_item di " + @Select("<script>SELECT o.id AS orderId,CASE WHEN EXISTS (SELECT 1 FROM ll_dispatch_item di " +
"JOIN ll_dispatch_order d ON d.id=di.dispatch_order_id " + "JOIN ll_dispatch_order d ON d.id=di.dispatch_order_id " +
"WHERE di.customer_id=o.customer_id AND di.order_id=o.id AND d.status!='CANCELLED') " + "WHERE di.customer_id=o.customer_id AND di.order_id=o.id AND d.status!='CANCELLED' AND COALESCE(d.task_type,'DELIVERY') NOT IN ('PICKUP','LOCAL_TRUCK')) " +
"THEN 'SCHEDULED' ELSE 'UNSCHEDULED' END AS dispatchStatus FROM ll_order o WHERE o.id IN " + "THEN 'SCHEDULED' ELSE 'UNSCHEDULED' END AS dispatchStatus FROM ll_order o WHERE o.id IN " +
"<foreach collection='orderIds' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>") "<foreach collection='orderIds' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
List<DispatchStatusRow> findDispatchStatuses(@Param("orderIds") List<Long> orderIds); List<DispatchStatusRow> findDispatchStatuses(@Param("orderIds") List<Long> orderIds);
/** 已开船来源键用于聚合前去重,保持客户隔离和人工节点覆盖规则。 */
@Select("<script>SELECT DISTINCT customer_id,source_order_no FROM ll_order WHERE source_order_no IS NOT NULL " +
"<if test='customer != null'>AND customer_id=#{customer} </if>AND " +
SAILING_STAGE_VALUE_SQL + "=1)" + "</script>")
List<DepartedSource> departedSources(@Param("customer") String customer);
record DepartedSource(String customerId, String sourceOrderNo) { }
record DispatchStatusRow(Long orderId, String dispatchStatus) { } record DispatchStatusRow(Long orderId, String dispatchStatus) { }
record OrderWeightRow(Long orderId, BigDecimal grossWeightKg, BigDecimal netWeightKg) { } record OrderWeightRow(Long orderId, BigDecimal grossWeightKg, BigDecimal netWeightKg) { }
@@ -12,6 +12,10 @@ import lombok.RequiredArgsConstructor;
import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardHistory;
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper;
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardHistoryMapper;
/** 资金门禁只访问本服务采购、卡车原单及其状态历史。 */ /** 资金门禁只访问本服务采购、卡车原单及其状态历史。 */
@Repository @Repository
@@ -21,6 +25,21 @@ public class OrderFundingRepository {
private final OverseasProcurementStatusHistoryMapper purchaseHistory; private final OverseasProcurementStatusHistoryMapper purchaseHistory;
private final UsTruckOrderMapper trucks; private final UsTruckOrderMapper trucks;
private final UsTruckOrderStatusHistoryMapper truckHistory; private final UsTruckOrderStatusHistoryMapper truckHistory;
private final LocalLooseboardOrderMapper looseboards;
private final LocalLooseboardHistoryMapper looseboardHistory;
/** 预算锁后按认证客户锁定散板原单。 */
public LocalLooseboardOrder looseboard(String customer, String number) { return looseboards.lockNo(customer, number); }
/** 保存散板资金状态,不改客户填写的业务资料。 */
public void save(LocalLooseboardOrder order) { looseboards.updateById(order); }
/** 追加散板冻结结果和重试的不可变历史。 */
public void history(LocalLooseboardOrder order, String from, String target, String reason, LocalDateTime now) {
var history = new LocalLooseboardHistory();
history.setId(IdWorker.getId()); history.setOrderId(order.getId()); history.setCustomerId(order.getCustomerId());
history.setOperationType("FUNDING"); history.setFromStatus(from); history.setToStatus(target);
history.setOperatorId("SYSTEM"); history.setRemark(reason); history.setCreatedAt(now);
looseboardHistory.insert(history);
}
/** 预算锁后按客户锁定采购原单,统一先预算后订单的加锁顺序。 */ /** 预算锁后按客户锁定采购原单,统一先预算后订单的加锁顺序。 */
public OverseasProcurementOrder purchase(String customer, String number) { return purchases.lockNo(customer, number); } public OverseasProcurementOrder purchase(String customer, String number) { return purchases.lockNo(customer, number); }
@@ -8,7 +8,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Set; import java.util.Set;
/** 两种订单复用资金状态门禁,不反向依赖订单编排服务。 */ /** 采购、卡车和散板复用资金状态门禁,不反向依赖订单编排服务。 */
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class OrderFundingService implements OrderFundingPort { public class OrderFundingService implements OrderFundingPort {
@@ -36,6 +36,15 @@ public class OrderFundingService implements OrderFundingPort {
order.setStatus(target); order.setVersion(order.getVersion() + 1); order.setUpdatedAt(now); order.setStatus(target); order.setVersion(order.getVersion() + 1); order.setUpdatedAt(now);
repository.save(order); repository.save(order);
repository.history(order, from, target, reason, now); repository.history(order, from, target, reason, now);
} else if ("looseboard".equals(budget.getKind())) {
var order = repository.looseboard(budget.getCustomerId(), budget.getOrderNo());
if (order == null) throw new IllegalStateException("资金关联的散板订单不存在");
String from = order.getStatus();
if (!changeable(from, target)) return;
String next = "PENDING_REVIEW".equals(target) ? "PENDING_ACCEPTANCE" : target;
order.setStatus(next); order.setVersion(order.getVersion() + 1); order.setUpdatedAt(now);
repository.save(order);
repository.history(order, from, next, reason, now);
} }
} }
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.repository;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import org.springframework.stereotype.Repository;
import java.util.Set;
import java.util.stream.Collectors;
/** 尾程实际船节点优先于头程摘要中缺失的开船时间。 */
@Repository
public class PreDepartureOrderRepository {
private final LastlegOrderMapper mapper;
/** 仅读本服务尾程事实,不读取订单服务数据库。 */
public PreDepartureOrderRepository(LastlegOrderMapper mapper) { this.mapper = mapper; }
/** 按客户与来源订单组成去重键,在分页前排除已开船记录。 */
public Set<String> departedKeys(String customer) {
return mapper.departedSources(customer).stream()
.map(row -> row.customerId() + ":" + row.sourceOrderNo()).collect(Collectors.toSet());
}
}
@@ -93,6 +93,7 @@ public class LastlegOrderService {
@Resource private ReturnOrderReferenceResolver returnOrderReferences; @Resource private ReturnOrderReferenceResolver returnOrderReferences;
@Resource private com.baiyue.lastleg.transfer.service.TransferQuantityService transferQuantities; @Resource private com.baiyue.lastleg.transfer.service.TransferQuantityService transferQuantities;
@Resource private OrderPreDepartureClient orderPreDepartureClient; @Resource private OrderPreDepartureClient orderPreDepartureClient;
@Resource private com.baiyue.lastleg.repository.PreDepartureOrderRepository preDepartureOrders;
@Value("${baiyue.security.internal-service-token:}") private String internalServiceToken; @Value("${baiyue.security.internal-service-token:}") private String internalServiceToken;
public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query) { public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query) {
@@ -101,12 +102,7 @@ public class LastlegOrderService {
public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query, String relabelNo) { public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query, String relabelNo) {
if (query.sailingStage() == LastlegOrderRequests.SailingStage.BEFORE_DEPARTURE) { if (query.sailingStage() == LastlegOrderRequests.SailingStage.BEFORE_DEPARTURE) {
Long customer = parseLong(customerId); return preDeparturePage(customerId, query);
var response = orderPreDepartureClient.page(internalServiceToken, customer, query.orderNo(), query.inboundCode(),
query.page().intValue(), query.size().intValue());
if (response == null || response.data() == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "订单服务暂时不可用");
List<LastlegOrderSummary> rows = response.data().records().stream().map(this::preDepartureSummary).toList();
return new PageResult<>(rows, response.data().page(), response.data().size(), response.data().total(), response.data().pages());
} }
if (query.sailingStage() == null && relabelNo == null) return aggregateAllPage(customerId, query); if (query.sailingStage() == null && relabelNo == null) return aggregateAllPage(customerId, query);
Page<LastlegOrder> page = orderMapper.selectPage( Page<LastlegOrder> page = orderMapper.selectPage(
@@ -115,6 +111,27 @@ public class LastlegOrderService {
page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
} }
/** 开船前先扣除尾程已有实际开船事实,再分页,避免两页签重复及总数失真。 */
private PageResult<LastlegOrderSummary> preDeparturePage(String customerId, LastlegOrderRequests.Query query) {
var departed = preDepartureOrders.departedKeys(customerId);
var records = new java.util.ArrayList<LastlegOrderSummary>();
for (int sourcePage = 1; ; sourcePage++) {
var response = orderPreDepartureClient.page(internalServiceToken, parseLong(customerId),
query.orderNo(), query.inboundCode(), sourcePage, 2000);
if (response == null || !response.successful() || response.data() == null)
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "订单服务暂时不可用");
response.data().records().stream()
.filter(row -> !departed.contains(orderKey(String.valueOf(row.customerId()), row.orderNo())))
.map(this::preDepartureSummary).forEach(records::add);
if (sourcePage >= response.data().pages()) break;
}
int from = (int)Math.min((query.page() - 1) * query.size(), records.size());
int to = (int)Math.min(from + query.size(), records.size());
long total = records.size();
return new PageResult<>(records.subList(from, to), query.page(), query.size(), total,
total == 0 ? 0 : (total + query.size() - 1) / query.size());
}
/** 全部及已开船列表使用同一组业务事实,避免聚合列表丢失并托、排车和换标信息。 */ /** 全部及已开船列表使用同一组业务事实,避免聚合列表丢失并托、排车和换标信息。 */
private List<LastlegOrderSummary> nativeSummaries(List<LastlegOrder> orders) { private List<LastlegOrderSummary> nativeSummaries(List<LastlegOrder> orders) {
Map<Long, List<LastlegOrderMapper.PalletMergeRelationRow>> mergeRelations = palletMergeRelations(orders); Map<Long, List<LastlegOrderMapper.PalletMergeRelationRow>> mergeRelations = palletMergeRelations(orders);
@@ -21,26 +21,26 @@ public class MerchantOrderBudgetController {
@NotBlank @Pattern(regexp="CNY|USD") String currency) { } @NotBlank @Pattern(regexp="CNY|USD") String currency) { }
/** 按业务创建权限发起预估冻结。 */ /** 按业务创建权限发起预估冻结。 */
@PostMapping("/{kind}/{key}") @PostMapping("/{kind}/{key}")
@PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:create')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:create'))") @PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:create')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:create')) or (#kind == 'looseboard' and hasAuthority('merchant:cross-border:pallet-shipping:create'))")
public ApiResponse<OrderBudgetService.View> prepare(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String kind,@PathVariable @Size(max=128) String key,@Valid @RequestBody Prepare request) { public ApiResponse<OrderBudgetService.View> prepare(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String kind,@PathVariable @Size(max=128) String key,@Valid @RequestBody Prepare request) {
return ApiResponse.success(service.prepare(customer(user),kind,key,request.amount(),request.currency())); return ApiResponse.success(service.prepare(customer(user),kind,key,request.amount(),request.currency()));
} }
/** 只读资金回执沿同一业务权限。 */ /** 只读资金回执沿同一业务权限。 */
@GetMapping("/{kind}/{key}") @GetMapping("/{kind}/{key}")
@PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:create')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:create'))") @PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:create')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:create')) or (#kind == 'looseboard' and hasAuthority('merchant:cross-border:pallet-shipping:create'))")
public ApiResponse<OrderBudgetService.View> read(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String kind,@PathVariable String key) { public ApiResponse<OrderBudgetService.View> read(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String kind,@PathVariable String key) {
return ApiResponse.success(service.read(customer(user),kind,key)); return ApiResponse.success(service.read(customer(user),kind,key));
} }
/** 列表及详情可核对当前客户订单资金状态和失败原因。 */ /** 列表及详情可核对当前客户订单资金状态和失败原因。 */
@GetMapping("/{kind}/orders/{orderNo}") @GetMapping("/{kind}/orders/{orderNo}")
@PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:list')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:list'))") @PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:list')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:list')) or (#kind == 'looseboard' and hasAuthority('merchant:cross-border:pallet-shipping:list'))")
public ApiResponse<OrderBudgetService.View> readOrder(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<OrderBudgetService.View> readOrder(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String kind, @PathVariable String orderNo) { @PathVariable String kind, @PathVariable String orderNo) {
return ApiResponse.success(service.readOrder(customer(user), kind, orderNo)); return ApiResponse.success(service.readOrder(customer(user), kind, orderNo));
} }
/** 失败订单按原预算重试;客户身份只取当前会话。 */ /** 失败订单按原预算重试;客户身份只取当前会话。 */
@PostMapping("/{kind}/orders/{orderNo}/retry") @PostMapping("/{kind}/orders/{orderNo}/retry")
@PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:create')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:create'))") @PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:create')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:create')) or (#kind == 'looseboard' and hasAuthority('merchant:cross-border:pallet-shipping:create'))")
public ApiResponse<OrderBudgetService.View> retry(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<OrderBudgetService.View> retry(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String kind, @PathVariable String orderNo) { @PathVariable String kind, @PathVariable String orderNo) {
return ApiResponse.success(service.retryOrder(customer(user), kind, orderNo)); return ApiResponse.success(service.retryOrder(customer(user), kind, orderNo));
@@ -24,6 +24,7 @@ public interface OrderBudgetMapper extends BaseMapper<OrderBudget> {
(b.order_no IS NULL AND b.created_at < #{expiredBefore}) (b.order_no IS NULL AND b.created_at < #{expiredBefore})
OR (b.kind='procurement' AND EXISTS (SELECT 1 FROM ll_overseas_procurement_order o WHERE o.customer_id=b.customer_id AND o.order_no=b.order_no AND o.status IN ('REJECTED','CANCELLED','COMPLETED','EXCEPTION_CLOSED'))) OR (b.kind='procurement' AND EXISTS (SELECT 1 FROM ll_overseas_procurement_order o WHERE o.customer_id=b.customer_id AND o.order_no=b.order_no AND o.status IN ('REJECTED','CANCELLED','COMPLETED','EXCEPTION_CLOSED')))
OR (b.kind='trucking' AND EXISTS (SELECT 1 FROM ll_us_truck_order o WHERE o.customer_id=b.customer_id AND o.order_no=b.order_no AND o.status IN ('REJECTED','CANCELLED','COMPLETED'))) OR (b.kind='trucking' AND EXISTS (SELECT 1 FROM ll_us_truck_order o WHERE o.customer_id=b.customer_id AND o.order_no=b.order_no AND o.status IN ('REJECTED','CANCELLED','COMPLETED')))
OR (b.kind='looseboard' AND EXISTS (SELECT 1 FROM ll_local_looseboard_order o WHERE o.customer_id=b.customer_id AND o.order_no=b.order_no AND o.status IN ('WAITING_INBOUND','RECEIVING','WAITING_CONSOLIDATION','PENDING_DISPATCH','DISPATCHED','IN_TRANSIT','CANCELLED','COMPLETED')))
)) ORDER BY b.id LIMIT 100 FOR UPDATE SKIP LOCKED )) ORDER BY b.id LIMIT 100 FOR UPDATE SKIP LOCKED
""") """)
List<OrderBudget> releasable(@Param("expiredBefore") LocalDateTime expiredBefore, List<OrderBudget> releasable(@Param("expiredBefore") LocalDateTime expiredBefore,
@@ -115,7 +115,15 @@ public class OrderBudgetService implements WalletResultHandler {
return change.get(); return change.get();
} }
/** 审核通过后立即释放采购或卡车预估冻结,后续实际费用按正常余额/授信扣除。 */ /** 审核及取消先锁预算再锁业务单,与异步资金回执保持相同锁序。 */
@Transactional
public <T> T withOrder(String customer,String kind,String no,Supplier<T> change) {
requireKind(kind);
repository.lockOrder(customer,kind,no);
return change.get();
}
/** 审核通过后立即释放订单预估冻结,后续实际费用按正常余额/授信扣除。 */
@Transactional @Transactional
public void releaseAfterReview(String customer, String kind, String orderNo) { public void releaseAfterReview(String customer, String kind, String orderNo) {
requireKind(kind); requireKind(kind);
@@ -194,7 +202,7 @@ public class OrderBudgetService implements WalletResultHandler {
};return new View(row.getStatus(),row.getAmountCny(),message,row.getOrderNo(),"FROZEN".equals(row.getStatus()) && !Boolean.TRUE.equals(row.getReleaseRequested()) && (row.getOrderNo()!=null || !row.getCreatedAt().isBefore(now().minusMinutes(15)))); };return new View(row.getStatus(),row.getAmountCny(),message,row.getOrderNo(),"FROZEN".equals(row.getStatus()) && !Boolean.TRUE.equals(row.getReleaseRequested()) && (row.getOrderNo()!=null || !row.getCreatedAt().isBefore(now().minusMinutes(15))));
} }
/** 仅开放本轮客户下单业务。 */ /** 仅开放本轮客户下单业务。 */
private void requireKind(String kind) { if(!Set.of("procurement","trucking").contains(kind)) throw bad("不支持的预算业务"); } private void requireKind(String kind) { if(!Set.of("procurement","trucking","looseboard").contains(kind)) throw bad("不支持的预算业务"); }
/** 时间按UTC保存以匹配资金事件。 */ /** 时间按UTC保存以匹配资金事件。 */
private LocalDateTime now() { return LocalDateTime.now(ZoneOffset.UTC); } private LocalDateTime now() { return LocalDateTime.now(ZoneOffset.UTC); }
/** 返回可恢复的业务校验失败。 */ /** 返回可恢复的业务校验失败。 */
@@ -3,6 +3,8 @@ info:
title: 本土散板排车照片履约 title: 本土散板排车照片履约
version: 1.0.0 version: 1.0.0
description: >- description: >-
管理资料按同客户散板原单聚合,每单展示一块并汇总本车各托箱数。itemId为该订单在本车的代表明细,
同单照片一次保存并在同一事务保留各托证据;不同客户或不同订单不能共用节点照片,分车仍分别履约。
仅同客户权威关联的本土散板明细进入照片节点。全车为散板且尚无真实扫码时,审核通过后逐票保存取货照片, 仅同客户权威关联的本土散板明细进入照片节点。全车为散板且尚无真实扫码时,审核通过后逐票保存取货照片,
全部齐全才显示取货完成按钮;显式确认后从LOADING进入DELIVERING。不生成库存、PDA拣货或仓库出库事实。逐票保存送达照片后自动DELIVERED并结案释放车辆司机。 全部齐全才显示取货完成按钮;显式确认后从LOADING进入DELIVERING。不生成库存、PDA拣货或仓库出库事实。逐票保存送达照片后自动DELIVERED并结案释放车辆司机。
混排从AFTER_LOADING或DELIVERING可逐票保存取货及送达照片;异地货同票须先取货再送达,不同票不限制取送顺序。 混排从AFTER_LOADING或DELIVERING可逐票保存取货及送达照片;异地货同票须先取货再送达,不同票不限制取送顺序。
@@ -2,7 +2,7 @@ openapi: 3.0.3
info: info:
title: 派送异常退回入库 title: 派送异常退回入库
version: 1.0.0 version: 1.0.0
description: 排车异常冻结原出库货物,异常单生成退回尾程单后进入PDA退货入库列表。现场展示异常单号、车牌和待收箱数,点击开始作业,不需要回单号或扫码找单。建单不增加库存,分批实收调用原来源RETURN_STOCK_IN或原箱恢复,异常和退回尾程单收齐后完成。 description: 排车异常允许一次选择同派送下多张原订单,逐原单生成异常并冻结对应原出库货物,整批校验与落单原子提交。异常单生成退回尾程单后进入PDA退货入库列表。现场展示异常单号、车牌和待收箱数,点击开始作业,不需要回单号或扫码找单。建单不增加库存,分批实收调用原来源RETURN_STOCK_IN或原箱恢复,异常和退回尾程单收齐后完成。回仓扫描当前仓有效托盘及其实际库位,不要求使用已出库报废的原托;同键实收重放不重复增加库存。
paths: paths:
/api/platform/v1/last-leg/exception-orders/return-inbound-plans: /api/platform/v1/last-leg/exception-orders/return-inbound-plans:
post: post:
@@ -3,7 +3,7 @@
"info": { "info": {
"title": "真实出车与自有外接运营统计", "title": "真实出车与自有外接运营统计",
"version": "1.0.0", "version": "1.0.0",
"description": "仅实际departed_at非空车次;使用DEPART不可变快照,一车次计一次,跨客户或多目的地不重复。物理托盘去重;自有与外接箱数以BOUND外接实收归属核算,支持同排车/同托混货。全筛选汇总与分页同快照;sourceType筛选含该类货的整车,并非裁切另一类货物。from含、to不含,UTC,最多366天/5000车次。发车车牌/司机使用历史,货物结案状态使用当前事实。约定金额按币种分别累计,不是财务应收/实收;列表财务按需读取,未读取明确NOT_QUERIED;单车次财务仅按DEPART客户/排车核对唯一派送来源,Finance返回已审核有效应收与明确归属整车次账单的确认收款分配。混合来源、无收款分配或多车次归属不估算,显示待分配。应收额仍按Finance实际币种展示,不把外接约定金额当应收;成本只通过FleetTripCostService.summaries(Collection<String>)按最多100车次一批读取,保持现有trip:list成本读权限。无权限为FORBIDDEN,既有车次无登记为NOT_RECORDED;可读汇总仅包含已登记直接成本,返回未登记及未能分摊车次数,未知分摊值为null。查询不跨服务逐行HTTP,不写执行状态。 图表与分页共用全筛选快照:按UTC日展示实运托盘及外接约定金额,目的地按稳定键统计涉及车次。按托均价仅包含PALLET计价,用该币种约定总金额除确认计价标准托位,ORDER报价排除。里程mile、油耗L是授权补充事实,与车辆档案累计里程和直接成本独立。" "description": "仅实际departed_at非空的派送、取货及本土卡车车次;分别读取DEPART、PICKUP_DEPART/PICKUP_PICKED或LOCAL_TRUCK_DEPART不可变发车事实,取货历史须带实际发车时间,一车次计一次,跨客户或多目的地不重复。物理托盘去重;自有与外接箱数以BOUND外接实收归属核算,支持同排车/同托混货。全筛选汇总与分页同快照;sourceType筛选含该类货的整车,并非裁切另一类货物。from含、to不含,UTC,最多366天/5000车次。发车车牌/司机使用历史,货物结案状态使用当前事实。约定金额按币种分别累计,不是财务应收/实收;列表财务按需读取,未读取明确NOT_QUERIED;单车次财务仅按DEPART客户/排车核对唯一派送来源,Finance返回已审核有效应收与明确归属整车次账单的确认收款分配。混合来源、无收款分配或多车次归属不估算,显示待分配。应收额仍按Finance实际币种展示,不把外接约定金额当应收;成本只通过FleetTripCostService.summaries(Collection<String>)按最多100车次一批读取,保持现有trip:list成本读权限。无权限为FORBIDDEN,既有车次无登记为NOT_RECORDED;可读汇总仅包含已登记直接成本,返回未登记及未能分摊车次数,未知分摊值为null。查询不跨服务逐行HTTP,不写执行状态。 图表与分页共用全筛选快照:按UTC日展示实运托盘及外接约定金额,目的地按稳定键统计涉及车次。按托均价仅包含PALLET计价,用该币种约定总金额除确认计价标准托位,ORDER报价排除。里程mile、油耗L是授权补充事实,与车辆档案累计里程和直接成本独立。"
}, },
"security": [ "security": [
{ {
@@ -13,7 +13,7 @@
"paths": { "paths": {
"/api/platform/v1/last-leg/fleet/operations": { "/api/platform/v1/last-leg/fleet/operations": {
"get": { "get": {
"description": "platform:fleet:operations:list;一次查询返回分页与全范围汇总", "description": "platform:fleet:operations:list;一次查询返回分页与全范围汇总。纯散板照片直送按LOOSEBOARD_DELIVERING不可变历史的实际发运箱数统计,要求已有实际发车时间且未发生仓内拣货或装车;实托和标准托位保持null,不以申报托数补齐。普通仓内出车仍要求DEPART实载快照。",
"parameters": [ "parameters": [
{ {
"name": "page", "name": "page",
@@ -793,6 +793,7 @@ components:
Cargo: Cargo:
type: "object" type: "object"
x-java-record: "OwnedBusinessModels.Cargo" x-java-record: "OwnedBusinessModels.Cargo"
description: 散板按客户和商业订单一行展示,关联托盘码去重连接,箱数按本单来源累加;其他货物保持原来源行。
required: required:
- "sourceKey" - "sourceKey"
- "customerId" - "customerId"
@@ -815,10 +816,19 @@ components:
nullable: true nullable: true
palletNo: palletNo:
type: "string" type: "string"
nullable: true
description: 关联的完整实托码集合,以顿号连接;未提供保持未知。
destinationKey: destinationKey:
type: "string" type: "string"
description: 首个目的地键;展示使用完整destinationKeys集合。
destinationKeys:
type: array
items:
type: string
description: 本订单全部真实目的地,不能丢失多目的地信息。
packages: packages:
type: "string" type: "string"
nullable: true
Detail: Detail:
type: "object" type: "object"
x-java-record: "OwnedBusinessModels.Detail" x-java-record: "OwnedBusinessModels.Detail"
@@ -0,0 +1,125 @@
openapi: 3.0.3
info:
title: 平台收货异常核对
version: 1.0.0
paths:
/api/platform/v1/last-leg/inbound-plans/{planNo}/receiving-reconciliation:
parameters:
- { name: planNo, in: path, required: true, schema: { type: string } }
get:
operationId: getInboundReceivingReconciliation
summary: 查询收货异常核对
description: 仅平台入库列表权限。按所选客户计划的来源柜次、柜号和实际接柜仓查询同柜计划。独立返回现场实盘与已分配数量,完成票据只读。普通FBA单票单SKU组可唯一确定归属时预填完整实盘,其余逐票逐SKU由平台显式核对。
parameters:
- { name: customerId, in: query, required: true, schema: { type: string } }
responses:
'200':
description: 当前事实快照和确认资格
content:
application/json:
schema:
type: object
properties:
code: { type: string }
data: { $ref: '#/components/schemas/Snapshot' }
post:
operationId: confirmInboundReceivingReconciliation
summary: 确认收货异常
description: 仅平台入库编辑权限。必须提交当前snapshotHash、明确原因以及快照内全部逐托和逐票SKU明细。普通FBA可确认短少、超收、零实收或更正误录,每托归属合计须等于核实清点数,单托可编辑数量0至66。普通地址逐箱来源和已入库票据保持只读。原申报不变,按原现场时间生成实际库存及计租批次,逐SKU短少进入PARTIAL_INBOUND,其余进入COMPLETED。库存、托盘、原预排交接和审计同事务提交,保留原清点及归属事实,不重复发布卸柜事件。已完成一件代发来源独立分组,待核对一件代发仍须核实冻结整托身份。
parameters:
- { name: Idempotency-Key, in: header, required: true, schema: { type: string, maxLength: 128 } }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/Confirm' }
responses:
'200':
description: 确认结果,同键同操作人同载荷重放返回原结果
content:
application/json:
schema:
type: object
properties:
code: { type: string }
data:
type: object
properties:
planNo: { type: string }
status: { type: string }
confirmedPackageCount: { type: integer }
reconciledPlanNos: { type: array, items: { type: string } }
'409': { description: 旧快照、归属不完整、逐托不平、已完成来源变化或证据缺失 }
components:
schemas:
Snapshot:
type: object
required: [planNo, containerNo, snapshotHash, expectedPackageCount, countedPackageCount, attributedPackageCount, canConfirm, plans, pallets, receipts]
properties:
planNo: { type: string }
containerNo: { type: string }
snapshotHash: { type: string }
expectedPackageCount: { type: integer, description: 本柜原申报箱数 }
countedPackageCount: { type: integer, description: 独立现场实盘箱数 }
attributedPackageCount: { type: integer, description: 当前已归属货件的箱数 }
canConfirm: { type: boolean }
blockedReason: { type: string, nullable: true }
plans:
type: array
items:
type: object
properties:
customerId: { type: string }
planNo: { type: string }
customerName: { type: string, nullable: true }
status: { type: string }
expectedPackageCount: { type: integer }
attributedPackageCount: { type: integer }
pallets:
type: array
items:
allOf:
- { $ref: '#/components/schemas/Count' }
- type: object
properties:
palletNo: { type: string }
groupKey: { type: string }
attributedPackageCount: { type: integer }
editable: { type: boolean }
receipts:
type: array
items:
allOf:
- { $ref: '#/components/schemas/Quantity' }
- type: object
properties:
inboundCode: { type: string, nullable: true }
palletNo: { type: string }
productName: { type: string, nullable: true }
expectedPackageCount: { type: integer, description: 该票SKU原申报合计,同SKU跨托显示重复值 }
editable: { type: boolean }
Confirm:
type: object
required: [customerId, snapshotHash, reason, pallets, receipts]
properties:
customerId: { type: string }
snapshotHash: { type: string }
reason: { type: string, minLength: 1, maxLength: 200 }
pallets: { type: array, items: { $ref: '#/components/schemas/Count' } }
receipts: { type: array, items: { $ref: '#/components/schemas/Quantity' } }
Count:
type: object
required: [palletId, countedPackageCount]
properties:
palletId: { type: string }
countedPackageCount: { type: integer, minimum: 0 }
Quantity:
type: object
required: [customerId, planNo, lineId, palletId, skuCode, receivedPackageCount]
properties:
customerId: { type: string }
planNo: { type: string }
lineId: { type: string }
palletId: { type: string }
skuCode: { type: string }
receivedPackageCount: { type: integer, minimum: 0 }
@@ -2,17 +2,17 @@ openapi: 3.0.3
info: info:
title: 海外客户下单预估资金校验 title: 海外客户下单预估资金校验
version: 1.0.0 version: 1.0.0
description: 客户订单先原子落库并进入资金处理中,资金冻结通过Outbox异步执行。冻结成功后进入待审核,失败进入资金失败并可在原订单上重试;平台仅可审核待审核订单。余额优先、授信补足及不足判断由Finance原子执行;本服务不自行修改钱包。预算预占、订单和冻结指令在同一事务内写入,订单终态释放。 description: 客户订单先原子落库并进入资金处理中,资金冻结通过Outbox异步执行。冻结成功后进入待审核,失败进入资金失败并可在原订单上重试;平台仅可审核待审核订单。余额优先、授信补足及不足判断由Finance原子执行;本服务不自行修改钱包。预算预占、订单和冻结指令在同一事务内写入,审核通过立即申请释放原预算,取消、驳回或终态同样释放;释放失败自动重试。散板冻结成功进入PENDING_ACCEPTANCE,失败进入FUNDING_FAILED。
security: security:
- bearerAuth: [] - bearerAuth: []
paths: paths:
/api/merchant/v1/last-leg/order-budgets/{kind}/{key}: /api/merchant/v1/last-leg/order-budgets/{kind}/{key}:
parameters: parameters:
- {name: kind, in: path, required: true, schema: {type: string, enum: [procurement, trucking]}} - {name: kind, in: path, required: true, schema: {type: string, enum: [procurement, trucking, looseboard]}}
- {name: key, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128}} - {name: key, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
post: post:
summary: 兼容接口:发起或读取预算冻结请求 summary: 兼容接口:发起或读取预算冻结请求
description: 客户身份仅来自认证。procurement需要merchant:cross-border:procurement:create,trucking需要merchant:cross-border:domestic-trucking:create;权限不能交叉使用。采购金额是预算单价乘采购数量,免费样品零金额不发资金指令;卡车金额必须大于0。重试禁止更改金额或币种。 description: 客户身份仅来自认证。procurement需要merchant:cross-border:procurement:create,trucking需要merchant:cross-border:domestic-trucking:create,looseboard需要merchant:cross-border:pallet-shipping:create;权限不能交叉使用。采购金额是预算单价乘采购数量,免费样品零金额不发资金指令;卡车金额必须大于0。重试禁止更改金额或币种。
requestBody: requestBody:
required: true required: true
content: content:
@@ -34,16 +34,16 @@ paths:
'403': {description: 身份或业务创建权限不足} '403': {description: 身份或业务创建权限不足}
/api/merchant/v1/last-leg/order-budgets/{kind}/orders/{orderNo}: /api/merchant/v1/last-leg/order-budgets/{kind}/orders/{orderNo}:
parameters: parameters:
- {name: kind, in: path, required: true, schema: {type: string, enum: [procurement, trucking]}} - {name: kind, in: path, required: true, schema: {type: string, enum: [procurement, trucking, looseboard]}}
- {name: orderNo, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128}} - {name: orderNo, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
get: get:
summary: 读取订单资金状态 summary: 读取订单资金状态
description: 仅当前客户可读。FUNDING_PENDING期间客户端可短暂轮询;FUNDING_FAILED展示失败原因并允许重试。只有PENDING_REVIEW允许平台审核。 description: 仅当前客户可读。FUNDING_PENDING期间客户端可短暂轮询;FUNDING_FAILED展示失败原因并允许重试。采购和卡车只有PENDING_REVIEW、散板只有PENDING_ACCEPTANCE且资金已冻结才允许平台审核。读取订单资金状态分别要求对应业务list权限,重试要求对应create权限。
responses: responses:
'200': {$ref: '#/components/responses/Result'} '200': {$ref: '#/components/responses/Result'}
/api/merchant/v1/last-leg/order-budgets/{kind}/orders/{orderNo}/retry: /api/merchant/v1/last-leg/order-budgets/{kind}/orders/{orderNo}/retry:
parameters: parameters:
- {name: kind, in: path, required: true, schema: {type: string, enum: [procurement, trucking]}} - {name: kind, in: path, required: true, schema: {type: string, enum: [procurement, trucking, looseboard]}}
- {name: orderNo, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128}} - {name: orderNo, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
post: post:
summary: 重试订单资金冻结 summary: 重试订单资金冻结
@@ -14,7 +14,7 @@ paths:
parameters: parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 50, default: 20 } } - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 50, default: 20 } }
responses: { '200': { description: 统一ApiResponse;data为正式排车CandidateList,含候选来源版本、目的地及资格原因 } } responses: { '200': { description: 统一ApiResponse;data为CandidateList,同客户散板原单一行且按订单分页,total为订单数;availablePackages汇总本车可选的同单各托,selectionGroup包含该单所有来源及原始版本,任一来源阻断则整单不可选,加入时一次展开全部来源 } }
post: post:
summary: 将已收货散板加入需求排车 summary: 将已收货散板加入需求排车
description: 需capacity-offer:cargo-add或manage以及dispatch:edit、dispatch:list权限;复用正式AddCargo命令,校验版本、幂等、同仓及整托占用,自动刷新目的站和货量并失效审核,不执行出库拣货。 description: 需capacity-offer:cargo-add或manage以及dispatch:edit、dispatch:list权限;复用正式AddCargo命令,校验版本、幂等、同仓及整托占用,自动刷新目的站和货量并失效审核,不执行出库拣货。
@@ -28,7 +28,7 @@ paths:
/api/platform/v1/last-leg/local-looseboard-orders/{orderNo}/review: /api/platform/v1/last-leg/local-looseboard-orders/{orderNo}/review:
post: post:
summary: 审核散板并创建唯一入仓履约单 summary: 审核散板并创建唯一入仓履约单
description: 权限platform:overseas-business:looseboard:status;通过后送货到仓进入PDA收货,仓库上门取货进入取货排车;审核后即可从散板需求添加申报货物;上门取货沿取货排车上传取货与到仓凭证,实收到仓后原单进入PENDING_DISPATCH并交接原排车货量。客户及幂等键严格校验。 description: 权限platform:overseas-business:looseboard:status;仅冻结成功可通过审核,通过和驳回均释放原预算预占;通过后送货到仓进入PDA收货,仓库上门取货进入取货排车;审核后即可从散板需求添加申报货物;上门取货沿取货排车上传取货与到仓凭证,实收到仓后原单进入PENDING_DISPATCH并交接原排车货量。客户及幂等键严格校验。
security: [{ bearerAuth: [] }] security: [{ bearerAuth: [] }]
parameters: parameters:
- { name: orderNo, in: path, required: true, schema: { type: string } } - { name: orderNo, in: path, required: true, schema: { type: string } }
@@ -2754,20 +2754,20 @@ paths:
/api/merchant/v1/last-leg/local-looseboard-orders: /api/merchant/v1/last-leg/local-looseboard-orders:
get: get:
summary: 客户分页查询自己的本土散板订单 summary: 客户分页查询自己的本土散板订单
description: 权限 merchant:cross-border:pallet-shipping:list;客户范围只取认证 Token,不接收 customerId。 description: 权限 merchant:cross-border:pallet-shipping:list;客户范围只取认证 Token,不接收 customerId;状态筛选、分页计数和显示共用真实运输进度。PENDING_ACCEPTANCE 为待审核,审核通过尚未排车为 PENDING_DISPATCH(待排车);已排车待取货为 DISPATCHED;取货到仓车次出发或已取货未到仓为 PICKING_UP(取货中),到仓后等待后续排车;直接取货送货取货完成后为 IN_TRANSIT(派送中)。内部收货与合托状态不作为客户筛选项。
security: [{ bearerAuth: [] }] security: [{ bearerAuth: [] }]
parameters: parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } } - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
- { name: orderNo, in: query, schema: { type: string } } - { name: orderNo, in: query, schema: { type: string } }
- { name: routeKeyword, in: query, schema: { type: string } } - { name: routeKeyword, in: query, schema: { type: string } }
- { name: status, in: query, schema: { $ref: '#/components/schemas/LooseboardStatus' } } - { name: status, in: query, schema: { $ref: '#/components/schemas/MerchantLooseboardStatus' } }
- { name: requestedFrom, in: query, schema: { type: string, format: date } } - { name: requestedFrom, in: query, schema: { type: string, format: date } }
- { name: requestedTo, 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' } } } } } responses: { '200': { description: 当前客户本土散板订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardPageResponse' } } } } }
post: post:
summary: 客户创建本土散板订单 summary: 客户创建本土散板订单
description: 权限 merchant:cross-border:pallet-shipping:create;新单固定进入 PENDING_ACCEPTANCE,客户不能调用平台状态推进端点。 description: 权限 merchant:cross-border:pallet-shipping:create;散板金额必填,预算与订单和冻结指令同事务保存;新单进入 FUNDING_PENDING,Finance按余额优先、授信补足冻结,成功进入 PENDING_ACCEPTANCE,失败进入 FUNDING_FAILED,支持原单重试。审核通过、取消或驳回申请释放预占,不自动扣款;客户不能调用平台状态推进端点。
security: [{ bearerAuth: [] }] security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardCreate' } } } } requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardCreate' } } } }
responses: { '200': { description: 本土散板订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '409': { description: 幂等冲突 } } responses: { '200': { description: 本土散板订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '409': { description: 幂等冲突 } }
@@ -2775,12 +2775,12 @@ paths:
parameters: [{ name: orderNo, in: path, required: true, schema: { type: string } }] parameters: [{ name: orderNo, in: path, required: true, schema: { type: string } }]
get: get:
summary: 客户查询自己的本土散板订单详情 summary: 客户查询自己的本土散板订单详情
description: 权限 merchant:cross-border:pallet-shipping:list;不泄露其他客户订单。 description: 权限 merchant:cross-border:pallet-shipping:list;不泄露其他客户订单;summary.status 使用 MerchantLooseboardStatus,与列表运输状态及筛选一致。
security: [{ bearerAuth: [] }] security: [{ bearerAuth: [] }]
responses: { '200': { description: 当前客户本土散板订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '404': { description: 当前客户下订单不存在 } } responses: { '200': { description: 当前客户本土散板订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '404': { description: 当前客户下订单不存在 } }
put: put:
summary: 客户编辑待接单的本土散板订单 summary: 客户编辑待审核的本土散板订单
description: 权限 merchant:cross-border:pallet-shipping:edit;仅 PENDING_ACCEPTANCE 可编辑,version 必填;不能修改状态。 description: 权限 merchant:cross-border:pallet-shipping:edit;仅 PENDING_ACCEPTANCE 可编辑,version 必填;已冻结的预算金额与币种不能通过编辑更改;不能修改状态。
security: [{ bearerAuth: [] }] security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardUpdate' } } } } requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardUpdate' } } } }
responses: { '200': { description: 更新后的订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '409': { description: 版本或状态冲突 } } responses: { '200': { description: 更新后的订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '409': { description: 版本或状态冲突 } }
@@ -4897,15 +4897,19 @@ components:
destinationContactPhone: { type: string, nullable: true } destinationContactPhone: { type: string, nullable: true }
pickupAt: { type: string, format: date-time, nullable: true, description: 当地日期时间不带时区;按分钟输入 } pickupAt: { type: string, format: date-time, nullable: true, description: 当地日期时间不带时区;按分钟输入 }
deliveryAt: { type: string, format: date-time, nullable: true, description: 当地日期时间,不能早于取货时间 } deliveryAt: { type: string, format: date-time, nullable: true, description: 当地日期时间,不能早于取货时间 }
status: { $ref: '#/components/schemas/LooseboardStatus' } status:
description: 客户读取为运输状态 MerchantLooseboardStatus,平台读取保留内部作业状态 LooseboardStatus。
anyOf:
- { $ref: '#/components/schemas/LooseboardStatus' }
- { $ref: '#/components/schemas/MerchantLooseboardStatus' }
version: { type: integer, format: int64 } version: { type: integer, format: int64 }
createdAt: { type: string, format: date-time } createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time }
fleetArrangement: { $ref: '#/components/schemas/FleetArrangement' } fleetArrangement: { $ref: '#/components/schemas/FleetArrangement' }
LooseboardDetail: LooseboardDetail:
type: object type: object
description: 承运安排位于summary.fleetArrangement,与列表摘要使用同一真实来源;根对象不重复该字段。池来源状态只能由实际履约推进。 description: 承运安排位于summary.fleetArrangement;dispatchNos独立按本客户散板原单的真实来源返回全部有效排车,不依赖车队预订,同车多托去重、分车全部展示、取消排车排除;未排车返回空数组。池来源状态只能由实际履约推进。
required: [summary, histories] required: [summary, histories, dispatchNos]
properties: properties:
summary: { $ref: '#/components/schemas/LooseboardSummary' } summary: { $ref: '#/components/schemas/LooseboardSummary' }
originAddress: { type: string, nullable: true } originAddress: { type: string, nullable: true }
@@ -4913,6 +4917,7 @@ components:
remark: { type: string, nullable: true } remark: { type: string, nullable: true }
createdBy: { type: string, nullable: true } createdBy: { type: string, nullable: true }
histories: { type: array, items: { $ref: '#/components/schemas/BusinessHistory' } } histories: { type: array, items: { $ref: '#/components/schemas/BusinessHistory' } }
dispatchNos: { type: array, uniqueItems: true, items: { type: string }, description: 本客户当前散板单关联的全部有效排车单号,不授予客户整车详情访问权限 }
FleetArrangement: FleetArrangement:
type: object type: object
nullable: true nullable: true
@@ -5291,7 +5296,11 @@ components:
pickupAt: { type: string, format: date-time, description: '取货日期时分,使用无偏移量的本地时间,不转换时区' } pickupAt: { type: string, format: date-time, description: '取货日期时分,使用无偏移量的本地时间,不转换时区' }
deliveryAt: { type: string, format: date-time, description: '送货日期时分,不能早于取货时间,不转换时区' } deliveryAt: { type: string, format: date-time, description: '送货日期时分,不能早于取货时间,不转换时区' }
remark: { type: string, nullable: true, maxLength: 1000 } remark: { type: string, nullable: true, maxLength: 1000 }
LooseboardStatus: { type: string, enum: [PENDING_ACCEPTANCE, WAITING_INBOUND, RECEIVING, WAITING_CONSOLIDATION, PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED] } LooseboardStatus: { type: string, enum: [FUNDING_PENDING, FUNDING_FAILED, PENDING_ACCEPTANCE, WAITING_INBOUND, RECEIVING, WAITING_CONSOLIDATION, PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED] }
MerchantLooseboardStatus:
type: string
description: 订单受理中、订单处理失败、待审核、待排车、待取货、取货中、派送中、已完成、已取消;只读运输进度,不作为平台状态推进命令。
enum: [FUNDING_PENDING, FUNDING_FAILED, PENDING_ACCEPTANCE, PENDING_DISPATCH, DISPATCHED, PICKING_UP, IN_TRANSIT, COMPLETED, CANCELLED]
LooseboardStatusChange: LooseboardStatusChange:
type: object type: object
required: [version, status, idempotencyKey] required: [version, status, idempotencyKey]
@@ -5302,7 +5311,7 @@ components:
remark: { type: string, nullable: true, maxLength: 1000 } remark: { type: string, nullable: true, maxLength: 1000 }
LooseboardCreate: LooseboardCreate:
type: object type: object
required: [operatingWarehouseId, deliveryMode, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, destination, estimatedPalletCount, packageCount, totalWeightKg, totalVolumeCbm, idempotencyKey] required: [operatingWarehouseId, deliveryMode, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, destination, estimatedPalletCount, packageCount, totalWeightKg, totalVolumeCbm, budgetAmount, idempotencyKey]
properties: properties:
deliveryMode: { type: string, enum: [DELIVERY_TO_WAREHOUSE, WAREHOUSE_PICKUP], description: 送货到仓或仓库上门取货 } deliveryMode: { type: string, enum: [DELIVERY_TO_WAREHOUSE, WAREHOUSE_PICKUP], description: 送货到仓或仓库上门取货 }
operatingWarehouseId: { type: string, description: 实际收货海外仓ID } operatingWarehouseId: { type: string, description: 实际收货海外仓ID }
@@ -5321,7 +5330,7 @@ components:
packageCount: { type: integer, minimum: 0 } packageCount: { type: integer, minimum: 0 }
totalWeightKg: { type: number, minimum: 0 } totalWeightKg: { type: number, minimum: 0 }
totalVolumeCbm: { type: number, minimum: 0 } totalVolumeCbm: { type: number, minimum: 0 }
budgetAmount: { type: number, nullable: true, minimum: 0 } budgetAmount: { type: number, minimum: 0, multipleOf: 0.01 }
currency: { type: string, minLength: 3, maxLength: 3, default: USD } currency: { type: string, minLength: 3, maxLength: 3, default: USD }
originContactName: { type: string, minLength: 1, maxLength: 128 } originContactName: { type: string, minLength: 1, maxLength: 128 }
originContactPhone: { type: string, minLength: 1, maxLength: 128 } originContactPhone: { type: string, minLength: 1, maxLength: 128 }
@@ -6771,6 +6780,7 @@ components:
DispatchOrderDetail: DispatchOrderDetail:
type: object type: object
x-java-record: DispatchViews.OrderDetail x-java-record: DispatchViews.OrderDetail
description: 散板按客户和权威商业订单汇总一行,实托去重计数;重量体积仅完整可分摊时汇总,未知不补零。普通来源保持原展示语义。
properties: properties:
customerId: customerId:
type: string type: string
@@ -6808,6 +6818,11 @@ components:
sourceBusinessNo: sourceBusinessNo:
type: string type: string
nullable: true nullable: true
sourceKeys:
type: array
items:
type: string
description: 本展示行包含的全部原来源键,只用于只读关联,不能作为新增或删除货物的命令身份。
required: required:
- customerId - customerId
- customerName - customerName
@@ -6821,6 +6836,7 @@ components:
- sourceKey - sourceKey
- sourceBusinessType - sourceBusinessType
- sourceBusinessNo - sourceBusinessNo
- sourceKeys
DispatchDetail: DispatchDetail:
type: object type: object
x-java-record: DispatchViews.Detail x-java-record: DispatchViews.Detail
@@ -8504,7 +8520,9 @@ components:
pattern: ^[1-9][0-9]*$ pattern: ^[1-9][0-9]*$
returnSelections: returnSelections:
type: array type: array
description: 退回海外仓必填。来源必须属于本车派送,箱数不超过原拣货及未签收余量。 description: 退回海外仓必填,可同时选择同一派送下多张原订单,每个deliveryNo和lastMileOrderId组合只允许一项,各原单分别生成异常。同一派送在行锁内核验批次总退回量不超过未签收余量;所有来源属于本车及对应原单,任一失败整批回滚,原幂等键重放不重复建单或冻结。
minItems: 1
maxItems: 200
items: items:
type: object type: object
required: [deliveryNo, lastMileOrderId, items] required: [deliveryNo, lastMileOrderId, items]
@@ -11,7 +11,7 @@ import java.util.UUID;
import static org.assertj.core.api.Assertions.*; import static org.assertj.core.api.Assertions.*;
class MerchantTransportPhotoMapperTest { class MerchantTransportPhotoMapperTest {
/** 同编号、混装和撤销安排均不能扩大客户读取范围,实际查询使用正式Mapper。 */ /** 同单分托去重且分车全部保留;同编号、混装和撤销安排不扩大客户读取范围。 */
@Test void queriesOnlyExactCustomerOrderAndActiveDispatches() throws Exception { @Test void queriesOnlyExactCustomerOrderAndActiveDispatches() throws Exception {
var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
var db=new JdbcTemplate(data); var db=new JdbcTemplate(data);
@@ -24,13 +24,13 @@ class MerchantTransportPhotoMapperTest {
db.update("INSERT INTO ll_local_looseboard_order VALUES('C1','LB1',1),('C2','LB1',2)"); db.update("INSERT INTO ll_local_looseboard_order VALUES('C1','LB1',1),('C2','LB1',2)");
db.update("INSERT INTO ll_inbound_plan VALUES(11,'C1',1),(12,'C2',2)"); db.update("INSERT INTO ll_inbound_plan VALUES(11,'C1',1),(12,'C2',2)");
db.update("INSERT INTO ll_inbound_plan_pallet VALUES(21,11,'C1'),(22,12,'C2')"); db.update("INSERT INTO ll_inbound_plan_pallet VALUES(21,11,'C1'),(22,12,'C2')");
db.update("INSERT INTO ll_dispatch_order VALUES(31,'PC1','ACTIVE','DELIVERY'),(32,'PC2','CANCELLED','DELIVERY'),(33,'PC3','ACTIVE','LOCAL_TRUCK')"); db.update("INSERT INTO ll_dispatch_order VALUES(31,'PC1','ACTIVE','DELIVERY'),(32,'PC2','CANCELLED','DELIVERY'),(33,'PC3','ACTIVE','LOCAL_TRUCK'),(34,'PC4','ACTIVE','DELIVERY')");
db.update("INSERT INTO ll_dispatch_item VALUES(21,'C1',31),(22,'C2',31),(21,'C1',32),(22,'C1',33)"); db.update("INSERT INTO ll_dispatch_item VALUES(21,'C1',31),(21,'C1',31),(22,'C2',31),(21,'C1',32),(22,'C1',33),(21,'C1',34)");
db.update("INSERT INTO ll_dispatch_truck_task VALUES('PC3','C1','TR1','C1:TR1'),('PC2','C1','TR1','C1:TR1'),('PC1','C2','TR1','C2:TR1')"); db.update("INSERT INTO ll_dispatch_truck_task VALUES('PC3','C1','TR1','C1:TR1'),('PC2','C1','TR1','C1:TR1'),('PC1','C2','TR1','C2:TR1')");
var config=new MybatisConfiguration();config.addMapper(MerchantTransportPhotoMapper.class); var config=new MybatisConfiguration();config.addMapper(MerchantTransportPhotoMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(config);factory.afterPropertiesSet(); var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(config);factory.afterPropertiesSet();
var repository=new MerchantTransportPhotoRepository(new SqlSessionTemplate(factory.getObject()).getMapper(MerchantTransportPhotoMapper.class)); var repository=new MerchantTransportPhotoRepository(new SqlSessionTemplate(factory.getObject()).getMapper(MerchantTransportPhotoMapper.class));
assertThat(repository.looseboardDispatches("C1","LB1")).containsExactly("PC1"); assertThat(repository.looseboardDispatches("C1","LB1")).containsExactly("PC1","PC4");
assertThat(repository.looseboardDispatches("C3","LB1")).isEmpty(); assertThat(repository.looseboardDispatches("C3","LB1")).isEmpty();
assertThat(repository.truckDispatches("C1","TR1")).containsExactly("PC3"); assertThat(repository.truckDispatches("C1","TR1")).containsExactly("PC3");
assertThat(repository.truckDispatches("C2","TR1")).isEmpty(); assertThat(repository.truckDispatches("C2","TR1")).isEmpty();
@@ -9,6 +9,23 @@ import static org.assertj.core.api.Assertions.*;
/** 本车同目的仓跨客户只填一次,先约自动带号,先排可暂缺。 */ /** 本车同目的仓跨客户只填一次,先约自动带号,先排可暂缺。 */
class DispatchDestinationAppointmentsTest { class DispatchDestinationAppointmentsTest {
/** 已预约的编号不能因尚无有效收货确认而丢失,也不能冒充确认时间。 */
@Test void registeredBatchNumberIsVisibleWithoutInventingReceivingConfirmation() {
var batch=new com.baiyue.lastleg.dispatch.mapper.DispatchReadMapper.BatchRow(1L,2L,"FBA_APPOINTMENT:1","AP-1","A","SOURCE","FBA:ONT8",22,"ISA-BOOKED","SUCCESS","ONT8",java.time.LocalDateTime.parse("2026-11-05T10:00:00"),1L);
var booked=DispatchDestinationAppointments.booked(batch,null);
assertThat(booked.status()).isEqualTo("REGISTERED");
assertThat(booked.confirmedAt()).isNull();
var rows=resolve(List.of(item("A","ONT8")),maintenance(new DestinationAppointment("FBA:ONT8",null)),List.of(booked));
assertThat(rows).singleElement().satisfies(a->{
assertThat(a.appointmentNo()).isEqualTo("ISA-BOOKED");
assertThat(a.status()).isEqualTo("REGISTERED");assertThat(a.confirmedAt()).isNull();
});
var window=new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.ReceivingWindow("FBA_APPOINTMENT:1","AP-1","A","FBA:ONT8","ISA-CURRENT",java.time.Instant.parse("2026-11-06T10:00:00Z"),null,"AG-1",1);
var confirmed=DispatchDestinationAppointments.booked(batch,window);
assertThat(confirmed.appointmentNo()).isEqualTo("ISA-CURRENT");
assertThat(confirmed.confirmedAt()).isEqualTo(window.startAt());
assertThat(confirmed.status()).isEqualTo("SUCCESS");
}
@Test void groupsMultipleCustomersAndPalletsByDestination() { @Test void groupsMultipleCustomersAndPalletsByDestination() {
var rows=resolve(List.of(item("A","ONT8"),item("B","ONT8"),item("A","LGB8")),null,List.of()); var rows=resolve(List.of(item("A","ONT8"),item("B","ONT8"),item("A","LGB8")),null,List.of());
assertThat(rows).extracting(FbaAppointment::destinationWarehouseCode).containsExactly("LGB8","ONT8"); assertThat(rows).extracting(FbaAppointment::destinationWarehouseCode).containsExactly("LGB8","ONT8");
@@ -8,12 +8,64 @@ import com.baiyue.lastleg.dispatch.port.*;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.*; import java.util.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*; import static org.mockito.ArgumentMatchers.*;
import static org.assertj.core.api.Assertions.*; import static org.assertj.core.api.Assertions.*;
class DispatchLooseboardDisplayServiceTest { class DispatchLooseboardDisplayServiceTest {
/** 维护车辆与排车详情共用订单投影,同单两托只展示一行且未知重量不补零。 */
@Test void looseboardOrderCombinesPalletsAndRetainsAllSourceKeys() {
var repository=mock(LooseboardCargoRepository.class);
when(repository.businessSources(any())).thenReturn(List.of(new BusinessSource("A","ZY1","LB202611050003",11L),new BusinessSource("A","ZY2","LB202611050002",13L)));
var first=item("A","ZY1",11L,"ZY1");var second=item("A","ZY1",12L,"ZY1");var other=item("A","ZY2",13L,"ZY2");
first.setVolumeCbm(new BigDecimal("5"));second.setVolumeCbm(new BigDecimal("5"));other.setVolumeCbm(new BigDecimal("4"));
var result=details(repository,List.of(first,second,other));
assertThat(result).hasSize(2);assertThat(result.get(0).orderNo()).isEqualTo("LB202611050003");
assertThat(result.get(0).palletCount()).isEqualTo(2);assertThat(result.get(0).volumeCbm()).isEqualByComparingTo("10");
assertThat(result.get(0).grossWeightKg()).isNull();assertThat(result.get(0).sourceKeys()).containsExactly("PLAN:11","PLAN:12");
assertThat(result.get(1).palletCount()).isEqualTo(1);assertThat(result.get(1).volumeCbm()).isEqualByComparingTo("4");
assertThat(first.getOrderNo()).isEqualTo("ZY1");assertThat(second.getVolumeCbm()).isEqualByComparingTo("5");
}
/** 同号跨客户不能合并,普通来源不能仅因编号类似散板而变成散板。 */
@Test void sameOrderNumberKeepsCustomerAndOrdinarySourceBoundaries() {
var repository=mock(LooseboardCargoRepository.class);
when(repository.businessSources(any())).thenReturn(List.of(new BusinessSource("A","ZY1","LB1",11L),new BusinessSource("B","ZY1","LB1",12L)));
var result=details(repository,List.of(item("A","ZY1",11L,"ZY1"),item("B","ZY1",12L,"ZY1"),item("C","LB1",13L,"REAL"),item("C","LB1",14L,"REAL")));
assertThat(result).hasSize(4);assertThat(result).extracting(DispatchViews.OrderDetail::customerId).containsExactly("A","B","C","C");
assertThat(result.get(2).sourceBusinessType()).isNull();assertThat(result.get(2).containerNo()).isEqualTo("REAL");
}
/** 同一实托关联多个来源只计一托和一次整托测量;缺测量的订单总量仍未知。 */
@Test void sharedPhysicalPalletIsCountedOnceAndPartialMeasurementRemainsUnknown() {
var repository=mock(LooseboardCargoRepository.class);
when(repository.businessSources(any())).thenReturn(List.of(new BusinessSource("A","ZY1","LB1",11L)));
var first=item("A","ZY1",11L,"ZY1");var second=item("A","ZY1",12L,"ZY1");
first.setWarehousePalletId(91L);second.setWarehousePalletId(91L);
first.setGrossWeightKg(new BigDecimal("30"));second.setGrossWeightKg(new BigDecimal("30.00"));
first.setVolumeCbm(new BigDecimal("5"));second.setVolumeCbm(new BigDecimal("5.0"));
var combined=details(repository,List.of(first,second)).get(0);
assertThat(combined.palletCount()).isEqualTo(1);assertThat(combined.volumeCbm()).isEqualByComparingTo("5");assertThat(combined.grossWeightKg()).isEqualByComparingTo("30");
second.setWarehousePalletId(92L);second.setVolumeCbm(null);
var partial=details(repository,List.of(first,second)).get(0);
assertThat(partial.palletCount()).isEqualTo(2);assertThat(partial.volumeCbm()).isNull();assertThat(partial.grossWeightKg()).isEqualByComparingTo("60");
second.setWarehousePalletId(91L);second.setGrossWeightKg(new BigDecimal("31"));
assertThat(details(repository,List.of(first,second)).get(0).grossWeightKg()).isNull();
}
/** 混托多订单无法分摊测量时,不给散板复制整托重量、体积或托数。 */
@Test void sharedPalletWithMultipleOrdersDoesNotInventPerOrderMeasurements() {
var repository=mock(LooseboardCargoRepository.class);
when(repository.businessSources(any())).thenReturn(List.of(new BusinessSource("A","ZY1","LB1",11L)));
var mixed=item("A","ZY1",11L,"ZY1");mixed.setOrderRefs("ZY1,OTHER");mixed.setGrossWeightKg(new BigDecimal("30"));mixed.setVolumeCbm(new BigDecimal("5"));
var result=details(repository,List.of(mixed));
assertThat(result).hasSize(1);assertThat(result.get(0).palletCount()).isNull();assertThat(result.get(0).grossWeightKg()).isNull();assertThat(result.get(0).volumeCbm()).isNull();
}
/** 调用正式排车关联订单投影,避免为聚合规则复制一份测试实现。 */
private List<DispatchViews.OrderDetail> details(LooseboardCargoRepository repository,List<DispatchItem> cargo) {
var aggregate=new DispatchAggregateService(null,null,null,null,null,null,new ObjectMapper());
ReflectionTestUtils.setField(aggregate,"looseboardDisplay",new DispatchLooseboardDisplayService(repository));
return ReflectionTestUtils.invokeMethod(aggregate,"orderDetails",cargo);
}
/** 历史排车仍按真实关联显示LB原单;不把内部ZY号代填为入仓码或集装箱号。 */ /** 历史排车仍按真实关联显示LB原单;不把内部ZY号代填为入仓码或集装箱号。 */
@Test void historicalDetailsUseBusinessOrderAndNoInventedContainer() { @Test void historicalDetailsUseBusinessOrderAndNoInventedContainer() {
var repository=mock(LooseboardCargoRepository.class); var repository=mock(LooseboardCargoRepository.class);
@@ -64,6 +116,7 @@ class DispatchLooseboardDisplayServiceTest {
/** 构造旧排车保存的履约快照,验证读取时纠正且不改原来源。 */ /** 构造旧排车保存的履约快照,验证读取时纠正且不改原来源。 */
private DispatchItem item(String customer,String number,long pallet,String container) { private DispatchItem item(String customer,String number,long pallet,String container) {
var item=new DispatchItem();item.setCustomerId(customer);item.setOrderNo(number);item.setOrderRefs(number);item.setPlanPalletId(pallet);item.setInboundCode(number); var item=new DispatchItem();item.setCustomerId(customer);item.setOrderNo(number);item.setOrderRefs(number);item.setPlanPalletId(pallet);item.setInboundCode(number);
item.setSourceKey("PLAN:"+pallet);
item.setSourceSnapshotJson("{\"containerNo\":\""+container+"\",\"planNo\":\"plan\"}");return item; item.setSourceSnapshotJson("{\"containerNo\":\""+container+"\",\"planNo\":\"plan\"}");return item;
} }
} }
@@ -19,6 +19,26 @@ import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*; import static org.mockito.ArgumentMatchers.*;
class DispatchLooseboardExecutionServiceTest { class DispatchLooseboardExecutionServiceTest {
/** 同一客户散板订单多托只显示一次,保存的照片逐托原子留证并精确计量。 */
@Test void oneOrderSharesPhotosAcrossPalletsWithoutCrossingCustomers() {
var f=new Fixture(false);var cargo=f.repository.cargo(1);
cargo.add(f.item(104,"A","ZY1"));cargo.add(f.item(105,"C","ZY3"));
when(f.sourceRepo.businessSources(anyList())).thenReturn(List.of(
new BusinessSource("A","ZY1","LB1",101L,"WAREHOUSE_PICKUP"),
new BusinessSource("A","ZY1","LB1",104L,"WAREHOUSE_PICKUP"),
new BusinessSource("B","ZY2","LB2",102L,"WAREHOUSE_PICKUP"),
new BusinessSource("C","ZY3","LB1",105L,"WAREHOUSE_PICKUP")));
var initial=f.service.get(f.actor,"PC1");
assertThat(initial.items()).hasSize(3);
assertThat(initial.items().get(0).packages()).isEqualTo(4);
var saved=f.save(101,Phase.PICKUP,1);
assertThat(saved.items().get(0).pickupPhotos()).hasSize(1);
assertThat(saved.items().get(2).pickupPhotos()).isEmpty();
assertThat(f.facts.current(f.row)).extracting(Evidence::itemId).containsExactlyInAnyOrder("101","104");
f.replace(104,Phase.PICKUP,1L,2L);
assertThat(f.service.get(f.actor,"PC1").items().get(0).pickupPhotos()).hasSize(2);
assertThatThrownBy(()->f.save(105,Phase.PICKUP,1)).hasMessageContaining("不能重复使用");
}
/** 装车后仓内货可先送达,异地货独立取送,不要求仓内货重复上传取货凭证。 */ /** 装车后仓内货可先送达,异地货独立取送,不要求仓内货重复上传取货凭证。 */
@Test void warehouseArrivalDoesNotWaitForRemotePickupOrOwnPickupPhoto() { @Test void warehouseArrivalDoesNotWaitForRemotePickupOrOwnPickupPhoto() {
var f=new Fixture(true);f.repository.cargo(1).get(0).setWarehousePalletId(501L); var f=new Fixture(true);f.repository.cargo(1).get(0).setWarehousePalletId(501L);
@@ -0,0 +1,67 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.LoadingMeasurement;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 混排仓内出库允许未知实测,但已知容量超限仍必须拒绝。 */
class DispatchMixedCapacityTest {
/** 仓内九箱已核平时,缺少重量体积不应额外要求异地货补测。 */
@Test void unknownMeasurementsDoNotBlockWarehouseLoading() {
var f=new Fixture(null,null);
assertThatCode(()->f.service.requireRemoteCapacity(f.row,f.measured(null,null,1))).doesNotThrowAnyException();
}
/** 部分未知不能掩盖已知重量已超载。 */
@Test void knownWeightStillBlocksWithUnknownVolume() {
var f=new Fixture("600",null);
assertThatThrownBy(()->f.service.requireRemoteCapacity(f.row,f.measured("401",null,1))).hasMessageContaining("超过本车容量");
}
/** 部分未知不能掩盖已知体积已超载。 */
@Test void knownVolumeStillBlocksWithUnknownWeight() {
var f=new Fixture(null,"30");
assertThatThrownBy(()->f.service.requireRemoteCapacity(f.row,f.measured(null,"21",1))).hasMessageContaining("超过本车容量");
}
/** 无重量体积依据时,明确的托位上限仍然有效。 */
@Test void palletSpacesRemainLimited() {
var f=new Fixture(null,null);
assertThatThrownBy(()->f.service.requireRemoteCapacity(f.row,f.measured(null,null,10))).hasMessageContaining("超过本车容量");
}
/** 已知占用恰好达到容量时允许确认。 */
@Test void exactKnownCapacityIsAllowed() {
var f=new Fixture("600","30");
assertThatCode(()->f.service.requireRemoteCapacity(f.row,f.measured("400","20",9))).doesNotThrowAnyException();
}
/** 隔离容量校验,异地身份及货量均由正式依赖返回。 */
private static class Fixture {
final DispatchAggregateService service=mock(DispatchAggregateService.class,CALLS_REAL_METHODS);
final DispatchAggregate row=new DispatchAggregate();
/** 构造一条异地取货来源与车辆真实容量,不制造未知实测。 */
Fixture(String weight,String volume) {
var repository=mock(DispatchAggregateRepository.class);
var facts=mock(DispatchLooseboardFacts.class);
ReflectionTestUtils.setField(service,"repository",repository);
ReflectionTestUtils.setField(service,"looseboardFacts",facts);
ReflectionTestUtils.setField(service,"json",new ObjectMapper());
row.setId(1L);
row.setResourcesJson("{\"vehicle\":{\"capacity\":{\"palletSpaces\":10,\"maxLoadKg\":1000,\"maxVolumeCbm\":50}}}");
var remote=new DispatchItem();remote.setId(2L);
remote.setGrossWeightKg(weight==null?null:new BigDecimal(weight));
remote.setVolumeCbm(volume==null?null:new BigDecimal(volume));
when(facts.remoteItemIds(1L)).thenReturn(Set.of(2L));
when(repository.cargo(1L)).thenReturn(List.of(remote));
}
/** 保留仓内原始可空测量值供校验使用。 */
List<LoadingMeasurement> measured(String weight,String volume,int spaces) {
return List.of(new LoadingMeasurement(3L,spaces,weight==null?null:new BigDecimal(weight),volume==null?null:new BigDecimal(volume),1));
}
}
}
@@ -62,6 +62,13 @@ class DispatchPdaReadPersistenceTest {
assertThat(task.items()).allSatisfy(item->assertThat(item.positions()).hasSize(1)); assertThat(task.items()).allSatisfy(item->assertThat(item.positions()).hasSize(1));
assertThatThrownBy(()->f.tx.execute(s->queries.task(new DispatchContracts.Actor("foreign",ActorMode.PDA,"99"),created.dispatchNo()))) assertThatThrownBy(()->f.tx.execute(s->queries.task(new DispatchContracts.Actor("foreign",ActorMode.PDA,"99"),created.dispatchNo())))
.isInstanceOf(com.baiyue.common.core.exception.BizException.class).hasMessageContaining("当前绑定仓无此排车作业"); .isInstanceOf(com.baiyue.common.core.exception.BizException.class).hasMessageContaining("当前绑定仓无此排车作业");
for (String type : List.of("LOCAL_TRUCK", "PICKUP")) {
f.db.update("UPDATE ll_dispatch_order SET task_type=?", type);
assertThat(f.tx.<List<DispatchViews.PdaTask>>execute(s->queries.list(actor,null))).isEmpty();
assertThatThrownBy(()->f.tx.execute(s->queries.task(actor,created.dispatchNo())))
.isInstanceOf(com.baiyue.common.core.exception.BizException.class);
}
f.db.update("UPDATE ll_dispatch_order SET task_type='DELIVERY'");
f.db.update("DELETE FROM ll_dispatch_pda_pick_order WHERE customer_id='202'"); f.db.update("DELETE FROM ll_dispatch_pda_pick_order WHERE customer_id='202'");
assertThat(f.tx.execute(s->queries.task(actor,created.dispatchNo())).pickingReconciled()).isFalse(); assertThat(f.tx.execute(s->queries.task(actor,created.dispatchNo())).pickingReconciled()).isFalse();
} }
@@ -0,0 +1,53 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
import com.baiyue.lastleg.dispatch.repository.*;
import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
/** 散板加货按完整订单分页,来源跨页和客户隔离不能丢失。 */
class LooseboardCargoServiceTest {
/** 同单51托跨来源页仍为一行,另一客户同号订单独立分页。 */
@Test void orderPaginationIncludesEveryPalletAndKeepsCustomersSeparate() throws Exception {
var cargo=mock(LooseboardCargoRepository.class);var repository=mock(DispatchAggregateRepository.class);
var workflow=mock(DispatchAggregateService.class);var json=new ObjectMapper();
var service=new LooseboardCargoService(cargo,repository,workflow,new DispatchCommandHasher(json));
var root=new DispatchAggregate();root.setTaskType("DELIVERY");root.setStatus("ACTIVE");root.setPublishLooseboardDemand(true);root.setOverseasWarehouseId("9");
when(repository.find("PC1")).thenReturn(root);when(cargo.readyPlans("9")).thenReturn(List.of(1L));
var sources=new ArrayList<Candidate>();for(int i=1;i<=51;i++)sources.add(candidate(json,i,"A",i==51));
sources.add(candidate(json,52,"B",false));
when(workflow.candidates(any(),any())).thenAnswer(call->{
CandidateQuery query=call.getArgument(1);int offset=(query.page()-1)*50;
return new CandidateList(sources.subList(offset,Math.min(offset+50,sources.size())),Instant.now(),false,52,query.page(),50,2);
});
var actor=new DispatchContracts.Actor("operator",DispatchTypes.ActorMode.PLATFORM,null);
var first=service.candidates(actor,"PC1",1,1);
assertThat(first.total()).isEqualTo(2);assertThat(first.pages()).isEqualTo(2);
assertThat(first.candidates()).singleElement().satisfies(order->{
assertThat(order.availablePackages()).isEqualTo(102);
assertThat(order.selectionGroup().sources()).hasSize(51);
assertThat(order.selectionGroup().pallets()).hasSize(51);
assertThat(order.blockReasons()).containsExactly("最后一托被占用");
});
assertThat(service.candidates(actor,"PC1",2,1).candidates()).singleElement().satisfies(order->{
assertThat(order.customerId()).isEqualTo("B");assertThat(order.availablePackages()).isEqualTo(2);
});
}
/** 构造带权威选择版本、物理组成及阻断事实的来源。 */
private static Candidate candidate(ObjectMapper json,int id,String customer,boolean blocked) throws Exception {
return json.readValue("""
{"selection":{"sourceType":"INBOUND_PLAN","planPalletId":%d,"sourceVersion":"v%d"},"sourceKey":"K%d","sourceVersion":"v%d",
"customerId":"%s","orderNo":"LB1","warehouseId":"9","availablePackages":2,"appointmentBatches":[],"blockReasons":%s,
"selectionGroup":{"groupKey":"G%d","groupVersion":"g%d","complete":true,"blockReasons":[],
"sources":[{"sourceKey":"K%d","customerId":"%s","packages":2,"selection":{"sourceType":"INBOUND_PLAN","planPalletId":%d,"sourceVersion":"v%d"}}],
"pallets":[{"palletNo":"P%d","sourceKeys":["K%d"],"packageCount":2}]}}
""".formatted(id,id,id,id,customer,blocked?"[\"最后一托被占用\"]":"[]",id,id,id,customer,id,id,id,id),Candidate.class);
}
}
@@ -207,7 +207,7 @@ class ExceptionOrderLifecycleTest {
var order = order("PENDING", 1); var order = order("PENDING", 1);
order.setExceptionType("RETURN"); order.setDeliveryNo("DL-1"); order.setExceptionType("RETURN"); order.setDeliveryNo("DL-1");
order.setReturnContextJson("{\"deliveryStatus\":\"DELIVERED\",\"tailId\":null,\"tailStatus\":null,\"abnormal\":null}"); order.setReturnContextJson("{\"deliveryStatus\":\"DELIVERED\",\"tailId\":null,\"tailStatus\":null,\"abnormal\":null}");
var delivery = new DeliveryOrder(); delivery.setStatus("RETURN_PENDING"); delivery.setVersion(3L); var delivery = new DeliveryOrder(); delivery.setId(71L); delivery.setStatus("RETURN_PENDING"); delivery.setVersion(3L);
var signedAt = LocalDateTime.of(2026, 9, 1, 12, 0); delivery.setSignedAt(signedAt); var signedAt = LocalDateTime.of(2026, 9, 1, 12, 0); delivery.setSignedAt(signedAt);
when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order); when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order);
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery); when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
@@ -36,7 +36,7 @@ class ReturnCartonReturnPersistenceTest {
var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
db=new JdbcTemplate(data);tx=new TransactionTemplate(new DataSourceTransactionManager(data)); db=new JdbcTemplate(data);tx=new TransactionTemplate(new DataSourceTransactionManager(data));
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true); var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);
var mappers=List.of(ExceptionOrderMapper.class,ExceptionOrderCommandMapper.class,com.baiyue.lastleg.mapper.LastlegOrderMapper.class,com.baiyue.lastleg.mapper.LastlegInboundOrderMapper.class,ExceptionReturnItemMapper.class,ReturnCartonStockReceiptMapper.class, var mappers=List.of(ExceptionWorkflowMapper.class,ExceptionOrderMapper.class,ExceptionOrderCommandMapper.class,com.baiyue.lastleg.mapper.LastlegOrderMapper.class,com.baiyue.lastleg.mapper.LastlegInboundOrderMapper.class,ExceptionReturnItemMapper.class,ReturnCartonStockReceiptMapper.class,
ReturnInventoryCartonMapper.class,ReturnInventoryCartonItemMapper.class,ReturnInventoryBalanceMapper.class,ReturnInventoryMovementMapper.class, ReturnInventoryCartonMapper.class,ReturnInventoryCartonItemMapper.class,ReturnInventoryBalanceMapper.class,ReturnInventoryMovementMapper.class,
DeliveryOrderMapper.class,OutboundOrderMapper.class,OutboundPalletItemMapper.class,OutboundPdaPickMapper.class,OutboundPdaTaskMapper.class,DispatchAggregateMapper.class,DispatchReadMapper.class,DispatchItemMapper.class,DispatchFeeCargoMapper.class, DeliveryOrderMapper.class,OutboundOrderMapper.class,OutboundPalletItemMapper.class,OutboundPdaPickMapper.class,OutboundPdaTaskMapper.class,DispatchAggregateMapper.class,DispatchReadMapper.class,DispatchItemMapper.class,DispatchFeeCargoMapper.class,
DispatchPdaTaskMapper.class,DispatchPdaPickMapper.class,DispatchPdaPickOrderMapper.class,DeliveryOperationMapper.class); DispatchPdaTaskMapper.class,DispatchPdaPickMapper.class,DispatchPdaPickOrderMapper.class,DeliveryOperationMapper.class);
@@ -165,6 +165,75 @@ class ReturnCartonReturnPersistenceTest {
assertThatThrownBy(()->tx.execute(t->business.markReturn("C","OP","操作员","DL-1",foreign))).hasMessageContaining("主单不属于该派送单"); assertThatThrownBy(()->tx.execute(t->business.markReturn("C","OP","操作员","DL-1",foreign))).hasMessageContaining("主单不属于该派送单");
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_exception_order",Long.class)).isZero(); assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_exception_order",Long.class)).isZero();
} }
/** 同派送两张原单可一起退回,部分签收数量保留,同键重放不增异常。 */
@Test void multipleMastersOfOneDeliveryFreezeTogetherAndReplayWithoutDuplicateExceptions() {
var requests=prepareTwoMasterReturns();var business=business();
var created=tx.execute(t->business.markReturns("C","OP","操作员","DL-1",requests));
assertThat(created).hasSize(2);
assertThat(db.queryForList("SELECT last_mile_order_id FROM ll_exception_order ORDER BY last_mile_order_id",Long.class)).containsExactly(100L,101L);
assertThat(db.queryForObject("SELECT SUM(approved_return_packages) FROM ll_exception_return_item",Long.class)).isEqualTo(2);
assertThat(db.queryForObject("SELECT signed_packages FROM ll_delivery_order WHERE id=3",Long.class)).isEqualTo(2);
var replay=tx.execute(t->business.markReturns("C","OP","操作员","DL-1",requests));
assertThat(replay).allSatisfy(row->assertThat(row.replayed()).isTrue());
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_exception_order",Long.class)).isEqualTo(2);
}
/** 从排车适配器传入两张原单,真实服务分别建异常;各来源仍保持独立。 */
@Test void dispatchAdapterCreatesBothReturnsThroughRealService() {
var requests=prepareTwoMasterReturns();var business=business();
var repository=mock(com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository.class);
when(repository.deliveries(10L)).thenReturn(List.of(new com.baiyue.lastleg.exceptionorder.mapper.ExceptionWorkflowMapper.Delivery("C","DL-1")));
var adapter=new DispatchExceptionAdapter(repository,business);
var selections=requests.stream().map(r->new com.baiyue.lastleg.dispatch.port.DispatchExceptionPort.Selection("DL-1",r.lastMileOrderId(),
r.items().stream().map(i->new com.baiyue.lastleg.dispatch.port.DispatchExceptionPort.Item(i.outboundPalletItemId(),i.inventoryPositionId(),i.returnCartonId(),i.packageCount())).toList())).toList();
var command=new com.baiyue.lastleg.dispatch.port.DispatchCommands.MarkException(0L,"adapter-multiple",com.baiyue.lastleg.dispatch.port.DispatchTypes.ExceptionType.PARTIAL_RECEIPT,
com.baiyue.lastleg.dispatch.port.DispatchTypes.ReturnDirection.OVERSEAS_WAREHOUSE,"多单部分签收",List.of(),selections);
tx.executeWithoutResult(t->adapter.register(10L,"OP",command));
assertThat(db.queryForList("SELECT last_mile_order_id FROM ll_exception_order ORDER BY last_mile_order_id",Long.class)).containsExactly(100L,101L);
assertThat(db.queryForObject("SELECT SUM(approved_return_packages) FROM ll_exception_return_item",Long.class)).isEqualTo(2);
}
/** 多单退回可按任意顺序取消;最后一单取消才恢复批次前状态,签收历史保持不变。 */
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans={false,true})
void cancellingBothMastersRestoresOriginalStateInEitherOrder(boolean reversed) {
var requests=prepareTwoMasterReturns();var business=business();
var created=new ArrayList<>(tx.execute(t->business.markReturns("C","OP","操作员","DL-1",requests)));
if(reversed)Collections.reverse(created);
for(int index=0;index<created.size();index++) {
var row=created.get(index);
tx.executeWithoutResult(t->business.cancel("C","OP",row.exceptionNo(),new ExceptionOrderRequests.Cancel(0,"cancel-"+row.id(),"取消本单退回")));
assertThat(db.queryForObject("SELECT status FROM ll_delivery_order WHERE id=3",String.class)).isEqualTo(index==0?"RETURN_PENDING":"DELIVERING");
}
assertThat(db.queryForObject("SELECT signed_packages FROM ll_delivery_order WHERE id=3",Long.class)).isEqualTo(2);
assertThat(db.queryForList("SELECT abnormal FROM ll_order",String.class)).doesNotContain("true");
}
/** 第二单借用第一单原箱时,第一单异常和冻结数量也必须回滚。 */
@Test void invalidSecondMasterRollsBackTheWholeMultiOrderReturn() {
var requests=prepareTwoMasterReturns();var business=business();
var wrong=new ExceptionOrderRequests.MarkReturn(101L,"原因","OVERSEAS_WAREHOUSE",null,requests.get(0).items(),"batch-wrong");
assertThatThrownBy(()->tx.execute(t->business.markReturns("C","OP","操作员","DL-1",List.of(requests.get(0),wrong))))
.hasMessageContaining("不属于该派送单和尾程主单");
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_exception_order",Long.class)).isZero();
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_exception_return_item",Long.class)).isZero();
assertThat(db.queryForObject("SELECT status FROM ll_delivery_order WHERE id=3",String.class)).isEqualTo("DELIVERING");
}
/** 单笔数量均合法也不能让合计退回超过部分签收后的未签数量。 */
@Test void multiOrderTotalCannotIncludeAlreadySignedPackages() {
var requests=prepareTwoMasterReturns();var business=business();
db.update("UPDATE ll_delivery_order SET signed_packages=3 WHERE id=3");
assertThatThrownBy(()->tx.execute(t->business.markReturns("C","OP","操作员","DL-1",requests))).hasMessageContaining("退回总箱数");
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_exception_order",Long.class)).isZero();
}
/** 两张原单共享派送单,但各自只拥有一只已实际出库的原箱。 */
private List<ExceptionOrderRequests.MarkReturn> prepareTwoMasterReturns() {
prepareMarkReturn();
db.update("UPDATE ll_delivery_order SET status='DELIVERING',package_count=4,signed_packages=2 WHERE id=3");
db.update("INSERT INTO ll_order(id,customer_id,order_no,delivery_order_no,outbound_order_no,overseas_warehouse_id,package_count) VALUES(101,'C','LM-2','DL-1','OUT-1','92',1)");
db.update("INSERT INTO ll_dispatch_pda_pick(id,customer_id,task_id,dispatch_item_id,return_carton_id,packages,product_quantity) VALUES(104,'C',102,4,102,1,1)");
db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,packages) VALUES(106,'C',104,101,1)");
db.update("INSERT INTO ll_return_inventory_carton(id,customer_id,overseas_warehouse_id,carton_mark,status,total_quantity) VALUES(102,'C','92','BOX-2','OUTBOUNDED',1)");
return List.of(new ExceptionOrderRequests.MarkReturn(100L,"多单部分签收","OVERSEAS_WAREHOUSE",null,List.of(new ExceptionOrderRequests.ReturnItem(5L,null,1,101L)),"batch-master-1"),
new ExceptionOrderRequests.MarkReturn(101L,"多单部分签收","OVERSEAS_WAREHOUSE",null,List.of(new ExceptionOrderRequests.ReturnItem(5L,null,1,102L)),"batch-master-2"));
}
/** 将真实已出库换标主单置为拒签,不添加普通入仓关系。 */ /** 将真实已出库换标主单置为拒签,不添加普通入仓关系。 */
private void prepareMarkReturn() { private void prepareMarkReturn() {
db.update("DELETE FROM ll_exception_return_item");db.update("DELETE FROM ll_exception_order"); db.update("DELETE FROM ll_exception_return_item");db.update("DELETE FROM ll_exception_order");
@@ -174,7 +243,8 @@ class ReturnCartonReturnPersistenceTest {
/** 使用真实Mapper组装异常业务服务,资源和文件网关维持隔离。 */ /** 使用真实Mapper组装异常业务服务,资源和文件网关维持隔离。 */
private ExceptionOrderService business() { private ExceptionOrderService business() {
var business=new ExceptionOrderService(); var business=new ExceptionOrderService();
org.springframework.test.util.ReflectionTestUtils.setField(business,"workflow",org.mockito.Mockito.mock(com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository.class)); org.springframework.test.util.ReflectionTestUtils.setField(business,"workflow",new com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository(
map(ExceptionWorkflowMapper.class),map(ExceptionOrderMapper.class),map(com.baiyue.lastleg.mapper.LastlegOrderMapper.class)));
org.springframework.test.util.ReflectionTestUtils.setField(business,"exceptionOrders",map(ExceptionOrderMapper.class)); org.springframework.test.util.ReflectionTestUtils.setField(business,"exceptionOrders",map(ExceptionOrderMapper.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"returnItems",map(ExceptionReturnItemMapper.class)); org.springframework.test.util.ReflectionTestUtils.setField(business,"returnItems",map(ExceptionReturnItemMapper.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"commands",map(ExceptionOrderCommandMapper.class)); org.springframework.test.util.ReflectionTestUtils.setField(business,"commands",map(ExceptionOrderCommandMapper.class));
@@ -13,6 +13,24 @@ import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
class FleetHandoverPdaQueryTest { class FleetHandoverPdaQueryTest {
/** 混排整车109箱,仓内真实发运9箱时按原实载客户及目的地读取交接。 */
@Test void mixedDispatchUsesOriginalWarehouseLoadBeforeAndAfterRemotePickup() {
var binding=bindings.findByDispatchNo("PC-A");binding.setPackageCount(109);
var departed=new FleetTripHistory();departed.setSnapshotJson("""
{"trip":{"tripNo":"TR-ONE","warehouseId":"92","vehiclePlate":"ORIGINAL"},
"dispatches":[{"dispatchNo":"PC-A","packageCount":109}],
"confirmedLoad":{"cargo":[{"dispatchNo":"PC-A","customerId":"CUSTOMER-A","warehousePalletId":"501","destinationKey":"FBA:ONE","packages":9}]}}
""");
when(history.selectList(any())).thenReturn(List.of(departed));
var delivery=new FleetTransitEvidence.Delivery(new FleetHandoverPolicy.Delivery("CUSTOMER-A","PC-A","DL-9","FBA:ONE",9,Instant.now(),List.of()),"DELIVERED",1,new FleetTransitEvidence.Transport("ORIGINAL","原司机",null));
for(int shipped:List.of(9,109)){
when(dispatches.transitEvidence("PC-A",false)).thenReturn(new FleetTransitEvidence("PC-A","92",shipped,List.of(delivery)));
assertEquals(9,service.status(92,"PC-A").deliveries().get(0).packages());
}
var wrongCustomer=new FleetTransitEvidence.Delivery(new FleetHandoverPolicy.Delivery("OTHER","PC-A","DL-9","FBA:ONE",9,null,List.of()),"DELIVERING",1,delivery.transport());
when(dispatches.transitEvidence("PC-A",false)).thenReturn(new FleetTransitEvidence("PC-A","92",109,List.of(wrongCustomer)));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.status(92,"PC-A"));
}
final FleetTripDispatchMapper bindings = mock(FleetTripDispatchMapper.class); final FleetTripDispatchMapper bindings = mock(FleetTripDispatchMapper.class);
final FleetTripMapper trips = mock(FleetTripMapper.class); final FleetTripMapper trips = mock(FleetTripMapper.class);
final FleetTripHistoryMapper history = mock(FleetTripHistoryMapper.class); final FleetTripHistoryMapper history = mock(FleetTripHistoryMapper.class);
@@ -18,6 +18,28 @@ import static org.mockito.Mockito.*;
/** 同筛选完整统计、旧单不回填和权限隔离的直接回归。 */ /** 同筛选完整统计、旧单不回填和权限隔离的直接回归。 */
class OwnedBusinessQueryTest { class OwnedBusinessQueryTest {
/** 自有、外配共享散板订单明细,跨客户同号不合并,目的地和托盘都不丢失。 */
@Test void detailsGroupLooseboardSourcesUsingDispatchOrderAuthorityInBothScopes() {
var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec();var dispatch=mock(DispatchDetailReader.class);
var original=mock(com.baiyue.lastleg.dispatch.port.DispatchViews.Detail.class);
var first=OwnedBusinessFixtures.cargo().get(0);var second=OwnedBusinessFixtures.cargo().get(1);var other=OwnedBusinessFixtures.cargo().get(0);
second.setCustomerId(first.getCustomerId());second.setWarehousePalletId(32L);second.setPalletNo("PAL-32");second.setDestinationWarehouseCode("LAX9");second.setPackageCount(8);
other.setCustomerId("102");other.setOrderNo("LB-SAME-NUMBER");
when(repo.cargo(any())).thenReturn(List.of(first,second,other));when(repo.batches(any())).thenReturn(List.of());
when(dispatch.detail(any(),any())).thenReturn(original);
when(original.orderDetails()).thenReturn(List.of(new com.baiyue.lastleg.dispatch.port.DispatchViews.OrderDetail("101","客户A","LB1",null,null,2,null,null,null,first.getSourceKey(),"LOCAL_LOOSEBOARD","LB1",List.of(first.getSourceKey(),second.getSourceKey())),
new com.baiyue.lastleg.dispatch.port.DispatchViews.OrderDetail("102","客户B","LB1",null,null,1,null,null,null,other.getSourceKey(),"LOCAL_LOOSEBOARD","LB1",List.of(other.getSourceKey()))));
var service=new OwnedBusinessQueryService(repo,new OwnedBusinessProjection(codec),codec,dispatch);
for(var scope:List.of(com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope.OWNED,com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope.OUTSOURCED)) {
var source=OwnedBusinessFixtures.source();source.setPooledCargo(scope.isPooledCargo());
var result=service.detailForWrite(OwnedBusinessFixtures.actor(),source,scope);
assertEquals(2,result.cargo().size());var order=result.cargo().get(0);
assertEquals("LB1",order.orderNo());assertNull(order.inboundCode());assertEquals("9",order.packages());
assertEquals("PAL-31、PAL-32",order.palletNo());assertEquals(List.of("FBA:ONT8","FBA:LAX9"),order.destinationKeys());
assertEquals("102",result.cargo().get(1).customerId());assertEquals("LB1",result.cargo().get(1).orderNo());
}
assertEquals("ORDER-IN-A",first.getOrderNo());verify(repo,never()).update(any(),anyLong());
}
/** 当前页只有一车时统计仍来自全量,日期边界转换为UTC且共用过滤。 */ /** 当前页只有一车时统计仍来自全量,日期边界转换为UTC且共用过滤。 */
@Test void summaryDoesNotUseCurrentPageLengthAndDateUsesPlannedDeparture() { @Test void summaryDoesNotUseCurrentPageLengthAndDateUsesPlannedDeparture() {
var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec(); var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec();
@@ -0,0 +1,156 @@
package com.baiyue.lastleg.fleet.reporting;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.fleet.trip.FleetTripCostService;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.AfterEach;
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.time.Instant;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.mock;
public class TripOperationsRepositoryTest {
private JdbcTemplate jdbc;
private TripOperationsRepository repository;
/** 使用真实 Mapper 覆盖灰度历史与测试版运输查询的合并。 */
@BeforeEach void setUp() throws Exception {
var source = new DriverManagerDataSource(
"jdbc:h2:mem:trip-report-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;INIT=SET TIME ZONE '+08:00'", "sa", "");
jdbc = new JdbcTemplate(source);
jdbc.execute("CREATE TABLE ll_fleet_trip(id BIGINT PRIMARY KEY,trip_no VARCHAR(64),trip_purpose VARCHAR(16),status VARCHAR(32),warehouse_id BIGINT,departed_at TIMESTAMP,completed_at TIMESTAMP)");
jdbc.execute("CREATE TABLE ll_fleet_trip_history(trip_id BIGINT,record_version BIGINT,action VARCHAR(32),snapshot_json CLOB)");
jdbc.execute("CREATE TABLE ll_fleet_trip_operations_record(trip_no VARCHAR(64),record_version BIGINT,details_json CLOB)");
jdbc.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,trip_id BIGINT,task_type VARCHAR(32),package_count INT)");
jdbc.execute("CREATE TABLE ll_dispatch_history(id BIGINT PRIMARY KEY,dispatch_id BIGINT,record_version BIGINT,action VARCHAR(32),snapshot_json CLOB)");
jdbc.execute("CREATE TABLE ll_fleet_pickup_task(trip_no VARCHAR(64),status VARCHAR(32),actual_packages INT)");
jdbc.execute("CREATE ALIAS JSON_EXTRACT FOR '" + getClass().getName() + ".jsonExtract'");
jdbc.execute("CREATE ALIAS JSON_UNQUOTE FOR '" + getClass().getName() + ".jsonUnquote'");
var configuration = new MybatisConfiguration();
configuration.setMapUnderscoreToCamelCase(true);
configuration.addMapper(TripOperationsQueryMapper.class);
var factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(source);
factory.setConfiguration(configuration);
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(TripOperationsQueryMapper.class);
repository = new TripOperationsRepository(mapper);
}
/** 释放本例数据库,避免定向回归累积隔离状态。 */
@AfterEach void closeDatabase() {
if (jdbc != null) jdbc.execute("SHUTDOWN");
}
/** H2 替身读取真实嵌套 JSON,缺失路径保持 SQL NULL。 */
public static String jsonExtract(String document, String path) throws Exception {
if (document == null) return null;
var value = new ObjectMapper().readTree(document).at(path.substring(1).replace('.', '/'));
return value.isMissingNode() ? null : value.toString();
}
/** 保留字符串及 JSON null 的解码语义。 */
public static String jsonUnquote(String document) throws Exception {
return document == null ? null : new ObjectMapper().readTree(document).asText();
}
/** 三类车次各取真实发车历史,取货初始空记录不能覆盖实提事实。 */
@Test void pickupAndLocalTruckFactsUseTheirOwnHistoryWithoutBreakingDeliveryReport() throws Exception {
trip(1, "TR-A", "DELIVERY", "IN_TRANSIT", 1, "2026-10-01 00:00:00");
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(1,3,'DEPART',?)", TripOperationsServiceTest.DEPARTURE);
trip(2, "PICKUP-A", "PICKUP", "COMPLETED", 1, "2026-10-02 00:00:00");
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(2,1,'PICKUP_PICKED','{}'),(2,4,'PICKUP_ARRIVED','{}')");
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(2,3,'PICKUP_PICKED',?)", transportSnapshot("PICKUP-A", "PICKUP"));
jdbc.update("INSERT INTO ll_fleet_pickup_task VALUES('PICKUP-A','PICKED',5),('PICKUP-A','ARRIVED',2),('PICKUP-A','PENDING',99)");
trip(3, "TRUCK-A", "LOCAL_TRUCK", "IN_TRANSIT", 1, "2026-10-02 00:00:00");
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(3,2,'LOCAL_TRUCK_DEPART',?)", transportSnapshot("TRUCK-A", "LOCAL_TRUCK"));
jdbc.update("INSERT INTO ll_dispatch_order VALUES(3,3,'LOCAL_TRUCK',9)");
var result = service().report(query(null, null), false);
assertThat(result.page().records()).extracting(TripOperationsModels.Row::tripNo).containsExactly("TRUCK-A", "PICKUP-A", "TR-A");
assertThat(result.summary().trips()).isEqualTo(3);
assertThat(result.summary().physicalPallets()).isEqualTo(1);
assertThat(result.summary().ownPackages()).isEqualTo(27);
assertThat(result.summary().externalPackages()).isEqualTo(4);
assertThat(result.page().records().get(0).physicalPallets()).isNull();
assertThat(result.page().records().get(1).physicalPallets()).isNull();
}
/** 非 UTC 连接仍按 UTC 含起点、不含终点筛选,完成时间也不能发生偏移。 */
@Test void deliveryFiltersKeepInclusiveStartExclusiveEndAndWarehouseStatus() {
trip(1, "START", "DELIVERY", "COMPLETED", 1, "2026-10-01 00:00:00");
trip(2, "END", "DELIVERY", "COMPLETED", 1, "2026-10-03 00:00:00");
trip(3, "BEFORE", "DELIVERY", "COMPLETED", 1, "2026-09-30 23:59:59");
trip(4, "OTHER-WAREHOUSE", "DELIVERY", "COMPLETED", 2, "2026-10-02 00:00:00");
trip(5, "IN-TRANSIT", "DELIVERY", "IN_TRANSIT", 1, "2026-10-02 00:00:00");
jdbc.update("UPDATE ll_fleet_trip SET completed_at='2026-10-02 14:15:00' WHERE id=1");
assertThat(repository.departures(query(1L, "COMPLETED")))
.extracting(TripOperationsRepository.Departure::tripNo).containsExactly("START");
assertThat(repository.departures(query(1L, "COMPLETED")).get(0).completedAt())
.isEqualTo(Instant.parse("2026-10-02T14:15:00Z"));
}
/** 三类车次缺少匹配历史都应显式报错,不能静默丢弃。 */
@Test void missingMatchingHistoryStillFailsForEveryTripPurpose() {
for (String purpose : java.util.List.of("DELIVERY", "PICKUP", "LOCAL_TRUCK")) {
jdbc.update("DELETE FROM ll_fleet_trip_history");
jdbc.update("DELETE FROM ll_fleet_trip");
trip(1, "MISSING", purpose, "IN_TRANSIT", 1, "2026-10-01 00:00:00");
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(1,1,'PICKUP_PICKED','{}')");
assertThatThrownBy(() -> service().report(query(null, null), false))
.isInstanceOf(IllegalStateException.class).hasMessageContaining("不可变历史").hasMessageContaining("MISSING");
}
}
/** 保留显式取货发车及旧版 DEPART 动作,未登记实提箱数不补预报。 */
@Test void pickupDepartureAndLegacyDepartActionsRemainReadable() throws Exception {
for (String action : java.util.List.of("PICKUP_DEPART", "DEPART")) {
jdbc.update("DELETE FROM ll_fleet_trip_history");
jdbc.update("DELETE FROM ll_fleet_trip");
trip(1, "PICKUP-A", "PICKUP", "IN_TRANSIT", 1, "2026-10-01 00:00:00");
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(1,1,?,?)", action, transportSnapshot("PICKUP-A", "PICKUP"));
var result = service().report(query(null, null), false);
assertThat(result.page().records()).extracting(TripOperationsModels.Row::tripNo).containsExactly("PICKUP-A");
assertThat(result.summary().ownPackages()).isZero();
}
}
/** 运输发车快照保留行程和实际时间,不伪造仓库装车托数。 */
private String transportSnapshot(String number, String purpose) throws Exception {
var json = new ObjectMapper();
var snapshot = json.createObjectNode();
var trip = snapshot.putObject("trip");
trip.put("tripNo", number);
trip.put("tripPurpose", purpose);
trip.put("departedAt", "2026-10-01T00:00:00Z");
var stops = trip.putArray("stops");
stops.addObject().put("name", "起点");
stops.addObject().put("name", "终点");
return json.writeValueAsString(snapshot);
}
/** 创建本例隔离库中的发车事实。 */
private void trip(long id, String number, String purpose, String status, long warehouse, String departed) {
jdbc.update("INSERT INTO ll_fleet_trip VALUES(?,?,?,?,?,?,NULL)", id, number, purpose, status, warehouse, departed);
}
/** 构造范围固定的只读查询。 */
private TripOperationsModels.Query query(Long warehouse, String status) {
return new TripOperationsModels.Query(1, 20, Instant.parse("2026-10-01T00:00:00Z"),
Instant.parse("2026-10-03T00:00:00Z"), warehouse, null, null, status);
}
/** 关闭成本查询,聚焦发车事实的持久化和投影。 */
private TripOperationsService service() {
return new TripOperationsService(repository, new ObjectMapper().registerModule(new JavaTimeModule()), mock(FleetTripCostService.class));
}
}
@@ -11,6 +11,33 @@ import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
class TripOperationsServiceTest { class TripOperationsServiceTest {
/** 照片直送没有仓内装载;与普通出库一起查询时仍保留真实车次及箱数。 */
@Test void photoLooseboardDepartureDoesNotBreakTheWholeReport() {
var repo=mock(TripOperationsRepository.class);
var service=new TripOperationsService(repo,new ObjectMapper().registerModule(new JavaTimeModule()),mock(FleetTripCostService.class));
String snapshot="""
{"trip":{"tripNo":"TR-PHOTO","tripPurpose":"DELIVERY","departedAt":"2026-10-01T00:00:00Z",
"vehiclePlate":"PHOTO-PLATE","stops":[{"name":"取货地"},{"name":"目的仓","destinationKey":"FBA:ONT8"}]}}
""";
var query=new TripOperationsModels.Query(1,20,Instant.parse("2026-10-01T00:00:00Z"),Instant.parse("2026-10-03T00:00:00Z"),null,null,null,null);
var photo=new TripOperationsRepository.Departure("TR-PHOTO","IN_TRANSIT",null,snapshot,null,"{\"taskType\":\"PHOTO_LOOSEBOARD\",\"packages\":6}");
when(repo.departures(query)).thenReturn(List.of(photo,new TripOperationsRepository.Departure("TR-A","COMPLETED",null,DEPARTURE,null)));
var result=service.report(query,false);
assertThat(result.page().records()).hasSize(2);
assertThat(result.summary().trips()).isEqualTo(2);
assertThat(result.summary().ownPackages()).isEqualTo(17);
assertThat(result.summary().physicalPallets()).isEqualTo(1);
var row=result.page().records().get(0);
assertThat(row.physicalPallets()).isNull();assertThat(row.palletSpaces()).isNull();
assertThat(row.destinations().get(0).label()).isEqualTo("目的仓");
// 普通车次仍不能因缺少装载而被静默当成照片直送。
assertThatThrownBy(()->service.project(new TripOperationsRepository.Departure("TR-PHOTO","IN_TRANSIT",null,snapshot,null)))
.hasMessageContaining("缺少装载");
for(String quantity:List.of("null","0","-1","1.5","\"6\"")) {
assertThatThrownBy(()->service.project(new TripOperationsRepository.Departure("TR-PHOTO","IN_TRANSIT",null,snapshot,null,
"{\"taskType\":\"PHOTO_LOOSEBOARD\",\"packages\":"+quantity+"}"))).hasMessageContaining("实际箱数");
}
}
static final String DEPARTURE=""" static final String DEPARTURE="""
{"trip":{"tripNo":"TR-A","departedAt":"2026-10-01T00:00:00Z","vehiclePlate":"OLD-PLATE","driverName":"发车司机","warehouseName":"起运仓"}, {"trip":{"tripNo":"TR-A","departedAt":"2026-10-01T00:00:00Z","vehiclePlate":"OLD-PLATE","driverName":"发车司机","warehouseName":"起运仓"},
"confirmedLoad":{"cargo":[ "confirmedLoad":{"cargo":[
@@ -32,6 +32,7 @@ class InboundPlanControllerTest {
@Mock InboundFbaAppointmentService appointmentService; @Mock InboundFbaAppointmentService appointmentService;
@Mock ContainerInboundPlanConversionCoordinator containerConversion; @Mock ContainerInboundPlanConversionCoordinator containerConversion;
@Mock com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService inboundWorkflow; @Mock com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService inboundWorkflow;
@Mock com.baiyue.lastleg.inboundplan.pda.service.InboundReceivingReconciliationService receivingReconciliation;
private InboundPlanController controller; private InboundPlanController controller;
@BeforeEach @BeforeEach
@@ -41,6 +42,24 @@ class InboundPlanControllerTest {
ReflectionTestUtils.setField(controller, "appointmentService", appointmentService); ReflectionTestUtils.setField(controller, "appointmentService", appointmentService);
ReflectionTestUtils.setField(controller, "containerConversion", containerConversion); ReflectionTestUtils.setField(controller, "containerConversion", containerConversion);
ReflectionTestUtils.setField(controller, "inboundWorkflow", inboundWorkflow); ReflectionTestUtils.setField(controller, "inboundWorkflow", inboundWorkflow);
ReflectionTestUtils.setField(controller, "receivingReconciliation", receivingReconciliation);
}
/** 平台统一核对短少或超收,客户范围、操作人与请求键原样交给事务服务。 */
@Test void platformConfirmsReceivingDifferenceWithExplicitScopeAndActor() {
var request = new com.baiyue.lastleg.inboundplan.dto.InboundReceivingReconciliation.Confirm(
"100", "snapshot", "现场核对", List.of(), List.of());
controller.receivingReconciliation(platform(), "IP-1", "100");
verify(receivingReconciliation).snapshot("100", "IP-1");
controller.confirmReceivingReconciliation(platform(), "IP-1", "key", request);
verify(receivingReconciliation).confirm("100", "IP-1", "7", "key", request);
var merchant = new AuthenticatedUser(8L, 100L, 100L, 9L, "customer", "MERCHANT", "WEB", Set.of());
assertThatThrownBy(() -> controller.receivingReconciliation(merchant, "IP-1", "100"))
.isInstanceOf(ResponseStatusException.class);
assertThatThrownBy(() -> controller.confirmReceivingReconciliation(merchant, "IP-1", "key", request))
.isInstanceOf(ResponseStatusException.class);
assertThatThrownBy(() -> controller.receivingReconciliation(platform(), "IP-1", "0"))
.isInstanceOf(ResponseStatusException.class);
} }
/** 短少确认只允许平台指定具体客户,并保留真实操作人。 */ /** 短少确认只允许平台指定具体客户,并保留真实操作人。 */
@@ -32,7 +32,7 @@ class InboundPlanDispatchStatusPersistenceTest {
db.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR(20),gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3))"); db.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR(20),gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3))");
db.execute("CREATE TABLE ll_inbound_order(id BIGINT,customer_id VARCHAR(20),gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3))"); db.execute("CREATE TABLE ll_inbound_order(id BIGINT,customer_id VARCHAR(20),gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3))");
db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,order_id BIGINT)"); db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,order_id BIGINT)");
db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,status VARCHAR(30),stage VARCHAR(30))"); db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,status VARCHAR(30),stage VARCHAR(30),task_type VARCHAR(30) DEFAULT 'DELIVERY')");
db.update("INSERT INTO ll_inbound_plan VALUES(1,'LIP-1','A',90,TRUE,FALSE,FALSE,'RECEIVED',CURRENT_TIMESTAMP,0)"); db.update("INSERT INTO ll_inbound_plan VALUES(1,'LIP-1','A',90,TRUE,FALSE,FALSE,'RECEIVED',CURRENT_TIMESTAMP,0)");
db.update("INSERT INTO ll_inbound_plan_line VALUES(2,1,3,'IN-1','91','ABE4','ABE4','ABE4',1,1,1)"); db.update("INSERT INTO ll_inbound_plan_line VALUES(2,1,3,'IN-1','91','ABE4','ABE4','ABE4',1,1,1)");
db.update("INSERT INTO ll_order VALUES(3,'A','LX-1','FIRST_LEG','LA','洛杉矶仓','客户A')"); db.update("INSERT INTO ll_order VALUES(3,'A','LX-1','FIRST_LEG','LA','洛杉矶仓','客户A')");
@@ -64,6 +64,15 @@ class InboundPlanDispatchStatusPersistenceTest {
assertStatus("UNSCHEDULED"); assertStatus("UNSCHEDULED");
} }
@Test
void pickupTripDoesNotCountAsOutboundScheduling() {
dispatch(10, "ACTIVE", "DELIVERED", "A");
db.update("UPDATE ll_dispatch_order SET task_type='PICKUP'");
assertStatus("UNSCHEDULED");
dispatch(11, "ACTIVE", "BEFORE_LOADING", "A");
assertStatus("SCHEDULED");
}
@Test @Test
void departedAndCompletedDispatchesRemainScheduled() { void departedAndCompletedDispatchesRemainScheduled() {
dispatch(10, "ACTIVE", "DELIVERING", "A"); dispatch(10, "ACTIVE", "DELIVERING", "A");
@@ -110,7 +119,7 @@ class InboundPlanDispatchStatusPersistenceTest {
} }
private void dispatch(long id, String status, String stage, String itemOwner) { private void dispatch(long id, String status, String stage, String itemOwner) {
db.update("INSERT INTO ll_dispatch_order VALUES(?,?,?)", id, status, stage); db.update("INSERT INTO ll_dispatch_order(id,status,stage) VALUES(?,?,?)", id, status, stage);
db.update("INSERT INTO ll_dispatch_item VALUES(?,?,?,3)", id, itemOwner, id); db.update("INSERT INTO ll_dispatch_item VALUES(?,?,?,3)", id, itemOwner, id);
} }
@@ -0,0 +1,152 @@
package com.baiyue.lastleg.inboundplan.pda.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingFbaPalletCount;
import com.baiyue.lastleg.inboundplan.pda.mapper.InboundReceivingReconciliationMapper;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/** 使用真实 Mapper 校验整柜范围、历史完成会话和独立实盘取数。 */
class InboundReceivingReconciliationPersistenceTest {
/** 同来源柜次同仓跨客户共同核对,复用柜号、其他仓及不匹配来源均排除。 */
@Test
void scopeIncludesOtherCustomersOnlyWithinTheSameBookingContainerAndWarehouse() throws Exception {
var fixture = new Fixture();
assertThat(fixture.mapper.scopePlans("101", "IP-10", false))
.extracting(InboundPlan::getId).containsExactly(10L, 20L);
assertThat(fixture.mapper.scopePlans("202", "IP-20", false))
.extracting(InboundPlan::getId).containsExactly(10L, 20L);
var lockedPlans = fixture.transaction.execute(status -> fixture.mapper.scopePlans("101", "IP-10", true));
assertThat(lockedPlans).extracting(InboundPlan::getId).containsExactly(10L, 20L);
}
/** 必须先定位指定客户的真实计划,空客户、其他客户和缺失柜次均不能扩大范围。 */
@Test
void scopeRequiresTheSelectedCustomerAndANonblankBooking() throws Exception {
var fixture = new Fixture();
assertThat(fixture.mapper.scopePlans(null, "IP-10", false)).isEmpty();
assertThat(fixture.mapper.scopePlans("", "IP-10", false)).isEmpty();
assertThat(fixture.mapper.scopePlans("202", "IP-10", false)).isEmpty();
assertThat(fixture.mapper.scopePlans("101", "MISSING", false)).isEmpty();
assertThat(fixture.mapper.scopePlans("101", "IP-70", false)).isEmpty();
assertThat(fixture.mapper.scopePlans("101", "IP-80", false)).isEmpty();
assertThat(fixture.mapper.scopePlans("101", "IP-90", false)).isEmpty();
}
/** 已完成会话释放活动键后,仍按柜次、柜号和仓定位,并能在事务中锁定。 */
@Test
void completedSessionCanBeLocatedAndLockedAfterItsActiveKeysAreCleared() throws Exception {
var fixture = new Fixture();
var originalTime = LocalDateTime.of(2026, 11, 6, 10, 0);
var latestTime = originalTime.plusHours(1);
fixture.session(100L, "BOOK-1", "CONT-1", 9L, "COMPLETED", originalTime);
fixture.session(110L, "BOOK-1", "CONT-1", 9L, "COMPLETED", latestTime);
fixture.session(111L, "BOOK-1", "CONT-1", 9L, "COMPLETED_WITH_DIFFERENCE", latestTime);
fixture.session(120L, "BOOK-2", "CONT-1", 9L, "COMPLETED", latestTime.plusHours(1));
fixture.session(130L, "BOOK-1", "CONT-1", 10L, "COMPLETED", latestTime.plusHours(1));
fixture.session(140L, "BOOK-1", "CONT-2", 9L, "COMPLETED", latestTime.plusHours(1));
fixture.session(150L, "book-1", "CONT-1", 9L, "COMPLETED", latestTime.plusHours(1));
ReceivingContainerSession session = fixture.transaction.execute(status ->
fixture.mapper.session("BOOK-1", "CONT-1", 9L, true));
assertThat(session).isNotNull();
assertThat(session.getId()).isEqualTo(111L);
assertThat(session.getStatus()).isEqualTo("COMPLETED_WITH_DIFFERENCE");
assertThat(session.getActiveOperatorKey()).isNull();
assertThat(session.getActiveContainerKey()).isNull();
assertThat(session.getCompletedAt()).isEqualTo(latestTime);
assertThat(fixture.mapper.session("BOOK-1", "CONT-1", 9L, false).getId()).isEqualTo(111L);
assertThat(fixture.mapper.session("MISSING", "CONT-1", 9L, false)).isNull();
assertThat(fixture.mapper.session(null, "CONT-1", 9L, false)).isNull();
}
/** 独立实盘保留超申报的完整数量及明确零记录,其他会话同码托盘不能混入。 */
@Test
void physicalCountsRetainActualAndExplicitZeroWithinTheirOriginalSession() throws Exception {
var fixture = new Fixture();
fixture.db.update("INSERT INTO ll_receiving_fba_pallet_count VALUES (2,111,'FBA:CONT-1:ONT8','PAL-2',0)");
fixture.db.update("INSERT INTO ll_receiving_fba_pallet_count VALUES (1,111,'FBA:CONT-1:ONT8','PAL-1',22)");
fixture.db.update("INSERT INTO ll_receiving_fba_pallet_count VALUES (3,222,'FBA:CONT-1:ONT8','PAL-1',66)");
var physical = fixture.mapper.physicalCounts(111L);
assertThat(physical).extracting(ReceivingFbaPalletCount::getId).containsExactly(1L, 2L);
assertThat(physical).extracting(ReceivingFbaPalletCount::getActualPackages).containsExactly(22, 0);
assertThat(physical).extracting(ReceivingFbaPalletCount::getSessionId).containsOnly(111L);
assertThat(fixture.mapper.physicalCounts(999L)).isEmpty();
}
private static final class Fixture {
final JdbcTemplate db;
final TransactionTemplate transaction;
final InboundReceivingReconciliationMapper mapper;
/** 仅建立本次生产 SQL 所需列,使用真实 MyBatis 和独立 READ_COMMITTED 事务。 */
Fixture() throws Exception {
var dataSource = new DriverManagerDataSource("jdbc:h2:mem:receiving-reconciliation-"
+ UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000", "sa", "");
db = new JdbcTemplate(dataSource);
transaction = new TransactionTemplate(new DataSourceTransactionManager(dataSource));
transaction.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
db.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY,plan_no VARCHAR(128),"
+ "customer_id VARCHAR(128),source_order_id BIGINT,container_no VARCHAR(64),"
+ "overseas_warehouse_id BIGINT,status VARCHAR(32))");
db.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),"
+ "source_container_booking_id VARCHAR(128))");
db.execute("CREATE TABLE ll_receiving_container_session(id BIGINT PRIMARY KEY,"
+ "source_container_booking_id VARCHAR(128),container_no VARCHAR(64),"
+ "overseas_warehouse_id BIGINT,status VARCHAR(32),active_operator_key VARCHAR(255),"
+ "active_container_key VARCHAR(255),created_at TIMESTAMP,completed_at TIMESTAMP)");
db.execute("CREATE TABLE ll_receiving_fba_pallet_count(id BIGINT PRIMARY KEY,session_id BIGINT,"
+ "group_key VARCHAR(255),pallet_code VARCHAR(128),actual_packages INT)");
plan(20L, "202", "BOOK-1", "CONT-1", 9L);
plan(10L, "101", "BOOK-1", "CONT-1", 9L);
plan(30L, "101", "BOOK-2", "CONT-1", 9L);
plan(40L, "101", "BOOK-1", "CONT-1", 10L);
plan(50L, "101", "BOOK-1", "CONT-2", 9L);
plan(60L, "101", "book-1", "CONT-1", 9L);
plan(70L, "101", "BOOK-1", "CONT-1", 9L);
db.update("UPDATE ll_order SET customer_id='202' WHERE id=1070");
plan(80L, "101", null, "CONT-1", 9L);
plan(90L, "101", " ", "CONT-1", 9L);
var configuration = new MybatisConfiguration();
configuration.setMapUnderscoreToCamelCase(true);
configuration.addMapper(InboundReceivingReconciliationMapper.class);
var factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(dataSource);
factory.setConfiguration(configuration);
factory.afterPropertiesSet();
mapper = new SqlSessionTemplate(factory.getObject()).getMapper(InboundReceivingReconciliationMapper.class);
}
/** 插入真实客户与来源订单的计划关系,测试只通过正式 Mapper 查询范围。 */
private void plan(long id, String customerId, String bookingId, String containerNo, long warehouseId) {
db.update("INSERT INTO ll_order VALUES (?,?,?)", id + 1000, customerId, bookingId);
db.update("INSERT INTO ll_inbound_plan VALUES (?,?,?,?,?,?,?)", id, "IP-" + id,
customerId, id + 1000, containerNo, warehouseId, "PENDING_RECONCILIATION");
}
/** 历史完成会话的活动唯一键为空,原始完成时间仍可作为核对证据。 */
private void session(long id, String bookingId, String containerNo, long warehouseId,
String status, LocalDateTime createdAt) {
db.update("INSERT INTO ll_receiving_container_session VALUES (?,?,?,?,?,?,?,?,?)",
id, bookingId, containerNo, warehouseId, status, null, null, createdAt, createdAt);
}
}
}
@@ -0,0 +1,51 @@
package com.baiyue.lastleg.inboundplan.pda.service;
import com.baiyue.lastleg.inboundplan.dto.InboundReceivingReconciliation.*;
import jakarta.validation.Validation;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.List;
import java.util.stream.IntStream;
import static org.assertj.core.api.Assertions.assertThat;
/** 请求层接受当前完整核对范围,数量守恒与只读事实继续由服务校验。 */
class InboundReceivingReconciliationRequestTest {
/** 多票多商品的完整矩阵不能被任意一万行上限阻塞。 */
@Test void acceptsCompleteLargeReconciliationSnapshot() {
List<Quantity> rows = IntStream.range(0, 10400).mapToObj(index ->
new Quantity("100", "IP-1", 3L, 10L + index / 400, "SKU-" + index % 400, 0)).toList();
List<Count> pallets = IntStream.range(0, 26).mapToObj(index -> new Count(10L + index, 0)).toList();
try (var factory = Validation.buildDefaultValidatorFactory()) {
assertThat(factory.getValidator().validate(new Confirm("100", "snapshot", "逐托复核", pallets, rows))).isEmpty();
}
}
/** 空行必须在进入业务事务前拒绝,不能触发空引用错误。 */
@Test void rejectsNullListElements() {
try (var factory = Validation.buildDefaultValidatorFactory()) {
var validator = factory.getValidator();
assertThat(validator.validate(new Confirm("100", "snapshot", "现场复核", Arrays.asList((Count) null), List.of())))
.isNotEmpty();
assertThat(validator.validate(new Confirm("100", "snapshot", "现场复核", List.of(), Arrays.asList((Quantity) null))))
.isNotEmpty();
}
}
/** 已完成只读整托可以超过普通FBA单托上限,服务仍禁止改写这些数量。 */
@Test void acceptsReadonlySourceQuantityAboveBulkLimit() {
try (var factory = Validation.buildDefaultValidatorFactory()) {
assertThat(factory.getValidator().validate(new Confirm("100", "snapshot", "现场复核",
List.of(new Count(10L, 80)), List.of(new Quantity("100", "IP-1", 3L, 10L, "SKU-1", 80)))))
.isEmpty();
}
}
/** 原因必填,负数清点或SKU数量均在请求层拒绝。 */
@Test void rejectsNegativeCountsAndMissingReason() {
try (var factory = Validation.buildDefaultValidatorFactory()) {
assertThat(factory.getValidator().validate(new Confirm("100", "snapshot", " ",
List.of(new Count(10L, -1)), List.of(new Quantity("100", "IP-1", 3L, 10L, "SKU-1", -1)))))
.hasSize(3);
}
}
}
@@ -0,0 +1,266 @@
package com.baiyue.lastleg.inboundplan.pda.service;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.inboundplan.dto.InboundReceivingReconciliation.*;
import com.baiyue.lastleg.inboundplan.entity.*;
import com.baiyue.lastleg.inboundplan.pda.entity.*;
import com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper.FrozenSku;
import com.baiyue.lastleg.inboundplan.pda.repository.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class InboundReceivingReconciliationServiceTest {
@Mock InboundReceivingReconciliationRepository reconciliation;
@Mock PdaWorkflowRepository repository;
@Mock InboundStockReceiptService stock;
@Mock InboundDispatchReceiptGateway dispatch;
InboundReceivingReconciliationService service;
InboundPlan plan;
InboundPdaTask task;
InboundPlanLine line;
ReceivingContainerSession session;
List<InboundPlanPallet> pallets;
List<InboundPlanPalletAllocation> allocations;
final LocalDateTime receivedAt = LocalDateTime.of(2026, 11, 6, 11, 28);
/** 现场5加20箱,申报内仅分配5加16箱,另有一个明确待核对的预留托盘。 */
@BeforeEach void setup() {
service = new InboundReceivingReconciliationService();
ReflectionTestUtils.setField(service, "reconciliation", reconciliation);
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "stockReceipts", stock);
ReflectionTestUtils.setField(service, "dispatchReceipts", dispatch);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
plan = new InboundPlan(); plan.setId(1L); plan.setPlanNo("IP-1"); plan.setCustomerId("100");
plan.setSourceOrderId(9L); plan.setContainerNo("BOX-1"); plan.setOverseasWarehouseId(99L);
plan.setStatus("PENDING_RECONCILIATION"); plan.setVersion(5);
LastlegOrder order = new LastlegOrder(); order.setId(9L); order.setCustomerId("100");
order.setCustomerName("核对客户"); order.setSourceContainerBookingId("BOOKING-1");
order.setOrderType("FIRST_LEG"); order.setDestinationType("FBA");
task = new InboundPdaTask(); task.setId(2L); task.setPlanId(1L); task.setCustomerId("100");
task.setStatus("PENDING_RECONCILIATION"); task.setCompletedAt(receivedAt); task.setVersion(4);
task.setUnloadPhotoFileIds("[10]"); task.setUnloadPhotoUploadedAt(receivedAt);
line = new InboundPlanLine(); line.setId(3L); line.setPlanId(1L); line.setCustomerId("100");
line.setOrderId(9L); line.setInboundCode("IN-1"); line.setFbaWarehouseCode("ACY2"); line.setRequiredPackageCount(21);
session = new ReceivingContainerSession(); session.setId(50L); session.setVersion(4);
session.setSourceContainerBookingId("BOOKING-1"); session.setContainerNo("BOX-1");
session.setOverseasWarehouseId(99L); session.setStatus("COMPLETED_WITH_DIFFERENCE"); session.setCompletedAt(receivedAt);
pallets = new ArrayList<>(); allocations = new ArrayList<>();
for (int i = 0; i < 3; i++) {
InboundPlanPallet pallet = new InboundPlanPallet(); pallet.setId(10L+i); pallet.setPlanId(1L);
pallet.setCustomerId("100"); pallet.setPalletNo("PALLET-"+i); pallet.setActualPackageCount(i == 0 ? 5 : i == 1 ? 16 : 0);
pallet.setWarehousePalletId(100L+i); pallet.setWarehouseLocationId(200L+i); pallet.setWarehouseResourceLockId(300L+i);
pallet.setDestinationType("FBA"); pallet.setDestinationWarehouseCode("ACY2"); pallet.setVersion(1); pallets.add(pallet);
InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation(); allocation.setId(20L+i);
allocation.setCustomerId("100"); allocation.setPlanId(1L); allocation.setLineId(3L); allocation.setPalletId(pallet.getId());
allocation.setPlannedPackageCount(7); allocation.setCountedPackageCount(pallet.getActualPackageCount());
allocation.setPickedPackageCount(pallet.getActualPackageCount()); allocation.setVersion(1); allocations.add(allocation);
}
lenient().when(reconciliation.plans(eq("100"), eq("IP-1"), anyBoolean())).thenReturn(List.of(plan));
when(repository.order(9L)).thenReturn(order);
lenient().when(reconciliation.session(eq("BOOKING-1"), eq("BOX-1"), eq(99L), anyBoolean())).thenReturn(session);
lenient().when(repository.task("100",1L)).thenReturn(task);
lenient().when(repository.lockTask("100",1L)).thenReturn(task);
when(repository.lines(1L)).thenReturn(List.of(line)); when(repository.pallets(1L)).thenReturn(pallets);
when(repository.allocations(1L)).thenReturn(allocations);
for (int i=0; i<3; i++) {
lenient().when(repository.lockAllocation(1L,20L+i)).thenReturn(allocations.get(i));
lenient().when(repository.lockPallet(1L,10L+i)).thenReturn(pallets.get(i));
}
when(repository.frozenSkus("100",1L,3L)).thenReturn(List.of(new FrozenSku("SKU-1",21)));
when(repository.skuReceipts("100",1L)).thenReturn(List.of(sku(10L,"SKU-1",5),sku(11L,"SKU-1",16)));
lenient().when(reconciliation.physicalCounts(50L)).thenReturn(List.of(physical("PALLET-0",5), physical("PALLET-1",20)));
}
/** 页面保留21箱归属,独立展示25箱实盘,唯一SKU预填完整实盘。 */
@Test void exposesPhysicalTwentyFiveSeparatelyFromAttributedTwentyOne() {
Snapshot snapshot = service.snapshot("100","IP-1");
assertTrue(snapshot.canConfirm()); assertEquals(21,snapshot.expectedPackageCount());
assertEquals(21,snapshot.attributedPackageCount()); assertEquals(25,snapshot.countedPackageCount());
assertEquals(List.of(5,20,0), snapshot.receipts().stream().map(Receipt::receivedPackageCount).toList());
verifyNoInteractions(stock,dispatch);
}
/** 超收按25箱入库,原申报21与原现场时间保留,同时交接运输并记录完整前后证据。 */
@Test void confirmsOverReceiptAndReplaysWithoutDuplicatingStock() {
Confirm request = request(service.snapshot("100","IP-1"), 5,20,0);
Result result = service.confirm("100","IP-1","admin","key",request);
assertEquals("COMPLETED",result.status()); assertEquals(25,result.confirmedPackageCount());
assertEquals(21,line.getRequiredPackageCount()); assertEquals(receivedAt,task.getCompletedAt());
assertEquals(20,allocations.get(1).getPickedPackageCount()); assertEquals(20,pallets.get(1).getActualPackageCount());
var sequence = inOrder(dispatch,reconciliation,stock);
sequence.verify(dispatch).lock(List.of(1L));
sequence.verify(reconciliation).session("BOOKING-1","BOX-1",99L,true);
sequence.verify(stock).stockInReconciled(eq("100"),eq(plan),eq(task),eq(List.of(line)),anyList(),anyList(),eq(receivedAt));
sequence.verify(dispatch).complete(eq(List.of(1L)),eq("admin"),any());
var audit = ArgumentCaptor.forClass(InboundPdaOperation.class); verify(repository).insert(audit.capture());
assertTrue(audit.getValue().getRequestJson().contains("sourceFacts"));
assertTrue(audit.getValue().getRequestJson().contains("现场核实数量"));
when(repository.operation("100","key")).thenReturn(audit.getValue());
assertEquals(result,service.confirm("100","IP-1","admin","key",request));
verify(stock,times(1)).stockInReconciled(anyString(),any(),any(),anyList(),anyList(),anyList(),any());
assertThrows(ResponseStatusException.class,()->service.confirm("100","IP-1","other-admin","key",request));
}
/** 清点误录可在明确原因下更正为21箱,旧实盘完整留在审计中。 */
@Test void correctsPhysicalEntryAndCompletesAtTwentyOne() {
Result result=service.confirm("100","IP-1","admin","key",request(service.snapshot("100","IP-1"),5,16,0));
assertEquals(21,result.confirmedPackageCount()); assertEquals("COMPLETED",result.status());
var saved=ArgumentCaptor.forClass(ReceivingFbaPalletCount.class);
verify(reconciliation,times(3)).savePhysical(saved.capture(),anyBoolean());
assertEquals(List.of(5,16,0),saved.getAllValues().stream().map(ReceivingFbaPalletCount::getActualPackages).toList());
}
/** 短少与全无实收都可确认,预留托盘明确写零,不伪造库存箱数。 */
@Test void confirmsShortageIncludingExplicitZero() {
Result result=service.confirm("100","IP-1","admin","key",request(service.snapshot("100","IP-1"),0,0,0));
assertEquals("PARTIAL_INBOUND",result.status()); assertEquals(0,result.confirmedPackageCount());
assertTrue(allocations.stream().allMatch(row->row.getCountedPackageCount()==0&&row.getPickedPackageCount()==0));
verify(reconciliation).replaceSkuReceipts("100",1L,List.of());
verify(dispatch).complete(eq(List.of(1L)),eq("admin"),any());
}
/** 每托归属必须与确认清点守恒,拒绝用21箱分配掩盖25箱实盘。 */
@Test void rejectsUnattributedOverReceiptBeforeAnyWrite() {
Snapshot snapshot=service.snapshot("100","IP-1"); Confirm good=request(snapshot,5,20,0);
Confirm bad=new Confirm("100",snapshot.snapshotHash(),"现场核实数量",good.pallets(),request(snapshot,5,16,0).receipts());
assertThrows(ResponseStatusException.class,()->service.confirm("100","IP-1","admin","key",bad));
verifyNoInteractions(stock); verify(reconciliation,never()).savePhysical(any(),anyBoolean());
}
/** 旧快照不能覆盖另一操作的最新清点。 */
@Test void rejectsStaleSnapshot() {
Snapshot snapshot=service.snapshot("100","IP-1"); Confirm good=request(snapshot,5,20,0);
session.setVersion(5);
assertThrows(ResponseStatusException.class,()->service.confirm("100","IP-1","admin","key",good));
verifyNoInteractions(stock); verify(reconciliation,never()).replaceSkuReceipts(anyString(),anyLong(),anyList());
}
/** 多SKU必须显式核对,不将总箱数自动猜分给商品。 */
@Test void explicitSkuShortageCannotBeOffsetByAnotherSkuOverReceipt() {
when(repository.frozenSkus("100",1L,3L)).thenReturn(List.of(new FrozenSku("SKU-1",10),new FrozenSku("SKU-2",11)));
when(repository.skuReceipts("100",1L)).thenReturn(List.of(sku(10L,"SKU-1",5),sku(11L,"SKU-2",16)));
Snapshot snapshot=service.snapshot("100","IP-1");
assertEquals(21,snapshot.receipts().stream().mapToInt(Receipt::receivedPackageCount).sum());
List<Quantity> quantities=snapshot.receipts().stream().map(row -> new Quantity(row.customerId(),row.planNo(),row.lineId(),row.palletId(),row.skuCode(),
row.palletId()==10L&&row.skuCode().equals("SKU-1")?5:
row.palletId()==11L&&row.skuCode().equals("SKU-1")?4:
row.palletId()==11L&&row.skuCode().equals("SKU-2")?16:0)).toList();
Confirm request=new Confirm("100",snapshot.snapshotHash(),"逐商品核实",request(snapshot,5,20,0).pallets(),quantities);
Result result=service.confirm("100","IP-1","admin","key",request);
assertEquals("PARTIAL_INBOUND",result.status()); assertEquals(25,result.confirmedPackageCount());
}
/** 非本人范围和缺失原会话不能进入确认。 */
@Test void rejectsForeignScopeAndMissingSession() {
assertThrows(ResponseStatusException.class,()->service.snapshot("200","IP-1"));
when(reconciliation.session(eq("BOOKING-1"),eq("BOX-1"),eq(99L),anyBoolean())).thenReturn(null);
Snapshot snapshot=service.snapshot("100","IP-1"); assertFalse(snapshot.canConfirm());
assertThrows(ResponseStatusException.class,()->service.confirm("100","IP-1","admin","key",request(snapshot,5,20,0)));
verifyNoInteractions(stock);
}
/** 原现场仍在收货时禁止后台抢先入库。 */
@Test void blocksActiveOnsiteSession() {
session.setStatus("RECEIVING"); assertFalse(service.snapshot("100","IP-1").canConfirm());
}
/** 已完成的普通地址票据从真实库存凭证恢复,不能因缺少批量收货行阻塞本柜核对。 */
@Test void completedOverseasOrderKeepsItsRealStockImmutable() {
addCompletedPlan(false);
Snapshot snapshot = service.snapshot("100", "IP-1");
Receipt fixed = snapshot.receipts().stream().filter(row -> row.planNo().equals("IP-2")).findFirst().orElseThrow();
assertEquals(8, fixed.receivedPackageCount());
assertFalse(fixed.editable());
Result result = service.confirm("100", "IP-1", "admin", "key", request(snapshot, 5, 20, 0));
assertEquals("COMPLETED", result.status());
assertEquals(List.of("IP-1"), result.reconciledPlanNos());
verify(stock, never()).stockInReconciled(eq("200"), any(), any(), anyList(), anyList(), anyList(), any());
}
/** 同目的仓一件代发的原整托独立冻结,普通票不能把额外实收写入该托。 */
@Test void completedDropshipPalletCannotReceiveOrdinaryFbaCargo() {
addCompletedPlan(true);
Snapshot snapshot = service.snapshot("100", "IP-1");
Pallet dropship = snapshot.pallets().stream().filter(row -> row.palletId().equals(64L)).findFirst().orElseThrow();
assertTrue(dropship.groupKey().startsWith("DROPSHIP:"));
assertFalse(dropship.editable());
assertTrue(snapshot.receipts().stream().filter(row -> row.palletId().equals(64L))
.allMatch(row -> row.planNo().equals("IP-2") && !row.editable()));
assertEquals("COMPLETED", service.confirm("100", "IP-1", "admin", "key", request(snapshot, 5, 20, 0)).status());
}
/** 清点量变化时逐来源及整托体积同步采用现有单箱体积口径。 */
@Test void recalculatesActualVolumesFromConfirmedCounts() {
line.setVolumeCbm(new java.math.BigDecimal("2.1000"));
service.confirm("100", "IP-1", "admin", "key", request(service.snapshot("100", "IP-1"), 5, 20, 0));
assertEquals(new java.math.BigDecimal("2.0000"), allocations.get(1).getActualVolumeCbm());
assertEquals(new java.math.BigDecimal("2.0000"), pallets.get(1).getActualVolumeCbm());
assertEquals(new java.math.BigDecimal("0.0000"), pallets.get(2).getActualVolumeCbm());
}
/** 按三托单 SKU 样本生成完整确认请求,其他只读票据采用原事实。 */
private Confirm request(Snapshot snapshot,int first,int second,int third) {
Map<Long,Integer> counts=Map.of(10L,first,11L,second,12L,third);
return new Confirm("100",snapshot.snapshotHash(),"现场核实数量",
snapshot.pallets().stream().map(row->new Count(row.palletId(),counts.getOrDefault(row.palletId(), row.countedPackageCount()))).toList(),
snapshot.receipts().stream().map(row->new Quantity(row.customerId(),row.planNo(),row.lineId(),row.palletId(),row.skuCode(),counts.getOrDefault(row.palletId(), row.receivedPackageCount()))).toList());
}
/** 准备同柜已结束的另一客户票据,其库存来源不可被本次普通实收改写。 */
private void addCompletedPlan(boolean dropship) {
InboundPlan other = new InboundPlan(); other.setId(60L); other.setPlanNo("IP-2"); other.setCustomerId("200");
other.setSourceOrderId(69L); other.setContainerNo("BOX-1"); other.setOverseasWarehouseId(99L);
other.setStatus("COMPLETED"); other.setVersion(2);
LastlegOrder order = new LastlegOrder(); order.setId(69L); order.setCustomerId("200");
order.setOrderType(dropship ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG");
order.setDestinationType(dropship ? "FBA" : "OVERSEAS");
InboundPdaTask completed = new InboundPdaTask(); completed.setId(62L); completed.setPlanId(60L);
completed.setCustomerId("200"); completed.setStatus("COMPLETED"); completed.setCompletedAt(receivedAt);
InboundPlanLine otherLine = new InboundPlanLine(); otherLine.setId(63L); otherLine.setPlanId(60L);
otherLine.setOrderId(69L); otherLine.setRequiredPackageCount(8); otherLine.setDestinationType(order.getDestinationType());
otherLine.setDestinationWarehouseId("DEST-2"); otherLine.setFbaWarehouseCode(dropship ? "ACY2" : null);
InboundPlanPallet pallet = new InboundPlanPallet(); pallet.setId(64L); pallet.setPlanId(60L);
pallet.setCustomerId("200"); pallet.setPalletNo("OTHER-PALLET"); pallet.setActualPackageCount(8);
InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation(); allocation.setId(65L);
allocation.setPlanId(60L); allocation.setLineId(63L); allocation.setPalletId(64L);
allocation.setCountedPackageCount(8); allocation.setPickedPackageCount(8);
InboundStockReceipt stock = new InboundStockReceipt(); stock.setAllocationId(65L); stock.setLineId(63L);
stock.setSkuCode(dropship ? "MIXED" : "SKU-2"); stock.setReceivedPackages(8L);
when(reconciliation.plans(eq("100"), eq("IP-1"), anyBoolean())).thenReturn(List.of(plan, other));
when(repository.order(69L)).thenReturn(order);
when(repository.task("200", 60L)).thenReturn(completed);
when(repository.lockTask("200", 60L)).thenReturn(completed);
when(repository.lines(60L)).thenReturn(List.of(otherLine));
when(repository.pallets(60L)).thenReturn(List.of(pallet));
when(repository.allocations(60L)).thenReturn(List.of(allocation));
when(repository.lockAllocation(60L, 65L)).thenReturn(allocation);
when(repository.lockPallet(60L, 64L)).thenReturn(pallet);
when(repository.frozenSkus("200", 60L, 63L)).thenReturn(List.of(new FrozenSku("SKU-2", 8)));
when(reconciliation.stockReceipts("200", 62L)).thenReturn(List.of(stock));
}
/** 构造已分配到货件的 SKU 数量。 */
private ReceivingSkuReceipt sku(long palletId,String skuCode,int packages) {
ReceivingSkuReceipt row=new ReceivingSkuReceipt(); row.setLineId(3L); row.setPalletId(palletId);
row.setSkuCode(skuCode); row.setReceivedPackages(packages); return row;
}
/** 构造独立于申报内分配的完整现场清点。 */
private ReceivingFbaPalletCount physical(String pallet,int packages) {
ReceivingFbaPalletCount row=new ReceivingFbaPalletCount(); row.setId(1000L+pallet.hashCode()); row.setSessionId(50L);
row.setGroupKey("FBA:BOX-1:ACY2"); row.setPalletCode(pallet); row.setActualPackages(packages);
row.setCreatedAt(receivedAt); row.setUpdatedAt(receivedAt); return row;
}
}
@@ -23,6 +23,7 @@ import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.lastleg.dropship.service.DropshipOrderService;
import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot; import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot;
import com.baiyue.lastleg.storagerent.service.StorageRentService;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
@@ -61,18 +62,24 @@ class InboundStockReceiptServiceTest {
@Mock WarehouseResourceGateway warehouseGateway; @Mock WarehouseResourceGateway warehouseGateway;
@Mock DropshipOrderService dropshipOrders; @Mock DropshipOrderService dropshipOrders;
@Mock LastlegOrderStatusHistoryMapper orderHistories; @Mock LastlegOrderStatusHistoryMapper orderHistories;
@Mock StorageRentService storageRent;
private InboundStockReceiptService service; private InboundStockReceiptService service;
@BeforeEach @BeforeEach
/** 组装库存、资源与逐箱事实的隔离依赖。 */ /** 组装库存、资源与逐箱事实的隔离依赖。 */
void setUp() { void setUp() {
service = new InboundStockReceiptService(); service = new InboundStockReceiptService();
org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class)); ReflectionTestUtils.setField(service, "storageRent", storageRent);
ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "orderDetails", orderDetails); ReflectionTestUtils.setField(service, "orderDetails", orderDetails);
ReflectionTestUtils.setField(service, "receipts", receipts); ReflectionTestUtils.setField(service, "receipts", receipts);
ReflectionTestUtils.setField(service, "receivingSkuReceipts", receivingSkuReceipts); ReflectionTestUtils.setField(service, "receivingSkuReceipts", receivingSkuReceipts);
ReflectionTestUtils.setField(service, "workflowRepository", workflowRepository); ReflectionTestUtils.setField(service, "workflowRepository", workflowRepository);
var reconciliationRepository = new com.baiyue.lastleg.inboundplan.pda.repository.InboundReceivingReconciliationRepository();
ReflectionTestUtils.setField(reconciliationRepository, "orders", orders);
ReflectionTestUtils.setField(reconciliationRepository, "stockReceipts", receipts);
ReflectionTestUtils.setField(reconciliationRepository, "skus", receivingSkuReceipts);
ReflectionTestUtils.setField(service, "reconciliationRepository", reconciliationRepository);
ReflectionTestUtils.setField(service, "inventory", inventory); ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "warehouseGateway", warehouseGateway); ReflectionTestUtils.setField(service, "warehouseGateway", warehouseGateway);
org.mockito.Mockito.lenient().when(warehouseGateway.resolveLocation(eq(900L), anyLong())).thenAnswer(invocation -> { org.mockito.Mockito.lenient().when(warehouseGateway.resolveLocation(eq(900L), anyLong())).thenAnswer(invocation -> {
@@ -82,7 +89,6 @@ class InboundStockReceiptServiceTest {
}); });
ReflectionTestUtils.setField(service, "dropshipOrders", dropshipOrders); ReflectionTestUtils.setField(service, "dropshipOrders", dropshipOrders);
ReflectionTestUtils.setField(service, "orderHistories", orderHistories); ReflectionTestUtils.setField(service, "orderHistories", orderHistories);
when(receipts.listTask("100", 2L)).thenReturn(List.of());
when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of()); when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of());
} }
@@ -476,6 +482,195 @@ class InboundStockReceiptServiceTest {
verify(inventory, never()).stockIn(any(), any(), any()); verify(inventory, never()).stockIn(any(), any(), any());
} }
/** 明确核对的超收按5加20分别入库,保留21箱申报和原现场计租时间。 */
@Test void reconciledOverReceiptStocksAllConfirmedPallets() {
Fixture f = fixture(21, 21, 5, 210);
f.allocation.setCountedPackageCount(5);
var secondAllocation = allocation(32L, 12L, 22L, 20);
secondAllocation.setCountedPackageCount(20);
var secondPallet = pallet(22L, 2002L);
LocalDateTime receivedAt = LocalDateTime.of(2026, 11, 6, 12, 0);
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order));
when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(
skuReceipt(12, 21, "SKU-1", 5), skuReceipt(12, 22, "SKU-1", 20)));
when(inventory.stockIn(eq("100"), eq("operator"), any()))
.thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 25, 0, 0, 0, false, false));
service.stockInReconciled("100", f.plan, f.task, List.of(f.line),
List.of(f.allocation, secondAllocation), List.of(f.pallet, secondPallet), receivedAt);
var commands = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class);
verify(inventory, times(2)).stockIn(eq("100"), eq("operator"), commands.capture());
assertEquals(List.of(5L, 20L), commands.getAllValues().stream().map(InventoryCommandRequests.StockIn::packages).toList());
var saved = ArgumentCaptor.forClass(InboundStockReceipt.class);
verify(receipts, times(2)).insert(saved.capture());
assertEquals(List.of(receivedAt, receivedAt), saved.getAllValues().stream().map(InboundStockReceipt::getReceivedAt).toList());
verify(storageRent, times(2)).registerInbound(any(InboundStockReceipt.class), eq(f.order));
assertEquals(21, f.line.getRequiredPackageCount());
assertEquals(21, f.detail.getPackageCount());
assertEquals("INBOUND_PLAN:COMPLETED", f.order.getSubOrderStatus());
verify(dropshipOrders, never()).activateAfterStockIn(any(), any(), any(), any());
}
/** 零实收可以完成异常核对,不创建库存或计租凭证。 */
@Test void reconciledZeroReceiptCompletesAsPartialWithoutStock() {
Fixture f = fixture(21, 21, 0, 210);
f.allocation.setCountedPackageCount(0);
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order));
service.stockInReconciled("100", f.plan, f.task, List.of(f.line), List.of(f.allocation),
List.of(f.pallet), LocalDateTime.now());
verify(inventory, never()).stockIn(any(), any(), any());
verify(receipts, never()).insert(any(InboundStockReceipt.class));
verify(storageRent, never()).registerInbound(any(), any());
assertEquals("INBOUND_COMPLETED", f.order.getCurrentStatus());
assertEquals("INBOUND_PLAN:PARTIAL_INBOUND", f.order.getSubOrderStatus());
}
/** 一个 SKU 超收不能掩盖另一个 SKU 短少,即使全票总数多于申报。 */
@Test void reconciledKnownSkuOverReceiptStillPreservesOtherSkuShortage() {
Fixture f = fixture(21, 21, 25, 210);
f.allocation.setCountedPackageCount(25);
f.detail.setPackageCount(10);
when(orderDetails.selectList(any())).thenReturn(List.of(f.detail, detail(202, "SKU-2", 11, 110)));
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order));
when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(
skuReceipt(12, 21, "SKU-1", 16), skuReceipt(12, 21, "SKU-2", 9)));
when(inventory.stockIn(eq("100"), eq("operator"), any()))
.thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 25, 0, 0, 0, false, false));
service.stockInReconciled("100", f.plan, f.task, List.of(f.line), List.of(f.allocation),
List.of(f.pallet), LocalDateTime.now());
verify(inventory, times(2)).stockIn(eq("100"), eq("operator"), any());
assertEquals("INBOUND_PLAN:PARTIAL_INBOUND", f.order.getSubOrderStatus());
}
/** 多票分别判断短少,不能把另一票零实收的状态套给已核平的超收订单。 */
@Test void reconciledReceiptCompletesEachOrderFromItsOwnSkuFacts() {
Fixture f = fixture(21, 21, 25, 210);
f.allocation.setCountedPackageCount(25);
var emptyLine = new InboundPlanLine();
emptyLine.setId(13L); emptyLine.setOrderId(102L); emptyLine.setRequiredPackageCount(10);
var emptyOrder = new LastlegOrder();
emptyOrder.setId(102L); emptyOrder.setCustomerId("100"); emptyOrder.setCurrentStatus("INBOUND_PLANNED");
var emptyAllocation = allocation(32L, 13L, 22L, 0);
emptyAllocation.setCountedPackageCount(0);
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order, emptyOrder));
when(orders.lockById("100", 102L)).thenReturn(emptyOrder);
when(orderDetails.selectList(any())).thenReturn(List.of(f.detail), List.of(detail(202L, "SKU-2", 10, 100)));
when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(skuReceipt(12, 21, "SKU-1", 25)));
when(inventory.stockIn(eq("100"), eq("operator"), any()))
.thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 25, 0, 0, 0, false, false));
service.stockInReconciled("100", f.plan, f.task, List.of(f.line, emptyLine),
List.of(f.allocation, emptyAllocation), List.of(f.pallet, pallet(22L, 2002L)), LocalDateTime.now());
assertEquals("INBOUND_PLAN:COMPLETED", f.order.getSubOrderStatus());
assertEquals("INBOUND_PLAN:PARTIAL_INBOUND", emptyOrder.getSubOrderStatus());
verify(inventory).stockIn(eq("100"), eq("operator"), any());
}
/** 缺少明确清点事实不能按零实收入库。 */
@Test void reconciledReceiptRejectsUnknownZeroCount() {
Fixture f = fixture(21, 21, 0, 210);
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order));
assertThrows(ResponseStatusException.class, () -> service.stockInReconciled("100", f.plan, f.task,
List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now()));
verify(inventory, never()).stockIn(any(), any(), any());
}
/** 异常确认入口不能绕过一件代发必须整托收货的冻结来源。 */
@Test void reconciledReceiptRejectsDropshipOrder() {
Fixture f = fixture(21, 21, 0, 210);
f.allocation.setCountedPackageCount(0);
f.order.setOrderType("ONE_CLICK_DROPSHIP");
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order));
assertThrows(ResponseStatusException.class, () -> service.stockInReconciled("100", f.plan, f.task,
List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now()));
verify(inventory, never()).stockIn(any(), any(), any());
}
/** 未明确逐 SKU 实收时不按原申报顺序自动分摊超收。 */
@Test void reconciledReceiptRequiresExactSkuFacts() {
Fixture f = fixture(21, 21, 25, 210);
f.allocation.setCountedPackageCount(25);
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order));
assertThrows(ResponseStatusException.class, () -> service.stockInReconciled("100", f.plan, f.task,
List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now()));
verify(inventory, never()).stockIn(any(), any(), any());
}
/** 异常核对仍不能引入冻结申报以外的 SKU。 */
@Test void reconciledReceiptRejectsUnknownSku() {
Fixture f = fixture(21, 21, 25, 210);
f.allocation.setCountedPackageCount(25);
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order));
when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(skuReceipt(12, 21, "UNKNOWN", 25)));
assertThrows(ResponseStatusException.class, () -> service.stockInReconciled("100", f.plan, f.task,
List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now()));
verify(inventory, never()).stockIn(any(), any(), any());
}
/** 全票总数相等也须拒绝逐托实收与 SKU 实收错配。 */
@Test void reconciledReceiptRejectsMismatchedPalletQuantities() {
Fixture f = fixture(21, 21, 5, 210);
f.allocation.setCountedPackageCount(5);
var secondAllocation = allocation(32L, 12L, 22L, 20);
secondAllocation.setCountedPackageCount(20);
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order));
when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(
skuReceipt(12, 21, "SKU-1", 6), skuReceipt(12, 22, "SKU-1", 19)));
assertThrows(ResponseStatusException.class, () -> service.stockInReconciled("100", f.plan, f.task,
List.of(f.line), List.of(f.allocation, secondAllocation), List.of(f.pallet, pallet(22L, 2002L)), LocalDateTime.now()));
verify(inventory, never()).stockIn(any(), any(), any());
}
/** 同一份已入账实收可重放,不重复生成库存和计租。 */
@Test void reconciledReceiptReplaysOnlyMatchingStockReceipts() {
Fixture f = fixture(21, 21, 25, 210);
f.allocation.setCountedPackageCount(25);
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order));
when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(skuReceipt(12, 21, "SKU-1", 25)));
var existing = new InboundStockReceipt();
existing.setAllocationId(31L); existing.setLineId(12L); existing.setSkuCode("SKU-1"); existing.setReceivedPackages(25L);
when(receipts.listTask("100", 2L)).thenReturn(List.of(existing));
service.stockInReconciled("100", f.plan, f.task, List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now());
verify(inventory, never()).stockIn(any(), any(), any());
verify(receipts, never()).insert(any(InboundStockReceipt.class));
verify(storageRent, never()).registerInbound(any(), any());
assertEquals("INBOUND_PLAN:COMPLETED", f.order.getSubOrderStatus());
}
/** 已入账凭证数量变化必须拒绝,不能沿用仅比较 allocation ID 的普通重试分支。 */
@Test void reconciledReceiptRejectsChangedStockReceiptQuantity() {
Fixture f = fixture(21, 21, 25, 210);
f.allocation.setCountedPackageCount(25);
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order));
when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(skuReceipt(12, 21, "SKU-1", 25)));
var existing = new InboundStockReceipt();
existing.setAllocationId(31L); existing.setLineId(12L); existing.setSkuCode("SKU-1"); existing.setReceivedPackages(21L);
when(receipts.listTask("100", 2L)).thenReturn(List.of(existing));
assertThrows(ResponseStatusException.class, () -> service.stockInReconciled("100", f.plan, f.task,
List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now()));
verify(inventory, never()).stockIn(any(), any(), any());
}
private Fixture fixture(int required, int planned, int picked, int quantity) { private Fixture fixture(int required, int planned, int picked, int quantity) {
InboundPlan plan = new InboundPlan(); plan.setId(1L); plan.setPlanNo("IP-1"); plan.setOverseasWarehouseId(900L); InboundPlan plan = new InboundPlan(); plan.setId(1L); plan.setPlanNo("IP-1"); plan.setOverseasWarehouseId(900L);
InboundPdaTask task = new InboundPdaTask(); task.setId(2L); task.setOperatorId("operator"); InboundPdaTask task = new InboundPdaTask(); task.setId(2L); task.setOperatorId("operator");

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