Compare commits
46
Commits
4165dc3403
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ab5411e53 | ||
|
|
c71a065dda | ||
|
|
e132c3c80a | ||
|
|
f4901d155c | ||
|
|
87bb905283 | ||
|
|
1611c42d28 | ||
|
|
e86bdcd623 | ||
|
|
3cc748974f | ||
|
|
6d8a69b995 | ||
|
|
99f896848e | ||
|
|
08b28c72e7 | ||
|
|
f571e107e4 | ||
|
|
51ea89e399 | ||
|
|
d37522081b | ||
|
|
cc9bcbed95 | ||
|
|
7cd764e14a | ||
|
|
f4362f6dfd | ||
|
|
9c8273d9a0 | ||
|
|
e995a87b0e | ||
|
|
51c3132195 | ||
|
|
c6bbb22a02 | ||
|
|
d722bcae49 | ||
|
|
9e99658bfa | ||
|
|
44202a7bff | ||
|
|
ef2ac1eafd | ||
|
|
08ea663334 | ||
|
|
8809801070 | ||
|
|
497544e9fd | ||
|
|
83c3b02d96 | ||
|
|
d1ade7e9dd | ||
|
|
97c80574df | ||
|
|
b063d7866c | ||
|
|
5a8918e2e4 | ||
|
|
94a36b8c4c | ||
|
|
51291b4c27 | ||
|
|
0993dd446e | ||
|
|
fbdf58c23c | ||
|
|
40475388e1 | ||
|
|
22e8939f1f | ||
|
|
a01598bd21 | ||
|
|
0d8e64c42c | ||
|
|
8b56af2cae | ||
|
|
12617e35a0 | ||
|
|
46bc137e99 | ||
|
|
00120df2eb | ||
|
|
9af1eba876 |
@@ -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 || {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# 仅由 TEST 部署入口叠加,固定 JVM 堆并为线程、元空间和直接内存保留余量。
|
||||
x-test-limits: &test-limits
|
||||
mem_limit: 1536m
|
||||
memswap_limit: 1536m
|
||||
environment: &test-java-environment
|
||||
JAVA_OPTS: "-XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8"
|
||||
JAVA_TOOL_OPTIONS: "-Xms128m -Xmx768m"
|
||||
|
||||
services:
|
||||
gateway:
|
||||
<<: *test-limits
|
||||
|
||||
auth:
|
||||
<<: *test-limits
|
||||
|
||||
iam:
|
||||
<<: *test-limits
|
||||
|
||||
order:
|
||||
<<: *test-limits
|
||||
|
||||
firstleg:
|
||||
<<: *test-limits
|
||||
mem_limit: 2g
|
||||
memswap_limit: 2g
|
||||
environment:
|
||||
<<: *test-java-environment
|
||||
JAVA_TOOL_OPTIONS: "-Xms256m -Xmx1g"
|
||||
|
||||
finance:
|
||||
<<: *test-limits
|
||||
|
||||
warehouse:
|
||||
<<: *test-limits
|
||||
|
||||
warehouse-overseas-service:
|
||||
<<: *test-limits
|
||||
|
||||
integration-service:
|
||||
<<: *test-limits
|
||||
|
||||
audit-service:
|
||||
<<: *test-limits
|
||||
|
||||
file:
|
||||
<<: *test-limits
|
||||
mem_limit: 3g
|
||||
memswap_limit: 3g
|
||||
environment:
|
||||
<<: *test-java-environment
|
||||
JAVA_TOOL_OPTIONS: "-Xms1g -Xmx2g"
|
||||
|
||||
print:
|
||||
<<: *test-limits
|
||||
environment:
|
||||
<<: *test-java-environment
|
||||
JAVA_TOOL_OPTIONS: "-Xms512m -Xmx1g"
|
||||
|
||||
lastleg-service:
|
||||
<<: *test-limits
|
||||
mem_limit: 3g
|
||||
memswap_limit: 3g
|
||||
environment:
|
||||
<<: *test-java-environment
|
||||
JAVA_TOOL_OPTIONS: "-Xms512m -Xmx1536m"
|
||||
@@ -12,6 +12,7 @@ IMAGE_TAG="$3"
|
||||
SERVICE="$4"
|
||||
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
@@ -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;
|
||||
|
||||
/*
|
||||
* 船期日历的事实契约。
|
||||
|
||||
+3
-3
@@ -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 '商业客户隔离范围';
|
||||
|
||||
+2
-2
@@ -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='采购费用目标更正与逐笔资金事实关联';
|
||||
|
||||
+5
-5
@@ -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测量现场照片引用';
|
||||
|
||||
+3
-3
@@ -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='异常关闭逐来源剩余货物最终去向,不产生新发运';
|
||||
|
||||
+31
-31
@@ -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;
|
||||
|
||||
+1
-1
@@ -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 '成功创建的业务单号';
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
-- 同一派送可按多张原订单生成退回异常;活动批次由派送行锁和来源数量校验保护。
|
||||
-- 只调整索引,不修改异常、签收、库存或资金事实;重复执行跳过已完成项。
|
||||
USE baiyue_lastleg;
|
||||
|
||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'll_exception_order' AND index_name = 'idx_ll_exception_delivery_return'),
|
||||
'ALTER TABLE ll_exception_order ADD KEY idx_ll_exception_delivery_return (customer_id, delivery_order_id, exception_type)', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'll_exception_order' AND index_name = 'uk_ll_exception_delivery_return'),
|
||||
'ALTER TABLE ll_exception_order DROP INDEX uk_ll_exception_delivery_return', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
@@ -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换标时为空';
|
||||
|
||||
+1
-1
@@ -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 ;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+1180
-1180
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -22,7 +22,7 @@
|
||||
| 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;`FINANCE_VOUCHER` 按客户与业务 ID 精确校验;`OVERSEAS_UNLOAD_PHOTO` 是允许同柜多客户的柜级物理证据,固定平台范围 `tenantId=0` 并按接柜会话 ID 精确校验 |
|
||||
| 公司车辆付款凭证 | `src/main/resources/openapi/finance-vehicle-voucher-v1.yaml`;类型`FINANCE_VEHICLE_VOUCHER`,固定平台0和Finance费用ID,仅非空JPG/PNG、10MiB及签名校验;不复用客户凭证或维修照片 |
|
||||
| 海外异常订单证据 | 通用内部业务文件入口;固定 `businessType=OVERSEAS_EXCEPTION_EVIDENCE`、`tenantId=异常订单所属客户ID`、`businessId=异常订单ID`,File 只校验文件复合归属,异常状态、完成门禁和证据引用归 Lastleg |
|
||||
| 平台司机驾驶证 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-driver-license-v1.yaml`;`FLEET_DRIVER_LICENSE` 固定平台 `tenantId=0`、`businessId=driverId`,仅非空 PDF/JPG/PNG、最大 10MiB并校验签名;Lastleg维护审核、当前/历史附件关联及读取权限 |
|
||||
| 平台司机驾驶证 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-driver-license-v1.yaml`;`FLEET_DRIVER_LICENSE` 固定平台 `tenantId=0`、`businessId=driverId`,上传仅非空 JPG/PNG、最大 10MiB并校验签名;Lastleg维护审核、当前/历史附件关联及读取权限 |
|
||||
| 车辆保养加油照片 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-vehicle-record-photo-v1.yaml`;`FLEET_VEHICLE_RECORD_PHOTO` 固定平台0及运维记录ID,仅非空JPG/PNG、最大10MiB并校验签名;Lastleg校验车辆/记录/附件关联与权限,不使用车牌作为文件归属键 |
|
||||
| 出车实测凭证 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-trip-operation-evidence-v1.yaml`;`FLEET_TRIP_OPERATION_EVIDENCE`固定平台0及车次ID,PDF/JPG/PNG、10MiB与签名校验;Lastleg管理实测版本引用及读写权限,附件选填 |
|
||||
| 海外换标 PDF 资料 | 通用内部业务文件入口,契约 `src/main/resources/openapi/overseas-relabel-document-v1.yaml`;`OVERSEAS_RELABEL_TOTAL_DOCUMENT` 按 `application.id`、`OVERSEAS_RELABEL_ITEM_DOCUMENT` 按 `item.id` 归属,必须指定明确客户与正数业务 ID;仅非空 PDF、最大 10MiB,校验 MIME 和文件签名。File 按客户、类型、业务 ID、文件 ID 复合校验,Lastleg 校验申请/明细关系、客户归属、权限和业务状态 |
|
||||
|
||||
@@ -9,7 +9,7 @@ import java.util.Arrays;
|
||||
enum BusinessFileCategory {
|
||||
FLEET_VEHICLE_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
FLEET_DRIVER_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
FLEET_DRIVER_LICENSE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
FLEET_DRIVER_LICENSE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ info:
|
||||
description: >-
|
||||
复用InternalBusinessFileController,固定平台tenantId=0、businessType=FLEET_DRIVER_LICENSE、
|
||||
businessId=司机内部ID。Lastleg负责司机存在性、列表/上传权限、关联和历史可见性;
|
||||
File校验内部服务令牌、平台复合归属、真实非空文件、MIME与签名。最大10MiB,仅PDF/JPG/PNG。
|
||||
File校验内部服务令牌、平台复合归属、真实非空文件、MIME与签名。上传最大10MiB,仅JPG/PNG。
|
||||
上传只生成不可变对象,不自动通过资质审核,不生成公开URL,旧附件通过Lastleg历史引用读取。
|
||||
security:
|
||||
- InternalServiceToken: []
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.file.entity.FileObjectEntity;
|
||||
import com.baiyue.file.mapper.FileObjectMapper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class CustomerVisitBusinessFileTest {
|
||||
private static final String TYPE = "OVERSEAS_CUSTOMER_VISIT";
|
||||
private static final long MAX_SIZE = 20L * 1024 * 1024;
|
||||
@TempDir Path root;
|
||||
private BusinessFileStorage storage;
|
||||
private FileObjectMapper files;
|
||||
private InternalBusinessFileService service;
|
||||
|
||||
/** 初始化文件元数据映射,供真实归属查询使用。 */
|
||||
@BeforeAll
|
||||
static void initializeMapping() {
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "visit-files"),
|
||||
FileObjectEntity.class);
|
||||
}
|
||||
|
||||
/** 使用真实本地存储,隔离数据库写入。 */
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
storage = new BusinessFileStorage();
|
||||
ReflectionTestUtils.setField(storage, "configuredRoot", root);
|
||||
storage.initializeRoot();
|
||||
files = mock(FileObjectMapper.class);
|
||||
service = new InternalBusinessFileService();
|
||||
ReflectionTestUtils.setField(service, "files", files);
|
||||
ReflectionTestUtils.setField(service, "storage", storage);
|
||||
}
|
||||
|
||||
/** 页面允许的全部回访资料均可保存,并保持客户及回访记录的精确归属。 */
|
||||
@ParameterizedTest
|
||||
@CsvSource(delimiter = '|', value = {
|
||||
"pdf|application/pdf", "doc|application/msword", "docx|application/octet-stream",
|
||||
"xls|application/vnd.ms-excel", "xlsx|application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"ppt|application/vnd.ms-powerpoint", "pptx|application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"jpg|image/jpeg", "jpeg|image/jpeg", "png|image/png", "webp|image/webp"
|
||||
})
|
||||
void uploadsAllSupportedFormatsWithCustomerAndVisitOwnership(String extension, String contentType) {
|
||||
var file = new MockMultipartFile("file", "回访资料." + extension, contentType, new byte[]{1, 2, 3});
|
||||
when(files.insert(any(FileObjectEntity.class))).thenAnswer(call -> {
|
||||
FileObjectEntity row = call.getArgument(0);
|
||||
row.setId(801L);
|
||||
return 1;
|
||||
});
|
||||
|
||||
var metadata = service.upload(1001L, TYPE, "71", 9L, file);
|
||||
|
||||
assertThat(metadata.ownerScope()).isEqualTo("CUSTOMER");
|
||||
assertThat(metadata.tenantId()).isEqualTo(1001L);
|
||||
assertThat(metadata.businessType()).isEqualTo(TYPE);
|
||||
assertThat(metadata.businessId()).isEqualTo("71");
|
||||
assertThat(metadata.fileId()).isEqualTo(801L);
|
||||
assertThat(metadata.fileName()).isEqualTo(file.getOriginalFilename());
|
||||
assertThat(metadata.contentType()).isEqualTo(contentType);
|
||||
assertThat(metadata.sizeBytes()).isEqualTo(3L);
|
||||
var persisted = ArgumentCaptor.forClass(FileObjectEntity.class);
|
||||
verify(files).insert(persisted.capture());
|
||||
assertThat(storage.read(persisted.getValue().getObjectKey())).containsExactly(1, 2, 3);
|
||||
}
|
||||
|
||||
/** MIME 缺失或扩展名大写不应阻断有效的 Office 回访资料。 */
|
||||
@Test
|
||||
void acceptsOfficeFileWithoutBrowserMimeType() {
|
||||
var file = new MockMultipartFile("file", "回访记录.DOCX", null, new byte[]{1});
|
||||
var stored = storage.store(1001L, "71", file, BusinessFileCategory.require(TYPE));
|
||||
assertThat(stored.contentType()).isEqualTo("application/octet-stream");
|
||||
assertThat(stored.objectKey()).endsWith(".docx");
|
||||
}
|
||||
|
||||
/** 回访资料单独使用 20MiB 限制,不放宽其他业务附件的限制。 */
|
||||
@Test
|
||||
void enforcesNonemptyAndTwentyMibBoundary() {
|
||||
var category = BusinessFileCategory.require(TYPE);
|
||||
assertThatThrownBy(() -> storage.store(1001L, "71", new MockMultipartFile("file", new byte[0]), category))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不能为空");
|
||||
var oversized = mock(MultipartFile.class);
|
||||
when(oversized.isEmpty()).thenReturn(false);
|
||||
when(oversized.getSize()).thenReturn(MAX_SIZE + 1);
|
||||
assertThatThrownBy(() -> storage.store(1001L, "71", oversized, category))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("20MB");
|
||||
var boundary = new MockMultipartFile("file", "回访.doc", "application/msword", new byte[(int) MAX_SIZE]);
|
||||
assertThat(storage.store(1001L, "71", boundary, category).size()).isEqualTo(MAX_SIZE);
|
||||
assertThat(BusinessFileCategory.CUSTOMS_DOCUMENT.maxSizeBytes()).isEqualTo(10L * 1024 * 1024);
|
||||
}
|
||||
|
||||
/** 不接受回访入口约定之外的文件或双扩展名可执行文件。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"readme.txt", "archive.zip", "visit.pdf.exe", "visit"})
|
||||
void rejectsUnsupportedFormatsBeforeMetadataIsWritten(String name) {
|
||||
var file = new MockMultipartFile("file", name, "application/octet-stream", new byte[]{1});
|
||||
assertThatThrownBy(() -> service.upload(1001L, TYPE, "71", 9L, file))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("仅支持");
|
||||
verifyNoInteractions(files);
|
||||
}
|
||||
|
||||
/** 回访资料必须归属明确客户和有效回访记录。 */
|
||||
@Test
|
||||
void rejectsPlatformScopeAndInvalidVisitId() {
|
||||
var file = new MockMultipartFile("file", "visit.pdf", "application/pdf", new byte[]{1});
|
||||
assertThatThrownBy(() -> service.upload(0L, TYPE, "71", 9L, file))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("必须归属明确客户");
|
||||
assertThatThrownBy(() -> service.upload(1001L, TYPE, "0", 9L, file))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("业务ID必须有效");
|
||||
verifyNoInteractions(files);
|
||||
}
|
||||
|
||||
/** 保存回访时按客户、类型、回访记录和文件 ID 校验,不能串用其他资料。 */
|
||||
@Test
|
||||
void metadataRequiresEveryOwnershipKey() {
|
||||
var value = new FileObjectEntity();
|
||||
value.setId(801L);
|
||||
value.setTenantId(1001L);
|
||||
value.setBusinessType(TYPE);
|
||||
value.setBusinessId("71");
|
||||
when(files.selectOne(any())).thenAnswer(call -> {
|
||||
LambdaQueryWrapper<FileObjectEntity> query = call.getArgument(0);
|
||||
assertThat(query.getSqlSegment()).contains("tenant_id", "business_type", "business_id", "id", "status", "deleted");
|
||||
return query.getParamNameValuePairs().values().containsAll(
|
||||
java.util.List.of(1001L, TYPE, "71", 801L, "ACTIVE", false)) ? value : null;
|
||||
});
|
||||
|
||||
assertThat(service.metadata(1001L, TYPE, "71", 801L).fileId()).isEqualTo(801L);
|
||||
assertThatThrownBy(() -> service.metadata(1002L, TYPE, "71", 801L))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
|
||||
assertThatThrownBy(() -> service.metadata(1001L, TYPE, "72", 801L))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
|
||||
assertThatThrownBy(() -> service.metadata(1001L, "OVERSEAS_EXCEPTION_EVIDENCE", "71", 801L))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
|
||||
assertThatThrownBy(() -> service.metadata(1001L, TYPE, "71", 802L))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
|
||||
}
|
||||
}
|
||||
@@ -24,15 +24,22 @@ class DriverLicenseFileTest {
|
||||
assertThatThrownBy(() -> category.storageBusinessId("0")).isInstanceOf(BizException.class);
|
||||
}
|
||||
|
||||
@Test void storesRealPdfAndRejectsWrongMimeAndSpoofedPdf() throws Exception {
|
||||
/** 驾驶证上传接受有效 JPG/PNG,其他格式和伪装的图片均被拒绝。 */
|
||||
@Test void storesImagesAndRejectsPdfWrongMimeAndSpoofedImages() throws Exception {
|
||||
var storage = storage();
|
||||
var pdf = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.7\nlicense".getBytes());
|
||||
var result = storage.store(0, "71", pdf, BusinessFileCategory.FLEET_DRIVER_LICENSE);
|
||||
assertThat(storage.read(result.objectKey())).isEqualTo(pdf.getBytes());
|
||||
for (var format : new String[]{"jpg", "png"}) {
|
||||
var type = format.equals("jpg") ? "image/jpeg" : "image/png";
|
||||
var file = new MockMultipartFile("file", "license." + format, type, image(format));
|
||||
var result = storage.store(0, "71", file, BusinessFileCategory.FLEET_DRIVER_LICENSE);
|
||||
assertThat(storage.read(result.objectKey())).isEqualTo(file.getBytes());
|
||||
}
|
||||
assertThatThrownBy(() -> storage.store(0, "71", pdf, BusinessFileCategory.FLEET_DRIVER_LICENSE))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("仅支持JPG、PNG");
|
||||
assertThatThrownBy(() -> storage.store(0, "71", new MockMultipartFile("file", "license.txt", "text/plain", "text".getBytes()), BusinessFileCategory.FLEET_DRIVER_LICENSE))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("仅支持");
|
||||
assertThatThrownBy(() -> storage.store(0, "71", new MockMultipartFile("file", "license.pdf", "application/pdf", "not pdf".getBytes()), BusinessFileCategory.FLEET_DRIVER_LICENSE))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不是有效PDF");
|
||||
assertThatThrownBy(() -> storage.store(0, "71", new MockMultipartFile("file", "license.png", "image/png", pdf.getBytes()), BusinessFileCategory.FLEET_DRIVER_LICENSE))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("图片");
|
||||
}
|
||||
|
||||
@Test void rejectsMoreThanTenMibBeforeReadingBytes() {
|
||||
@@ -43,6 +50,14 @@ class DriverLicenseFileTest {
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("10MB");
|
||||
}
|
||||
|
||||
/** 生成可解码的图片,覆盖文件格式与实际内容一致的上传路径。 */
|
||||
private static byte[] image(String format) throws java.io.IOException {
|
||||
var bytes = new java.io.ByteArrayOutputStream();
|
||||
var image = new java.awt.image.BufferedImage(1, 1, java.awt.image.BufferedImage.TYPE_INT_RGB);
|
||||
assertThat(javax.imageio.ImageIO.write(image, format, bytes)).isTrue();
|
||||
return bytes.toByteArray();
|
||||
}
|
||||
|
||||
private BusinessFileStorage storage() {
|
||||
var storage = new BusinessFileStorage();
|
||||
ReflectionTestUtils.setField(storage, "configuredRoot", root);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+11
-18
@@ -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("部分结清");
|
||||
}
|
||||
}
|
||||
+15
-2
@@ -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
@@ -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`;查“柜次费用明细与跨客户分摊组” |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
+2
-1
@@ -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) {
|
||||
|
||||
+3
@@ -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;
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
|
||||
+1
-4
@@ -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",
|
||||
|
||||
+14
-13
@@ -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}}
|
||||
|
||||
+13
@@ -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);
|
||||
|
||||
+5
-3
@@ -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")
|
||||
|
||||
+5
-5
@@ -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);
|
||||
|
||||
+23
@@ -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 {
|
||||
|
||||
+5
-4
@@ -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
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/** 分托结算直接验证实际Mapper,箱序号不代表物理已装托盘。 */
|
||||
class DropshipSplitRepositoryTest {
|
||||
/** 已装箱位于序号末尾时,仍只迁移另一托的箱,跨客户同码不受影响。 */
|
||||
@Test void transfersOnlyUnloadedPhysicalCartons() throws Exception {
|
||||
var data = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
var db = new JdbcTemplate(data);
|
||||
db.execute("CREATE TABLE fl_inbound_carton_label(id BIGINT,tenant_id BIGINT,inbound_id BIGINT,order_id BIGINT,inbound_code VARCHAR(50),batch_status VARCHAR(20),total_carton_count INT,detail_package_count INT,box_code VARCHAR(50))");
|
||||
db.execute("CREATE TABLE fl_dropship_pallet_carton(id BIGINT,tenant_id BIGINT,inbound_id BIGINT,pallet_id BIGINT,carton_label_id BIGINT)");
|
||||
db.update("INSERT INTO fl_inbound_carton_label VALUES(1,10,31,100,'IN-1','ACTIVE',4,4,'BOX-1'),(2,10,31,100,'IN-1','ACTIVE',4,4,'BOX-2'),(3,10,31,100,'IN-1','ACTIVE',4,4,'BOX-3'),(4,10,31,100,'IN-1','ACTIVE',4,4,'BOX-4'),(5,20,31,200,'IN-2','ACTIVE',1,1,'BOX-1')");
|
||||
db.update("INSERT INTO fl_dropship_pallet_carton VALUES(11,10,31,802,1),(12,10,31,802,2),(13,10,31,801,3),(14,10,31,801,4),(15,20,31,802,5)");
|
||||
var config = new MybatisConfiguration(); config.addMapper(DropshipPalletizationMapper.class);
|
||||
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(data); factory.setConfiguration(config);
|
||||
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(DropshipPalletizationMapper.class);
|
||||
new DropshipSplitRepository(mapper).transferRemainder(10,31,32,101,"IN-1-1",2,List.of(801L));
|
||||
assertThat(db.queryForList("SELECT id FROM fl_inbound_carton_label WHERE inbound_id=32 ORDER BY id",Long.class)).containsExactly(1L,2L);
|
||||
assertThat(db.queryForList("SELECT id FROM fl_dropship_pallet_carton WHERE inbound_id=32 ORDER BY id",Long.class)).containsExactly(11L,12L);
|
||||
assertThat(db.queryForObject("SELECT inbound_id FROM fl_inbound_carton_label WHERE id=5",Long.class)).isEqualTo(31L);
|
||||
assertThat(db.queryForList("SELECT box_code FROM fl_inbound_carton_label WHERE inbound_id=32 ORDER BY id",String.class)).containsExactly("BOX-1","BOX-2");
|
||||
// 下一柜重新读取封托内容时,货物身份必须归到余量子入库单和余量子订单。
|
||||
db.execute("CREATE TABLE fl_dropship_pallet(id BIGINT,pallet_no VARCHAR(50),pallet_status VARCHAR(20))");
|
||||
db.execute("INSERT INTO fl_dropship_pallet VALUES(802,'DP-802','SEALED')");
|
||||
db.execute("ALTER TABLE fl_dropship_pallet_carton ADD inbound_detail_id BIGINT DEFAULT 41");
|
||||
db.execute("ALTER TABLE fl_dropship_pallet_carton ADD sku_code VARCHAR(30) DEFAULT 'SKU-1'");
|
||||
db.execute("ALTER TABLE fl_dropship_pallet_carton ADD cargo_name VARCHAR(30) DEFAULT '货物'");
|
||||
db.execute("ALTER TABLE fl_dropship_pallet_carton ADD actual_sku_quantity INT DEFAULT 1");
|
||||
db.execute("CREATE TABLE fl_dropship_pallet_carton_sku(carton_id BIGINT,inbound_detail_id BIGINT,sku_code VARCHAR(30),cargo_name VARCHAR(30),actual_quantity INT)");
|
||||
db.execute("CREATE TABLE fl_inbound_order(id BIGINT,tenant_id BIGINT,order_id BIGINT,order_no VARCHAR(50),inbound_code VARCHAR(50),warehouse_id BIGINT,channel_code VARCHAR(30),channel_name VARCHAR(30),destination_warehouse_id BIGINT,destination_code VARCHAR(30),destination_name VARCHAR(30),customer_name VARCHAR(30),actual_gross_weight_kg DECIMAL,actual_net_weight_kg DECIMAL,actual_volume_cbm DECIMAL,total_package_count INT)");
|
||||
db.execute("INSERT INTO fl_inbound_order(id,tenant_id,order_id,order_no,inbound_code,total_package_count) VALUES(32,10,101,'ORDER-1-1','IN-1-1',2)");
|
||||
var remainder = mapper.sealedPalletLines(802);
|
||||
assertThat(remainder).hasSize(1);
|
||||
assertThat(remainder.get(0).getInboundId()).isEqualTo(32L);
|
||||
assertThat(remainder.get(0).getOrderId()).isEqualTo(101L);
|
||||
assertThat(remainder.get(0).getInboundDetailId()).isEqualTo(41L);
|
||||
assertThat(remainder.get(0).getCartonCount()).isEqualTo(2);
|
||||
|
||||
}
|
||||
}
|
||||
+65
-7
@@ -37,7 +37,8 @@ class FbaPricingFeeTransitionTest {
|
||||
session.getConnection();
|
||||
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,?,?)",
|
||||
|
||||
+21
-17
@@ -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
|
||||
|
||||
+2
-1
@@ -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() {
|
||||
|
||||
+89
-1
@@ -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");
|
||||
|
||||
+2
-3
@@ -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");
|
||||
|
||||
+10
-6
@@ -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");
|
||||
|
||||
@@ -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权限。结案核对所有派送终态与实发数量;全车派送完成自动结案释放车辆和司机,提货全部到仓及本土卡车完成同事务释放;不等待二次确认,不改变车辆主档状态,收款不触发释放 |
|
||||
|
||||
@@ -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/天,按阶梯累加 |
|
||||
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+2
-1
@@ -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);}
|
||||
|
||||
+45
-11
@@ -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("仓内装载与异地取货合计超过本车容量");
|
||||
}
|
||||
/** 取货照片保存事务复验已确认仓内实载与当前异地申报。 */
|
||||
|
||||
+11
-2
@@ -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;
|
||||
|
||||
+20
-7
@@ -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;}
|
||||
|
||||
+3
-1
@@ -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;
|
||||
|
||||
+32
-5
@@ -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) {
|
||||
|
||||
+11
-4
@@ -14,6 +14,10 @@ public interface ExceptionWorkflowMapper {
|
||||
/** 本车可登记的派送单,客户跟随真实派送记录。 */
|
||||
@Select("SELECT customer_id,delivery_no FROM ll_delivery_order WHERE dispatch_order_id=#{id} AND status IN ('DELIVERING','REJECTED','DELIVERED') ORDER BY id")
|
||||
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'
|
||||
|
||||
+4
@@ -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); }
|
||||
/** 保存人工完结后的异常状态,保留关联单据和实际库存事实。 */
|
||||
|
||||
+10
-3
@@ -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));
|
||||
}
|
||||
|
||||
+46
-6
@@ -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);
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -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(" / ")));
|
||||
|
||||
+9
-5
@@ -19,7 +19,8 @@ import java.util.Set;
|
||||
public class HttpDriverFileGateway implements DriverFileGateway {
|
||||
static final String TYPE = "FLEET_DRIVER_LICENSE";
|
||||
static final long MAX_SIZE = 10L * 1024 * 1024;
|
||||
private static final Set<String> CONTENT_TYPES = Set.of("application/pdf", "image/jpeg", "image/png");
|
||||
private static final Set<String> READ_CONTENT_TYPES = Set.of("application/pdf", "image/jpeg", "image/png");
|
||||
private static final Set<String> UPLOAD_CONTENT_TYPES = Set.of("image/jpeg", "image/png");
|
||||
private static final String PATH = "/internal/file/v1/business-files/0/FLEET_DRIVER_LICENSE/{driver}";
|
||||
private final RestClient client;
|
||||
private final String token;
|
||||
@@ -31,10 +32,11 @@ public class HttpDriverFileGateway implements DriverFileGateway {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
/** 新上传的驾驶证只接受 JPG/PNG,读取历史附件继续按原文件元数据校验。 */
|
||||
@Override
|
||||
public DriverModels.File upload(long driverId, String operatorId, MultipartFile file) {
|
||||
if (file == null || file.isEmpty() || file.getSize() > MAX_SIZE || !CONTENT_TYPES.contains(Objects.toString(file.getContentType(), "")))
|
||||
throw bad("驾驶证附件须为非空的 PDF、JPG 或 PNG,最大 10MiB");
|
||||
if (file == null || file.isEmpty() || file.getSize() > MAX_SIZE || !UPLOAD_CONTENT_TYPES.contains(Objects.toString(file.getContentType(), "")))
|
||||
throw bad("驾驶证附件须为非空的 JPG 或 PNG,最大 10MiB");
|
||||
var body = new MultipartBodyBuilder();
|
||||
try {
|
||||
body.part("file", new ByteArrayResource(file.getBytes()) {
|
||||
@@ -45,7 +47,9 @@ public class HttpDriverFileGateway implements DriverFileGateway {
|
||||
.header("X-Operator-Id", operatorId).contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
|
||||
.onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); })
|
||||
.body(Envelope.class);
|
||||
return verified(result, driverId, null);
|
||||
DriverModels.File uploaded = verified(result, driverId, null);
|
||||
if (!UPLOAD_CONTENT_TYPES.contains(uploaded.contentType())) throw bad("驾驶证附件须为 JPG 或 PNG");
|
||||
return uploaded;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -72,7 +76,7 @@ public class HttpDriverFileGateway implements DriverFileGateway {
|
||||
if (data == null || data.fileId() == null || data.fileId() <= 0 || !Objects.equals(data.tenantId(), 0L)
|
||||
|| !TYPE.equals(data.businessType()) || !Long.toString(driverId).equals(data.businessId())
|
||||
|| (fileId != null && !fileId.equals(data.fileId())) || data.fileName() == null || data.fileName().isBlank()
|
||||
|| !CONTENT_TYPES.contains(Objects.toString(data.contentType(), "")) || data.sizeBytes() == null
|
||||
|| !READ_CONTENT_TYPES.contains(Objects.toString(data.contentType(), "")) || data.sizeBytes() == null
|
||||
|| data.sizeBytes() <= 0 || data.sizeBytes() > MAX_SIZE)
|
||||
throw bad("文件不属于当前司机或不是有效驾驶证附件");
|
||||
return new DriverModels.File(data.fileId().toString(), data.fileName(), data.contentType(), data.sizeBytes());
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user