This commit is contained in:
2026-10-15 15:42:10 +08:00
parent 095a95e64a
commit c6bd6af5ce
43 changed files with 1153 additions and 92 deletions
+12
View File
@@ -2,6 +2,8 @@
`../lastleg-service/scripts/selfoperated-test-env-acceptance.mjs`:自营统计 TEST Gateway 验收,默认仅输出计划;显式 `--execute --environment TEST --source-account <账号键> --warehouse <仓库编码> --other-warehouse <第二仓> --report <报告路径>` 才登录并查询。凭据由运行者通过脚本之前的 `node --env-file=<明确TEST文件>` 注入,需平台、只读平台、无权限平台及客户身份。`--probe-off-command` 仅在确认 OFF 后提交预期被拒绝的命令并核对无新增任务;不采集、发布、造数或写 SQL。`--synthetic-fixture <审核后的说明文件>` 验证合成样本,不能证明真实来源覆盖。报告脱敏记录各项结果及请求标识;失败保留报告,重跑使用新报告路径并重新登录,结束退出自身会话,不清理业务样本。离线合同为同目录 `selfoperated-test-env-acceptance.test.mjs`。
`db/cargo-discard-consolidated-incremental-20261014.sql`:废弃业务结构增量汇总,MySQL 8.0.16+,仅对 `baiyue_lastleg` 已有业务库执行。覆盖废弃基础表、PDA完工字段、照片关联、退货箱废弃状态、通用库存来源和部分选箱;表/列/索引按存在性判断,约束原子替换,可重复执行。不回填历史订单、不补造出库单、不修改库存数量,无业务 DML;File及仓储资源库无需本次SQL。实例和凭据由执行者指定,完整执行、遇错停止、不使用 `--force`;DDL隐式提交,修正错误后可重跑,末尾 `SHOW CREATE TABLE` 结果由执行者保存核对,不自动清理或连接环境。
`db/return-consolidated-incremental-20261012.sql`:10月10日/12日退货、签收、清点、换标与按件出库增量汇总,显式 `USE baiyue_lastleg`,MySQL 8.0.16+ 已有业务库可重复执行。本轮补齐签收原包装、尺寸重量来源及库存箱原包装三个字段;逐列判断存在性,不回填签收/清点事实,不改库存数量。连接实例及凭据由执行者明确指定,整份执行、遇错停止;保留执行错误,修复后可重跑,末尾输出字段、索引与约束供核对。托盘重量、体积汇总是应用逻辑,不新增资源库字段;不自动执行到测试或生产环境。
`procurement-sample-test-env.mjs` 是采购样品正常全链路 TEST 专项入口,默认或 `--plan` 零网络。真实执行使用 `node --env-file="<授权目录>/test-acceptance.env" scripts/procurement-sample-test-env.mjs --execute --environment TEST --run-id <唯一短批次> --warehouse-id <已核对有效海外收货仓>`。只允许当前 TEST 工作区及指定配置;由正式 API 创建独立客户并完成资质,人工充值10元,上传凭证/面单,调用内部 `procurement-sample-fulfillment.mjs` 完成两箱正常履约。充值、文件和采购请求前落盘,结果未知停写;报告独占 `reports/procurement-sample-<批次>/report.json`,不覆盖旧报告,不自动重跑,不执行 SQL/部署/删除。结束退出自身平台与客户会话,保留样本。通过只表示该正常路径,不替代异常、退款、权限、真实页面和请求量验收。离线入口合同为 `procurement-sample-test-env.test.mjs`;内部模块安全合同见模块索引。
@@ -506,3 +508,13 @@ Admin 全量中的 `phase4-cross-region-events` 使用内部 `test-event-fault-e
`f32-pda-hold-race.mjs` 是显式 TEST 暂扣/PDA拣货并发验收入口,离线合同 `f32-pda-hold-race.test.mjs`。命令为 `node --env-file="<TEST配置>" --env-file="<Jenkins配置>" --env-file="<本轮自有PDA配置>" scripts/f32-pda-hold-race.mjs --plan <冻结计划> --report <全新报告> --confirm-test-runtime yes`;只消费已实际入仓、4箱可用、无占用、调度审核通过且同仓PDA canPick=true的本轮独立样本,不创建订单或车次。凭据只读明确TEST/Jenkins/自有PDA配置,不写报告;计划冻结客户、来源订单、仓、余额、位置、排车明细、车次、PDA成员和不可变Lastleg镜像ID。通过Jenkins在指定TEST Lastleg所用MySQL建立仅针对原订单的12秒行锁租约(SELECT FOR UPDATE、固定等待、ROLLBACK,无业务DML),用performance_schema实际锁等待证明两条正式HTTP命令同时竞争,再按暂扣先到/拣货先到两种顺序验证。会真实暂扣、解除本轮暂扣和拣货1箱,逐位置、库存流水、拣货及来源归属、原键重放分别核对;两个阶段顺序执行,不并发造数。结束恢复自有PDA停用状态并退出认证会话,测试单据、部分拣货和资源保留。命令结果未知或断言失败时保存原报告及逐步checkpoint,不自动重发命令、重建样本、删数据或回填SQL;租约自动释放后先核对原单和原命令,未证实的暂扣恢复必须单独按原事实处理。离线通过不代表实际MySQL/PDA或四端页面通过。
- `db/return-package-packing-boundary-incremental-20261010.sql`:退货包裹/箱出库及标签字段增量,显式选定 baiyue_lastleg 库后写结构;无凭据、无业务造数,可重复执行,失败停止并检查数据库错误。
废弃明细字段专项修复:db/cargo-discard-item-columns-repair-20261014.sql。仅对 MySQL 8.0.16+ 的 baiyue_lastleg 已有 ll_cargo_discard_item 表补齐 10 个新增字段,逐列判断,可重复执行,独立于退货约束。实例和凭据由执行者指定;不写业务数据,不修改库存,不自动访问环境。完整执行、遇错停止,DDL隐式提交,修正错误后可重跑;末尾只读查询应返回10行,由执行者保存结果,无自动清理。
废弃退货箱归零约束修复:db/cargo-discard-return-quantity-repair-20261014.sql。仅 baiyue_lastleg,MySQL 8.0.16+,将箱内SKU数量约束改为非负以支持整箱废弃。实例及凭据由执行者指定;可重复执行,原子替换约束,无业务DML,不连接环境自动执行。整份执行、遇错停止,DDL隐式提交,修正后可重跑;末尾输出表结构供保存核对,不清理业务数据。正式增量为 deploy/sql/migrations/overseas/baiyue_lastleg/V26__allow_zero_return_carton_item_quantity.sql,已纳入废弃汇总脚本。
客户废弃照片确认增量:db/cargo-discard-customer-confirmation-incremental-20261014.sql。MySQL 8.0.16+,目标明确为 baiyue_lastleg 和 baiyue_iam;连接实例与凭据由执行者指定。补齐客户确认时间和独立确认权限,默认客户管理员获得授权,自定义角色需手动分配;授权与受影响管理员的安全版本在同一事务提交,重跑安全版本仍单调递增。完整执行、遇错停止、修正后可重跑;不确认或回填历史单据,不修改库存,不自动访问环境或清理。执行结果由操作者保存,权限更新后需重新登录客户账号。对应正式迁移为 Lastleg V27 和 IAM V6;未执行时页面权限与服务字段尚不可用。
`db/warehouse-transfer-carton-counting-incremental.sql`:仓储转运逐箱测量与清点增量,显式 USE baiyue_lastleg;逐列检查并新增箱子尺寸、重量、实点件数、清点操作人、时间、幂等字段、箱所属转运托盘ID及独立SKU申报件数 declared_quantity,可重复执行,无业务DML、不回填历史事实。适用于明确授权的本地或开发MySQL8,凭据由操作者外部提供;失败保留原错误,修复后重跑,不清理单据。已同步尾程基线和海外统一增量。
`local-warehouse-transfer-carton-acceptance.mjs`:仅本机统一Gateway的仓储转运逐箱验收;命令前显式 `node --env-file=<本地配置文件>`,必须提供 ADMIN_TEST_GATEWAY、ADMIN_TEST_USERNAME/PASSWORD、LOCAL_TRANSFER_PDA_PASSWORD,可选 OVERSEAS_TEST_CUSTOMER_ID、TRANSFER_CARTON_OUTPUT、TRANSFER_KEEP_PDA。复用既有有效客户和海外仓,以业务HTTP创建4笔带批次标识的新单,覆盖单箱、多SKU分箱多托、短收及超收、SKU申报件数与预计箱数独立持久化且PDA实收不覆盖原申报、先生成多托盘再逐箱选择托盘、箱级测量清点及托盘体积重量自动汇总、错误SKU/零件数/缺少差异原因拒绝、幂等重放、箱标签、打托绑定、测试照片和入库完成回查。测试照片为合成PNG,不证明真机拍照或物理打印。创建本轮PDA账号,默认finally停用;仅显式 TRANSFER_KEEP_PDA=true 时保留供本轮页面验收后人工停用。不改主档、不写业务数据库、不删除单据;报告不保存凭据或Token,失败保留已创建单号,排查后用新批次重跑,不自动清库或续跑既有单。
@@ -0,0 +1,190 @@
-- 货物废弃增量汇总(2026-10-14),MySQL 8.0.16+,可重复执行。
-- 目标仅 baiyue_lastleg,连接实例和凭据由执行者指定。
-- 前提:已有尾程入仓箱、退货库存及出库基线;不是空库初始化脚本。
-- 覆盖基础废弃表、PDA完工、退货箱废弃、照片、通用库存及部分选箱。
-- 不回填历史单据、不补造出库单、不修改库存数量、不执行业务DML。
-- File复用已有文件表,无需修改 baiyue_file 或仓储资源库。
-- 完整执行,遇错停止,不使用 --force;DDL隐式提交,失败修正后可重跑。
-- 已有同名表/列/索引沿用,不修复任意未知旧结构,末尾输出结构供核对。
USE `baiyue_lastleg`;
SET NAMES utf8mb4;
SELECT DATABASE() AS target_database, @@hostname AS mysql_host, @@port AS mysql_port, VERSION() AS mysql_version;
-- 一、废弃业务表,存在则保留。
CREATE TABLE IF NOT EXISTS ll_cargo_discard_application (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
customer_confirmed_at DATETIME(3) NULL COMMENT '客户确认完成时间',
discard_no VARCHAR(64) NOT NULL COMMENT '废弃申请单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID',
overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', inbound_codes VARCHAR(2000) NOT NULL COMMENT '入仓单号集合',
total_packages INT NOT NULL COMMENT '总箱数', fee_per_package DECIMAL(18,2) NOT NULL COMMENT '每箱处理费用',
total_fee DECIMAL(18,2) NOT NULL COMMENT '费用合计', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种',
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', remark VARCHAR(1000) NULL COMMENT '备注',
idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希',
created_by VARCHAR(128) NULL COMMENT '创建人ID', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', review_remark VARCHAR(1000) NULL COMMENT '审核备注',
review_idempotency_key VARCHAR(255) NULL COMMENT '审核幂等键', review_request_hash CHAR(64) NULL COMMENT '审核请求内容哈希',
reviewed_at DATETIME(3) NULL COMMENT '审核时间', 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_ll_cargo_discard_no(customer_id,discard_no),
UNIQUE KEY uk_ll_cargo_discard_key(customer_id,idempotency_key),
KEY idx_ll_cargo_discard_list(customer_id,status,created_at),
KEY idx_ll_cargo_discard_warehouse(customer_id,overseas_warehouse_id,created_at)
) ENGINE=InnoDB COMMENT='货物废弃申请单';
CREATE TABLE IF NOT EXISTS ll_cargo_discard_item (
id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
order_id BIGINT NULL COMMENT '一件代发订单ID', source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON、通用库存INVENTORY',
return_carton_id BIGINT NULL COMMENT '退货库存箱ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', profile_id BIGINT NULL COMMENT '托盘档案ID',
sku_inventory_id BIGINT NULL COMMENT 'SKU库存记录ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称',
available_packages_snapshot INT NOT NULL COMMENT '可用箱数快照', discard_packages INT NOT NULL COMMENT '申请废弃箱数',
inventory_position_id BIGINT NULL COMMENT '废弃来源库存仓位ID',
carton_marks_json TEXT NULL COMMENT '本次废弃所选真实箱唛快照JSON数组',
carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否需要逐箱核对本次所选箱子',
completion_photo_file_ids TEXT NULL COMMENT 'PDA完工照片文件ID JSON数组',
actual_discard_packages INT NULL COMMENT 'PDA确认实际废弃箱数', pda_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'PDA处理状态:待处理PENDING、已完成COMPLETED',
pda_operator_id VARCHAR(128) NULL COMMENT 'PDA操作人', pda_completed_at DATETIME(3) NULL COMMENT 'PDA完成时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_cargo_discard_item(application_id,profile_id,sku_code),
UNIQUE KEY uk_ll_discard_position_sku(application_id,inventory_position_id,sku_code),
KEY idx_ll_cargo_discard_item_list(customer_id,application_id),
KEY idx_ll_cargo_discard_item_inbound(customer_id,inbound_code,sku_code)
) ENGINE=InnoDB COMMENT='货物废弃SKU明细';
CREATE TABLE IF NOT EXISTS ll_cargo_discard_carton (
id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', item_id BIGINT NOT NULL COMMENT '明细ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', carton_id BIGINT NOT NULL COMMENT '箱唛记录ID', carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛',
source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_cargo_discard_carton(application_id,carton_id),
KEY idx_ll_cargo_discard_carton_application(customer_id,application_id,item_id),
KEY idx_ll_cargo_discard_carton_identity(customer_id,carton_id)
) ENGINE=InnoDB COMMENT='货物废弃锁定箱明细';
CREATE TABLE IF NOT EXISTS ll_cargo_discard_status_history (
id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID',
remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
KEY idx_ll_cargo_discard_history(customer_id,application_id,created_at)
) ENGINE=InnoDB COMMENT='货物废弃审批历史';
CREATE TABLE IF NOT EXISTS ll_cargo_discard_photo (
id BIGINT NOT NULL,
customer_id VARCHAR(64) NOT NULL,
application_id BIGINT NOT NULL,
pallet_no VARCHAR(128) NOT NULL,
file_id BIGINT NOT NULL,
file_name VARCHAR(255) NOT NULL,
content_type VARCHAR(128) NOT NULL,
size_bytes BIGINT NOT NULL,
operator_id VARCHAR(64) NOT NULL,
created_at DATETIME(6) NOT NULL,
submitted_at DATETIME(6) NULL,
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;
-- 二、补齐已有表的PDA字段及完工照片快照。
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='customer_confirmed_at'),
'ALTER TABLE ll_cargo_discard_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认完成时间''', '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='actual_discard_packages'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN actual_discard_packages INT NULL COMMENT ''PDA确认实际废弃箱数''', '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) NOT NULL DEFAULT ''PENDING'' COMMENT ''PDA处理状态:待处理PENDING、已完成COMPLETED''', '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) NULL COMMENT ''PDA操作人''', '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完成时间''', '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='completion_photo_file_ids'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN completion_photo_file_ids TEXT NULL COMMENT ''PDA完工照片文件ID JSON数组''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 三、退货箱来源和废弃状态约束(V4)。
ALTER TABLE ll_cargo_discard_item
MODIFY COLUMN order_id BIGINT NULL COMMENT '一件代发订单ID',
MODIFY COLUMN profile_id BIGINT NULL COMMENT '托盘档案ID',
MODIFY COLUMN sku_inventory_id BIGINT NULL COMMENT 'SKU库存记录ID';
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'),
'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity, ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN(''AVAILABLE'',''PACKING_RESERVED'',''RELABEL_PENDING'',''OUTBOUND_PENDING'',''OUTBOUNDED'',''DISCARD_PENDING'') AND sku_types>0 AND total_quantity>0) OR (status IN(''MERGED'',''DISCARDED'') AND sku_types=0 AND total_quantity=0))',
'ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN(''AVAILABLE'',''PACKING_RESERVED'',''RELABEL_PENDING'',''OUTBOUND_PENDING'',''OUTBOUNDED'',''DISCARD_PENDING'') AND sku_types>0 AND total_quantity>0) OR (status IN(''MERGED'',''DISCARDED'') AND sku_types=0 AND total_quantity=0))');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status'),
'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status, ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN(''AVAILABLE'',''PACKING_RESERVED'',''RELABEL_PENDING'',''OUTBOUND_PENDING'',''MERGED'',''OUTBOUNDED'',''DISCARD_PENDING'',''DISCARDED''))',
'ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN(''AVAILABLE'',''PACKING_RESERVED'',''RELABEL_PENDING'',''OUTBOUND_PENDING'',''MERGED'',''OUTBOUNDED'',''DISCARD_PENDING'',''DISCARDED''))');
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='source_type'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN source_type VARCHAR(32) NOT NULL DEFAULT ''DROPSHIP'' COMMENT ''库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON'' AFTER order_id', '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='return_carton_id'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN return_carton_id BIGINT NULL COMMENT ''退货库存箱ID'' AFTER source_type', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND index_name='idx_ll_cargo_discard_return_carton'),
'ALTER TABLE ll_cargo_discard_item ADD KEY idx_ll_cargo_discard_return_carton(customer_id,return_carton_id)', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 四、通用库存、部分选箱、箱来源(V25)。
-- 货物废弃支持所有入库来源及散箱全量作业;不回填或修改历史单据。
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='inventory_position_id'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN inventory_position_id BIGINT NULL COMMENT ''废弃来源库存仓位ID''', '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='carton_marks_json'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_marks_json TEXT NULL COMMENT ''本次废弃所选真实箱唛快照JSON数组''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND index_name='uk_ll_discard_position_sku'),
'ALTER TABLE ll_cargo_discard_item ADD UNIQUE KEY uk_ll_discard_position_sku(application_id,inventory_position_id,sku_code)', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
ALTER TABLE ll_cargo_discard_item MODIFY COLUMN source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON、通用库存INVENTORY';
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='carton_scan_required'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要逐箱核对本次所选箱子''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
ALTER TABLE ll_cargo_discard_item MODIFY COLUMN carton_marks_json TEXT NULL COMMENT '本次废弃所选真实箱唛快照JSON数组';
ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT '箱唛状态:未绑定UNASSIGNED、已绑托ON_PALLET、整票收货BULK_RECEIVED、可用AVAILABLE、待废弃DISCARD_PENDING、已废弃DISCARDED';
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_carton' AND column_name='source_type'),
'ALTER TABLE ll_cargo_discard_carton ADD COLUMN source_type VARCHAR(32) NOT NULL DEFAULT ''DROPSHIP'' COMMENT ''箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 五、退货箱SKU归零约束(V26)。
-- 整箱废弃后保留SKU明细,剩余数量允许归零,仍禁止负库存。
-- 原子替换约束,可重复执行;不修改业务记录。
SET @ddl = IF(EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton_item'
AND constraint_name='chk_ll_return_inventory_carton_item_quantity'
),
'ALTER TABLE ll_return_inventory_carton_item DROP CHECK chk_ll_return_inventory_carton_item_quantity, ADD CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)',
'ALTER TABLE ll_return_inventory_carton_item ADD CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 六、只读核对。
SHOW CREATE TABLE `ll_cargo_discard_application`;
SHOW CREATE TABLE `ll_cargo_discard_item`;
SHOW CREATE TABLE `ll_cargo_discard_carton`;
SHOW CREATE TABLE `ll_cargo_discard_status_history`;
SHOW CREATE TABLE `ll_cargo_discard_photo`;
SHOW CREATE TABLE `ll_return_inventory_carton`;
SHOW CREATE TABLE `ll_inbound_plan_carton`;
SHOW CREATE TABLE ll_return_inventory_carton_item;
@@ -0,0 +1,30 @@
-- 客户照片确认功能增量,MySQL 8.0.16+,可重复执行。
-- 完整执行,遇错停止;须连接同时承载这两个业务库的指定实例。
-- 仅新增确认时间和权限,不确认/回填任何历史业务单据。
USE `baiyue_lastleg`;
-- 客户确认废弃完结时间,可重复执行;仅结构变更。
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='customer_confirmed_at'), 'ALTER TABLE ll_cargo_discard_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认废弃完结时间''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
USE `baiyue_iam`;
-- 仅 baiyue_iam,新增客户废弃确认按钮权限并授权默认客户管理员。
-- 自定义客户角色由管理员按需分配;不自动扩大其权限。
-- 可重复执行;授权和持久化安全版本在同一事务提交。
START TRANSACTION;
INSERT INTO iam_permission(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted)
VALUES (5306183,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:confirm','确认废弃完成',NULL,'POST',30,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),status='ACTIVE',deleted=0;
INSERT IGNORE INTO iam_role_permission(tenant_id,role_id,permission_id,created_at)
SELECT 0,r.id,p.id,NOW(3) FROM iam_role r JOIN iam_permission p
ON p.tenant_id=0 AND p.app_code='MERCHANT' AND p.permission_code='merchant:overseas-warehouse:discard:confirm'
WHERE r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0;
INSERT INTO iam_security_subject_version(auth_user_id,permission_version)
SELECT DISTINCT u.auth_user_id,1 FROM iam_user u
JOIN iam_membership m ON m.user_id=u.id
JOIN iam_membership_role mr ON mr.membership_id=m.id AND mr.tenant_id=m.tenant_id
JOIN iam_role r ON r.id=mr.role_id
WHERE u.auth_user_id IS NOT NULL AND u.status='ACTIVE' AND u.deleted=0
AND m.status='ACTIVE' AND m.deleted=0 AND r.tenant_id=0 AND r.app_code='MERCHANT'
AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0
ON DUPLICATE KEY UPDATE permission_version=permission_version+1;
COMMIT;
@@ -0,0 +1,53 @@
-- 废弃明细新增字段专项补齐,MySQL 8.0.16+,可重复执行。
-- 仅 baiyue_lastleg;前提是 ll_cargo_discard_item 已存在。
-- 独立于退货库存约束,无需先执行其他汇总章节。
-- 不修改业务记录或库存数量;完整执行,遇错停止,DDL隐式提交。
USE `baiyue_lastleg`;
SET NAMES utf8mb4;
SELECT DATABASE() AS target_database, @@hostname AS mysql_host, @@port AS mysql_port;
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确认实际废弃箱数''', '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) NOT NULL DEFAULT ''PENDING'' COMMENT ''PDA处理状态:待处理PENDING、已完成COMPLETED''', '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) NULL COMMENT ''PDA操作人''', '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完成时间''', '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='completion_photo_file_ids'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN completion_photo_file_ids TEXT NULL COMMENT ''PDA完工照片文件ID JSON数组''', '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='source_type'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN source_type VARCHAR(32) NOT NULL DEFAULT ''DROPSHIP'' COMMENT ''库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON'' AFTER order_id', '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='return_carton_id'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN return_carton_id BIGINT NULL COMMENT ''退货库存箱ID'' AFTER source_type', '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='inventory_position_id'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN inventory_position_id BIGINT NULL COMMENT ''废弃来源库存仓位ID''', '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='carton_marks_json'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_marks_json TEXT NULL COMMENT ''本次废弃所选真实箱唛快照JSON数组''', '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='carton_scan_required'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要逐箱核对本次所选箱子''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 核对新增字段;应返回10行。
SELECT column_name, column_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item'
AND column_name IN ('actual_discard_packages','pda_status','pda_operator_id','pda_completed_at','completion_photo_file_ids','source_type','return_carton_id','inventory_position_id','carton_marks_json','carton_scan_required')
ORDER BY ordinal_position;
@@ -0,0 +1,20 @@
-- MySQL 8.0.16+;对Lastleg连接的实例完整执行,遇错停止。DDL隐式提交。
USE `baiyue_lastleg`;
SET NAMES utf8mb4;
SELECT DATABASE() AS target_database, @@hostname AS mysql_host, @@port AS mysql_port;
-- 整箱废弃后保留SKU明细,剩余数量允许归零,仍禁止负库存。
-- 原子替换约束,可重复执行;不修改业务记录。
SET @ddl = IF(EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton_item'
AND constraint_name='chk_ll_return_inventory_carton_item_quantity'
),
'ALTER TABLE ll_return_inventory_carton_item DROP CHECK chk_ll_return_inventory_carton_item_quantity, ADD CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)',
'ALTER TABLE ll_return_inventory_carton_item ADD CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SHOW CREATE TABLE ll_return_inventory_carton_item;
+1 -1
View File
@@ -4139,7 +4139,7 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
PREPARE return_inventory_carton_dimension_stmt FROM @ddl; EXECUTE return_inventory_carton_dimension_stmt; DEALLOCATE PREPARE return_inventory_carton_dimension_stmt;
CREATE TABLE IF NOT EXISTS ll_return_inventory_carton_item (
id BIGINT NOT NULL COMMENT '退货库存箱SKU明细ID', carton_id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', quantity BIGINT NOT NULL COMMENT '该SKU箱内数量',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_item(carton_id,sku_code), KEY idx_ll_return_inventory_carton_item_customer(customer_id,sku_code), CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>0)
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_item(carton_id,sku_code), KEY idx_ll_return_inventory_carton_item_customer(customer_id,sku_code), CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)
) ENGINE=InnoDB COMMENT='退货库存箱内SKU和数量';
CREATE TABLE IF NOT EXISTS ll_return_inventory_balance (
id BIGINT NOT NULL COMMENT '退货库存余额ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', total_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '实物总库存,仅实际出库扣减', available_quantity BIGINT NOT NULL COMMENT '未合箱可用上架数量', carton_quantity BIGINT NOT NULL COMMENT '包含该SKU的可用箱数', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
@@ -0,0 +1,30 @@
-- 仓储转运逐箱测量与清点;仅增加字段,不修改业务数据。
USE baiyue_lastleg;
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 ''箱子实测长度厘米'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
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='width_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN width_cm DECIMAL(12,2) NULL COMMENT ''箱子实测宽度厘米'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
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='height_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN height_cm DECIMAL(12,2) NULL COMMENT ''箱子实测高度厘米'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
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='gross_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN gross_weight_kg DECIMAL(12,2) NULL COMMENT ''箱子实测重量千克'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
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='counted_quantity'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_quantity BIGINT NULL COMMENT ''箱内SKU实点件数'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
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='count_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN count_idempotency_key VARCHAR(255) NULL COMMENT ''箱内清点幂等键'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
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='count_request_hash'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN count_request_hash VARCHAR(64) NULL COMMENT ''箱内清点请求摘要'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
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='counted_by'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_by VARCHAR(128) NULL COMMENT ''箱内清点操作人'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
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='counted_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_at DATETIME(3) NULL COMMENT ''箱内清点完成时间'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
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='discrepancy_reason'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN discrepancy_reason VARCHAR(1000) NULL COMMENT ''实收与预报箱数差异原因'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
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='transfer_pallet_id'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN transfer_pallet_id BIGINT NULL COMMENT ''签收箱所属转运托盘事实ID'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
-- SKU 申报件数独立于预计箱数;历史未申报件数保持 NULL,实收以 PDA 清点为准。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND column_name='declared_quantity'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_item ADD COLUMN declared_quantity BIGINT NULL COMMENT ''SKU申报件数,非预计或实收箱数'''));
PREPARE transfer_quantity_stmt FROM @ddl; EXECUTE transfer_quantity_stmt; DEALLOCATE PREPARE transfer_quantity_stmt;
@@ -0,0 +1,118 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { randomInt, randomUUID } from 'node:crypto'
const gateway = String(process.env.ADMIN_TEST_GATEWAY || '').replace(/\/$/, '')
if (!gateway || !['localhost', '127.0.0.1'].includes(new URL(gateway).hostname)) throw new Error('Explicit localhost Gateway required')
if (!process.env.ADMIN_TEST_USERNAME || !process.env.ADMIN_TEST_PASSWORD) throw new Error('Explicit local platform credentials required')
const marker = `WT-BOX-${new Date().toISOString().replace(/\D/g, '').slice(0,14)}-${randomInt(1000,9999)}`
const output = resolve(process.env.TRANSFER_CARTON_OUTPUT || `reports/${marker}.json`)
const report = { marker, gateway, environment: 'LOCAL', photoEvidence: 'SYNTHETIC_TEST_PNG', passed: false, orders: [], checks: [] }
let admin, pda, userId
const ok = value => value.http === 200 && String(value.body?.code) === '200'
async function call(token, method, path, body, expectedFailure = false) {
const isForm = body instanceof FormData
const response = await fetch(`${gateway}${path}`, { method, signal: AbortSignal.timeout(45000), headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(!isForm && body !== undefined ? { 'Content-Type': 'application/json' } : {}) }, body: body === undefined ? undefined : isForm ? body : JSON.stringify(body) })
let value; try { value = await response.json() } catch { value = null }
const result = { http: response.status, body: value, requestId: response.headers.get('x-request-id') || value?.requestId, traceId: response.headers.get('x-trace-id') || value?.traceId }
// Never persist auth bodies, credentials, customer profiles or tokens.
report.checks.push({ method, path, expectedFailure, http: result.http, code: value?.code, requestId: result.requestId, traceId: result.traceId, passed: expectedFailure ? !ok(result) && ['A0400','A0422','A0409','A0404','A0403','400','409','404','403'].includes(String(value?.code)) : ok(result), message: ok(result) ? undefined : value?.message })
if (expectedFailure ? report.checks.at(-1).passed !== true : !ok(result)) throw new Error(`${method} ${path}: ${response.status}/${value?.code} ${value?.message}; requestId=${result.requestId}; traceId=${result.traceId}`)
return expectedFailure ? result : value.data
}
function assert(value, name) { report.checks.push({ name, passed: Boolean(value) }); if (!value) throw new Error(name) }
async function save() { await mkdir(resolve(output, '..'), { recursive: true }); await writeFile(output, JSON.stringify(report,null,2)) }
try {
admin = (await call('', 'POST', '/api/auth/login', { loginId: process.env.ADMIN_TEST_USERNAME, password: process.env.ADMIN_TEST_PASSWORD })).accessToken
const tenants = await call(admin,'GET','/api/platform/v1/iam/tenants?page=1&size=200')
const customer = tenants.records.find(row => row.status === 'ACTIVE' && (!process.env.OVERSEAS_TEST_CUSTOMER_ID || row.id === process.env.OVERSEAS_TEST_CUSTOMER_ID))
assert(customer?.id, 'Existing active local customer')
const customerId = customer.id
const warehouses = (await call(admin,'GET','/api/platform/v1/warehouse-admin?page=1&size=100&warehouseType=OVERSEAS&status=ACTIVE')).records
const warehouse = warehouses.find(row => row.warehouseCode === 'US-LAX-01') || warehouses[0]
assert(warehouse?.id, 'Existing overseas warehouse')
report.customerId = customerId; report.warehouseId = warehouse.id
const roles = await call(admin,'GET','/api/platform/v1/iam/roles')
const role = roles.find(row => row.roleCode === 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' && row.status === 'ACTIVE')
assert(role?.id, 'Existing PDA role')
const username = `pda_box_${marker.replace(/\D/g,'').slice(-18)}`
const password = process.env.LOCAL_TRANSFER_PDA_PASSWORD
if (!password) throw new Error('Explicit fixture PDA password required')
const user = await call(admin,'POST','/api/platform/v1/iam/users', { username, password, displayName: marker, phone: `18${randomInt(100000000,999999999)}`, email: `${username}@example.com`, tenantId: '0', roleIds: [role.id], overseasWarehouseId: warehouse.id, status:'ACTIVE' })
userId = user.membershipId
pda = (await call('', 'POST', '/api/auth/login', { loginId: username, password })).accessToken
assert(pda, 'Dedicated warehouse-bound PDA session')
await writeFile(resolve('.env.transfer-carton-session'), `PDA_USERNAME=${username}\nPDA_PASSWORD=${password}\n`, { mode: 0o600 })
for (const scenario of [
{ name:'single-box', counts:[1], actual:1 },
{ name:'multi-box-multi-sku', counts:[2,1], actual:3, pallets:[1,2] },
{ name:'short-receipt', counts:[3], actual:2, reason:'验收短收一箱' },
{ name:'over-receipt', counts:[2], actual:3, reason:'验收超收一箱' }
]) {
const key = `${marker}-${scenario.name}`
const items = scenario.counts.map((packageCount,index) => ({ skuCode:`${key}-SKU${index+1}`,productNameZh:`验收货物${index+1}`,quantity:120+index*30,packageCount,shippingMark:`${key}-CUSTOMER${index+1}` }))
let detail = await call(admin,'POST',`/api/platform/v1/last-leg/warehouse-transfers?customerId=${customerId}`, { operatingWarehouseId:warehouse.id,destinationType:'PRIVATE',privateDestinationAddress:'100 Test Warehouse Road',privateDestinationPostalCode:'90001',privateDestinationContactName:'测试收件人',privateDestinationContactPhone:'0000000000',deliveryAddress:'100 Test Warehouse Road',pickupContactName:'测试操作员',pickupContactPhone:'0000000000',packageCount:scenario.counts.reduce((a,b)=>a+b,0),volumeCbm:0.1,deliveryMethod:'TRUCK',estimatedArrivalAt:new Date().toISOString().slice(0,19),estimatedShipAt:new Date(Date.now()+86400000).toISOString().slice(0,19),remark:key,needRelabel:false,idempotencyKey:key,items })
assert(detail.items.every((item,index) => Number(item.quantity) === items[index].quantity && item.packageCount === items[index].packageCount), 'SKU declared units and expected cartons persist independently')
const no = detail.transferNo
const fact = { scenario: scenario.name, transferNo:no, plannedBoxes:detail.packageCount, actualBoxes:scenario.actual, status:detail.status, palletNos:[] }; report.orders.push(fact); await save()
detail = await call(admin,'POST',`/api/platform/v1/last-leg/warehouse-transfers/${no}/review?customerId=${customerId}`,{decision:'APPROVE',remark:key})
assert(detail.status === 'WAIT_PDA_RECEIVING', 'Review enters PDA receiving')
const path = `/api/pda/v1/last-leg/warehouse-transfers/${encodeURIComponent(no)}`
await call(pda,'POST',`${path}/palletize`,{idempotencyKey:`${key}-premature`,photoFileIds:['1']},true)
detail = await call(pda,'GET',path)
const palletAssignments = [];
for (const [index, cartonCount] of (scenario.pallets || [scenario.actual]).entries()) {
detail = await call(pda,'POST',`${path}/pallets/drafts`,{idempotencyKey:`${key}-pallet${index}`})
let pallet = detail.pallets.find(row=>row.status==='DRAFT')
assert(pallet?.id,'Saved pallet draft')
detail=await call(pda,'POST',`${path}/pallets/${pallet.id}/generate`,{idempotencyKey:`${key}-generate${index}`})
pallet=detail.pallets.find(row=>row.id===pallet.id)
detail=await call(pda,'POST',`${path}/pallets/${pallet.id}/bind`,{idempotencyKey:`${key}-bind${index}`,palletCode:pallet.palletNo})
fact.palletNos.push(pallet.palletNo); palletAssignments.push({ id: pallet.id, count: cartonCount })
}
let allocationIndex = 0;
const palletIds = palletAssignments.flatMap(pallet => Array(pallet.count).fill(pallet.id));
let boxes = detail.cartons.slice(0,scenario.actual).map((box,index)=>({ cartonMark:box.cartonMark,transferItemId:box.transferItemId,transferPalletId:palletIds[allocationIndex++],length:30+index,width:20+index,height:15+index,weight:2+index,skuCode:detail.items.find(row=>row.id===box.transferItemId).skuCode,quantity:5+index }))
if(scenario.actual>boxes.length) boxes.push({cartonMark:null,transferItemId:detail.items[0].id,transferPalletId:palletIds[allocationIndex++],length:44,width:33,height:22,weight:4.5,skuCode:detail.items[0].skuCode,quantity:13})
const count = {idempotencyKey:`${key}-count`,boxes,discrepancyReason:scenario.reason || null}
await call(pda,'POST',`${path}/cartons/count`,{...count,idempotencyKey:`${key}-bad-sku`,boxes:boxes.map((row,index)=>index?row:{...row,skuCode:'WRONG-SKU'})},true)
if (scenario.reason) await call(pda,'POST',`${path}/cartons/count`,{...count,idempotencyKey:`${key}-missing-reason`,discrepancyReason:null},true)
await call(pda,'POST',`${path}/cartons/count`,{...count,idempotencyKey:`${key}-zero`,boxes:boxes.map((row,index)=>index?row:{...row,quantity:0})},true)
detail = await call(pda,'POST',`${path}/cartons/count`,count)
assert(detail.packageCount === scenario.counts.reduce((a,b)=>a+b,0), 'Actual receipt preserves declared expected carton count')
assert(detail.items.every((item,index) => Number(item.quantity) === items[index].quantity), 'PDA actual units do not overwrite SKU declared units')
const counted = detail.cartons.filter(row=>row.status==='COUNTED')
for (const pallet of detail.pallets) {
const assigned = counted.filter(box => String(box.transferPalletId) === String(pallet.id));
assert(assigned.length === pallet.cartonCount, 'Pallet count derived from assigned boxes');
assert(Math.abs(assigned.reduce((sum,box)=>sum+Number(box.length)*Number(box.width)*Number(box.height)/1000000,0)-Number(pallet.volumeCbm)) < 0.000001, 'Pallet volume derived from boxes');
assert(Math.abs(assigned.reduce((sum,box)=>sum+Number(box.weight),0)-Number(pallet.grossWeightKg)) < 0.001, 'Pallet weight derived from boxes');
}
assert(counted.length===scenario.actual && counted.every(row=>row.countedAt && Number(row.quantity)>0 && Number(row.length)>0), `${scenario.name} persisted box measurements and quantities`)
fact.boxes = counted.map(row=>({cartonMark:row.cartonMark,length:row.length,width:row.width,height:row.height,weight:row.weight,quantity:row.quantity}))
assert(detail.cartons.filter(row=>row.status==='NOT_RECEIVED').length===Math.max(0,detail.packageCount-scenario.actual),'Missing box facts retained')
const replay = await call(pda,'POST',`${path}/cartons/count`,count)
assert(JSON.stringify(replay.cartons)===JSON.stringify(detail.cartons),'Count replay preserves original box facts')
await call(pda,'POST',`${path}/cartons/count`,{...count,boxes:boxes.map((row,index)=>index?row:{...row,quantity:row.quantity+1})},true)
const label = await call(pda,'GET',`${path}/carton-label`)
assert(counted.every(row=>label.printableHtml.includes(row.cartonMark)), 'Printable labels include every received carton')
const form = new FormData();form.append('file',new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=','base64')],{type:'image/png'}),`${key}.png`)
const photo=await call(pda,'POST',`${path}/measurement-photos/files`,form)
const complete={idempotencyKey:`${key}-complete`,photoFileIds:[photo.fileId]}
detail=await call(pda,'POST',`${path}/palletize`,complete)
assert(detail.status==='WAIT_CUSTOMER_CONFIRMATION' && detail.orderStatus==='INBOUND_COMPLETED','Completed receiving waits for customer confirmation')
assert(detail.pallets.reduce((sum,row)=>sum+Number(row.cartonCount),0)===scenario.actual,'Pallet box total equals counted receipts')
await call(pda,'POST',`${path}/palletize`,complete)
const readback=await call(admin,'GET',`/api/platform/v1/last-leg/warehouse-transfers/${no}?customerId=${customerId}`)
assert(JSON.stringify(readback.cartons)===JSON.stringify(detail.cartons),'Platform readback matches persisted PDA box facts')
assert(readback.pallets.every(row=>row.status==='COMPLETED'),'Completed pallets persisted')
fact.status=readback.status;fact.orderStatus=readback.orderStatus;fact.passed=true
await save()
}
report.passed = report.orders.length===4 && report.orders.every(row=>row.passed) && report.checks.every(row=>row.passed)
} catch(error) { report.error=error.message; process.exitCode=1 }
finally {
if(userId && process.env.TRANSFER_KEEP_PDA !== 'true') { try {await call(admin,'PUT',`/api/platform/v1/iam/users/${userId}/status`,{status:'DISABLED'});report.fixturePdaDisabled=true} catch(error) {report.cleanupError=error.message;report.passed=false;process.exitCode=1} }
if(userId) report.fixturePdaMembershipId=userId
await save();console.log(JSON.stringify({passed:report.passed,output,orders:report.orders.map(row=>({scenario:row.scenario,transferNo:row.transferNo,status:row.status,passed:row.passed})),checks:report.checks.length,error:report.error}))
}