Compare commits
32
Commits
97c80574df
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ab5411e53 | ||
|
|
c71a065dda | ||
|
|
e132c3c80a | ||
|
|
f4901d155c | ||
|
|
87bb905283 | ||
|
|
1611c42d28 | ||
|
|
e86bdcd623 | ||
|
|
3cc748974f | ||
|
|
6d8a69b995 | ||
|
|
99f896848e | ||
|
|
08b28c72e7 | ||
|
|
f571e107e4 | ||
|
|
51ea89e399 | ||
|
|
d37522081b | ||
|
|
cc9bcbed95 | ||
|
|
7cd764e14a | ||
|
|
f4362f6dfd | ||
|
|
9c8273d9a0 | ||
|
|
e995a87b0e | ||
|
|
51c3132195 | ||
|
|
c6bbb22a02 | ||
|
|
d722bcae49 | ||
|
|
9e99658bfa | ||
|
|
44202a7bff | ||
|
|
ef2ac1eafd | ||
|
|
08ea663334 | ||
|
|
8809801070 | ||
|
|
497544e9fd | ||
|
|
83c3b02d96 | ||
|
|
d1ade7e9dd | ||
|
|
00120df2eb | ||
|
|
9af1eba876 |
@@ -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 || {
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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" \
|
||||||
|
|||||||
@@ -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 或部署。
|
||||||
|
|||||||
+10
@@ -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;
|
||||||
+1
-1
@@ -22,7 +22,7 @@
|
|||||||
| 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;`FINANCE_VOUCHER` 按客户与业务 ID 精确校验;`OVERSEAS_UNLOAD_PHOTO` 是允许同柜多客户的柜级物理证据,固定平台范围 `tenantId=0` 并按接柜会话 ID 精确校验 |
|
| 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;`FINANCE_VOUCHER` 按客户与业务 ID 精确校验;`OVERSEAS_UNLOAD_PHOTO` 是允许同柜多客户的柜级物理证据,固定平台范围 `tenantId=0` 并按接柜会话 ID 精确校验 |
|
||||||
| 公司车辆付款凭证 | `src/main/resources/openapi/finance-vehicle-voucher-v1.yaml`;类型`FINANCE_VEHICLE_VOUCHER`,固定平台0和Finance费用ID,仅非空JPG/PNG、10MiB及签名校验;不复用客户凭证或维修照片 |
|
| 公司车辆付款凭证 | `src/main/resources/openapi/finance-vehicle-voucher-v1.yaml`;类型`FINANCE_VEHICLE_VOUCHER`,固定平台0和Finance费用ID,仅非空JPG/PNG、10MiB及签名校验;不复用客户凭证或维修照片 |
|
||||||
| 海外异常订单证据 | 通用内部业务文件入口;固定 `businessType=OVERSEAS_EXCEPTION_EVIDENCE`、`tenantId=异常订单所属客户ID`、`businessId=异常订单ID`,File 只校验文件复合归属,异常状态、完成门禁和证据引用归 Lastleg |
|
| 海外异常订单证据 | 通用内部业务文件入口;固定 `businessType=OVERSEAS_EXCEPTION_EVIDENCE`、`tenantId=异常订单所属客户ID`、`businessId=异常订单ID`,File 只校验文件复合归属,异常状态、完成门禁和证据引用归 Lastleg |
|
||||||
| 平台司机驾驶证 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-driver-license-v1.yaml`;`FLEET_DRIVER_LICENSE` 固定平台 `tenantId=0`、`businessId=driverId`,仅非空 PDF/JPG/PNG、最大 10MiB并校验签名;Lastleg维护审核、当前/历史附件关联及读取权限 |
|
| 平台司机驾驶证 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-driver-license-v1.yaml`;`FLEET_DRIVER_LICENSE` 固定平台 `tenantId=0`、`businessId=driverId`,上传仅非空 JPG/PNG、最大 10MiB并校验签名;Lastleg维护审核、当前/历史附件关联及读取权限 |
|
||||||
| 车辆保养加油照片 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-vehicle-record-photo-v1.yaml`;`FLEET_VEHICLE_RECORD_PHOTO` 固定平台0及运维记录ID,仅非空JPG/PNG、最大10MiB并校验签名;Lastleg校验车辆/记录/附件关联与权限,不使用车牌作为文件归属键 |
|
| 车辆保养加油照片 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-vehicle-record-photo-v1.yaml`;`FLEET_VEHICLE_RECORD_PHOTO` 固定平台0及运维记录ID,仅非空JPG/PNG、最大10MiB并校验签名;Lastleg校验车辆/记录/附件关联与权限,不使用车牌作为文件归属键 |
|
||||||
| 出车实测凭证 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-trip-operation-evidence-v1.yaml`;`FLEET_TRIP_OPERATION_EVIDENCE`固定平台0及车次ID,PDF/JPG/PNG、10MiB与签名校验;Lastleg管理实测版本引用及读写权限,附件选填 |
|
| 出车实测凭证 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-trip-operation-evidence-v1.yaml`;`FLEET_TRIP_OPERATION_EVIDENCE`固定平台0及车次ID,PDF/JPG/PNG、10MiB与签名校验;Lastleg管理实测版本引用及读写权限,附件选填 |
|
||||||
| 海外换标 PDF 资料 | 通用内部业务文件入口,契约 `src/main/resources/openapi/overseas-relabel-document-v1.yaml`;`OVERSEAS_RELABEL_TOTAL_DOCUMENT` 按 `application.id`、`OVERSEAS_RELABEL_ITEM_DOCUMENT` 按 `item.id` 归属,必须指定明确客户与正数业务 ID;仅非空 PDF、最大 10MiB,校验 MIME 和文件签名。File 按客户、类型、业务 ID、文件 ID 复合校验,Lastleg 校验申请/明细关系、客户归属、权限和业务状态 |
|
| 海外换标 PDF 资料 | 通用内部业务文件入口,契约 `src/main/resources/openapi/overseas-relabel-document-v1.yaml`;`OVERSEAS_RELABEL_TOTAL_DOCUMENT` 按 `application.id`、`OVERSEAS_RELABEL_ITEM_DOCUMENT` 按 `item.id` 归属,必须指定明确客户与正数业务 ID;仅非空 PDF、最大 10MiB,校验 MIME 和文件签名。File 按客户、类型、业务 ID、文件 ID 复合校验,Lastleg 校验申请/明细关系、客户归属、权限和业务状态 |
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import java.util.Arrays;
|
|||||||
enum BusinessFileCategory {
|
enum BusinessFileCategory {
|
||||||
FLEET_VEHICLE_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
FLEET_VEHICLE_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
FLEET_DRIVER_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
FLEET_DRIVER_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
FLEET_DRIVER_LICENSE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
FLEET_DRIVER_LICENSE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ info:
|
|||||||
description: >-
|
description: >-
|
||||||
复用InternalBusinessFileController,固定平台tenantId=0、businessType=FLEET_DRIVER_LICENSE、
|
复用InternalBusinessFileController,固定平台tenantId=0、businessType=FLEET_DRIVER_LICENSE、
|
||||||
businessId=司机内部ID。Lastleg负责司机存在性、列表/上传权限、关联和历史可见性;
|
businessId=司机内部ID。Lastleg负责司机存在性、列表/上传权限、关联和历史可见性;
|
||||||
File校验内部服务令牌、平台复合归属、真实非空文件、MIME与签名。最大10MiB,仅PDF/JPG/PNG。
|
File校验内部服务令牌、平台复合归属、真实非空文件、MIME与签名。上传最大10MiB,仅JPG/PNG。
|
||||||
上传只生成不可变对象,不自动通过资质审核,不生成公开URL,旧附件通过Lastleg历史引用读取。
|
上传只生成不可变对象,不自动通过资质审核,不生成公开URL,旧附件通过Lastleg历史引用读取。
|
||||||
security:
|
security:
|
||||||
- InternalServiceToken: []
|
- InternalServiceToken: []
|
||||||
|
|||||||
@@ -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("归属不匹配");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,15 +24,22 @@ class DriverLicenseFileTest {
|
|||||||
assertThatThrownBy(() -> category.storageBusinessId("0")).isInstanceOf(BizException.class);
|
assertThatThrownBy(() -> category.storageBusinessId("0")).isInstanceOf(BizException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test void storesRealPdfAndRejectsWrongMimeAndSpoofedPdf() throws Exception {
|
/** 驾驶证上传接受有效 JPG/PNG,其他格式和伪装的图片均被拒绝。 */
|
||||||
|
@Test void storesImagesAndRejectsPdfWrongMimeAndSpoofedImages() throws Exception {
|
||||||
var storage = storage();
|
var storage = storage();
|
||||||
var pdf = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.7\nlicense".getBytes());
|
var pdf = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.7\nlicense".getBytes());
|
||||||
var result = storage.store(0, "71", pdf, BusinessFileCategory.FLEET_DRIVER_LICENSE);
|
for (var format : new String[]{"jpg", "png"}) {
|
||||||
assertThat(storage.read(result.objectKey())).isEqualTo(pdf.getBytes());
|
var type = format.equals("jpg") ? "image/jpeg" : "image/png";
|
||||||
|
var file = new MockMultipartFile("file", "license." + format, type, image(format));
|
||||||
|
var result = storage.store(0, "71", file, BusinessFileCategory.FLEET_DRIVER_LICENSE);
|
||||||
|
assertThat(storage.read(result.objectKey())).isEqualTo(file.getBytes());
|
||||||
|
}
|
||||||
|
assertThatThrownBy(() -> storage.store(0, "71", pdf, BusinessFileCategory.FLEET_DRIVER_LICENSE))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("仅支持JPG、PNG");
|
||||||
assertThatThrownBy(() -> storage.store(0, "71", new MockMultipartFile("file", "license.txt", "text/plain", "text".getBytes()), BusinessFileCategory.FLEET_DRIVER_LICENSE))
|
assertThatThrownBy(() -> storage.store(0, "71", new MockMultipartFile("file", "license.txt", "text/plain", "text".getBytes()), BusinessFileCategory.FLEET_DRIVER_LICENSE))
|
||||||
.isInstanceOf(BizException.class).hasMessageContaining("仅支持");
|
.isInstanceOf(BizException.class).hasMessageContaining("仅支持");
|
||||||
assertThatThrownBy(() -> storage.store(0, "71", new MockMultipartFile("file", "license.pdf", "application/pdf", "not pdf".getBytes()), BusinessFileCategory.FLEET_DRIVER_LICENSE))
|
assertThatThrownBy(() -> storage.store(0, "71", new MockMultipartFile("file", "license.png", "image/png", pdf.getBytes()), BusinessFileCategory.FLEET_DRIVER_LICENSE))
|
||||||
.isInstanceOf(BizException.class).hasMessageContaining("不是有效PDF");
|
.isInstanceOf(BizException.class).hasMessageContaining("图片");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test void rejectsMoreThanTenMibBeforeReadingBytes() {
|
@Test void rejectsMoreThanTenMibBeforeReadingBytes() {
|
||||||
@@ -43,6 +50,14 @@ class DriverLicenseFileTest {
|
|||||||
.isInstanceOf(BizException.class).hasMessageContaining("10MB");
|
.isInstanceOf(BizException.class).hasMessageContaining("10MB");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 生成可解码的图片,覆盖文件格式与实际内容一致的上传路径。 */
|
||||||
|
private static byte[] image(String format) throws java.io.IOException {
|
||||||
|
var bytes = new java.io.ByteArrayOutputStream();
|
||||||
|
var image = new java.awt.image.BufferedImage(1, 1, java.awt.image.BufferedImage.TYPE_INT_RGB);
|
||||||
|
assertThat(javax.imageio.ImageIO.write(image, format, bytes)).isTrue();
|
||||||
|
return bytes.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
private BusinessFileStorage storage() {
|
private BusinessFileStorage storage() {
|
||||||
var storage = new BusinessFileStorage();
|
var storage = new BusinessFileStorage();
|
||||||
ReflectionTestUtils.setField(storage, "configuredRoot", root);
|
ReflectionTestUtils.setField(storage, "configuredRoot", root);
|
||||||
|
|||||||
+2
-1
@@ -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",
|
||||||
|
|||||||
+9
-10
@@ -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), "余量子入库单");
|
||||||
splits.transferRemainingCartonLabels(parent.tenantId(), parentInboundId, remainderChildId,
|
if (dropshipWholePallet) {
|
||||||
orderSplit.remainderChild().orderId(), remainderChildCode, pickedPackageCount, childPackageCount);
|
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,
|
||||||
|
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}
|
||||||
|
|||||||
+5
-3
@@ -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")
|
||||||
|
|||||||
+23
@@ -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 {
|
||||||
|
|||||||
+5
-4
@@ -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
|
||||||
|
|||||||
+50
@@ -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);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
+65
-7
@@ -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,?,?)",
|
||||||
|
|||||||
+21
-17
@@ -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
|
||||||
|
|||||||
+2
-1
@@ -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() {
|
||||||
|
|||||||
+55
@@ -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");
|
||||||
|
|||||||
+2
-3
@@ -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");
|
||||||
|
|||||||
+10
-6
@@ -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");
|
||||||
|
|||||||
@@ -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` |
|
||||||
|
|||||||
+2
-2
@@ -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,
|
||||||
|
|||||||
+2
-1
@@ -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);}
|
||||||
|
|||||||
+45
-11
@@ -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("仓内装载与异地取货合计超过本车容量");
|
||||||
}
|
}
|
||||||
/** 取货照片保存事务复验已确认仓内实载与当前异地申报。 */
|
/** 取货照片保存事务复验已确认仓内实载与当前异地申报。 */
|
||||||
|
|||||||
+11
-2
@@ -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;
|
||||||
|
|||||||
+20
-7
@@ -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("排车版本竞争失败");
|
||||||
if(repository.history(IdWorker.getId(),row,"LOOSEBOARD_PHOTO",actor.operatorId(),phase.name(),write(evidence),row.getUpdatedAt())!=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(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;}
|
||||||
|
|||||||
+3
-1
@@ -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;
|
||||||
|
|||||||
+32
-5
@@ -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) {
|
||||||
|
|||||||
+4
@@ -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);
|
||||||
|
|||||||
+4
@@ -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); }
|
||||||
/** 保存人工完结后的异常状态,保留关联单据和实际库存事实。 */
|
/** 保存人工完结后的异常状态,保留关联单据和实际库存事实。 */
|
||||||
|
|||||||
+10
-3
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-6
@@ -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());
|
||||||
|
|||||||
+9
-5
@@ -19,7 +19,8 @@ import java.util.Set;
|
|||||||
public class HttpDriverFileGateway implements DriverFileGateway {
|
public class HttpDriverFileGateway implements DriverFileGateway {
|
||||||
static final String TYPE = "FLEET_DRIVER_LICENSE";
|
static final String TYPE = "FLEET_DRIVER_LICENSE";
|
||||||
static final long MAX_SIZE = 10L * 1024 * 1024;
|
static final long MAX_SIZE = 10L * 1024 * 1024;
|
||||||
private static final Set<String> CONTENT_TYPES = Set.of("application/pdf", "image/jpeg", "image/png");
|
private static final Set<String> READ_CONTENT_TYPES = Set.of("application/pdf", "image/jpeg", "image/png");
|
||||||
|
private static final Set<String> UPLOAD_CONTENT_TYPES = Set.of("image/jpeg", "image/png");
|
||||||
private static final String PATH = "/internal/file/v1/business-files/0/FLEET_DRIVER_LICENSE/{driver}";
|
private static final String PATH = "/internal/file/v1/business-files/0/FLEET_DRIVER_LICENSE/{driver}";
|
||||||
private final RestClient client;
|
private final RestClient client;
|
||||||
private final String token;
|
private final String token;
|
||||||
@@ -31,10 +32,11 @@ public class HttpDriverFileGateway implements DriverFileGateway {
|
|||||||
this.token = token;
|
this.token = token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 新上传的驾驶证只接受 JPG/PNG,读取历史附件继续按原文件元数据校验。 */
|
||||||
@Override
|
@Override
|
||||||
public DriverModels.File upload(long driverId, String operatorId, MultipartFile file) {
|
public DriverModels.File upload(long driverId, String operatorId, MultipartFile file) {
|
||||||
if (file == null || file.isEmpty() || file.getSize() > MAX_SIZE || !CONTENT_TYPES.contains(Objects.toString(file.getContentType(), "")))
|
if (file == null || file.isEmpty() || file.getSize() > MAX_SIZE || !UPLOAD_CONTENT_TYPES.contains(Objects.toString(file.getContentType(), "")))
|
||||||
throw bad("驾驶证附件须为非空的 PDF、JPG 或 PNG,最大 10MiB");
|
throw bad("驾驶证附件须为非空的 JPG 或 PNG,最大 10MiB");
|
||||||
var body = new MultipartBodyBuilder();
|
var body = new MultipartBodyBuilder();
|
||||||
try {
|
try {
|
||||||
body.part("file", new ByteArrayResource(file.getBytes()) {
|
body.part("file", new ByteArrayResource(file.getBytes()) {
|
||||||
@@ -45,7 +47,9 @@ public class HttpDriverFileGateway implements DriverFileGateway {
|
|||||||
.header("X-Operator-Id", operatorId).contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
|
.header("X-Operator-Id", operatorId).contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
|
||||||
.onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); })
|
.onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); })
|
||||||
.body(Envelope.class);
|
.body(Envelope.class);
|
||||||
return verified(result, driverId, null);
|
DriverModels.File uploaded = verified(result, driverId, null);
|
||||||
|
if (!UPLOAD_CONTENT_TYPES.contains(uploaded.contentType())) throw bad("驾驶证附件须为 JPG 或 PNG");
|
||||||
|
return uploaded;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -72,7 +76,7 @@ public class HttpDriverFileGateway implements DriverFileGateway {
|
|||||||
if (data == null || data.fileId() == null || data.fileId() <= 0 || !Objects.equals(data.tenantId(), 0L)
|
if (data == null || data.fileId() == null || data.fileId() <= 0 || !Objects.equals(data.tenantId(), 0L)
|
||||||
|| !TYPE.equals(data.businessType()) || !Long.toString(driverId).equals(data.businessId())
|
|| !TYPE.equals(data.businessType()) || !Long.toString(driverId).equals(data.businessId())
|
||||||
|| (fileId != null && !fileId.equals(data.fileId())) || data.fileName() == null || data.fileName().isBlank()
|
|| (fileId != null && !fileId.equals(data.fileId())) || data.fileName() == null || data.fileName().isBlank()
|
||||||
|| !CONTENT_TYPES.contains(Objects.toString(data.contentType(), "")) || data.sizeBytes() == null
|
|| !READ_CONTENT_TYPES.contains(Objects.toString(data.contentType(), "")) || data.sizeBytes() == null
|
||||||
|| data.sizeBytes() <= 0 || data.sizeBytes() > MAX_SIZE)
|
|| data.sizeBytes() <= 0 || data.sizeBytes() > MAX_SIZE)
|
||||||
throw bad("文件不属于当前司机或不是有效驾驶证附件");
|
throw bad("文件不属于当前司机或不是有效驾驶证附件");
|
||||||
return new DriverModels.File(data.fileId().toString(), data.fileName(), data.contentType(), data.sizeBytes());
|
return new DriverModels.File(data.fileId().toString(), data.fileName(), data.contentType(), data.sizeBytes());
|
||||||
|
|||||||
+28
-4
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -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) {}
|
||||||
|
|||||||
+28
-4
@@ -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) {
|
||||||
|
|||||||
+22
-2
@@ -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') > 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 >= #{q.from} AND t.departed_at < #{q.to}
|
WHERE t.departed_at >= #{q.from,typeHandler=com.baiyue.lastleg.fleet.reporting.TripOperationsUtcInstantTypeHandler}
|
||||||
|
AND t.departed_at < #{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
|
||||||
|
|||||||
+7
-1
@@ -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++) {
|
||||||
|
|||||||
+45
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -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")
|
||||||
|
|||||||
+41
@@ -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) { }
|
||||||
|
}
|
||||||
+1
-1
@@ -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<>'CANCELLED')";
|
" AND dispatch.status<>'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" +
|
||||||
|
|||||||
+8
-5
@@ -13,7 +13,8 @@ public final class ReceivingContainerResponses {
|
|||||||
List<String> destinationCodes, int plannedPalletCount,
|
List<String> destinationCodes, int plannedPalletCount,
|
||||||
int totalPackages, int receivedPackages,
|
int totalPackages, int receivedPackages,
|
||||||
boolean operable, boolean allPlansReady, String blockReason,
|
boolean operable, boolean allPlansReady, String blockReason,
|
||||||
LocalDateTime plannedArrivalAt, boolean currentAccount) {
|
LocalDateTime plannedArrivalAt, boolean currentAccount, String cabinetNo) {
|
||||||
|
/** 保留不含接柜仓与柜次号的既有响应构造方式。 */
|
||||||
public Candidate(String sourceContainerBookingId, String containerNo, String status,
|
public Candidate(String sourceContainerBookingId, String containerNo, String status,
|
||||||
int totalOrderCount, int inboundCodeCount, int missingInboundCodeCount,
|
int totalOrderCount, int inboundCodeCount, int missingInboundCodeCount,
|
||||||
int planCount, int plannedInboundCodeCount, int pendingPlanCount,
|
int planCount, int plannedInboundCodeCount, int pendingPlanCount,
|
||||||
@@ -23,7 +24,7 @@ public final class ReceivingContainerResponses {
|
|||||||
this(sourceContainerBookingId, containerNo, null, null, status, totalOrderCount,
|
this(sourceContainerBookingId, containerNo, null, null, status, totalOrderCount,
|
||||||
inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount,
|
inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount,
|
||||||
pendingPlanCount, destinationCodes, plannedPalletCount, totalPackages,
|
pendingPlanCount, destinationCodes, plannedPalletCount, totalPackages,
|
||||||
receivedPackages, operable, allPlansReady, blockReason, plannedArrivalAt, currentAccount);
|
receivedPackages, operable, allPlansReady, blockReason, plannedArrivalAt, currentAccount, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +37,8 @@ public final class ReceivingContainerResponses {
|
|||||||
LocalDateTime preparedAt, LocalDateTime signedAt,
|
LocalDateTime preparedAt, LocalDateTime signedAt,
|
||||||
LocalDateTime receivingAt, LocalDateTime completedAt,
|
LocalDateTime receivingAt, LocalDateTime completedAt,
|
||||||
int totalPackages, int receivedPackages, int pendingPackages,
|
int totalPackages, int receivedPackages, int pendingPackages,
|
||||||
boolean hasDifference, List<Inbound> inbounds) {
|
boolean hasDifference, List<Inbound> inbounds, String cabinetNo) {
|
||||||
|
/** 旧会话构造缺少接柜仓编码及柜次号时保持空值。 */
|
||||||
public Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseName,
|
public Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseName,
|
||||||
String status, String operatorName, int version, List<SignedPhoto> photos,
|
String status, String operatorName, int version, List<SignedPhoto> photos,
|
||||||
LocalDateTime preparedAt, LocalDateTime signedAt, LocalDateTime receivingAt,
|
LocalDateTime preparedAt, LocalDateTime signedAt, LocalDateTime receivingAt,
|
||||||
@@ -44,9 +46,10 @@ public final class ReceivingContainerResponses {
|
|||||||
int pendingPackages, List<Inbound> inbounds) {
|
int pendingPackages, List<Inbound> inbounds) {
|
||||||
this(containerNo, overseasWarehouseId, null, overseasWarehouseName, status, operatorName,
|
this(containerNo, overseasWarehouseId, null, overseasWarehouseName, status, operatorName,
|
||||||
version, photos, List.of(), preparedAt, signedAt, receivingAt, completedAt, totalPackages,
|
version, photos, List.of(), preparedAt, signedAt, receivingAt, completedAt, totalPackages,
|
||||||
receivedPackages, pendingPackages, false, inbounds);
|
receivedPackages, pendingPackages, false, inbounds, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 保留不含柜次号及国内照片的既有会话构造方式。 */
|
||||||
public Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseCode,
|
public Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseCode,
|
||||||
String overseasWarehouseName, String status, String operatorName, int version,
|
String overseasWarehouseName, String status, String operatorName, int version,
|
||||||
List<SignedPhoto> photos, LocalDateTime preparedAt, LocalDateTime signedAt,
|
List<SignedPhoto> photos, LocalDateTime preparedAt, LocalDateTime signedAt,
|
||||||
@@ -54,7 +57,7 @@ public final class ReceivingContainerResponses {
|
|||||||
int receivedPackages, int pendingPackages, List<Inbound> inbounds) {
|
int receivedPackages, int pendingPackages, List<Inbound> inbounds) {
|
||||||
this(containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName,
|
this(containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName,
|
||||||
status, operatorName, version, photos, List.of(), preparedAt, signedAt, receivingAt,
|
status, operatorName, version, photos, List.of(), preparedAt, signedAt, receivingAt,
|
||||||
completedAt, totalPackages, receivedPackages, pendingPackages, false, inbounds);
|
completedAt, totalPackages, receivedPackages, pendingPackages, false, inbounds, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+47
@@ -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);
|
||||||
|
}
|
||||||
+15
@@ -17,6 +17,21 @@ public interface ReceivingCandidateMapper {
|
|||||||
|
|
||||||
record AvailableOrder(Long orderId, String bookingId) { }
|
record AvailableOrder(Long orderId, String bookingId) { }
|
||||||
|
|
||||||
|
/** 按稳定柜次身份批量读取本库装柜事件中的正式柜次号,不从柜号或内部 ID 推导。 */
|
||||||
|
@Select("""
|
||||||
|
<script>
|
||||||
|
SELECT o.source_container_booking_id AS booking_id,
|
||||||
|
MAX(NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(e.payload_json,'$.cabinetNo'))),'null'),'')) AS cabinet_no
|
||||||
|
FROM ll_order o LEFT JOIN ll_loading_event_inbox e ON e.event_id=o.source_event_id
|
||||||
|
WHERE o.source_container_booking_id IN
|
||||||
|
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||||
|
GROUP BY o.source_container_booking_id
|
||||||
|
</script>
|
||||||
|
""")
|
||||||
|
List<CabinetNumber> cabinetNumbers(@Param("ids") List<String> ids);
|
||||||
|
|
||||||
|
record CabinetNumber(String bookingId, String cabinetNo) { }
|
||||||
|
|
||||||
/** 保留原转换命令的客户和计划精确匹配,一批读取未确认计划。 */
|
/** 保留原转换命令的客户和计划精确匹配,一批读取未确认计划。 */
|
||||||
@Select("<script>SELECT p.id FROM ll_inbound_plan p JOIN ll_order o ON o.customer_id=p.customer_id " +
|
@Select("<script>SELECT p.id FROM ll_inbound_plan p JOIN ll_order o ON o.customer_id=p.customer_id " +
|
||||||
"AND o.id=p.source_order_id WHERE p.id IN " +
|
"AND o.id=p.source_order_id WHERE p.id IN " +
|
||||||
|
|||||||
+71
@@ -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("收货会话已变化");
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
@@ -41,6 +41,14 @@ public class ReceivingCandidateRepository {
|
|||||||
return sessions.findActiveContainers();
|
return sessions.findActiveContainers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 柜次号按批次读取,缺失快照时保持为空,避免候选列表逐柜查询。 */
|
||||||
|
public Map<String, String> cabinetNumbers(List<String> bookingIds) {
|
||||||
|
return batch(bookingIds.stream().distinct().toList(), candidates::cabinetNumbers).stream()
|
||||||
|
.filter(row -> row.cabinetNo() != null && !row.cabinetNo().isBlank())
|
||||||
|
.collect(Collectors.toMap(ReceivingCandidateMapper.CabinetNumber::bookingId,
|
||||||
|
row -> row.cabinetNo().trim()));
|
||||||
|
}
|
||||||
|
|
||||||
public record LineKey(String customerId, Long planId, Long lineId) { }
|
public record LineKey(String customerId, Long planId, Long lineId) { }
|
||||||
public record Facts(List<LastlegOrder> orders, List<InboundPlan> plans, List<InboundPlanLine> lines,
|
public record Facts(List<LastlegOrder> orders, List<InboundPlan> plans, List<InboundPlanLine> lines,
|
||||||
Set<Long> availableOrderIds, Set<Long> unconfirmedPlans, Map<LineKey,Integer> received) { }
|
Set<Long> availableOrderIds, Set<Long> unconfirmedPlans, Map<LineKey,Integer> received) { }
|
||||||
|
|||||||
+472
@@ -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) { }
|
||||||
|
}
|
||||||
+97
@@ -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) {}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-5
@@ -139,7 +139,7 @@ public class ReceivingContainerService {
|
|||||||
Set<String> reservedByOthers = candidateRepository.activeSessions().stream()
|
Set<String> reservedByOthers = candidateRepository.activeSessions().stream()
|
||||||
.filter(session -> !operatorLockKey.equals(session.getActiveOperatorKey()))
|
.filter(session -> !operatorLockKey.equals(session.getActiveOperatorKey()))
|
||||||
.map(ReceivingContainerSession::getActiveContainerKey).filter(Objects::nonNull).collect(Collectors.toSet());
|
.map(ReceivingContainerSession::getActiveContainerKey).filter(Objects::nonNull).collect(Collectors.toSet());
|
||||||
return grouped.entrySet().stream()
|
List<ContainerReadiness> visible = grouped.entrySet().stream()
|
||||||
.map(entry -> {
|
.map(entry -> {
|
||||||
var localLines = entry.getValue().stream().flatMap(order ->
|
var localLines = entry.getValue().stream().flatMap(order ->
|
||||||
linesByOrder.getOrDefault(order.getId(), List.of()).stream()).toList();
|
linesByOrder.getOrDefault(order.getId(), List.of()).stream()).toList();
|
||||||
@@ -155,7 +155,11 @@ public class ReceivingContainerService {
|
|||||||
.thenComparing(ContainerReadiness::plannedArrivalAt,
|
.thenComparing(ContainerReadiness::plannedArrivalAt,
|
||||||
Comparator.nullsLast(Comparator.naturalOrder()))
|
Comparator.nullsLast(Comparator.naturalOrder()))
|
||||||
.thenComparing(ContainerReadiness::sourceContainerBookingId))
|
.thenComparing(ContainerReadiness::sourceContainerBookingId))
|
||||||
.map(value -> candidate(value, current)).toList();
|
.toList();
|
||||||
|
Map<String, String> cabinetNumbers = candidateRepository.cabinetNumbers(
|
||||||
|
visible.stream().map(ContainerReadiness::sourceContainerBookingId).toList());
|
||||||
|
return visible.stream().map(value -> candidate(value, current,
|
||||||
|
cabinetNumbers.get(value.sourceContainerBookingId()))).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo,
|
public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo,
|
||||||
@@ -783,8 +787,9 @@ public class ReceivingContainerService {
|
|||||||
blockReason, arrival, factHash, totals);
|
blockReason, arrival, factHash, totals);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 候选展示批量读取的正式柜次号,继续以稳定柜身份执行接柜门禁。 */
|
||||||
private ReceivingContainerResponses.Candidate candidate(ContainerReadiness value,
|
private ReceivingContainerResponses.Candidate candidate(ContainerReadiness value,
|
||||||
ReceivingContainerSession current) {
|
ReceivingContainerSession current, String cabinetNo) {
|
||||||
boolean active = current != null && value.containerNo().equals(current.getContainerNo());
|
boolean active = current != null && value.containerNo().equals(current.getContainerNo());
|
||||||
Totals totals = value.totals();
|
Totals totals = value.totals();
|
||||||
return new ReceivingContainerResponses.Candidate(value.sourceContainerBookingId(), value.containerNo(),
|
return new ReceivingContainerResponses.Candidate(value.sourceContainerBookingId(), value.containerNo(),
|
||||||
@@ -793,7 +798,7 @@ public class ReceivingContainerService {
|
|||||||
value.missingInboundCodeCount(), value.planCount(), value.plannedInboundCodeCount(),
|
value.missingInboundCodeCount(), value.planCount(), value.plannedInboundCodeCount(),
|
||||||
value.pendingPlanCount(), totals.destinationCodes(), totals.plannedPalletCount(), totals.total(),
|
value.pendingPlanCount(), totals.destinationCodes(), totals.plannedPalletCount(), totals.total(),
|
||||||
totals.received(), value.operable(), value.allPlansReady(), value.blockReason(),
|
totals.received(), value.operable(), value.allPlansReady(), value.blockReason(),
|
||||||
value.plannedArrivalAt(), active);
|
value.plannedArrivalAt(), active, cabinetNo);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ReceivingContainerSession requireSigned(String operatorKey) {
|
private ReceivingContainerSession requireSigned(String operatorKey) {
|
||||||
@@ -1083,18 +1088,22 @@ public class ReceivingContainerService {
|
|||||||
private record DropshipPalletRef(InboundRef ref, InboundPlanPallet pallet,
|
private record DropshipPalletRef(InboundRef ref, InboundPlanPallet pallet,
|
||||||
InboundPlanPalletAllocation allocation) { }
|
InboundPlanPalletAllocation allocation) { }
|
||||||
|
|
||||||
|
/** 当前柜摘要从本地冻结快照补充柜次号,覆盖详情、签收、恢复及完成响应。 */
|
||||||
private ReceivingContainerResponses.Session snapshot(ReceivingContainerSession session, List<InboundPlan> containerPlans) {
|
private ReceivingContainerResponses.Session snapshot(ReceivingContainerSession session, List<InboundPlan> containerPlans) {
|
||||||
List<ReceivingContainerResponses.Inbound> inbounds = refs(
|
List<ReceivingContainerResponses.Inbound> inbounds = refs(
|
||||||
session.getSourceContainerBookingId(), session.getContainerNo()).stream()
|
session.getSourceContainerBookingId(), session.getContainerNo()).stream()
|
||||||
.map(this::inbound).toList();
|
.map(this::inbound).toList();
|
||||||
int total = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum();
|
int total = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum();
|
||||||
int received = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum();
|
int received = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum();
|
||||||
|
String bookingId = session.getSourceContainerBookingId();
|
||||||
|
String cabinetNo = bookingId == null ? null
|
||||||
|
: candidateRepository.cabinetNumbers(List.of(bookingId)).get(bookingId);
|
||||||
return new ReceivingContainerResponses.Session(session.getContainerNo(), session.getOverseasWarehouseId(),
|
return new ReceivingContainerResponses.Session(session.getContainerNo(), session.getOverseasWarehouseId(),
|
||||||
session.getOverseasWarehouseCode(), session.getOverseasWarehouseName(), session.getStatus(), session.getOperatorName(),
|
session.getOverseasWarehouseCode(), session.getOverseasWarehouseName(), session.getStatus(), session.getOperatorName(),
|
||||||
value(session.getVersion()), signPhotos(session), domesticLoadingPhotos(session),
|
value(session.getVersion()), signPhotos(session), domesticLoadingPhotos(session),
|
||||||
session.getPreparedAt(), session.getSignedAt(),
|
session.getPreparedAt(), session.getSignedAt(),
|
||||||
session.getReceivingAt(), session.getCompletedAt(), total, received, Math.max(0, total - received),
|
session.getReceivingAt(), session.getCompletedAt(), total, received, Math.max(0, total - received),
|
||||||
hasFbaOverReceipt(session, containerPlans), inbounds);
|
hasFbaOverReceipt(session, containerPlans), inbounds, cabinetNo);
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<ReceivingContainerResponses.DomesticLoadingPhoto> domesticLoadingPhotos(
|
private List<ReceivingContainerResponses.DomesticLoadingPhoto> domesticLoadingPhotos(
|
||||||
|
|||||||
+2
-1
@@ -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}")
|
||||||
|
|||||||
+2
-1
@@ -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) { }
|
||||||
}
|
}
|
||||||
|
|||||||
+36
@@ -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'")
|
||||||
|
|||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
package com.baiyue.lastleg.looseboard.port;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** 散板原单的排车关联只读入口,不向客户暴露整车货物。 */
|
||||||
|
public interface LooseboardDispatches {
|
||||||
|
/** 按真实客户和散板原单返回去重后的有效排车单号。 */
|
||||||
|
List<String> looseboardDispatches(String customerId, String orderNo);
|
||||||
|
}
|
||||||
+19
-2
@@ -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(); }
|
||||||
|
|||||||
+93
-23
@@ -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 (!orderBudget.readOrder(customerId, "looseboard", orderNo).usable()) throw conflict("散板订单资金尚未冻结成功,暂不能审核");
|
||||||
|
if (order.getOperatingWarehouseId()==null || order.getDeliveryMode()==null) throw conflict("散板订单缺少收货安排,请先编辑补齐");
|
||||||
|
var created = receiving.approve(order, operatorId);
|
||||||
|
order.setTransferNo(created.transferNo()); order.setLastlegOrderId(created.lastlegOrderId()); order.setStatus("WAITING_INBOUND");
|
||||||
}
|
}
|
||||||
if (!"APPROVE".equals(decision)) throw badRequest("审核结果无效");
|
|
||||||
if (order.getOperatingWarehouseId()==null || order.getDeliveryMode()==null) throw conflict("旧散板订单缺少收货安排,请先编辑补齐");
|
|
||||||
var created = receiving.approve(order, operatorId);
|
|
||||||
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<>'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<>'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) { }
|
||||||
|
|||||||
+19
@@ -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); }
|
||||||
|
|||||||
+10
-1
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+19
@@ -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);
|
||||||
|
|||||||
+4
-4
@@ -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,
|
||||||
|
|||||||
+10
-2
@@ -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:
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ paths:
|
|||||||
parameters: [{$ref: '#/components/parameters/Id'}]
|
parameters: [{$ref: '#/components/parameters/Id'}]
|
||||||
post:
|
post:
|
||||||
summary: 上传并绑定驾驶证,重置为待审
|
summary: 上传并绑定驾驶证,重置为待审
|
||||||
description: '权限:platform:overseas-master-data:driver:upload;复合归属固定平台0、FLEET_DRIVER_LICENSE、当前司机;非空PDF/JPG/PNG不超过10MiB,校验MIME和签名,旧版本附件保留在历史中。'
|
description: '权限:platform:overseas-master-data:driver:upload;复合归属固定平台0、FLEET_DRIVER_LICENSE、当前司机;上传仅接受非空JPG/PNG且不超过10MiB,校验MIME和签名,旧版本附件按历史引用读取。'
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
|
|||||||
@@ -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: 版本或状态冲突 } }
|
||||||
@@ -4024,6 +4024,7 @@ components:
|
|||||||
required: [sourceContainerBookingId, containerNo, overseasWarehouseCode, overseasWarehouseName, status, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, destinationCodes, plannedPalletCount, totalPackages, receivedPackages, operable, allPlansReady, currentAccount]
|
required: [sourceContainerBookingId, containerNo, overseasWarehouseCode, overseasWarehouseName, status, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, destinationCodes, plannedPalletCount, totalPackages, receivedPackages, operable, allPlansReady, currentAccount]
|
||||||
properties:
|
properties:
|
||||||
sourceContainerBookingId: { type: string, description: 头程与尾程共享的稳定柜身份 }
|
sourceContainerBookingId: { type: string, description: 头程与尾程共享的稳定柜身份 }
|
||||||
|
cabinetNo: { type: string, nullable: true, description: 头程装柜事件快照中的正式柜次号,缺失时为空 }
|
||||||
containerNo: { type: string }
|
containerNo: { type: string }
|
||||||
overseasWarehouseCode: { type: string }
|
overseasWarehouseCode: { type: string }
|
||||||
overseasWarehouseName: { type: string }
|
overseasWarehouseName: { type: string }
|
||||||
@@ -4047,6 +4048,7 @@ components:
|
|||||||
type: object
|
type: object
|
||||||
required: [containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, status, version, photos, domesticLoadingPhotos, totalPackages, receivedPackages, pendingPackages, hasDifference, inbounds]
|
required: [containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, status, version, photos, domesticLoadingPhotos, totalPackages, receivedPackages, pendingPackages, hasDifference, inbounds]
|
||||||
properties:
|
properties:
|
||||||
|
cabinetNo: { type: string, nullable: true, description: 当前柜次对应的头程装柜事件快照中的正式柜次号,缺失时为空 }
|
||||||
containerNo: { type: string }
|
containerNo: { type: string }
|
||||||
overseasWarehouseId: { type: string, pattern: '^\d+$', description: 平台预分配并与PDA Token匹配的海外接柜仓ID }
|
overseasWarehouseId: { type: string, pattern: '^\d+$', description: 平台预分配并与PDA Token匹配的海外接柜仓ID }
|
||||||
overseasWarehouseCode: { type: string, description: 海外接柜仓业务编码快照 }
|
overseasWarehouseCode: { type: string, description: 海外接柜仓业务编码快照 }
|
||||||
@@ -4897,15 +4899,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 +4919,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 +5298,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 +5313,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 +5332,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 +6782,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 +6820,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 +6838,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 +8522,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]
|
||||||
|
|||||||
+4
-4
@@ -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();
|
||||||
|
|||||||
+17
@@ -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");
|
||||||
|
|||||||
+53
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
@@ -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);
|
||||||
|
|||||||
+67
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
+53
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -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);
|
||||||
|
|||||||
+72
-2
@@ -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));
|
||||||
|
|||||||
+3
-2
@@ -111,10 +111,11 @@ class DriverServiceTest {
|
|||||||
assertThat(service.update(71, "e", new DriverModels.Update(3L, changedExpiry)).reviewStatus()).isEqualTo("PENDING");
|
assertThat(service.update(71, "e", new DriverModels.Update(3L, changedExpiry)).reviewStatus()).isEqualTo("PENDING");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 绑定新图片后,司机证照恢复待审并保留旧附件的历史关联。 */
|
||||||
@Test void replacingLicenseBindsNewFileAndResetsApproval() {
|
@Test void replacingLicenseBindsNewFileAndResetsApproval() {
|
||||||
when(drivers.lockById(71)).thenReturn(row("APPROVED"));
|
when(drivers.lockById(71)).thenReturn(row("APPROVED"));
|
||||||
var file = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-test".getBytes());
|
var file = new MockMultipartFile("file", "license.png", "image/png", "image".getBytes());
|
||||||
when(files.upload(71, "e", file)).thenReturn(new DriverModels.File("82", "license.pdf", "application/pdf", 9));
|
when(files.upload(71, "e", file)).thenReturn(new DriverModels.File("82", "license.png", "image/png", 5));
|
||||||
var response = service.uploadLicense(71, 2, "e", file);
|
var response = service.uploadLicense(71, 2, "e", file);
|
||||||
assertThat(response.licenseFileId()).isEqualTo("82");
|
assertThat(response.licenseFileId()).isEqualTo("82");
|
||||||
assertThat(response.reviewStatus()).isEqualTo("PENDING");
|
assertThat(response.reviewStatus()).isEqualTo("PENDING");
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user