Compare commits

..
Author SHA1 Message Date
ouyangxq f4901d155c fix: unify receiving discrepancy confirmation 2026-11-06 20:13:41 +08:00
ouyangxq 87bb905283 fix: unify receiving discrepancy confirmation 2026-11-06 20:08:45 +08:00
admin 1611c42d28 fix(lastleg): 合并客户散板生产查询修复
完整客户标识按字节关联,定向测试与生产只读 SQL 验证通过。
2026-11-06 17:25:31 +08:00
ouyangxq e86bdcd623 fix(lastleg): match looseboard pickup customer identity across collations 2026-11-06 17:24:53 +08:00
admin 3cc748974f release: 合并最新 TEST 并发布阿里云灰度
按本轮授权合并经过验证的发布候选,保留 main 已有修复。
2026-11-06 15:10:51 +08:00
ouyangxq 6d8a69b995 test(integration): align provider dispatch with canonical container contract 2026-11-06 15:10:37 +08:00
admin 99f896848e release: 合并最新 TEST 并发布阿里云灰度
按本轮授权合并经过验证的发布候选,保留 main 已有修复。
2026-11-06 14:59:38 +08:00
ouyangxq 08b28c72e7 test(lastleg): align trip reporting fixture with dispatch history 2026-11-06 14:59:23 +08:00
admin f571e107e4 release: 合并最新 TEST 并发布阿里云灰度
按本轮授权合并经过验证的发布候选,保留 main 已有修复。
2026-11-06 14:16:54 +08:00
ouyangxq 51ea89e399 test(firstleg): align release contracts with current evidence and fee states 2026-11-06 14:16:33 +08:00
admin d37522081b release: 合并最新 TEST 并发布阿里云灰度
按本轮授权合并经过验证的发布候选,保留 main 已有修复。
2026-11-06 14:07:01 +08:00
ouyangxq cc9bcbed95 release: include latest TEST revision memory guard 2026-11-06 14:05:09 +08:00
ouyangxq 7cd764e14a release: merge all current TEST changes into main 2026-11-06 14:04:24 +08:00
ouyangxq f4362f6dfd fix(ci): reject TEST revisions without memory protection 2026-11-06 13:59:34 +08:00
liuyihe 9c8273d9a0 Merge remote-tracking branch 'origin/test' into test 2026-11-06 13:34:06 +08:00
liuyihe e995a87b0e 提交 2026-11-06 13:33:37 +08:00
ouyangxq 51c3132195 fix(deploy): enforce TEST service memory budgets 2026-11-06 13:31:25 +08:00
admin c6bbb22a02 Merge pull request 'release: 晋级阿里云 TEST 后端并修复出车统计 UTC 合并回归' (#40) from release/aliyun-gray-20261106 into main 2026-11-06 12:08:48 +08:00
ouyangxq d722bcae49 release: merge gray history and preserve UTC trip reporting 2026-11-06 11:53:51 +08:00
liuyihe 9e99658bfa 提交 2026-11-06 10:41:49 +08:00
liuyihe 44202a7bff 提交 2026-11-05 17:46:30 +08:00
liuyihe ef2ac1eafd Merge remote-tracking branch 'origin/test' into test 2026-11-05 16:52:53 +08:00
liuyihe 08ea663334 提交 2026-11-05 16:52:35 +08:00
ouyangxq 8809801070 Merge remote-tracking branch 'origin/test' into test 2026-11-05 16:30:27 +08:00
ouyangxq 497544e9fd fix: restore customer visit uploads and guard fee publication 2026-11-05 16:30:27 +08:00
liuyihe 83c3b02d96 Merge remote-tracking branch 'origin/test' into test 2026-11-05 16:04:51 +08:00
liuyihe d1ade7e9dd 提交 2026-11-05 16:04:10 +08:00
ouyangxq 97c80574df Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-11-05 14:53:54 +08:00
ouyangxq b063d7866c 优化 2026-11-05 14:53:52 +08:00
liuyihe 5a8918e2e4 提交 2026-11-05 11:55:23 +08:00
liuyihe 94a36b8c4c 提交 2026-11-04 19:26:27 +08:00
ouyangxq 51291b4c27 优化 2026-11-04 18:08:19 +08:00
liuyihe 0993dd446e 提交 2026-11-04 15:55:55 +08:00
ouyangxq fbdf58c23c 补齐海外仓和私人地址正常及短少收货真实验收 2026-11-04 14:23:41 +08:00
ouyangxq 40475388e1 支持私人地址按实际收货生成库存凭证 2026-11-04 13:55:24 +08:00
ouyangxq 22e8939f1f 修复私人地址逐箱收货的目的地匹配 2026-11-04 13:45:59 +08:00
ouyangxq a01598bd21 允许私人地址按独立托盘生成海外入库计划 2026-11-04 13:35:23 +08:00
ouyangxq 0d8e64c42c 支持私人地址装柜后按普通海外收货交接 2026-11-04 12:45:05 +08:00
ouyangxq 8b56af2cae 统一人工报价时间精度以保证原键精确重放 2026-11-04 12:28:47 +08:00
liuyihe 12617e35a0 Merge remote-tracking branch 'origin/test' into test 2026-11-04 12:22:26 +08:00
liuyihe 46bc137e99 提交 2026-11-04 12:21:59 +08:00
admin 00120df2eb Merge pull request 'fix(lastleg): 修复取货车次导致出车记录 S0500' (#39) from hotfix-trip-operations into main 2026-10-26 18:00:51 +08:00
ouyangxq 9af1eba876 fix(lastleg): scope departure reporting to delivery trips 2026-10-26 17:59:59 +08:00
222 changed files with 9694 additions and 1825 deletions
+8
View File
@@ -125,6 +125,14 @@ pipeline {
git cat-file -e "${RESOLVED_SOURCE_COMMIT}^{commit}"
git checkout --detach "${RESOLVED_SOURCE_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
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
git merge-base --is-ancestor "$RESOLVED_SOURCE_COMMIT" origin/main || {
+3
View File
@@ -10,6 +10,9 @@ baiyue.file.storage-root: ${FILE_STORAGE_ROOT}
baiyue.clients.integration.url: ${INTEGRATION_SERVICE_URL:}
baiyue:
file:
pda:
# 在目标 Nacos 命名空间填写公网 HTTPS 根地址,不含 /api;真实环境地址不提交仓库。
public-base-url: ""
export:
definitions:
CUSTOMER_LIST:
+65
View File
@@ -0,0 +1,65 @@
# 仅由 TEST 部署入口叠加,固定 JVM 堆并为线程、元空间和直接内存保留余量。
x-test-limits: &test-limits
mem_limit: 1536m
memswap_limit: 1536m
environment: &test-java-environment
JAVA_OPTS: "-XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8"
JAVA_TOOL_OPTIONS: "-Xms128m -Xmx768m"
services:
gateway:
<<: *test-limits
auth:
<<: *test-limits
iam:
<<: *test-limits
order:
<<: *test-limits
firstleg:
<<: *test-limits
mem_limit: 2g
memswap_limit: 2g
environment:
<<: *test-java-environment
JAVA_TOOL_OPTIONS: "-Xms256m -Xmx1g"
finance:
<<: *test-limits
warehouse:
<<: *test-limits
warehouse-overseas-service:
<<: *test-limits
integration-service:
<<: *test-limits
audit-service:
<<: *test-limits
file:
<<: *test-limits
mem_limit: 3g
memswap_limit: 3g
environment:
<<: *test-java-environment
JAVA_TOOL_OPTIONS: "-Xms1g -Xmx2g"
print:
<<: *test-limits
environment:
<<: *test-java-environment
JAVA_TOOL_OPTIONS: "-Xms512m -Xmx1g"
lastleg-service:
<<: *test-limits
mem_limit: 3g
memswap_limit: 3g
environment:
<<: *test-java-environment
JAVA_TOOL_OPTIONS: "-Xms512m -Xmx1536m"
+8
View File
@@ -12,6 +12,7 @@ IMAGE_TAG="$3"
SERVICE="$4"
ENV_FILE="$5"
COMPOSE_FILE="deploy/docker-compose.yml"
TEST_LIMITS_FILE="deploy/docker-compose.test-limits.yml"
IMAGE="$IMAGE_REPOSITORY/$SERVICE:$IMAGE_TAG"
REGISTRY_HOST="${IMAGE_REPOSITORY%%/*}"
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
echo "TEST deploy must run on the target host" >&2; exit 2
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; }
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
@@ -74,7 +78,11 @@ local_registry_logout() {
if [ -n "${deploy_docker_config:-}" ]; then rm -rf -- "$deploy_docker_config"; fi
}
# TEST 的查询、部署和回滚共用同一份内存限额,缺失时由入口提前拒绝。
local_compose() {
if [ "$DEPLOY_ENV" = "test" ]; then
set -- -f "$TEST_LIMITS_FILE" "$@"
fi
docker compose \
--project-name "$PROJECT_NAME" \
--env-file "$ENV_FILE" \
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -1,5 +1,9 @@
# 单测试环境数据库空库初始化
`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`。
退货仓租改为原托整托计费、余一箱仍计一托,现有阶梯不变。已有 Lastleg 库先执行 [整托计费结构增量](return-storage-rent-whole-pallet.sql) 增加 `ll_storage_rent_lot.charged_days`,再发布匹配的 Lastleg;源基线和海外空库基线已同步。实际出库事务推进已生成费用的截止库龄日,同托同计租日不重复收费。切换期间暂停相关出库、取消未消费预占,并逐单核对旧比例费用;脚本不修改既有费用、库存或资金。本次仅准备代码及脚本,未执行环境 SQL 或部署。
散板收货闭环:`migrations/overseas/baiyue_lastleg/V37__looseboard_receiving_workflow.sql` 为已有 Lastleg 库增加入仓方式、收货仓、联系人、预计到仓、物流号及转运关联字段;新库使用同步后的海外统一基线。IAM 使用 `migrations/cn/baiyue_iam/V7__looseboard_demand_cargo_permission.sql` 补齐“散板需求”菜单及“加入散板货物”按钮,并为既有平台管理员补取货排车创建权限;权限源同步于 `iam/src/main/resources/db/fleet-capacity-offer-cargo-permission.sql` 和国内统一基线。两项已在本机应用并通过两场景HTTP验收,未据此认定TEST或生产已部署;已有库不可重放空库基线,V37执行前须核对九个新增字段尚不存在。
@@ -52,7 +56,7 @@
本土散板简化下单的当前结构同步于 Lastleg 源基线及海外统一空库基线:移除散板柜型与旧要求日期,增加商品名称、申报预计托数及当地取货/送达时间,移除运输方式,增加起运地及目的地各自的联系人和联系电话;四项联系信息、取货及送货时间均必填;打盘与运输安排允许未知。预计托数不代表实收。字段语义唯一见工作区 `docs/海外仓业务/README.md`,不回填测试存量,不提供自动重置或启动时 SQL;需与匹配 Lastleg、Admin 和 Client 一起发布。本说明不表示 SQL 已执行。
`migrations/overseas/baiyue_lastleg/V23__align_return_movement_collation.sql` 将退货进销存 UNION 涉及的八张表、22 个文本字段及表默认规则显式统一为 `utf8mb4_unicode_ci`,同步 Lastleg 两份源基线与海外统一空库基线。执行前核对字段长度、默认值、可空性及目标规则下唯一索引无冲突;只修改规则,不回填或删除数据,不重放空库基线。DDL 非事务,失败时停止并复查实际已完成项;可按最终定义重跑。脚本存在不表示测试库已执行,执行后用原始计数/分页查询验证,无需发布查询转换代码。
`migrations/overseas/baiyue_lastleg/V23__align_return_movement_collation.sql` 将退货进销存 UNION 涉及的八张表、22 个文本字段及表默认规则显式统一为 `utf8mb4_0900_ai_ci`,同步 Lastleg 两份源基线与海外统一空库基线。执行前核对字段长度、默认值、可空性及目标规则下唯一索引无冲突;只修改规则,不回填或删除数据,不重放空库基线。DDL 非事务,失败时停止并复查实际已完成项;可按最终定义重跑。脚本存在不表示测试库已执行,执行后用原始计数/分页查询验证,无需发布查询转换代码。
`migrations/overseas/baiyue_lastleg/V22__selfop_warehouse_daily_sales.sql` 新增自营仓库每日汇总表及销量版本 `warehouse_daily_ready` 标记。与匹配 Lastleg 一起发布;汇总随 SKU 日事实批次原子构建,完整性验证后方可发布。已有未构建汇总的销量版本须重新构建发布,不回填旧版本、不回退旧查询;空版本同样必须有就绪证据。源基线与海外空库基线同步。
@@ -72,11 +76,11 @@
`reset-test-fba-appointments.sql` 仅用于明确授权的 TEST 预约模型重建:删除并按当前基线重建 `ll_inbound_fba_appointment`、`ll_inbound_fba_appointment_item`,并首次创建 `ll_fba_appointment_command` 保存同事务幂等结果与历史。执行任何DROP前必须确认命令表尚不存在;若已存在,先核对上次执行结果,本脚本不清空命令历史、不支持盲目重试。它会丢弃旧预约及关联明细,但不修改订单、入仓计划、库存和排车。调用者必须核对测试连接及两张表的具体重置授权;不得作为自动启动脚本或用于生产。主单区分计划日期/时段、系统单号、外部确认号和确认UTC/IANA时区;明细按稳定货源占用,不再依赖入仓计划明细。结构必须与匹配Lastleg和Admin一同发布,失败立即停止,不回填旧数据。权限从IAM种子核对;文件存在不表示已执行。
`migrations/overseas/baiyue_lastleg/V13__widen_inbound_plan_creation_mode.sql` 仅将 `ll_inbound_plan.creation_mode` 从旧统一基线的 `VARCHAR(16)` 扩至模块基线已有的 `VARCHAR(32)`,容纳实际来货审核写入的 `WAREHOUSE_TRANSFER`(18字符)。保留 `utf8mb4_unicode_ci`、非空及 `AUTO` 默认值;不更新已有计划或状态。执行前核对目标库该列的长度、排序规则、可空性和默认值,已符合最终定义则核对后跳过;执行结果不确定时先只读复查,不盲目重试。统一空库基线同步最终定义,不重放模块历史初始化脚本。
`migrations/overseas/baiyue_lastleg/V13__widen_inbound_plan_creation_mode.sql` 仅将 `ll_inbound_plan.creation_mode` 从旧统一基线的 `VARCHAR(16)` 扩至模块基线已有的 `VARCHAR(32)`,容纳实际来货审核写入的 `WAREHOUSE_TRANSFER`(18字符)。保留 `utf8mb4_0900_ai_ci`、非空及 `AUTO` 默认值;不更新已有计划或状态。执行前核对目标库该列的长度、排序规则、可空性和默认值,已符合最终定义则核对后跳过;执行结果不确定时先只读复查,不盲目重试。统一空库基线同步最终定义,不重放模块历史初始化脚本。
`migrations/overseas/baiyue_lastleg/V12__preserve_unknown_pool_order_fields.sql` 仅放开散板打盘需求及两类本土订单柜型三列为空、默认空,供未提供这些事实的拼车商业订单使用;普通订单输入校验仍由原服务端请求合同执行。对应源及统一空库基线同步最终定义,不回填已生成订单或把托盘数量解释成打盘服务需求。
内部拼车需求池的当前结构在 Lastleg 源基线和海外统一空库基线同步维护,独立五项权限在 IAM 源种子和国内统一基线维护。已存在基础表的指定环境结构补齐使用 `migrations/overseas/baiyue_lastleg/V10__fleet_capacity_pool.sql` 与 `migrations/cn/baiyue_iam/V4__fleet_capacity_offer_permissions.sql`;执行前确认三张新表、六个新增字段均未存在,并单独核对目标环境。随后 `V11__align_capacity_pool_collation.sql` 将预订与商业桥的客户关联字段显式统一为 `utf8mb4_unicode_ci`,与原订单域一致,避免依赖MySQL实例默认排序规则导致JOIN失败。它们不回填、删除或重置业务数据,不能重放整个空库基线。候选文件存在不表示已经执行。
内部拼车需求池的当前结构在 Lastleg 源基线和海外统一空库基线同步维护,独立五项权限在 IAM 源种子和国内统一基线维护。已存在基础表的指定环境结构补齐使用 `migrations/overseas/baiyue_lastleg/V10__fleet_capacity_pool.sql` 与 `migrations/cn/baiyue_iam/V4__fleet_capacity_offer_permissions.sql`;执行前确认三张新表、六个新增字段均未存在,并单独核对目标环境。随后 `V11__align_capacity_pool_collation.sql` 将预订与商业桥的客户关联字段显式统一为 `utf8mb4_0900_ai_ci`,与原订单域一致,避免依赖MySQL实例默认排序规则导致JOIN失败。它们不回填、删除或重置业务数据,不能重放整个空库基线。候选文件存在不表示已经执行。
平台自营领星统计十张 `ll_selfop_*` 表直接同步 Lastleg 源基线及海外统一空库基线;时间使用 UTC 存储及已确认北京时间业务日,数量使用 DECIMAL 保留 PCS 精度,维持独立发布版本。包含账号共享预算/冷却、验证根用途与硬预算、TEST BUSINESS 根会话持久预算、稳定任务号、阶段进度/心跳和旧版替代时间;任务的 `business_root_task_id` 标记共享根,只有根保存 `business_budget_json`,生产任务不使用该测试预算。库存 run 包含每日代表日期、选择规则及账号/仓库/日期唯一约束,代表库存与 profile 不适用销量副本淘汰。IAM 权限源为 `iam/src/main/resources/db/selfoperated-analytics-permissions.sql`,自营业务下经营总览、销量分析、库存分析、数据采集的菜单资源与独立读写权限同步国内统一基线,仅平台管理员默认授权。执行和部署状态只维护于工作区测试环境主契约;真实采集模式默认 OFF。
@@ -4,4 +4,4 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_daily_sequence (
business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)',
last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999',
PRIMARY KEY(business_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='排车单号每日独立原子流水';
@@ -69,7 +69,7 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_daily_sequence (
business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)',
last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999',
PRIMARY KEY (business_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='排车单号每日独立原子流水';
CREATE TABLE IF NOT EXISTS ll_dispatch_truck_task (
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
@@ -81,13 +81,13 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_truck_task (
UNIQUE KEY uk_transport_trip (trip_no),
UNIQUE KEY uk_transport_source (active_order),
KEY idx_transport_order (customer_id, order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车与本土卡车原单关联';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='排车与本土卡车原单关联';
CREATE TABLE IF NOT EXISTS ll_dispatch_transport_create (
trip_no VARCHAR(64) NOT NULL COMMENT '本次运输资源记录号',
request_hash CHAR(64) NOT NULL COMMENT '首次安排请求摘要',
PRIMARY KEY (trip_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土卡车排车创建请求账本';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='本土卡车排车创建请求账本';
CREATE TABLE IF NOT EXISTS ll_dispatch_transport_command (
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
@@ -96,7 +96,7 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_transport_command (
evidence_json JSON NULL COMMENT '已确认运输完成凭证标识列表',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间',
PRIMARY KEY (dispatch_no, request_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车运输执行命令与凭证账本';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='排车运输执行命令与凭证账本';
-- 当前承运占用查询还依赖散板来源标识(与V33一致);仅补结构,不创建散板业务记录。
SET @dispatch_transport_pool_ddl = IF(
@@ -27,7 +27,7 @@ CREATE TABLE IF NOT EXISTS fl_sailing_channel (
PRIMARY KEY (id),
UNIQUE KEY uk_fl_sailing_channel_code (channel_code),
KEY idx_fl_sailing_channel_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS fl_sailing_template (
id BIGINT NOT NULL,
@@ -63,7 +63,7 @@ CREATE TABLE IF NOT EXISTS fl_sailing_template (
KEY idx_fl_sailing_template_filter (warehouse_id,channel_id,status,weekday),
CONSTRAINT ck_fl_sailing_template_weekday CHECK (weekday BETWEEN 1 AND 7),
CONSTRAINT ck_fl_sailing_template_cutoff_days CHECK (cutoff_days BETWEEN 0 AND 30)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS fl_sailing (
id BIGINT NOT NULL,
@@ -100,7 +100,7 @@ CREATE TABLE IF NOT EXISTS fl_sailing (
KEY idx_fl_sailing_calendar (etd_at,warehouse_id,channel_id,status),
KEY idx_fl_sailing_route (departure_port_code,destination_port_code),
CONSTRAINT ck_fl_sailing_status CHECK (status IN ('DRAFT','OPEN','CLOSED','DEPARTED','CANCELLED','COMPLETED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS fl_sailing_history (
id BIGINT NOT NULL,
@@ -114,7 +114,7 @@ CREATE TABLE IF NOT EXISTS fl_sailing_history (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_fl_sailing_history_sailing (sailing_id,created_at,id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*
* 船期日历的事实契约。
@@ -2,6 +2,6 @@
-- Schema: baiyue_lastleg
-- Purpose: 拼车履约客户关联字段与现有订单域排序规则一致
-- Preconditions: 外接预订和商业履约桥已存在,客户字段为VARCHAR(128)且非空
-- Postconditions: 两个客户关联字段使用utf8mb4_unicode_ci;不修改客户标识或业务状态
ALTER TABLE ll_fleet_external_booking MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '委托客户及付款归属';
ALTER TABLE ll_fleet_pool_order_link MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '商业客户隔离范围';
-- Postconditions: 两个客户关联字段使用utf8mb4_0900_ai_ci;不修改客户标识或业务状态
ALTER TABLE ll_fleet_external_booking MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '委托客户及付款归属';
ALTER TABLE ll_fleet_pool_order_link MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '商业客户隔离范围';
@@ -1,8 +1,8 @@
-- Region: OVERSEAS
-- Schema: baiyue_lastleg
-- Purpose: 仓储转运来货审核生成入仓计划时允许完整WAREHOUSE_TRANSFER来源值
-- Preconditions: ll_inbound_plan.creation_mode现为VARCHAR(16),utf8mb4_unicode_ci、NOT NULL、DEFAULT AUTO
-- Preconditions: ll_inbound_plan.creation_mode现为VARCHAR(16),utf8mb4_0900_ai_ci、NOT NULL、DEFAULT AUTO
-- Postconditions: 仅将本列扩至VARCHAR(32);保留排序规则、非空、默认值和已有业务数据
ALTER TABLE ll_inbound_plan
MODIFY COLUMN creation_mode VARCHAR(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
MODIFY COLUMN creation_mode VARCHAR(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci
NOT NULL DEFAULT 'AUTO' COMMENT '计划生成方式:AUTO柜级自动、MANUAL单票人工、WAREHOUSE_TRANSFER仓储转运';
@@ -45,4 +45,4 @@ CREATE TABLE ll_procurement_wallet_attempt (
UNIQUE KEY uk_procurement_attempt_number(fee_id,attempt_no),
KEY idx_procurement_attempt_order(customer_id,order_id,status),
CONSTRAINT chk_procurement_attempt_status CHECK(status IN('PENDING','SUCCEEDED','FAILED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用资金命令尝试与不可变结果';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购费用资金命令尝试与不可变结果';
@@ -31,7 +31,7 @@ CREATE TABLE ll_procurement_flow (
PRIMARY KEY(order_id), KEY idx_procurement_flow_customer(customer_id,order_id),
CONSTRAINT chk_procurement_purchase_quantity CHECK(purchased_quantity IS NULL OR purchased_quantity>0),
CONSTRAINT chk_procurement_purchase_amount CHECK(purchase_amount IS NULL OR purchase_amount>=0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单一批次履约事实';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购单一批次履约事实';
CREATE TABLE ll_procurement_command (
id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID',
@@ -42,7 +42,7 @@ CREATE TABLE ll_procurement_command (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '执行时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_command(order_id,action,idempotency_key),
KEY idx_procurement_command_customer(customer_id,order_id,created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购节点幂等与不可变审计';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购节点幂等与不可变审计';
CREATE TABLE ll_procurement_budget_authorization (
id BIGINT NOT NULL COMMENT '预算授权ID', order_id BIGINT NOT NULL COMMENT '采购订单ID',
@@ -54,4 +54,4 @@ CREATE TABLE ll_procurement_budget_authorization (
PRIMARY KEY(id), KEY idx_procurement_budget_order(customer_id,order_id,id),
CONSTRAINT chk_procurement_budget_amount CHECK(authorized_amount>=0 AND previous_amount>=0),
CONSTRAINT chk_procurement_budget_currency CHECK(currency IN('CNY','USD') AND previous_currency IN('CNY','USD'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购货款预算授权核验历史';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购货款预算授权核验历史';
@@ -10,7 +10,7 @@ CREATE TABLE ll_procurement_receipt (
PRIMARY KEY(id), UNIQUE KEY uk_procurement_receipt_no(receipt_no), KEY idx_procurement_receipt_source(order_id,leg,source_carton_id,active),
CONSTRAINT chk_procurement_receipt_quantity CHECK(good_quantity>=0 AND damaged_quantity>=0 AND extra_quantity>=0 AND good_quantity+damaged_quantity+extra_quantity>0),
CONSTRAINT chk_procurement_receipt_leg CHECK((leg='OVERSEAS' AND source_carton_id IS NULL) OR (leg='DOMESTIC' AND source_carton_id IS NOT NULL))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购分次实收与受控更正事实';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购分次实收与受控更正事实';
CREATE TABLE ll_procurement_disposition (
id BIGINT NOT NULL COMMENT '差异处理ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
@@ -22,7 +22,7 @@ CREATE TABLE ll_procurement_disposition (
created_by VARCHAR(128) NOT NULL COMMENT '采购管理人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间',
PRIMARY KEY(id), KEY idx_procurement_disposition_source(order_id,leg,source_carton_id,receipt_id,kind),
CONSTRAINT chk_procurement_disposition_quantity CHECK(quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单内差异处置,不自动退款';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购单内差异处置,不自动退款';
CREATE TABLE ll_procurement_manifest (
id BIGINT NOT NULL COMMENT '装箱清单ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
@@ -31,7 +31,7 @@ CREATE TABLE ll_procurement_manifest (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '版本保存时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_manifest_version(order_id,version),
CONSTRAINT chk_procurement_manifest_status CHECK(status IN('DRAFT','CONFIRMED','SUPERSEDED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购装箱清单不可变版本';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购装箱清单不可变版本';
CREATE TABLE ll_procurement_carton (
id BIGINT NOT NULL COMMENT '版本内箱记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', manifest_id BIGINT NOT NULL COMMENT '装箱清单版本',
@@ -40,12 +40,12 @@ CREATE TABLE ll_procurement_carton (
gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '封箱毛重千克', quantity BIGINT NOT NULL COMMENT '本箱合格商品总件数',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_carton_version(manifest_id,carton_no), KEY idx_procurement_carton_order(order_id,manifest_id),
CONSTRAINT chk_procurement_carton_measurement CHECK(length_cm>0 AND width_cm>0 AND height_cm>0 AND gross_weight_kg>0 AND quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购版本化逐箱实测与件数';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购版本化逐箱实测与件数';
CREATE TABLE ll_procurement_carton_source (
carton_id BIGINT NOT NULL COMMENT '版本内箱ID', receipt_id BIGINT NOT NULL COMMENT '海外实收明细来源', quantity BIGINT NOT NULL COMMENT '从该实收装入本箱的件数',
PRIMARY KEY(carton_id,receipt_id), KEY idx_procurement_carton_receipt(receipt_id), CONSTRAINT chk_procurement_carton_source_quantity CHECK(quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购箱内货物的实收来源与数量';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购箱内货物的实收来源与数量';
CREATE TABLE ll_procurement_shipment (
id BIGINT NOT NULL COMMENT '采购交寄ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
@@ -55,11 +55,11 @@ CREATE TABLE ll_procurement_shipment (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_shipment_leg(order_id,leg), UNIQUE KEY uk_procurement_shipment_no(shipment_no),
CONSTRAINT chk_procurement_shipment_leg CHECK(leg IN('OVERSEAS','DOMESTIC'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段各一次整批真实交寄';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购两段各一次整批真实交寄';
CREATE TABLE ll_procurement_shipment_item (
shipment_id BIGINT NOT NULL COMMENT '采购交寄ID', carton_id BIGINT NOT NULL COMMENT '原海外装箱版本内箱ID', quantity BIGINT NOT NULL COMMENT '本次实际交寄件数',
carrier VARCHAR(128) NOT NULL COMMENT '承运商快照', tracking_no VARCHAR(128) NOT NULL COMMENT '运单号快照',
label_snapshot JSON NOT NULL COMMENT '当次箱规、面单及货物来源快照',
PRIMARY KEY(shipment_id,carton_id), CONSTRAINT chk_procurement_shipment_item_quantity CHECK(quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购整批交寄逐箱事实';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购整批交寄逐箱事实';
@@ -7,7 +7,7 @@ CREATE TABLE ll_procurement_label_set (
created_by VARCHAR(128) NOT NULL COMMENT '制单操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传确认时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_label_version(order_id,leg,version),
CONSTRAINT chk_procurement_label_leg CHECK(leg IN('OVERSEAS','DOMESTIC'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段面单不可变版本和地址快照';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购两段面单不可变版本和地址快照';
CREATE TABLE ll_procurement_label_item (
label_set_id BIGINT NOT NULL COMMENT '面单批版本ID', carton_id BIGINT NOT NULL COMMENT '原海外清单箱ID',
@@ -15,14 +15,14 @@ CREATE TABLE ll_procurement_label_item (
carrier VARCHAR(128) NOT NULL COMMENT '承运商', tracking_no VARCHAR(128) NOT NULL COMMENT '箱主或子运单号',
applied_at DATETIME(3) NULL COMMENT '真实贴单时间', applied_by VARCHAR(128) NULL COMMENT '贴单确认人',
PRIMARY KEY(label_set_id,carton_id), CONSTRAINT chk_procurement_label_page CHECK(page_no>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购逐箱文件页关联及贴单事实';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购逐箱文件页关联及贴单事实';
CREATE TABLE ll_procurement_estimate_review (
id BIGINT NOT NULL COMMENT '预估核对ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', label_set_id BIGINT NOT NULL COMMENT '已核对国际面单版本',
fee_id BIGINT NOT NULL COMMENT '唯一有效预估费用版本', reviewed_by VARCHAR(128) NOT NULL COMMENT '核价人员',
reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '核对时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_estimate_review(label_set_id,fee_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='面单清单与预估冻结的显式核对关联';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='面单清单与预估冻结的显式核对关联';
CREATE TABLE ll_procurement_repack (
id BIGINT NOT NULL COMMENT '换包装版本ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', carton_id BIGINT NOT NULL COMMENT '原国际交寄箱ID,保持一对一来源',
@@ -32,9 +32,9 @@ CREATE TABLE ll_procurement_repack (
created_by VARCHAR(128) NOT NULL COMMENT '国内作业人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间',
PRIMARY KEY(id), KEY idx_procurement_repack_carton(order_id,carton_id,id),
CONSTRAINT chk_procurement_repack_measurement CHECK(length_cm>0 AND width_cm>0 AND height_cm>0 AND gross_weight_kg>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购国内原箱一对一换包装不可变历史';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购国内原箱一对一换包装不可变历史';
ALTER TABLE ll_overseas_procurement_file
DROP CHECK chk_ll_procurement_file_category,
MODIFY COLUMN category VARCHAR(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'RECEIPT实收、VOUCHER费用、LABEL面单、PURCHASE采购、BUDGET预算、EXCEPTION异常凭证',
MODIFY COLUMN category VARCHAR(16) COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'RECEIPT实收、VOUCHER费用、LABEL面单、PURCHASE采购、BUDGET预算、EXCEPTION异常凭证',
ADD CONSTRAINT chk_ll_procurement_file_category CHECK(category IN ('RECEIPT','VOUCHER','LABEL','PURCHASE','BUDGET','EXCEPTION'));
@@ -12,9 +12,9 @@ CREATE TABLE ll_procurement_fee_correction (
PRIMARY KEY(id), UNIQUE KEY uk_procurement_correction_no(correction_no), UNIQUE KEY uk_procurement_correction_request(order_id,idempotency_key),
KEY idx_procurement_correction_root(order_id,root_fee_id,id),
CONSTRAINT chk_procurement_correction_amount CHECK(target_original_amount>=0 AND target_amount>=0 AND previous_net_amount>=0 AND exchange_rate>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用链更正目标与原汇率快照';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购费用链更正目标与原汇率快照';
CREATE TABLE ll_procurement_fee_correction_item (
correction_id BIGINT NOT NULL COMMENT '本次费用链更正ID', fee_id BIGINT NOT NULL COMMENT '本次产生的独立正差额或退款费用ID',
PRIMARY KEY(correction_id,fee_id), UNIQUE KEY uk_procurement_correction_fee(fee_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用目标更正与逐笔资金事实关联';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购费用目标更正与逐笔资金事实关联';
@@ -133,15 +133,15 @@ PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_measurement_photo (
id bigint NOT NULL COMMENT '测量照片引用ID',
transfer_order_id bigint NOT NULL COMMENT '仓储转运单ID',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
customer_id varchar(128) COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户租户ID',
file_id bigint NOT NULL COMMENT '文件服务文件ID',
file_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '文件名快照',
content_type varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '文件媒体类型快照',
file_name varchar(255) COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '文件名快照',
content_type varchar(128) COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '文件媒体类型快照',
size_bytes bigint NOT NULL DEFAULT 0 COMMENT '文件字节数快照',
uploaded_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'PDA上传操作人',
uploaded_by varchar(128) COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT 'PDA上传操作人',
uploaded_at datetime(3) NOT NULL COMMENT '上传时间',
submitted_at datetime(3) DEFAULT NULL COMMENT '随测量打托提交时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_transfer_measurement_photo_file (customer_id,transfer_order_id,file_id),
KEY idx_ll_transfer_measurement_photo_order (customer_id,transfer_order_id,uploaded_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓储转运PDA测量现场照片引用';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='仓储转运PDA测量现场照片引用';
@@ -10,7 +10,7 @@ CREATE TABLE ll_procurement_control_note (
resolved_by VARCHAR(128) NULL COMMENT '处理确认人', resolved_at DATETIME(3) NULL COMMENT '阻断解除时间',
resolution_reason VARCHAR(1000) NULL COMMENT '内部处理结果', resolution_voucher_id BIGINT NULL COMMENT '本单处理凭证',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_note_no(note_no), KEY idx_procurement_note_block(order_id,blocking,resolved_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购不可覆盖事实的更正说明及阻断异常';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购不可覆盖事实的更正说明及阻断异常';
CREATE TABLE ll_procurement_closure (
id BIGINT NOT NULL COMMENT '采购异常关闭ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
@@ -19,7 +19,7 @@ CREATE TABLE ll_procurement_closure (
reason VARCHAR(1000) NOT NULL COMMENT '内部关闭原因', client_summary VARCHAR(500) NOT NULL COMMENT '人工确认的对客关闭摘要',
created_by VARCHAR(128) NOT NULL COMMENT '采购管理确认人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '关闭确认时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_closure_order(order_id), UNIQUE KEY uk_procurement_closure_no(closure_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购实物资金收口后的异常关闭事实';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购实物资金收口后的异常关闭事实';
CREATE TABLE ll_procurement_closure_item (
id BIGINT NOT NULL COMMENT '关闭货物处置ID', closure_id BIGINT NOT NULL COMMENT '采购关闭ID',
@@ -28,4 +28,4 @@ CREATE TABLE ll_procurement_closure_item (
destination VARCHAR(500) NOT NULL COMMENT '实际去向及接收方说明', voucher_file_id BIGINT NOT NULL COMMENT '本单实物处置证明',
PRIMARY KEY(id), KEY idx_procurement_closure_source(closure_id,source_kind,source_id),
CONSTRAINT chk_procurement_closure_quantity CHECK(quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常关闭逐来源剩余货物最终去向,不产生新发运';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='异常关闭逐来源剩余货物最终去向,不产生新发运';
@@ -2,43 +2,43 @@
-- Schema: baiyue_lastleg
-- Purpose: 统一退货进销存 UNION 文本列排序规则,去除查询转换需求
-- Preconditions: 八张退货表已存在;核对字段长度、可空性、默认值及唯一索引无目标规则冲突
-- Postconditions: 指定文本字段及表默认规则统一为 utf8mb4_unicode_ci;不修改数量、状态或业务标识
-- Postconditions: 指定文本字段及表默认规则统一为 utf8mb4_0900_ai_ci;不修改数量、状态或业务标识
-- 不重放空库基线;本文件可重复执行。DDL 非事务,失败停止并核对后续执行范围。
ALTER TABLE ll_return_order
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键',
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作海外仓ID';
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户ID和隔离键',
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '操作海外仓ID';
ALTER TABLE ll_return_order_item
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码',
MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名';
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'SKU编码',
MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '中文品名';
ALTER TABLE ll_return_receipt
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
MODIFY COLUMN receipt_no VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收单号';
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
MODIFY COLUMN receipt_no VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '签收单号';
ALTER TABLE ll_return_receipt_item
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码';
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'SKU编码';
ALTER TABLE ll_return_inventory_carton
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键',
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外仓ID',
MODIFY COLUMN carton_mark VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '退货箱号和箱唛编码';
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户ID和隔离键',
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '海外仓ID',
MODIFY COLUMN carton_mark VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '退货箱号和箱唛编码';
ALTER TABLE ll_return_inventory_carton_item
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码',
MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名';
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'SKU编码',
MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '中文品名';
ALTER TABLE ll_return_inventory_outbound_movement
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '库存所属客户ID和隔离键',
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际出库海外仓ID',
MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次出库SKU编码快照',
MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名快照',
MODIFY COLUMN outbound_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次退货出库业务单号';
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '库存所属客户ID和隔离键',
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '实际出库海外仓ID',
MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '本次出库SKU编码快照',
MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '中文品名快照',
MODIFY COLUMN outbound_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '本次退货出库业务单号';
ALTER TABLE ll_return_sku_conversion
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键',
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作海外仓ID',
MODIFY COLUMN old_sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标前SKU',
MODIFY COLUMN new_sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标后SKU',
MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名快照',
MODIFY COLUMN relabel_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标单号';
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户隔离键',
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '操作海外仓ID',
MODIFY COLUMN old_sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '换标前SKU',
MODIFY COLUMN new_sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '换标后SKU',
MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '中文品名快照',
MODIFY COLUMN relabel_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '换标单号';
@@ -13,4 +13,4 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_photo (
PRIMARY KEY (id),
UNIQUE KEY uk_cargo_discard_photo_file (file_id),
KEY idx_cargo_discard_photo_application (customer_id, application_id, pallet_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
@@ -8,7 +8,7 @@ CREATE TABLE IF NOT EXISTS ll_compact_number_clock (
number_key VARCHAR(16) NOT NULL COMMENT '业务前缀和客户名称缩写',
last_second BIGINT NOT NULL COMMENT '最后分配的Unix逻辑秒',
PRIMARY KEY (number_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务短单号分配时钟';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='海外业务短单号分配时钟';
-- 仓储转运逐箱测量与清点;仅增加字段,不修改业务数据。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='length_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN length_cm DECIMAL(12,2) NULL COMMENT ''箱子实测长度厘米'''));
@@ -25,9 +25,9 @@ PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 测试环境当前缺失、生产已存在的 PDA 废弃作业字段。
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'actual_discard_packages'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN actual_discard_packages int NULL COMMENT ''PDA确认实际废弃箱数'' AFTER discard_packages', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'pda_status'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT ''PENDING'' COMMENT ''PDA处理状态:待处理PENDING、已完成COMPLETED'' AFTER actual_discard_packages', 'SELECT 1');
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'pda_status'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_status varchar(32) COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT ''PENDING'' COMMENT ''PDA处理状态:待处理PENDING、已完成COMPLETED'' AFTER actual_discard_packages', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'pda_operator_id'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_operator_id varchar(128) COLLATE utf8mb4_unicode_ci NULL COMMENT ''PDA操作人'' AFTER pda_status', 'SELECT 1');
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'pda_operator_id'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_operator_id varchar(128) COLLATE utf8mb4_0900_ai_ci NULL COMMENT ''PDA操作人'' AFTER pda_status', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'pda_completed_at'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_completed_at datetime(3) NULL COMMENT ''PDA完成时间'' AFTER pda_operator_id', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -35,36 +35,36 @@ PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 生产环境当前缺失、测试已存在的换标与退货字段。
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_pda_unit_fact' AND column_name = 'quantity'), 'ALTER TABLE ll_relabel_pda_unit_fact ADD COLUMN quantity int NOT NULL DEFAULT 1 COMMENT ''本次事实处理件数,箱级作业固定为1'' AFTER reason', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_inventory_carton' AND column_name = 'pallet_code'), 'ALTER TABLE ll_return_inventory_carton ADD COLUMN pallet_code varchar(128) COLLATE utf8mb4_unicode_ci NULL COMMENT ''签收时绑定的托盘码'' AFTER source_return_no', 'SELECT 1');
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_inventory_carton' AND column_name = 'pallet_code'), 'ALTER TABLE ll_return_inventory_carton ADD COLUMN pallet_code varchar(128) COLLATE utf8mb4_0900_ai_ci NULL COMMENT ''签收时绑定的托盘码'' AFTER source_return_no', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_order' AND column_name = 'discrepancy_reason'), 'ALTER TABLE ll_return_order ADD COLUMN discrepancy_reason varchar(500) COLLATE utf8mb4_unicode_ci NULL COMMENT ''手动完结差异原因'' AFTER status', 'SELECT 1');
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_order' AND column_name = 'discrepancy_reason'), 'ALTER TABLE ll_return_order ADD COLUMN discrepancy_reason varchar(500) COLLATE utf8mb4_0900_ai_ci NULL COMMENT ''手动完结差异原因'' AFTER status', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_order' AND column_name = 'manually_completed_at'), 'ALTER TABLE ll_return_order ADD COLUMN manually_completed_at datetime(3) NULL COMMENT ''手动差异完结时间'' AFTER discrepancy_reason', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_receipt' AND column_name = 'pallet_code'), 'ALTER TABLE ll_return_receipt ADD COLUMN pallet_code varchar(128) COLLATE utf8mb4_unicode_ci NULL COMMENT ''本次签收绑定的托盘码'' AFTER carton_mark', 'SELECT 1');
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_receipt' AND column_name = 'pallet_code'), 'ALTER TABLE ll_return_receipt ADD COLUMN pallet_code varchar(128) COLLATE utf8mb4_0900_ai_ci NULL COMMENT ''本次签收绑定的托盘码'' AFTER carton_mark', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_delivery_order' AND column_name = 'delivery_group_key' AND (column_type <> 'varchar(255)' OR is_nullable <> 'NO' OR collation_name <> 'utf8mb4_unicode_ci')), 'ALTER TABLE ll_delivery_order MODIFY COLUMN delivery_group_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT ''同一出库单内按目的FBA仓拆单的稳定分组键''', 'SELECT 1');
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_delivery_order' AND column_name = 'delivery_group_key' AND (column_type <> 'varchar(255)' OR is_nullable <> 'NO' OR collation_name <> 'utf8mb4_0900_ai_ci')), 'ALTER TABLE ll_delivery_order MODIFY COLUMN delivery_group_key varchar(255) COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT ''同一出库单内按目的FBA仓拆单的稳定分组键''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_local_looseboard_order' AND column_name = 'container_type' AND (column_type <> 'varchar(32)' OR is_nullable <> 'NO' OR column_default <> '40HQ' OR collation_name <> 'utf8mb4_unicode_ci')), 'ALTER TABLE ll_local_looseboard_order MODIFY COLUMN container_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT ''40HQ'' COMMENT ''平台统一柜型:40GP、40HQ、45HQ''', 'SELECT 1');
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_local_looseboard_order' AND column_name = 'container_type' AND (column_type <> 'varchar(32)' OR is_nullable <> 'NO' OR column_default <> '40HQ' OR collation_name <> 'utf8mb4_0900_ai_ci')), 'ALTER TABLE ll_local_looseboard_order MODIFY COLUMN container_type varchar(32) COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT ''40HQ'' COMMENT ''平台统一柜型:40GP、40HQ、45HQ''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_us_truck_order' AND column_name = 'container_type' AND (column_type <> 'varchar(32)' OR is_nullable <> 'NO' OR column_default <> '40HQ' OR collation_name <> 'utf8mb4_unicode_ci')), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN container_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT ''40HQ'' COMMENT ''平台统一柜型:40GP、40HQ、45HQ''', 'SELECT 1');
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_us_truck_order' AND column_name = 'container_type' AND (column_type <> 'varchar(32)' OR is_nullable <> 'NO' OR column_default <> '40HQ' OR collation_name <> 'utf8mb4_0900_ai_ci')), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN container_type varchar(32) COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT ''40HQ'' COMMENT ''平台统一柜型:40GP、40HQ、45HQ''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_outbound_pda_pick' AND column_name = 'inventory_position_id' AND (column_type <> 'bigint' OR is_nullable <> 'NO')), 'ALTER TABLE ll_outbound_pda_pick MODIFY COLUMN inventory_position_id bigint NOT NULL COMMENT ''库存仓位ID''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_item' AND column_name = 'new_sku_code' AND (column_type <> 'varchar(128)' OR is_nullable <> 'NO' OR collation_name <> 'utf8mb4_unicode_ci'))
OR EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_item' AND column_name = 'source_warehouse_id' AND (column_type <> 'varchar(128)' OR is_nullable <> 'NO' OR collation_name <> 'utf8mb4_unicode_ci')),
'ALTER TABLE ll_relabel_item MODIFY COLUMN new_sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT ''换标后SKU编码'', MODIFY COLUMN source_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT ''原海外中转仓ID''',
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_item' AND column_name = 'new_sku_code' AND (column_type <> 'varchar(128)' OR is_nullable <> 'NO' OR collation_name <> 'utf8mb4_0900_ai_ci'))
OR EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_item' AND column_name = 'source_warehouse_id' AND (column_type <> 'varchar(128)' OR is_nullable <> 'NO' OR collation_name <> 'utf8mb4_0900_ai_ci')),
'ALTER TABLE ll_relabel_item MODIFY COLUMN new_sku_code varchar(128) COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT ''换标后SKU编码'', MODIFY COLUMN source_warehouse_id varchar(128) COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT ''原海外中转仓ID''',
'SELECT 1'
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_pda_task' AND column_name = 'review_decision' AND (column_type <> 'varchar(16)' OR is_nullable <> 'YES' OR collation_name <> 'utf8mb4_unicode_ci')), 'ALTER TABLE ll_relabel_pda_task MODIFY COLUMN review_decision varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT ''最近复核结论:APPROVE或REWORK''', 'SELECT 1');
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_pda_task' AND column_name = 'review_decision' AND (column_type <> 'varchar(16)' OR is_nullable <> 'YES' OR collation_name <> 'utf8mb4_0900_ai_ci')), 'ALTER TABLE ll_relabel_pda_task MODIFY COLUMN review_decision varchar(16) COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT ''最近复核结论:APPROVE或REWORK''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(
(SELECT table_collation FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'll_warehouse_transfer_measurement_photo') <> 'utf8mb4_unicode_ci',
'ALTER TABLE ll_warehouse_transfer_measurement_photo CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci',
(SELECT table_collation FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'll_warehouse_transfer_measurement_photo') <> 'utf8mb4_0900_ai_ci',
'ALTER TABLE ll_warehouse_transfer_measurement_photo CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci',
'SELECT 1'
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -8,11 +8,11 @@ CREATE TABLE ll_dispatch_truck_task (
active_order VARCHAR(193) NULL COMMENT '有效原单唯一键;取消置空,完成保留',
PRIMARY KEY(dispatch_no), UNIQUE KEY uk_transport_trip(trip_no), UNIQUE KEY uk_transport_source(active_order),
KEY idx_transport_order(customer_id,order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车与本土卡车原单关联';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='排车与本土卡车原单关联';
CREATE TABLE ll_dispatch_transport_create (
trip_no VARCHAR(64) NOT NULL PRIMARY KEY COMMENT '本次运输资源记录号',
request_hash CHAR(64) NOT NULL COMMENT '首次安排请求摘要'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土卡车排车创建请求账本';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='本土卡车排车创建请求账本';
CREATE TABLE ll_dispatch_transport_command (
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
request_key VARCHAR(128) NOT NULL COMMENT '客户端幂等请求键',
@@ -20,4 +20,4 @@ CREATE TABLE ll_dispatch_transport_command (
evidence_json JSON NULL COMMENT '已确认运输完成凭证标识列表',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间',
PRIMARY KEY(dispatch_no,request_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车运输执行命令与凭证账本';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='排车运输执行命令与凭证账本';
@@ -2,9 +2,9 @@
-- Schema: baiyue_lastleg
-- Purpose: 预算释放查询的客户和订单号关联字段与采购单、卡车单排序规则一致
-- Preconditions: ll_order_budget_reservation 已存在;核对列长度、可空性及索引
-- Postconditions: customer_id、order_no 使用 utf8mb4_unicode_ci;不修改任何业务数据
-- Postconditions: customer_id、order_no 使用 utf8mb4_0900_ai_ci;不修改任何业务数据
-- DDL 非事务,执行前核对目标库;失败后检查列定义再继续。
ALTER TABLE baiyue_lastleg.ll_order_budget_reservation
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
MODIFY COLUMN customer_id VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离标识',
MODIFY COLUMN order_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '成功创建的业务单号';
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
MODIFY COLUMN customer_id VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户隔离标识',
MODIFY COLUMN order_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '成功创建的业务单号';
@@ -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;
@@ -8,5 +8,5 @@ ALTER TABLE ll_outbound_pda_pick
MODIFY COLUMN inventory_position_id bigint NULL COMMENT '库存仓位ID;退货箱直接出库时为空';
ALTER TABLE ll_relabel_item
MODIFY COLUMN new_sku_code varchar(128) COLLATE utf8mb4_unicode_ci NULL COMMENT '换标后SKU编码;批量SKU换标使用子明细JSON时为空',
MODIFY COLUMN source_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NULL COMMENT '原海外中转仓ID;预入仓批量SKU换标时为空';
MODIFY COLUMN new_sku_code varchar(128) COLLATE utf8mb4_0900_ai_ci NULL COMMENT '换标后SKU编码;批量SKU换标使用子明细JSON时为空',
MODIFY COLUMN source_warehouse_id varchar(128) COLLATE utf8mb4_0900_ai_ci NULL COMMENT '原海外中转仓ID;预入仓批量SKU换标时为空';
@@ -27,4 +27,4 @@ CREATE TABLE ll_warehouse_transfer_receipt_command (
UNIQUE KEY uk_ll_wh_receipt_cmd_key (customer_id, idempotency_key),
KEY idx_ll_wh_receipt_cmd_target (customer_id, transfer_order_id, target_pallet_id),
CONSTRAINT ck_ll_wh_receipt_cmd_status CHECK (status IN ('PREPARED','RESERVED','COMPLETED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转运逐来源收货原请求与共享目标资源占用命令';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='转运逐来源收货原请求与共享目标资源占用命令';
@@ -41,7 +41,7 @@ BEGIN
IF inbound_exists>0 THEN
IF (SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound'
AND TABLE_TYPE='BASE TABLE' AND ENGINE='InnoDB' AND TABLE_COLLATION='utf8mb4_unicode_ci')<>1
AND TABLE_TYPE='BASE TABLE' AND ENGINE='InnoDB' AND TABLE_COLLATION='utf8mb4_0900_ai_ci')<>1
OR (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound')<>4
OR (SELECT COUNT(*) FROM information_schema.COLUMNS
@@ -81,7 +81,7 @@ BEGIN
created_at datetime(3) NOT NULL COMMENT '计租登记时间,采用协调世界时',
PRIMARY KEY (receipt_id),
KEY idx_storage_rent_inbound_lot (lot_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='头程实收计租幂等登记';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='头程实收计租幂等登记';
END IF;
END$$
DELIMITER ;
+4 -4
View File
@@ -52,7 +52,7 @@ CREATE TABLE ll_inbound_fba_appointment (
UNIQUE KEY uk_ll_fba_appointment_no (appointment_no),
KEY idx_ll_fba_appointment_list (appointment_at,appointment_status,fba_warehouse_code),
KEY idx_ll_fba_appointment_planned (planned_at,appointment_status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='FBA预约安排与正式确认唯一主记录';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='FBA预约安排与正式确认唯一主记录';
CREATE TABLE ll_inbound_fba_appointment_item (
id BIGINT NOT NULL COMMENT '记录主键',
@@ -74,7 +74,7 @@ CREATE TABLE ll_inbound_fba_appointment_item (
KEY idx_ll_fba_active_order (customer_id,order_id,active),
KEY idx_ll_fba_item_source_lookup (source_key,customer_id),
KEY idx_ll_fba_item_customer_order (customer_id,order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='入仓前后共用的预约真实货源';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入仓前后共用的预约真实货源';
CREATE TABLE ll_appointment_cargo_verification (
id BIGINT NOT NULL COMMENT '核实记录主键',
@@ -97,7 +97,7 @@ CREATE TABLE ll_appointment_cargo_verification (
UNIQUE KEY uk_ll_cargo_verification_command (customer_id,source_key,command_key),
CONSTRAINT chk_ll_cargo_verification_quantity CHECK (package_count IS NULL OR package_count > 0),
CONSTRAINT chk_ll_cargo_verification_version CHECK (version > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约货物核实资料及不可覆盖的版本历史';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='预约货物核实资料及不可覆盖的版本历史';
CREATE TABLE ll_fba_appointment_command (
command_key VARCHAR(128) NOT NULL COMMENT '持久化幂等键',
@@ -109,4 +109,4 @@ CREATE TABLE ll_fba_appointment_command (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建UTC时间',
PRIMARY KEY(command_key),
KEY idx_ll_fba_command_appointment(appointment_no,created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约命令与变更历史';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='预约命令与变更历史';
@@ -3093,7 +3093,7 @@ CREATE TABLE fl_loading_cabinet_evidence (
seal_no varchar(64) DEFAULT NULL COMMENT '带锁码照对应锁码',
remark varchar(1000) DEFAULT NULL COMMENT '证据备注',
status varchar(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '证据状态:ACTIVE有效、VOID作废',
active_flag tinyint unsigned DEFAULT '1' COMMENT '有效记录为1,作废记录为NULL,保证每柜每类型仅一条有效事实',
active_flag tinyint unsigned DEFAULT '1' COMMENT '有效记录为1,作废记录为NULL,同类允许多张有效照片',
operation_key varchar(128) NOT NULL COMMENT '任务范围内幂等操作键',
request_hash char(64) NOT NULL COMMENT '规范化请求SHA-256摘要',
void_reason varchar(500) DEFAULT NULL COMMENT '作废原因',
@@ -3109,7 +3109,7 @@ CREATE TABLE fl_loading_cabinet_evidence (
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_cabinet_evidence_operation (source_task_id,operation_key),
UNIQUE KEY uk_cabinet_evidence_active (container_booking_id,evidence_type,active_flag),
KEY idx_cabinet_evidence_active (container_booking_id,evidence_type,active_flag),
KEY idx_cabinet_evidence_container (container_booking_id,status),
KEY idx_cabinet_evidence_seal (seal_no,active_flag),
CONSTRAINT fk_cabinet_evidence_booking FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking (id),
@@ -4144,7 +4144,7 @@ CREATE TABLE fl_direct_delivery_unloading (
PRIMARY KEY (container_booking_id,inbound_id),
UNIQUE KEY uk_direct_unloading_command (container_booking_id,idempotency_key),
CONSTRAINT fk_direct_unloading_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='直送目的仓真实卸货凭据';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='直送目的仓真实卸货凭据';
CREATE TABLE fl_terminal_drayage (
@@ -8070,7 +8070,7 @@ CREATE TABLE fl_sailing_channel (
PRIMARY KEY (id),
UNIQUE KEY uk_fl_sailing_channel_code (channel_code),
KEY idx_fl_sailing_channel_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE fl_sailing_template (
id BIGINT NOT NULL,
@@ -8106,7 +8106,7 @@ CREATE TABLE fl_sailing_template (
KEY idx_fl_sailing_template_filter (warehouse_id,channel_id,status,weekday),
CONSTRAINT ck_fl_sailing_template_weekday CHECK (weekday BETWEEN 1 AND 7),
CONSTRAINT ck_fl_sailing_template_cutoff_days CHECK (cutoff_days BETWEEN 0 AND 30)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE fl_sailing (
id BIGINT NOT NULL,
@@ -8143,7 +8143,7 @@ CREATE TABLE fl_sailing (
KEY idx_fl_sailing_calendar (etd_at,warehouse_id,channel_id,status),
KEY idx_fl_sailing_route (departure_port_code,destination_port_code),
CONSTRAINT ck_fl_sailing_status CHECK (status IN ('DRAFT','OPEN','CLOSED','DEPARTED','CANCELLED','COMPLETED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE fl_sailing_history (
id BIGINT NOT NULL,
@@ -8157,7 +8157,7 @@ CREATE TABLE fl_sailing_history (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_fl_sailing_history_sailing (sailing_id,created_at,id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
USE baiyue_iam;
@@ -8453,7 +8453,7 @@ CREATE TABLE ord_document_sequence (
current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收',
PRIMARY KEY (sequence_key),
CONSTRAINT chk_ord_document_sequence_value CHECK (current_value > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='业务单据每日独立原子流水';
USE baiyue_warehouse;
CREATE TABLE wh_document_sequence (
@@ -8461,7 +8461,7 @@ CREATE TABLE wh_document_sequence (
current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收',
PRIMARY KEY (sequence_key),
CONSTRAINT chk_wh_document_sequence_value CHECK (current_value > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='业务单据每日独立原子流水';
-- 海外费用查询菜单;编辑、审核继续使用独立财务按钮授权。
USE baiyue_iam;
File diff suppressed because it is too large Load Diff
@@ -49,6 +49,7 @@ enum BusinessFileCategory {
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_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_RECEIPT(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);
}
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 static final class Limits {
private static final long STANDARD = 10L * 1024 * 1024;
private static final long CUSTOMS = 50L * 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.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
@Component
@@ -27,6 +28,8 @@ public class BusinessFileStorage {
private static final Map<String, String> TYPES = Map.of(
"text/plain", ".txt", "application/pdf", ".pdf",
"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}")
private Path configuredRoot;
@@ -46,6 +49,7 @@ public class BusinessFileStorage {
return store(tenantId, businessId, file, category.maxSizeBytes(), category.formatPolicy());
}
/** 按业务类别检查大小和格式,保存真实文件并返回存储结果。 */
private Stored store(long tenantId, String businessId, MultipartFile file, long maxSize,
BusinessFileCategory.FormatPolicy formatPolicy) {
if (file == null || file.isEmpty()) throw bad("附件不能为空");
@@ -55,6 +59,8 @@ public class BusinessFileStorage {
String extension;
if (formatPolicy == BusinessFileCategory.FormatPolicy.ARBITRARY) {
extension = "";
} else if (formatPolicy == BusinessFileCategory.FormatPolicy.CUSTOMER_VISIT) {
extension = customerVisitExtension(file);
} else if (formatPolicy == BusinessFileCategory.FormatPolicy.PDF) {
if (!"application/pdf".equals(type)) throw bad("附件仅支持PDF");
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) {
try (InputStream input = file.getInputStream()) {
byte[] prefix = input.readNBytes(4);
@@ -174,7 +174,8 @@ public class GenericXlsxExportRunner {
case STRING -> cell.setCellValue(safeString(String.valueOf(value), column.key(), warnedTruncatedColumns));
case INTEGER -> cell.setCellValue(new BigDecimal(String.valueOf(value)).longValueExact());
case DECIMAL -> cell.setCellValue(new BigDecimal(String.valueOf(value)).doubleValue());
case BOOLEAN -> cell.setCellValue(value instanceof Boolean b ? b : Boolean.parseBoolean(String.valueOf(value)));
// 面向业务人员的是否字段统一显示中文,Provider 仍传输布尔类型。
case BOOLEAN -> cell.setCellValue((value instanceof Boolean b ? b : Boolean.parseBoolean(String.valueOf(value))) ? "是" : "否");
case DATE -> { cell.setCellValue(LocalDate.parse(String.valueOf(value))); cell.setCellStyle(styles.date); }
case DATETIME -> { cell.setCellValue(LocalDateTime.parse(String.valueOf(value))); cell.setCellStyle(styles.dateTime); }
}
@@ -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("归属不匹配");
}
}
@@ -48,7 +48,7 @@ class GenericXlsxExportRunnerTest {
}
@Test void writesRealTypedWorkbookAndCarriesSnapshotAcrossBatches() throws Exception {
List<ExportColumn> columns=List.of(new ExportColumn("text","文本",ExportColumnType.STRING,18),new ExportColumn("integer","整数",ExportColumnType.INTEGER,12),new ExportColumn("decimal","金额",ExportColumnType.DECIMAL,12),new ExportColumn("flag","布尔",ExportColumnType.BOOLEAN,10),new ExportColumn("date","日期",ExportColumnType.DATE,12),new ExportColumn("time","时间",ExportColumnType.DATETIME,20));
List<Map<String,Object>> firstRows=IntStream.range(0,100).mapToObj(index -> Map.<String,Object>of("text",index==0?"=SUM(1,2)":"row-"+index,"integer",12,"decimal","3.50","flag",true,"date","2026-08-03","time","2026-08-03T21:00:00")).toList();
List<Map<String,Object>> firstRows=IntStream.range(0,100).mapToObj(index -> Map.<String,Object>of("text",index==0?"=SUM(1,2)":"row-"+index,"integer",12,"decimal","3.50","flag",index % 2 == 0,"date","2026-08-03","time","2026-08-03T21:00:00")).toList();
var first=new ExportBatchResponse(1,"sha256:test","snapshot-9",columns,firstRows,"10",true,101L);
var second=new ExportBatchResponse(1,"sha256:test","snapshot-9",null,List.of(Map.of("text","\n=SUM(1,2)","integer",13,"decimal",4.5,"flag",false,"date","2026-08-04","time","2026-08-04T21:00:00")),null,false,101L);
when(provider.batch(anyString(),eq("CUSTOMER_LIST"),any(),any())).thenReturn(ApiResponse.success(first),ApiResponse.success(second));
@@ -58,7 +58,7 @@ class GenericXlsxExportRunnerTest {
assertEquals(101,result.rows());
try(var workbook=new XSSFWorkbook(storage.load(result.objectKey()).getInputStream())){
var row=workbook.getSheetAt(0).getRow(1);assertEquals("'=SUM(1,2)",row.getCell(0).getStringCellValue());
assertEquals(CellType.NUMERIC,row.getCell(1).getCellType());assertEquals(CellType.NUMERIC,row.getCell(2).getCellType());assertEquals(CellType.BOOLEAN,row.getCell(3).getCellType());assertEquals(CellType.NUMERIC,row.getCell(4).getCellType());assertEquals(CellType.NUMERIC,row.getCell(5).getCellType());
assertEquals(CellType.NUMERIC,row.getCell(1).getCellType());assertEquals(CellType.NUMERIC,row.getCell(2).getCellType());assertEquals(CellType.STRING,row.getCell(3).getCellType());assertEquals("是",row.getCell(3).getStringCellValue());assertEquals("否",workbook.getSheetAt(0).getRow(2).getCell(3).getStringCellValue());assertEquals(CellType.NUMERIC,row.getCell(4).getCellType());assertEquals(CellType.NUMERIC,row.getCell(5).getCellType());
assertEquals("'\n=SUM(1,2)",workbook.getSheetAt(0).getRow(101).getCell(0).getStringCellValue());
assertTrue(workbook.getNumCellStyles()<10,"日期样式必须复用,不能按单元格创建");
}
@@ -0,0 +1,107 @@
package com.baiyue.finance.service;
import java.util.Locale;
import java.util.Map;
/** 导出业务枚举沿用财务页面中文口径,不改动筛选值、币种及业务编号。 */
final class FinanceExportLabels {
/** 中文导出映射仅提供静态转换。 */
private FinanceExportLabels() { }
private static final Map<String, String> STATUSES = Map.ofEntries(
Map.entry("ACTIVE", "正常"),
Map.entry("DISABLED", "停用"),
Map.entry("INACTIVE", "停用"),
Map.entry("DRAFT", "草稿"),
Map.entry("PENDING", "待审核"),
Map.entry("PENDING_REVIEW", "待审核"),
Map.entry("APPROVED", "已审核"),
Map.entry("ISSUED", "已出账"),
Map.entry("REJECTED", "已驳回"),
Map.entry("UNPAID", "未支付"),
Map.entry("PARTIALLY_PAID", "部分结清"),
Map.entry("PAID", "已结清"),
Map.entry("VOIDED", "已作废"),
Map.entry("AVAILABLE", "待结算"),
Map.entry("LOCKED", "结算锁定"),
Map.entry("BILLED", "已入账"),
Map.entry("SETTLED", "已结算"),
Map.entry("RECORDED", "已登记"),
Map.entry("CONFIRMED", "已确认"),
Map.entry("PENDING_CONFIRMATION", "待客户确认"),
Map.entry("NOT_REQUIRED", "无需确认"));
private static final Map<String, String> SOURCES = Map.ofEntries(
Map.entry("INBOUND_FEE", "入库费用"),
Map.entry("ORDER_FEE", "订单费用"),
Map.entry("CONTAINER_FEE", "装柜费用"),
Map.entry("CONTAINER_COST", "整柜成本"),
Map.entry("MANUAL_COST", "人工成本"),
Map.entry("STORAGE_FEE", "仓储费用"),
Map.entry("OVERSEAS_INBOUND_FEE", "海外入仓费用"),
Map.entry("OVERSEAS_PROCUREMENT_FEE", "海外采购费用"),
Map.entry("OVERSEAS_TRUCK_FEE", "本土卡车费用"),
Map.entry("OVERSEAS_LOOSEBOARD_FEE", "本土散板费用"),
Map.entry("OVERSEAS_WAREHOUSE_FEE", "海外仓服务费"),
Map.entry("OVERSEAS_CONTAINER_FEE", "海外柜子费用"),
Map.entry("OVERSEAS_ORDER_FEE", "尾程订单费用"),
Map.entry("OVERSEAS_DELIVERY_FEE", "尾程派送费用"),
Map.entry("OVERSEAS_RELABEL_FEE", "换标费用"));
private static final Map<String, String> BUSINESSES = Map.ofEntries(
Map.entry("RECHARGE", "充值"),
Map.entry("DEDUCTION", "扣款"),
Map.entry("CREDIT_LIMIT", "授信额度调整"),
Map.entry("FREEZE", "冻结"),
Map.entry("UNFREEZE", "解冻"),
Map.entry("CONSUMPTION", "消费扣款"),
Map.entry("REFUND", "退款"),
Map.entry("RECONCILIATION_CREATED", "生成对账单"),
Map.entry("SETTLEMENT_ISSUED", "结算单生效"),
Map.entry("RECEIPT", "收款"),
Map.entry("PAYMENT", "付款"),
Map.entry("SUPPLIER_DIRECT_PAID", "供应商线下直付"),
Map.entry("SUPPLIER_ON_ACCOUNT_PAYMENT", "供应商账期付款"),
Map.entry("ADJUSTMENT", "账务调整"),
Map.entry("VOID", "作废"),
Map.entry("BILL", "对账单"),
Map.entry("COST", "成本"),
Map.entry("SETTLEMENT", "结算"),
Map.entry("CREATE", "创建"),
Map.entry("APPROVE", "审核通过"),
Map.entry("REJECT", "审核驳回"),
Map.entry("UPDATE", "更新"),
Map.entry("FEE_SOURCE_EDIT", "编辑费用"),
Map.entry("FEE_SOURCE_SUBMIT", "提交审核"),
Map.entry("FEE_SOURCE_REVIEW_APPROVE", "审核通过"),
Map.entry("FEE_SOURCE_REVIEW_REJECT", "审核驳回"),
Map.entry("FEE_SOURCE_ADJUST", "调整费用"),
Map.entry("FEE_SOURCE_VOID", "作废费用"),
Map.entry("FEE_SOURCE_PUBLISHED", "费用同步"),
Map.entry("FEE_SOURCE_VERSION_ADJUST", "费用调整同步"),
Map.entry("RECONCILIATION_CREATE", "生成对账单"),
Map.entry("RECONCILIATION_ADJUST", "调整对账单"),
Map.entry("BILL_CREATE", "创建账单"),
Map.entry("BILL_ISSUE", "出账"),
Map.entry("BILL_ADJUST", "调整账单"),
Map.entry("BILL_VOID", "作废账单"),
Map.entry("SETTLEMENT_AR_CREATE", "生成应收结算单"),
Map.entry("SETTLEMENT_AP_CREATE", "生成应付结算单"),
Map.entry("SETTLEMENT_PAYMENT_CREATE", "登记收付款"),
Map.entry("RECEIPT_CREATE", "登记收款"),
Map.entry("COST_CREATE", "登记成本"),
Map.entry("REOPEN", "重开"));
/** 转换账户、审核及结算状态。 */
static String status(String value) { return label(value, STATUSES, "未知状态"); }
/** 转换费用来源类型。 */
static String source(String value) { return label(value, SOURCES, "未知来源类型"); }
/** 转换账务及资金流水业务类型。 */
static String business(String value) { return label(value, BUSINESSES, "未知业务类型"); }
/** 转换应收应付方向。 */
static String direction(String value) { return label(value, Map.of("RECEIVABLE", "应收", "PAYABLE", "应付"), "未知方向"); }
/** 空值保持空白,已是中文的历史快照保持原文;新增英文枚举不直接泄露到业务报表。 */
private static String label(String value, Map<String, String> labels, String fallback) {
if (value == null || value.isBlank()) return value;
String code = value.trim().toUpperCase(Locale.ROOT);
return labels.getOrDefault(code, value.matches(".*[一-鿿].*") ? value : fallback);
}
}
@@ -339,35 +339,39 @@ public class FinanceExportProviderService {
} catch (GeneralSecurityException exception) { throw new IllegalStateException("HmacSHA256 is unavailable", exception); }
}
/** 导出账户摘要,状态使用中文展示。 */
private Map<String, Object> accountRow(AccountSummary value) {
return row("customerCode", value.customerCode(), "customerName", value.customerName(), "contactName", value.contactName(),
"contactPhone", value.contactPhone(), "outstandingReceivable", value.outstandingReceivable(), "pendingBilling", value.pendingBilling(),
"receivedInPeriod", value.receivedInPeriod(), "totalCost", value.totalCost(), "paidCost", value.paidCost(),
"outstandingPayable", value.outstandingPayable(), "status", value.status(), "lastChangedAt", value.lastChangedAt());
"outstandingPayable", value.outstandingPayable(), "status", FinanceExportLabels.status(value.status()), "lastChangedAt", value.lastChangedAt());
}
/** 导出账户流水,转换业务类型并保留金额和编号。 */
private Map<String, Object> ledgerRow(LedgerEntry value) {
return row("ledgerId", String.valueOf(value.ledgerId()), "occurredAt", value.occurredAt(), "businessType", value.businessType(),
return row("ledgerId", String.valueOf(value.ledgerId()), "occurredAt", value.occurredAt(), "businessType", FinanceExportLabels.business(value.businessType()),
"documentNo", value.documentNo(), "receivableChange", value.receivableChange(), "payableChange", value.payableChange(),
"outstandingReceivableAfter", value.outstandingReceivableAfter(), "operatorName", value.operatorName(), "remark", value.remark());
}
/** 费用方向、来源类型及审核和结算状态统一展示中文。 */
private Map<String, Object> feeRow(FeeSourceSummary source) {
return row("customerName", source.customerName(), "direction", source.direction(), "sourceType", source.sourceType(),
return row("customerName", source.customerName(), "direction", FinanceExportLabels.direction(source.direction()), "sourceType", FinanceExportLabels.source(source.sourceType()),
"sourceFeeId", String.valueOf(source.sourceFeeId()), "sourceVersion", source.sourceVersion(), "inboundCode", source.inboundCode(),
"containerNo", source.containerNo(), "businessDate", source.businessDate(), "feeItemName", source.feeItemName(),
"quantity", source.quantity(), "unitPrice", source.unitPrice(), "amount", source.amount(),
"reviewStatus", source.reviewStatus(), "sourceStatus", source.sourceStatus(), "statementGenerated", source.statementGenerated());
"reviewStatus", FinanceExportLabels.status(source.reviewStatus()), "sourceStatus", FinanceExportLabels.status(source.sourceStatus()), "statementGenerated", source.statementGenerated());
}
/** 对账单导出沿用页面状态口径,是否结清仍传递布尔值。 */
private Map<String, Object> statementRow(ReconciliationDetail value) {
return row("statementNo", value.statementNo(), "customerName", value.customerName(), "periodStart", value.periodStart(),
"periodEnd", value.periodEnd(), "creditDays", value.creditDays(), "currency", value.currency(), "totalReceivable", value.totalReceivable(),
"receivedAmount", value.receivedAmount(), "outstandingAmount", value.outstandingAmount(), "settled", value.settled(), "status", value.status(),
"receivedAmount", value.receivedAmount(), "outstandingAmount", value.outstandingAmount(), "settled", value.settled(), "status", FinanceExportLabels.status(value.status()),
"remark", value.remark(), "createdByName", value.createdByName(), "createdAt", value.createdAt(), "updatedByName", value.updatedByName(), "updatedAt", value.updatedAt());
}
/** 结算单列表导出使用中文状态,便于财务人员直接阅读。 */
private Map<String, Object> settlementRow(SettlementSummary value) {
return row("settlementNo", value.settlementNo(), "customerName", value.customerName(), "supplierName", value.supplierName(),
"currency", value.currency(), "totalAmount", value.totalAmount(), "paidAmount", value.paidAmount(), "outstandingAmount", value.outstandingAmount(),
"businessPeriod", value.businessPeriod(), "generatedDate", value.generatedDate(), "dueDate", value.dueDate(), "status", settlementStatusLabel(value.status()));
"businessPeriod", value.businessPeriod(), "generatedDate", value.generatedDate(), "dueDate", value.dueDate(), "status", FinanceExportLabels.status(value.status()));
}
/** 明细导出与列表保持相同的中文结算状态。 */
private Map<String, Object> settlementDetailRow(SettlementSummary summary, SettlementLine line) {
@@ -375,18 +379,7 @@ public class FinanceExportProviderService {
"feeItemName", line == null ? null : line.feeItemName(), "inboundCode", line == null ? null : line.inboundCode(),
"containerNo", line == null ? null : line.containerNo(), "businessDate", line == null ? null : line.businessDate(),
"lineAmount", line == null ? null : line.amount(), "totalAmount", summary.totalAmount(), "paidAmount", summary.paidAmount(),
"outstandingAmount", summary.outstandingAmount(), "status", settlementStatusLabel(summary.status()));
}
/** 转换正式结算状态,未知值保留原值以便排查。 */
private String settlementStatusLabel(String status) {
if (status == null) return null;
return switch (status) {
case "ISSUED" -> "已出账";
case "PARTIALLY_PAID" -> "部分结清";
case "PAID" -> "已结清";
case "VOIDED" -> "已作废";
default -> status;
};
"outstandingAmount", summary.outstandingAmount(), "status", FinanceExportLabels.status(summary.status()));
}
private Map<String, Object> receivableStatisticsRow(YearMonth month, ReceivableStatisticsRow value) {
return row("statisticsMonth", month.toString(), "customerName", value.customerName(),
@@ -0,0 +1,34 @@
package com.baiyue.finance.service;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class FinanceExportLabelsTest {
@Test void translatesStatusSourceAndLedgerFamiliesWithoutLeakingCodes() {
assertThat(FinanceExportLabels.status("PARTIALLY_PAID")).isEqualTo("部分结清");
assertThat(FinanceExportLabels.status("SETTLED")).isEqualTo("已结算");
for (String code : List.of("ACTIVE", "DISABLED", "DRAFT", "PENDING", "PENDING_REVIEW", "APPROVED", "REJECTED", "ISSUED", "PARTIALLY_PAID", "PAID", "VOIDED", "AVAILABLE", "LOCKED", "BILLED")) {
assertThat(FinanceExportLabels.status(code)).matches(".*[一-鿿].*").doesNotContain("未知");
}
for (String code : List.of("INBOUND_FEE", "ORDER_FEE", "CONTAINER_FEE", "CONTAINER_COST", "MANUAL_COST", "STORAGE_FEE", "OVERSEAS_INBOUND_FEE", "OVERSEAS_PROCUREMENT_FEE", "OVERSEAS_TRUCK_FEE", "OVERSEAS_LOOSEBOARD_FEE", "OVERSEAS_WAREHOUSE_FEE", "OVERSEAS_CONTAINER_FEE", "OVERSEAS_ORDER_FEE", "OVERSEAS_DELIVERY_FEE", "OVERSEAS_RELABEL_FEE")) {
assertThat(FinanceExportLabels.source(code)).matches(".*[一-鿿].*").doesNotContain("未知");
}
for (String code : List.of("FEE_SOURCE_EDIT", "FEE_SOURCE_SUBMIT", "FEE_SOURCE_REVIEW_APPROVE", "FEE_SOURCE_REVIEW_REJECT", "FEE_SOURCE_ADJUST", "FEE_SOURCE_VOID", "RECONCILIATION_CREATED", "SETTLEMENT_ISSUED", "RECEIPT", "PAYMENT", "SUPPLIER_DIRECT_PAID", "SUPPLIER_ON_ACCOUNT_PAYMENT", "ADJUSTMENT", "VOID")) {
assertThat(FinanceExportLabels.business(code)).matches(".*[一-鿿].*").doesNotContain("未知");
}
assertThat(FinanceExportLabels.direction("RECEIVABLE")).isEqualTo("应收");
for (String code : List.of("RECHARGE", "DEDUCTION", "CREDIT_LIMIT", "FREEZE", "UNFREEZE", "CONSUMPTION", "REFUND")) {
assertThat(FinanceExportLabels.business(code)).matches(".*[一-鿿].*").doesNotContain("未知");
}
assertThat(FinanceExportLabels.direction("PAYABLE")).isEqualTo("应付");
}
@Test void handlesEmptyChineseSnapshotsAndUnknownValues() {
assertThat(FinanceExportLabels.status(null)).isNull();
assertThat(FinanceExportLabels.status("")).isEmpty();
assertThat(FinanceExportLabels.status("已结清")).isEqualTo("已结清");
assertThat(FinanceExportLabels.status("NEW_INTERNAL_STATUS")).isEqualTo("未知状态");
assertThat(FinanceExportLabels.status(" partially_paid ")).isEqualTo("部分结清");
}
}
@@ -45,6 +45,19 @@ class FinanceExportProviderServiceTest {
service.initializeSigningKey();
}
@Test
void exportsAccountAndLedgerEnumsAsChineseWithoutChangingFiltersOrReferences() throws Exception {
var mapper = new ObjectMapper().findAndRegisterModules();
var account = mapper.convertValue(Map.of("customerId", 10001L, "customerCode", "C-10001", "status", "ACTIVE"), com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary.class);
var ledger = mapper.convertValue(Map.of("ledgerId", 1L, "businessType", "SUPPLIER_ON_ACCOUNT_PAYMENT", "documentNo", "AP-1"), com.baiyue.finance.dto.FinancePhase1Dtos.LedgerEntry.class);
when(accountService.list(1, 100, null, "ACTIVE", 10001L)).thenReturn(PageEnvelope.success(List.of(account), 1, 100, 1));
when(operationService.listDerivedCustomerAccountLedger(10001L, 1, 100, "SUPPLIER_ON_ACCOUNT_PAYMENT", null, null)).thenReturn(PageEnvelope.success(List.of(ledger), 1, 100, 1));
var accounts = service.exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LIST, request(Map.of("customerId", "10001", "status", "ACTIVE"), null, null));
var ledgers = service.exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LEDGER, request(Map.of("customerId", "10001", "businessType", "SUPPLIER_ON_ACCOUNT_PAYMENT"), null, null));
assertThat(accounts.rows().get(0)).containsEntry("status", "正常").containsEntry("customerCode", "C-10001");
assertThat(ledgers.rows().get(0)).containsEntry("businessType", "供应商账期付款").containsEntry("documentNo", "AP-1");
}
@Test
/** 应付结算列表和详情导出均使用中文状态,包括无明细的单据。 */
void exportsSettlementStatusesInChinese() {
@@ -98,7 +111,7 @@ class FinanceExportProviderServiceTest {
assertThat(first.columns()).isNotEmpty();
assertThat(first.rows()).hasSize(1);
assertThat(first.rows().get(0)).containsEntry("statementNo", "REC-501").containsEntry("customerName", "客户A");
assertThat(first.rows().get(0)).containsEntry("statementNo", "REC-501").containsEntry("customerName", "客户A").containsEntry("status", "已出账");
assertThat(first.totalRows()).isEqualTo(1L);
assertThat(continued.columns()).isEmpty();
assertThat(continued.rows()).isEmpty();
@@ -119,7 +132,7 @@ class FinanceExportProviderServiceTest {
var result = service.exportBatch(FinanceExportProviderService.FEE_SOURCE_LIST,
request(Map.of("direction", "PAYABLE", "status", "PAID"), null, null));
assertThat(result.rows()).singleElement().satisfies(row -> {
assertThat(row).containsEntry("sourceStatus", "PAID").containsEntry("sourceFeeId", "9001");
assertThat(row).containsEntry("sourceStatus", "已结清").containsEntry("reviewStatus", "已审核").containsEntry("direction", "应付").containsEntry("sourceType", "装柜费用").containsEntry("sourceFeeId", "9001");
assertThat(row.get("amount")).isEqualTo(new BigDecimal("300"));
});
org.mockito.Mockito.verifyNoInteractions(queryService);
+1 -1
View File
@@ -32,7 +32,7 @@
| 称重确认、整票库位块、部分拣货结算 | `service/MeasurementLocationOccupationService.java`、`service/MeasurementSubmissionService.java`;查“首张称重图片”“删除称重图片”“PDA 部分拣货” |
| 审核与初始计价 | `service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`;查“测量审核通过” |
| 客户四档箱规与默认算法 | `service/CustomerDimensionConfigService.java`、`service/CustomerDimensionWeightingService.java`;查“客户箱规配置” |
| FBA 计价、客户专属价格簿、柜级阶梯运费 | `service/FbaPricingService.java`、`service/PlatformPriceBookService.java`、`pricing/ContainerTierAmountAllocator.java`;查“客户专属价格簿、柜级阶梯运费与调价边界” |
| FBA 计价、私人地址人工报价、客户价格簿与柜级阶梯运费 | `service/FbaPricingService.java`、`service/PlatformPriceBookService.java`、`pricing/ContainerTierAmountAllocator.java`;查“私人地址”“客户专属价格簿、柜级阶梯运费与调价边界” |
| 费用项目、共享费用目录与价格簿查询内部切片 | `controller/PlatformFeeItemController.java`、`controller/InternalFeeCatalogController.java`、`pricebookquery/application/PriceBookQueryService.java`;共享目录契约 `src/main/resources/openapi/fee-catalog-internal-v1.yaml`,价格簿查“价格簿查询内部切片” |
| 入库费用、客户确认、直接已付 | `service/InboundFeeDetailService.java`、`service/MerchantInboundBillingService.java`、`service/FeePaymentSnapshotValidator.java`;查“客户入库费用”“DIRECT_PAID” |
| 柜次费用、跨客户分摊与财务快照 | `service/ContainerFeeDetailService.java`、`controller/InternalFinanceFeeSourceController.java`、`loading/service/FinanceCostSnapshotService.java`;查“柜次费用明细与跨客户分摊组” |
+1 -1
View File
@@ -47,7 +47,7 @@
| 货柜与全局智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`client/PendingReviewOrderClient.java`、`loading/`;全局候选合并本服务履约投影与 Order `/internal/order/v1/pending-review-orders` 权威待审核页,工作台柜次从 `/inbound-candidates`、`/containers` 进入;普通箱数、待审核占位、同柜组和全部未拣移除分别走 `/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,明细规格拆分留在服务端 |
| 货柜资料、PDA 证据与业务总览 | `controller/PlatformContainerController.java` 的 `/{id}/overview`、`/{id}/documents/{documentType}`,聚合 `mapper/ContainerOverviewMapper.java`;契约 `src/main/resources/openapi/phase3-container-overview-v1.yaml`,四类资料固定为目的港清关、国内出口报关、DO、PU 的当前唯一版本 |
| PDA 拣货与装柜、证据幂等上传 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java`、`loading/service/CabinetLoadingEvidenceService.java`;证据写入和 multipart 上传均先以任务内幂等键匹配请求哈希,上传哈希覆盖仓库、版本、证据类型、锁码、备注、文件名、媒体类型、大小与文件内容;完全重放返回当前视图且不得再次上传 File,相同键不同内容返回冲突 |
| 装柜完成直连事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`loading/mapper/CabinetLoadingEvidenceMapper.java`、`client/LastlegEventClient.java`;当前发布 V3,从 `fl_inbound_order.destination_type` 冻结 `orders[].destinationType`(`FBA` 或 `OVERSEAS`),OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数,并冻结物理柜五类有效装柜照片引用;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` |
| 装柜完成直连事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`loading/mapper/CabinetLoadingEvidenceMapper.java`、`client/LastlegEventClient.java`;当前发布 V3,从 `fl_inbound_order.destination_type` 冻结 `orders[].destinationType`(`FBA` 或 `OVERSEAS`),国内 PRIVATE 按 OVERSEAS 交接并保留私人地址及箱码快照,同一入仓单的原始目的类型必须唯一;OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数,并冻结物理柜五类有效装柜照片引用;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` |
| 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` |
| 海外暂扣状态回写 | 同一内部事件入口、消费者 `service/OverseasInboundOrderDetainedConsumer.java`;按来源入仓单 ID 更新 `fl_inbound_order.hold_flag/current_hold_reason`,复用跨区 Inbox 并写 `fl_inbound_hold_record` |
| 海外派送完成回写 | 同一内部事件入口、消费者 `service/OverseasDeliveryCompletedConsumer.java`;按来源入仓单、订单、入仓码和 FBA 仓校验后推进当前入仓单 `LOADED→DELIVERED`,同一 Order 全部有效入仓单完成后才经 `OrderFulfillmentCompletionClient` 推进 Order `COMPLETED` |
@@ -87,11 +87,12 @@ public class PlatformFbaPricingController {
return ApiResponse.success(service.confirm(scope, user.userId(), inboundId, idempotencyKey));
}
/** 按费用发送权限发布已确认费用,审核门禁由服务端校验且不推进履约状态。 */
@PostMapping("/send")
@PreAuthorize("hasAnyAuthority('firstleg:inbound:confirm-send-fee',"
+ " 'firstleg:inbound:send-fee')")
@BusinessAudit(event = "FBA_FEE_SEND", resourceType = "INBOUND", resourceId = "#inboundId",
description = "发送FBA费用快照并进入待托盘状态")
description = "发送FBA费用快照")
public ApiResponse<FeeSnapshotResponse> send(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String idempotencyKey) {
@@ -254,6 +254,9 @@ public class ContainerCustomsService {
throw conflict("当前柜尚未完成首次发送报关行资料");
}
CompletionRow completion = mapper.lockCompletion(documentId);
if (completion != null && "CONFIRMED".equals(completion.customerConfirmStatus())) {
throw conflict("客户已确认预录单,不可再次上传");
}
CustomsFileClient.Metadata metadata = upload(source.tenantId(), RECEIPT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file);
long completionId;
int version;
@@ -14,7 +14,7 @@ public interface CabinetLoadingEvidenceMapper extends BaseMapper<CabinetLoadingE
@Select("SELECT * FROM fl_loading_cabinet_evidence WHERE container_booking_id=#{containerBookingId} ORDER BY evidence_type,id")
List<CabinetLoadingEvidenceEntity> byContainer(@Param("containerBookingId") long containerBookingId);
@Select("SELECT * FROM fl_loading_cabinet_evidence WHERE container_booking_id=#{containerBookingId} AND evidence_type=#{type} AND active_flag=1 LIMIT 1 FOR UPDATE")
@Select("SELECT * FROM fl_loading_cabinet_evidence WHERE container_booking_id=#{containerBookingId} AND evidence_type=#{type} AND active_flag=1 ORDER BY id DESC LIMIT 1 FOR UPDATE")
CabinetLoadingEvidenceEntity activeForUpdate(@Param("containerBookingId") long containerBookingId,
@Param("type") String type);
@@ -74,8 +74,6 @@ public class CabinetLoadingEvidenceService {
if (current != null && !Objects.equals(current.getVersion(), expected)) throw conflict("装柜证据版本已变化,请刷新后重试");
if (current != null && Objects.equals(current.getFileId(), request.fileId())
&& Objects.equals(current.getSealNo(), sealNo)) return view(scope);
if (current != null && mapper.voidActive(current.getId(), scope.containerBookingId(), current.getVersion(),
"REPLACED", operatorId, operatorName) != 1) throw conflict("装柜证据版本已变化,请刷新后重试");
CabinetLoadingEvidenceEntity value = new CabinetLoadingEvidenceEntity();
value.setContainerBookingId(scope.containerBookingId()); value.setSourceTaskId(taskId);
value.setEvidenceType(type); value.setFileId(request.fileId()); value.setFileName(file.fileName());
@@ -84,8 +82,7 @@ public class CabinetLoadingEvidenceService {
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName);
try { mapper.insert(value); } catch (DuplicateKeyException exception) {
// A replacement may already have voided the old row in this transaction.
// Roll it back; a subsequent same-key request can read the committed winner.
// The task-scoped operation key protects concurrent duplicate uploads.
throw conflict("装柜证据发生并发变更,请刷新后重试");
}
return view(scope);
@@ -100,8 +100,8 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
WHERE departure.container_booking_id=b.id
AND departure.confirmed_at IS NOT NULL AND departure.reversed_at IS NULL
AND departure.status='WAITING_FOR_LOADING')
AND ((#{query.scope}='ACTIVE' AND b.actual_empty_reported_at IS NULL)
OR (#{query.scope}='HISTORY' AND b.actual_empty_reported_at IS NOT 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 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}, '%')
OR b.cabinet_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
""") 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 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; }
@@ -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);
}
/** 保存试算及计费量,保留测量、审核与仓库作业的主状态。 */
public int markInboundCalculated(long tenantId, long inboundId, String processStatus, String chargeMode,
java.math.BigDecimal selectedWeightKg,
java.math.BigDecimal selectedVolumeCbm,
@@ -341,10 +342,6 @@ public class FbaPricingRepository {
.eq(InboundPricingEntity::getProcessStatus, processStatus)
.in(InboundPricingEntity::getFeeStatus, List.of("UNPRICED", "CALCULATED", "SENT"))
.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);
return inbounds.update(null, update);
}
@@ -383,12 +380,14 @@ public class FbaPricingRepository {
.set(InboundFeeEntity::getConfirmedAt, now));
}
/** 确认费用时保留物流主状态,一件代发待组托也允许确认。 */
/** 仅确认已通过测量审核的费用,并保留当前履约状态。 */
public int markInboundConfirmed(long tenantId, long inboundId) {
return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
.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")
.set(InboundPricingEntity::getFeeStatus, "CONFIRMED"));
}
@@ -436,15 +435,14 @@ public class FbaPricingRepository {
.set(InboundPricingEntity::getFeeStatus, "VOIDED"));
}
/** 发送仅更新费用状态,并原子校验测量已审核及履约状态未变化。 */
public int markInboundFeeSent(long tenantId, long inboundId, String processStatus) {
LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
.eq(InboundPricingEntity::getProcessStatus, processStatus)
.eq(InboundPricingEntity::getMeasureReviewStatus, "APPROVED")
.eq(InboundPricingEntity::getFeeStatus, "CONFIRMED")
.set(InboundPricingEntity::getFeeStatus, "SENT");
if ("FEE_CONFIRMING".equals(processStatus)) {
update.set(InboundPricingEntity::getProcessStatus, "WAIT_PALLET");
}
return inbounds.update(null, update);
}
@@ -196,9 +196,8 @@ public class FbaPricingService {
}
feeDetails.materializeCalculatedFreight(tenantId, inboundId, fee.getId(), operatorId, now);
complete(token, fee.getId(), now);
String nextStatus = preserveProcessStatusDuringPricing(inbound.getProcessStatus())
? inbound.getProcessStatus() : "FEE_CONFIRMING";
return response(inbound, fee, rate.getUnitPrice(), nextStatus);
// 费用是独立状态,提前试算不得覆盖待测量或待审核等履约进度。
return response(inbound, fee, rate.getUnitPrice(), inbound.getProcessStatus());
}
/** 价格未命中时按人工单价生成正式费用,计费量与只读预览保持一致。 */
@@ -229,9 +228,10 @@ public class FbaPricingService {
if (current != null && !List.of("CALCULATED", "CONFIRMED", "SENT").contains(current.getFeeStatus())) {
throw conflict("当前费用状态不允许重新定价");
}
LocalDateTime now = LocalDateTime.now(clock);
// 与DATETIME(3)保存精度一致,首次响应和原键重放保留同一时间事实。
LocalDateTime now = LocalDateTime.now(clock).truncatedTo(java.time.temporal.ChronoUnit.MILLIS);
PricingPolicy policy = pricingPolicy(inbound, now);
// 私人地址使用人工报价;FBA仍须先证明价格表未命中,避免绕开有效报价。
// 私人地址使用人工报价。FBA仍须先证明价格表未命中,避免绕开有效报价。
if ("FBA".equals(inbound.getDestinationType())) {
try {
priceBookEstimator.estimate(tenantId, new EstimateRequest(
@@ -289,9 +289,7 @@ public class FbaPricingService {
weight, volume, quantity) != 1) throw conflict("入库单状态已变化,费用未保存");
feeDetails.materializeCalculatedFreight(tenantId, inboundId, fee.getId(), operatorId, now);
complete(token, fee.getId(), now);
String nextStatus = preserveProcessStatusDuringPricing(inbound.getProcessStatus())
? inbound.getProcessStatus() : "FEE_CONFIRMING";
return response(inbound, fee, request.unitPrice(), nextStatus);
return response(inbound, fee, request.unitPrice(), inbound.getProcessStatus());
}
/** 预览和正式计价共用价格匹配与柜级阶梯分摊,避免前端复制计价公式。 */
@@ -576,10 +574,14 @@ public class FbaPricingService {
public record ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus status,
int recalculatedCount) { }
/** 测量审核通过后才允许确认费用,避免提前冻结费用阻断审核后的重新计价。 */
@Transactional
public FeeSnapshotResponse confirm(long tenantId, long operatorId, long inboundId,
String idempotencyKey) {
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
if (!"APPROVED".equals(inbound.getMeasureReviewStatus())) {
throw conflict("测量数据尚未审核通过,请先完成测量审核后再确认费用");
}
if (!feeRevisionStatusSupported(inbound.getProcessStatus())) {
throw conflict("当前入库单状态不允许确认费用");
}
@@ -598,10 +600,14 @@ public class FbaPricingService {
return response(tenantId, current.getId(), null);
}
/** 仅发布已通过测量审核的费用,发送不改变测量与仓库作业进度。 */
@Transactional
public FeeSnapshotResponse send(long tenantId, long operatorId, long inboundId,
String idempotencyKey) {
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
if (!"APPROVED".equals(inbound.getMeasureReviewStatus())) {
throw conflict("测量数据尚未审核通过,请先完成测量审核后再发送客户");
}
if (!feeRevisionStatusSupported(inbound.getProcessStatus())) {
throw conflict("当前入库单状态不允许发送费用");
}
@@ -610,8 +616,7 @@ public class FbaPricingService {
String hash = hash("SEND", inboundId, current.getId().toString());
InboundPricingIdempotencyEntity token = beginIdempotency(
tenantId, operatorId, inboundId, "FEE_SEND", idempotencyKey, hash);
String nextProcessStatus = preserveProcessStatusDuringPricing(inbound.getProcessStatus())
? inbound.getProcessStatus() : "WAIT_PALLET";
String nextProcessStatus = inbound.getProcessStatus();
if ("COMPLETED".equals(token.getStatus())) {
feeDetails.publishFreight(tenantId, inboundId, token.getResultResourceId(),
operatorId, null, LocalDateTime.now(clock));
@@ -811,11 +816,6 @@ public class FbaPricingService {
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) {
return List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
"WAIT_PRICING", "FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING",
@@ -169,24 +169,23 @@ public class LoadingCompletedOutboxService {
traceId, eventKey, null, payloadNode, hash).requireValid();
}
/** 国内同类照片可追加,交接按每类最新有效照片冻结一张对照图。 */
private List<FirstLegLoadingPlanCompletedV3.LoadingEvidence> loadingEvidence(Long containerBookingId) {
if (containerBookingId == null || containerBookingId <= 0) {
throw new BizException(ResultCode.CONFLICT, "装柜计划缺少柜次,不能冻结装柜照片");
}
List<FirstLegLoadingPlanCompletedV3.LoadingEvidence> result = cabinetEvidenceMapper
.byContainer(containerBookingId).stream()
Map<String, FirstLegLoadingPlanCompletedV3.LoadingEvidence> latestByType = new java.util.TreeMap<>();
// Mapper 按类型、证据 ID 升序返回;同类后到的有效照片覆盖前一张,原始证据全部保留。
cabinetEvidenceMapper.byContainer(containerBookingId).stream()
.filter(value -> Integer.valueOf(1).equals(value.getActiveFlag()) && "ACTIVE".equals(value.getStatus()))
.map(value -> new FirstLegLoadingPlanCompletedV3.LoadingEvidence(
value.getEvidenceType(), value.getFileId(), value.getFileName()))
.sorted(Comparator.comparing(FirstLegLoadingPlanCompletedV3.LoadingEvidence::evidenceType))
.toList();
Set<String> types = result.stream().map(FirstLegLoadingPlanCompletedV3.LoadingEvidence::evidenceType)
.collect(java.util.stream.Collectors.toSet());
if (result.size() != 5 || !types.equals(Set.of(
.forEach(value -> latestByType.put(value.getEvidenceType(),
new FirstLegLoadingPlanCompletedV3.LoadingEvidence(
value.getEvidenceType(), value.getFileId(), value.getFileName())));
if (!latestByType.keySet().equals(Set.of(
"PHOTO_STEP_1", "PHOTO_STEP_2", "PHOTO_STEP_3", "PHOTO_STEP_4", "SEAL"))) {
throw new BizException(ResultCode.CONFLICT, "国内装柜五类照片不完整,不能生成尾程交接事件");
}
return result;
return List.copyOf(latestByType.values());
}
private FirstLegLoadingPlanCompletedV2.Order order(List<Row> values, List<PalletRow> palletRows,
@@ -335,11 +334,13 @@ public class LoadingCompletedOutboxService {
Set<String> values = rows.stream().map(Row::getDestinationType)
.map(value -> value == null ? "" : value.trim().toUpperCase(java.util.Locale.ROOT))
.collect(java.util.stream.Collectors.toSet());
if (values.size() != 1 || (!values.contains("FBA") && !values.contains("OVERSEAS"))) {
if (values.size() != 1
|| (!values.contains("FBA") && !values.contains("OVERSEAS") && !values.contains("PRIVATE"))) {
throw new BizException(ResultCode.CONFLICT,
"装柜入仓单目的类型必须唯一且为 FBA 或 OVERSEAS,不能生成海外交接事件");
"装柜入仓单目的类型必须唯一且为 FBA、OVERSEAS 或 PRIVATE,不能生成海外交接事件");
}
return values.iterator().next();
String value = values.iterator().next();
return "PRIVATE".equals(value) ? "OVERSEAS" : value;
}
private String fulfillmentType(List<Row> rows) {
@@ -53,6 +53,8 @@ public class PdaPickSettlementService {
private InboundHoldGuard inboundHoldGuard;
@Resource
private DropshipPalletizationMapper dropshipPallets;
@Resource
private com.baiyue.firstleg.repository.DropshipSplitRepository dropshipSplits;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
@@ -121,6 +123,7 @@ public class PdaPickSettlementService {
}
}
/** 按柜次实拣数量结算,一件代发保留未装整托供后续柜次使用。 */
private boolean settleInbound(long parentInboundId, int pickedPackageCount,
List<LoadingPickItemEntity> taskItems, long taskId,
long operatorId, String operatorName) {
@@ -184,9 +187,7 @@ public class PdaPickSettlementService {
&& pickedByDetail.values().stream().mapToInt(Integer::intValue).sum() != pickedPackageCount) {
throw conflict("PDA 已拣明细与入库单已拣箱数不一致");
}
if (dropshipWholePallet && childPackageCount != 0) {
throw conflict("一件代发现场托盘必须整票装柜,不能生成余量分片");
}
List<MeasurementSplitLine> measurementLines = dropshipWholePallet
? wholePalletMeasurementLines(measurements, pickedPackageCount, parentAvailable)
: measurementLines(measurements, pickedByDetail);
@@ -219,8 +220,17 @@ public class PdaPickSettlementService {
requireAffected(splits.cloneChildWithPurpose(parent.tenantId(), parentInboundId, rootInboundId, remainderChildId,
orderSplit.remainderChild().orderId(), orderSplit.remainderChild().orderNo(),
remainderChildCode, childPackageCount, sequence, "REMAINDER", operatorId, operatorName), "余量子入库单");
splits.transferRemainingCartonLabels(parent.tenantId(), parentInboundId, remainderChildId,
orderSplit.remainderChild().orderId(), remainderChildCode, pickedPackageCount, childPackageCount);
if (dropshipWholePallet) {
var pickedPalletIds = taskItems.stream().filter(item -> "PICKED".equals(item.getItemStatus())
&& "SEALED_PALLET".equals(item.getUnitType())).map(LoadingPickItemEntity::getPalletId)
.filter(Objects::nonNull).distinct().toList();
// 托内箱按实际箱唛移动,不能按箱序号截取,否则会移错已装托盘。
dropshipSplits.transferRemainder(parent.tenantId(), parentInboundId, remainderChildId,
orderSplit.remainderChild().orderId(), remainderChildCode, childPackageCount, pickedPalletIds);
} else {
splits.transferRemainingCartonLabels(parent.tenantId(), parentInboundId, remainderChildId,
orderSplit.remainderChild().orderId(), remainderChildCode, pickedPackageCount, childPackageCount);
}
splits.normalizeLoadedCartonLabels(parent.tenantId(), parentInboundId, pickedPackageCount);
}
requireAffected(splits.insertBatchWithChildren(batchId, parent.tenantId(), rootInboundId, operationId, parentInboundId,
@@ -408,17 +418,24 @@ public class PdaPickSettlementService {
return result;
}
/** 一件代发逐箱封托已经冻结 SKU 归属;测量批次只承担整票体积重量,不再冒充 SKU 明细。 */
/** 整托箱数守恒,测量行按箱数分摊重量体积;真实SKU组成仍由封托箱账本冻结。 */
private static List<MeasurementSplitLine> wholePalletMeasurementLines(List<MeasurementItemRow> measurements,
int pickedPackageCount,
int parentAvailable) {
int measuredPackages = measurements.stream()
.mapToInt(item -> count(item.packageCount(), "测量明细箱数")).sum();
if (pickedPackageCount != parentAvailable || measuredPackages != parentAvailable) {
if (pickedPackageCount <= 0 || pickedPackageCount > parentAvailable || measuredPackages != parentAvailable) {
throw conflict("一件代发整托箱数与入库单、测量批次不守恒");
}
return measurements.stream().map(item -> new MeasurementSplitLine(item,
count(item.packageCount(), "测量明细箱数"), 0)).toList();
int left = pickedPackageCount;
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) {
@@ -72,7 +72,7 @@ public class SplitPreloadService {
@Transactional
public void ensurePendingForLoadingAllocation(long tenantId, long inboundId, long operatorId,
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. */
@@ -113,16 +113,17 @@ public class SplitPreloadService {
@Transactional
public void ensureForRunningLoadingAllocation(long tenantId, long inboundId, long operatorId,
String operatorName) {
ensureLoadingAllocationLifecycle(tenantId, inboundId, operatorId, operatorName, true);
ensureLoadingAllocationLifecycle(tenantId, inboundId, operatorId, operatorName);
}
/** 复用既有预装计划;同一入仓单其他未分配整托可继续进入不同柜次。 */
private void ensureLoadingAllocationLifecycle(long tenantId, long inboundId, long operatorId,
String operatorName, boolean running) {
String operatorName) {
InboundSnapshot inbound = requireInbound(tenantId, inboundId);
PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId);
boolean waitingSplit = isWaitingSplit(inbound.processStatus());
boolean loading = "LOADING".equals(inbound.processStatus());
if (!waitingSplit && !(running && loading)) {
if (!waitingSplit && !loading) {
throw new BizException(ResultCode.CONFLICT, "仅待分柜订单可以建立智能装柜预装计划");
}
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("费用确认事实不属于本批入仓来源");
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<>();
if(!sources.isEmpty()) {
try {
@@ -40,8 +42,10 @@ public class WorkflowTabService {
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()));
}
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) {
throw (BizException) new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试").initCause(e);
}
@@ -61,6 +65,11 @@ public class WorkflowTabService {
Set<String> keys=new LinkedHashSet<>();
if("MEASURE_PENDING".equals(process) && "PENDING".equals(f.reviewStatus())) keys.add("MEASUREMENT_REVIEW");
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();
if(!clearanceStage && !signed) {
@@ -75,8 +84,6 @@ public class WorkflowTabService {
if(!o.outboundExists() && !o.unloaded()) keys.add("WAIT_UNLOAD");
// 取消的出库不构成有效覆盖,读取事实后自动回显,不能保存永久隐藏标志。
if(!o.outboundExists()) keys.add("WAIT_DISPATCH");
if(o.pendingOutbound()) keys.add("OUTBOUND_DOCUMENT");
if(o.delivering()) keys.add("DELIVERING");
}
}
return keys;
@@ -0,0 +1,16 @@
-- Existing databases: allow multiple active photos per evidence type. No photo rows are changed.
USE baiyue_firstleg;
SET @evidence_ddl = IF(EXISTS(
SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='fl_loading_cabinet_evidence' AND index_name='uk_cabinet_evidence_active'
), 'ALTER TABLE fl_loading_cabinet_evidence DROP INDEX uk_cabinet_evidence_active', 'SELECT 1');
PREPARE evidence_ddl FROM @evidence_ddl;
EXECUTE evidence_ddl;
DEALLOCATE PREPARE evidence_ddl;
SET @evidence_ddl = IF(EXISTS(
SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='fl_loading_cabinet_evidence' AND index_name='idx_cabinet_evidence_active'
), 'SELECT 1', 'ALTER TABLE fl_loading_cabinet_evidence ADD INDEX idx_cabinet_evidence_active (container_booking_id,evidence_type,active_flag)');
PREPARE evidence_ddl FROM @evidence_ddl;
EXECUTE evidence_ddl;
DEALLOCATE PREPARE evidence_ddl;
@@ -16,4 +16,4 @@ CREATE TABLE IF NOT EXISTS fl_direct_delivery_unloading (
PRIMARY KEY (container_booking_id,inbound_id),
UNIQUE KEY uk_direct_unloading_command (container_booking_id,idempotency_key),
CONSTRAINT fk_direct_unloading_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='直送目的仓真实卸货凭据';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='直送目的仓真实卸货凭据';
@@ -11,7 +11,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_cabinet_evidence (
seal_no VARCHAR(64) NULL COMMENT '带锁码照对应锁码',
remark VARCHAR(1000) NULL COMMENT '证据备注',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '证据状态:ACTIVE有效、VOID作废',
active_flag TINYINT UNSIGNED NULL DEFAULT 1 COMMENT '有效唯一标识;作废后置NULL',
active_flag TINYINT UNSIGNED NULL DEFAULT 1 COMMENT '有效标识;同类型允许多张,作废后置NULL',
operation_key VARCHAR(128) NOT NULL COMMENT '任务范围内幂等操作键',
request_hash CHAR(64) NOT NULL COMMENT '规范化请求SHA-256摘要',
void_reason VARCHAR(500) NULL COMMENT '作废原因',
@@ -26,7 +26,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_cabinet_evidence (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_cabinet_evidence_active (container_booking_id,evidence_type,active_flag),
KEY idx_cabinet_evidence_active (container_booking_id,evidence_type,active_flag),
UNIQUE KEY uk_cabinet_evidence_operation (source_task_id,operation_key),
KEY idx_cabinet_evidence_container (container_booking_id,status),
KEY idx_cabinet_evidence_seal (seal_no,active_flag),
@@ -23,7 +23,7 @@ CREATE TABLE fl_sailing_channel (
PRIMARY KEY (id),
UNIQUE KEY uk_fl_sailing_channel_code (channel_code),
KEY idx_fl_sailing_channel_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE fl_sailing_template (
id BIGINT NOT NULL,
@@ -59,7 +59,7 @@ CREATE TABLE fl_sailing_template (
KEY idx_fl_sailing_template_filter (warehouse_id,channel_id,status,weekday),
CONSTRAINT ck_fl_sailing_template_weekday CHECK (weekday BETWEEN 1 AND 7),
CONSTRAINT ck_fl_sailing_template_cutoff_days CHECK (cutoff_days BETWEEN 0 AND 30)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE fl_sailing (
id BIGINT NOT NULL,
@@ -96,7 +96,7 @@ CREATE TABLE fl_sailing (
KEY idx_fl_sailing_calendar (etd_at,warehouse_id,channel_id,status),
KEY idx_fl_sailing_route (departure_port_code,destination_port_code),
CONSTRAINT ck_fl_sailing_status CHECK (status IN ('DRAFT','OPEN','CLOSED','DEPARTED','CANCELLED','COMPLETED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE fl_sailing_history (
id BIGINT NOT NULL,
@@ -110,7 +110,7 @@ CREATE TABLE fl_sailing_history (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_fl_sailing_history_sailing (sailing_id,created_at,id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
USE baiyue_iam;
@@ -171,13 +171,13 @@ paths:
/api/platform/v1/inbounds/{inboundId}/fees/confirm:
post:
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'}]
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}}
/api/platform/v1/inbounds/{inboundId}/fees/send:
post:
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'}]
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}}
/api/platform/v1/inbounds/{inboundId}/fees/latest:
@@ -1679,7 +1679,7 @@ components:
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]
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}
receivingWarehouseId: {type: integer, format: int64, nullable: true}
destinationWarehouseId: {type: integer, format: int64, nullable: true}
@@ -140,6 +140,7 @@ paths:
responses: {'200': {description: 仅platform:firstleg:pda:work且任务必须由当前用户领取}}
post:
summary: 通过Firstleg代理上传并登记当前柜次装柜证据
description: 同类型照片追加保留,不替换已有有效照片。expectedVersion取当前同类型最新有效照片版本,无照片时为0;重复幂等键不追加。
parameters:
- {$ref: '#/components/parameters/TaskId'}
- {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
@@ -304,6 +304,19 @@ class ContainerCustomsServiceTest {
verify(mapper,never()).insertBrokerSubmission(anyLong(),anyLong(),anyLong(),anyString(),anyInt(),anyString(),anyLong(),anyString());
}
@Test void receiptUploadRejectsAfterCustomerConfirmationBeforeBrokerExecution(){
var source=document(1L);var declaring=container(ContainerCustomsService.DECLARING,3);
when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("confirmed"),anyString())).thenReturn(1);
when(mapper.document(1L)).thenReturn(source);when(mapper.lockContainerById(2L)).thenReturn(declaring);
when(mapper.lockDocument(1L)).thenReturn(source);when(mapper.brokerSubmission(2L)).thenReturn(brokerSubmission());
when(mapper.lockCompletion(1L)).thenReturn(new ContainerCustomsMapper.CompletionRow(21L,1L,99L,
"receipt.pdf","b".repeat(64),"application/pdf",1L,5L,"admin",java.time.LocalDateTime.now(),1,"CONFIRMED",null));
assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","confirmed",1,
new MockMultipartFile("file","receipt.pdf","application/pdf",new byte[]{1})))
.isInstanceOf(BizException.class).hasMessageContaining("客户已确认预录单");
verifyNoInteractions(files);
}
@Test void receiptUploadRejectsWhenInitialBrokerSubmissionIsMissing(){
var source=document(1L);var container=container(ContainerCustomsService.DECLARING,3);
when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("before-send"),anyString())).thenReturn(1);
@@ -11,14 +11,15 @@ import static org.assertj.core.api.Assertions.assertThat;
class LoadingEvidenceSqlContractTest {
@Test
void schemaOwnsEvidenceByCabinetWithOneActiveFactPerType() throws Exception {
void schemaOwnsMultipleEvidencePhotosByCabinetAndKeepsOperationIdempotency() throws Exception {
String sql = new ClassPathResource("db/phase3-loading-delivery-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS fl_loading_cabinet_evidence")
.contains("container_booking_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("DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci")
.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");
String sql = Files.readString(unified, StandardCharsets.UTF_8);
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("FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking (id)")
.doesNotContain("CREATE TABLE fl_loading_evidence")
@@ -58,7 +58,7 @@ class CabinetLoadingEvidenceTransactionTest {
}).collect(Collectors.joining(","));
jdbc.execute("CREATE TABLE fl_loading_cabinet_evidence(" + columns + ")");
jdbc.execute("ALTER TABLE fl_loading_cabinet_evidence ADD CONSTRAINT uq_operation UNIQUE(source_task_id,operation_key)");
jdbc.execute("ALTER TABLE fl_loading_cabinet_evidence ADD CONSTRAINT uq_seal UNIQUE(seal_no,active_flag)");
jdbc.execute("CREATE INDEX idx_active ON fl_loading_cabinet_evidence(container_booking_id,evidence_type,active_flag)");
String taskColumns = Arrays.stream(LoadingPickTaskEntity.class.getDeclaredFields())
.filter(field -> !Modifier.isStatic(field.getModifiers())).map(field ->
field.getName().replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase() + " "
@@ -98,7 +98,7 @@ class CabinetLoadingEvidenceTransactionTest {
}
@Test
void replacementInsertFailureRollsBackOldActiveRowAndSameRequestCanRetry() {
void appendInsertFailureRetainsOldActiveRowAndSameRequestCanRetry() {
nextFile = 301; upload("original");
var before = snapshot();
jdbc.execute("ALTER TABLE fl_loading_cabinet_evidence ADD CONSTRAINT reject_new_file CHECK(file_id <> 302)");
@@ -108,11 +108,11 @@ class CabinetLoadingEvidenceTransactionTest {
jdbc.execute("ALTER TABLE fl_loading_cabinet_evidence DROP CONSTRAINT reject_new_file");
upload("replacement");
assertThat(mapper.byContainer(99)).hasSize(2).filteredOn(row -> "ACTIVE".equals(row.getStatus()))
.singleElement().satisfies(row -> assertThat(row.getFileId()).isEqualTo(302L));
.extracting(CabinetLoadingEvidenceEntity::getFileId).containsExactly(301L, 302L);
}
@Test
void duplicateKeyAfterStaleReplayReadsMustRollBackPriorVoid() {
void duplicateKeyAfterStaleReplayReadsRetainsAllPhotos() {
nextFile = 301; upload("original");
nextFile = 302; upload("winner");
var before = snapshot();
@@ -167,7 +167,7 @@ class CabinetLoadingEvidenceTransactionTest {
boolean secondPassed = second.get(20, java.util.concurrent.TimeUnit.SECONDS);
assertThat(firstPassed || secondPassed).isTrue();
assertThat(mapper.byContainer(99)).hasSize(2).filteredOn(row -> "ACTIVE".equals(row.getStatus()))
.singleElement().satisfies(row -> assertThat(row.getOperationKey()).isEqualTo("concurrent"));
.extracting(CabinetLoadingEvidenceEntity::getOperationKey).containsExactly("original", "concurrent");
var committed = snapshot();
upload("concurrent");
assertThat(snapshot()).isEqualTo(committed);
@@ -28,6 +28,29 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("货柜数据库映射契约")
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
@DisplayName("货柜映射平台级最终表且状态只从装柜计划读取")
void mapsFinalBookingContract() throws Exception {
@@ -11,7 +11,7 @@ import static org.assertj.core.api.Assertions.assertThat;
class ContainerWorkbenchSqlContractTest {
@Test
void pagesDomesticAndOverseasScopesFromOwnedActualFactsWithoutCopyingShipNodeSql() throws Exception {
void pagesDomesticAndOverseasScopesFromDispatchAndEmptyReturnFacts() throws Exception {
String sql = String.join("\n", ContainerBookingMapper.class
.getMethod("selectBookingPage", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class,
com.baiyue.firstleg.dto.ContainerDtos.Query.class, String.class)
@@ -19,9 +19,10 @@ class ContainerWorkbenchSqlContractTest {
assertThat(sql).contains("#{workbench}='DOMESTIC'", "#{workbench}='OVERSEAS'",
"#{query.scope}='ACTIVE'", "#{query.scope}='HISTORY'", "fl_outbound_dispatch",
"confirmed_at IS NOT NULL", "b.actual_empty_reported_at IS NULL",
"b.actual_empty_reported_at IS NOT NULL", "outbound_dispatch_status")
.doesNotContain("fl_carrier_schedule_query", "fl_carrier_schedule_node_fact");
"confirmed_at IS NOT NULL", "fl_terminal_drayage returned",
"returned.actual_empty_return_at IS NOT NULL", "returned_node.standard_node='EMPTY_RETURNED'",
"returned_node.actual_at IS NOT NULL", "outbound_dispatch_status")
.doesNotContain("fl_carrier_schedule_query", "b.actual_empty_reported_at");
}
@Test
@@ -0,0 +1,50 @@
package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/** 分托结算直接验证实际Mapper,箱序号不代表物理已装托盘。 */
class DropshipSplitRepositoryTest {
/** 已装箱位于序号末尾时,仍只迁移另一托的箱,跨客户同码不受影响。 */
@Test void transfersOnlyUnloadedPhysicalCartons() throws Exception {
var data = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
var db = new JdbcTemplate(data);
db.execute("CREATE TABLE fl_inbound_carton_label(id BIGINT,tenant_id BIGINT,inbound_id BIGINT,order_id BIGINT,inbound_code VARCHAR(50),batch_status VARCHAR(20),total_carton_count INT,detail_package_count INT,box_code VARCHAR(50))");
db.execute("CREATE TABLE fl_dropship_pallet_carton(id BIGINT,tenant_id BIGINT,inbound_id BIGINT,pallet_id BIGINT,carton_label_id BIGINT)");
db.update("INSERT INTO fl_inbound_carton_label VALUES(1,10,31,100,'IN-1','ACTIVE',4,4,'BOX-1'),(2,10,31,100,'IN-1','ACTIVE',4,4,'BOX-2'),(3,10,31,100,'IN-1','ACTIVE',4,4,'BOX-3'),(4,10,31,100,'IN-1','ACTIVE',4,4,'BOX-4'),(5,20,31,200,'IN-2','ACTIVE',1,1,'BOX-1')");
db.update("INSERT INTO fl_dropship_pallet_carton VALUES(11,10,31,802,1),(12,10,31,802,2),(13,10,31,801,3),(14,10,31,801,4),(15,20,31,802,5)");
var config = new MybatisConfiguration(); config.addMapper(DropshipPalletizationMapper.class);
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(data); factory.setConfiguration(config);
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(DropshipPalletizationMapper.class);
new DropshipSplitRepository(mapper).transferRemainder(10,31,32,101,"IN-1-1",2,List.of(801L));
assertThat(db.queryForList("SELECT id FROM fl_inbound_carton_label WHERE inbound_id=32 ORDER BY id",Long.class)).containsExactly(1L,2L);
assertThat(db.queryForList("SELECT id FROM fl_dropship_pallet_carton WHERE inbound_id=32 ORDER BY id",Long.class)).containsExactly(11L,12L);
assertThat(db.queryForObject("SELECT inbound_id FROM fl_inbound_carton_label WHERE id=5",Long.class)).isEqualTo(31L);
assertThat(db.queryForList("SELECT box_code FROM fl_inbound_carton_label WHERE inbound_id=32 ORDER BY id",String.class)).containsExactly("BOX-1","BOX-2");
// 下一柜重新读取封托内容时,货物身份必须归到余量子入库单和余量子订单。
db.execute("CREATE TABLE fl_dropship_pallet(id BIGINT,pallet_no VARCHAR(50),pallet_status VARCHAR(20))");
db.execute("INSERT INTO fl_dropship_pallet VALUES(802,'DP-802','SEALED')");
db.execute("ALTER TABLE fl_dropship_pallet_carton ADD inbound_detail_id BIGINT DEFAULT 41");
db.execute("ALTER TABLE fl_dropship_pallet_carton ADD sku_code VARCHAR(30) DEFAULT 'SKU-1'");
db.execute("ALTER TABLE fl_dropship_pallet_carton ADD cargo_name VARCHAR(30) DEFAULT '货物'");
db.execute("ALTER TABLE fl_dropship_pallet_carton ADD actual_sku_quantity INT DEFAULT 1");
db.execute("CREATE TABLE fl_dropship_pallet_carton_sku(carton_id BIGINT,inbound_detail_id BIGINT,sku_code VARCHAR(30),cargo_name VARCHAR(30),actual_quantity INT)");
db.execute("CREATE TABLE fl_inbound_order(id BIGINT,tenant_id BIGINT,order_id BIGINT,order_no VARCHAR(50),inbound_code VARCHAR(50),warehouse_id BIGINT,channel_code VARCHAR(30),channel_name VARCHAR(30),destination_warehouse_id BIGINT,destination_code VARCHAR(30),destination_name VARCHAR(30),customer_name VARCHAR(30),actual_gross_weight_kg DECIMAL,actual_net_weight_kg DECIMAL,actual_volume_cbm DECIMAL,total_package_count INT)");
db.execute("INSERT INTO fl_inbound_order(id,tenant_id,order_id,order_no,inbound_code,total_package_count) VALUES(32,10,101,'ORDER-1-1','IN-1-1',2)");
var remainder = mapper.sealedPalletLines(802);
assertThat(remainder).hasSize(1);
assertThat(remainder.get(0).getInboundId()).isEqualTo(32L);
assertThat(remainder.get(0).getOrderId()).isEqualTo(101L);
assertThat(remainder.get(0).getInboundDetailId()).isEqualTo(41L);
assertThat(remainder.get(0).getCartonCount()).isEqualTo(2);
}
}
@@ -37,7 +37,8 @@ class FbaPricingFeeTransitionTest {
session.getConnection();
sql = new JdbcTemplate(ds);
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();
ReflectionTestUtils.setField(repository, "inbounds", session.getMapper(InboundPricingMapper.class));
}
@@ -50,7 +51,8 @@ class FbaPricingFeeTransitionTest {
/** 费用确认和发送不得提前完成一件代发组托或回退已有履约进度。 */
@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) {
insertInbound(processStatus, "CALCULATED");
@@ -58,7 +60,7 @@ class FbaPricingFeeTransitionTest {
assertState(processStatus, "CONFIRMED");
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
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();
}
@@ -71,12 +73,10 @@ class FbaPricingFeeTransitionTest {
assertState("WAIT_PALLETIZE", feeStatus);
}
/** 非确认阶段、其他客户和已删除入库单仍不可确认。 */
/** 其他客户和已删除入库单仍不可确认。 */
@Test
void preservesProcessCustomerAndDeletionGuards() {
insertInbound("WAIT_MEASURE", "CALCULATED");
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
sql.update("UPDATE fl_inbound_order SET process_status='WAIT_PALLETIZE' WHERE id=88");
insertInbound("WAIT_PALLETIZE", "CALCULATED");
assertThat(repository.markInboundConfirmed(20002L, 88L)).isZero();
sql.update("UPDATE fl_inbound_order SET deleted=TRUE WHERE id=88");
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
@@ -91,6 +91,64 @@ class FbaPricingFeeTransitionTest {
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) {
sql.update("INSERT INTO fl_inbound_order(id,tenant_id,process_status,fee_status) VALUES(88,10001,?,?)",
@@ -32,9 +32,6 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.util.Collection;
import java.util.List;
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.mockito.Mockito.mock;
@@ -178,7 +175,7 @@ class FbaPricingRepositoryRuleListTest {
}
@Test
void feeSendFromLegacyFlowAdvancesToWaitingPallet() {
void feeSendPreservesFulfillmentAndRequiresApprovedMeasurement() {
when(pricing.update(org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.any()))
.thenReturn(1);
@@ -188,25 +185,32 @@ class FbaPricingRepositoryRuleListTest {
ArgumentCaptor<LambdaUpdateWrapper<InboundPricingEntity>> wrapper =
ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
verify(pricing).update(org.mockito.ArgumentMatchers.isNull(), wrapper.capture());
assertThat(wrapper.getValue().getSqlSet()).contains("feeStatus").contains("processStatus");
assertThat(wrapper.getValue().getCustomSqlSegment()).contains("tenantId", "id", "processStatus", "feeStatus");
assertThat(wrapper.getValue().getSqlSet()).contains("feeStatus").doesNotContain("processStatus");
assertThat(wrapper.getValue().getCustomSqlSegment())
.contains("tenantId", "id", "processStatus", "measureReviewStatus", "feeStatus");
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
void feeConfirmationAllowsPalletizationAndPostLoadingInboundStatuses() {
String source;
try {
source = Files.readString(Path.of(
"src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java"),
StandardCharsets.UTF_8);
} catch (java.io.IOException exception) {
throw new AssertionError(exception);
}
assertThat(source)
.contains("List.of(\"FEE_CONFIRMING\", \"WAIT_PALLET\", \"WAIT_PALLETIZE\", \"WAIT_SPLIT\", \"LOADING\", \"LOADED\", \"DELIVERED\")");
when(pricing.update(org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.any()))
.thenReturn(1);
assertThat(repository.markInboundConfirmed(10001L, 88L)).isEqualTo(1);
@SuppressWarnings("unchecked")
ArgumentCaptor<LambdaUpdateWrapper<InboundPricingEntity>> wrapper =
ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
verify(pricing).update(org.mockito.ArgumentMatchers.isNull(), wrapper.capture());
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
@@ -45,7 +45,8 @@ class Phase3FinalUnifiedSqlContractTest {
void unifiedEntryKeepsFinalConstraintsAndImmutableTriggers() throws Exception {
String sql = 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_inbound_one_active_remainder")
.contains("UNIQUE KEY uk_inbound_split_source_once")
@@ -165,6 +165,7 @@ class FbaPricingServiceTest {
}
@Test
/** 试算生成费用但保留原履约进度。 */
@DisplayName("包税KG按生效价格表计费且不依赖客户规则或承运商")
void calculatesTaxIncludedKgWithoutCustomerRuleOrCarrier() {
readyRepository();
@@ -186,7 +187,7 @@ class FbaPricingServiceTest {
assertThat(response.conversionFactor()).isEqualByComparingTo("167");
assertThat(response.roundingScale()).isEqualTo(6);
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);
verify(repository).insertFee(fee.capture());
assertThat(fee.getValue().getTaxMode()).isEqualTo("BUY_DECLARATION");
@@ -560,6 +561,34 @@ class FbaPricingServiceTest {
org.mockito.Mockito.verifyNoInteractions(priceBookEstimator);
}
@Test
void manualQuoteFreezesDatabaseMillisecondPrecisionForExactReplay() {
readyRepository();
InboundPricingEntity inbound = inbound("62.500", "0.3840");
inbound.setDestinationType("PRIVATE");
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(301L);
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(measurementRepository.findLatestMeasurement(TENANT, INBOUND)).thenReturn(measurement);
when(measurementRepository.listItems(TENANT, 301L)).thenReturn(List.of(new InboundMeasurementItemEntity()));
when(repository.completeIdempotency(any(), eq(501L), any())).thenReturn(1);
Clock original = (Clock) org.springframework.test.util.ReflectionTestUtils.getField(service, "clock");
var preciseClock = Clock.fixed(Instant.parse("2026-08-03T02:00:00.123456789Z"), ZoneId.of("Asia/Shanghai"));
org.springframework.test.util.ReflectionTestUtils.setField(service, "clock", preciseClock);
try {
var result = service.manualQuote(TENANT, OPERATOR, INBOUND,
new ManualQuoteRequest(new BigDecimal("62.500"), new BigDecimal("0.3840"),
BigDecimal.ONE, "私人地址测试人工报价"), "private-precision");
assertThat(result.calculatedAt().getNano()).isEqualTo(123000000);
ArgumentCaptor<InboundFeeEntity> fee = ArgumentCaptor.forClass(InboundFeeEntity.class);
verify(repository).insertFee(fee.capture());
assertThat(fee.getValue().getCreatedAt()).isEqualTo(result.calculatedAt());
assertThat(fee.getValue().getUpdatedAt()).isEqualTo(result.calculatedAt());
} finally {
org.springframework.test.util.ReflectionTestUtils.setField(service, "clock", original);
}
}
@Test
@DisplayName("已有匹配价格时拒绝绕过价格表创建人工报价")
void rejectsManualQuoteWhenRateExists() {
@@ -602,7 +631,7 @@ class FbaPricingServiceTest {
/** 待组托发送费用保留主状态,重复请求不重复生成费用事件。 */
@ParameterizedTest
@CsvSource({"FEE_CONFIRMING,WAIT_PALLET", "WAIT_PALLETIZE,WAIT_PALLETIZE"})
@CsvSource({"FEE_CONFIRMING,FEE_CONFIRMING", "WAIT_PALLETIZE,WAIT_PALLETIZE"})
@DisplayName("重复发送只返回首次结果且不会重复写Outbox")
void sendIsIdempotentAndDoesNotDuplicateOutbox(String processStatus, String expectedStatus) {
InboundPricingEntity inbound = inbound("10", "1");
@@ -659,6 +688,39 @@ class FbaPricingServiceTest {
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
@DisplayName("同一幂等键用于不同试算时点会冲突")
void rejectsReusedIdempotencyKeyWithDifferentRequest() {
@@ -101,6 +101,61 @@ class LoadingCompletedOutboxServiceTest {
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 事件,并保持冻结柜号原值。 */
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"JZXH10231043", "MSCU6639871", "ABC", "LOCAL-2026-001"})
@@ -189,13 +244,46 @@ class LoadingCompletedOutboxServiceTest {
verify(manifests, org.mockito.Mockito.never()).labelPool(11L, 100L);
}
@Test void privateAddressFreezesAsOverseasWithOriginalCartonIdentity() throws Exception {
Row value = row(11, 100, 101);
value.setDestinationType("PRIVATE");
value.setDestinationWarehouseId(null);
value.setDestinationWarehouseCode(null);
value.setDestinationWarehouseName("私人地址");
when(snapshots.rows(90L)).thenReturn(List.of(value));
service.publish(90L);
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(outbox).insert(captor.capture());
EventEnvelope event = mapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class);
assertThat(event.payload().at("/orders/0/destinationType").asText()).isEqualTo("OVERSEAS");
assertThat(event.payload().at("/orders/0/destinationWarehouseId").isNull()).isTrue();
assertThat(event.payload().at("/orders/0/destinationWarehouseCode").isNull()).isTrue();
assertThat(event.payload().at("/orders/0/destinationWarehouseName").asText()).isEqualTo("私人地址");
assertThat(event.payload().at("/orders/0/items/0/sourceInboundDetailId").asText()).isEqualTo("101");
assertThat(event.payload().at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU101");
assertThat(event.payload().at("/orders/0/items/0/cartonMarks/0").asText()).isEqualTo("BOX-101");
verify(manifests).labelPool(11L, 100L);
}
@Test void rejectsMixedPrivateAndOverseasDestinationsInOneInbound() {
Row privateAddress = row(11, 100, 101);
privateAddress.setDestinationType("PRIVATE");
when(snapshots.rows(90L)).thenReturn(List.of(privateAddress, row(11, 100, 102)));
assertThatThrownBy(() -> service.publish(90L)).hasMessageContaining("目的类型必须唯一");
verify(outbox, org.mockito.Mockito.never()).insert(
org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class));
}
@Test void rejectsUnfrozenDestinationType() {
Row value = row(11, 100, 101);
value.setDestinationType(null);
when(snapshots.rows(90L)).thenReturn(List.of(value));
assertThatThrownBy(() -> service.publish(90L))
.hasMessageContaining("目的类型必须唯一且为 FBA 或 OVERSEAS");
.hasMessageContaining("目的类型必须唯一且为 FBA、OVERSEAS 或 PRIVATE");
verify(outbox, org.mockito.Mockito.never()).insert(
org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class));
}
@@ -46,6 +46,7 @@ class PdaPickSettlementServiceTest {
@MockBean private OrderFulfillmentSplitClient orderSplits;
@MockBean private InboundHoldGuard inboundHoldGuard;
@MockBean private DropshipPalletizationMapper dropshipPallets;
@MockBean private com.baiyue.firstleg.repository.DropshipSplitRepository dropshipSplits;
@Resource private PdaPickSettlementService service;
@Test
@@ -266,6 +267,29 @@ class PdaPickSettlementServiceTest {
&& 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
void onlyPureSkippedInboundRestoresCandidate() {
LoadingPickItemEntity item = item(12L, 22L, 32L, 42L, 8, 0, "SKIPPED");
@@ -148,14 +148,13 @@ class SplitPreloadServiceTest {
}
@Test
void ordinaryLoadingAllocationStillRequiresWaitingSplit() {
void otherCabinetMayReuseExistingPreloadWhileSourceIsLoading() {
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 3));
PreloadPlanEntity plan = new PreloadPlanEntity();
plan.setStatus("PENDING");
when(repository.findPlan(10, 20)).thenReturn(plan);
assertThatThrownBy(() -> service.ensurePendingForLoadingAllocation(10, 20, 7, "admin"))
.hasMessageContaining("仅待分柜订单");
service.ensurePendingForLoadingAllocation(10, 20, 7, "admin");
verify(repository, never()).createPlan(any());
}
@@ -36,7 +36,7 @@ class WorkflowBoundaryTest {
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class).hasCause(failure);
}
/** 报关完成或提前上传资料仍留在出口报关,实际开船后才查询精确海外来源。 */
/** 未开船也查询尾程建单事实,未产生尾程来源时仍保留国内阶段。 */
@Test void completedCustomsAndClearanceUploadWaitForActualDeparture() {
var repository=mock(WorkflowFactRepository.class);
var downstream=mock(LastlegWorkflowClient.class);
@@ -45,12 +45,15 @@ class WorkflowBoundaryTest {
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,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");
verifyNoInteractions(downstream);
verify(downstream).read(any(),any());
clearInvocations(downstream);
when(repository.facts(List.of(1L))).thenReturn(List.of(
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");
verifyNoInteractions(downstream);
verify(downstream).read(any(),any());
clearInvocations(downstream);
when(repository.facts(List.of(1L))).thenReturn(List.of(
new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,true,true)));
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)))));
}
/** 理货审核建单后,未补录开船与清关资料也必须返回出库待办。 */
@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))
.contains("SIGNED").doesNotContain("EXPORT_CUSTOMS","WAIT_CUSTOMS_DOCS");
}
/** 清关前不能被海外建单事实提前推进,清关后出库与派送依次替换待办。 */
/** 出库与派送按尾程事实展示,不受清关资料限制。 */
@Test void downstreamFactsCannotBypassClearanceAndOutboundReplacesQueues() {
var pending=new WorkflowFacts.Overseas(11,1,true,true,false,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",shipped)).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("DELIVERING","SAILING_CLEARANCE");
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),"DELIVERED",shipped)).containsExactly("SIGNED");
@@ -7,9 +7,12 @@ import com.baiyue.integration.dto.CarrierScheduleDtos.ExceptionObservation;
import com.baiyue.integration.provider.CarrierScheduleProviderAdapter;
import com.baiyue.integration.provider.CompliantCarrierScheduleProviderAdapter;
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.util.List;
import java.util.Locale;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -25,21 +28,22 @@ class CarrierScheduleProviderServiceTest {
assertThat(result.exceptions()).isEmpty();
}
@Test
void onlyDispatchesNormalizedContainerNumberTracking() {
@ParameterizedTest
@ValueSource(strings = {"ZCSU7881970", "ZCSU7881971", "LOCAL-2026-001"})
void onlyDispatchesNormalizedContainerNumberTracking(String containerNo) {
CarrierScheduleProviderAdapter capturing = new CarrierScheduleProviderAdapter() {
@Override public boolean supports(String carrierCode) { return true; }
@Override public QueryResult query(QueryRequest request) {
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());
}
};
var service = new CarrierScheduleProviderService(List.of(capturing));
service.query(new QueryRequest("ZIM", "CONTAINER_NO", "ZCSU7881970"));
assertThatThrownBy(() -> service.query(new QueryRequest("ZIM", "CONTAINER_NO", "zcsu7881970")))
service.query(new QueryRequest("ZIM", "CONTAINER_NO", containerNo));
assertThatThrownBy(() -> service.query(new QueryRequest("ZIM", "CONTAINER_NO", containerNo.toLowerCase(Locale.ROOT))))
.hasMessageContaining("规范化真实集装箱号");
assertThatThrownBy(() -> service.query(new QueryRequest("ZIM", "CONTAINER_NO", "ZCSU7881971")))
assertThatThrownBy(() -> service.query(new QueryRequest("ZIM", "CONTAINER_NO", " " + containerNo + " ")))
.hasMessageContaining("规范化真实集装箱号");
assertThatThrownBy(() -> service.query(new QueryRequest("ZIM", "BILL_OF_LADING", "BL-1")))
.hasMessageContaining("CONTAINER_NO");
+7 -6
View File
@@ -44,7 +44,7 @@
| 自动生成托盘、资源生成失败与重试 | `inboundplan/service/ContainerInboundPlanConversionCoordinator.java` 的 `convertManualAndAutoMaterialize`;详细规则定向查 `docs/business-index.md` 的“人工计划自动生成托盘与资源重试” |
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
| 跨客户同仓预约、日历 | `InboundPlanController.java`;在途/外接货跟进与覆盖见`appointmentplanning/` |
| PDA 入仓、柜号签收、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java` → `inboundplan/pda/service/ReceivingContainerService.java`;门禁与状态定向查 `docs/business-index.md` 的“PDA 入仓、实收、上架与完成” |
| PDA 入仓、私人地址收货、实收与上架完成 | `inboundplan/pda/controller/ReceivingContainerController.java` → `inboundplan/pda/service/ReceivingContainerService.java`;门禁与状态定向查 `docs/business-index.md` 的“PDA 入仓、实收、上架与完成” |
| PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` |
| PDA 国内装柜照片对照 | `ReceivingContainerController.java` |
| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java` |
@@ -56,7 +56,8 @@
| 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/` |
| PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java` |
| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java` |
| 内部散板需求只读来源 | `dispatch/service/DispatchCapacityDemandService.java`、`DispatchQueryController.java`的`/dispatches/capacity-demands`;发布字段仅随智能排车创建保存且缺省不发布,编辑命令不接收该字段;重发须按原门禁取消排车再重新创建,当前业务口径见拼车主契约,不恢复旧车次人工发布入口 |
| 散板需求列表与货物登记 | `dispatch/service/DispatchCapacityDemandService.java`、`dispatch/controller/LooseboardCargoController.java`;列表与加货接口分开,发布规则查拼车主契约 |
| 排车散板提货/派送执行与取证 | `dispatch/controller/DispatchLooseboardExecutionController.java`、`dispatch/service/DispatchLooseboardExecutionService.java`;契约 `src/main/resources/openapi/dispatch-looseboard-execution-v1.yaml` |
| 平台排车查询、整车四块详情、审核与实载 | `dispatch/controller/DispatchQueryController.java`、`DispatchAggregateController.java`;公开契约`dispatch/port/`,业务规则见`../../docs/海外仓业务/排车列表统一作业改造契约.md` |
| 草稿、双端装车发运 | `dispatch/controller/SmartDispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`;平台维护`DispatchController.java`;自动发运编排`dispatch/service/DispatchLoadingDepartureService.java` |
| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java` |
@@ -65,7 +66,7 @@
| 货物废弃、待审锁箱、实际确认扣减 | `discard/controller/CargoDiscardController.java` |
| 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java` |
| 派送三类资料、摘要与历史证据 | `delivery/controller/DeliveryDocumentController.java` |
| 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java` |
| 异常回仓建单与 PDA 分批实收 | `exceptionorder/controller/ExceptionOrderController.java`、`PdaExceptionReturnController.java`;契约 `src/main/resources/openapi/exception-return-inbound-v1.yaml` |
| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/` |
| 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java` |
| 海外业务费用来源、费用明细审核与重试 | `financefee/controller/PlatformOverseasFeeSourceController.java`、`financefee/service/OverseasManualFeeService.java`;目录入口 `financefee/controller/PlatformOverseasFeeCatalogController.java`,细则查详细索引同主题 |
@@ -74,10 +75,10 @@
| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java` |
| 海外仓报价、月度汇率、车辆主数据 | `masterdata/quote/` |
| 司机档案、证照上传/审核、资格预览与历史 | `fleet/driver/PlatformDriverController.java`、`DriverService.java`;OpenAPI `fleet-drivers-v1.yaml`,表 `ll_fleet_driver` / `ll_fleet_driver_history`;资格规则 `fleet/domain/DriverQualificationPolicy.java` |
| 车辆运维:保养、加油、维修、故障、救援、异常 | `fleet/maintenance/PlatformVehicleCareController.java`、`VehicleCareService.java`;合同`fleet-vehicle-records-v1.yaml`。按车/类型隔离,单位/币种明确,处理不等付款;创建/附件幂等,更正留版本历史,不改主档里程/状态。维修救援经`financefee/fleet/VehicleCareFinanceAdapter`入公司成本,Finance为付款权威,未关联UNLINKED,不生成客户应收 |
| 车次计划、车/司机分配与占用、客户排车关联 | `fleet/trip/PlatformFleetTripController.java`、`FleetTripService.java`;OpenAPI `fleet-trips-v1.yaml`;`FleetDispatchGateway` 由 `dispatch/fleet/DispatchFleetAdapter.java` 实现,`FleetDispatchBindingGuard` 控制子单独立修改/发车;车辆通过 `VehicleRepository` 和 `VehicleAssignmentGuard` 协作,fleet不直读车辆持久化适配器 |
| 车辆运维:保养、加油、维修、故障、救援、异常 | `fleet/maintenance/PlatformVehicleCareController.java`、`VehicleCareService.java`;合同 `fleet-vehicle-records-v1.yaml`。维修救援经 `financefee/fleet/VehicleCareFinanceAdapter` 入公司成本;Finance 为付款权威,未关联为 UNLINKED,不生成客户应收;权限、幂等与更正见详细索引同主题 |
| 车次计划、车/司机占用与排车关联 | `fleet/trip/PlatformFleetTripController.java`、`FleetTripService.java`;协作入口 `dispatch/fleet/DispatchFleetAdapter.java`、`FleetDispatchBindingGuard`、`VehicleAssignmentGuard`,不穿透车辆持久化;契约 `fleet-trips-v1.yaml` |
| 整车调度与理货审核 | `dispatch/controller/DispatchAggregateController.java` → `dispatch/service/DispatchAggregateService.java`;两级审核、依据失效与零扫码退回规则查当前排车主契约 |
| 外接询价、确认占位及取消 | `fleet/trip/PlatformFleetBookingController.java`、`FleetBookingService.java`、`FleetBookingReservations.java`;客户从`fleet/customer/`读取IAM有效主档与资格,询价不占位,确认后与自有货计划共同校验逐段容量。约定金额不代表应收/实收,`FleetBookingIncomingGateway`由`transfer/fleet/FleetIncomingAdapter`实现来货登记/实收确认,排车关联通过`FleetDispatchGateway.sourceCargo`核对实收来源 |
| 外接询价、确认占位及取消 | `fleet/trip/PlatformFleetBookingController.java`、`FleetBookingService.java`、`FleetBookingReservations.java`;来源、实收及资金边界查 `fleet-trips-v1.yaml` 与详细索引同主题 |
| 拣货前计划容量与逐段余位 | `FleetTripExecutionService.capacityPlan/confirmCapacityPlan`、`/fleet/trips/{tripNo}/capacity-plan`;保存自有货的计划值,不检查拣货完成、不推进审核或扣库存;`planValid`校验当前货物hash,不把计划值当实测值,资源/计划/关联变更后须重新核对 |
| 实物装载、逐段容量与双端整车发车 | `dispatch/service/DispatchLoadingDepartureService.java`、`DispatchLoadingDocumentService.java`;双端使用 `recordVersion`;装车确认、照片齐全后的自动派送、容量与幂等只查当前排车主契约,无公开 `/depart` |
| 车次货物结案、车辆和司机自动释放 | `fleet/trip/FleetTripCompletionService.java`、`FleetCompletionModels.java`;平台 `/fleet/trips/{tripNo}/completion`、`/complete`、`/resources/release`,独立complete/release权限。结案核对所有派送终态与实发数量;全车派送完成自动结案释放车辆和司机,提货全部到仓及本土卡车完成同事务释放;不等待二次确认,不改变车辆主档状态,收款不触发释放 |
+3 -2
View File
@@ -32,11 +32,12 @@
| 独立尾程入仓单快照 | `entity/LastlegInboundOrder.java`、`mapper/LastlegInboundOrderMapper.java`;按客户与来源入仓标识冻结 inboundCode、来源/根订单及目的仓,不以尾程订单行代替入仓单聚合 |
| 内部令牌保护 | `security/InternalServiceTokenGuard.java` |
| 统一认证与撤销 | 复用 common-security 的Redis会话及Auth/IAM权威指纹校验,故障拒绝访问;Lastleg不保存独立授权投影 |
| 入仓计划分页、柜级预览、转换与详情 | 平台 `inboundplan/controller/InboundPlanController.java`,客户只读 `inboundplan/controller/MerchantInboundPlanController.java`;共用 `inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`ContainerInboundPlanPreviewResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java`;客户入口固定 `/api/merchant/v1/last-leg/inbound-plans`,范围只取 Token。`/container-preview` 按全柜返回 FBA/一件代发/私人地址分组、托数、就绪、阻断项及 `snapshotVersion/snapshotHash`;`ContainerInboundPlanConversionCoordinator.java` 用唯一柜级 Saga 锁柜重算,先经 `WarehouseResourceGateway` 批量预留真实托盘,再本地原子生成剩余独立计划并确认资源,本地失败只补偿未确认资源,远端确认失败的重放只重试确认,同柜不同幂等键也不得产生第二批资源 |
| 入仓计划分页、柜级预览、转换与详情 | 平台 `inboundplan/controller/InboundPlanController.java`,客户只读 `inboundplan/controller/MerchantInboundPlanController.java`;共用 `inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`ContainerInboundPlanPreviewResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java`;客户入口固定 `/api/merchant/v1/last-leg/inbound-plans`,范围只取 Token。`/container-preview` 按全柜返回 FBA/一件代发/私人地址分组、托数、就绪、阻断项及 `snapshotVersion/snapshotHash`;私人地址可无目的仓 ID/编码,票内地址名称须非空且一致,托盘分配按来源订单独立归属,接柜仓仍须有效绑定;`ContainerInboundPlanConversionCoordinator.java` 用唯一柜级 Saga 锁柜重算,先经 `WarehouseResourceGateway` 批量预留真实托盘,再本地原子生成剩余独立计划并确认资源,本地失败只补偿未确认资源,远端确认失败的重放只重试确认,同柜不同幂等键也不得产生第二批资源 |
| 托盘分配算法 | `inboundplan/service/PalletAllocator.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接口,不生成第二个托盘码 |
| 普通散货收货短少确认 | `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/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` |
@@ -60,7 +61,7 @@
| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;客户入口按 `merchant:cross-border:procurement:list/create` 拆分且只取 Token 客户范围;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
| 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java`、`WalletCommandCompletedConsumer.java`、`WalletResultHandler.java`;采购、换标、人工海外费用和仓租通过 `WALLET_COMMAND_REQUESTED` 请求 Finance 冻结、结算或释放,并以 `WALLET_COMMAND_COMPLETED` 幂等回写业务资金状态;不得同步调用 Finance 钱包或在 Lastleg 自建余额账本 |
| 海外业务费用来源 | `financefee/controller/PlatformOverseasFeeSourceController.java`、`financefee/service/OverseasManualFeeService.java`、`OverseasFeeSourceService.java`、`OverseasFeeBusinessGuard.java`、`OverseasFeeOutboxService.java`;费用必须匹配当前客户的真实服务单,公共费用目录通过 Firstleg 内部接口读取。头程附加费审核后进入账单;海外人工应收创建先冻结、审核后扣款,驳回或取消释放,扣款成功后仅登记本地已收来源,不再推送财务账单;采购、换标原业务收费仍按其独立资金身份处理。头程账单费用及供应商应付经直接 Outbox 投递 Finance,余额应收仅通过资金指令取得扣款回执,OpenAPI `src/main/resources/openapi/overseas-finance-fees-v1.yaml`。新菜单列表权限与财务编辑、审核按钮权限分离;本地检查不能代替真实资金、账单联调 |
| 自动仓租及出库资金门禁 | `storagerent/`;实际 PDA 收货建立原计租批次,头程自有海外仓目的地与仓储转运用中转阶梯,退货用原托阶梯;每24小时进一天,中转部分出库按原批次份额计费,退货原托余一箱仍按整托计费,同托同计租日不重复收取,余货不重置库龄。退货已计费截止日与实际出库、费用生成同事务推进,后续只收新增库龄;并发推进后旧预占必须释放重建。实际出库命令前单独提交资金预校验,冻结成功后才允许扣库存并生成待审核仓租,订单费用与海外费用管理展示实际入库、出库、原计算额及实扣额;审核前可调价且说明必填,批准后沿原冻结按实额结算,驳回释放冻结;全部仓租使用余额及授信。客户仓租配置优先,无配置按报价表默认,内部查询失败不得回退。预占保存配置版本、完整阶梯、USD/CNY 原币及结算汇率快照,实际扣款不读最新价格。发布及真实验收清单见 `docs/overseas-fees-storage-rent-release.md` |
| 自动仓租及出库资金门禁 | `storagerent/`;实际 PDA 收货建立原计租批次,头程自有海外仓目的地与仓储转运用中转阶梯,退货用原托阶梯;每24小时进一天,头程无逐箱测量时按原批次份额计费;仓储转运的排车、正常出库及废弃按实际箱长宽高汇总CBM计费,部分出库需明确箱唛,缺尺寸或来源不符则阻断,冻结快照保存实箱与体积;退货原托余一箱仍按整托计费,同托同计租日不重复收取,余货不重置库龄。退货已计费截止日与实际出库、费用生成同事务推进,后续只收新增库龄;并发推进后旧预占必须释放重建。实际出库命令前单独提交资金预校验,冻结成功后才允许扣库存并生成待审核仓租,订单费用与海外费用管理展示实际入库、出库、原计算额及实扣额;审核前可调价且说明必填,批准后沿原冻结按实额结算,驳回释放冻结;全部仓租使用余额及授信。客户仓租配置优先,无配置按报价表默认,内部查询失败不得回退。预占保存配置版本、完整阶梯、USD/CNY 原币及结算汇率快照,实际扣款不读最新价格。发布及真实验收清单见 `docs/overseas-fees-storage-rent-release.md` |
| 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java`、`MerchantUsTruckOrderController.java` 及同子域 Service/DTO;客户入口按 `merchant:cross-border:domestic-trucking:list/create/edit` 拆分 |
| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java`、`MerchantLocalLooseboardController.java` 及同子域 Service/DTO/Entity/Mapper;客户入口按 `merchant:cross-border:pallet-shipping:list/create/edit` 拆分;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
| 海外仓报价、月度汇率、车辆主数据 | 报价 `masterdata/quote/`、汇率 `masterdata/exchange/`;车辆入口 `masterdata/vehicle/adapter/in/web/PlatformVehicleController.java`,应用服务 `application/VehicleApplicationService.java`,持久化端口与适配器 `application/port/out/VehicleRepository.java`、`adapter/out/persistence/`;OpenAPI `src/main/resources/openapi/overseas-master-data-v1.yaml`,车辆档案不含派送履约历史 |
@@ -18,6 +18,8 @@
头程同单同仓多次实际入库合并为一个计租批次,统一从首次实际入库起算;补入库只追加箱数和计费体积,不重置库龄、免费期或已出库记录。单位体积沿用首次入库比例,后续修改订单体积不改写既有计租比例。其他业务保留各批实际入库时间分别计龄。头程未逐箱清点时,部分出库按来源单据箱数比例估算体积,审核前可调整金额并填写原因。本次不合并历史已存在的计租批次。
仓储转运库存的正常出库、排车及废弃按本次实际箱长×宽×高÷1,000,000汇总CBM,再乘原入库库龄对应的客户阶梯累计单价,不按原批次箱数平均分摊体积。部分出库必须指明实际箱唛;整托可按该仓位全部可追溯实箱求和。缺少尺寸、箱数不符或无法核对原实收批次时阻断预校验。箱唛与实测体积一并固化在冻结快照,变化后须重新预校验。正常出库同事务保存实箱SHIPPED状态,废弃由原作业保存DISCARDED状态,避免后续重复计租。历史已生成费用保持原快照,不自动重算。本次修改不需要新增数据库字段,尚未发布到测试环境。
| 规则 | 计费阶梯(USD) | 数量口径 |
|---|---|---|
| 中转仓租 | 第1–7天免费;8–30天0.8;31–60天1;61–90天1.5;91天起2 | CBM/天,按阶梯累加 |
@@ -35,7 +35,7 @@ public interface DispatchCandidatePageMapper {
}
/** 在UNION前统一文本列,避免表字段、CAST及聚合结果采用不同collation而报1271;数值与时间保持原类型。 */
private static String column(String field,String expression) {
return (TEXT_FIELDS.contains(field)?"CONVERT("+expression+" USING utf8mb4) COLLATE utf8mb4_unicode_ci":expression)+" AS "+field;
return (TEXT_FIELDS.contains(field)?"CONVERT("+expression+" USING utf8mb4) COLLATE utf8mb4_0900_ai_ci":expression)+" AS "+field;
}
/** 不同来源用同一组筛选和稳定行标识组合,排序前排除未选来源类型。 */
private static String rows(boolean count) {
@@ -11,8 +11,8 @@ public interface DispatchWholePdaReadMapper {
record Picked(Long itemId,long packages) { }
record Invalid(Long dispatchId,long quantity) { }
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>"
+"<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);
@@ -54,10 +54,11 @@ public final class DispatchViews {
List<DispatchDeliveryModels.DeliveryReference> deliveries,
Instant createdAt, Instant updatedAt, String remark) { }
/** 散板按客户和商业订单汇总;来源键集合仅供只读明细关联,不替代作业来源身份。 */
public record OrderDetail(String customerId, String customerName, String orderNo, String inboundCode,
BigDecimal grossWeightKg, Integer palletCount, String destinationFbaWarehouse,
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,
@@ -6,11 +6,12 @@ import java.util.List;
/** 客户照片仅查询已核准客户与原单的有效承运关联。 */
@Repository
public class MerchantTransportPhotoRepository {
public class MerchantTransportPhotoRepository implements com.baiyue.lastleg.looseboard.port.LooseboardDispatches {
private final MerchantTransportPhotoMapper 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> truckDispatches(String customer,String number){return mapper.truckDispatches(customer,number);}
@@ -962,8 +962,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
return batches.stream().collect(Collectors.toMap(
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);
return new DispatchViews.FbaAppointment(b.batchKey(),b.batchNo(),confirmed==null?null:confirmed.confirmationNo(),b.destinationKey(),b.warehouseCode(),
confirmed==null?"PENDING":"SUCCESS",confirmed==null?null:confirmed.startAt(),confirmed==null?"尚无当前有效收货确认":null,b.customerId(),List.of());
return DispatchDestinationAppointments.booked(b,confirmed);
}).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));
}
/** 混托多订单无法真实分摊重量体积时保持未知,不能给每票复制整托总量。 */
/** 散板同客户同商业订单只展示一行,逐托来源仍保留用于原作业和运营明细关联。 */
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());
for(var item:cargo) {
var source=read(item.getSourceSnapshotJson(),SourceDisplay.class);
@@ -1240,11 +1239,46 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
if(orders.isEmpty()) orders=Collections.singletonList(null);
for(var order:orders) {
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,
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()));
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(),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权威资源、本趟照片和维护资料,不写入车辆或司机主档。 */
@@ -1308,14 +1342,14 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
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 void requireRemoteCapacity(DispatchAggregate row,List<LoadingMeasurement> measured){
var ids=remoteItemIds(row);if(ids.isEmpty())return;
var remote=repository.cargo(row.getId()).stream().filter(i->ids.contains(i.getId())).toList();
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 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 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).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("仓内装载与异地取货合计超过本车容量");
}
/** 取货照片保存事务复验已确认仓内实载与当前异地申报。 */
@@ -26,13 +26,13 @@ final class DispatchDestinationAppointments {
if (maintenance != null) for (var entry : maintenance.destinationAppointments()) entered.put(entry.destinationKey(),entry.appointmentNo());
var result = new ArrayList<FbaAppointment>();
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 enteredNumber = entered.get(key);
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 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();
result.add(new FbaAppointment(null,null,number,key,code,number == null ? "PENDING" : at == null ? "REGISTERED" : "SUCCESS",at,
number == null ? "请填写目的仓 " + code + " 的预约号" : null,null,shipments));
@@ -40,6 +40,15 @@ final class DispatchDestinationAppointments {
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) {
if (maintenance == null) return;
@@ -57,7 +57,7 @@ public class DispatchLooseboardExecutionService {
if(!(phase==Phase.PICKUP?item.canPickup():item.canArrive()))throw conflict("当前阶段不能上传该节点照片");
return files.upload(required(number).getId(),kind(phase),actor.operatorId(),file);
}
/** 父锁、公共版本、不可变命令和照片推进在同一事务保存;失败不留下假发车。 */
/** 同客户散板原单的各托照片原子保存,公共版本与命令身份仍由整车统一控制。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public Detail save(Actor actor,String number,long itemId,Phase phase,Save request){
workflow.lockForArrangement(actor,number);var row=required(number);
@@ -69,7 +69,9 @@ public class DispatchLooseboardExecutionService {
}
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 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(request.fileIds().size()>20 || new HashSet<>(request.fileIds()).size()!=request.fileIds().size())throw conflict("每个节点最多保存20张不重复照片");
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("已确认取货或送达的节点至少保留一张凭证,可先追加新照片再删除旧照片");
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 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());
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("照片已绑定其他节点或明细,不能重复使用");
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->!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();
if(photos.stream().anyMatch(f->!Set.of("image/jpeg","image/png").contains(f.contentType())))throw conflict("取货和送达凭证必须为照片");
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));
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(!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)){
@@ -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());
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);
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;}
@@ -26,7 +26,7 @@ public class DispatchPdaQueryService {
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public DispatchViews.PdaTask task(Actor actor,String number) {
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不推算实收仓位或箱数。 */
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();
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>();
// 纯异地取货没有仓内拣货,不向PDA推送空任务。
if(items.isEmpty())continue;
boolean sourceValid=true;var expectedHold=new HashMap<String,Long>();
for(var source:frozen)if(Objects.equals(source.getDispatchOrderId(),root.getId())) {
long used=consumed.getOrDefault(source.getId(),0L),left=n(source.getPackageCount())-used;
@@ -7,8 +7,8 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import java.util.*;
import static com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.*;
/** 已发布日常派送排车复用正式加货命令,不创建第二车次或库存。 */
@Service
@@ -16,8 +16,10 @@ public class LooseboardCargoService {
private final LooseboardCargoRepository cargo;
private final DispatchAggregateRepository dispatches;
private final DispatchAggregateService service;
public LooseboardCargoService(LooseboardCargoRepository cargo, DispatchAggregateRepository dispatches, DispatchAggregateService service) {
this.cargo=cargo; this.dispatches=dispatches; this.service=service;
private final DispatchCommandHasher hashes;
/** 加货候选复用权威来源及版本摘要,不在客户端拼造来源。 */
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) {
var row=dispatches.find(number);
@@ -25,12 +27,37 @@ public class LooseboardCargoService {
throw new ResponseStatusException(HttpStatus.CONFLICT,"该排车已退出散板需求池,请刷新列表");
return row.getOverseasWarehouseId();
}
/** 按完整散板订单分页;同单各托组成一次选择,不能因托盘跨页漏选。 */
@Transactional(readOnly=true)
public DispatchPlanningModels.CandidateList candidates(DispatchContracts.Actor actor,String number,int page,int size) {
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.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
public DispatchContracts.CommandResult add(DispatchContracts.Actor actor,String number,DispatchCommands.AddCargo request) {
@@ -14,6 +14,10 @@ public interface ExceptionWorkflowMapper {
/** 本车可登记的派送单,客户跟随真实派送记录。 */
@Select("SELECT customer_id,delivery_no FROM ll_delivery_order WHERE dispatch_order_id=#{id} AND status IN ('DELIVERING','REJECTED','DELIVERED') ORDER BY id")
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")
ExceptionOrder lockWarehouse(@Param("warehouse") String warehouse, @Param("number") String number);
@@ -24,11 +28,14 @@ public interface ExceptionWorkflowMapper {
@Select("""
SELECT e.exception_no,p.vehicle_plate,e.customer_name,e.status,e.package_count,
COALESCE((SELECT SUM(i.received_packages) FROM ll_exception_return_item i
WHERE i.exception_order_id=e.id AND i.customer_id=e.customer_id),0) received_packages,
WHERE i.exception_order_id=e.id
AND i.customer_id COLLATE utf8mb4_0900_ai_ci=e.customer_id COLLATE utf8mb4_0900_ai_ci),0) received_packages,
r.order_no return_order_no
FROM ll_exception_order e JOIN ll_order r ON r.customer_id=e.customer_id
AND r.source_region='EXCEPTION_RETURN' AND r.source_inbound_id=CAST(e.id AS CHAR)
JOIN ll_delivery_order d ON d.id=e.delivery_order_id AND d.customer_id=e.customer_id
FROM ll_exception_order e JOIN ll_order r ON r.customer_id COLLATE utf8mb4_0900_ai_ci=e.customer_id COLLATE utf8mb4_0900_ai_ci
AND r.source_region='EXCEPTION_RETURN'
AND r.source_inbound_id=CAST(e.id AS CHAR CHARACTER SET utf8mb4) COLLATE utf8mb4_0900_ai_ci
JOIN ll_delivery_order d ON d.id=e.delivery_order_id
AND d.customer_id COLLATE utf8mb4_0900_ai_ci=e.customer_id COLLATE utf8mb4_0900_ai_ci
LEFT JOIN ll_dispatch_order p ON p.id=d.dispatch_order_id
WHERE e.overseas_warehouse_id=#{warehouse} AND e.status='PROCESSING'
AND e.exception_type='RETURN' AND e.return_direction='OVERSEAS_WAREHOUSE'
@@ -24,6 +24,10 @@ public class ExceptionWorkflowRepository {
}
/** 查询本车派送归属。 */
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); }
/** 保存人工完结后的异常状态,保留关联单据和实际库存事实。 */
@@ -36,17 +36,24 @@ public class DispatchExceptionAdapter implements DispatchExceptionPort {
if(candidates.isEmpty())throw new ResponseStatusException(HttpStatus.CONFLICT,"本车没有可关联的派送来源");
if(warehouse) {
var seen=new HashSet<String>();
var grouped=new LinkedHashMap<Candidate,List<ExceptionOrderRequests.MarkReturn>>();
for(int index=0;index<selected.size();index++) {
var selection=selected.get(index);
if(selection==null || selection.items()==null || selection.items().isEmpty() || selection.items().size()>200 || !seen.add(selection.deliveryNo()))
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"每张派送单请选择一张来源订单及有效退回货物");
if(selection==null || selection.items()==null || selection.items().isEmpty() || selection.items().size()>200
|| !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()
.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,"退回明细无效");
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(),
"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,
new ExceptionOrderRequests.Create(source.customerId(),null,source.lastMileOrderNo(),"REFUSED".equals(request.exceptionType().name())?"REJECT":"OTHER",reason,null,null));
}
@@ -216,7 +216,7 @@ public class ExceptionOrderService {
ExceptionOrderResponses.Relation relation = relation(scopedCustomerId, request.customerId(),
request.inboundCode(), request.lastMileOrderNo());
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());
if (replay != null) return replayCreated(replay, requestHash);
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);
ExceptionOrderResponses.Relation selected = candidates.stream()
.filter(candidate -> Objects.equals(candidate.lastMileOrderId(), request.lastMileOrderId()))
@@ -248,7 +285,7 @@ public class ExceptionOrderService {
}
ExceptionOrderResponses.Created created = insert(selected, delivery, "RETURN", request.reason(),
packages, sku, returnDirection, request.returnLogisticsNo(),
"DELIVERY_RETURN", operatorId, operatorName);
"DELIVERY_RETURN", operatorId, operatorName, originalDeliveryStatus);
for (ExceptionOrderRequests.ReturnItem requestedItem : requested) {
ExceptionOrderResponses.ReturnSource source = available.stream()
@@ -284,7 +321,7 @@ public class ExceptionOrderService {
return created;
}
/** Caller keeps the delivery row locked until return creation commits. */
/** 调用方持有派送行锁,禁止活动退回批次之间重叠冻结。 */
DeliveryOrder lockForReturn(String customerId, String deliveryNo) {
DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo);
if (delivery == null) throw notFound("派送单不存在");
@@ -461,7 +498,7 @@ public class ExceptionOrderService {
private ExceptionOrderResponses.Created insert(ExceptionOrderResponses.Relation relation, DeliveryOrder delivery,
String type, String reason, Integer packages, String sku,
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()) {
throw conflict("异常箱数不能大于订单箱数 " + relation.availablePackages());
}
@@ -482,7 +519,8 @@ public class ExceptionOrderService {
value.setReturnLogisticsNo(trim(logisticsNo));
if (delivery != null) {
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())));
}
if (StringUtils.hasText(direction)) {
@@ -695,6 +733,7 @@ public class ExceptionOrderService {
private record ReturnContext(String deliveryStatus, Long tailId, String tailStatus, Boolean abnormal) { }
/** 取消只恢复本原单;同批仍有未取消退回时继续保留派送退回中状态。 */
private void restoreCancelledReturn(ExceptionOrder order) {
if (!"RETURN".equals(order.getExceptionType()) || !StringUtils.hasText(order.getDeliveryNo())) return;
ReturnContext previous;
@@ -706,7 +745,8 @@ public class ExceptionOrderService {
if (delivery == null || !"RETURN_PENDING".equals(delivery.getStatus())) throw conflict("关联派送状态已变化,不能取消退回");
if (returnItems.findByException(order.getCustomerId(), order.getId()).stream().anyMatch(item -> ReturnReceiptPolicy.received(item) > 0))
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);
if (previous.tailId() != null) {
LastlegOrder tail = lastMileOrders.selectById(previous.tailId());
@@ -0,0 +1,169 @@
package com.baiyue.lastleg.export;
import java.util.Arrays;
import java.util.Locale;
import java.util.Map;
import java.util.stream.Collectors;
/** 导出按任务类型及字段转换中文;仓码、SKU、币种与自由文本原样保留。 */
final class LastlegExportLabels {
/** 中文导出映射仅提供静态转换。 */
private LastlegExportLabels() { }
private static final Map<String, String> WORKFLOW = Map.ofEntries(
Map.entry("PENDING_INBOUND", "待入库"),
Map.entry("PENDING_REVIEW", "待审核"),
Map.entry("PENDING_RECEIPT", "待签收入库"),
Map.entry("PARTIALLY_RECEIVED", "部分入库"),
Map.entry("INBOUND_COMPLETED", "全部入库"),
Map.entry("WAIT_PDA_RECEIVING", "入库测量中"),
Map.entry("INBOUND_MEASURING", "入库测量中"),
Map.entry("WAIT_CUSTOMER_CONFIRMATION", "待客户确认实测"),
Map.entry("APPROVED", "审核通过"),
Map.entry("PENDING_ASSIGNMENT", "待分配"),
Map.entry("WAIT_RELABEL", "待换标"),
Map.entry("WAIT_FBA_DESTINATION", "待确认目的仓"),
Map.entry("READY_FOR_DISPATCH", "待创建出库单"),
Map.entry("WAIT_OUTBOUND", "待出库"),
Map.entry("ALLOCATED", "已分配库存"),
Map.entry("OUTBOUNDED", "已出库"),
Map.entry("RECEIVED", "已收货"),
Map.entry("IN_TRANSIT", "运输中"),
Map.entry("DESTINATION_ASSIGNED", "已分配目的仓"),
Map.entry("INBOUND_PLANNED", "已生成入库计划"),
Map.entry("PENDING_PROCESSING", "待处理"),
Map.entry("PROCESSING", "处理中"),
Map.entry("COMPLETED", "已完成"),
Map.entry("REJECTED", "已驳回"),
Map.entry("CANCELLED", "已取消"),
Map.entry("DRAFT", "草稿"),
Map.entry("PARTIAL_INBOUND", "部分入库"),
Map.entry("SIGNED", "已签收"),
Map.entry("PICKING", "拣货中"),
Map.entry("PICKED", "已拣货"),
Map.entry("SHIPPED", "已出库"),
Map.entry("WAIT_DELIVERY", "待派送"),
Map.entry("PENDING_DELIVERY", "待派送"),
Map.entry("DELIVERING", "派送中"),
Map.entry("DELIVERED", "派送完成"),
Map.entry("RETURNED", "已退回"),
Map.entry("CUSTOMER_CONFIRM_PENDING", "待客户确认"),
Map.entry("READY_FOR_OUTBOUND", "待出库"),
Map.entry("NOT_REQUIRED", "无需换标"));
private static final Map<String, String> INVENTORY_BUSINESS = Map.ofEntries(
Map.entry("IN_TRANSIT", "在途入账"),
Map.entry("STOCK_IN", "入库"),
Map.entry("STOCK_IN_TRANSIT_RELEASE", "在途库存转入"),
Map.entry("HOLD", "库存暂扣"),
Map.entry("RELEASE", "解除暂扣"),
Map.entry("ALLOCATE", "库存分配"),
Map.entry("UNALLOCATE", "取消分配"),
Map.entry("PICK", "拣货"),
Map.entry("SHIP", "出库"),
Map.entry("ADJUST", "库存调整"),
Map.entry("STOCKTAKE_ADJUST", "盘点调整"),
Map.entry("RECEIVING_WAREHOUSE_CHANGED", "变更接仓点"),
Map.entry("INTERNAL_MOVE", "库内移库"),
Map.entry("RELABEL_PALLET_DISASSEMBLE", "换标拆托"),
Map.entry("PALLET_ASSEMBLE", "组托"),
Map.entry("PALLET_DISASSEMBLE", "拆托"),
Map.entry("PALLET_SWAP", "换托"),
Map.entry("PALLET_UNBIND", "解托"),
Map.entry("RETURN_STOCK_IN", "退回入库"),
Map.entry("DISCARD", "货物废弃"));
private static final Map<String, String> ORDER_TYPES = Map.ofEntries(
Map.entry("ONE_CLICK_DROPSHIP", "一件代发"),
Map.entry("FIRST_LEG", "头程订单"),
Map.entry("RELABEL_APPLICATION", "换标下单"),
Map.entry("WAREHOUSE_TRANSFER", "仓储转运"),
Map.entry("RETURN_RELABEL", "退货换标"),
Map.entry("DELIVERY_RETURN", "派送异常退回"),
Map.entry("LOCAL_LOOSEBOARD", "本土散板"),
Map.entry("CARGO_DISCARD", "货物废弃订单"),
Map.entry("REJECTED", "拒收订单"),
Map.entry("EXPRESS", "快递订单"));
private static final Map<String, String> NODES = Map.ofEntries(
Map.entry("WAIT_LOADING", "待装柜"),
Map.entry("LOADING", "装柜中"),
Map.entry("WAIT_CUSTOMS", "待出口报关"),
Map.entry("WAIT_OUTBOUND", "待国内出仓"),
Map.entry("DEPARTED_WAREHOUSE", "已离开国内仓库"),
Map.entry("IN_TRANSIT", "海运途中"),
Map.entry("ARRIVED_DESTINATION_PORT", "船舶已到目的港"),
Map.entry("WAITING_FOR_ARRIVAL", "待到港"),
Map.entry("ARRIVED", "已到港"),
Map.entry("PICKUP_READY", "已清关、可提柜"),
Map.entry("PICKUP_SCHEDULED", "已预约提柜"),
Map.entry("PICKED_UP", "已提柜"),
Map.entry("UNLOADED", "待还柜"),
Map.entry("EMPTY_RETURNED", "已还柜"),
Map.entry("AT_ORIGIN_PORT", "重箱已进入起运港"),
Map.entry("LOADED_ON_VESSEL", "已装船"),
Map.entry("DEPARTED_ORIGIN_PORT", "船舶已离开起运港"),
Map.entry("DISCHARGED_FROM_VESSEL", "已卸船、未清关"));
private static final Map<String, String> INVENTORY_STATUS = Map.ofEntries(
Map.entry("OUTSIDE", "仓外"),
Map.entry("UNBOUND", "未绑托"),
Map.entry("IN_TRANSIT", "在途"),
Map.entry("RECEIVING", "收货中"),
Map.entry("AVAILABLE", "可用"),
Map.entry("HOLD", "暂扣"),
Map.entry("HELD", "暂扣"),
Map.entry("ALLOCATED", "已分配"),
Map.entry("PICKED", "已拣货"),
Map.entry("SHIPPED", "已出库"),
Map.entry("DISCARDED", "已废弃"),
Map.entry("STOCK_IN", "已入库"),
Map.entry("EMPTY", "空置"),
Map.entry("OCCUPIED", "占用"));
/** 只转换明确的业务枚举列,避免将编号、币种和备注误当作状态。 */
static Object value(String type, String key, Object value) {
if (!(value instanceof String text) || text.isBlank()) return value;
return switch (type) {
case "LAST_MILE_ORDER_LIST" -> switch (key) {
case "currentStatus" -> label(text, WORKFLOW, "未知状态");
case "orderTypeName", "businessSource.label" -> label(text, ORDER_TYPES, "未知业务类型");
case "containerNode" -> label(text, NODES, "未知货柜节点");
case "relabelStatus" -> label(text, WORKFLOW, "未知换标状态");
case "palletMergeFlag" -> label(text, Map.of("REQUIRED", "待合托", "MERGED", "已合托"), "未标记");
case "subOrderStatus" -> subOrders(text);
default -> value;
};
case "OVERSEAS_INVENTORY_LEDGER" -> switch (key) {
case "businessType" -> label(text, INVENTORY_BUSINESS, "未知业务类型");
case "statusFrom", "statusTo" -> label(text, INVENTORY_STATUS, "未知库存状态");
default -> value;
};
case "OVERSEAS_QUOTE_LIST", "MONTHLY_EXCHANGE_RATE_LIST", "FLEET_QUOTE_LIST" -> switch (key) {
case "status" -> label(text, Map.of("ACTIVE", "启用", "INACTIVE", "停用", "DELETED", "已删除"), "未知状态");
case "details.kind" -> label(text, Map.of("PURCHASE", "车队采购报价", "SALE", "外接销售报价"), "未知用途");
default -> value;
};
case "VEHICLE_LIST" -> switch (key) {
case "status" -> label(text, Map.of("IN_SERVICE", "在役", "DISPATCHED", "外派中", "MAINTENANCE", "维修中", "INACTIVE", "停用", "RETURNED", "已归还"), "未知状态");
case "ownership" -> label(text, Map.of("OWNED", "自有", "DISPATCHED", "外派"), "未知车辆归属");
case "region" -> label(text, Map.of("DOMESTIC", "国内", "OVERSEAS", "海外"), "未知区域");
default -> value;
};
case "TRIP_OPERATIONS_LIST" -> "status".equals(key)
? label(text, Map.of("PLANNED", "计划中", "IN_TRANSIT", "运输中", "COMPLETED", "货物已结案", "CANCELLED", "已取消"), "未知状态") : value;
default -> value;
};
}
/** 子单状态可能由多个“业务类型:状态”组成,分别翻译而不破坏分组。 */
private static String subOrders(String value) {
Map<String, String> kinds = Map.of("INBOUND_PLAN", "入库单", "OUTBOUND", "出库单", "DELIVERY", "派送单", "RELABEL", "换标单", "WAREHOUSE_TRANSFER", "仓储转运", "RETURN_RELABEL", "退货换标");
return Arrays.stream(value.split("[;;]"))
.map(part -> {
String[] pair = part.split(":", 2);
return pair.length == 2 ? label(pair[0], kinds, "子单") + ":" + label(pair[1], WORKFLOW, "未知状态")
: label(part, WORKFLOW, "未知状态");
}).collect(Collectors.joining(";"));
}
/** 中文历史快照保持原文,未知英文枚举使用中文占位。 */
private static String label(String value, Map<String, String> labels, String fallback) {
return labels.getOrDefault(value.trim().toUpperCase(Locale.ROOT), value.matches(".*[一-鿿].*") ? value : fallback);
}
}
@@ -47,6 +47,7 @@ public class LastlegExportProviderService {
@Resource private Validator validator;
@Value("${baiyue.security.internal-service-token}") private String signingKey;
/** 校验平台导出快照,复用列表筛选并生成中文业务数据批次。 */
public ExportBatchResponse batch(String type, ExportBatchRequest request) {
if (request == null || !"PLATFORM".equals(request.appCode()) || !"0".equals(request.tenantId())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅支持平台导出");
@@ -81,7 +82,7 @@ public class LastlegExportProviderService {
case "TRIP_OPERATIONS_LIST" -> operations.report(query(parameters, TripOperationsModels.Query.class), costVisible).page();
default -> throw invalid("不支持该导出类型");
};
List<Map<String, Object>> rows = page.records().stream().map(value -> row(value, columns)).toList();
List<Map<String, Object>> rows = page.records().stream().map(value -> row(type, value, columns)).toList();
int next = offset + rows.size();
if (rows.isEmpty() && next < page.total()) throw invalid("导出分页未推进");
return new ExportBatchResponse(1, hash(columns), snapshot, first ? columns : List.of(), rows,
@@ -98,7 +99,8 @@ public class LastlegExportProviderService {
} catch (IllegalArgumentException e) { throw invalid("导出筛选条件无效"); }
}
private Map<String, Object> row(Object value, List<ExportColumn> columns) {
/** 按显式列投影业务数据,仅对指定枚举字段进行中文转换。 */
private Map<String, Object> row(String type, Object value, List<ExportColumn> columns) {
JsonNode source = json.valueToTree(value);
Map<String, Object> result = new LinkedHashMap<>();
for (ExportColumn column : columns) {
@@ -106,12 +108,11 @@ public class LastlegExportProviderService {
for (String part : column.key().split("\\.")) node = node.path(part);
Object cell = node.isMissingNode() || node.isNull() ? null
: node.isBoolean() ? node.booleanValue() : node.isNumber() ? node.decimalValue() : node.asText();
result.put(column.key(), cell);
result.put(column.key(), LastlegExportLabels.value(type, column.key(), cell));
}
if (value instanceof com.baiyue.lastleg.dto.LastlegOrderSummary order) {
result.put("abnormal", Boolean.TRUE.equals(order.abnormal()) ? "是" : "否");
result.put("detained", Boolean.TRUE.equals(order.detained()) ? "是" : "否");
result.put("currentStatus", com.baiyue.lastleg.model.LastlegOrderStatus.labelOf(order.currentStatus()));
}
if (value instanceof TripOperationsModels.Row trip) {
result.put("destinations", trip.destinations().stream().map(TripOperationsModels.Destination::label).collect(Collectors.joining(" / ")));
@@ -34,29 +34,32 @@ public class FleetHandoverPdaQuery {
public record Status(String dispatchNo, String tripNo, String tripStatus, String tripRecordVersion,
FleetTransitEvidence.Transport originalDeparture, List<Delivery> deliveries, Handover latestHandover) { }
/** 交接只读范围按原发车仓内实载核对,异地取货量不冒充仓库派送。 */
public Status status(long warehouseId, String dispatchNo) {
var binding = bindings.findByDispatchNo(dispatchNo);
if (binding == null) throw notFound();
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 (!List.of("IN_TRANSIT", "COMPLETED").contains(trip.getStatus()))
throw new ResponseStatusException(HttpStatus.CONFLICT, "尚未发车,请按原拣货发车流程作业");
var evidence = dispatches.transitEvidence(dispatchNo, false);
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>();
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()
|| !dispatchNo.equals(row.cargo().dispatchNo()) || row.cargo().deliveryNo()==null
|| !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())
.eq(FleetTripHistory::getAction, "DEPART"));
if (original.size() != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "缺少唯一原始发车记录");
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();
requireWarehouseCargo(dispatchNo,binding,evidence,originalSnapshot);
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(),
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);
} 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) {
return new FleetTransitEvidence.Transport(resource.vehiclePlate(), resource.driverName(), resource.driverPhone());
}
@@ -56,8 +56,9 @@ public final class OwnedBusinessModels {
String recordVersion,String dispatchRecordVersion,PhotoSummary photoSummary,Actions actions,List<String> sourceIssues,String taskType) {}
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 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 History(String eventSource,String recordVersion,String dispatchRecordVersion,String action,String operatorName,String occurredAt,String reason,
Operations operations,List<Photo> photos) {}
@@ -5,6 +5,7 @@ import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Search;
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.dispatch.port.DispatchDetailReader;
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
@@ -68,14 +69,37 @@ public class OwnedBusinessQueryService {
public Detail detailForWrite(Actor actor,Source source) {
return detailForWrite(actor,source,OwnedBusinessScope.OWNED);
}
/** 写回执也执行固定归属校验,调用者已核对本域独立写权限。 */
/** 写回执也执行固定归属校验;散板明细复用原排车公开的商业订单归属。 */
public Detail detailForWrite(Actor actor,Source source,OwnedBusinessScope scope) {
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 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(),
c.getOrderNo(),c.getInboundCode(),c.getPalletNo(),projection.destinationKey(c),String.valueOf(c.getPackageCount()))).toList(),
projection.photos(repository.record(source.getId())));
var looseboardOrders=new HashMap<List<String>,String>();
if(original!=null && original.orderDetails()!=null)for(var order:original.orderDetails()) {
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) {
@@ -6,6 +6,15 @@ import java.util.List;
/** 每趟只取首次真实发车快照;取货动作记录不能冒充仓库装车。 */
@Mapper
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("""
<script>
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)
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)
ELSE NULL END AS transport_facts
ELSE (SELECT JSON_OBJECT('taskType','PHOTO_LOOSEBOARD','packages',
JSON_EXTRACT(dh.snapshot_json,'$.shippedPackageCount'))
FROM ll_dispatch_order d JOIN ll_dispatch_history dh ON dh.dispatch_id=d.id
WHERE d.trip_id=t.id AND dh.action='LOOSEBOARD_DELIVERING'
AND JSON_EXTRACT(dh.snapshot_json,'$.departedAt') IS NOT NULL
AND JSON_UNQUOTE(JSON_EXTRACT(dh.snapshot_json,'$.departedAt'))!='null'
AND (JSON_EXTRACT(dh.snapshot_json,'$.loadingCompletedAt') IS NULL
OR JSON_UNQUOTE(JSON_EXTRACT(dh.snapshot_json,'$.loadingCompletedAt'))='null')
AND JSON_EXTRACT(dh.snapshot_json,'$.pickedPackageCount')=0
AND JSON_EXTRACT(dh.snapshot_json,'$.shippedPackageCount') &gt; 0
ORDER BY dh.record_version,dh.id LIMIT 1) END AS transport_facts
FROM ll_fleet_trip t
WHERE t.departed_at &gt;= #{q.from} AND t.departed_at &lt; #{q.to}
WHERE t.departed_at &gt;= #{q.from,typeHandler=com.baiyue.lastleg.fleet.reporting.TripOperationsUtcInstantTypeHandler}
AND t.departed_at &lt; #{q.to,typeHandler=com.baiyue.lastleg.fleet.reporting.TripOperationsUtcInstantTypeHandler}
<if test='q.warehouseId != null'>AND t.warehouse_id=#{q.warehouseId}</if>
<if test='q.status != null and q.status != ""'>AND t.status=#{q.status}</if>
ORDER BY t.departed_at DESC,t.id DESC LIMIT 5001

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