This commit is contained in:
2026-09-21 14:31:35 +08:00
179 changed files with 1672215 additions and 1429 deletions
+4
View File
@@ -34,6 +34,10 @@
## 通用工程与质量门禁
- 对外 API 新增、改名、删除或访问域变化,以“调用端实际路径(含 API Base)→ 对应区域 Gateway → Controller → OpenAPI → 权限”作为一个交付单元;不能只在修改网关文件时才检查注册。已被正确通配路由覆盖时无需重复添加路由,内部服务令牌接口不得为通过检查而公开。
- 提交前运行 `node --test scripts/gateway-route-contract.test.mjs` 及受影响服务/调用端的定向测试;路由合同失败不得提交或声称完成。该离线合同检查源码映射、目标服务及内部路径隔离,不代替权限、HTTP 方法、运行时 Nacos 或真实 HTTP 验收;前端 Mock 测试、类型检查及服务单测通过也不证明网关可达。
- 交付列明变更接口、路由是否复用或新增、验证结果及部署单元;Gateway 配置变化必须包含对应网关,Nacos 配置变化单独说明发布项。没有环境授权时只交付代码和待验收说明,不自动部署、发布配置或调用真实 HTTP。
- 保持变更小而聚焦,遵循目标项目现有命名、分层和格式,不顺手重构无关代码。
- 优先复用现有依赖和公共能力;新增或升级依赖前说明必要性、影响范围和替代方案。
- 不提交依赖目录、构建产物、运行日志、编辑器缓存或本地临时文件;不写入密钥、令牌、真实客户数据或环境特定地址。
@@ -33,6 +33,7 @@ Admin 与 Client 已向客户讲解或确认过的页面,必须以对应原型
### Admin 已确认布局基线
- 订单样板及国内仓样式统一采用 [Admin 高密度界面规范](Admin高密度界面规范.md)。其已确认的密度、容器和页签图标规则优先于历史原型中的对应视觉;字段、业务分组、权限、操作语义与页面/弹框/抽屉承载方式继续逐操作核对。该规范不表示其他页面已完成验收。
- Admin 是高信息密度业务系统。保留原型字段、顺序、业务分组和交互语义,但普通短字段优先使用连续白底、多列键值网格、描述表或紧凑列表,不把“一个标签 + 一个值”做成独立卡片。
- 详情页顶部尽量合并返回、标题、核心状态和主要操作;路由壳层已显示标题时正文不重复。状态或流程结论只保留一个主要视觉落点,除真实错误、阻断或危险外不叠加大面积彩色横幅。
- 在 `1920×1080` 下短字段通常并排 `4–6` 组;长文本、地址、备注和异常原因可以跨列或换行。长编号集中省略说明列宽设计失败,应调整列数、宽度或跨列,不继续缩小字号。
@@ -44,6 +45,7 @@ Admin 与 Client 已向客户讲解或确认过的页面,必须以对应原型
- Admin、Client 当前只按 `1920×1080`、浏览器缩放 `100%` 验收;不检查或整改 `720px`、`320px` 等窄屏,也不删除已有响应式能力。用户恢复多视口验收后再统一调整。
- 弹框正文超过视口时只滚动正文,标题和操作区保持可见;独立详情页按正常页面滚动。
- 字段数量较多、两列会导致弹框明显过高时,在 `1920×1080`验收视口下改用宽版三列表单;短字段单列占位,地址、长文本、备注、文件和表格按内容跨列或整行。小型确认类弹框不因此扩宽。
- 保留键盘操作、焦点、表单标签、图标按钮名称和足够对比度。可访问性调整不得改变原型的主要布局与视觉语义。
## 执行与验收
@@ -59,8 +61,8 @@ Admin 与 Client 已向客户讲解或确认过的页面,必须以对应原型
Admin 客户管理原型:
- 客户详情:独立页面(`page-tenant-detail`)。
- 新增客户:居中弹框(`modal-tenant-add`,最大宽度约 680px)。
- 编辑客户:居中弹框(`modal-tenant-edit`,宽度约 640px)。
- 新增客户:居中宽版弹框(`modal-tenant-add`,宽度约 1120px),基础资料和联系与商务使用三列,地址、备注和资质表格整行。
- 编辑客户:复用新增客户的居中宽版三列弹框(`modal-tenant-edit`,宽度约 1120px)。
- 重置密码:居中弹框(`modal-tenant-reset-password`,宽度约 520px)。
- 入驻审核:居中弹框(`modal-tenant-audit`,宽度约 900px)。
+65
View File
@@ -0,0 +1,65 @@
# Admin 全模块样式核对记录
范围为 `apps/admin-web/src/core/navigation/adminNavigation.ts` 当前可见的 48 个菜单、顶部消息中心的 3 个正式页面,以及它们通过实际路由和组件可达的新增、编辑、详情、审核、文件、删除确认等操作。隐藏原型不恢复;某操作没有正式入口时记录现状,不凭原型补建业务能力。目标不缩减为已改模块。
统一依据为本轮用户确认、国内订单/客户详情样板及 [高密度界面规范](Admin高密度界面规范.md)。操作不超过 3 个时全部显示,超过 3 个才折叠;折叠按钮只显示省略号图标,保留提示和可访问名称。
| 导航组 | 可见菜单数 | 当前记录及下一步 |
|---|---:|---|
| 顶部消息中心(侧栏外) | 3 个页面 | 已补入三个列表、两处详情抽屉、两处编辑弹框及状态确认,见 [消息中心记录](Admin消息中心样式核对记录.md);1981 项全量测试、类型与构建通过,待人工截图 |
| 首页数据看板 | 1 | 四项真实指标、刷新与全屏已接入;代码检查通过,待人工截图,见 [首页与系统记录](Admin首页与系统管理样式核对记录.md) |
| 客户管理 | 2 | 已有 [客户管理记录](Admin客户管理样式核对记录.md),最终按截图复核 |
| 国内仓 | 7 | 已有 [国内仓记录](Admin国内仓样式核对记录.md),最终按截图复核 |
| 货柜管理 | 4 | 已有 [货柜管理记录](Admin货柜管理样式核对记录.md),最终按截图复核 |
| 海外业务 | 3 | 代码已修正,代码门禁通过,待人工截图,见 [海外业务记录](Admin海外业务样式核对记录.md) |
| 海外仓 | 10 | 已有 [海外仓记录](Admin海外仓样式核对记录.md),尾程截图差异及省略号按钮已修正,仍需修正后截图 |
| 客户服务 | 2 | 代码已修正,覆盖异常新增/详情、回访/再次回访及资料,见 [客户服务记录](Admin客户服务样式核对记录.md);代码门禁通过,待人工截图 |
| 财务管理 | 6 | 代码已修正,覆盖 11 路由、7 个页面文件及 11 处弹框声明;1950 项全量测试、类型与构建通过,待人工截图,见 [财务管理记录](Admin财务管理样式核对记录.md) |
| 通用 | 10 | 10 菜单、14 页面代码样式已核对,1957 项全量测试、类型与构建通过,待人工截图,见 [通用资料记录](Admin通用资料样式核对记录.md) |
| 系统管理 | 3 | 三页及新增/编辑/配置/启停/删除已核对;菜单权限恢复 720px 居中框,1961 项全量测试、类型与构建通过,待人工截图,见 [首页与系统记录](Admin首页与系统管理样式核对记录.md) |
“已有记录”不是视觉验收通过。每组必须保留逐操作矩阵、受影响测试/类型/构建证据,以及人工在 1920×1080、100% 缩放下提供的截图结论;缺少截图标记待确认。
## 共享子弹框复核
2026-09-14,已补齐订单费用新增/编辑/调整、费用驳回、订单申报编辑和货品申报编辑四处弹框的显式密度入口。保留原尺寸、表单、权限和提交行为,费用行操作按实际权限计数,不超过三个直接显示,超过三个使用纯省略号图标。
从密度合同声明的 93 个页面/组件入口沿实际 Vue import 检查到 118 个组件,未再发现缺少显式密度入口的弹框;引用检查已纳入维护合同,覆盖静态导入及字面量动态导入。当前左侧生产导航 48 个菜单均对应已登记的页面或组件入口。后续复核发现顶部消息中心未被侧栏清单覆盖,已另行补入三个正式页面及两个组件;原 93 / 118 计数仅为该次补查范围。此项为代码覆盖检查,不代表全部页面的视觉验收。
四处弹框相关 61 项定向测试、1971 项全量测试、类型检查、构建及 git diff --check 通过。本地 Vite 返回的排车列表、尾程订单列表均已采用纯省略号操作按钮。仍待人工在 1920×1080、100% 缩放下核对修正后的页面及弹框截图。
## 应用外层确认框
沿正式路由检查后,顶部消息中心已补齐。登录、首页入口重定向与 403 属于应用外层;不机械套用业务表格密度。应用外层的退出登录、权限变化、关闭页签、离开未保存页面和新版本恢复五处确认均已显式接入公共样式,51 项定向检查通过;取消、权限和草稿保护逻辑未改变。
| 应用外层操作 | 当前承载与依据 | 调整结论 |
|---|---|---|
| 退出登录 | AppLayout 现有消息确认框;原型无具体确认容器 | 保留原提示、取消与登出流程,只接入公共确认框样式 |
| 权限变化 | AppLayout 现有警告框 | 保留未保存条目说明与关闭失权页签逻辑 |
| 关闭页签 | WorkspaceTabs 现有未保存确认框 | 保留继续编辑、放弃并关闭以及后续焦点行为 |
| 离开未保存页面 | router/index.ts 的现有路由守卫确认框 | 保留守卫与版本缓存失效逻辑 |
| 新版本资源恢复 | main.ts 的现有刷新确认框 | 保留确认后刷新与放弃草稿边界,不触发真实刷新 |
以上五处仅统一消息框密度,不改变承载方式或内容。原型未定义的保护行为沿用当前实现,人工截图待确认。
## 人工证据待补
代码与离线测试不能证明最终 CSS 优先级、溢出、遮挡、缩放或长内容效果。人工按 1920×1080、100% 缩放逐项截图:
1. 国内订单与尾程订单详情(同一页签)、排车列表(少于/等于三个与超过三个可用操作的记录),复核此前反馈。
2. 顶部消息中心的三个列表、消息和模板新增/编辑、任务/消息详情抽屉,确认字段换行、滚动及底部按钮。
3. 各模块记录中尚未取得的新增、编辑、审核/确认、文件预览、删除/停用、未保存保护等状态截图,逐操作闭环,不以一张列表图代替详情或弹框。
在上述证据补齐前,全模块视觉目标保持未完成。不得把已登记组件检查与全量测试通过写成已完成所有页面视觉验收。
## 本轮最终代码门禁
最终 253 个文件 / 1982 项全量测试、类型检查、构建和 git diff --check 通过。消息中心 42 项、应用外层 51 项、国内仓确认框 184 项定向检查通过(这些集合存在共享合同重叠,不将其简单相加)。实际引用链覆盖 123 个 Vue 组件的 56 处业务消息框调用,以及应用外层五处确认。原有业务脚本在排除样式导入、customClass 与此前已确认的费用按钮计数函数后保持一致。
未部署,未向真实账号发送消息,未操作数据库或业务记录。不将代码门禁视为全模块视觉验收通过。
## 2026-09-15 浏览器截图验收
用户已明确授权内置浏览器在测试环境截图,后续不再以缺少人工截图授权作为阻塞原因。本批按 1920×1080、100% 缩放取得 10 张截图,见 [测试环境截图验收记录](Admin测试环境截图验收记录-20260915.md)。国内与尾程订单详情基本信息、排车按钮和编辑弹框已取得实际页面证据。
测试首页和消息模板仍未呈现本地最新样式;需获得单独的 Admin 测试部署授权并确认版本,再继续完整验收。其余页面和状态仍待逐项核对,本批不代表全模块通过。
+153
View File
@@ -0,0 +1,153 @@
# Admin 国内仓样式核对记录
## 与客户管理共用样式的维护收敛
本次按用户确认,仅整理国内仓与客户管理已接入范围的样式归属;不新增页面、业务操作或环境写入。此前界面验收截图不作为本次收敛后的截图证据,本次仍待人工截图确认。
| 国内仓入口/消费者 | 公共层收敛 | 业务例外与边界 |
|---|---|---|
| 订单列表及详情 | 公共描边按钮、详情页签、连续信息表、表格基础密度 | 操作资格、详情字段跨列与业务数据排列保留 |
| 订单测量与审核、称重图片 | 字号、间距、行高使用公共变量,弹框标题/正文/底部由公共层管理 | 1560px / 92vh 作业宽高、图片/画布尺寸、测量对照列保留 |
| 预装列表与详情、模拟柜与托盘码弹框 | 字号、间距、表格及弹框使用公共规则 | `isPreload` / `dense` 继续显式控制,共用组件未接入入口使用原布局;PDF 标签尺寸不变 |
| 库位、托盘 | 删除重复弹框框架,统一控件/字号/间距变量 | 560px 编辑宽度、双列表单和业务字段保留 |
| 整柜业务与库存详情 | 摘要引用公共变量,库存详情使用公共信息表 | 出入库分组、库存事实和交易表结构保留 |
默认值唯一入口为 `src/styles/adminDensityTokens.css`,公共外观唯一入口为 `src/styles/adminDensity.css`;业务 CSS 不重新定义按钮配色或密度默认值。维护合同与后续接入规则见《Admin 高密度界面规范》的“国内仓、客户管理的样式维护边界”。
## 订单共享子弹框补查矩阵(2026-09-14)
从订单页实际 import 追踪到以下两个组件,当前仅由 `UnifiedOrdersView.vue` 调用。原有订单页密度根不能覆盖其 Teleport 弹框。依据本轮统一要求补齐显式入口,保持当前业务合同和以下承载,不恢复原型旧字段。矩阵对应实际组件模板及页面调用,而非仅凭原型推断。
| 组件与操作 | 当前承载 / 尺寸 | 核对结论 |
|---|---|---|
| `InboundFeeDetailManager.vue` 新增、编辑、已审核费用调整 | 680px 居中框、两列表单 | 保留应收/应付、结算方式、已付字段、凭证、金额及版本;显式接入弹框和表单密度,长备注/凭证整行。 |
| 同组件审核、驳回、删除、作废 | 审核为原行内动作;驳回为 AppDialog 默认 560px;删除/作废独立确认框 | 保留权限和状态。待审核最多四个可用操作,超过三个才将删除收起;三项以内全部直显;已审核的调整/作废两项直显。 |
| `OrderDeclarationEditorDialog.vue` 订单申报、渠道修改 | 1180px 居中框;渠道变更另有确认框 | 保留四项只读信息、订单级五项表单、原货品表及渠道确认内容;只让弹框正文滚动,去掉内部重复滚动容器。 |
| 同组件货品申报编辑 | 980px 居中框、四列 22 项字段 | 保留“应用本项”、字段顺序、长备注跨列及原校验,行内编辑使用共享描边按钮。 |
上述四处弹框已补齐公共密度入口,61 项定向测试、1971 项全量测试、类型检查、构建和 git diff --check 通过;仍待人工截图确认。保留计费、凭证上传、审核资格、货品项序号、仓库实测和订单版本请求。
## 2026-09-14 接手复核:订单列表与详情样式失效
用户在本地订单列表发现按钮回退,并提供详情截图:页签呈浏览器默认按钮,基本信息退回逐项竖排。复核发现 `UnifiedOrdersView.vue` 的列表与详情共用外层缺少 `admin-density`;子项虽已改为公共类,公共选择器和变量却无法生效。已在两种路由共用的页面外层补回入口。
维护合同新增模板祖先检查,覆盖订单与客户页面中的公共操作按钮、页签、信息表;避免仅凭 CSS 导入和子项类名存在就判为通过。用户截图确认的是修复前问题,修复后仍待 1920×1080、100% 缩放人工截图确认。
本次验证:订单页面与公共密度定向测试 48/48、`pnpm typecheck`、`pnpm build` 和差异空白检查通过;AGENTS 索引合同 1050 项通过。当前工作区全量测试 1910/1911 通过,唯一失败为费用项目页面的 5000ms 超时,该文件单独复跑 5/5 通过;不将这次全量运行记录为全部通过。验证覆盖工作区现有在途代码,未提交或部署。
## 国内仓 7 个菜单统一复核与返回入口调整(2026-09-14)
用户要求按全部 7 个菜单继续收敛,并提供整柜库存详情返回按钮独立于信息区的截图。本次取消“只有订单列表采用描边行内按钮”的旧范围例外;列表权限、操作资格、加载/禁用状态、确认与保存事件不变。
| 菜单 | 本次核对和调整 | 详情与弹框范围 |
|---|---|---|
| 订单列表 | 保留修复后的页面密度根与公共描边按钮 | 详情七个页签、测量/审核及页面内弹框;返回接入公共轻量导航 |
| 订单测量 | 行内测量为紫色、查看为灰色,移除装饰图标 | 共用测量作业、手动录入、图片预览和称重确认 |
| 测量数据审核 | 行内审核为橙色、查看为灰色,移除装饰图标 | 共用审核工作区、测量图片和确认窗口 |
| 预装清单 | 查看接入灰色描边;通过 `isPreload` 隔离共用组件 | 详情行内备注、返回、运输信息/备注/确认弹框、模拟柜和托盘打印;已确认清单模式不变 |
| 库位管理 | 编辑灰色、启用绿色、停用/删除红色;按钮组间距使用公共规则 | 560px 编辑弹框、确认消息保持显式密度入口 |
| 托盘管理 | 编辑灰色、删除红色;按钮组间距使用公共规则 | 560px 编辑弹框及删除确认 |
| 整柜业务 | 查看库存接入灰色描边 | 新建入库/出库弹框;库存详情返回、编号、状态与基本信息合并为一个白底区,交易记录保留独立分区 |
维护检查覆盖导航中的精确 7 个入口、测量/审核/预装入口的实际共享组件模式、独立打开页面所需的 CSS 导入、公共样式的模板祖先,以及弹框自身的接入类。不能把进入过订单页后其他页面“看起来正常”当作独立入口通过。用户截图属于调整前证据,全部修改后的视觉状态仍待 1920×1080、100% 缩放人工截图确认。
本次同时消除公共按钮组 6px 间距与 `DataTable` 默认 8px 左边距叠加的问题;未接入按钮仍使用原默认值。验证结果:7 页及详情定向测试 142/142、全量测试 249 个文件与 1913 项测试通过;最终表格间距合同及 DataTable 回归 28/28 通过,类型检查、构建与 AGENTS 索引合同 1050 项通过。未执行部署或业务数据写入。
## 向后续模块复用前的规则核对(2026-09-14)
复核发现“父子订单关联”抽屉遗漏 `admin-density-drawer`,已补回并增加抽屉独立入口检查。公共斑马纹、描述表标签背景和按钮悬停色现统一由变量消费。完整发现、验证结果和后续接入结论见 [客户管理核对记录](Admin客户管理样式核对记录.md) 的“向后续模块复用前的规则核对”;货柜管理入口与共享边界见 [货柜管理核对记录](Admin货柜管理样式核对记录.md)。这次复核不将历史截图视为新效果验收证据。
## 首轮国内仓验收依据(历史记录)
依据:已确认订单样板与 [Admin 高密度界面规范](Admin高密度界面规范.md)。首轮覆盖第 1、2、3 组。当时用户明确要求由 AI 在 1920×1080、100% 缩放下进行界面验收;该授权只用于当轮界面检查。
## 改造前逐操作矩阵
原型路径前缀为 `docs/悦/货代SAAS平台后台/prototype-src/`。保留实际业务入口、权限、提交与关闭语义,仅收敛密度。
| 页面 | 操作及当前承载 | 原型/代码依据 | 实施结论 |
|---|---|---|---|
| 订单测量 | 列表、详情跳转订单页 | `modules/02-domestic-warehouse.html:1465`;InboundTaskList | 紧凑表格,复用已确认订单详情 |
| 订单测量 | 测量作业居中弹框、全屏 | `overlays.html:720`;InboundMeasurementDialog | 1560px、92vh,固定标题和底部操作 |
| 订单测量 | 手动录入、测量原图、称重确认居中弹框 | InboundMeasurementPanel、WeightOcrPanel | 原有宽度与字段,统一边距及正文滚动;原图保留可读尺寸 |
| 测量审核 | 列表、审核居中弹框 | `overlays.html:811`;InboundTaskList、MeasurementReviewPanel | 1560px 审核工作区,操作栏可见,紧凑对比与明细 |
| 测量审核 | 审核通过二次确认、称重修正与原图 | MeasurementReviewPanel.confirmApprove、WeightOcrPanel | 保留确认、原因及权限;当前没有驳回按钮,不恢复历史操作 |
| 预装清单 | 列表、详情独立路由 | `modules/06-containers.html:1105,1118`;PreloadPlanDetailView | 使用可用页面宽度,摘要与历史表格 |
| 预装清单 | 新增模拟柜居中弹框 | ContainerSimulationCreate | 仅预装入口显式接入密度,保留当前业务流程 |
| 预装详情 | 补录/纠正运输信息、编辑仓库备注、确认预装居中弹框 | PreloadPlanDetailView 的三个 AppDialog | 统一标题、正文、操作区;尺寸随内容保留 |
| 预装详情 | 打印托盘码居中弹框与 PDF 输出 | PalletLabelPrintDialog;`overlays.html:1902` 为历史标签预览 | 保留当前 PDF 下载流程和标签尺寸,只调整选项容器 |
| 库位管理 | 列表、新增/编辑居中弹框 | `modules/02-domestic-warehouse.html:1807,1827` | 紧凑列表与双列表单,保留 560px 宽度 |
| 库位管理 | 启用/停用、删除二次确认 | LocationsView.toggleStatus/removeLocation | 保留业务文案、禁用、危险语义 |
| 托盘管理 | 列表、新增/编辑居中弹框、删除确认 | `modules/02-domestic-warehouse.html` 的 modal-pallet;PalletsView | 大统计卡改摘要行;当前路由没有托盘独立详情,不新增历史页面 |
| 整柜业务 | 入库/出库/库存页签与列表 | `modules/02-domestic-warehouse.html:1897` | 38px 彩色图标页签,库存统计改摘要行 |
| 整柜业务 | 新增入库、新增出库居中弹框 | `modules/02-domestic-warehouse.html:1931` 及相邻出库弹框 | 720px 双列表单,固定操作区 |
| 整柜库存 | 库存详情独立路由、交易记录 | `modules/02-domestic-warehouse.html:1923` | 四列连续描述表,长文本换行,紧凑流水表 |
预装完成后的跳转进入货柜管理既有详情,不扩大到全部货柜菜单。共享组件须显式接入,其他入口默认不受影响。
## 验收结果
2026-09-14 完成第 1、2、3 组实现和首轮浏览器界面验收。视口为 **1920×1080,缩放 100%**,使用当前本地 Admin 页面与现有测试数据。下表只表示实际检查到的界面,不代表写入流程或全部权限状态通过。
| 范围 | 实际检查 | 结论与本轮修正 | 截图证据 |
|---|---|---|---|
| 订单测量 | 列表、测量作业、手动录入、全屏/退出全屏、取消与重开;另从订单列表进入共用测量工作区 | 1560px 作业框及固定底部可用;修正样式优先级,使五项实测摘要保持一行;缩小表单组间距 | [测量工作区](../.codex-temp/admin-density-acceptance/order-measurement.png) |
| 测量审核 | 9 条待办;单票审核、测量原图、含托盘毛重修改、审核通过二次确认;勾选后批量审核确认并取消 | 审核框按内容自然收高;修正箱规、时间列宽,避免尺寸和时间截断;称重图与数据紧凑并列,原图窗口保留查看面积 | [审核工作区](../.codex-temp/admin-density-acceptance/measurement-review.png)、[称重修正](../.codex-temp/admin-density-acceptance/review-weight.png)、[批量确认](../.codex-temp/admin-density-acceptance/review-batch-confirm.png) |
| 预装清单 | 列表、新增模拟柜、两票明细详情、长船名、仓库备注、补录运输信息、打印托盘码选项 | 摘要四列、客户跨列、历史改紧凑表格;修正长表头单位换行及订单/SKU/客户列宽;缺少订舱或 PDA 已开始时,原禁用与只读提示保持可见 | [列表](../.codex-temp/admin-density-acceptance/preload-list.png)、[详情](../.codex-temp/admin-density-acceptance/preload-detail.png)、[备注](../.codex-temp/admin-density-acceptance/preload-remark.png)、[运输信息](../.codex-temp/admin-density-acceptance/preload-transport.png)、[打印选项](../.codex-temp/admin-density-acceptance/preload-print.png) |
| 库位管理 | 96 条记录列表、新增双列表单、状态筛选空态、查询重置、加载失败后重试 | 560px 表单完整显示;系统库位/占用库位操作限制保留;仓库名称与编码为两行,允许行高自然增长 | [列表](../.codex-temp/admin-density-acceptance/locations-list.png)、[表单](../.codex-temp/admin-density-acceptance/location-editor.png)、[空态](../.codex-temp/admin-density-acceptance/locations-empty.png) |
| 托盘管理 | 7 条记录列表、新增、已有托盘编辑后取消 | 统计改摘要行;560px 双列表单完整显示,容积、重量、颜色和备注保持原字段 | [列表](../.codex-temp/admin-density-acceptance/pallets-list.png)、[新增](../.codex-temp/admin-density-acceptance/pallet-create.png)、[编辑](../.codex-temp/admin-density-acceptance/pallet-edit.png) |
| 整柜业务 | 入库、出库、库存三个页签;新建入库/出库弹框;部分出库库存详情及 3 条交易记录 | 彩色页签、摘要行、720px 双列表单、四列描述表正常;长单号列表省略,详情交易表显示完整编号;底部操作可见 | [入库](../.codex-temp/admin-density-acceptance/container-inbound.png)、[出库](../.codex-temp/admin-density-acceptance/container-outbound.png)、[库存](../.codex-temp/admin-density-acceptance/container-inventory.png)、[入库表单](../.codex-temp/admin-density-acceptance/container-inbound-editor.png)、[出库表单](../.codex-temp/admin-density-acceptance/container-outbound-editor.png)、[库存详情](../.codex-temp/admin-density-acceptance/container-detail.png) |
| 已确认订单样板回归 | 订单列表测量入口、详情基本信息、状态历史 | 共用样式提取后测量正常;补齐订单页通用弹框样式导入;彩色页签、连续描述表与历史表保持 | [基本信息](../.codex-temp/admin-density-acceptance/order-detail.png)、[状态历史](../.codex-temp/admin-density-acceptance/order-history.png) |
截图位于当前工作区临时验收目录,不作为应用资源或构建产物提交;需要长期归档时应另行保存这些证据。
### 自动检查
- `pnpm typecheck`:通过。
- `pnpm test`:247 个测试文件、1899 项测试通过。此前一次并行负载下费用项目页面测试超时,单独复跑 5/5 通过,随后完整重跑也全部通过。
- `pnpm build`:通过。
- `git diff --check`:通过。
- 后端根 `./scripts/agents-index-contract.ps1`:1050 项离线索引检查通过,不访问业务服务或数据库。
### 覆盖边界与发现
1. 所有新增、保存、提交审核、确认预装和删除等业务写入均未执行;打印只检查选项窗口,未生成 PDF。本轮检查的是样式与可达交互,提交成功、服务端校验失败、处理中关闭等业务结果不列为实页通过。
2. 已访问的预装记录处于未关联订舱、PDA 作业中或已确认状态,未进入可提交的“确认整份预装计划”窗口。该窗口已接入统一样式并通过代码测试,实际可确认状态仍待补充截图。库位现有可用记录为系统 A0,其余样本为占用状态,编辑、启停及删除确认未取得实页证据;保留原权限与限制,不为截图改动测试数据。
3. 未切换账号验证全部无权限状态,也未穷举大批量照片、多页长表、所有业务状态。本轮称重原图样本为红色测试占位图片,只能验证图片承载与窗口布局,不能代表真实照片清晰度验收。
4. 测量草稿的客户名称显示“客户未命名”,而相同订单的列表/详情有客户名称,作为既有数据展示问题记录;本轮未修改客户映射与业务接口。
5. 库位首次加载曾失败,重试后正常。后续审核页客户筛选出现“服务暂时不可用”,待办数据仍显示;错误提示没有遮挡列表,见 [筛选加载失败](../.codex-temp/admin-density-acceptance/review-filter-error.png)。不将环境服务可用性计入视觉通过。
6. `LoadingListDetailDrawer` 同时供预装和已确认清单使用:历史记录的表格结构会影响这两个消费者,既有筛选和排序保持不变;其余紧凑间距仅预装入口启用。模拟柜创建、托盘码打印通过可选 `dense` 参数接入,其他入口未改变默认密度。
本轮未部署、未修改数据库或测试业务数据。规则与实现可以作为国内仓后续样式维护基线;上列未覆盖状态补齐后再作完整业务验收结论。
## 人工验收调整:订单列表行内按钮
用户提供参考图 `codex-clipboard-3cab512c-3b1a-412c-9a6d-1cbf796bf020.png`,要求订单列表采用该按钮风格。
| 页面/操作 | 参考依据 | 当前承载与调整 | 验收状态 |
|---|---|---|---|
| 订单列表:详情 | 参考图深灰纯文字、白底细边框 | 保留独立详情页跳转;移除按钮前图标,采用深灰文字 | 待人工截图确认 |
| 订单列表:通过 | 参考图橙色审核按钮 | 保留订单审核弹框与确认流程;改橙色文字及浅橙边框 | 待人工截图确认 |
| 订单列表:测量 | 参考图紫色业务操作按钮 | 保留测量作业弹框;改紫色文字及浅紫边框 | 待人工截图确认 |
| 订单列表:备注、更多 | 参考图普通描边按钮 | 备注用深灰纯文字;更多保留省略号及原下拉操作,不改变权限和排列顺序 | 待人工截图确认 |
所有行内按钮保持 28px 高度,按钮间距 6px;白底无阴影、细边框、6px 圆角。悬停、键盘焦点、处理中和禁用反馈保留。未调整顶部工具栏及弹框操作,未增加参考图中的客户管理功能。通用说明同步到《Admin 高密度界面规范》的“订单列表行内按钮”。
本次 `pnpm typecheck`、`pnpm test`(247 个文件、1899 项测试)、`pnpm build` 和 `git diff --check` 均通过。未部署;待用户在 1920×1080、100% 缩放下提供本次按钮截图。之前首轮验收截图不作为本次按钮效果通过的证据。
## 脚本确认框补查(2026-09-14)
沿已登记组件的实际引用检查发现八处 ElMessageBox 确认未显式接入密度。以下均保留当前确认框承载、原提示和操作逻辑;本次仅补充 customClass。
| 页面 / 操作 | 当前承载与结论 |
|---|---|
| 国内托盘删除 | 原有删除确认框;保留危险按钮和占用门禁提示 |
| 国内库位启用/停用 | 原有状态确认框;保留不同状态的标题、提示与版本 |
| 删除称重图片 | 原有确认框;保留本票库位不会释放说明 |
| 测量加载最新版本 | 原有放弃草稿确认框;保留继续编辑 |
| 提交测量数据 | 原有提交确认框;不改变校验及提交顺序 |
| 关闭未保存测量窗口 | 原有放弃修改确认框;保留取消后停留 |
| 订单解除同柜组 | 原有解除确认框;保留原有资格、提示和接口 |
| 入库费用作废 | 原有作废确认框;保留费用版本与原因校验 |
184 项定向检查通过;最终 253 个文件 / 1982 项全量测试、类型检查、构建和 git diff --check 通过。八处确认框均仅补充显式密度入口,提示、权限、版本和提交逻辑未变。人工截图待确认。
+24
View File
@@ -0,0 +1,24 @@
# Admin 客户服务样式核对记录
范围为导航“客户服务”的异常订单、客户回访两页及其实际可达弹框,源码位于 `apps/admin-web/src/modules/overseas-warehouse/pages/`。与海外仓 10 菜单分开记录。
| 页面/操作 | 原型证据 | 当前承载 | 本轮修正 |
|---|---|---|---|
| 异常订单查询/查看 | `prototype-src/modules/04a-customer-service.html` 第 1 行 | `ExceptionOrdersView` 列表与 760px 只读详情框 | 查看采用纯文字描边按钮;详情保留双组描述表与长原因/处理动作,接入弹框密度 |
| 新增异常订单 | `prototype-src/overlays.html` 第 1572 行 `modal-dispatch-exception-create`,620px | 760px 居中框,比历史原型增加箱数和 SKU 字段 | 保留现宽度与双列表单,原因跨行,保留关联解析、冲突提示和保存状态 |
| 异常处理/删除 | 第 1586 行历史处理框 | 当前列表只有查看,未开放处理/删除入口 | 不恢复历史操作,不将不存在的操作记作通过 |
| 客户回访列表与统计 | `04a-customer-service.html` 第 21 行起 | `CustomerVisitsView`,3 个统计卡与单个回访/再次回访按钮 | 统计改紧凑摘要;按权限保留单个纯文字操作,无省略号菜单 |
| 回访/再次回访 | `overlays.html` 第 4 行 `modal-customer-followup`,720px | 920px 居中框,增加实际入仓单选择,允许较长反馈和附件 | 保留现宽度;订单上下文改连续描述表,压缩可选入仓单布局,保留选择状态、反馈、备注、保存校验 |
| 资料上传/移除 | 同上,原型文件选择与文件列表 | 系统文件选择,回访保存前可移除附件 | 使用可聚焦的原生按钮触发文件选择,附件移除使用纯文字危险按钮,保留文件类型/大小限制与客户作用域 |
当前模板未提供独立回访详情路由或文件预览入口,不增加新业务能力。业务返回字段、状态机和权限保持现有契约。
代码修正已完成;修正后所有页面和弹框待人工在 1920×1080、100% 缩放截图确认。
## 本轮检查结果
- 全量 251 个测试文件、1,942 项通过;类型检查、生产构建及差异空白检查通过。
- 密度合同已纳入本组全部页面和真实 Teleport 弹框,覆盖公共样式独立导入、实际祖先、行内按钮、标签与字号规则。
- 本组视觉仍待人工截图确认;代码和组件测试不代表页面视觉验收。
- 回归覆盖异常新建权限、订单关联与冲突、只读详情、处理人 ID 隐藏、回访必填/客户范围、文件选择按钮和附件上传;入仓单选择及附件语义保持原样。
+104
View File
@@ -0,0 +1,104 @@
# Admin 客户管理样式核对记录
## 范围与依据
按当前客户管理导航的两个入口:客户列表、海外仓阶梯费用;客户详情及两个入口实际调用的编辑、审核、预览一并收敛。用户要求按钮使用纯文字,避免图标占位;详情页签保留已确认的彩色 Emoji。沿用《Admin 高密度界面规范》,验收视口为 1920×1080、100% 缩放。
本轮开始时客户编辑已有 1120px、三列表单,详情联系信息已有三列调整;保留这些在途改动并在其基础上处理密度,不回退业务逻辑。
## 逐操作矩阵
原型入口:`docs/悦/货代SAAS平台后台/prototype-src/modules/01-customers.html` 的 `page-tenant-list`、`page-warehouse-tier-fee`、`page-tenant-detail`。当前用户确认的密度及按钮要求优先于历史装饰。
| 页面/操作 | 当前承载 | 调整目标 |
|---|---|---|
| 客户列表、筛选、行内操作 | 标准列表 | 13px 正文、36px 基准行高,28px 白底描边纯文字操作,保留直接操作数量与更多菜单 |
| 新增/编辑客户 | 1120px 居中弹框、三列表单 | 保留字段、联络信息说明与资质上传,紧凑分区、正文滚动、底部操作可见 |
| 客户入驻审核 | 1040px 居中弹框 | 企业资料连续描述表、资质表,保留同意/驳回与理由 |
| 创建/审核结果、重置密码、跳转登录、删除 | 各自居中弹框 | 统一密度与纯文字按钮;凭证、复制、确认和失败行为保留 |
| 资质文件预览 | 居中弹框 | 保留原图/PDF 查看面积与加载反馈 |
| 客户详情基本信息 | 独立页面 | 紧凑头部、38px 彩色页签,企业和联络信息用连续键值表,资质和企业成员用紧凑列表 |
| 客户详情计费规则 | 箱规表、权重系数表及编辑弹框 | 收紧标题与行距,保留公式、默认规则、权限与保存语义 |
| 客户详情海外仓费用 | 阶梯表及编辑弹框 | 紧凑分组表,保留币种、档位、单位与未保存确认 |
| 客户详情操作日志 | 标准列表及详情抽屉 | 紧凑表格、描述表,保留既有抽屉承载、快照、错误诊断 |
| 海外仓阶梯费用 | 标准列表及 1120px 编辑弹框 | “天数:单价”连续排列并整组换行、币种单列,两类费用并排编辑,保留原增删档位校验 |
## 验证
### 人工截图反馈:更多入口与阶梯费用
依据本轮用户提供的海外仓阶梯费用单行截图(`codex-clipboard-60f898d1-3c3c-49ba-8eda-b741e40bbd6d.png`)及“请同步”的确认:
| 页面/操作 | 当前截图或实现 | 本轮处理 |
|---|---|---|
| 客户列表更多操作 | 文字“更多”,与订单列表省略号不一致 | 统一 `MoreHorizontal` 三点入口,保留悬停提示和可访问名称;菜单项纯文字 |
| 订单列表更多菜单 | 三点入口,菜单项带图标 | 保留三点入口,菜单项改为纯文字 |
| 海外仓阶梯费用列表 | 档位分成两列白底块,每档重复币种、行高较大 | 改为“天数:单价”连续排列,按原档位顺序整组换行;币种只在独立列出现,计费单位在表头;空档位显示“—” |
| 海外仓阶梯费用编辑 | 原 1120px 双费用分组弹框 | 保留当前承载、字段与增删校验;后续按钮反馈补齐新增/删除白底描边样式 |
本轮修改后的截图待人工确认,原截图只作为问题证据。三点入口与连续阶梯展示完成后重新执行类型检查、全量测试、构建及差异检查,结果如下。
后续按钮反馈补充:源码中列表“编辑”已经接入 `customer-action-button`,本次增强其选择器和 Element Plus 状态变量,明确灰字白底细描边及焦点反馈;阶梯编辑弹框的“新增阶梯、删除”此前仍使用默认按钮,本次补齐蓝/红语义描边,并同步客户详情同类费用编辑。没有将源码检查等同于用户当前环境已经生效,最终外观仍待人工截图确认。
按钮补齐后再次验证:`pnpm typecheck`、`pnpm test`(247 个文件、1901 项)、`pnpm build` 和客户模块差异检查均通过;未部署。
2026-09-14,本轮样式实现及代码验证完成:
- `pnpm typecheck`:通过。
- `pnpm test`:247 个测试文件、1901 项测试全部通过,包含客户模块、客户操作日志及订单列表的直接影响测试。原有图标和弹框类名断言按本轮确认更新,保留 API、权限、保存与错误处理断言。
- `pnpm build`:通过。
- 本轮客户模块、客户操作日志与订单列表的 `git diff --check`:通过。
视觉状态为 **待人工截图确认**。没有用代码检查代替截图验收,没有沿用国内仓截图作证据;未部署、未进行业务写入。测试输出包含已有的其他页面 `query` 常量绑定编译提示,不影响本轮检查通过。
## 实现入口与影响边界
- 页面显式接入 `admin-density customer-density`,相关居中弹框显式接入 `admin-density-dialog customer-density-dialog`,Teleport 后仍应用密度规则。
- 客户专用样式位于 `apps/admin-web/src/modules/customers/customerDensity.css`;基础密度复用 `src/styles/adminDensity.css`。本轮没有修改全局默认样式或共享组件接口。
- `CustomerAuditLogsPanel.vue` 当前由客户详情消费,本轮调整其详情按钮和日志抽屉间距、描述表。保留原抽屉、快照和诊断信息。
- 纯文字规则覆盖业务操作与带文案的查询、重置、复制;分页箭头、列设置、筛选布局、关闭、全屏及加载反馈继续可见,避免出现空按钮。
- 客户列表白底描边按钮按既有操作权限和直接操作数量显示,更多入口统一三点 `…`,不显示“更多”文字;菜单项纯文字。未修改状态、权限、API 或计费口径。
## 样式维护收敛(国内仓、客户管理)
按用户确认,将两模块共用的默认值和通用外观合并到公共层:
| 范围 | 接入内容 | 保留的业务配置 |
|---|---|---|
| 客户列表、阶梯费用列表及编辑弹框 | `density-action` + `data-tone`,按钮交互状态只维护一份 | 操作权限、配色语义、直接操作数量、三点更多 |
| 客户详情、订单详情 | `density-tabs` / `density-tab-icon`;信息表接入 `density-fields` | 页签标签和事件,四列/三列、字段跨列 |
| 客户审核 | 公共弹框框架及 `density-fields` | 1040px 宽度、审核资料分组、96px 标签宽度 |
| 客户规则与费用 | 公共字号/间距/行高变量 | 规则公式、阶梯顺序、两类费用分组与编辑宽度 |
| 客户操作日志 | 公共密度变量、`admin-density-drawer` | 日志详情抽屉、结果与快照布局 |
`customerDensity.css` 中重复的按钮、页签、信息表基础规则已上移;客户 Vue 文件中被模块样式覆盖的旧声明已清除,不再保留旧卡片尺寸后用更强选择器覆盖。此前记录提到的 `customer-action-button` 已由公共 `density-action` 替换,属于样式归属调整。
维护规则见《Admin 高密度界面规范》的“国内仓、客户管理的样式维护边界”。`src/styles/adminDensity.test.ts` 检查 21 个明确消费者及其模块 CSS:禁止业务选择器进入公共层、禁止页面重复实现按钮、禁止在页面重定义密度默认值、检查所有变量引用已定义。此次代码收敛后的视觉状态仍为待人工截图确认。
## 向后续模块复用前的规则核对(2026-09-14)
本次按实际代码核对国内仓和客户管理样板、公共 CSS、变量、维护合同与应用指引,并以货柜管理的真实菜单/路由检查规范能否复用。
| 检查项 | 发现与处理 | 证据范围 |
|---|---|---|
| 客户详情返回 | 原按钮尚未接入公共返回样式,现使用 `link` + `density-back`;保留纯文字及返回客户列表的事件 | 页面代码及返回入口合同;新视觉待截图 |
| 客户日志行内按钮 | 仍使用独立链接按钮,现接入灰色 `density-action`;日志面板自身导入公共 CSS 并显式接入密度根 | 独立组件入口、权限与详情查询测试 |
| 公共颜色变量 | 斑马纹和描述表标签底色绕过了已定义变量,已接回;各语义按钮悬停浅底改由公共变量提供 | 变量定义、消费与状态规则合同 |
| 浮层覆盖 | 国内仓父子订单关联抽屉缺少独立密度类,已补齐;合同新增抽屉检查 | 弹框/抽屉显式入口,不依赖页面祖先 |
| 后续接入说明 | 增加完整页面/弹框示例、共享模式边界、检查清单扩展与代码/视觉证据界限 | 《Admin 高密度界面规范》“新模块接入规则”及 Admin 指引 |
结论:公共基线和接入规则可用于后续 Admin 页面;新增页面仍须逐操作接入和验收,不能只导入 CSS 就称为完成。货柜管理 4 个菜单及 DOMESTIC/OVERSEAS 共享边界已登记在 [货柜管理核对记录](Admin货柜管理样式核对记录.md),尚未实施其页面改造。
本次定向测试 85/85、全量测试 249 个文件与 1915 项测试、类型检查、构建、AGENTS 索引合同 1050 项及文档链接检查通过。未执行部署或业务数据写入;修复后的页面视觉仍待人工截图确认。
## 人工验收步骤
统一采用 1920×1080、浏览器缩放 100%。每项包含空态、长内容、禁用及加载反馈;涉及真实业务提交时按人工验收数据操作。
1. 客户列表:筛选、查询/重置、列设置、分页,以及按钮最多的行;核对按钮间距和颜色、更多菜单、权限变化后的操作列。
2. 新增/编辑客户:三列表单、校验提示、长公司名称、资质上传与有效期;检查滚动、底部按钮、全屏和重开。
3. 客户审核与凭证相关弹框:企业资料、资质预览、审核理由、密码显示/复制、跳转登录理由及删除确认;核对信息完整和处理中锁定。
4. 客户详情:基本信息四组字段、联系信息三组字段、长地址/邮箱/文件名、资质预览及企业成员表;核对彩色页签与返回/编辑按钮。
5. 计费规则及海外仓费用:只读分组、默认规则入口、规则编辑、币种与多档阶梯、取消和未保存提示;核对单位、公式与底部操作。
6. 操作日志:筛选、详情抽屉、成功/失败记录及快照长内容。
7. 海外仓阶梯费用列表:多档位行、横向空间、编辑弹框两类费用并排、档位增删后的表格及校验。
+139
View File
@@ -0,0 +1,139 @@
# Admin 按钮与订单状态颜色清单
核对日期:2026-09-15。以下为当前工作区源码事实,不代表测试环境已部署。范围为公共按钮样式、公共状态徽标、国内订单列表/详情/审核状态、海外尾程订单列表与详情;不是所有业务模块的完整状态机盘点。清单先按源码盘点;后续已补齐的预装清单颜色映射单列记录。业务状态与状态文案未调整。
## 统一情况与修改入口
| 对象 | 当前统一程度 | 修改入口 |
|---|---|---|
| 接入 `density-action` 的行内按钮 | 色值、边框、悬停/禁用/焦点、尺寸集中;页面通过 `data-tone` 选择语义 | `apps/admin-web/src/styles/adminDensityTokens.css`、`adminDensity.css` |
| 顶部查询/新增/提交等 Element 按钮 | 使用基础主题,与行内按钮是两套色板 | `apps/admin-web/src/styles/foundation.css`,Element 主题 |
| `StatusBadge` 订单状态徽标 | 公共外观已集中;颜色仍直接写在 CSS 中;按中文文案判断类型 | `apps/admin-web/src/shared/components/StatusBadge.vue`、`src/styles/index.css` |
| 国内订单状态文案 | 状态码到文案集中;文案到颜色在另一处 | `apps/admin-web/src/modules/unified-orders/status.ts` |
| 尾程订单详情 | `OverseasDetailShell` 的 `dense` 分支复用 `StatusBadge` | `apps/admin-web/src/modules/overseas-warehouse/components/OverseasDetailShell.vue` |
| 尾程订单列表 | “当前状态”直接输出文本,没有状态徽标插槽 | `apps/admin-web/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue` |
| 扣货状态等 `el-tag` | 独立走 Element 标签主题,不等同于 `StatusBadge` | 对应页面 `type/effect` + 基础主题 |
结论:有公共入口,但尚未形成全应用唯一色板、唯一状态映射和唯一状态展示组件。修改按钮色不会同步修改订单状态徽标。
## 行内按钮色板
普通背景均为白色 `#fff`;文字与边框分别配置。默认蓝色已经按用户确认改为 `#1777ff`。
| 语义 / `data-tone` | 文字颜色 | 普通边框 | 悬停背景 | 现有用途示例 |
|---|---|---|---|---|
| 默认 / 省略或 `neutral` | `#1777ff` | `#dfe3ee` | `#f8fafc` | 详情、编辑、备注、省略号 |
| 主要 / `primary` | `#1677cf` | `#bfdbfe` | `#eff6ff` | 指定为主要操作的行内按钮 |
| 成功 / `success` | `#15803d` | `#bbf7d0` | `#f0fdf4` | 启用 |
| 警告 / `warning` | `#c76500` | `#fed7aa` | `#fff7ed` | 审核、暂扣等需注意的操作 |
| 危险 / `danger` | `#dc2626` | `#fecaca` | `#fef2f2` | 停用、删除、驳回 |
| 紫色 / `purple` | `#7c3aed` | `#ddd6fe` | `#f5f3ff` | 测量、重置密码 |
`warning`/`danger` 是按钮操作的语义,不会根据“异常”“成功”“失败”等按钮文字自动识别。页面需要显式选择类型。
| 交互状态 | 公共规则 |
|---|---|
| 普通 | 高 28px、圆角 6px、白底、1px 描边,13px / 600 字重,padding 4px 10px |
| 悬停 | 文字保持语义色,边框变为文字同色,背景使用上表悬停色 |
| 按下 | Element active 色变量使用相同语义色和悬停背景 |
| 键盘焦点 | 2px 同语义色轮廓,外偏移 2px |
| 禁用 | 文字 `#a1a1aa`、边框 `#e5e7eb`、白底;文字颜色目前直接写在 `adminDensity.css` |
| 加载中 | 沿用所属按钮的语义色与 Element loading 行为,无独立 loading 色板 |
## 顶部按钮基础主题
| 主题 | 基础色变量 | 当前值 |
|---|---|---|
| 主要 | `--cc-primary` → `--el-color-primary` | `#667eea` |
| 主要悬停 | `--cc-primary-hover` | `#764ba2` |
| 成功 | `--cc-success` → `--el-color-success` | `#067647` |
| 警告 | `--cc-warning` → `--el-color-warning` | `#b54708` |
| 危险 | `--cc-danger` → `--el-color-danger` | `#b42318` |
| 信息 | `--cc-info` → `--el-color-info` | `#175cd3` |
这些是基础语义值;实际按钮的实心/描边、禁用和悬停颜色还取决于 `type`、`plain` 和 Element 主题变量。不能把此表视为每个按钮最终计算样式。修改 `--cc-primary` 还可能影响导航、页签等,不能只为按钮颜色直接整体替换。
## 订单状态徽标色板
统一外观:胶囊形、最小高度 24px、padding 2px 8px、圆点 6px、间距 6px、字重 650,字号读取 `--cc-list-status-font-size`(基础默认 11px)。圆点使用文字色。
| 类型 | 文字 / 圆点 | 背景 | `StatusBadge` 当前精确匹配的中文文案 |
|---|---|---|---|
| 默认 `neutral` | `#475467` | `#f2f4f7` | 所有未匹配下面映射的文案 |
| 成功 `success` | `#067647` | `#ecfdf3` | 正常、启用、已完成、已审核、审核通过、已入库、已出库、装柜完成、派送完成、已确认、已放舱 |
| 警告 `warning` | `#b54708` | `#fffaeb` | 待审核、草稿、待处理、待分配、未提交、未录入、待测量数据、待分柜、待装柜、预装柜中、待出库、已拣货、变更中 |
| 危险 `danger` | `#b42318` | `#fef3f2` | 停用、已取消、已驳回、审核驳回、失败、订舱失败 |
| 信息 `info` | `#3538cd` | `#eff4ff` | 已受理、已订舱、已提交、运输中、处理中、拣货中、装柜中 |
注意:`异常`、`成功` 这两个字面文案本身不在映射中,直接传给 `StatusBadge` 会显示默认灰色;`失败` 在危险映射中。`info` 徽标的颜色与基础主题的 `--cc-info` 不相同。成功/警告/危险虽然当前数值与基础主题一致,但 CSS 直接写死,因此改基础变量不会同步修改徽标。
## 国内订单当前实际映射
由 `unified-orders/status.ts` 翻译后传给 `StatusBadge`,列表主状态和详情沿用这条链路。
| 状态码 | 文案 | 当前徽标类型 |
|---|---|---|
| DRAFT | 草稿 | warning |
| SUBMITTED / PENDING_REVIEW | 待审核 | warning |
| APPROVED | 已审核 | success |
| ACCEPTED | 已受理 | info |
| REJECTED | 已驳回 | danger |
| PROCESSING | 处理中 | info |
| COMPLETED | 已完成 | success |
| CANCELLED | 已取消 | danger |
| WAIT_MEASURE | 待测量数据 | warning |
| WAIT_PALLETIZE | 待一件代发组托 | **neutral:没有专门映射** |
| WAIT_SPLIT | 待分柜 | warning |
| LOADING | 装柜中 | info |
| LOADED | 装柜完成 | success |
| DELIVERED | 派送完成 | success |
审核状态:`NOT_SUBMITTED` 未提交、`UNRECORDED` 未录入、`PENDING` 待审核为 warning;`APPROVED` 审核通过和 `CONFIRMED` 已确认为 success;`REJECTED` 审核驳回为 danger。
## 尾程订单当前实际映射
文案来自 `overseas-warehouse/lastMilePresentation.ts`。下表颜色用于已接入 `dense` 的尾程详情;尾程列表当前状态列仍为普通文本。
| 状态码 | 文案 | 详情当前徽标类型 |
|---|---|---|
| PENDING_ASSIGNMENT | 待分配 | warning |
| DESTINATION_ASSIGNED | 已分配目的仓 | **neutral:没有专门映射** |
| INBOUND_PLANNED | 已生成入库计划 | **neutral:没有专门映射** |
| PENDING_PROCESSING | 待处理 | warning |
| PROCESSING | 处理中 | info |
| COMPLETED | 已完成 | success |
| CANCELLED | 已取消 | danger |
## 预装清单状态
2026-09-15 根据用户截图补齐公共徽标映射;预装列表与使用同一徽标的详情同步生效,不改变业务状态、文案、权限和操作。其他入口传入相同状态文案时也使用公共语义色。
| 状态码 | 文案 | 语义 | 文字色 / 背景色 |
|---|---|---|---|
| WAIT_LOADING | 待装柜 | 等待,warning | `#b54708` / `#fffaeb` |
| EDITING | 预装柜中 | 计划准备,warning | `#b54708` / `#fffaeb` |
| LOADING | 拣货中 | 进行中,info | `#3538cd` / `#eff4ff` |
| COMPLETED | 装柜完成 | 完成,success | `#067647` / `#ecfdf3` |
| 未知状态 / 空值 | 未知状态 / — | neutral | `#475467` / `#f2f4f7` |
当前预装列表主要显示预装柜中和拣货中,需要直接用颜色区分:预装柜中采用橙色文字与浅橙背景,拣货中采用蓝紫文字与浅蓝背景。两者仍复用公共语义色,不新增页面私有色值。
## 出库单状态
列表与详情使用公共状态徽标;已出库从缺失映射的灰色改为完成绿色。列表明确识别待出库,不再将待出库或未知状态统一显示为拣货中。
| 状态码 | 文案 | 语义 | 文字色 / 背景色 |
|---|---|---|---|
| WAIT_OUTBOUND | 待出库 | warning | `#b54708` / `#fffaeb` |
| PICKING | 拣货中 | info | `#3538cd` / `#eff4ff` |
| PICKED | 已拣货 | warning(待完成出库) | `#b54708` / `#fffaeb` |
| OUTBOUNDED | 已出库 | success | `#067647` / `#ecfdf3` |
| CANCELLED | 已取消 | danger | `#b42318` / `#fef3f2` |
## 后续统一时需要确定的范围
1. 默认蓝 `#1777ff` 已确认;是否将行内主要蓝 `#1677cf` 也统一为该值,尚未修改。
2. 按钮、徽标可共享成功/警告/危险等语义,但文字、背景、边框分别使用变量,避免把徽标改成按钮外观。
3. 状态样式统一放在公共徽标中,颜色提取到公共变量;业务模块按状态码显式提供文案和语义,避免改中文文案导致颜色变化。
4. 补齐待组托、已分配目的仓、已生成入库计划及异常等映射,并统一尾程列表与详情的状态呈现。以上为待调整项,本次未改实现。
5. 对未接入 `density-action`、使用 `el-tag` 或非 dense 详情分支的入口另行定向接入,不能宣称改一处已覆盖全应用。
@@ -0,0 +1,36 @@
# Admin 测试环境截图验收记录(2026-09-15)
用户本轮明确授权使用内置浏览器在测试环境截图验收。本次采用 1920×1080 视口、100% 浏览器缩放。该授权替代此前默认等待人工截图的方式;不包含提交业务表单、发送消息或部署授权。
本批取得 10 张截图。测试页面已呈现国内订单/尾程订单详情和排车省略号的修改,但首页、消息模板仍与当前本地实现不同。因此仅记录已观察到的页面状态,不将全模块视觉验收标记完成。
截图保存在本机目录:`C:/Users/BY-OY/.codex/visualizations/2026/09/14/01a09f47-4fd9-79a2-ab11-907fafb01423/qa-test-20260915/`。以下文件名均相对于该目录。
| 截图 | 页面或操作 | 本批结论 |
|---|---|---|
| 01-dashboard.png | 首页 | 仍为四张独立指标卡;当前本地已改成连续指标信息表,暂不能验收本地修改 |
| 02-domestic-orders.png | 国内订单列表 | 已留首屏证据;不替代全部状态及操作验收 |
| 03-domestic-order-detail.png | 国内订单详情基本信息 | 返回、业务单号、状态同行;摘要、页签、紧凑信息表可见,未再出现此前原生按钮和无布局字段现象 |
| 04-lastmile-orders.png | 尾程订单列表 | 可见查看、入库计划及纯省略号入口;没有可见“更多”文字 |
| 05-lastmile-order-detail.png | 尾程订单详情基本信息 | 与国内订单详情采用一致的标题行、摘要、页签及信息表样式;其余页签仍需复核 |
| 06-dispatches.png | 排车列表 | 已出发样本只有查看、打印 BOL 两个按钮,没有折叠入口;待发车样本为三个直接操作加省略号 |
| 07-dispatch-more.png | 排车省略号菜单 | 待发车样本菜单包含转派送、取消,共五个操作;入口只有省略号图标 |
| 08-dispatch-edit.png | 排车编辑 | 弹框居中、三列表单,底部取消和保存可见,截图中未见遮挡;未修改字段,已取消 |
| 09-message-templates.png | 模板管理列表 | 仍为旧样式,页面缺少本地的 admin-density 入口;长列表及按钮暂不能验收为当前版本 |
| 10-template-create.png | 新增消息模板 | 已记录旧版弹框外观;未填写或保存,已取消,部署后需重新截图 |
## 测试页面与本地实现差异
- 首页本地 `apps/admin-web/src/modules/dashboard/pages/DashboardView.vue` 使用密度入口及连续指标表;实际测试截图仍显示四张卡片。
- 模板管理本地 `apps/admin-web/src/modules/notifications/pages/MessageTemplatesView.vue` 使用 `admin-density` 与 `density-action`;测试 DOM 的页面根未包含该入口,已观察到的禁用按钮仍为 `el-button--primary is-link`,高度约 21px。
- 本地 `TemplateEditorDialog.vue` 已显式接入 `admin-density-dialog`。测试截图不作为这部分新样式的通过证据。
以上证据表明当前测试页面未呈现全部本地修改;本批未核实测试部署的精确提交号,不能将差异归因到某一次部署。
## 后续验收范围
1. 获得单独的 Admin 测试部署授权后,按部署指南发布本次相关修改并确认版本,再复核上述差异。
2. 继续按各模块记录覆盖列表、新增、编辑、详情及各页签、确认框和操作菜单;样本未覆盖的权限、状态、长内容保留待验收。
3. 本批未提交业务表单、未发送消息、未变更业务数据,也未部署。消息详情可能自动标记已读,需在后续操作前纳入授权范围判断。
本地既有门禁为 253 个文件 / 1982 项测试、类型检查、构建及差异检查通过;本批只新增截图记录,不将离线检查替代浏览器视觉验收。
+32
View File
@@ -0,0 +1,32 @@
# Admin 海外业务样式核对记录
## 范围与修改前逐操作核对
当前三菜单映射到 `apps/admin-web/src/modules/overseas-business/routes.ts` 的 9 个路由、5 个页面文件。原型入口为 `悦/货代SAAS平台后台/prototype-src/modules/07-overseas-business.html`。通用样式按本轮国内订单样板,字段/权限/业务承载以已实现契约和当前用户要求为准。
| 页面/操作 | 原型证据 | 当前实现 | 本轮修正 |
|---|---|---|---|
| 采购列表查询、详情、编辑 | `page-overseas-purchase` 第 2 行 | `PurchaseOrdersView`,详情/编辑两个操作 | 公共密度、纯文字描边按钮、左侧操作列,保留查询和分页 |
| 采购新增 | 第 14 行明确历史新增模板不可达 | 后台无新增路由/API | 不补建新增能力 |
| 采购详情 | `page-overseas-purchase-detail` 第 204 行,商品/库存/订单/快递/操作记录 | `PurchaseOrderDetailView` 独立页面,四组信息与四组业务表 | 返回、编号和状态合并;实际事实摘要;按原顺序连续键值表;取消独立字段卡片式排版 |
| 采购审核通过/拒绝 | 原型详情为动态内容;当前已接入服务端审核 | 520px 居中 `reviewDialogOpen`,备注、版本和幂等键 | 保留承载与请求,显式接入弹框密度并展示审核业务对象 |
| 签收文件、凭证上传、登记费用 | 当前详情实际上传入口,原型未完整定义 | 系统文件选择、560px 费用弹框 | 保留现有文件生命周期与冻结说明,统一弹框和表单;无预览/删除入口不虚构操作 |
| 采购编辑、出库明细添加/删除、保存/取消 | `page-overseas-purchase-edit` 第 143 行,独立页面 | `PurchaseOrderEditView` 三列表单、出库明细行、未保存保护 | 同行返回、业务编号;紧凑分组及表单;保留入库资格、全部字段、三态 PATCH 与未保存保护 |
| 本土卡车列表、新增、编辑 | 第 58/83/332 行 | `TransportOrdersView domain=truck`、`TransportOrderFormView` 独立页面;原型 760px,现实现 900px 表单 | 保留新增/编辑承载;同一白底表单区域、统一控件和行内编辑按钮,按字段组保留两/四/五列布局 |
| 本土散板列表、新增、编辑 | 第 222/247/390 行 | 上述共享页的 `domain=looseboard` 模式 | 同上,同时保留散板方式、是否打盘与权限状态差异 |
| 卡车/散板详情、资料、删除 | 卡车原型第 313 行存在详情,当前正式路由与列表只开放新增/编辑 | 无对应只读详情/资料上传/删除入口 | 记录当前可达范围,不恢复历史功能,不将其标记为已验收 |
共同问题:5 页均未显式接入公共密度样式;详情/表单头部返回靠右、重复标题/编号标签,编号缺失时回退路由参数;旧局部字号/大间距与公共规则冲突。本轮按实际字段重排,业务请求与权限不改。
## 验证
代码修正已完成;所有页面及弹框的修正后视觉效果待人工截图确认。
## 本轮检查结果
- 全量 251 个测试文件、1,942 项通过;类型检查、生产构建及差异空白检查通过。
- 密度合同已纳入本组全部页面和真实 Teleport 弹框,覆盖公共样式独立导入、实际祖先、行内按钮、标签与字号规则。
- 本组视觉仍待人工截图确认;代码和组件测试不代表页面视觉验收。
- 采购/卡车/散板编辑区域均限制在 1120px 连续白底区域,标题和表单同宽;采购出库明细保留独立行、完整输入和删除操作。该宽度属于本组业务布局,不改变全局页面宽度。
- 组件回归覆盖采购 23 个信息字段/4 个业务分组/4 个长字段跨列,以及凭证选择、上传、登记费用、审核后按钮隐藏;原有分页、409 输入保留、幂等重试和卡车/散板状态限制全部通过。
+92
View File
@@ -0,0 +1,92 @@
# Admin 海外仓样式核对记录
## 范围与依据
本轮用户确认继续“海外仓”模块。范围以 `adminNavigation.ts` 的 `overseas-inventory` 分组为准,包含 10 个菜单、对应独立详情和实际调用的作业弹框。客户服务、海外业务、隐藏产品库和旧箱号页面不在范围内。
视觉采用 [Admin 高密度界面规范](Admin高密度界面规范.md),保留当前路由、字段、权限、服务端状态资格及操作承载方式。原型入口为 `悦/货代SAAS平台后台/prototype-src/modules/03-overseas-warehouse.html`;入库、出库、换标、托盘详情分别见 706、714、720、726 行,尾程列表/详情见 894/927 行,库存详情/流水见 974/1022 行。当前真实业务契约优先于历史原型占位。
## 修改前逐操作矩阵
页面文件均相对 `apps/admin-web/src/modules/overseas-warehouse/`。原型未覆盖的新操作保留现有承载方式,不新增业务流程。
| 菜单/入口 | 操作及源码证据 | 当前承载与尺寸 | 本轮结论 |
|---|---|---|---|
| 尾程订单 | `LastMileOrdersView` 查询、导出、查看、更多;`LastMileOrderDetailView` | 独立详情;基本/货物/计划/费用/日志页签 | 纯文字行内按钮、彩色页签、连续信息表、同行返回 |
| 尾程订单 | `transferOpen`、`inboundPlanOpen` | 转仓默认宽度、入库计划 720px 居中框 | 保留确认、候选、版本,接入弹框密度 |
| 尾程订单 | `containerConvertOpen`、`containerDetailOpen`、`receivingWarehouseOpen` | 预览 1240px、柜详情 1120px、接柜仓 600px | 保留逐柜预览/阻断及当前隐藏入口,不恢复隐藏菜单 |
| 尾程订单 | `holdOpen`、`manualNodesOpen` | 暂扣 620px、节点 700px | 保留原因、计费、节点原子提交,紧凑表单 |
| 尾程订单 | `WarehouseTransferReviewDialog` | 转运审核 920px | 保留审核/驳回、货物与打托实测、按钮资格 |
| 尾程详情 | `feeDialogOpen` | 新增费用 720px | 保留金额/币种/保存流程 |
| 入库单 | `InboundOrdersView` 查看、编辑计划、预约;`InboundOrderDetailView` | 独立详情;预约 1080px;计划编辑默认框 | 保留只读费用空态、预约模式,统一按钮与描述表 |
| 入库单 | `SmartDispatchDialog` | 1800px 三栏作业框;车型选择 360px | 保留拖拽、锁定、容量、超重与预排车;只压缩普通控件 |
| 排车列表 | `DispatchesView` 查看、BOL、更多、取消;`DispatchDetailView` | 独立详情;PDF 原打印流程;取消确认框 | 保留返回目标、直接操作及危险确认 |
| 排车列表 | `transferOpen`、`editOpen`、`removePallet` | 转派送 1120px;编辑 900px;移除确认框 | 保留费用/箱唛/托盘事实,描边行内操作 |
| 出库单 | `OutboundsView` 新建、查看、取消;`OutboundDetailView` | 新建 1320px;独立详情;取消原因框 | 保留一件代发/退货两模式、选择表、PDA 提交 |
| 派送单列表 | `DeliveryOrdersView` 查看、编辑状态、标记退货 | 独立详情;状态 520px;退货 720px | 保留原出库明细可退数量与去向;统一按钮 |
| 派送详情 | `DeliveryOrderDetailView` 四页签 | 订单/SKU/费用/操作,内容在页签内 | 彩色页签且所有正文保持可见,去重复正文标题 |
| 派送单资料 | `DeliveryDocumentsDialog` 上传、替换、删除、下载、保存、重新加载确认 | 1040px;关闭放弃未保存修改 | 保留文件生命周期,正文单一滚动区 |
| 换标单 | `RelabelOrdersView` 新增、审核、查看 | 760px 表单;独立详情 | 保留创建与审核模式、状态候选与必填字段 |
| 换标详情 | `RelabelOrderDetailView` 完工证据、总 PDF、明细 PDF 上传/预览 | 连续详情、预览 900px | 保留 PDF 物理预览尺寸与绑定数据,紧凑文件行 |
| 托盘管理 | `PalletsView` 汇总、新增、编辑、查看;`PalletDetailView` | 760px 表单;独立详情 | 汇总改紧凑行;保留仓库编辑限制 |
| 库存管理 | `InventoryView` 汇总、查看、导出;`InventoryDetailView` | 独立详情,库位/托盘箱号/流水表 | 连续汇总、信息表;不改变库存口径 |
| 库存流水 | `InventoryTransactionsView` 导出、返回、关联数据 | 列表深链;共享关联框 1060px | 只为本轮调用显式接入共享框,保留分区域只读权限 |
| 退货库存管理 | `ReturnInventoryManagementView mode=inventory` | 库存/流水页签、查询、分页 | 两页签保留各自数据和查询模式 |
| 退货箱号管理 | `ReturnInventoryManagementView mode=cartons` 查看、箱唛打印 | 详情 1080px;现有 HTML 打印 | 统一详情按钮/信息表,不修改打印内容或尺寸 |
## 共享边界和验收
- `OverseasDetailShell`、`OverseasReadOnlyDetail`、`InboundCodeRelationshipDialog` 显式 `dense` 接入;默认调用保持原样。
- 公共字号、间距、按钮、页签和键值表只使用公共密度变量与类;模块仅保留业务布局与作业区尺寸。
- 最终全量 `pnpm test --maxWorkers=4`:1,927 项通过;`pnpm typecheck`、`pnpm build` 通过;索引合同 1,050 项通过。新增合同核对 10 个导航入口、两种退货模式、详情祖先样式、Teleport 接入和共享组件关闭状态。
- 尾程与派送详情的 `density-contained-tabs` 保留所有页签正文;智能排车将原操作按钮移入固定 footer 插槽,保留预排车事件与原三栏作业区。
- 页面事件、请求和状态门禁保持原有业务逻辑;本轮没有部署或修改环境数据。复核修正托盘标题的内部 ID 回退,出库仓库名称仅采用同仓关联派送返回的名称;缺失或关联不一致时显示“—”。托盘 DTO 目前未返回库位编码,详情保留空值,不展示 `locationId`。
- 托盘汇总移除与页面 `gap` 叠加的底部间距;删除已被公共按钮组接管的局部间距及空样式规则。
- 默认高并发全量回归出现分散用例的偶发失败;失败项定向复跑通过,随后在不修改断言或超时的情况下限制为 4 并发,全量通过。托盘加载/失败时不展示内部 ID、出库仓库名称匹配/缺失/不一致均有组件回归证据。
- 本地 5173 端口的 Vite 服务进程指向当前 `chongchong-global/apps/admin-web` 工作区;这只证明服务路径,不代替页面视觉证据。
- 全部 10 个菜单及详情/弹框均 **待人工截图确认**,验收为 1920×1080、浏览器缩放 100%。
## 用户截图复核修正
用户提供尾程详情与国内订单详情截图后,确认上一轮仅接入密度变量不足以达到样板一致:尾程存在重复页头标签、缺少业务摘要、自定义绿色状态边框、页签首项贴边且选中底色缺失、基本信息被两个标题拆断。截图证据为本轮附件 f76bcf5f(尾程)与 151a1d06(国内样板)。
| 操作 | 样板及现状 | 本次修正 |
|---|---|---|
| 尾程详情页头 | 国内为返回列表、业务编号、状态;尾程额外展示页面标题和编号标签 | 共用页头排版,仅保留业务编号;复制功能缩为轻量图标 |
| 状态 | 国内使用带圆点的 StatusBadge;尾程将全部已接入状态画成绿色 | 使用同一状态徽标,待分配按待办语义展示 |
| 概览 | 国内摘要位于标题和页签之间;尾程没有 | 展示客户、海外仓、箱数、托数、体积和最近更新,均取当前订单事实 |
| 页签 | 原生与 Element 两套样式存在实际差异 | 共用选中底色、字号、内边距、下划线,保留 Element 键盘切换及五页签正文 |
| 基本信息 | 国内连续键值表;尾程两个标题与两个表 | 保留全部字段顺序,合并为连续表;长备注按两组字段宽度展示 |
上述截图证明原版本存在差异,不作为修正后视觉通过的证据。
- 按本轮补充要求复核操作按钮:尾程订单、排车按当前行权限和状态统计实际显示操作,3 个及以下全部直显,超过 3 个才出现“更多”;直接操作优先保留查看、编辑/计划、打印。其余海外仓菜单操作列最多 3 个,继续直接显示。
### 本次截图修正的验证结果
- 新增尾程详情组件回归:单一业务标题、待分配状态语义、紧凑复制、实际摘要(含零箱数)、19 个基本字段、长备注、返回路由与 5 个页签可达。
- 新增尾程/排车权限组合回归:1—3 个操作直接显示,4—5 个操作才显示“更多”,并保留高频操作。公共样式合同校验国内原生与海外 Element 页签共用同一组外观规则。
- 全量执行 251 个测试文件、1,940 项:1,938 项首轮通过;另两项是旧的 230px 列宽及单一标题类名断言,按新规则更新后,其所属两个测试文件定向复跑全部通过。类型检查、生产构建及差异空白检查通过。
- 修正后仍待人工页面截图核对;本次代码与组件验证不作为浏览器视觉验收通过的依据。
### 折叠按钮文字修正
- 用户截图 f21098e6 确认折叠入口只展示省略号图标。尾程订单、排车列表移除可见“更多”文字,保留悬停提示和无障碍名称;超过 3 个操作才折叠的判断保持不变。
- 本次折叠按钮修正:全量 1,940 项测试、类型检查与生产构建通过。
### 新建出库单搜索、批量选择与分页(2026-09-15)
依据用户截图 `codex-clipboard-6a1295e8-ae69-44de-86bd-a0dd22446396.png`,仅调整 `OutboundsView` 的新建弹窗:
| 操作 | 截图现状 | 修正 |
|---|---|---|
| 仓库选择 | 下拉框的搜索能力不明显 | 保留可搜索下拉,提示输入名称/编码;选项同时展示名称与编码,可清空重选 |
| 订单搜索 | 已有关键词查询 | 保留订单号、入库单号、客户、托盘号、SKU、品名查询,增加搜索图标和无障碍名称;输入后查询或回车生效 |
| 批量选择 | 只有逐行复选框 | 表头全选本页;取消本页仅影响本页,翻页保留选择。按用户后续截图要求移除独立“全选筛选结果/清空选择”按钮行,已选总数并入分页左侧 |
| 客户/仓库限制 | 提示仅提及同一仓库,但接口还要求单一客户 | 保留同一客户、同一仓库提交限制;跨客户勾选后明确提示,用户可取消勾选并按客户查询 |
| 分页 | 每页条数文字不可见;内容过长时分页靠近弹窗底部 | 收窄表单全宽选择器范围,分页条数下拉固定 128px;增加每页 50 条、当前/总页数、跳页;条数变化回到第一页 |
| 长列表 | 增加每页条数会拉长整个弹窗 | 候选表格内部滚动,最大高度 360px;分页可换行,退货分支的分页条数宽度同步修正 |
后续截图 `codex-clipboard-1adadeb3-9d55-4c41-81c1-d6dafba87293.png` 确认搜索与批量按钮占行过多:海外仓搜索(280px)和一件代发订单搜索及查询/重置(640px)同排,移除独立批量按钮行,已选数并入分页左侧。普通说明收为单行文字,跨客户警告仍保留,备注默认一行并按输入自动增高至三行。
已增加真实组件回归覆盖分页选择、筛选后跨页勾选、半选状态、每页条数切换、仓库变更清空选择、提交明细数量以及多客户提交门禁。仅使用测试 Mock 验证提交,无真实出库单创建或 PDA 推送。修正后视觉仍待 1920×1080、100% 截图确认。
+18
View File
@@ -0,0 +1,18 @@
# Admin 消息中心样式核对记录
范围为顶部消息中心实际注册的三个页面及其现有操作。入口由 `apps/admin-web/src/app/layouts/AppLayout.vue:181` 和 `src/modules/notifications/routes.ts` 证明,不计入左侧 48 个菜单。采用当前用户确认的国内订单样板与公共密度规范。
| 页面 | 操作 | 原型依据 / 承载 | 当前实现与调整结论 |
|---|---|---|---|
| 我的消息 | 查询、查看、标为已读、全部已读 | `prototype-src/modules/11-system-messages.html:283` 的 `page-msg-my`;详情容器未定义 | 保留正式列表及 680px 详情抽屉;列表按钮、抽屉字号和正文间距接入公共密度,不改变打开未读消息时的标记逻辑 |
| 消息管理 | 列表、查看 | 同文件 260 行 `page-msg-manage`;原型仅给详情按钮 | 保留正式列表及现有 820px 任务详情抽屉;六项摘要改连续两列键值表,保留收件人表和失败原因 |
| 消息管理 | 新建、立即/定时发送 | `prototype-src/overlays.html:354` 的 `modal-msg-create`,680px 居中弹框 | 保留宽度、两列表单、整行模板变量及定时选项;独立浮层显式接入密度 |
| 消息管理 | 取消、重试 | 原型未定义;现有确认框及权限、状态限定 | 保留确认和 API;取消为危险色、重试为普通描边,不增加操作入口 |
| 模板管理 | 列表、激活、禁用 | `prototype-src/modules/11-system-messages.html:272` 的 `page-msg-template`;状态确认原型未定义 | 保留现有状态门禁及确认;激活为成功色、禁用为危险色 |
| 模板管理 | 新增、编辑、变量插入、预览 | `prototype-src/overlays.html:367` 的 `modal-msg-template-create`,760px 居中弹框;编辑复用现有容器 | 保留两列基本字段和整行正文、变量、预览;统一表单间距与浮层密度 |
现有 `notifications/PROTOTYPE_MATRIX.md` 中“我的消息未注册”和“无任务详情”与当前正式路由、顶部入口及 `getTask` 实现不符。本次按实际入口核对样式,不移除已存在功能,不新增发送渠道、草稿或导出能力。各行最多两个可用操作,全部直接展示,不引入更多菜单。
本次只调整样式,消息发送、标记已读、模板写入、取消与重试均仅使用离线 Mock 验证,不向真实账号发送消息。42 项定向检查、253 个文件 / 1981 项全量测试、类型检查、构建和 git diff --check 均通过;本地 Vite 五个组件资源均返回 200。三个列表、两处抽屉、两处编辑弹框及状态确认均待人工在 1920×1080、100% 缩放下截图确认。
对五个组件的脚本 AST 与修改前比较,排除样式导入和确认框 customClass 后相同。新增原生 Element Plus 挂载用例核对了按权限查看/已读、任务与模板状态操作、两处详情抽屉、模板不可变编码/变量预览/版本及定时发送表单。实际字段校验、消息发送及服务端结果仍由人工业务验收补充。
+43
View File
@@ -0,0 +1,43 @@
# Admin 财务管理样式核对记录
范围为当前财务导航 6 个菜单、11 个正式路由及其实际可达操作。入口为 `apps/admin-web/src/modules/finance/routes.ts`,5 个主体页面与应收/应付两个路由封装。依据本轮全模块统一样式要求、国内订单样板和财务最近指引;历史原型的旧钱包/台账边界不覆盖当前已接入业务。
## 修改前逐操作矩阵
原型目录为 `docs/悦/货代SAAS平台后台/prototype-src/`;页面在 `modules/08-finance.html`,浮层在 `overlays.html`。尺寸列记录当前实现;本轮保留业务弹框宽度及版本、权限、幂等和金额口径。
| 页面/操作 | 原型或当前事实 | 当前承载/本轮调整 |
|---|---|---|
| 客户账户列表、详情、账期 | `page-customer-account` 637 行、详情 645 行;`FinanceCustomerAccountsView` | 列表/独立详情/详情内账期编辑;返回与客户同行,账户实际余额、冻结、授信等全部口径保留为紧凑摘要 |
| 手工充值、手工扣款 | 当前 `adjustmentOpen`,与最近 Finance 指引一致 | 720px 弹框;保留类型、金额、付款方式、流水号、原因、账户版本及幂等键 |
| 授信维护 | 当前 `creditLimitOpen` | 560px 弹框;保留到期日、额度下限与校验 |
| 充值申请审核/驳回 | `modal-customer-recharge-review` 1807 行,当前 `reviewOpen` | 620px 弹框;保留申请描述表、凭证和审核原因 |
| 充值凭证预览 | 当前 `voucherPreviewOpen` | 920px 图片预览,保留图片尺寸和清理,不压成普通表单 |
| 账户流水查询/导出 | 当前详情 `ledger-query` 和 `exportLedger` | 独立筛选与服务端流水表保留,统一行高和工具栏 |
| 客户应收应付列表/费用详情 | `page-customer-arap` 661 行、详情 798 行;`FinanceCustomerArapView` | 独立详情、来源金额摘要、单据与结算信息;统一标题、描述表和日志 |
| 来源编辑、提交、审核/驳回、调整、作废 | 当前 `mutationOpen` 与 `reviewOpen` | 默认居中框;上游来源只读、整组分摊、状态及权限条件不变,按实际可见操作数量折叠 |
| 应收款统计查询/打印/导出 | `page-finance-ar-detail-report` 761 行;`FinanceReceivableStatisticsView` | 真实按月 CNY 汇总;摘要压缩,打印保持既有规则和异步 Excel 导出 |
| 对账单列表/详情 | `page-customer-recon-stmt` 822 行;`FinanceCustomerStatementsView` | 独立详情;去重复页头,保留客户/期间与 20 项概要、两个页签及全部版本记录 |
| 生成对账单 | 当前 `createOpen` | 680px 弹框,保留费用候选、已选总额与账期 |
| 提交、通过、驳回、出账、作废、重开 | 当前 `performAction` | 保留权限/状态条件和原因确认,操作不超过 3 个全部直显;超过 3 个才显示省略号 |
| 对账单调整 | 当前 `adjustOpen` | 900px 弹框,保留来源明细及金额计算 |
| 应收/应付列表与详情 | `page-settlement-ar` 940 行、`page-settlement-ap` 995 行、详情 1073 行;`FinanceFormalSettlementPage` 两模式 | 返回、结算单号和状态同行,删除路由 ID 文案回退;四项金额/日期摘要与描述表保留全部字段 |
| 生成应收/应付结算单 | 当前 `createOpen` | 600px 弹框,保留各自候选和方向约束 |
| 部分收款、部分付款 | 当前 `receiptOpen`/`paymentOpen` | 各 760px 弹框;保留部分核销摘要、凭证上传、剩余金额预览及保存限制 |
| 结算单作废/重开、导出 | 当前 `performAction`/`exportDetail` | 原因确认与导出沿用原流程,接入公共确认框样式 |
`CustomerStatementsPanel` 虽作为 Finance 公开组件存在,但当前六菜单路由没有引用;不能凭文件存在恢复旧抽屉入口。若后续模块沿实际引用命中,另行纳入其操作范围。
## 检查
本轮代码核对覆盖上述 6 菜单 / 11 路由。5 个主体页面显式接入公共密度样式,财务局部布局位于 `src/modules/finance/financeDensity.css`;应收/应付两个路由封装移除旧的 `tbody` 网格覆盖,由共享详情描述表统一排版。
- 客户账户、费用来源、对账单、应收/应付详情统一返回、业务标题与状态;金额摘要使用公共紧凑布局,对账单保留 20 个概要字段与两个页签。
- 11 处弹框声明及各自版本、权限、金额、凭证处理保留;操作原因框和导出提示框显式接入样式。导出公共辅助函数增加可选 `dense` 参数,未接入消费者保持原样。
- 账户仅有详情/账期时直接显示两个按钮;有充扣及授信权限时才折叠。费用来源草稿有 4 个动作时折叠作废,3 个及以下全部显示;通过真实组件测试验证省略号无文字且菜单里的作废仍能打开原确认流程。
- 收款/付款由结算方向与权限互斥,最多 3 个行内操作;应收款统计打印/导出归入表格工具栏,打印时隐藏工具栏。
- 清除页面内重复密度外观及空 CSS 选择器;公共维护合同纳入财务 5 个页面和模块 CSS,并增加空选择器检查。
2026-09-14 检查结果:财务、公共密度和导出提示定向测试 183 项通过;全量 `pnpm test --maxWorkers=4` 为 251 个文件、1950 项通过;`pnpm typecheck`、`pnpm build`、`git diff --check` 通过。新增/编辑、审核、上传、部分核销、金额守恒及导出行为沿用原有财务测试,未删除这些业务断言。
代码检查与财务行为测试不等同于视觉验收;所有本轮修改后的页面和弹框仍待人工在 1920×1080、100% 缩放下截图确认。
+79
View File
@@ -0,0 +1,79 @@
# Admin 货柜管理样式核对记录
## 本次范围与状态
用户确认继续优化货柜管理的 4 个菜单。本轮按下表实现列表、独立新建/详情页、智能装柜工作台及关联弹框的密度统一。代码检查结果在文末记录;未进行本轮浏览器视觉验收,所有修改后效果均待人工截图确认。
统一使用 [Admin 高密度界面规范](Admin高密度界面规范.md),以国内仓与客户管理为样板。普通列表、行内描边按钮、详情返回与页签、连续信息表、表单和浮层框架复用公共能力;业务状态、权限、接口、字段顺序、独立页面/弹框/抽屉承载方式保持现有契约。
## 4 个菜单与实际入口
范围依据:[Admin 导航](../apps/admin-web/src/core/navigation/adminNavigation.ts) 的 `container-management` 分组。下表源码路径相对 `apps/admin-web/src/modules/`。
| 菜单 | 页面与路由 | 新建、详情及相关组件 | 改造前继续核对 |
|---|---|---|---|
| 订舱列表 | `ocean-bookings/pages/OceanBookingsView.vue`,`/ocean-bookings` | `/ocean-bookings/new` 与 `/:bookingId` 共用 `OceanBookingDetailView.vue`;`OceanBookingDocumentsPanel`、`OceanBookingPrintDialog`、`CarrierScheduleQueryPanel` 和确认放舱弹框 | 新建与详情分别核对;资料、打印、船期、放舱资格及处理中状态不能丢失 |
| 拖车列表 | `trucking/pages/TruckAppointmentsView.vue`,`/trucking` | `TruckAppointmentCreateView.vue`(`/trucking/new`)、`TruckAppointmentDetailView.vue`(`/trucking/:id`);共用 `TruckAppointmentForm`、`TruckAttachmentDialog` | 新建是独立页面,编辑经详情页 `edit=1`;表单、附件、返回目标和状态资格分别核对 |
| 送柜报关 | `container-loading/pages/DomesticContainersView.vue`,`/container-loading/domestic` | 列表共用 `ContainersView.vue`;详情经 `DomesticContainerDetailView.vue` 到 `ContainerDetailView.vue`,均传 `workbench="DOMESTIC"`;另有 `SmartLoadingWorkbenchView.vue` 深链 | 模拟柜、关联订舱、装柜/预装关联、报关、费用、出仓及智能装柜入口按实际权限展开 |
| 清关提柜 | `container-loading/pages/OverseasContainersView.vue`,`/container-loading/overseas` | 列表共用 `ContainersView.vue`;详情经 `OverseasContainerDetailView.vue` 到 `ContainerDetailView.vue`,均传 `workbench="OVERSEAS"` | 船节点、清关资料、码头车队、提柜/还柜、异常、文件和尾程费用按实际权限展开 |
路由事实:[订舱](../apps/admin-web/src/modules/ocean-bookings/routes.ts)、[拖车](../apps/admin-web/src/modules/trucking/routes.ts)、[货柜工作台](../apps/admin-web/src/modules/container-loading/routes.ts)。不将“国内仓”的整柜业务 `/domestic-containers` 当作这里的送柜报关页面。
## 共享边界与例外
1. `ContainersView.vue` 和 `ContainerDetailView.vue` 同时服务国内、海外。可以复用样式,但两个模式分别保留字段、操作权限和业务资格;不能只验国内模式就声称覆盖清关提柜。
2. `ContainerSimulationCreate.vue`、`PalletLabelPrintDialog.vue` 已被国内仓预装入口以 `dense` 接入。货柜管理接入时优先沿用该参数,并回归原国内仓调用;不复制组件或撤销已有密度。
3. 货柜详情实际引用 `ContainerOverviewPanel`、`CarrierScheduleQueryPanel`、`ContainerCustomsPanel`、`LoadingListDetailDrawer`、`ContainerFeesPanel`、`ContainerLastLegFeesPanel` 和 `OceanBookingLinkDialog`。沿这些组件继续盘点实际页签、弹框与消息确认;不要因为父详情接入了公共类就把全部子页面记为完成。
4. `LoadingListDetailDrawer` 当前是嵌入内容组件,名称不决定承载方式。它同时服务预装和已确认清单;为货柜详情的已确认模式接入密度时,要同步修改原“未接入模式保持默认”的合同与两个实际消费者用例,不能只放开样式后删检查。
5. 智能装柜的模型/画布、船期轨迹、打印标签与 PDF 保留必要面积;只压缩周边表单、说明和装饰留白。航运业务编号、实际柜号、长文件名应有完整展示方式。
## 原型与逐操作矩阵
原型先读 [局部指引](悦/货代SAAS平台后台/AGENTS.md),目标模块为 `prototype-src/modules/06-containers.html`:订舱 `page-booking-list` / `page-booking-detail`,拖车 `page-truck-list` / `page-truck-add` / `page-truck-edit`,历史货柜与装柜入口 `page-ct-list` / `page-ct-manifest` / `page-ct-board`。
历史原型没有完整表达当前国内/海外双工作台,不能照其页面数量恢复旧菜单。本轮以用户确认的密度规则和当前实际组件、事件为依据,保留如下承载方式:
| 页面/模式 | 操作与承载方式 | 当前组件与依据 | 采用公共规则/业务例外 | 验证与截图 |
|---|---|---|---|---|
| 订舱 | 列表查看/编辑,跳转独立页面 | `page-booking-list`;`OceanBookingsView.open` | 灰色纯文字描边;草稿编辑、已放舱查看资格保留 | 待人工截图确认 |
| 订舱 | 新建草稿、保存及详情 | `page-booking-detail`;`OceanBookingDetailView` 的 `booking-groups` | 轻量返回与标题同区;原业务分组与字段顺序保留,去除内层卡片;柜型操作列保留在首列 | 待人工截图确认 |
| 订舱 | 删除草稿、确认放舱 | `removeDraft` / `releaseDialogOpen` 与 `submitRelease` | 删除消息独立接入;放舱保留 560px 居中确认与只读、处理中门禁 | 待人工截图确认 |
| 订舱 | 上传/替换、下载、作废资料与作废历史 | `OceanBookingDocumentsPanel.selectFile/download/remove` | 类型分组改连续资料行,长文件名换行;替换与作废仍需原消息确认 | 待人工截图确认 |
| 订舱 | 选择打印类型、PDF 预览/下载 | `OceanBookingPrintDialog.preview/backToSelection/download` | 保留 1120px 弹框、预览视口及 PDF 输出尺寸;选择项减小留白 | 待人工截图确认 |
| 订舱 | 船期与状态历史 | `CarrierScheduleQueryPanel`;`OceanBookingDetailView` 的 `detail.histories` | 船期显式 `dense`;状态历史表保留时间、状态变化、操作人、原因与原排序 | 待人工截图确认 |
| 拖车 | 列表详情/编辑,独立新建页 | `page-truck-list` / `page-truck-add`;`TruckAppointmentsView.openDetail`、`TruckAppointmentCreateView` | 行内纯文字描边;新建页返回和标题同区,不再放进表单说明末尾 | 待人工截图确认 |
| 拖车 | 编辑、只读详情及保存 | `page-truck-edit`;`TruckAppointmentDetailView`、`TruckAppointmentForm` | 返回/编号/状态同区;桌面四列表单、关联与长地址跨列保留,公共表单字号间距 | 待人工截图确认 |
| 拖车 | 状态变更确认与历史 | `changeStatus`、`history` | 原资格和确认消息;历史连续表格保留全部记录及操作人/原因 | 待人工截图确认 |
| 拖车 | 附件查看、上传/关联、删除 | `TruckAttachmentDialog` | 保留 640px 居中弹框;连续附件行,删除红色描边,消息独立接入 | 待人工截图确认 |
| 送柜报关 DOMESTIC | 列表、编辑基础资料 | `DomesticContainersView → ContainersView`;`openEdit/save` | 未完结/已完结页签 38px;查看、编辑、装柜、清单保持原排列;820px 双列表单使用公共弹框 | 待人工截图确认 |
| 送柜报关 DOMESTIC | 新增模拟柜、关联订舱 | `ContainerSimulationCreate`;`OceanBookingLinkDialog.save` | 模拟柜沿用 `dense`;关联保留 680px 弹框和候选资格 | 待人工截图确认 |
| 送柜报关 DOMESTIC | 智能装柜、分配/移除、调整箱数 | `SmartLoadingWorkbenchView.recommend/openAssign/assign/remove/startQuantityEdit` | 独立深链;保留容量进度、同柜组与封托说明、列表和分配弹框;普通行内操作接入描边 | 待人工截图确认 |
| 双工作台详情 | 返回、概要、业务页签 | `ContainerDetailView` 的 `workbench-tabs` | Element 页签保留键盘行为,改 38px 彩色图标;概要为紧凑摘要,字段为四组连续描述表 | 待人工截图确认 |
| 送柜报关 DOMESTIC | 异常标记/取消与历史 | `ContainerOverviewPanel` 的 `exception`;`ContainerDetailView.exceptionOpen` | 历史原因自然换行;520px 原因弹框独立接入 | 待人工截图确认 |
| 送柜报关 DOMESTIC | PDA 进度与照片、国内出仓门禁 | `ContainerOverviewPanel` 的 `pda`;`confirmDeparture` | 去除内层卡片、数字改小摘要;保留进度、照片原图入口、全部门禁展开及离仓确认 | 待人工截图确认 |
| 送柜报关 DOMESTIC | 报关资料、下载、上传完成文件、开始/完成报关 | `ContainerCustomsPanel` | 连续原生资料表,操作首列;纯文字描边,不改变全买单/退税分流或完成条件 | 待人工截图确认 |
| 送柜报关 DOMESTIC | 装柜清单页签、打印清单/托盘码 | `LoadingListDetailDrawer`;`printLoadingList`;`PalletLabelPrintDialog` | 预装/已确认模式均由调用者显式接入;保持嵌入内容及打印载体,PDF 尺寸不变 | 待人工截图确认 |
| 清关提柜 OVERSEAS | 列表、车队选择、发送/重试邮件与记录 | `ContainersView.selectRowFleet/sendPickupEmail/retryEmail/openEmailHistory` | 原列表控件与业务资格;邮件记录 1100px 弹框、重试确认独立接入,邮件正文保留换行 | 待人工截图确认 |
| 清关提柜 OVERSEAS | 预约/实际提柜、还柜及拖车历史 | `drayageActionOpen`、`drayageHistoryOpen`;详情 `emptyReturnOpen` | 520px 时间与原因表单、980px 历史;超时状态和服务端时间事实原样保留 | 待人工截图确认 |
| 清关提柜 OVERSEAS | 清关/DO 文件上传下载、PU 保存、完成清关 | `ContainerOverviewPanel` 的 `documents`;`documentOpen/clearanceOpen` | 操作首列描边;文件与 PU 保持不同流程,520px 弹框;无资料/只读/失败状态保留 | 待人工截图确认 |
| 订舱/双工作台 | 履约轨迹、任务历史、人工船节点补录 | `CarrierScheduleQueryPanel` | 原轨迹分组和时间顺序保留;长摘要换行,展开查询事实为连续信息表;620px 补录弹框仅在 `dense` 模式接入 | 待人工截图确认 |
| 双工作台费用页签 | 头程新增、调整、通过/驳回/作废、凭证、财务分摊 | `ContainerFeesPanel` | 行内纯文字描边,页内工具栏复用公共尺寸;编辑/驳回弹框和消息独立接入,客户归属与金额口径不变 | 待人工截图确认 |
| 双工作台费用页签 | 尾程新增/调整、提交/审核/作废、付款凭证预览 | `ContainerLastLegFeesPanel` | 620px 双列表单、900px 预览及消息独立接入;保留图片 65vh 与全部权限条件 | 待人工截图确认 |
交付按菜单逐个覆盖完整工作区;两种货柜模式共享代码时同时回归。代码检查和人工截图分别记录,不将入口盘点或公共规范可复用等同于本模块已优化。
## 公共实现与范围控制
- `adminDensity.css` 增补 `density-element-tabs`、`density-table` 和 `density-form`,分别供现有 Element 页签、原生资料/历史表和分组表单显式使用;样式仍限定在密度入口内。原生表保留业务列宽和固定操作列。
- `LoadingListDetailDrawer` 增加可选 `dense`;预装默认接入规则保留,货柜已确认清单显式开启,其他未开启消费者仍保持原布局。其局部密度类统一命名为 `loading-density`。
- `CarrierScheduleQueryPanel` 增加可选 `dense`;订舱和货柜详情开启,独立船期入口未开启;原有无密度模式用原尺寸兜底。
- 修改仅涉及样式、布局、图标及可选密度参数;不修改 API、业务权限、字段、提交资格或状态推导。图片原图、标签和 PDF 输出保持原功能。
- 密度合同显式登记 4 个菜单、独立新建/详情和实际子组件,分别检查 DOMESTIC/OVERSEAS 入口、共享组件开启/关闭及预装/已确认模式。原有国内仓与客户管理清单保留。
## 2026-09-14 代码验证结果
- 货柜、订舱、拖车、船期、清单与公共密度定向回归:53 个文件、485 项通过;补充的原生表、共享入口检查及智能装柜/双工作台回归 50 项通过。
- 最终全量 `pnpm test`:249 个文件、1918 项全部通过。首次全量运行中仅跨模块状态合同仍锁定拖车旧图标,更新外观断言后完整复跑通过;原权限、状态、路由和业务提交断言保留。
- `pnpm typecheck`、`pnpm build` 与 `git diff --check` 通过;AGENTS 索引合同 1050 项通过。
- 本轮业务脚本与修改前版本对比,仅增加样式导入、确认浮层类和可选密度参数,没有修改业务处理逻辑。实际浏览器加载、长数据布局、浮层普通/全屏/重开及各权限状态仍待 1920×1080、100% 缩放人工截图确认。
- 本轮未部署、未启动后端或修改业务数据。代码验证不替代实际业务写入及视觉验收。
+58
View File
@@ -0,0 +1,58 @@
# Admin 通用资料样式核对记录
范围为 `adminNavigation.ts` 的通用组 10 个可见菜单,沿正式路由定位到 14 个页面。10 个菜单的 14 个页面已完成代码样式核对;全部修改后页面尚待人工截图确认。隐藏产品信息、税率和旧库位页面不恢复。
原型目录为 `docs/悦/货代SAAS平台后台/prototype-src/`。列表操作依据当前用户确认:不超过 3 个全部显示,超过 3 个才折叠,折叠入口只显示省略号。新增、编辑、详情、文件和确认逐项检查,不能从某一弹框推断其他操作。
| 菜单 | 正式页面入口(`apps/admin-web/src/modules/` 下) | 原型入口 | 已定位操作 / 下一步 |
|---|---|---|---|
| 供应商列表 | `suppliers/pages/SuppliersView.vue`、`SupplierDetailView.vue`、`SupplierFormView.vue` | `05-suppliers.html`:列表 34 行、编辑 108 行 | 独立新增/编辑/详情;实际引用 `components/SupplierLicenseDialog.vue` 560px;核对返回、资质信息与操作权限 |
| 快递公司 | `master-data/express-companies/pages/ExpressCompaniesView.vue` | `05-suppliers.html`:2 行 | 批量导入、580px 编辑抽屉;原型静态列表仅列出新增/导入/导出,具体表单原型未定义,保留当前承载 |
| 往来单位 | `master-data/counterparties/pages/PartnersView.vue` | `05-suppliers.html`:8 行 | 680px 编辑/详情抽屉、批量导入;详情、编辑、启停均已显式接入,保留真实权限 |
| 费用项目 | `fee-items/pages/FeeItemsView.vue` | `10-general.html`:8 行 | 620px 编辑抽屉;启停与删除分别使用确认框;核对表单分组和危险操作 |
| 汇率管理 | `overseas-master-data/pages/ExchangeRatesView.vue` | `10-general.html`:21 行 | 560px 月度汇率新增/编辑框,保留月份和汇率校验 |
| 仓库管理 | `warehouses/pages/WarehousesView.vue` | `10-general.html`:44、51、58、65 行 | 多类仓库列表;650px 新增/编辑框、760px 导入框,无独立详情入口;保留仓库类型、版本、导入文件和状态操作 |
| 海外仓报价 | `overseas-master-data/pages/OverseasQuotesView.vue` | `10-general.html`:167 行 | 980px 详情、1180px 新增/编辑框;保留报价与计费字段 |
| 船期价格表 | `fba-pricing/carrier-rates/pages/CarrierRatesView.vue`、`CarrierRateDetailView.vue` | `10-general.html`:273 行;当前价格簿接口与应用指引优先 | 560px 上传框、独立版本详情、仓库映射/确认;价格行只读,不恢复旧原型手工计价能力 |
| 车辆列表 | `overseas-master-data/pages/VehiclesView.vue`、`VehicleDetailView.vue` | `10-general.html`:314、442 行 | 760px 新增/编辑、480px 状态变更、独立详情;核对关联派送及更换记录 |
| 下载中心 | `downloads/pages/DownloadCenterView.vue` | `10-general.html`:228 行 | 720px 任务详情框、下载与重试;保留任务状态/退避/文件权限 |
共享批量导入组件与供应商资质组件须沿实际调用逐项检查是否能显式接入密度样式。不能仅在父页面增加类名就声称 Teleport 弹框已覆盖。
本轮先实施供应商、汇率、海外仓报价、车辆四个菜单(7 个页面及供应商资质组件)。供应商新增/编辑/详情继续独立路由;资质 560px、汇率 560px、报价详情 980px/编辑 1180px、车辆编辑 760px/状态 480px 保留承载和宽度,车辆详情继续独立页面。海外资料操作证据补充见应用内 `overseas-master-data/PROTOTYPE_MATRIX.md`:`modal-gen-overseas-quote-detail`、`modal-gen-overseas-quote`、`modal-exchange-rate-edit`、`modal-vehicle-edit`。供应商资质来自 `overlays.html:660`,保留其证照版式。供应商分类数量合并到同一组彩色页签,保留四类数量与筛选事件;连续详情使用公共页头与字段网格,列表行内操作均不超过三个,全部直显。状态和停用确认只接入外观,保留原权限、版本与请求。
所有修改后页面仍需人工在 1920×1080、100% 缩放下提供截图核对。
## 其余六菜单逐操作核对
- 快递公司:编辑、启停最多两项直显;580px 编辑抽屉和共享批量导入 720px。原型未定义的具体编辑承载按当前实现保留,不擅自改为独立页。
- 往来单位:详情、编辑、启停最多三项直显;680px 详情/编辑抽屉和共享批量导入 720px。
- 费用项目:编辑、启停、删除最多三项直显;620px 编辑抽屉,启停和删除各有确认框。
- 仓库管理:编辑、启停最多两项直显;650px 编辑和 760px 导入框。不要把 `AppDialog` 内表单误认为独立页面。
- 船期价格表:列表详情、原文件、待确认版本作废最多三项直显;560px 上传框;独立详情页头保留下载与确认生效操作,不按列表行内按钮压缩。
- 下载中心:详情、成功后下载、符合条件时重试;详情 720px 弹框,按状态保留后端重试资格。
共享 `BatchImportDialog.vue` 保持单一 `el-dialog` 为根;挂载测试已验证父传 class 实际到达 Teleport 根。组件直接引用密度变量并保留未接入时的默认值,无需新增参数。
## 已实施四菜单的检查结果
2026-09-14:7 个页面和供应商资质组件显式接入公共密度。供应商分类数量并入彩色页签;汇率四项摘要保留为紧凑一行;三个独立详情/表单页使用公共 `density-detail-layout`、同行返回与业务标题,供应商路由关闭重复页头。报价详情采用两组连续键值表,供应商及车辆长字段跨列;车辆标题不再回退内部 ID,筛选、列表、表单与详情统一使用原中文状态名称。
- 原字段及作业入口保留:供应商表单 14 项、报价表单 8 项及两组 10 个表格列、车辆表单 10 项、汇率表单 3 项;供应商详情 12 项、车辆详情 11 项。已有资质版式和六个弹框的宽度、关闭与提交逻辑保留。
- 定向测试 60 项通过,包含长供应商名称/地址、返回目标、车辆详情失败不展示内部 ID、最多三个操作直接显示;原有版本冲突、长 ID、汇率精度和失败保留表单测试继续通过。
- 全量测试 251 个文件、1956 项通过。随后仅统一车辆状态中文名称,再次运行上述 60 项定向检查和 `pnpm build`(含类型检查),均通过;`git diff --check` 通过。
- 本地 5173 开发服务返回这 7 个页面的已编译密度代码,HTTP 均为 200。该检查证明正在提供新代码,不代替真实页面视觉验收。
四菜单均待人工在 1920×1080、100% 缩放下核对修改后截图。
## 其余六菜单的检查结果
2026-09-14:七个页面和共享批量导入组件完成显式密度接入。列表操作使用白底描边纯文字,最多三项全部直显;抽屉根同时接入页面密度和抽屉密度,弹框根显式接入对话框密度。公共 FormSection 样式覆盖独立浮层,修正只依赖父页面造成的样式遗漏。仓库新增菜单的浮层间距限定在自身,不再覆盖其他菜单。
- 维持前表逐操作承载与尺寸。往来单位详情和下载详情采用两组连续键值网格,长地址、备注与文件跨列;船期价格详情使用同行返回、版本号、状态及主要操作,摘要保留报价范围和上传时间,正文为连续白底。渠道页签使用公共彩色图标,预览表保留在页签正文外,价格行继续只读。
- 七页脚本语法树比对(排除样式导入、已移除装饰图标与确认框外观参数)及所有表单绑定比对均一致,原业务处理、权限、版本、仓库映射、导入预检与下载重试逻辑保留。
- 定向检查 94 项通过,包含排车六组权限下的操作数量、往来单位详情浮层、下载详情 11 个字段、批量导入普通/密度模式及成功提交;类型检查通过。
- 全量测试 251 个文件、1957 项通过;构建(含类型检查)和差异空白检查通过。
- 本地 5173 返回这七页及排车列表的新密度代码,HTTP 均为 200。排车列表已是纯省略号入口;代码与开发服务验证不能代替页面截图。
通用 10 菜单代码检查完成,全部待人工在 1920×1080、100% 缩放下核对修改后截图。首页、系统管理另行继续。
@@ -0,0 +1,28 @@
# Admin 首页与系统管理样式核对记录
范围为导航首页 1 个菜单及系统管理 3 个菜单,沿正式路由进入四个页面。依据当前确认的高密度样板,保留权限、状态、请求和真实字段。四页代码样式及直接交互检查已完成;全部页面待人工在 1920×1080、100% 缩放下提供修改后截图。
原型目录:`docs/悦/货代SAAS平台后台/prototype-src/`。页面入口相对 `apps/admin-web/src/modules/`。
| 页面 | 操作 | 原型证据 | 当前实现与结论 |
|---|---|---|---|
| `dashboard/pages/DashboardView.vue` | 指标、刷新、全屏 | `modules/00-dashboard.html:2`,全屏按钮 7 行、指标 10 行起 | 保留当前四项真实指标、统计口径、部分失败和更新时间;合并为连续信息区,保留原生全屏。无新增、编辑、详情、删除入口,不恢复静态图表、营收和待办演示。 |
| `iam/pages/InternalUsersView.vue` | 新增、编辑、密码重置 | `modules/11-system-messages.html:2`;`overlays.html:545` 新增 650px,682 行编辑 650px | 保留 650px 居中框及两列表单;密码重置仍在编辑框内,保留独立权限、校验、受保护账号和海外 PDA 仓库绑定。 |
| 用户管理 | 启停 | 当前列表 `toggleStatus` | 两项以内全部直显;保留独立确认框、当前登录用户及系统管理员禁用限制。无独立详情或文件入口。 |
| `iam/pages/RolesView.vue` | 新增、配置角色及权限 | `modules/11-system-messages.html:7`;`app.js:5035` 的 `showRoleEdit` 打开模态框 | 保留当前 860px 居中框及左右基础信息/权限树;半选、恢复已选、菜单分组和授予权限独立控制。原型旧数据范围和字段授权不恢复。 |
| 角色权限 | 启停、删除 | 当前 `toggleStatus`、`removeRole` | 三项以内全部直显;启停和删除分别保留确认,内置角色、已关联用户及加载门禁不变。无独立详情、文件入口。 |
| `iam/pages/PermissionsView.vue` | 新增、添加下级、编辑 | `modules/11-system-messages.html:62` 的 `modal-sys-menu-permission`,720px 居中框;`app.js:5235` | 已由 600px 非模态抽屉修正为 720px 居中框,保留当前资源字段与可编辑性、上级候选、保存和未保存登记。 |
| 菜单权限 | 启停、删除、展开/收起 | 原型同文件 54 行树操作;当前 `permissionActionCount` / `showDeleteMore` | 保留树展开/收起与层级;按实际权限超过三项才折叠,省略号图标独显、下拉项纯文字;受保护资源不可修改,删除资格和确认保留。无独立详情或文件入口。 |
公共密度样式通过四页显式导入,所有模态框及消息确认框在自身浮层根接入;不向未声明页面扩散。此处矩阵记录实现范围,不代表视觉验收通过。
## 代码检查结果
2026-09-14:四个页面及 IAM 局部布局样式完成接入,列表按钮沿用公共纯文字描边与语义色;菜单权限最多三项直显、第四项进纯省略号菜单,保留原权限及内置资源门禁。角色权限树保持半选与已保存授权恢复,长名称及权限编码可换行,无授权配置权限时基础表单使用完整宽度。首页四项指标使用连续键值信息区,刷新及全屏使用普通工具栏按钮;不增加未接通的演示数据。
- 93 项定向检查通过,覆盖用户/PDA 仓库绑定、角色权限树、菜单权限数量及保护、居中框真实挂载、新增/编辑保存失败保留表单、首页部分失败刷新和进出全屏。菜单权限弹框测试明确模拟表单校验成功/失败结果,避免 Vitest 外部加载 Element Plus 时的 CJS 校验库互操作差异;不声称该测试替代真实浏览器校验。
- 四页脚本语法树及表单绑定与修改前一致(排除样式/组件导入、确认框外观参数及已移除的首页装饰色字段),权限、保存、校验规则和统计口径未变。
- 全量测试 252 个文件、1961 项通过;类型检查、构建与差异空白检查通过。
- 本地 5173 的四页源码编译请求均为 200,并返回密度样式入口;该证据只证明开发服务提供了当前代码。
此批无剩余代码检查失败;修改后视觉仍待人工截图。全模块沿组件引用继续复核,不能由四页通过推断其他共享子弹框全部通过。
+269
View File
@@ -0,0 +1,269 @@
# Admin 高密度界面规范
## 确认依据与适用范围
2026-09-14,用户在 Admin 样式优化对话中确认订单列表样板,并要求以此推进国内仓样式统一。样板包括订单列表、测量与订单审核弹框、订单详情的基本信息、货品信息、费用信息、状态历史、测量数据、其他、拆分履约。详情页签使用与客户管理一致的彩色 Emoji 图标。
本规范固定该样板的视觉与密度规则。验收视口为 **1920×1080,浏览器缩放 100%**。其他页面采用本规范不等于其功能或视觉已验收。
这是一套后续 Admin 页面可复用的基线。新增菜单按“新模块接入规则”实施,不复制订单或客户模块 CSS;货柜管理的入口与共享组件边界见 [货柜管理核对记录](Admin货柜管理样式核对记录.md)。海外仓的 10 个菜单及共享详情边界见 [海外仓核对记录](Admin海外仓样式核对记录.md)。列入下一批入口不代表页面已完成改造或验收。
当前推广范围包括 Admin 导航中的“国内仓”,以及本轮确认的“客户管理”两个入口:客户列表、海外仓阶梯费用,包含各入口实际使用的详情、页签、新增、编辑、审核、确认、图片/文件预览和其他业务弹框。客户管理下的海外仓阶梯费用属于客户费用配置,不扩大到海外仓作业菜单。跨菜单跳转应记录目标入口和共享组件影响;不因此把另一个菜单的全部页面自动加入改造范围。Client、PDA 与其他菜单采用规范时另行明确范围。
样板实现入口:
- [公共密度样式](../apps/admin-web/src/styles/adminDensity.css):页面显式使用 `admin-density`,Teleport 弹框显式使用 `admin-density-dialog`;默认不改变其他菜单。
- [公共密度变量](../apps/admin-web/src/styles/adminDensityTokens.css):两模块的字号、间距、控件/行/按钮/页签高度、圆角、底色和按钮语义色的唯一默认值入口。
- [维护合同检查](../apps/admin-web/src/styles/adminDensity.test.ts):限定已接入消费者,检查变量归属、公共样式作用域及页面重复实现。
- [测量作业密度](../apps/admin-web/src/modules/inbounds/measurement/measurementDensity.css):测量与审核共享作业布局,宽度由测量弹框自身提供。
- [国内仓逐操作核对记录](Admin国内仓样式核对记录.md):三组页面的承载方式、例外和验收证据。
- [客户管理局部密度样式](../apps/admin-web/src/modules/customers/customerDensity.css):客户列表、详情、计费与相关弹框的显式密度入口。
- [客户管理逐操作核对记录](Admin客户管理样式核对记录.md):两个菜单入口及关联详情、审核、编辑与预览的范围和人工验收项。
- [订单列表与详情](../apps/admin-web/src/modules/unified-orders/pages/UnifiedOrdersView.vue):列表、顶部摘要、描述表、页签、日志表及订单弹框。
- [订单局部密度样式](../apps/admin-web/src/modules/unified-orders/styles/orderDensity.css):费用、履约与测量弹框的局部样式。
- [国内仓导航](../apps/admin-web/src/core/navigation/adminNavigation.ts):`domestic-warehouse` 分组是菜单范围来源。
本规范与 [Admin/Client 前端原型还原验收规则](Admin-Client前端原型还原验收规则.md) 配合使用。在本规范适用范围内,已确认的密度、容器和页签图标规则优先于历史原型中的大卡片、大留白和单色页签图标;字段、业务分组、权限、操作语义及页面/弹框/抽屉承载方式仍按当前业务契约和逐操作矩阵核对。样板中某个字段的专属宽度不能直接当作其他页面的固定宽度。
## 基本原则
表格顶部操作栏的桌面尺寸统一由 `DataTable.vue` 维护:`padding: 6px 12px`、`min-height: 44px`。普通栏与 `.is-compact` 共用尺寸,后者只改变对齐;不再按 1920px 断点切换尺寸。公共密度样式和业务页面不得重复声明这部分 padding / height;组件现有移动端排列规则保留。
1. 普通业务内容采用连续白底、细边框、描述表与数据表格。短字段以“标签 + 值”连续排列,不制作独立指标卡片。
2. 先压缩重复标题、重复汇总、容器层数和无意义留白,再调整列宽与行高。正文保持可读,不通过缩小整页、浏览器缩放或截断关键值提高密度。
3. 长编号、地址、邮箱、文件名与备注允许跨列、换行或按已确认方式查看完整值。空值显示“—”或明确的未关联状态,不显示内部数据库 ID。
4. 业务状态使用现有语义色和文字。只有真实阻断、错误、风险才使用明显的警示区域。
5. 密度改造不改变计算口径、API、保存触发方式、权限资格、状态机或字段含义。遇到功能问题单独记录,不能用样式隐藏。
## 尺寸与颜色
下表是普通桌面业务布局基线;图片、长内容、错误反馈和可编辑表格可以自然增高,不能强制裁切。
| 项目 | 基线 |
|---|---|
| 正文、表格、输入文字 | 13px |
| 标签、辅助说明 | 12px;不进一步缩小 |
| 分区标题 | 14px,适度加粗 |
| 页面业务编号、弹框标题 | 16px |
| 普通页签 | 高 38px,左右内边距 16px,图文间距 6px |
| 页签图标 | 17px,固定 18px 占位,独立彩色显示 |
| 普通表格行 | 基准 36px,单元格上下内边距 4px;按内容自然增高 |
| 普通输入框与工具栏按钮 | 通常 32px |
| 表格内操作按钮 | 28px,保留文字、焦点和禁用反馈 |
| 区块间距 | 通常 12px;标准列表遵守现有 ListPageLayout 的单一间距,不叠加页级空白 |
| 普通内容内边距 | 12px;弹框正文 12px 16px |
| 容器圆角 | 0–4px;普通数据区无装饰阴影、渐变或上浮效果 |
| 内容底色 | 白色 |
| 表头、描述表标签底色 | 浅灰,参考 #f8fafc / #f9fafb |
| 边框 | 现有 `--cc-border`;表格参考 #e5e7eb |
| 悬停、斑马纹 | 轻量区分,参考 #f3f7fb / #fcfcfd |
颜色优先复用当前 `--cc-*` 与 Element Plus 语义变量。金额和测量数字可使用等宽数字排版,只有总计或关键结论适度加粗,不将每个值都加粗。
## 各类页面规则
### 列表
- 继续使用 `ListPageLayout + QueryToolbar + DataTable`。查询、批量操作、表格、分页构成连续工作区,不重复放置正文大标题或统计卡片。
- “操作”列始终第一列,需要固定时固定在左侧。最多三个高频操作直接显示,更多操作使用已有菜单约定;保留页面已确认的直接操作数量。国内仓全部 7 个菜单及客户管理的行内业务操作采用下述纯文字描边按钮。危险动作保留明确文字与确认。
- 列宽按实际编号、状态和按钮数量决定;不能把样板的 260px 操作列套给所有列表。核对最多按钮、加载中和权限变化后的布局。
- 数值列右对齐,单位优先放表头;编号按业务需要设置宽度。保留横向滚动、列偏好、分页和已有行内编辑能力。
#### 国内仓行内按钮(以订单列表样板统一)
依据用户提供的白底描边按钮参考图,订单列表行内操作采用纯文字、白底、1px 细边框,无阴影,圆角 6px;保持 28px 高、13px 字号、600 字重、左右内边距 10px、按钮间距 6px。该按钮圆角为明确例外,普通内容容器仍为 0–4px。
- 默认行内操作(详情、编辑、备注、更多):蓝色文字 `#1777ff` 与浅灰边框,由 `--admin-action-neutral` 统一维护。
- 审核“通过”:橙色文字与浅橙边框,保留原确认流程。
- 测量:紫色文字与浅紫边框。
- 悬停使用对应浅色底,键盘焦点保持可见;禁用、处理中状态保留。更多入口统一为 `…`(Lucide `MoreHorizontal`),保留包含业务对象的“更多操作”悬停提示和可访问名称;下拉项纯文字。
该规则覆盖订单列表、订单测量、测量数据审核、预装清单、库位管理、托盘管理、整柜业务以及其详情中的行内操作。查看/编辑/备注为默认蓝色 `#1777ff`,测量为紫色,审核为橙色,启用为绿色,停用/删除为红色。预装共用组件只在 `preload` 模式接入,已确认装柜清单模式保持原规则。顶部查询/新增/批量操作和弹框提交仍使用 32px 主次按钮,不机械改成行内按钮,也不增加样板中不存在的业务操作。
#### 客户管理按钮与详情
- 客户列表、海外仓阶梯费用及关联详情、编辑、审核、预览的业务操作按钮采用纯文字;查询、重置、复制也去掉装饰图标。分页箭头、列设置、筛选布局、关闭与全屏等独立图标控件保留,处理中旋转图标保留。
- 客户列表行内按钮沿用 28px 白底描边、6px 圆角、6px 间距:详情/编辑/更多为默认蓝色 `#1777ff`,审核为橙色,重置密码为紫色,跳转登录为蓝色,启用为绿色,停用/删除为红色。更多入口与订单列表统一显示 `…`,不显示“更多”文字;悬停提示及可访问名称保留,菜单项纯文字,保留原权限与操作分配。
- 海外仓阶梯费用列表按原档位顺序显示“天数:单价”,在费用单元格中连续排列、整组自然换行;不制作每档独立白块,不强制左右两列,不隐藏后续档位。币种只在独立列展示,计费单位保留在表头;零单价照常显示 `0`,没有档位显示“—”。编辑按钮沿用默认蓝色 `#1777ff` 文字、白底描边。
- 阶梯费用编辑弹框中的“新增阶梯”为蓝字浅蓝边、“删除”为红字浅红边,均使用 28px 高、6px 圆角的白底纯文字按钮;客户详情的海外仓费用编辑同步执行。底部保存/取消沿用 32px 主次按钮。按钮规则同时覆盖页面与 Teleport 弹框,并显式设置普通、悬停、焦点及禁用状态,避免默认主题色覆盖。
- 客户详情保留彩色页签;企业信息用连续四组键值表,联系与合作信息保留三组键值表,长地址整行。资质文件采用连续行,企业成员使用表格。
- 客户新增/编辑保留 1120px 三列表单,入驻审核保留 1040px,箱规规则编辑保留 1200px,海外仓阶梯编辑保留 1120px。宽度属于当前内容的适配值,不推广为全局默认。
### 详情与基本信息
- 第一行集中放返回、业务编号、核心状态及主要操作。下一行用可换行摘要展示关键上下文,不使用大数字概览卡。
- 返回使用 `density-back` 轻量导航,与业务编号和状态排在同一行,保留箭头、文字、键盘焦点及原返回目标。该行并入连续白底详情区域,不为返回单独增加卡片或空白行;整柜库存的标题行与基本信息共用一个信息区。
- 页签紧接摘要。页签已表达的标题不再在正文重复占用一大行;数量、更新时间与相关操作可放紧凑工具栏。
- 短字段通常每行四组,长内容跨两组或整行。描述表标签浅灰、数值白底,边线连续;标签宽度以 112px 为样板参考,按字段实际长度调整。
- 保留字段顺序和业务分组,不使用会改变阅读顺序的自动密集填空布局。
- 同一个订单或实体内部的内容连续分区,不再层层套卡片。
### 费用、汇总与测量
- 汇总使用单行摘要或紧凑描述表。自动费用与其他费用保持各自业务口径,金额、单价、计费量、附加费和版本完整展示。
- 保留公式、计费依据和价格来源入口,不能为了减少行数合并不同口径的金额或重量。
- 测量各来源保留独立分组;展开时在表尾显示合计,折叠时在标题显示摘要,同一口径不在上下重复占位。
- 同一计算策略只保留一个明确操作入口,保存中、无权限、冻结与失败信息照常显示。
- 称重图片可用缩略图加紧凑事实行;保留查看原图、托盘/库位信息和人工修正入口。可视化、图纸、标签预览、库位示意等按业务用途保留合适尺寸。
### 历史、备注与空态
- 大量状态历史与操作日志优先使用表格:时间、事件、状态、操作人、备注。保留全部记录和原有排序,不合并或删除相似事件。
- 长备注自然换行;简短备注也可使用连续列表。空态使用简短文字,不用大型插画或为无数据区设置大最小高度。
- 真实流程进度图可以保留,不能因日志样板是表格就删除业务需要的阶段关系。
### 弹框与表单
- 保留各操作已确认的独立页面、居中弹框或抽屉承载方式。审核确认不自动变为完整详情;简单备注不套用大型作业弹框。
- 标题和底部操作保持可见,长内容只滚动正文。优先使用 `AppDialog` 的原生标题栏和已有全屏能力。
- 宽度按内容选择:简单确认/备注参考 560–600px,普通双列表单参考 680–920px,批量预览参考 1100px;订单测量作业样板为 1560px、92vh。以上是类型参考,不是所有弹框的统一固定尺寸。
- 普通弹框参考上边距 6vh、最大高度 88vh;大型测量作业参考上边距 4vh、最大高度 92vh。全屏时使用组件的完整视口规则。
- 表单控件、单位、必填标识、校验错误和原因说明完整保留。长表单按业务分组排两至四列,文本域按需跨列。
- 审核确认清楚展示业务对象;批量审核保留逐票预览、选中数量和不可操作原因。保留处理中锁定、关闭未保存确认与失败上下文。
## 彩色图标
导航性质的详情页签采用与客户管理一致的彩色 Emoji,同一语义尽量复用同一图标:
| 语义 | 图标 |
|---|---|
| 基本信息 | 📋 |
| 货品、货物明细 | 📦 |
| 费用 | 💰 |
| 历史、日志 | 📝 |
| 测量、尺寸 | 📐 |
| 其他资料 | 🗂️ |
| 拆分履约 | 🧩 |
| 计费规则(客户管理已有) | ⚖️ |
图标只作辅助,保留文字标签;装饰图标设置 `aria-hidden="true"`。选中态通过文字色和底部横线表示,不把 Emoji 强制染成单色。采用系统彩色 Emoji 字体,不引入图片生成或外部图标下载依赖。
彩色图标用于详情页签。国内仓 7 个菜单的行内按钮与客户管理业务操作使用纯文字;返回箭头、顶部与弹框的既有功能图标可保留。不要给操作按钮、表头或字段添加 Emoji。
## 国内仓推广清单与顺序
此表是实施入口,不是完成状态。每个入口必须继续沿实际组件引用盘点可达弹框与详情,不把父页面验收等同于所有子界面通过。
| 顺序 | 菜单 | 主入口(相对 apps/admin-web/src) | 本组应覆盖 |
|---|---|---|---|
| 样板 | 订单列表 | `modules/unified-orders/pages/UnifiedOrdersView.vue` | 列表、详情全部页签、测量、单票/批量审核及实际可达弹框 |
| 1 | 订单测量 | `modules/inbounds/pages/InboundMeasurementsView.vue` | 列表、测量作业、称重图片与相关编辑/预览 |
| 1 | 测量数据审核 | `modules/inbounds/pages/MeasurementReviewsView.vue` | 列表、审核工作区、明细、确认及当前实际存在的修正操作 |
| 2 | 预装清单 | `modules/loading-lists/pages/PreloadPlansView.vue`、`PreloadPlanDetailView.vue` | 列表、详情各页签、相关确认及打印/预览容器 |
| 3 | 库位管理 | `modules/warehouse-resources/locations/pages/LocationsView.vue` | 列表、详情、新增/编辑及实际存在的批量操作 |
| 3 | 托盘管理 | `modules/warehouse-resources/pallets/pages/PalletsView.vue` | 列表、详情、新增/编辑及实际存在的关联操作 |
| 3 | 整柜业务 | `modules/domestic-containers/pages/DomesticContainersView.vue`、`DomesticContainerInventoryDetailView.vue` | 入库/出库/库存工作区、库存详情与实际可达弹框 |
每组先统一列表,再处理详情与弹框;同一业务工作区一起交付人工查看。未注册、已重定向或只存在于历史原型的页面不能只凭文件存在就恢复或加入本轮。
## 实现与验收
1. 改造前读取最近指引,沿菜单、路由、组件与事件定位实际页面;填写逐操作矩阵。原型已有承载方式不因密度统一而替换,明确的样式改造可在已授权范围内推进。
2. 多个真实消费者使用同类密度时,将稳定尺寸与布局能力收敛到本应用的样式变量或现有共享组件的可选能力。先明确接入范围,不让全局默认变化影响尚未改造的菜单。
3. `adminDensityTokens.css` 定义默认值,`adminDensity.css` 承载通用组件规则;`orderDensity.css`、`measurementDensity.css`、`customerDensity.css` 只保留各自业务布局与引用公共变量的局部规则。按钮外观不得在模块中另写一套。跨菜单共用组件(如模拟柜创建、托盘码打印)通过可选 `dense` 参数接入;未传参数的调用保持原布局。
4. 弹框经 Teleport 后必须仍能获得对应样式。检查普通、全屏、关闭、重开以及多个入口使用同一组件时的表现;避免无边界的全局 `.el-dialog`、`.el-table` 或 `!important` 覆盖。
5. 每页检查长编号、长地址、多条数据、空态、失败态、无权限、禁用态及处理中状态;所有字段、单位、排序、权限与操作资格应保留。测试不得继续锁定已废弃的卡片结构,也不能删除业务断言以换取通过。
6. 按 Admin 指引运行直接影响测试、类型检查、全量测试与构建;公共组件检查实际消费者。视觉默认由用户在指定视口操作并截图,缺少证据标记“待人工截图确认”;用户明确要求 AI 界面验收时,按本轮授权操作并记录实际证据,不将代码测试当作视觉通过。
7. 每组交付说明修改页面、公共影响、检查结果、剩余视觉差异。任何尺寸或布局例外记录在逐页矩阵中,避免把一次性例外写成新的全局默认。
逐页核对记录模板:
| 页面/路由 | 操作及承载方式 | 原型或确认依据 | 采用规则/业务例外 | 检查与截图结论 |
|---|---|---|---|---|
| 填写具体页面 | 分别填写详情、编辑、审核、预览等 | 原型 ID、代码入口或用户确认 | 列表/描述表/日志/作业弹框等 | 代码检查结果;待人工截图确认或实际结论 |
规范只记录稳定规则和入口。各组进度、临时问题与验收结果写入对应核对记录,不写入 `AGENTS.md`。
## 国内仓、客户管理的样式维护边界
本节适用于已接入的两个菜单及其实际子组件,不改变其他菜单默认样式。此次收敛以保留业务字段、权限、计算与承载方式为前提。
| 要修改什么 | 唯一维护位置 | 页面需要做什么 |
|---|---|---|
| 正文/辅助/分区/页头字号 | `adminDensityTokens.css` 的 `--admin-font-*` | 引用变量,不重新写 13px 等普通字号 |
| 表格行高、输入高度、操作按钮高度、页签高度 | `--admin-row-height`、`--admin-control-height`、`--admin-action-height`、`--admin-tab-height` | 使用已有公共组件和公共类 |
| 常用间距、圆角、普通数据区底色 | `--admin-space-*`、`--admin-radius`、`--admin-surface` 等 | 业务布局引用变量;不可在页面重定义变量默认值 |
| 描边按钮外观、悬停/禁用/焦点状态 | `adminDensity.css` 的 `.density-action` | 只选择 `data-tone` 与业务事件 |
| 详情返回导航 | `.density-back` | 使用 `link`,保留返回事件和可访问名称;客户管理按纯文字约定保留返回文字 |
| 详情页头与业务摘要 | `.density-detail-header` / `.density-detail-heading` / `.density-detail-title` / `.density-detail-overview` | 与国内订单样板共用排版;页面提供实际业务编号、状态及摘要,不复制标题字号和摘要间距 |
| 连续白底独立详情容器 | `DetailPageLayout` 上的 `.density-detail-layout` | 显式接入统一页头分隔线、正文间距与零区块间隙;正文去掉重复面板,业务仅保留列数和长字段跨列 |
| 详情页签及彩色图标 | `.density-tabs`、`.density-tab-icon` | 保留标签、键盘事件和激活态;只配置业务内容 |
| 现有 Element 详情页签 | `.density-element-tabs` | 保留 `el-tabs` 的键盘处理、权限条件与切换事件,标签复用彩色图标 |
| 现有原生资料表、状态历史表 | `.density-table` | 保留字段、记录排序、业务列宽和固定操作列;不另写行高、边线和表头底色 |
| 分组页面与弹框表单 | `.density-form` | 显式接入统一标签字号和字段间距,模块只维护列数、跨列及字段顺序 |
| 既有 FormSection 分组 | `.density-form-section` | 在 `FormSection` 上显式添加,复用公共标题字号和分组间距,保留业务标题与分组顺序 |
| Element 描述表 | `.density-descriptions` | 页面显式接入统一标签/内容字号与行高;保留原字段及列数 |
| 连续信息表边框、标签底色、文字与内边距 | `.density-fields` | 只配置列数、跨列、字段顺序;特殊标签宽度使用局部 `--admin-field-label-width` |
| 弹框/抽屉标题、正文滚动、底部间距 | `.admin-density-dialog` / `.admin-density-drawer` | 容器显式接入,保留该业务已确认宽度与作业高度 |
| 客户资质列、测量对照列、预装信息布局 | 对应模块 CSS 或页面 `scoped` | 只保留业务列宽、列数、图片尺寸、定位与排列 |
### 使用约定
```html
<!-- 业务按钮只选择语义,不复制尺寸、颜色及交互状态规则 -->
<el-button class="density-action" @click="edit">编辑</el-button>
<el-button class="density-action" data-tone="warning" @click="review">审核</el-button>
<el-button class="density-action" data-tone="danger" @click="remove">删除</el-button>
<!-- 更多入口仍是三点,必须保留可访问名称与悬停提示 -->
<el-button class="density-action" aria-label="更多操作" title="更多操作">
<MoreHorizontal :size="17" aria-hidden="true" />
</el-button>
```
`data-tone` 可用 `neutral`(默认)、`warning`、`purple`、`primary`、`success`、`danger`。每个页面仍按已确认的动作配色表选择;纯文字与图标策略不由 CSS 猜测。按钮组使用 `density-actions` 统一间距,业务布局可决定是否换行。
`density-actions` 使用 `gap` 管理间距,并将 `--cc-list-action-sibling-margin` 设为 0;`DataTable` 通过该变量保留未接入按钮的默认 8px 左边距。不得将两份间距叠加,也不以更强选择器在页面逐个覆盖。
### 防止维护退化
1. **同一通用外观只有一个实现。** 已由公共类接管的按钮配色、页签高度、信息表边框或弹框内边距,删除原页面声明,不能保留两套后再增加优先级或 `!important`。
2. **新增通用尺寸先改变量。** 模块与页面只引用 `--admin-*`;禁止在页面重定义公共默认值。局部标签宽度是明确的业务参数,不改变全局标签宽度。
3. **不要把代码搬到模块 CSS 就称为公共化。** 多个业务共用的按钮、页签、信息表与弹框规则留在公共层;模块 CSS 不能依赖另一个业务模块的类名,也不引入第二套按钮颜色变量。
4. **保留显式接入边界。** 公共样式不能使用无范围的 `.el-button`、`.el-table`、`.el-dialog` 选择器。Teleport 的弹框、抽屉必须自己携带密度类;不能仅依赖页面父级。
5. **共用组件保留未接入消费者。** CSS 变量的兜底值用于独立渲染和未接入入口;两个已接入模块始终由公共变量决定实际值,兜底值不是第二份密度配置。打印标签、PDF 尺寸、原图、画布和既有窄屏规则不能机械替换。
6. **公共调整按消费者回归。** `adminDensity.test.ts` 维护明确的页面/组件清单,不扫描全部后台。检查模板中公共按钮、页签和信息表的真实祖先是否带有密度入口,不能仅断言文件导入了 CSS 或出现过密度类;页面弹框带有 `admin-density-dialog` 不代表列表与详情已接入。新增接入时同步清单及逐操作矩阵,运行合同、受影响测试、类型检查、全量测试和构建。
7. **视觉与代码分开记录。** 本轮默认人工截图;代码通过不等于视觉验收通过。已确认业务宽度和工作区高度可以保留为例外,例外写入核对记录。
## 新模块接入规则
### 先确定范围,再选择公共能力
1. 用导航与实际路由列出全部菜单入口,以及新建、详情、编辑和深链页面;不能把文件夹数量当作页面覆盖数。为每个页面沿实际组件引用列出弹框、抽屉、消息确认、预览与打印入口,按逐操作矩阵记录承载方式。
2. 默认复用普通表格 36px 行高、正文 13px、辅助文字 12px、行内描边按钮 28px、页签 38px、轻量返回和连续信息表。新模块的行内业务操作默认纯文字;顶部/提交保留 32px 主次按钮。动作语义色沿用本规范,不通过按钮文字自动猜测。
3. 模块只保留业务列宽、字段跨列、布局与图纸尺寸等参数。`density-fields` 的四列是默认值,不是固定业务要求;返回位置与大弹框尺寸也不能从订单整页复制。
4. 编辑表格、流程图、装柜模型、数据看板、图片、标签和 PDF 保留业务所需面积;指标卡和图表不得套用 `.density-fields`或其他详情表格样式,模型画布和打印尺寸不套普通行高。空态、错误、长内容、处理中、只读和无权限仍要可用。
### 完整接入示例
下面展示已有页面的接入结构,`rows`、`columns`、事件与表单仍使用该页面原实现。页面脚本直接导入 `@/styles/adminDensity.css`,不依赖先进入其他菜单后残留的样式,也不把这份 CSS 放入 `scoped` 样式块。
```vue
<AdminPage class="admin-density">
<ListPageLayout>
<DataTable :data="rows" :columns="columns">
<template #actions="{ row }">
<div class="density-actions">
<el-button class="density-action" @click="openDetail(row)">详情</el-button>
<el-button class="density-action" data-tone="warning" @click="review(row)">审核</el-button>
</div>
</template>
</DataTable>
</ListPageLayout>
<AppDialog class="admin-density-dialog" v-model="editorOpen" title="编辑资料">
<!-- 原有业务表单 -->
</AppDialog>
</AdminPage>
```
此结构只说明样式接入,不授权新增审核等业务动作。详情复用页面根节点的 `admin-density`;原生详情页签用 `density-tabs` / `density-tab-icon`,连续信息表用 `density-fields`。既有 Element 页签使用 `density-element-tabs`;页签栏与正文分离时隐藏空内容容器。在 `el-tab-pane` 内承载正文的页面必须同时使用 `density-contained-tabs`,保留正文并由公共样式设置内容间距。不得依赖页面 CSS 覆盖隐藏规则。既有原生资料/历史表用 `density-table`,分组表单用 `density-form`。返回按钮使用 `link class="density-back"`,跟编号、状态排在同一行。
Teleport 弹框用 `admin-density-dialog`,抽屉框架用 `admin-density-drawer`,`ElMessageBox` 用 `customClass: 'admin-density-confirm'`;这些浮层必须各自声明,页面祖先不能替代。抽屉当前公共框架负责字号、标题和正文边距;其正文若复用页面级表格、信息表或按钮原语,在正文业务根节点显式接入 `admin-density`。不要把名为 Drawer 的嵌入组件当作实际抽屉,承载方式以模板和调用端为准。
### 共享组件与检查的扩展
- 对每个共享组件列出真实调用方及模式。现有 `dense` 参数由调用入口显式传递;`mode` / `workbench` 属于业务范围,不能为了样式变更其含义。新增消费者后,对开启和关闭密度、国内和海外等实际模式分别回归。
- `adminDensity.test.ts` 中菜单清单、`densityPages`、`consumers`、`moduleCss` 分别覆盖导航、独立入口、子组件和模块 CSS。接入时补充受影响清单,不移除国内仓和客户管理基线,也不通过递归扫描全后台推导范围。
- 动态类检查显式执行预装/已确认、`dense` 开/关,以及 DOMESTIC/OVERSEAS 入口;货柜已确认清单与船期浮层有独立开关断言。接入其他条件时补充真实上下文和对应开关用例,不能一律传 `preload`、强行把所有条件置真,或只断言出现过类名。
- 检查必须同时覆盖 CSS 独立导入、公共原语的实际祖先、Teleport 容器自己的类、变量定义与消费、按钮间距、普通/悬停/焦点/禁用,以及原有业务行为。源码合同只防止已知结构退化,不能证明最终 CSS 优先级或布局视觉正确。
- 发布前的代码门禁为密度合同、受影响业务测试、类型检查、全量测试和构建;维护指引后运行 AGENTS 索引合同。人工按 1920×1080、100% 缩放检查独立打开/刷新、列表到详情、浮层普通/全屏/重开、长数据和权限状态。缺少对应截图时保留“待人工截图确认”。
+97
View File
@@ -0,0 +1,97 @@
# Client 界面样式规范
## 范围与验收状态
- 本规范由客户端样式对话确认建立,首个验收样板为“头程物流 → 订单列表”(`/orders/first-leg`)。当前为首版实现,待人工截图确认;通过后再逐模块推广。
- 只维护 `apps/client-web/` 内的样式和公共组件,不与 Admin 跨应用链接依赖。
- 左侧菜单背景保持现状,不修改 `shell.css` 中的导航背景,也不通过全局品牌变量间接改变导航。
- 标准通过正文容器 `.client-ui` 接入;首批覆盖订单列表。创建、详情与其他模块需分别核对承载方式和字段,不以列表通过代替整模块验收。
- 按 1920×1080、浏览器缩放 100% 验收,保留现有窄屏能力。
## 公共入口与职责
| 入口(相对 apps/client-web) | 职责 |
|---|---|
| `src/styles/client-ui.css` | `--client-*` 颜色、尺寸、间距;正文按钮和状态外观 |
| `src/components/common/QueryToolbar.vue` | 搜索栏;`layout="grid"` 为四列筛选布局 |
| `src/components/common/DataTable.vue` | 表格、工具栏、行操作、分页;工具栏内容用插槽提供 |
| `src/components/common/tableActions.ts` | 三个操作以内全部展示,超过三个折叠;禁用和加载状态执行保护 |
| `src/modules/first-leg/utils/firstLegOrder.ts` | 头程状态码 → 语义色,不在公共样式中判断业务状态码 |
页面只提供字段、操作、业务状态与权限,不再覆盖公共工具栏 padding、表格行高及分页 padding。确有业务差异时增加明确的组件能力,避免重复页面 CSS。
## 尺寸与布局
| 项目 | 标准 |
|---|---|
| 正文、表格、按钮字号 | 13px |
| 次要说明、状态字号 | 12px |
| 普通按钮、输入框 | 高 32px |
| 表格行操作按钮 | 高 28px,左右 padding 8px |
| 按钮间距 | 8px |
| 搜索栏 | padding 16px;四列;字段间距纵向 12px、横向 16px |
| 搜索操作区 | 顶部 margin 12px、padding 12px,分隔线与字段区区分 |
| 表格工具栏 | 最小高 48px;padding 8px 16px;实际高度随操作换行增长 |
| 表头 | 高 44px,浅灰底,600 字重 |
| 数据行 | 高 48px;白底与浅灰交替,悬停浅蓝 |
| 单元格 | 左右 padding 12px,行高 20px |
| 分页 | 最小高 58px;padding 12px 16px;页码按钮 32px |
| 内容分区间距、圆角 | 16px、6px |
长编号保持可辨认,普通长文本省略并提供完整内容提示;金额右对齐,状态居中。操作列在业务列最左侧并固定,批量选择框在其前方。列设置、排序与选择行为保持原有业务逻辑。
## 颜色与按钮
| 用途 | 文字色 | 背景色 |
|---|---|---|
| 默认操作 | `#1777ff` | 白色 |
| 主要操作 | 白色 | `#1777ff` |
| 处理中 | `#175cd3` | `#eff8ff` |
| 成功 | `#067647` | `#ecfdf3` |
| 待处理、提醒 | `#b54708` | `#fffaeb` |
| 失败、危险 | `#b42318` | `#fef3f2` |
| 草稿、中性 | `#475467` | `#f2f4f7` |
| 禁用按钮 | `#98a2b3` | `#f9fafb` |
- 查询、创建等主要操作使用实心蓝色;行内详情、编辑等使用白底描边蓝字,统一使用纯文字,不显示装饰图标。加载中的进度指示保留。
- 行操作先按权限及业务条件过滤,再计算数量;1–3 个全部展示,4 个及以上显示前两个和 `…`。入口保留“更多操作”的可访问名称。
- 禁用操作不被隐藏,不因禁用改变位置;加载中不可重复执行。
- 表格顶部批量操作不套用行操作的三个阈值,保留明确的批量入口和创建入口。
- 危险操作仍需原有业务确认;样式不改变权限和提交条件。
## 头程状态映射
| 状态码 | 文案 | 语义色 |
|---|---|---|
| DRAFT | 草稿 | 中性灰 |
| PENDING_REVIEW | 待审核 | 提醒橙 |
| WAIT_MEASURE | 待测量数据 | 处理中蓝 |
| WAIT_PALLETIZE | 待组托 | 处理中蓝 |
| WAIT_SPLIT | 待分柜 | 处理中蓝 |
| LOADING | 装柜中 | 处理中蓝 |
| LOADED | 装柜完成 | 成功绿 |
| DELIVERED | 派送完成 | 成功绿 |
| COMPLETED | 已完成 | 成功绿 |
测试环境复验补充:服务端统一订单已正式返回 `COMPLETED`,列表、筛选和详情沿用该状态,不能再显示英文或映射成“装柜完成”。订单号列宽 240px,入仓码列宽 230px,状态默认紧随入仓码,保证首屏可识别。
标签包含状态文字,颜色为辅助识别。其他模块需按业务阶段单独列出状态映射,不以中文模糊匹配统一推断;未知状态不得伪装为处理中或完成。
## 样板逐操作验收矩阵
| 检查对象 | 首版目标 | 验收方式 |
|---|---|---|
| 左侧菜单 | 背景与修改前一致 | 同视口截图对照 |
| 列表首屏 | 筛选、工具栏、表格、分页层次一致 | 有数据的完整页面截图 |
| 搜索 | 查询和重置有效;输入框不遮挡、字段标签完整 | 查询前后、重置后 |
| 工具栏 | 列设置、下载、导出、批量提交、批量导入、创建入口保留 | 未选择/已选择/无写权限 |
| 行操作 | 1、2、3 个不折叠;4 个时前两个加省略入口 | 按权限与业务条件验证,不修改真实订单来凑状态 |
| 状态 | 草稿、待审核、处理中、装柜完成的语义颜色不同 | 对照多状态列表 |
| 表格 | 横向滚动时选择、操作、订单号可用;完整值可查看 | 长编号、长渠道、空值、列重排 |
| 分页 | 条数和页码完整;更换条数回到第 1 页 | 10/20/50 条、首尾页 |
| 加载/失败/空态 | 失败保留重试和工具栏;不把失败显示为无订单 | 加载、失败重试、空结果 |
| 关联操作 | 详情、费用、编辑、报关入口保持原有行为 | 只读打开核对;写入另按授权执行 |
自动化检查不代替真实页面截图。列表样板确认前,不将其他 21 个菜单标记为已统一或已验收。
2026-09-15 经用户明确授权完成内置浏览器验收:测试部署 `client-web-test #282`、提交 `7068f49` 成功;1920×1080、100% 缩放下检查首屏、筛选重置、全选、列设置、10/20/50 分页、横向固定列、详情入口以及灰/橙/蓝/绿状态。实测工具栏 padding 为 8px 16px,分页为 12px 16px,表头/行高为 44px/48px,行按钮 28px、#1777ff;菜单背景与部署前一致。四操作折叠分支仅自动化覆盖,缺少真实四操作数据;用户对视觉标准的最终确认待完成。
+19 -1
View File
@@ -4,31 +4,49 @@
## 业务域文档
- [业务快速索引](业务快速索引.md):按关键词检索跨端代码入口与业务边界;先查根 AGENTS.md 短路由,按需读取命中行。
- [订单与客户](订单与客户/README.md):登录认证、客户与企业、权限、头程订单和客户工作台。
- [入库测量与计费](入库测量与计费/README.md):预约取货、到仓收货、箱码与箱唛、测量、尺寸策略、计价和入库费用。
- [国内货柜履约](国内货柜履约/README.md):分柜与同柜、智能排柜、订舱、拖车、PDA 拣货、装柜、报关和国内出仓。
- [海外仓业务](海外仓业务/README.md):船节点、海外接柜、入仓、库存、托盘、换标、转运、废弃、退货、排车、PDA 出库、派送、异常退回、客户回访、一件代发及海外本土业务。
- [船节点标准化契约](海外仓业务/船节点标准化契约.md):统一船司原始状态、标准节点、预计/实际时间、异常事实、还柜停止规则和新增船司接入门禁。
- [财务管理](财务管理/README.md):应收、应付、费用池、账单、收款、成本、核销、凭证以及国内外业务费用来源。
- [打印标签维护](打印标签维护/README.md):箱唛、各类托盘标、HOLD、BOL 与其他单据的现状盘点、参考样张和逐项改版记录。
## 全流程公共契约
- [头程下单到尾程出库全流程操作手册](头程下单到尾程出库全流程操作手册.md):从客户头程下单、国内收货测量、分柜装柜和国内出仓,贯通跨区尾程建单、海外接柜入仓、排车、PDA 拣货与尾程出库的角色、操作、状态、编号、门禁和异常处理。
- [头程下单到尾程出库现状流程全景清单](头程下单到尾程出库现状流程全景清单.md):以 F01~F40 盘点现有主链、并行流程、条件分支和异常流,明确操作人、入口/出口状态、单据、编号、数据交接和评审项;应先评审本清单,再进入优化。
- [头程到尾程分阶段流程](头程到尾程分阶段流程/README.md):F01~F40 六个阶段现状文档已全部完成,逐阶段记录操作、字段、状态、数据流、异常、完成判定和上下游交接条件,可按阶段评审并进入优化。
- [头程到尾程 AI 流程优化上下文包](头程到尾程AI流程优化上下文包.md):统一 AI 分析所需的术语、流程变体、RACI、阶段合同、阻断分类、问题证据、指标口径、优化卡模板和完成定义。
- [头程下单到尾程出库流程易用性与数据流转优化方案](头程下单到尾程出库流程易用性与数据流转优化方案.md):基于已走通主链梳理现状操作问题,提出统一进度、下一动作、操作回执、数据归属、跨区可观测、PDA 易用性、P0-P2 优先级和验收指标。
- [跨区域认证与服务通信契约](跨区域认证与服务通信契约.md):统一账号、国内/海外 Token、双区域 API、JWKS、Outbox/Inbox 与服务身份边界。
- [双区域部署与 Nacos 规划](双区域部署与Nacos规划.md):开发/测试同机共置、生产国内/海外独立控制面、Nacos、数据库及 Event Bridge 部署边界。
- [平台、客户层级与权限契约](平台客户层级与权限契约.md):平台/客户两级产品语义、列表包含详情的只读权限和跨层同步要求。
- [统一订单主状态与操作契约](统一订单主状态与操作契约.md):统一订单状态、操作编码、内部状态映射和可信履约回传边界。
- [Admin/Client 前端原型还原验收规则](Admin-Client前端原型还原验收规则.md):页面操作承载方式、视觉还原、响应式检查和验收证据要求。
- [Admin 高密度界面规范](Admin高密度界面规范.md):已确认订单样板的字号、间距、表格、描述表、弹框和彩色页签规则,以及国内仓、客户管理推广入口。
- [Admin 客户管理样式核对记录](Admin客户管理样式核对记录.md):客户列表、海外仓阶梯费用及关联详情和弹框的样式与人工验收项。
- [Admin 国内仓样式核对记录](Admin国内仓样式核对记录.md):7 个菜单、关联详情与浮层的范围、样式复核和验收证据。
- [Admin 货柜管理样式核对记录](Admin货柜管理样式核对记录.md):订舱、拖车、送柜报关、清关提柜入口及共享组件边界。
- [Admin 海外仓样式核对记录](Admin海外仓样式核对记录.md):海外仓 10 个菜单、独立详情、作业弹框和共享组件显式接入边界。
- [AI 工程执行与协作约束](AI工程执行与协作约束.md):工作区、依赖、Git、部署、数据库、质量门禁和独立任务协作规则。
- [数据库中文注释维护规范](数据库注释维护规范.md):数据库注释口径、职责、迁移流程、自动门禁与实值验收要求。
- [数据库版本迁移与 Jenkins 自动发布方案](数据库版本迁移与Jenkins自动发布方案.md):两人团队自动迁移规划;统一工具入口、自动编号、开发/测试/生产执行分工、单服务迁移范围、漏写迁移检查、Flyway 校验、失败候选修订、发布证据保留、带数据升级演练及实施顺序。
- [数据库迁移操作手册](../baiyue-firstleg/deploy/sql/数据库迁移操作手册.md):开发者和发布执行人日常使用说明;包含新建、检查、生成清单、测试执行、生产晋级、失败处理和维护规则。
- [生产服务监控告警与无停机发布方案](生产服务监控告警与无停机发布方案.md):容器与外部可用性监控、部署期告警处理、自动恢复、发布通知及滚动/蓝绿发布演进方案。
- [测试环境 Jenkins 部署指南](测试环境Jenkins部署指南.md):测试环境持续部署授权、Job 参数、健康检查与失败处理。
- [阿里云生产环境选型、压测与上线手册](阿里云生产环境选型压测与上线手册.md):ECS/ALB、RDS MySQL、Redis、OSS、Nacos、RocketMQ、压测、高可用、监控、备份与上线清单。
- [阿里云 OSS 文件中心接入设计与评审](阿里云OSS文件中心接入设计与评审.md):Admin、Client、国内/海外 PDA 文件接入 OSS 的区域拓扑、存储适配、权限、一致性、实施拆分、验收门禁和方案评审。
- [首页数据看板真实化与统计策略规划](首页数据看板真实化与统计策略规划.md):首页指标、实时与缓存边界、统计口径和实施顺序。
- [墨家设备测量上传接口说明 V3](墨家设备测量上传接口说明-V3.md):测量设备经 Gateway 上传箱码、尺寸、重量和图片的接口契约。
## 环境协作
- [开发环境独立任务协作登记](开发环境独立任务协作登记.md):开发环境全流程总控与长期专项的稳定任务、职责及开发数据库/部署/HTTP 授权。
- [测试环境独立任务协作登记](独立任务协作登记.md):测试环境系统总控与长期专项的稳定任务、职责和测试数据库授权。
- [测试环境独立任务协作登记](独立任务协作登记.md):登记已清空,任务由用户手动管理。
- [测试任务盘点与需求台账](测试任务盘点与需求台账.md):历史任务快照、未闭环需求、提交线索和待核实证据,不作为当前调度依据。
## 参考资料
+90
View File
@@ -0,0 +1,90 @@
# 业务快速索引
本文件按需检索,不作为启动必读文档。以下路径均按工作区根目录或表内注明的应用源码根目录起算,不相对于 docs/。
## 常用说法与歧义分流
仓储转运审核、转运驳回:Admin 尾程订单 `apps/admin-web/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue` → `apps/admin-web/src/modules/overseas-warehouse/components/WarehouseTransferReviewDialog.vue`;权限、状态及 API 见该模块 `AGENTS.md` 的“仓储转运审核、通过与驳回”。不要按“审核”误入头程订单审核,也不要按“转运”误入排车转派送。
先从任务中取“终端 + 业务对象 + 动作”,如“海外 PDA + 柜号 + 收货”。下面是检索提示,不替代业务契约;代码路径均从工作区根起算。用短词检索,不拿整句自然语言做精确匹配。
| 常用说法 | 区分依据 | 首个定位范围 |
|---|---|---|
| 没有报价、价格未命中、人工定价、手工报价 | FBA 单票费用的显式人工报价,不是价格簿上传或静默回退 | Admin `apps/admin-web/src/modules/inbounds/fee/api.ts` 的 `createManualInboundQuote`;Backend `baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java` 的 `manualQuote`,联查 `PlatformFbaPricingController`、`fba-pricing-v1.yaml`;Client 快照类型 `apps/client-web/src/modules/first-leg/types/inboundBilling.ts` |
| 退货换标候选、退货单创建换标 | 客户退货库存进入换标申请,区别于平台审核与 PDA 签收 | `baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnOrderController.java` 的 `GET /{returnNo}/relabel-candidates`、`POST /{returnNo}/relabel` → `ReturnReceiptService.relabelCandidates/createRelabel`;公共前缀 `/api/merchant/v1/last-leg/return-orders`,客户范围取认证身份 |
| 退货审核、退货驳回、换标审核 | 先区分退货预报准入与换标申请审核,均不是仓储转运审核 | 退货预报 `apps/admin-web/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue`;换标申请 `apps/admin-web/src/modules/overseas-warehouse/pages/RelabelOrdersView.vue`;条件与 API 见该模块 `AGENTS.md` |
| PDA版本、安装包下载、热更新、发布版本、停发 | 平台管理版本与设备检查更新分开定位,先区分国内/海外 PDA | 管理页 `apps/admin-web/src/modules/pda-releases/pages/PdaReleasesView.vue`;设备更新为国内 `apps/pda-uniapp/utils/app-update.js`、海外 `apps/pda-overseas-uniapp/src/utils/app-update.js`;后端查 `baiyue-firstleg/file/AGENTS.md` |
| 收货、入库、测量、称重 | 国内到仓称重或测量弹框 | `apps/admin-web/src/modules/inbounds/`;业务路由查“到仓测量” |
| 仓储转运测量、转运打托、转运托盘标签 | 海外 PDA 逐托实测、生成新托盘号与扫码绑定,不是 Admin 转运审核或排车转派送 | `apps/pda-overseas-uniapp/src/pages/warehouse-transfer/warehouse-transfer.vue` → `apps/pda-overseas-uniapp/src/api/warehouse-transfers.js`;约束与回归见该应用 `AGENTS.md` |
| 接柜、卸柜、收货、签收 | 海外 PDA 柜号认领、照片、实收 | `apps/pda-overseas-uniapp/src/pages/operation/operation.vue`;后端查 `ReceivingContainerService` |
| 入仓计划、生成计划、按柜生成 | 平台尾程订单预览与确认,不是现场实收 | `apps/admin-web/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue`;后端查 `ContainerInboundPlanConversionCoordinator` |
| 审核、审单、批量审核 | 客户头程订单待审核 | `apps/admin-web/src/modules/unified-orders/`;后端查 `BatchOrderReviewPreviewService` |
| 审核、测量审核、实测审核 | 仓库测量结果审核 | `baiyue-firstleg/firstleg/AGENTS.md` 中“审核与初始计价”;页面先查 Admin 入库模块 |
| 费用、运费、报价、计费、船期价格表、价格簿 | 单票入仓计价、客户专属报价或调价 | `apps/admin-web/src/modules/inbounds/fee/`、`apps/admin-web/src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue`(列表/上传)、`apps/admin-web/src/modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue`(版本详情/映射确认);后端查 `FbaPricingService` |
| 柜费、公共费用、分摊 | 国内柜费来源归 Firstleg;海外柜费来源归 Lastleg;账单与结算结果归 Finance | 业务路由查“财务管理与柜级公共费用分摊”或“海外业务费用来源与客户财务”,按实际请求区分 |
| 充值、余额、授信、账单、核销 | 客户自助与平台管理先按终端区分 | Client `apps/client-web/src/modules/finance/`;Admin `apps/admin-web/src/modules/finance/` |
| 自动生成托盘、托盘码没生成、计划齐全、重试生成托盘码 | 海外入库计划保存后资源生成,不是国内托盘标签打印 | Admin `apps/admin-web/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue`;Backend `baiyue-firstleg/lastleg-service/docs/business-index.md` 的“人工计划自动生成托盘与资源重试”,执行规则先读该服务 `AGENTS.md` |
| 托盘码、托盘标签、补打 | 先区分散货装柜、一件代发和海外实体托盘 | 散货 `apps/admin-web/src/modules/loading-lists/components/PalletLabelPrintDialog.vue`;一件代发 `apps/admin-web/src/modules/inbounds/core/DropshipPalletPrintPanel.vue`,从订单“托盘打印”页签进入;海外单张打印 `apps/admin-web/src/modules/overseas-warehouse/pages/PalletsView.vue`,详见各应用/模块指引 |
| 箱码、箱唛、箱标、单箱扫码 | 单箱身份与测量,不是托盘或库位 | 业务路由查“箱唛、箱码与单箱测量”;后端查 `InboundCartonLabelService` |
| 库位、托盘、占位、释放 | 国内称重整票块归 Warehouse;海外实物托盘资源归 Warehouse Overseas;业务任务归 Lastleg | 业务路由查“称重确认”或“海外仓库与通用资料”;用国内/海外和业务阶段继续限定 |
| 拖车、派车、提柜、还柜 | 国内订舱拖车与目的港码头车队是不同业务;海外配送排车另属 Lastleg | 国内 `apps/admin-web/src/modules/trucking/`;目的港 `apps/admin-web/src/modules/container-loading/api/terminalDrayages.ts`;配送查“海外出库、排车” |
| 报关资料、退税资料、完成文件 | 平台整柜操作与客户订单上传先按终端区分 | Admin `apps/admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue`;Client `apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue` |
| 退货、退货预报、退货换标、退货签收、退货合箱 | 客户退货预报及签收库存,区别于派送异常退回 | Client `apps/client-web/src/modules/overseas-warehouse/views/ReturnOrdersView.vue`;Admin `apps/admin-web/src/modules/overseas-warehouse/pages/ReturnInventoryManagementView.vue`;PDA `apps/pda-overseas-uniapp/AGENTS.md`;Lastleg `returnreceipt/`,见下文“退货预报、签收、库存与合箱” |
| 异常、退回、退仓、回访 | 派送异常退回与库存退货业务不可混用 | 派送查 `apps/admin-web/src/modules/overseas-warehouse/pages/ExceptionOrdersView.vue`;退货预报/库存查 Lastleg `returnreceipt/` |
## 定位步骤与反馈
- 已知文件、组件或请求路径:读取适用指引后直接进入该文件或限定服务搜索路由,无需经过词表。
- 页面问题:终端指引 → 命中页面 → 实际 import/API → 对应 Controller/Service。纯展示问题定位到相关组件即可;遇到后端业务判断再追踪调用链。
- 状态或权限问题:已知请求时先定位 Controller 的权限与 Service 的状态门禁,再核对前端消费;尚不知请求时从触发动作找到 API。不要先扫所有权限初始化 SQL。
- 无精确命中:从用户原话提取短词查本表;仍有歧义时利用页面标题、业务编号类型和国内/海外范围缩小。每次只扩展相邻业务范围。
- 实际任务若发生入口误导、路径失效或重复扩大搜索,在当次交付中简记“原说法 → 最终入口 → 绕路原因”,并修正命中的索引行;进度与临时结果不写回本文件。
- 评价检索质量看首次入口是否正确、扩大范围次数、找到目标前额外读取的无关文件数;文件存在或离线合同通过只能证明部分有效性,不能代替真实任务耗时。普通任务不额外建立计时脚本或登记台账。
## 业务快速路由表
退货预报、签收、库存与合箱:Client `modules/overseas-warehouse/views/ReturnOrdersView.vue`、`ReturnInventoryView.vue`;Admin `modules/overseas-warehouse/pages/ReturnInventoryManagementView.vue`;海外 PDA `src/pages/return-receipt/return-receipt.vue`、`src/pages/return-packing/return-packing.vue`,API 与检查入口见 `apps/pda-overseas-uniapp/AGENTS.md`;Backend `lastleg-service/returnreceipt/`,执行规则先读 `baiyue-firstleg/lastleg-service/AGENTS.md`,详细接口与状态按需查 `baiyue-firstleg/lastleg-service/docs/business-index.md` 的退货入口,OpenAPI 为 `overseas-business-v1.yaml`。客户预报 → 平台审核 → PDA 签收 → 退货箱/SKU 库存 → 同客户合箱 → `delivery/` 退货出库;两个 PDA 页面均在 `src/pages.json` 注册,由 `src/data/overseas-workbench.js` 提供入口,真机验收另取证。派送异常退回仍归 `exceptionorder/`。
本表只路由到范围依据和首个代码入口;Admin/Client 路径从对应应用的 `src/` 起算,国内 PDA 路径从 `apps/pda-uniapp/` 起算,海外 PDA 路径从 `apps/pda-overseas-uniapp/src/` 起算,Backend 路径从 `baiyue-firstleg/` 起算。进入目标后继续读最近的 `AGENTS.md`,不要把根表当作模块细节清单。
| 业务 | 范围依据 | 首个代码入口 |
|---|---|---|
| 登录认证 | `docs/订单与客户/README.md` | Admin `modules/auth/`、`core/auth/`;Client `modules/auth/`;Backend `auth/`、`iam/`。海外 Token 不携带角色或权限集,Admin/Client/海外 PDA 从 Auth 登录与刷新响应的顶层授权快照读取,海外 Gateway 再按 IAM 安全投影校验 |
| 客户与企业 | `docs/订单与客户/README.md`、`docs/订单与客户/订单与客户头程订单闭环设计.md` | Admin `modules/customers/`;Client `modules/tenant-management/`、`modules/account/`;Backend `iam/`、`auth/`、`file/`。公司联系电话/邮箱是企业资料,不作为成员登录标识且不要求跨企业唯一;成员登录手机号归账号链路,修改时同步 Auth 并撤销该账号会话。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可在已有营业执照后单独补传或替换,营业执照仍须与统一社会信用代码整包提交;默认尺寸配置由 IAM 持久任务异步调用 `firstleg/`,未就绪不得审核通过 |
| 供应商与国内整柜 | `docs/国内货柜履约/国内货柜履约需求确认表.md` | Admin `modules/suppliers/`、`modules/domestic-containers/`;Backend `warehouse/`;OpenAPI `supplier-v1.yaml`、`domestic-container-v1.yaml` |
| 平台收货仓 | `docs/订单与客户/订单与客户头程订单闭环设计.md` | Admin `modules/warehouses/`;Client `modules/first-leg/`;Backend `warehouse/` |
| 头程订单、批量审核预览与入仓码关系 | `docs/订单与客户/README.md`、头程订单闭环设计 | Admin `modules/first-leg-orders/`、`modules/unified-orders/`;Client `modules/first-leg/api/inboundCodeAssociations.ts`、`modules/first-leg/`;Backend `order/` 的 `InboundCodeAssociationService`、`UnifiedOrderQueryService`、`BatchOrderReviewPreviewService` 与 `firstleg/`。Order 为 `SUBMITTED` 时,即使 Firstleg 已存在准备中入库投影,统一列表仍固定显示 `PENDING_REVIEW` 并提供审核动作;批量审核弹框只调用 `/orders/first-leg/batch-review-preview` 一次获取按请求顺序返回的快照,不逐票查询详情,主要展示入仓码而非内部订单 ID |
| 客户头程订单批量导入 | 当前 Order 契约 | Client `modules/first-leg/views/FirstLegOrdersView.vue`、`api/firstLeg.ts`;Backend `order/` 的 `MerchantBatchImportService`、`MerchantWarehouseCatalogClient`、`MerchantPricingCatalogClient` 与 `order-v1.yaml`。中文 XLSX 模板按当前客户动态生成地址、全部有效仓库和渠道下拉;导入时重新校验动态选项与客户归属,最多 50 行,只创建草稿,支持部分成功或整批原子模式 |
| 平台旧系统订单导入 | 当前 Order 契约 | Admin `modules/unified-orders/pages/UnifiedOrdersView.vue`、`api/unifiedOrders.ts`;Backend `order/` 的 `PlatformLegacyOrderImportService` 与 `/api/platform/v1/orders/first-leg/legacy-import`。固定归属启用客户“IT测试公司”,保留旧入仓码,按当前公共仓库和客户价格簿逐行校验,成功行进入待审核 |
| 平台修改客户原始申报与履约渠道 | 当前 Order/Firstleg 契约 | Admin `modules/unified-orders/components/OrderDeclarationEditorDialog.vue`、`api/unifiedOrders.ts`;Backend `order/` 的 `OrderDeclarationUpdateService` 与 `order-v1.yaml`,通过 `ORDER_DECLARATION_UPDATED_V1` 同步 `firstleg/` 的 `OrderDeclarationSyncService`。只修改申报快照,不覆盖仓库/PDA 实测、测量审核、箱码或已确认费用;渠道变化额外校验分柜、同柜、装柜可逆性与重新计价,国内收货仓或中转仓变化必须重新下单 |
| 脚本管理、全量验证与测试造数 | `baiyue-firstleg/scripts/AGENTS.md`、`baiyue-firstleg/scripts/README.md` | Backend `scripts/` 为仓库级入口,`scripts/admin-test-env-modules/` 为 Admin HTTP 内部模块;服务局部脚本留在 `<service>/scripts/`,不得散落到 `src/test/scripts/`;真实 HTTP、部署或环境写入仍须当前轮明确授权 |
| 平台/企业 RBAC | 订单与客户契约;公共语义见 `docs/平台客户层级与权限契约.md` | Admin `modules/iam/`、`core/access/`;Client `modules/tenant-management/`;Backend `iam/`、`auth/` |
| 消息中心与站内通知 | 触发业务的阶段/专题契约,收件箱以 IAM OpenAPI 为准 | Admin/Client `modules/notifications/`;Backend `iam/.../notification/`;生产者留在所属服务 |
| PDA 拣货与柜级装柜证据 | 国内 PDA README、拣货原型、国内货柜履约需求确认 | PDA `pages/picking/`、`stores/picking.js`、`api/pda-loading.js`;证据以 `taskId + warehouseId` 进入任务专用 API,业务所有权固定真实 `containerBookingId`,不得按入仓单或客户拆分;上传以幂等键绑定包含文件内容的请求哈希,完全重放直接返回已有结果且不得重复上传 File;Backend `firstleg/`、`warehouse/`、`file/` |
| 到仓测量、测量库位、箱规策略与头程计费 | `docs/入库测量与计费/README.md`、测量计费入库主流程 | Admin 测量 `modules/inbounds/measurement/InboundMeasurementDialog.vue`、`InboundMeasurementPanel.vue`,称重 `modules/inbounds/weight/components/WeightOcrPanel.vue`,以及 `warehouse-resources/`、`fba-pricing/`、`dimension-config/`、`fee-items/`;Backend `firstleg/` 的 `InboundMeasurementService`、`MeasurementLocationOccupationService`、`MeasurementSubmissionService`、`CustomerDimensionConfigService`、`CustomerDimensionWeightingService`、`FbaPricingService`,并联查 `warehouse/`、`file/`、`integration-service/`。整票库位按确认图片数与有效体积容量需求的较大值尽力预留,扩容只追加且删除图片保留空槽;库位指引失败不阻断称重确认或测量提交。客户固定四档箱规可选择 `ADD/MULTIPLY`,计价和费用确认以服务端及当前实际测量明细为准 |
| 客户专属报价、柜级阶梯运费与费用调整 | 当前 Firstleg 计价契约、`docs/入库测量与计费/README.md` | Admin `modules/fba-pricing/carrier-rates/`、`modules/inbounds/fee/`、`modules/unified-orders/components/InboundFeeDetailManager.vue`;Backend `firstleg/` 的 `PlatformPriceBookService`、`ActivePriceBookEstimator`、`FbaPricingService`、`ContainerTierAmountAllocator`、`InboundFeeDetailService` 与 `fba-pricing-v1.yaml`。公共报价使用 `customerId=0`,客户专属报价按客户独立版本化并优先于公共 ACTIVE 版本;柜级区域重量阶梯只聚合同柜同客户中费用快照同为 `REGION_WEIGHT_TIER` 的成员并守恒分摊,混柜的固定仓价等其他模型不参与阶梯且保持原费用,装柜成员变化只重算未锁定的区域阶梯费用。人工调整计费量限系统基准上下 10% 且必须带版本、原因和二次确认;`PRIVATE_LAST_MILE_DELIVERY` 只允许私人目的仓;一件代发在预装确认前完成计价,`WAIT_PALLETIZE` 试算不得回退托盘化状态 |
| 箱唛、箱码与单箱测量 | `docs/入库测量与计费/入库测量与计费需求优化版-v2-测量计费入库主流程.md`、`docs/墨家设备测量上传接口说明-V3.md` | Admin `modules/inbounds/measurement/`;Client `modules/first-leg/`;Backend 按 `firstleg/AGENTS.md` 的箱唛与测量入口 |
| 称重确认、整票库位块与部分拣货资源结算 | 入库测量与计费主流程、国内货柜履约 PDA 契约 | Admin `modules/inbounds/weight/components/WeightOcrPanel.vue`;Backend `firstleg/` 的 `MeasurementLocationOccupationService`、`InboundMeasurementService`、`MeasurementSubmissionService` 与 `warehouse/` 的 `LocationBlockService`。测量提交审核前至少上传并确认一张称重图片,单箱也不得免称重;称重确认尽力建立整票块,提交时再次补齐,库位指引失败不阻断称重确认或测量提交。容量按确认图片数与有效体积折算的较大值,最新测量批次内实测/冻结申报体积取有效较大值;删除图片只清空绑定不释放槽位。部分拣货有余量时整段转移给子单,整票拣完才一次性释放全部普通位与共享 A0,资源身份以 `reservationId` 为准 |
| 入库扣货、解除与凭证 | 入库测量与计费、国内货柜履约契约 | Admin `modules/unified-orders/pages/UnifiedOrdersView.vue`、`modules/inbounds/core/api.ts`;Backend `firstleg/` 的 `InboundHoldService`、`InboundHoldGuard`、`InboundHoldEvidenceService`,文件归 `file/` 的 `INBOUND_HOLD_EVIDENCE`。扣货状态必须阻断后续物理履约写入;已发生拣货、装柜或出仓时禁止手工扣货/解除,命令携带版本与幂等键,只有进入不可变历史的凭证可下载 |
| 财务管理与柜级公共费用分摊 | `docs/财务管理/` 产品确认、五页验收契约、Firstleg-Finance 跨服务契约 | Admin `modules/finance/`、`modules/loading-lists/components/ContainerFeesPanel.vue`;Client `modules/finance/views/FinanceBillsView.vue`,账单行按 `inboundCode` 复用头程入仓关系详情;Backend `finance/`、`firstleg/`,契约 `container-fees-v1.yaml`;导出与凭证归 `file/`。跨客户公共柜费按柜内真实客户与订单归属原子建组,金额合计等于柜费总额,审核、驳回、调整和作废整组处理,单行入口不得破坏组一致性 |
| 客户资金账户、充值、授信与业务扣费 | `docs/财务管理/README.md`、跨区域认证与服务通信契约 | Admin `modules/finance/pages/FinanceCustomerAccountsView.vue`;Client `modules/finance/views/FinanceAccountView.vue`;Backend `finance/` 的 `FinanceWalletService` 与三类 Wallet Controller。充值申请经 File 保存凭证,平台审核或手工充扣;余额、授信、冻结、结算和释放以 Finance 主账为准。海外业务经 `WALLET_COMMAND_REQUESTED/COMPLETED` Outbox/Event Bridge 往返,不跨区同步调用钱包 |
| 订单编排、分柜、同柜不可拆组与柜次 | `docs/国内货柜履约/README.md`、`docs/国内货柜履约/国内货柜履约需求确认表.md`、`docs/统一订单主状态与操作契约.md` | Admin `modules/unified-orders/api/coLoadGroups.ts`、`modules/unified-orders/`、`container-loading/`;Client `modules/first-leg/`;Backend `firstleg/loading/coload/`、`firstleg/split/`、`firstleg/loading/`、`order/`;同柜组按订单创建,由服务端冻结每票全部可用入仓片段,仅要求同国内仓、同渠道,允许跨客户及不同目的仓港 |
| 全局智能排柜、预装与真实集装箱号后补 | 同上 | Admin `modules/container-loading/components/SmartLoadingDialog.vue` 内嵌 `pages/GlobalSmartLoadingView.vue`,柜次页 `SmartLoadingWorkbenchView.vue`,预装详情 `loading-lists/pages/PreloadPlanDetailView.vue`;PDA `pages/picking/`;Backend `firstleg/` 的 `PlatformSmartLoadingController`、`SmartLoadingService`、`PendingReviewOrderClient`、`loading/`,待审核事实来自 Order `PendingReviewOrderQueryService`。全局候选覆盖 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING`,状态只用于筛选展示,能否加入以服务端 `canAssign` 为准;待审核/待测量可提前占位,但确认预装或启动 PDA 前必须进入 `WAIT_SPLIT`。暂估明细的 `totalPackageCount/remainingPackageCount` 在测量前取 Order 原始申报箱数,测量完成后取最新已审核测量箱数,读取补充失败只回退当前已分配数,写入调整仍重新校验权威数量。同柜不可拆组仅在全员命中状态集合时作为一个分页与分配单元;人工加入允许跨渠道,智能推荐仍按柜次渠道。计划可一次性移除全部未拣明细,但任一成员已拣的同柜组必须整组保留。创建柜次时原子生成唯一预装计划,加入入仓码即建立关联;真实集装箱号不阻塞订舱关联,但确认预装和创建 PDA 任务前必须补齐。历史缺号任务允许在预装详情首次补录,保存后恢复 PDA 可进入状态 |
| 客户入库结果与头程费用确认 | 入库测量与计费主流程、当前 Firstleg 契约 | Client `modules/first-leg/components/InboundBillingPanel.vue`、`api/inboundBilling.ts`;Backend `firstleg/` 的 `MerchantInboundBillingController`、`MerchantInboundBillingService`、`FbaPricingService` 与 `firstleg-v1.yaml`。客户仅能确认本客户 `SENT` 的最新费用版本,提交 `expectedFeeVersion` 与幂等键;确认记录当前成员和时间并永久锁定该版本,平台不得再修改 |
| 订舱资料、放舱、出仓前船期与订舱级拖车 | 国内货柜履约 README、需求确认和实施边界 | Admin `modules/ocean-bookings/`、`port-schedules/`、`trucking/`;Backend `firstleg/oceanbooking/`、`firstleg/portschedule/`、`TruckAppointmentService`。拖车预约由 `RELEASED` 订舱拥有,不按客户、柜次、装柜计划或真实柜号归属;每个订舱最多一张非终态拖车单,创建候选由服务端排除缺提单/有效订舱文件或已有有效拖车单的订舱;PDA 装柜完成不推进拖车状态;全部要求货柜确认国内离仓后自动完成,人民币拖车费按实装体积分摊为柜次应付并经 Finance Outbox 入池 |
| 目的港码头拖车与还空预警 | `docs/海外仓业务/README.md` | 与订舱级拖车预约是不同生命周期。Admin 海外清关提柜列表/详情 `modules/container-loading/pages/OverseasContainersView.vue`、`modules/container-loading/pages/OverseasContainerDetailView.vue`;Firstleg 拖车 API `api/terminalDrayages.ts`,柜级到仓 API `api/containerArrivalTimes.ts`。原预约/实际提柜列改为预计/实际到仓:预计时间由平台在列表快捷选择并同步 Lastleg 整柜尾程订单,实际时间取海外 PDA 首次开始收货;车队、邮件、还柜与 `unloadingCompletedAt + 72h` 预警仍归 `firstleg/terminaldrayage/`。 |
| 整柜报关资料与完成文件 | 国内货柜履约契约 | 装柜完成后由 Admin `modules/container-loading/components/ContainerCustomsPanel.vue` 管理整柜报关,Client `modules/first-leg/components/CustomsDocumentsDialog.vue`、`api/customs.ts` 按订单处理本客户退税资料;Backend `firstleg/customs/container/`、`customs-v1.yaml`,文件归 `file/`。客户资料可覆盖同柜多个退税入仓码,开始报关后冻结;平台逐资料回传完成文件,全买单柜走平台确认与明细导出 |
| 国内出仓确认 | 国内货柜履约契约 | Admin `modules/outbound-dispatch/`;Backend `firstleg/outbound/dispatch/` |
| 客户统计与平台日志 | 当前统计/审计契约 | Admin `modules/customers/`、`modules/audit/`;Backend `firstleg/customerstatistics/`、`audit-service/` |
| 客户工作台账户与设置 | 订单与客户契约 | Client `modules/account/`、`system/`、`tenant-management/`;Backend `iam/`、`auth/` |
| 企业订单与跨境业务 | 订单与客户、国内货柜履约契约 | Client `modules/order-management/`、`cross-border/`;Backend `order/`、`firstleg/`、`integration-service/` |
| 海外接柜、入库与库内库存 | `docs/海外仓业务/README.md` | Admin 按 `apps/admin-web/AGENTS.md` 海外模块入口;海外入口 `gateway-overseas/`;尾程/入仓/换标/库存 `lastleg-service/`;仓库主档归国内 `warehouse/`,库位/托盘/锁归 `warehouse-overseas-service/` 并只引用国内 warehouseId;与排车、出库、派送共同组成海外仓全流程 |
| 尾程订单控制与柜级入仓计划转换 | `docs/海外仓业务/README.md` | Admin `modules/overseas-warehouse/pages/LastMileOrdersView.vue`、`LastMileOrderDetailView.vue`、`api/lastMileOrders.ts`;Backend `lastleg-service/` 的 `LastlegOrderController`、`LastlegOrderCommandService`、`InboundPlanService`、`ContainerInboundPlanConversionCoordinator`,资源归 `warehouse-overseas-service/` 的 `WarehousePalletBatchService`。`orders/container-summaries` 按 `sourceContainerBookingId` 跨客户聚合并分页返回全部稳定来源柜,已完成计划转换的柜仍保留用于接柜仓查询与修改;生成动作只消费 `allPlansReady/pendingPlanCount/resourcePendingPlanCount`,详情不受列表分页截断。确认前从 `/inbound-plans/container-preview` 读取服务端分组、托数、阻断项与 `snapshotVersion/snapshotHash`;`convert-container` 锁柜重算后先批量预留真实托盘,再本地原子生成剩余独立计划并确认资源,失败仅补偿未确认资源,同柜重放不得产生第二批资源。单票手工计划保存后,全柜计划齐全且预览就绪时自动生成并绑定托盘资源,一票柜立即触发;`resourcePendingPlanCount` 表示自动生成异常后的重试入口,资源未齐不得进入 PDA 接柜。自动入口见 Lastleg `ContainerInboundPlanConversionCoordinator.convertManualAndAutoMaterialize`。单票可维护完整入库计划、暂扣/解除及八行人工节点快照;命令提交订单版本与幂等键,新增暂扣只允许完成入库后执行并仅阻断该票后续排车/出库,不阻断入库计划或入库作业;人工节点首次保存后固定 `MANUAL` 来源且不得回退柜级节点 |
| 海外仓 PDA | `docs/海外仓业务/README.md` | Overseas PDA 入仓 `api/inbound.js`、`api/receiving-adapter.js`、`pages/operation/`,库内/换标按工作台入口,排车与出库 `pages/dispatch/`、`pages/outbound/`;Backend `lastleg-service/inboundplan/pda/`、`pda/`、`dispatch/pda/`、`delivery/`;统一经 `gateway-overseas/`。接柜候选、会话、照片和整柜完成按 `sourceContainerBookingId + containerNo` 聚合且不绑定客户,允许同柜多客户;各入仓码的计划、任务、SKU、托盘与库存仍按自身客户隔离。多计划同柜按服务端返回的 `assigned/mergeAllowed/urgent` 展示,合托只是现场可选提示;扫码实收确认计划生成时预留的真实托盘与库位,不重新分配资源;派送与回访由 Admin/Client 承载 |
| 海外采购、本土卡车与散板 | `docs/海外仓业务/README.md` | Admin `modules/overseas-business/`;Client `modules/cross-border/`;Backend `lastleg-service/` 的 `procurement/`、`trucking/`、`looseboard/`;统一经 `gateway-overseas/` |
| 海外仓库与通用资料 | `docs/海外仓业务/README.md` | 全部仓库主档进入 Admin `modules/warehouses/` 与国内 `warehouse/`;海外库位/托盘/锁进入 `warehouse-overseas-service/` 并引用国内 warehouseId;报价、汇率、车辆按海外服务实际契约 |
| 国内出仓后船节点 | `docs/海外仓业务/README.md` | Admin `modules/port-schedules/`、`ocean-bookings/`、货柜详情;Backend `firstleg/portschedule/`、`integration-service/`;从确认国内出仓查询至目的港到达、提柜和还柜。节点按 `containerBookingId` 柜级聚合,跨区事件使用 `tenantId=0` 平台技术范围,不携带或校验客户 |
| 海外出库、排车、派送、异常与回访 | 当前海外履约契约 | Admin `modules/overseas-warehouse/pages/OutboundsView.vue`、`DeliveryOrdersView.vue`、`ExceptionOrdersView.vue`、`CustomerVisitsView.vue`;Backend `lastleg-service/dispatch/`、`delivery/`、`dropship/`、`exceptionorder/`、`customervisit/`;统一经 `gateway-overseas/`。异常单按 `PENDING → PROCESSING → COMPLETED` 流转,处理完成必须引用归属为 `OVERSEAS_EXCEPTION_EVIDENCE` 的证据;派送退回只从原出库拣货事实选择可退明细,退回海外仓时先把实收恢复到指定真实托盘/库位,再原子完成异常单并将派送置为 `RETURNED` |
| 海外业务费用来源与客户财务 | 财务契约、`docs/跨区域认证与服务通信契约.md` | 海外入仓、采购、美国卡车、本土散板和海外仓服务费由 `lastleg-service/financefee/` 校验真实业务归属并版本化审核;通过 `BUSINESS_FEE_SOURCE_CHANGED` 完整快照、`event-bridge-service/` 和国内 Finance `/internal/finance/v1/business-fee-source-events` 可靠入池。`PAYABLE + DIRECT_PAID` 进入已确认成本且不得再次进入 AP 待结算;Client 仍从 `modules/finance/` 读取获准客户财务,不自动开放平台费用来源维护页 |
散货托盘码生成、打印与跨区传递:Admin `modules/loading-lists/components/PalletLabelPrintDialog.vue`;Firstleg `LoadingPalletLabelService`、`LoadingCompletedOutboxService`;Lastleg `LastlegSourcePalletMapper`、`InboundPlanService`。从各项目最近指引继续定位;散货 FBA 标签与一件代发实装托盘分别取各自冻结事实。
@@ -7,7 +7,7 @@
> 技术状态(2026-08-04):任务 `019fc6f9-8dcf-74e1-bc68-d42f15d7802a` 已完成核心正常主链路开发和真实 API 回归。以下“产品确认状态”仍由产品/业务负责人填写,技术完成不自动等同于产品确认。
> 订单衔接规则更新(2026-08-07,已确认):客户草稿不生成入仓码;客户提交进入待审核时立即生成并固定唯一入仓码。平台审核不以选择承运商为前置条件;审核通过后才创建可进入测量流程的正式入库单,并沿用提交时生成的入仓码。承运商可在后续履约或计费前补充。
> 订单衔接规则更新(2026-09-09,当前对话确认):客户草稿不生成入仓码;客户提交进入待审核时立即生成并固定唯一入仓码,同时准备同号预入库单。货物已到国内仓时可在订单审核前先收货、称重、保存箱规并提交测量数据,但订单仍保持待审核,测量审核通过、计费、分柜和装柜必须等待订单审核通过;审核后沿用同一入仓单和已采集数据。承运商可在后续履约或计费前补充。
> 主体模型:入库测量与计费只实现“平台—客户”两级;业务数据的 `tenant_id>0` 是客户隔离兼容字段,平台公共仓储资源归属 `tenant_id=0`。下表历史“资源租户”均指公共主数据技术归属,不代表真实租户。
@@ -170,7 +170,7 @@ POST /api/platform/inbounds/{inboundId}/weight-results/confirm
- 无法识别、置信度过低或单位不明时标记为“待人工确认”。
- 支持公斤和磅,服务端统一换算成 KG,原单位和原值必须保留。
- 同一图片不得重复计重;替换、删除图片必须留下操作记录。
- 测量提交审核前,所有有效图片必须已确认,且总重量大于 0。
- 测量提交审核前,至少上传并确认一张称重图片;即使整票只有一箱也不得免称重。所有称重图片必须已确认,且总重量大于 0。
### 2.4 FBA 计费范围
+2
View File
@@ -44,6 +44,7 @@ Namespace 名称不是服务名。环境变量必须填写 Nacos 实际 Namespac
- `gateway-overseas`
- `lastleg-service`,同时持有 `baiyue_lastleg` 内的 IAM 安全事件 Inbox/投影,并开放内部事件接收和实时校验端点
- `warehouse-overseas-service`
- 海外 `file-service`,独占海外 RDS 中的 `baiyue_file`,只访问海外本区域私有 OSS Bucket;详细边界见《[阿里云 OSS 文件中心接入设计与评审](阿里云OSS文件中心接入设计与评审.md)》
- 海外 Event Bridge,用于接收目标区域为 `OVERSEAS` 的事件;业务事件和三类 IAM 安全事件按事件类型投递 Lastleg 的不同内部入口
海外浏览器请求直接进入海外 Gateway,不经过国内 Gateway。Auth 仍只部署在国内;海外服务通过受控 JWKS 地址验证 Auth 签发的短时海外 Token,不访问国内 Redis。
@@ -160,5 +161,6 @@ firstleg-production-overseas-env
2. `deploy/scripts/deploy-built-service.sh` 与 `deploy-services.sh` 的海外服务门禁应校验 `OVERSEAS_NACOS_SERVER_ADDR`。
3. Jenkins 生产配置需要拆分国内/海外 Secret File,并为 Event Bridge 提供明确区域部署参数或独立 Job。
4. 海外 Nacos Namespace 创建、配置发布、JWKS 可达性和双向 Event Bridge 真实投递必须分别验收,不能以容器健康检查代替。
5. 海外 Gateway 和 Nacos 已存在 File Service 入口,但海外统一 SQL 与部署清单尚未包含海外 `file-service`、`baiyue_file`;OSS 接入实施前必须补齐并完成区域内真实上传、下载和双实例读取验收。
这些差距未完成前,不得声称生产双区域部署已就绪。
@@ -28,7 +28,7 @@
| CT-D07 | 装柜清单进行中/已完成 | CT-D04 | 装载明细、数量、状态与预装来源一致 | 装柜清单列表/详情可点击 | 待契约 |
| CT-D08 | PDA 领取、拣货、复核、异常摘要 | CT-D04 | overview 分别返回 `taskStatus`、`workflowStatus`、任务 ID、版本、计数、`photoCounts`、`photos`、`downloadPath` 和异常原因;逐照片代理下载成功 | PDA 可完成主流程;Admin 入口待回传 | 服务端已冻结;人工入口待定 |
| CT-D09 | 报关/清关资料及有效回执 | CT-D06 | 目的港清关走货柜 `/clearance`;出口报关走 loading-list `/customs`;分别验证上传、完成和完成后替换锁定 | 报关协同入口待回传 | 服务端已冻结;人工入口待定 |
| CT-D10 | DO/PU 未齐、部分完成、全部完成 | 柜次与运输协同已建立 | `DELIVERY_ORDER`、`PICKUP_ORDER` 可独立查询、上传和鉴权下载,版本与幂等有效 | 柜次详情入口待回传 | 服务端已冻结;人工入口待定 |
| CT-D10 | DO 附件与 PU 码未填、部分完成、全部完成 | 柜次与运输协同已建立 | `DELIVERY_ORDER` 可独立查询、上传和鉴权下载;PU 资料通过 `/pickup-code` 保存 1–64 位数字字符串并保留前导零,均使用版本校验 | 货柜详情在“报关清关信息”一级区块内直接展示 DO 附件与 PU 码输入 | 已按最新确认收敛;待人工截图确认 |
| CT-D11 | 正常、异常、取消异常 | 任意有效柜次及显式货柜版本 | 标记/取消使用版本和必填原因;overview 保留 `MARK`/`CANCEL` 全部历史 | 货柜详情入口待回传 | 服务端已冻结;人工入口待定 |
| CT-D12 | 多节点运输轨迹与操作日志 | CT-D10 | overview 时间线含运输更新、异常和 PDA 事件,按发生时间升序 | 详情时间线入口待回传 | 服务端聚合已冻结;人工入口待定 |
| CT-D13 | PDA 确认装柜并推进 `LOADED` | inbound `LOADING`、bridge、订舱 `RELEASED`、PDA 全拣和五类 ACTIVE 证据齐备;拖车允许缺失、未完成或存在多条 | PDA `complete` 的主事务不以拖车数量/状态为阻断;提交后才在独立事务 best-effort 同步唯一同柜有效拖车。零条直接跳过,一条可推进 `COMPLETED`,多条或拖车并发只隔离记录同步失败,task/plan/inbound 仍保持 `LOADED` | 订单/柜次详情显示完成且编辑锁定;拖车同步失败单独呈现 | `LANDED_STATIC`:OpenAPI、Service、提交后同步隔离与结算版本回归已冻结;待部署后 HTTP 复核 |
@@ -83,12 +83,12 @@
| 验收点 | 冻结契约 | 自动化证据 |
|---|---|---|
| 来源隔离 | `customerRemark` 来自订单客户备注;`internalRemark` 来自最新内部备注,禁止冒用 `cargoRemark` | `orders` 写入三条互异 marker;`phase3-loading` 精确比较来源与非串值 |
| 仓库备注 | `warehouseRemark` 仅在 EDITING/LOADING 通过正式 item PATCH 维护;空白归一化为 null | 覆盖保存、空白清空、版本递增、旧版本 A0409 后回查零漂移 |
| 订单备注关联 | `customerRemark/internalRemark/warehouseRemark/customerServiceRemark` 均以 Order 订单备注为权威;订单列表修改后三类平台备注同步全部未冻结预装明细,预装详情修改后三类平台备注写回订单并同步同票未冻结明细;`customerRemark` 只随客户申报修改同步,禁止冒用 `cargoRemark` | `orders` 写入四条互异 marker;分别从订单列表和预装详情修改后,两个入口精确比较一致性、版本递增与并发冲突 |
| 冻结边界 | EDITING/LOADING 的预装明细参与订单备注同步;COMPLETED 或 `frozen_at` 非空的装柜历史保留完成时快照,不被订单后续修改覆盖 | 覆盖保存、空白清空、版本递增、旧版本 A0409、完成后订单备注变更不改历史快照 |
| 幂等与终态 | 同 operationId 同参数精确重放不重复递增;异参冲突;COMPLETED 后 A0409 且零写 | OpenAPI/Service 静态合同已锁定;COMPLETED HTTP 负向待部署后与 finalize 串联 |
| 动态增量 | assignment/数量调整/删除不得覆盖已有仓库备注 | `phase3-loading` 在 LOADING 增量前后断言原明细仓库备注保持,调整明细备注保持 |
| 海外事件 | `FirstLegLoadingPlanCompletedV2` 每个 item 含 customer/internal/warehouse 三备注,事件哈希与幂等基于同一稳定 payload | `loading-list-remarks-contract.test.mjs` 锁定 V2 类型与三字段映射;海外消费者仓库不在本仓,列为跨仓部署依赖 |
| Admin | 客户/内部备注只读,仅仓库备注显示编辑入口;COMPLETED 隐藏编辑 | Admin 静态合同已锁定 |
| Admin | 客户备注只读;EDITING/LOADING 可从订单列表或预装详情编辑内部、仓库、客服备注,两个入口保存后保持一致;COMPLETED 隐藏编辑 | Admin 静态合同与双入口接口回归 |
- 结算版本缺陷证据:task `2089254643058184193`、inbound `2089181999189299202` 在 taskVersion=1/inboundVersion=6、PICKED 2/2 与五证据齐备时返回 A0409;requestId=`12a913ba-9845-45f6-91ec-bdba28e3eb9d`、traceId=`395d11b5c0da45eca948a39a08e29d3d`。回归锁定 settle 后重新读取 full/partial 实际完成分片版本;真实并发仍冲突,失败必须零写入。
## PDA 进行中增量预装冻结回归
@@ -131,16 +131,16 @@
| `container-overview` | `GET /api/platform/v1/containers/{id}/overview`;聚合 container、loadingPlanId、PDA 摘要、documents、exception/history、timeline | `platform:firstleg:container:list`;旧 `container:view` 已移除 | 已冻结 |
| `pda-progress-exception-summary` | PDA `taskStatus/workflowStatus`、任务 ID/版本、明细计数、`photoCounts`、`photos`(含 fileId/status/downloadPath)及异常原因;照片下载为 `GET /api/platform/v1/containers/{id}/pda/photos/{fileId}/download` | 随 overview 查看权限;下载复核柜次、计划、入库单与文件归属 | 已冻结 |
| `container-abnormal-history` | `POST/DELETE /api/platform/v1/containers/{id}/exception`;请求使用 overview 返回的 container.version、必填原因和幂等键 | `platform:firstleg:container:edit`;版本冲突 fail-closed | 已冻结 |
| `container-documents` | 通用查询、multipart 上传和受控文件下载仅支持 DO/PU;下载透传已校验 Content-Type,非法类型回退 `application/octet-stream`,并返回 UTF-8 文件名、`no-store` 和内容长度 | 查询/下载用 `platform:firstleg:container:list`,上传用 `platform:firstleg:container:edit`;旧 `container:view` 已移除;下载校验柜次、类型、文件 ID 及文件服务 tenant/business 归属 | 已冻结 |
| `do-pu` | `DELIVERY_ORDER`、`PICKUP_ORDER` 通用查询/上传/下载 | 同 `container-documents` | 已冻结 |
| `container-documents` | 通用查询、multipart 上传和受控文件下载仅支持 DO;下载透传已校验 Content-Type,非法类型回退 `application/octet-stream`,并返回 UTF-8 文件名、`no-store` 和内容长度 | 查询/下载用 `platform:firstleg:container:list`,上传用 `platform:firstleg:container:edit`;旧 `container:view` 已移除;下载校验柜次、类型、文件 ID 及文件服务 tenant/business 归属 | 已冻结 |
| `do-pu` | `DELIVERY_ORDER` 使用通用附件查询/上传/下载;PU 资料使用 `PUT /api/platform/v1/containers/{id}/pickup-code` 保存数字码 | `platform:firstleg:container:edit`;两者均使用服务端版本校验 | 已冻结 |
| `transport-trajectory-log` | overview timeline 的事件类型、详情、操作者、发生时间;服务端升序 | 随 overview 查看权限 | 已冻结 |
### 最终安全分流
1. overview 与通用 documents 只覆盖 `DELIVERY_ORDER`、`PICKUP_ORDER`,不得向通用路径传清关或报关类型。
1. overview 的 documents 与通用文件接口只覆盖 `DELIVERY_ORDER`;PU 数字码位于 overview.container,不得向通用文件路径传 PU、清关或报关类型。
2. 目的港清关使用 `/api/platform/v1/containers/{id}/clearance` 及其专用上传、完成动作,权限为 `platform:firstleg:customs:manage`。
3. 出口报关使用 `/api/platform/v1/first-leg/loading-lists/{planId}/customs` 及其专用上传、完成动作;查询使用 `platform:firstleg:loading-list:list`,旧 `loading-list:view` 已移除,写入使用 `platform:firstleg:customs:manage`。
4. 清关和出口报关分别验证完成态禁止替换,不能用 DO/PU 通用上传替代。
4. 清关和出口报关分别验证完成态禁止替换,不能用 DO 通用上传或 PU 码入口替代。
5. 下载允许透传经校验的 Content-Type,非法值回退二进制类型;同时必须保留 UTF-8 文件名、`no-store`、内容长度及资源归属复核。
## 自动化落地门禁
+4 -1
View File
@@ -12,6 +12,9 @@
每次请求对应一次设备测量,不需要传订单内部字段,也不需要关心柏悦后续如何处理数据。
墨家设备属于平台仓库设备,不固定绑定某个客户。柏悦根据全平台唯一的 `boxCode`
解析真实客户和入库单,并校验该入库单属于 `machineNo` 绑定的仓库;设备不能跨仓上传。
唛头二维码内容由柏悦生成,设备应原样读取并作为 `boxCode` 上传。例如:
```text
@@ -67,7 +70,7 @@ POST <GATEWAY_HOST>/openapi/v1/inbound-measurements
| `width` | 是 | 当前箱宽度,单位 cm |
| `height` | 是 | 当前箱高度,单位 cm |
| `weight` | 不再接收 | 墨家接口只上传尺寸;货物毛重、托盘皮重和净重统一由称重图片确认链路维护 |
| `machineNo` | 是 | 墨家设备编号,每台机器固定且唯一 |
| `machineNo` | 是 | 墨家设备编号,每台机器固定且唯一,并由柏悦绑定到一个实际仓库 |
| `picture` | 是 | JPEG 图片的 Data URL Base64 文本 |
## 4. 图片格式
@@ -0,0 +1,538 @@
# 头程下单到尾程出库全流程操作手册
> 适用范围:企业客户头程下单,国内收货、测量、分柜、装柜、报关与出仓,跨区生成尾程订单,海外接柜入仓、库存、排车及尾程出库。
> 流程终点:海外出库单变为 `OUTBOUNDED`;排车场景同时以排车单 `DEPARTED` 为离仓事实。
> 当前口径:以现行服务端模块指引、页面入口和代码状态机为准。历史设计与当前实现冲突的地方集中列在第 11 节,旧文档不能直接作为操作依据。
> 不在主链内展开:换标、仓储转运、货物废弃、退货换标、异常退回、客户回访、海外采购和本土散板;这些能力在第 9 节说明如何接入出库。
> 完整现状流程目录:见[《头程下单到尾程出库现状流程全景清单》](头程下单到尾程出库现状流程全景清单.md)。该清单以 F01~F40 覆盖主链、并行流程、条件分支和异常流;本文侧重一票主链如何实际操作。
## 1. 一张图看完整主链
```text
企业资料审核通过、资金账户可用
↓
客户创建头程订单草稿 → 提交订单 → 生成全局唯一入仓码
↓
平台审核订单 → 创建国内正式入库单、冻结申报与箱码事实
↓
国内仓收货 → 测量/称重 → 测量审核 → 进入待分柜
├─ 普通散货/FBA:直接进入分柜
└─ 一件代发:先逐箱清点、组托、封托,再进入分柜
↓
创建模拟柜/订舱 → 智能或人工排柜 → 预装确认
↓
国内 PDA 拣货装柜 → 五类证据和铅封 → 装柜完成 LOADED
↓
整柜出口报关完成 → 系统自动收敛或平台确认国内出仓
↓
船节点:离开国内仓 → 起运港 → 装船 → 在途 → 到目的港 → 卸船 → 可提柜
↓
装柜完成事件跨区 → 海外生成 PENDING_ASSIGNMENT 尾程订单
↓
平台按柜预览并转换入仓计划 → 生成/绑定海外托盘资源
↓
海外 PDA 锁柜 → 三类签收照片 → FBA 按箱数 / OVERSEAS 逐箱收货
↓
整柜核平完成 → INBOUND_COMPLETED → 海外库存、托盘、库位和流水落账
↓
平台智能预排/排车 → 维护车辆及预约 → 转派送,生成 WAIT_OUTBOUND 出库单
↓
海外 PDA 扫托盘拣货并核平 → 发车 → 出库单 OUTBOUNDED / 排车单 DEPARTED
```
主链有三个不能混淆的“出库/出仓”:
1. 国内装柜完成:入库单为 `LOADED`,表示货已经装入国内出口柜,不等于柜已离开国内仓。
2. 国内出仓:整柜通过报关等门禁并确认离仓,系统开始船节点跟踪。
3. 海外尾程出库:海外仓库存经排车/PDA 作业离仓,出库单为 `OUTBOUNDED`。
### 1.1 操作与交接总表
下表把每一步的人工操作、系统自动动作和下一个责任人放在一起。操作员完成当前步骤后,应以“系统结果”作为交接依据,不能只凭口头通知或按钮点击成功提示判断流程已经流转。
| 步骤 | 操作人/终端 | 人工操作 | 系统自动动作与数据结果 | 下一责任人 |
|---:|---|---|---|---|
| 1 | 企业客户 / Client | 新建订单,选择仓库、渠道和目的仓,填写货物,保存草稿 | 保存 `DRAFT` 和客户申报快照;不生成入仓码 | 企业客户 |
| 2 | 企业客户 / Client | 检查后点击“提交订单” | 校验企业资格、资金、仓库和渠道;生成固定入仓码,进入 `PENDING_REVIEW` | 平台订单运营 |
| 3 | 平台运营 / Admin | 在待审核列表查看详情或批量预览,点击审核通过 | Order 进入 `APPROVED`;Firstleg 建正式入库单和稳定箱码,主状态进入 `WAIT_MEASURE` | 国内仓操作员 |
| 4 | 国内仓 / 设备、Admin | 按入仓码收货,上传尺寸和称重图片,校正 OCR,提交审核 | 保存测量批次、原始值、修正值、图片和库位块预留 | 测量审核员 |
| 5 | 审核员 / Admin | 审核测量结果;异常时退回修正 | 普通订单进入 `WAIT_SPLIT`;一件代发进入 `WAIT_PALLETIZE` | 国内履约运营或 PDA |
| 6 | 国内 PDA | 一件代发逐箱清点、归托、封托 | 冻结真实 DP 托盘及 SKU/箱唛组成,核平后进入 `WAIT_SPLIT` | 国内履约运营 |
| 7 | 财务/运营、客户 / Admin、Client | 平台试算和发送费用;客户核对并确认 | 独立生成、发布和确认费用版本;不阻断待分柜主流程 | 国内履约运营 |
| 8 | 国内履约运营 / Admin | 创建模拟柜,选择候选,人工/智能排柜 | 生成稳定柜次、预装计划、分配明细和容量占用 | 订舱/柜务运营 |
| 9 | 订舱/柜务运营 / Admin | 建订舱草稿、补资料、放舱,关联柜次并录入真实柜号 | 冻结订舱和运输快照,建立订舱需求与柜次分配关系 | 国内履约运营 |
| 10 | 国内履约运营 / Admin | 核对预装清单并确认 | 固化预装快照,生成或开放国内 PDA 任务 | 国内 PDA |
| 11 | 国内 PDA | 扫箱/整托拣货,批量提交,拍四步照片和铅封,完成装柜 | 计划 `COMPLETED`、入库单 `LOADED`,发布最终实装事件 | 报关/出仓运营 |
| 12 | 报关运营 / Admin | 上传出口报关资料并完成报关 | 报关状态完成;系统自动尝试收敛国内出仓门禁 | 出仓运营/系统 |
| 13 | 出仓运营 / Admin | 自动未收敛时,在柜次门禁面板点击“确认离仓” | 整柜进入已离仓事实,自动建船节点任务和 `DEPARTED_WAREHOUSE` | 船务/海外运营 |
| 14 | 船务 / Admin、第三方 | 查看自动轨迹,缺失时按权限补录真实节点 | 实际节点单调推进;到 `PICKUP_READY` 后开放海外 PDA 候选 | 海外计划运营、海外 PDA |
| 15 | 海外计划运营 / Admin | 在尾程订单按柜预览阻断项并确认转换入仓计划 | `PENDING_ASSIGNMENT → INBOUND_PLANNED`;逐票建计划并生成/绑定托盘资源 | 海外 PDA |
| 16 | 海外 PDA | 选柜锁定,拍车牌/封签/开柜照片,确认签收 | 建立柜级收货会话,冻结实际接柜仓和操作员 | 海外 PDA |
| 17 | 海外 PDA | FBA 按 SKU+箱数收货,OVERSEAS 按箱唛逐箱收货,完成整柜 | 尾程进入 `INBOUND_COMPLETED`;生成库存余额、仓位、托盘、流水并回写国内 | 海外排车运营 |
| 18 | 海外运营 / Admin | 智能预排,选择托盘和车型,生成排车,维护车辆/预约 | 锁定托盘并生成 `PENDING_DEPARTURE` 排车单 | 海外运营、海外 PDA |
| 19 | 海外运营 / Admin | 在发车前点击“转派送”,填写派送信息和费用 | 生成一张 `WAIT_OUTBOUND` 出库单,并按目的仓拆分 `WAIT_DELIVERY` 派送单 | 海外 PDA |
| 20 | 海外 PDA | 扫托盘拣货、核平、完成拣货;待 `canDepart=true` 后点击发车 | 扫码事务扣减库存;发车后排车 `DEPARTED`、出库单 `OUTBOUNDED` | 派送运营/客户 |
易用性和数据流转的改造目标、优先级与验收指标见[《头程下单到尾程出库流程易用性与数据流转优化方案》](头程下单到尾程出库流程易用性与数据流转优化方案.md)。
## 2. 角色、终端与职责
| 角色 | 使用终端 | 主要职责 |
|---|---|---|
| 企业订单操作员 | Client | 新建/导入头程订单、保存草稿、提交、查看入仓码、查看与确认已发送费用 |
| 平台订单运营 | Admin | 审核订单、修订允许修改的申报信息、查看统一订单主状态 |
| 国内仓操作员 | Admin/测量设备 | 收货、尺寸测量、称重图片、OCR 校正、测量提交 |
| 国内履约运营 | Admin | 计费、分柜、模拟柜、订舱、预装、报关、国内出仓 |
| 国内 PDA 操作员 | 国内 PDA | 普通箱码拣货或一件代发整托拣货、装柜取证、铅封和最终完成 |
| 海外履约运营 | Admin | 尾程订单、按柜转换入仓计划、预计到仓、排车、转派送、出库与派送单核对 |
| 海外仓 PDA 操作员 | 海外 PDA | 柜号签收、卸柜入仓、排车拣货、普通/一件代发出库、发车 |
| 企业客户 | Client | 查看本企业尾程/派送结果;不操作平台排车和 PDA 作业 |
海外 PDA 操作员必须是平台内部 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`,并且只绑定一个启用收货能力的实际海外仓。客户账号、平台普通管理员或国内 PDA 角色不能替代该身份作业。
## 3. 开始前必须准备的基础资料
### 3.1 客户与下单资格
- 企业账号可登录,企业资料已由平台审核通过。
- 当前用户具备创建、提交头程订单的权限。
- 客户资金账户可用;草稿保存不校验资金,提交时实际余额小于 0 会被拒绝。
- 国内收货仓、目的仓、物流渠道及需要使用的往来单位处于有效状态。
### 3.2 国内履约资料
- 价格簿、客户尺寸/计费规则已发布;缺少价格不应伪造费用。
- 柜型、渠道、订舱代理、拖车供应商等主数据可用。
- 国内 PDA 人员、仓库和设备已配置。
### 3.3 海外履约资料
- 实际海外收货仓必须为平台公共仓、类型 `OVERSEAS`、状态 `ACTIVE` 且启用 `RECEIVING`。
- 仓内存在可用库位和实物托盘。
- 海外 PDA 人员绑定上述唯一实际收货仓。
- 排车所需车辆、司机、预约信息以及派送费用录入条件已准备。
实际海外收货仓、国内来源仓、FBA/私人目的仓是三个不同概念,不能互相代填。
## 4. 阶段一:客户头程下单与平台审核
### 4.1 客户新建订单
入口:Client → 头程订单。
操作:
1. 点击新建订单,选择运输方式、国内收货仓、渠道和目的类型。
2. 按目的类型选择 FBA 仓、海外仓或填写私人地址;公共目的仓必须从有效主数据选择。
3. 填写提货/送货信息、客户参考号、预计时间及备注。
4. 录入至少一条货物明细,包含箱数、件数、重量、体积、SKU、品名等当前页面要求的字段。
5. 一件代发必须提供可冻结的 SKU/箱内明细,不能在后续扫码阶段用货名猜测 SKU。
6. 点击“保存草稿”,检查订单后再点击“提交订单”。
批量下单可使用中文 XLSX 模板,最多 50 行。导入成功只生成草稿,不会自动提交;操作员仍需逐票检查并提交。
系统结果:
- 草稿为 `DRAFT`。
- 提交成功后 Order 状态为 `SUBMITTED`,统一主状态展示为 `PENDING_REVIEW/待审核`。
- 提交时生成并固定全局唯一 `inboundCode/入仓码`;草稿阶段没有入仓码。
- 提交时重新校验企业资格、资金账户、仓库/目的仓和渠道;不能只相信页面下拉快照。
### 4.2 平台审核订单
入口:Admin → 统一订单/头程订单 → 待审核。
操作:
1. 用订单号、入仓码、客户或状态定位订单。
2. 打开详情,核对客户申报、货物、渠道、国内收货仓和目的仓。
3. 批量审核时先打开批量审核预览,逐票查看 `canApprove` 和阻断原因。
4. 如需修订,只修改当前状态允许的平台申报字段;不得改客户、国内收货仓、订单号、入仓码,也不得增删或重排货物行。
5. 点击审核通过。
系统结果:
- Order 进入 `APPROVED`。
- Firstleg 幂等创建正式国内入库单,沿用提交时生成的入仓码。
- 生成或补齐稳定箱码/箱唛映射;后续测量、国内 PDA 和跨区事件都复用这些事实。
- 统一主状态进入 `WAIT_MEASURE/待测量数据`。
操作门禁:待审核订单即使已经出现 Firstleg 准备中投影,统一列表仍必须显示待审核,不能绕过审核直接进入物理履约。
## 5. 阶段二:国内收货、测量、计费与待分柜
### 5.1 国内仓收货和测量
入口:Admin → 入库管理/统一订单 → 测量;第三方测量设备按入仓码上传。
操作:
1. 仓库按入仓码定位入库单。
2. 第三方设备或人工逐箱记录长、宽、高和箱数;第三方只能使用业务入仓码/箱码,不能传内部数据库 ID。
3. 上传至少一张称重图片。
4. 系统执行 OCR;操作员核对原始值、单位和置信度,必要时填写原因后人工修正。
5. 确认全部有效称重图片,确保总重量大于 0。
6. 提交测量审核;审核不通过时修正后重新提交,不能带病进入后续实物作业。
系统结果:
- 测量子流程可经历 `WAIT_MEASURE → MEASURING → MEASURE_PENDING`;审核结果、计费结果分别保存,不能混成一个状态字段。
- 测量审核通过后,统一主状态进入 `WAIT_SPLIT/待分柜`。
- 首张称重图片确认时,系统会尽力预留整票库位块;库位指引失败记录警告,但不能伪造成功资源。
### 5.2 费用处理
平台操作:
1. 依据客户规则、渠道、目的仓和价格簿试算。
2. 检查计费重量/体积、单价、附加费及费用明细。
3. 必要时按权限调整计费量或费用明细,提交审核并发布。
4. 将当前费用版本发送给客户。
客户操作:Client 订单详情 → 费用信息 → 核对并确认当前已发送版本。
当前主链口径:测量审核通过后即可进入 `WAIT_SPLIT`;费用是版本化的独立事实,不再阻断或回退主流程。客户确认和装柜完成同时满足后,费用版本最终冻结。不要再按旧文档把“发送费用”理解成唯一入库成功按钮。
### 5.3 一件代发分支
一件代发在测量审核后先进入 `WAIT_PALLETIZE`:
1. 系统按预计托数预生成 `DP` 托盘码。
2. Admin 打印托盘信息单;PDA 不负责连接打印机。
3. 国内 PDA 逐箱扫描稳定箱码,清点箱内全部 SKU,并归入现场托盘。
4. 逐托封托;参与订单的开放托盘必须全部完成。
5. 全部箱数与实际参与托盘核平后进入 `WAIT_SPLIT`。
一件代发托盘在后续智能装柜中是不可拆整托单元;不能按成员箱部分装柜或跳拣。
## 6. 阶段三:分柜、订舱、国内 PDA 装柜与出仓
### 6.1 建柜与排柜
入口:Admin → 货柜管理 → 送柜报关/智能装柜。
推荐操作顺序:
1. 创建内部模拟柜,选择正式装柜仓、计划渠道组、柜型、容量和限重;系统生成稳定 `containerBookingId` 和 `cabinetNo`。
2. 在智能装柜工作台按客户、入仓码、状态、到货日期等筛选候选。
3. 人工加入或使用智能推荐,将入仓单/箱数分配到柜次。
4. 核对容量、重量、渠道、同柜组和剩余箱数;按钮资格以服务端 `canAssign` 为准。
5. 生成并维护订舱草稿,补齐提单号、船名、航次、ETD、ETA 等资料后放舱为 `RELEASED`。
6. 港口取得真实柜号后,将模拟柜与已放舱订舱需求关联并录入 `containerNo`。
7. 在预装详情核对全部明细并确认预装。
允许待审核、待测量数据提前进入模拟排柜作为占位,但确认预装和启动真实 PDA 作业前,成员必须达到可履约状态。为了避免现有页面与后端历史口径差异,实际操作应在预装确认前补齐真实柜号;无论如何,最终装柜完成前必须有真实柜号。
### 6.2 国内 PDA 拣货和装柜
入口:国内 PDA → 拣货/装柜任务。
普通散货:
1. 领取柜次任务。
2. 按稳定箱码逐箱扫描;一个有效箱码只代表一箱。
3. 批量提交本次扫描;断网时不能正式提交,恢复后刷新服务端状态再重试。
4. 未完成项可暂时挂起,但必须填写异常原因;未核平不能最终完成。
一件代发:扫描托盘码或托盘内任一箱码定位整托,以 `PICK_PALLET` 整托确认,不得拆托拣货。
装柜证据:
- 空仓、半仓、满仓、锁仓四步照片;
- 带有效铅封号的铅封证据;
- 同类可追加,错误证据作废并保留审计,不硬删除历史。
最终完成门禁:全部计划明细核平、五类有效证据齐全、铅封号存在且真实柜号已就绪。完成后装柜计划为 `COMPLETED`,来源入库单为 `LOADED`。
### 6.3 出口报关与国内出仓
入口:Admin → 货柜管理 → 柜次详情/国内出仓。
当前整柜出仓门禁:
| 门禁 | 要求 |
|---|---|
| 扣货 | 柜内入库单均未被扣货 |
| 入库单 | 所有绑定入库单为 `LOADED` |
| 装柜计划 | `COMPLETED` |
| 国内 PDA | 拣货任务 `COMPLETED` |
| 订舱 | 已放舱并分配当前柜次,状态 `RELEASED` |
| 装柜证据 | 四步照片和有效铅封齐全 |
| 出口报关 | 整柜报关 `COMPLETED` |
拖车状态和独立船期当前只作为参考,不属于服务端硬门禁。出口报关完成后,系统会在全部门禁满足时自动尝试确认离仓;若未自动收敛,平台可在“国内出仓门禁”面板点击“确认离仓”。
成功结果:
- 整柜出仓状态进入 `WAITING_FOR_LOADING`(现有内部编码,业务含义是已确认离开国内仓);
- 只写一次整柜离仓历史;
- 自动创建唯一船节点查询任务和第一条实际 `DEPARTED_WAREHOUSE`;
- 尝试完成同订舱关联的国内拖车单;
- 已产生实际离仓节点后不得撤销。
## 7. 阶段四:海运节点、跨区尾程建单和海外入仓
### 7.1 船节点推进
船节点任务由国内出仓自动创建,不能由操作员另造一套。标准实际节点按以下顺序单调推进:
`DEPARTED_WAREHOUSE → AT_ORIGIN_PORT → LOADED_ON_VESSEL → DEPARTED_ORIGIN_PORT → IN_TRANSIT → ARRIVED_DESTINATION_PORT → DISCHARGED_FROM_VESSEL → PICKUP_READY → PICKED_UP → EMPTY_RETURNED`
规则:
- 预计时间只增加预计事实,不推进最新实际节点。
- 实际节点不能重复、回退或越权覆盖。
- `PICKUP_READY` 是海外 PDA 待收柜的开放门禁。
- 实际 `EMPTY_RETURNED` 后查询任务停止。
平台可在 Admin → 船期信息/订舱详情/货柜详情查看,并在第三方数据缺失时按权限人工补录真实节点。
### 7.2 跨区生成尾程订单
国内装柜完成事件通过 Event Bridge 投递海外区域。当前正确结果是:
- 按最终实装数据生成尾程订单和货物冻结快照;
- 尾程订单状态为 `PENDING_ASSIGNMENT`;
- 建立 `FIRST_LEG` 在途库存快照;
- 保留来源订单、来源入仓码、柜号、目的类型、SKU/箱唛、头程托盘码和备注等事实;
- 不自动创建海外入仓计划。
若页面暂未出现,可等待异步事件收敛后刷新;不得直改海外数据库或手工伪造尾程订单。
### 7.3 平台按柜转换海外入仓计划
入口:Admin → 海外仓 → 尾程订单。
操作:
1. 在柜子工作台按柜号找到 `sourceContainerBookingId` 对应的整柜。
2. 打开柜级详情,核对柜内所有客户、订单、来源入仓码、目的仓和预计到仓时间。
3. 先执行“入仓计划预览”,查看按 FBA/一件代发/私人地址的分组、头程已有托数、海外补充托数、最终托数、阻断项和快照版本。
4. 阻断项为零后确认转换;前端不能自行计算托数或逐票拼装计划。
5. 转换成功后回到尾程订单/入库单核对每票独立计划、计划号、托盘码和实际接柜仓。
系统规则:
- 整柜锁定、重算、资源预留和逐票建计划是一次原子操作,重放不能生成第二批资源。
- 尾程订单推进到 `INBOUND_PLANNED`。
- 散货保留所有头程已有托盘码;海外计划托数更多时按同柜同 FBA 连续补码。
- 一件代发严格沿用国内封托结果,不能在海外计划中随意改托数。
- 预计到仓和来源入仓码是冻结事实,不允许人工改写。
- 计划或真实托盘资源未齐时,海外 PDA 不能接柜。
### 7.4 海外 PDA 柜号签收
前置:船节点已到实际 `PICKUP_READY`,计划和资源完整,当前操作员绑定正确实际海外收货仓。
入口:海外 PDA → 拆柜收货。
操作:
1. 在待卸货列表选择柜号,核对柜内所有入仓码、目的仓、托数和箱数。
2. 锁定柜号;未完成前不能切换其他柜,其他操作员也不能抢占。
3. 用真机依次拍摄车牌 `LICENSE_PLATE`、集装箱封签 `CONTAINER_SEAL`、开柜现场 `DOOR_OPEN`。
4. 三类照片齐全后点击“确认签收并开始收货”。
### 7.5 两种海外实收方式
FBA:
1. 选择 FBA 入仓码并扫描冻结 SKU。
2. 扫描真实托盘码。
3. 输入本次放入该托盘的箱数并确认。
4. 可分多次操作,直至该入仓码待收箱数为 0;不得超收。
OVERSEAS:
1. 扫描最终实装冻结箱唛,系统自动定位所属入仓码。
2. 扫描真实托盘码。
3. 逐箱确认;不能用输入箱数代替逐箱扫码。
4. 非本柜、未实装、已收或不存在的箱唛必须拒绝。
完成整柜:所有入仓码 `pendingPackages=0` 后,点击“完成整柜入库”。系统同事务生成库存余额、仓位、流水和托盘事实,尾程主流程进入 `INBOUND_COMPLETED`,并通过 Outbox 回写国内卸柜完成事实。
## 8. 阶段五:海外排车、PDA 拣货和尾程出库
本节以普通 FBA/私人地址库存的排车出库为主链。暂扣订单不能进入排车或出库;其他同柜订单不受影响。
### 8.1 平台创建排车
入口:Admin → 海外仓 → 排车列表 → 智能预排。
操作:
1. 从真实已完成入仓的计划或已收托盘加载候选。
2. 把托盘加入预排草稿;加入即锁定,移除或关闭草稿时释放。
3. 选择车型并检查托位和重量。页面当前提示:大车 30 托/40000 LB,小车 15 托/10000 LB;最终仍以服务端校验为准。
4. 点击“预排车”,生成正式排车单和正式托盘锁。
5. 在排车列表点击“编辑”,维护车牌、司机、联系电话、车队、预计发车/到达时间、FBA 预约号和预约状态。
6. 路线由已排托盘的目的 FBA 仓自动汇总,不能手工编造。
初始排车状态为 `PENDING_DEPARTURE`。未开始且未生成出库单时可移除托盘或取消;取消会释放托盘锁。
### 8.2 平台转派送
入口:Admin → 海外仓 → 排车列表 → 转派送。
可操作状态:`PENDING_DEPARTURE`、`PICKING`、`READY_TO_DEPART`,但必须在 PDA 发车前完成。
操作:
1. 填写预计派送时间、派送方式、提货方式、装车类型。
2. 按实际业务填写车队/快递、运单号、司机、电话、车牌、车型、车辆大小和预约账号。
3. 填写派送费用;按业务条件填写装车费、买约费用和卖家姓名。
4. 确认提交。
系统结果:
- 一张排车单生成一张车辆级出库单,状态 `WAIT_OUTBOUND`,类型 `DISPATCH_DELIVERY`。
- 按目的 FBA 仓/私人地址拆成一张或多张派送单,初始状态 `WAIT_DELIVERY`。
- 尾程订单记录出库单号、派送单号和对应子单状态。
- 相同排车单重复操作返回既有结果,不能重复建出库单。
### 8.3 海外 PDA 排车拣货
入口:海外 PDA → 排车拣货。
操作:
1. 打开本仓排车任务,核对排车单、车辆、托盘、目的仓和计划箱数。
2. 扫描托盘业务码;系统验证托盘属于当前排车、当前仓和当前客户库存位置。
3. 确认本托箱数,重复直至全部托盘和箱数核平。
4. 点击“完成拣货”。
状态结果:
- 首次成功扫码后任务进入 `PICKING`。
- 全部托盘核平后 PDA 任务为 `COMPLETED`,排车单为 `READY_TO_DEPART`。
- 库存扣减点是成功的 PDA 扫码拣货事务;发车只推进排车/出库事实,不得重复扣库存。
### 8.4 海外 PDA 发车并完成尾程出库
发车按钮只消费服务端返回的 `canDepart` 和 `departBlockReason`。必须同时满足:
- 车辆信息完整;
- PDA 拣货任务完成;
- 所有托盘和箱数核平;
- 排车单为 `READY_TO_DEPART`;
- 平台已经“转派送”;
- 关联出库单仍为 `WAIT_OUTBOUND`。
点击发车后:
- 排车单进入 `DEPARTED`,记录实际发车时间和已发箱数;
- 车辆级出库单进入 `OUTBOUNDED`;
- 释放排车托盘锁;
- 后续派送单继续在 Admin/Client 流转,派送不在海外 PDA 完成。
至此,“头程下单 → 尾程出库”主链完成。如果业务还要求末端签收,则继续在派送单上传签收资料,系统自动完成派送并向国内回写最终履约完成事实。
## 9. 非普通排车出库的分支
| 出库类型 | 如何进入 | PDA/系统终点 |
|---|---|---|
| 一件代发出库 | Admin 从已完成入仓且仍有可用 SKU/箱唛的库存位置创建;候选必须属于唯一客户和唯一海外仓 | 状态 `WAIT_PICK → PICKING → PICKED → OUTBOUNDED`;PDA 可逐箱,也可扫 DP 托盘码原子拣完整托剩余箱件 |
| 换标出库 | PDA 换标完工、仓库复核、客户确认后;卡车分支进排车,自提/快递直接建出库单 | 客户确认前禁止出库;PDA 完成后出库单 `OUTBOUNDED` |
| 仓储转运出库 | 客户申请,平台审核通过后生成待出库任务 | PDA 完成后转运单 `OUTBOUNDED`,尾程主单进入在途子状态 |
| 退货出库 | 从退货箱/SKU 可用库存创建 | 按服务端冻结箱唛和 SKU 实际拣出,不能自由填库存来源 |
| 派送异常退回 | 从原出库拣货事实选择可退明细 | 实收入真实托盘/库位,恢复库存后异常单完成、派送变 `RETURNED` |
出库列表统一状态包括 `WAIT_PICK`、`PICKING`、`PICKED`、`WAIT_OUTBOUND`、`OUTBOUNDED`、`CANCELLED`。只有尚未执行的 `WAIT_PICK/WAIT_OUTBOUND` 可取消。
## 10. 核心状态与业务编号对照
### 10.1 主状态时间线
| 阶段 | 关键状态 | 业务含义 |
|---|---|---|
| 客户下单 | `DRAFT` | 可编辑草稿,无入仓码 |
| 客户提交 | `SUBMITTED` / `PENDING_REVIEW` | 已生成固定入仓码,待平台审核 |
| 审核后 | `WAIT_MEASURE` | 国内正式入库单已建立,等待实测 |
| 一件代发组托 | `WAIT_PALLETIZE` | 等待逐箱清点、组托和封托 |
| 测量/组托后 | `WAIT_SPLIT` | 可进入真实分柜与预装 |
| 国内作业 | `LOADING` | 国内 PDA 拣货/装柜中 |
| 国内装柜完成 | `LOADED` | 装柜完成,但不等于国内离仓 |
| 海外尾程建单 | `PENDING_ASSIGNMENT` | 事件已建尾程订单,尚未转换入仓计划 |
| 海外计划完成 | `INBOUND_PLANNED` | 入仓计划和资源就绪 |
| 海外入仓完成 | `INBOUND_COMPLETED` | 海外库存、仓位和流水已生成 |
| 排车拣货完成 | `READY_TO_DEPART` | 待平台转派送/发车门禁收敛 |
| 海外出库完成 | `OUTBOUNDED` / `DEPARTED` | 货已离开海外仓,进入派送阶段 |
### 10.2 编号串联关系
| 编号 | 谁生成 | 用途 |
|---|---|---|
| `orderNo` | Order | 客户头程订单号 |
| `inboundCode` | Order 提交时 | 跨国内入库、装柜、海外尾程的稳定业务关联码 |
| `containerBookingId` | Firstleg 建柜 | 柜级稳定内部业务身份,跨区事件和海外整柜聚合使用 |
| `cabinetNo` | Firstleg 建模拟柜 | 平台可见柜次号 |
| `containerNo` | 港口取得后录入 | 真实集装箱号,PDA 和船运现场识别使用 |
| `loadingPlanId` | Firstleg 建柜/预装 | 国内预装、PDA 和装柜清单关联 |
| `palletNo` | 国内/海外资源服务 | 现场托盘扫描身份;一件代发与散货生成规则不同 |
| `planNo` | Lastleg 转换计划 | 海外入仓计划号 |
| `dispatchNo` | Lastleg 预排 | 海外排车单号 |
| `outboundNo` | Lastleg 转派送/独立出库 | 海外出库单号 |
| `deliveryNo` | Lastleg 转派送 | 按目的仓拆分的派送单号 |
页面不得用内部数据库 ID 代替这些业务编号。编号缺失时应显示“—”,不能拿别的单号冒充。
## 11. 当前统一口径与旧文档差异
1. 费用不再阻断待分柜。旧测量文档把“发送费用给客户”写成入库成功节点;当前 Firstleg 口径是测量审核通过后直接进入 `WAIT_SPLIT`,费用独立版本化。
2. 跨区事件不自动建海外入仓计划。旧海外验收手册曾写“装柜完成事件直接生成入仓计划”;当前只生成 `PENDING_ASSIGNMENT` 尾程订单,平台必须按柜预览并确认转换。
3. 国内出仓硬门禁不含拖车和独立船期。当前服务端硬门禁是未扣货、入库单 `LOADED`、装柜计划/PDA完成、订舱 `RELEASED`、装柜证据与出口报关完成;拖车和独立船期仅展示参考。
4. 国内出仓既有自动收敛也保留人工入口。出口报关完成后系统会自动尝试确认;未收敛时平台可在柜次或国内出仓页手工确认,二者共享同一服务端门禁与幂等结果。
5. 真实柜号最迟时点存在历史指引差异。当前代码允许在 `EDITING/LOADING` 补录,但最终 PDA 完成前必须存在;为避免页面入口差异,正式操作统一要求预装确认前补齐。
6. 尾程主状态与子单状态分开。入仓后主流程固定在 `INBOUND_COMPLETED`,排车、出库、派送、换标和转运的精确状态保存在子单,不得互相覆盖。
## 12. 异常处理和禁止绕过事项
- 订单提交失败:先检查企业审核、资金账户、仓库/目的仓和渠道,不要通过保存草稿伪装已提交。
- 测量失败:修正尺寸/称重证据后重新提交;未确认图片、总重为零和重复箱码不得放行。
- 排柜失败:以 `canAssign`、剩余箱数、容量、同柜组和版本冲突原因处理,不从前端强塞分配。
- 国内 PDA 版本冲突:刷新任务和库存快照;不能重复使用旧 token 或旧版本继续提交。
- 国内出仓失败:逐项查看服务端门禁;不以人工改状态、改数据库或伪造船节点解决。
- 跨区未建单:先检查事件 Outbox/Event Bridge/Inbox 和幂等结果;不手工补海外订单。
- 海外 PDA 看不到柜:依次检查实际 `PICKUP_READY`、整柜计划资源、操作员绑定仓、柜是否被锁定。
- 海外收货不平:按入仓码核对计划数、实收数、箱唛/SKU 和托盘;未核平不能完成整柜。
- 排车不能发车:直接展示 `departBlockReason`,检查车辆、拣货核平、`READY_TO_DEPART` 和 `WAIT_OUTBOUND` 出库单。
- 所有写操作都应携带服务端版本和幂等键;超时后先查结果,不能换新键盲目重复操作。
- 不通过数据库直改、浏览器调用 internal 接口、跨客户 ID 或内部主键跳过正常业务流程。
## 13. 一票到底的验收清单
- [ ] 企业资料审核通过,提交时资金账户通过校验
- [ ] 客户订单从草稿成功提交并生成唯一入仓码
- [ ] 平台审核通过,正式国内入库单和箱码事实已建立
- [ ] 尺寸、称重、OCR 修正和测量审核完整
- [ ] 普通订单进入 `WAIT_SPLIT`;一件代发先完成组托再进入 `WAIT_SPLIT`
- [ ] 模拟柜、订舱、真实柜号、预装计划和订单分配关系一致
- [ ] 国内 PDA 全部拣货,五类证据和铅封齐全
- [ ] 装柜计划 `COMPLETED`,来源入库单 `LOADED`
- [ ] 整柜出口报关完成,国内出仓成功并生成船节点任务
- [ ] 实际船节点到 `PICKUP_READY` 后海外 PDA 才看到柜号
- [ ] 海外只先生成 `PENDING_ASSIGNMENT` 尾程订单,平台按柜转换计划成功
- [ ] 海外 PDA 三类签收照片齐全,FBA/OVERSEAS 按正确模式实收
- [ ] 整柜所有入仓码核平,尾程主流程 `INBOUND_COMPLETED`
- [ ] 库存余额、托盘、库位和流水一致,国内收到卸柜回写
- [ ] 排车托盘来源真实且未暂扣,车辆和预约信息完整
- [ ] 平台转派送生成一张 `WAIT_OUTBOUND` 出库单和按目的仓拆分的派送单
- [ ] 海外 PDA 拣货核平,服务端 `canDepart=true`
- [ ] 发车后排车单 `DEPARTED`、出库单 `OUTBOUNDED`,库存与出库数量守恒
## 14. 主要事实来源
- `docs/业务快速索引.md`
- `docs/订单与客户/订单与客户头程订单闭环设计.md`
- `docs/入库测量与计费/入库测量与计费需求优化版-v2-测量计费入库主流程.md`
- `docs/国内货柜履约/货柜管理全链验收矩阵-2026-08-13.md`
- `docs/海外仓业务/README.md`
- `docs/海外仓业务/虫虫出海全流程人工验收手册.md`
- `baiyue-firstleg/order/AGENTS.md`
- `baiyue-firstleg/firstleg/AGENTS.md`
- `baiyue-firstleg/lastleg-service/AGENTS.md`
- `apps/admin-web/src/modules/overseas-warehouse/AGENTS.md`
- `apps/pda-overseas-uniapp/AGENTS.md`
本文用于统一业务操作和评审口径;接口字段、权限码、并发版本和错误码仍以对应 Controller、DTO、OpenAPI 与服务端状态机为最终依据。
@@ -0,0 +1,463 @@
# 头程下单到尾程出库流程易用性与数据流转优化方案
> 目标:在现有端到端流程已经走通的基础上,降低学习成本、重复录入、跨页面查找和交接等待,让每个岗位始终知道“货在哪里、现在由谁处理、为什么不能继续、下一步做什么”。
> 基线流程:见[《头程下单到尾程出库全流程操作手册》](头程下单到尾程出库全流程操作手册.md)。
> 边界:本方案优先复用现有 Admin、Client、国内/海外 PDA、Gateway、Order、Firstleg、Lastleg 和 Warehouse 能力,不新建平行状态机,不引入第二套订单或跨区同步写链路。
> 当前状态:现状全景和六阶段基线已经完成;本文进入分阶段业务评审,暂不直接作为实施基线。AI 分析和评审记录统一遵循[《头程到尾程 AI 流程优化上下文包》](头程到尾程AI流程优化上下文包.md),逐项决定保留、合并、自动化、简化或删除。
## 1. 优化后的成功标准
流程是否“能走通”只代表功能闭环;易用性优化完成后还应同时达到:
1. 操作员进入一个单据后,10 秒内能回答当前阶段、下一动作、责任岗位和阻断原因。
2. 同一业务事实只录一次,后续阶段自动带出并注明来源、冻结时间和是否可修改。
3. 每次操作完成都明确展示“产生了什么结果、下一步去哪里”,并提供可点击入口。
4. 异步跨区操作不再表现为“点完没反应”,页面可见待投递、处理中、成功或失败。
5. PDA 扫错时直接说明“扫到了什么、为什么不匹配、正确应该扫什么”,而不是只返回错误码。
6. 列表默认突出需要当前岗位处理的记录,不要求用户同时理解全部内部状态。
7. 批量操作在提交前有预览,提交后有逐项结果和可重试项,不能整批静默失败。
8. 所有阶段以服务端 `nextAction/allowedActions/blockers` 为准,页面不自行猜测资格。
9. 数据跨服务、跨区域流转可追踪、可重放、可对账,但业务人员不需要接触内部 ID。
## 2. 当前主要问题
### 2.1 操作层问题
| 问题 | 当前表现 | 业务影响 |
|---|---|---|
| 页面按模块拆开,缺少一票到底视角 | 订单、测量、货柜、出仓、船节点、尾程、入仓、排车、出库分别查询 | 操作员反复复制订单号、入仓码和柜号,交接依赖群消息 |
| 状态多但不回答下一步 | 页面显示 `WAIT_SPLIT`、`PENDING_ASSIGNMENT`、`READY_TO_DEPART` 等编码映射 | 新员工知道状态名但不知道该去哪个菜单、找哪个岗位 |
| 阻断原因与修复入口分离 | 门禁面板告诉用户缺报关、缺车辆或缺计划,但需要自行找页面 | 处理时间长,容易重复点击同一操作 |
| 操作成功提示过轻 | 常见结果只有“保存成功”或“提交成功” | 用户不清楚是否建了入库单、出库单、派送单或跨区任务 |
| 相同信息重复填写 | 车辆、司机、目的仓、箱数、费用来源、预计时间在不同阶段重复录入 | 容易产生快照不一致和人工抄错 |
| 异步流转不可见 | 国内完成后海外页面暂时没有数据,只能等待刷新 | 用户误判失败,重复操作或要求技术人员查库 |
| PDA 错误反馈缺现场语义 | 扫错托盘/箱唛时只知道失败,不知道资源实际归属 | 仓库人员需要退出任务或电话询问后台 |
| 批量能力缺少统一反馈 | 批量导入、审核、排柜、计划转换各自反馈形式不同 | 错误定位和重试成本高 |
### 2.2 数据流问题
| 问题 | 风险 |
|---|---|
| 交易状态、履约状态、费用状态、子单状态被用户当成一个状态理解 | 页面互相覆盖或展示“倒退”,实际只是不同状态维度 |
| 同一字段在多个服务留有快照,但页面不显示来源 | 用户无法判断当前值来自客户申报、实测、计划还是实际作业 |
| 跨区事件处理结果缺少业务可见回执 | 事件已发送、消费失败、资源生成失败等场景只能从日志判断 |
| 单据间只有内部 ID 关联,缺少统一业务链查询 | 客服无法用订单号/入仓码直接查到柜、尾程计划、排车和出库 |
| 自动动作和人工动作混在同一历史里 | 无法快速判断是系统自动收敛、第三方回传还是人工补录 |
| 同步调用失败与业务失败提示不分 | 操作员不知道应补资料、刷新版本还是等待系统重试 |
| 分页列表承担跨柜/跨客户聚合 | 容易出现只看当前页就做整柜判断的问题 |
## 3. 总体产品方案
### 3.1 一票到底进度条
在 Client 订单详情和 Admin 统一订单详情顶部增加同一套业务阶段条:
```text
订单提交 → 国内入库 → 测量/组托 → 待分柜 → 国内装柜 → 国内出仓
→ 海运在途 → 海外计划 → 海外入仓 → 海外排车 → 海外出库 → 派送完成
```
每个节点只展示四种用户可理解状态:未开始、处理中、已完成、异常。点击节点进入对应详情或只读摘要。
阶段条不能根据前端已有字段自行拼装,应由服务端聚合返回:
- `currentStageCode/currentStageName`:当前主阶段;
- `stageStatus`:阶段状态;
- `nextActionCode/nextActionName`:下一动作;
- `ownerRole/ownerName`:当前责任岗位;
- `blockers[]`:阻断码、中文原因、修复建议和跳转目标;
- `relatedDocuments[]`:相关业务单号、状态和详情入口;
- `lastChangedAt`:最后业务变化时间;
- `asyncState`:无异步任务、处理中、成功、失败待重试。
实现上优先扩展现有统一订单聚合、柜级 overview 和尾程柜级 summary,不新建另一套可写流程引擎。
### 3.2 “现在做什么”任务卡
列表第一列保持“操作”,并在状态旁增加任务卡:
- 当前需要我做:例如“审核订单”“补称重图片”“确认预装”“转换海外入仓计划”“维护车辆”“先转派送”。
- 为什么:显示最多两条最高优先级阻断原因。
- 去处理:直接深链到目标弹窗、页签或 PDA 作业。
- 谁负责:当前角色无权限时显示责任岗位和只读说明,不显示无效按钮。
- 完成期限:按业务 SLA 显示剩余时间或超时标记。
服务端必须返回稳定动作编码;页面只负责中文展示和路由,不根据状态组合重新推导动作。
### 3.3 操作完成回执
所有关键写操作采用一致的结果反馈,而不是单纯 Toast:
| 当前操作 | 回执必须告诉用户 |
|---|---|
| 提交订单 | 订单号、入仓码、当前状态、下一步由平台审核 |
| 审核通过 | 已生成的国内入库单/箱码、下一步国内仓测量 |
| 测量审核通过 | 是否进入待分柜或待组托、费用是否已生成 |
| 预装确认 | PDA 任务是否已生成、缺真实柜号时的明确提醒 |
| 国内装柜完成 | 装柜结果、报关状态和国内出仓门禁摘要 |
| 国内出仓 | 船节点任务是否创建、第一条节点和海外异步建单状态 |
| 海外计划转换 | 计划数量、托盘数量、资源结果和 PDA 是否可接柜 |
| 海外整柜入仓完成 | 实收箱数、库存/托盘/库位结果和下一步排车入口 |
| 转派送 | 出库单号、按目的仓拆分的派送单号、发车尚缺条件 |
| PDA 发车 | 已出库箱数、排车单/出库单终态和后续派送入口 |
回执应支持“查看结果”和“继续处理下一票”,避免保存后返回一个没有上下文的列表。
## 4. 分阶段易用性优化
### 4.1 客户下单
现状操作:一个较长表单或 XLSX 导入,保存草稿后再提交。
优化:
- 用“运输与仓库 → 货物 → 提货/目的信息 → 确认提交”四步向导组织表单。
- 顶部常驻草稿保存状态;字段失焦自动保存草稿,但最终提交仍由用户明确点击。
- 选择目的类型后只展示相关字段:FBA 显示 FBA 仓,海外仓显示公共目的仓,私人仓显示地址。
- 仓库、渠道、目的仓下拉显示业务名称、代码、地址和可用状态,不展示内部 ID。
- 提交前集中展示校验清单,点击错误可滚动到对应字段。
- 批量导入先预览“可创建、需修正、重复”三组;错误报告可按原行号下载,修正后只重试失败行。
- 提交成功回执直接展示入仓码,并提供复制、打印箱唛和查看订单进度入口。
数据优化:客户端只提交用户输入;仓库名称、地址、渠道名称和客户归属由服务端验证后冻结快照,避免客户端抄写。
### 4.2 平台订单审核
现状操作:列表定位,打开详情或批量预览后审核。
优化:
- 默认进入“待我审核”,按提交时间和加急程度排序。
- 列表直接展示入仓码、目的类型、目的仓、渠道、箱数/体积/重量和阻断摘要。
- 批量预览用“可通过/需补资料/系统异常”分组;只允许勾选可通过项。
- 修改客户申报时做前后对比,标出会影响渠道、计价和排柜的字段。
- 审核通过后自动跳转“下一票”或进入本票进度页,不要求重新筛选。
数据优化:审核只推进 Order 权威状态;Firstleg 建单结果通过明确回执返回。Firstleg 暂时不可用时显示“审核已提交/建单待收敛”或失败原因,不能把部分成功伪装为整体成功。
### 4.3 国内收货、测量与费用
现状操作:按入仓码查询,测量和称重分多次录入,费用在另一工作区处理。
优化:
- 提供“扫码入仓码进入本票”入口,落地后固定显示客户、订单、目的仓和应收箱数。
- 将尺寸、称重、图片、审核和库位块放在同一作业页的连续步骤中。
- OCR 列表突出低置信度、单位异常和重复图片;批量确认高置信度结果。
- 每条实测数据展示“客户申报值 vs 当前实测值 vs 计费采用值”,差异超阈值自动提示。
- 审核不通过必须选择标准原因,可一键生成待修正任务;修正后回到原审核人队列。
- 费用卡明确标注“费用处理不阻断待分柜”,避免运营等待费用后才排柜。
- 客户收到费用后在订单进度中直接确认,无需进入独立财务菜单寻找。
数据优化:尺寸原始值、修正值、计费值分字段保存;页面必须显示来源,禁止覆盖原始数据。费用用版本号关联测量快照,不复制一套无法追溯的合计。
### 4.4 分柜、订舱和预装
现状操作:在模拟柜、智能装柜、订舱和预装详情之间切换。
优化:
- 建立柜级工作台,顶部固定显示柜次号、真实柜号、订舱、渠道、仓库、容量、重量和下一动作。
- 左侧候选、右侧已装计划保持筛选和滚动位置;加入后即时显示容量/重量变化。
- 候选行直接说明不可加入原因,例如“待审核占位可加入,但暂不能确认预装”。
- 同柜组、一件代发整托用不可拆分卡片展示,避免用户误以为可按行拆分。
- 订舱放舱、柜次关联、真实柜号、预装确认形成同页检查清单。
- 真实柜号未填时提供醒目待办;正式操作口径统一在预装确认前补齐。
- 确认预装前显示最终成员、箱数、托盘、容量、重量、费用重算影响和将生成的 PDA 任务。
数据优化:所有柜级动作使用稳定 `containerBookingId`,`cabinetNo/containerNo` 只作为业务展示身份。预装预览必须来自服务端完整柜级结果,不能使用当前分页数据拼装。
### 4.5 国内 PDA 装柜
现状操作:领取任务、逐箱或整托扫描、本地形成草稿、批量提交、上传证据并完成。
优化:
- 首页默认显示当前仓、当前任务、已扫/应扫、异常数和网络状态。
- 普通箱、一件代发托盘使用不同颜色和提示音,避免将整托按箱扫描。
- 扫描成功即时显示货物、入仓码、目标柜和累计数;错误显示扫描对象实际归属。
- 批量提交前展示新增、重复、错柜和待处理异常四类统计。
- 五类证据使用固定槽位和进度 `0/5…5/5`;铅封照片和铅封号在同一步完成。
- 最终完成页只展示服务端门禁,缺哪项就提供对应操作入口。
- 网络中断保留本地扫描草稿和同一幂等键;恢复后先对账服务端已接收项,再允许重试。
数据优化:箱码、托盘码、柜次和任务全部由服务端解析归属;PDA 不提交客户或猜测仓库范围。批量响应返回逐项结果及服务端最新任务版本。
### 4.6 报关和国内出仓
现状操作:装柜完成后在报关、柜次详情或国内出仓页处理。
优化:
- 柜次详情新增“离仓准备度”,用 7 个门禁卡片显示已完成/阻断。
- 每个阻断卡片提供深链:去解除扣货、查看 PDA、补证据、完成订舱、处理报关。
- 明确标注拖车和独立船期“仅供参考,不阻断国内出仓”。
- 报关完成后的自动确认展示为“系统正在确认离仓”;自动失败时才开放人工处理,并显示失败原因。
- 离仓成功回执展示船节点任务号和异步海外建单状态。
数据优化:门禁由 Firstleg 整柜锁定重算。页面不缓存门禁结论;执行前后都读取最新版本。自动与人工确认复用同一命令和幂等语义。
### 4.7 船节点和跨区建单
现状操作:多个页面可查看船节点;跨区建单需要等待刷新。
优化:
- 柜级时间线同时展示预计节点、实际节点、异常和数据来源,预计/实际视觉区分。
- `PICKUP_READY` 节点旁明确显示“海外 PDA 已开放/尚未开放”。
- 增加跨区处理卡:国内事件已生成、已投递、海外已接收、尾程订单已生成、资源待转换。
- 超过预期时间仍未建单时,平台可查看业务化失败原因并触发受控重试;不能直接补数据。
- 同一柜多客户时用柜级聚合显示事件状态,再进入每票结果,避免逐票判断整柜完成。
数据优化:跨区状态使用 `eventId + aggregateId + schemaVersion + payloadHash` 追踪。国内只发布事实,海外只消费并幂等建单;禁止跨区同步双写数据库。
### 4.8 海外入仓计划转换
现状操作:按柜查看、预览、处理阻断后确认转换。
优化:
- 柜级卡片直接显示订单数、待建计划数、资源待补数、预计到仓和准备度。
- 转换弹窗用三栏对比“头程已有托数、海外补充托数、最终托数”。
- 阻断项按可操作性排序,并提供修复入口;例如缺预计到仓、目的仓异常、托盘资源失败。
- 转换后展示逐票计划结果及托盘码,支持只重试 `resourcePendingPlanCount` 指向的资源异常。
- 已转换柜仍可查询,但主按钮改为“查看/维护计划”,避免用户误以为要重复转换。
数据优化:预览返回 `snapshotVersion/snapshotHash`;提交只回传该快照标识和明确选择。服务端锁柜重算,避免页面停留期间数据变化导致错建计划。
### 4.9 海外 PDA 接柜入仓
现状操作:选择柜、三类照片、按两种模式实收、完成整柜。
优化:
- 待收柜按可作业、未到 `PICKUP_READY`、资源未齐分组;后两组只读显示原因。
- 进入柜后顶部固定显示柜号、当前操作员、开始时间、总计划/实收/待收。
- 三类照片采用顺序引导,可重拍但保留审计;完成后自动进入收货,不返回列表。
- FBA 页只显示“SKU + 本次箱数”,OVERSEAS 页只显示“箱唛逐箱”,从交互上消除模式混用。
- 扫托盘后显示托盘已含入仓码和剩余容量,合托时提示来源组成。
- 整柜完成前用入仓码列表标红未核平项,点击直接继续收货。
- 完成回执显示库存、托盘、库位和国内回写状态。
数据优化:柜级会话只绑定物理柜和实际接柜仓;每票实收、库存和托盘组成仍按客户隔离。PDA 不上传客户 ID 或仓库 ID。
### 4.10 海外排车、转派送和出库
现状操作:智能预排、编辑车辆、转派送、PDA 拣货、发车。
优化:
- 排车候选默认只显示可用库存,暂扣、换标未确认、已锁定的托盘直接说明不可选原因。
- 预排草稿常驻显示托数、箱数、重量、目的仓、车型容量和超限值。
- 加入/移除托盘时即时显示锁状态;离开未提交草稿时二次确认并释放锁。
- 编辑排车时从车辆主数据带出车牌、司机、电话和车队,可覆盖的字段明确标识。
- “转派送”放在排车详情主流程位置,不藏在更多菜单;未转派送时 PDA 发车页显示平台待办。
- 转派送自动带出排车、车辆、目的仓、箱数和预约资料,只要求补充缺失字段及费用。
- 提交前预览将生成的一张出库单和按目的仓拆分的派送单。
- PDA 拣货页固定显示 `canDepart`;未满足时将服务端阻断原因翻译为动作提示。
- 发车成功回执显示出库单已完成、库存扣减数、派送单清单和下一责任岗位。
数据优化:排车、出库、派送是三张单据,各自状态独立;转派送只创建一次。库存扣减发生在 PDA 成功扫码事务,发车只推进排车和出库状态,防止重复扣减。
## 5. 数据归属与流转优化
### 5.1 权威数据归属
| 数据 | 权威服务 | 下游使用方式 |
|---|---|---|
| 客户、账号、角色、仓库绑定 | IAM/Auth | Token 和服务端身份校验,不接受页面自报客户范围 |
| 头程订单、客户申报、订单交易状态 | Order | Firstleg 使用版本化事件/内部 DTO,不反向覆盖客户申报 |
| 国内入库、实测、费用快照、柜次、装柜、出仓、船节点 | Firstleg | Admin/PDA 读写正式 API;向海外发布最终实装事实 |
| 国内/海外公共仓库主档 | Warehouse | 其他服务按 `warehouseId` 引用并冻结必要快照 |
| 海外库位、实物托盘和资源锁 | Warehouse Overseas | Lastleg 使用内部资源接口,不复制第二套仓库主档 |
| 尾程订单、海外计划、库存、排车、出库、派送 | Lastleg | Admin/Client/海外 PDA 经海外 Gateway 访问 |
| 文件 | File | 业务服务只保存已校验文件 ID 和业务归属 |
| 国内财务主账 | Finance | 海外费用通过事件入池,不跨区同步改钱包 |
### 5.2 业务主键链
对用户可见的链路检索顺序统一为:
```text
orderNo / inboundCode
→ cabinetNo / containerNo
→ tailOrderNo / planNo
→ palletNo
→ dispatchNo
→ outboundNo
→ deliveryNo
```
建议所有详情页提供“关联单据”侧栏,允许用任意一个业务号跳到上游和下游。内部数据库 ID 只留在接口和诊断信息中,不作为页面回退编号。
### 5.3 快照来源标识
页面展示容易混淆的字段时增加来源标签:
| 字段 | 可能来源 | 页面要求 |
|---|---|---|
| 箱数、重量、体积 | 客户申报、仓库实测、国内实装、海外实收 | 同时展示当前采用值与来源,历史值可展开 |
| 仓库/目的仓 | 下单选择、柜级计划、实际接柜仓 | 名称旁标注“国内来源仓/实际接柜仓/FBA目的仓” |
| 托数 | 头程推荐、头程已有、海外补充、海外最终 | 转换预览分别展示,不把补充数写成最终总数 |
| 预计/实际时间 | 客户期望、订舱 ETA、预计到仓、实际到仓 | 用不同字段和标签,不用创建时间回退 |
| 状态 | 主流程、审核、费用、子单 | 分区展示,不用子单状态覆盖主状态 |
### 5.4 异步事件可观测
每个重要异步交接保留统一回执:
- 事件名称、业务单号和来源阶段;
- `eventId`、版本、发生时间、投递时间、接收时间;
- 当前状态:待发送、发送中、已接收、已处理、失败待重试、永久失败需人工;
- 重试次数和最近失败的业务化原因;
- 下游生成的业务单号;
- 是否允许受控重试。
业务页面只展示业务化摘要;`requestId/traceId/eventId` 放在“诊断信息”中供技术支持复制。
### 5.5 一致性规则
1. 写操作携带 `expectedVersion` 和幂等键;版本冲突必须刷新,不能由前端自动覆盖。
2. 同一业务操作超时后先查幂等结果;不能自动换新键重复提交。
3. 跨服务写入使用本地事务 + Outbox/Inbox,不做国内、海外数据库分布式双写。
4. 聚合页只做读模型,不成为新的权威数据源。
5. 计划、门禁和转换必须基于完整柜级/任务级查询,不能用当前分页记录判断整体完成。
6. 自动动作与人工动作使用同一服务命令,但历史记录明确 `SYSTEM/USER/THIRD_PARTY` 来源。
7. 下游快照只在规定时点冻结;上游主数据之后变化不静默覆盖历史单据。
## 6. 共性交互规范
### 6.1 状态展示
- 主标题用中文业务状态,稳定编码放在悬浮说明或诊断区。
- 每页最多突出一个主状态;审核、费用、库存和子单状态分组展示。
- “等待系统处理”和“等待人工处理”使用不同颜色和图标。
- 终态不继续显示无效主按钮。
### 6.2 按钮与门禁
- 主按钮文案使用业务结果,例如“确认离仓”“转换整柜入仓计划”“完成整柜入仓”“转派送并生成出库单”。
- 禁用按钮必须能查看原因;有修复入口时直接跳转。
- 危险或不可逆操作在确认框中展示影响范围、将生成/冻结的单据和是否可撤销。
- 批量操作先预览,提交后逐项回执。
### 6.3 搜索与定位
- 所有跨阶段列表至少支持订单号、入仓码、柜次号和真实柜号中的适用项。
- 海外阶段继续支持计划号、托盘号、排车单号、出库单号和派送单号。
- 最近搜索和筛选在返回列表时保留。
- 扫码字段自动识别业务码类型;无法识别时提示允许的码,而不是当作普通关键词无结果。
### 6.4 错误反馈
错误消息统一包含:发生了什么、为什么、用户现在能做什么。示例:
```text
暂不能发车:平台尚未完成“转派送”,当前排车还没有待出库单。
请联系海外运营在排车详情完成转派送后刷新本页。
```
不要向业务用户直接展示内部数据库 ID、SQL 异常或只有 `A0409` 的提示。业务码、requestId 和 traceId 收在可复制的诊断详情中。
## 7. 优先级建议
### P0:先解决“找不到下一步”和“数据是否流转成功”
1. 在统一订单、柜次、尾程订单、排车详情增加当前阶段、下一动作、责任岗位、阻断原因。
2. 国内出仓、跨区建单、海外计划转换、转派送和发车增加标准操作回执。
3. 所有门禁项增加修复深链。
4. 增加跨区事件业务状态卡,覆盖国内装柜到海外尾程建单、海外卸柜到国内回写。
5. 海外 PDA 发车严格展示 `canDepart/departBlockReason`;错误改为现场可执行文案。
6. 转派送提交前预览出库单和派送单,成功后直接展示业务单号。
7. 统一关联单据侧栏和业务编号全链搜索。
P0 不改变核心状态机,主要是聚合查询、反馈和导航,风险相对较低。
### P1:减少重复输入和跨页面操作
1. 客户下单四步向导、自动保存草稿和集中校验。
2. 审核、测量、排柜、海外计划的批量预览与逐项回执统一。
3. 柜级工作台整合订舱、真实柜号、预装、PDA、报关和出仓准备度。
4. 海外收货完成后直接提供“查看库存/创建排车”动作。
5. 排车编辑从车辆主数据带出司机、电话和车队;转派送复用排车快照。
6. PDA 扫描反馈增加实际归属、错误对象和恢复动作。
7. 费用确认嵌入订单进度,不要求客户跨菜单寻找。
### P2:用数据持续优化效率
1. 建立阶段耗时、等待耗时、返工率和阻断原因看板。
2. 根据历史到仓和处理耗时做 SLA 预警,但不自动改业务事实。
3. 智能推荐下一批可审核、可排柜、可计划、可排车记录。
4. 对重复出现的扫码错误、计划资源失败和跨区失败生成问题趋势。
5. 在保持服务端门禁的前提下,优化默认值和批量操作策略。
## 8. P0 交付拆分
| 交付包 | 前端范围 | 后端范围 | 验收重点 |
|---|---|---|---|
| A. 统一进度与下一动作 | Client 订单详情、Admin 统一订单详情 | Order/Firstleg 聚合返回阶段、动作和阻断 | 同一订单两端阶段一致,权限不同但事实一致 |
| B. 柜级离仓准备度 | Admin 货柜详情、国内出仓 | Firstleg 复用现有柜级 gate/overview | 7 个门禁实时、可深链、自动/人工结果一致 |
| C. 跨区回执 | Admin 柜次和尾程柜级工作台 | Firstleg Outbox、Event Bridge、Lastleg Inbox 只读聚合 | 能区分待发送、已接收、处理失败和成功建单 |
| D. 海外计划转换体验 | Admin 尾程订单 | Lastleg 预览/转换响应补充业务化 blockers 和结果 | 不重复建计划,资源异常可定向重试 |
| E. 排车到出库回执 | Admin 排车、出库、派送详情 | Lastleg 返回出库/派送拆分结果及下一门禁 | 转派送和发车后单号、状态、数量立即可核对 |
| F. PDA 错误可执行化 | 国内/海外 PDA | Firstleg/Lastleg 错误响应补充稳定原因码和建议动作 | 扫错箱/托盘、版本冲突、未转派送均能现场恢复 |
每个交付包独立上线,但必须复用同一状态和动作编码,不能各页面自行定义一套中文判断。
## 9. 验收指标
### 9.1 效率指标
- 新员工在不查技术文档的情况下完成一票主链关键操作的成功率。
- 每票从提交到完成审核、测量、排柜、海外计划、排车的人工点击数。
- 订单号/入仓码在不同页面重复复制粘贴次数。
- 从看到阻断到进入正确修复页面的平均时间。
- 批量操作失败后定位具体失败记录的时间。
### 9.2 质量指标
- 重复提交产生重复单据数必须为 0。
- 跨区事件超过 SLA 未收敛但页面仍显示成功的次数必须为 0。
- 页面自行推导动作与服务端 `allowedActions` 不一致的次数必须为 0。
- 因重复录入导致的仓库、目的仓、车辆、箱数不一致数量。
- PDA 错扫后需要后台或技术人员介入才能恢复的比例。
### 9.3 建议目标值
首轮上线前先采集两周基线,再确认具体下降比例。若当前没有埋点,P0 同步补充:页面进入、主按钮点击、阻断码、操作结果、耗时、回退/重试、跨区收敛时间。埋点只记录业务编码和阶段,不记录密钥、Token 或客户敏感明细。
## 10. 关键验收场景
1. 客户提交订单后立即看到入仓码、待审核和下一责任人。
2. 审核员打开待审核列表,无需进入详情即可识别可批量通过和需补资料订单。
3. 测量审核完成后,页面明确显示已进入待分柜且费用可继续独立处理。
4. 柜次缺报关时,“确认离仓”不可执行,并能一键进入报关区域。
5. 国内离仓后,页面可见跨区事件进度;海外建单后自动出现尾程订单号。
6. 海外计划转换前清楚看到头程已有托盘和海外补充托盘,重复提交不重复建资源。
7. 海外 PDA 扫错柜外箱唛时,提示实际不属于当前柜且库存零变化。
8. 海外入仓完成后,操作员可直接查看库存或进入排车,不需要重新搜索。
9. 未转派送时 PDA 明确提示平台应做的动作;转派送完成后刷新即可发车。
10. 发车成功回执同时展示排车 `DEPARTED`、出库 `OUTBOUNDED` 和各派送单号。
11. 任一页面用入仓码搜索,都能进入关联单据侧栏查看上游和下游业务号。
12. 版本冲突、网络超时和幂等重放均不会产生重复订单、计划、托盘、出库或派送单。
## 11. 不建议采用的做法
- 不新建独立“全流程状态表”作为新的可写权威;使用现有权威状态聚合展示。
- 不让前端通过状态字符串组合推导写资格;服务端返回下一动作和门禁。
- 不为了页面即时显示而跨区同步写海外数据库。
- 不把所有业务状态压缩成一个字段,继续区分主流程、审核、费用、库存和子单。
- 不用内部数据库 ID 作为客户和仓库人员的定位方式。
- 不通过自动刷新和无限重试掩盖事件失败;失败必须可见并受控重试。
- 不在多个页面重复维护仓库、车辆、目的仓和客户名称,统一从权威主数据选择并冻结快照。
## 12. 推荐落地顺序
1. 先冻结统一阶段、动作、阻断和责任岗位字典。
2. 再补服务端聚合字段与跨区回执,不先让各前端自行拼装。
3. Admin 先落统一订单、柜次、尾程计划、排车四个核心详情页。
4. Client 接入只读进度、下一动作和费用确认。
5. 国内/海外 PDA 接入稳定错误原因、恢复动作和操作回执。
6. 完成 P0 人工验收和埋点基线后,再做表单向导、自动带出和批量体验。
7. 最后基于真实操作数据决定 P2 的智能推荐和 SLA 预警,不凭主观增加自动化。
@@ -0,0 +1,581 @@
# 头程下单到尾程出库现状流程全景清单
> 文档性质:现状流程盘点(AS-IS),先描述现在业务怎么走,不在本文讨论页面改版或技术优化。
> 覆盖起点:企业具备下单资格。
> 主链终点:海外仓完成尾程出库;为保证交接完整,附带记录派送、异常退回和国内最终完成回写。
> 使用方法:先用第 2 节确认流程是否覆盖,再按第 3~8 节核对操作、状态、单据和数据流。优化讨论统一以后续评审确认的本清单为输入。
## 1. 范围和口径
### 1.1 本文覆盖
- 企业资料、账号权限、下单资格和资金提交门禁;
- 单票下单、批量导入、编辑、提交、取消和平台审核;
- 国内收货、箱码、测量、称重、测量审核、费用与扣货;
- 普通散货和一件代发国内组托分支;
- 模拟柜、智能排柜、订舱、订舱级拖车、真实柜号、预装、国内 PDA、装柜证据;
- 整柜出口报关、国内出仓、船节点、目的港清关提柜和还空;
- 跨区尾程建单、海外入仓计划、海外 PDA 接柜和三种收货形态;
- 海外库存、暂扣、库内作业及会影响出库的服务分支;
- 智能排车、转派送、PDA 拣货、发车和普通/一件代发/换标/转运/退货出库;
- 派送完成、异常退回和国内订单最终回写。
### 1.2 三类时间和三类仓库必须分开
| 容易混淆项 | 正确含义 |
|---|---|
| 预计开船/预计到港 | 订舱和船运节点事实,不是预计到海外仓时间 |
| 预计到仓 | 整柜预计到实际海外接柜仓的时间,入仓计划生成后锁定 |
| 实际到仓 | 海外 PDA 首次开始收货的时间,不是码头提柜时间 |
| 国内来源仓 | 国内收货、测量、拣货和装柜的仓库 |
| 实际海外接柜仓 | 海外 PDA 作业、库存、托盘和库位所在物理仓 |
| FBA/私人目的仓 | 货物最终送达地址,不是实际接柜仓 |
### 1.3 三种“完成”必须分开
1. `LOADED/装柜完成`:国内仓已经把货装进出口柜。
2. 国内出仓完成:整柜已经确认离开国内仓,开始船节点跟踪。
3. `OUTBOUNDED/海外出库完成`:货已经从海外仓出库;后续可能仍在派送中。
## 2. 全量流程目录与覆盖矩阵
### 2.1 订单路径分叉
| 路径 | 国内测量后 | 国内装柜 | 海外实收 | 海外出库 |
|---|---|---|---|---|
| 普通 FBA 散货 | 进入 `WAIT_SPLIT` | 按箱码拣货;按真实柜号+FBA仓生成散货托盘码 | 按入仓码/SKU/托盘输入本次实收箱数 | 通常进入智能排车,转派送后 PDA 发车 |
| OVERSEAS 海外仓货物 | 进入 `WAIT_SPLIT` | 按箱码拣货并冻结最终实装箱唛 | 按最终箱唛逐箱扫码入托 | 可直接一件代发,或先换标、转运、废弃等库内服务再出库 |
| 一件代发 | 先进入 `WAIT_PALLETIZE`,国内逐箱清点、组托和封托后再 `WAIT_SPLIT` | 国内封托托盘作为不可拆整托进入柜次 | 沿用国内封托来源托盘整托收货 | 从可用 SKU/箱唛库存创建一件代发出库,PDA 逐箱或整托完成 |
| 私人目的仓 | 进入 `WAIT_SPLIT` | 按普通货物完成国内装柜 | 海外入仓计划按冻结私人地址分组 | 排车转派送时按私人地址拆分派送单 |
### 2.2 交货方式分叉
| 客户选择 | 下单必填 | 国内到仓前的数据流 |
|---|---|---|
| 快递自送 `SELF_EXPRESS` | 国内收货仓、快递/跟踪信息按当前页面要求 | 客户自行寄送,系统保留履约快递快照;到仓后进入统一收货测量 |
| 平台上门提货 `PICKUP` | 本企业提货地址、联系人和预约提货时间 | 订单冻结提货地址与时间;当前主链以订单预约事实进入统一订单,不把它混同订舱级拖车 |
| 卡车自送 `SELF_TRUCK` | 国内收货仓和客户自送信息 | 客户自行送仓;到仓后进入统一收货测量 |
交货方式只影响货物如何到达国内收货仓;海运订舱级拖车、目的港码头拖车和海外配送排车属于后续三个独立流程。
### 2.3 后置服务分叉
海外入仓完成后并非所有货都直接排车:
```text
INBOUND_COMPLETED
├─ 无库内服务 → 智能排车/独立出库
├─ 暂扣 → 解除前禁止排车和出库
├─ 换标 → PDA完工 → 仓库复核 → 客户确认 → 排车或直接出库
├─ 仓储转运 → 平台审核 → 转运出库
├─ 货物废弃 → 审核确认时实际扣减库存,不再出库
└─ 退货库存 → 合箱/换标后进入退货出库
```
| 编号 | 流程 | 主要操作者/终端 | 入口状态或触发 | 出口状态或结果 | 是否主链 |
|---|---|---|---|---|---|
| F01 | 企业资料与下单资格 | 客户 Client、平台 Admin | 企业已注册 | 企业资料审核通过 | 前置 |
| F02 | 基础资料准备 | 平台 Admin | 仓库/渠道/价格等待维护 | 可用于下单和履约 | 前置 |
| F03 | 客户单票下单 | 客户 Client | 有创建权限 | `DRAFT` | 是 |
| F04 | 客户批量导入 | 客户 Client | 下载当前模板 | 多张 `DRAFT` | 分支 |
| F05 | 订单提交与入仓码 | 客户 Client | 草稿完整 | `SUBMITTED/PENDING_REVIEW` + 入仓码 | 是 |
| F06 | 草稿编辑与取消 | 客户 Client | 草稿或允许取消状态 | 更新草稿或 `CANCELLED` | 分支 |
| F07 | 平台审核与申报修订 | 平台 Admin | 待审核 | `APPROVED` + 正式国内入库单 | 是 |
| F08 | 国内到仓与箱码 | 国内仓 Admin/设备 | 正式入库单 | 可测量箱码/箱唛事实 | 是 |
| F09 | 尺寸测量与称重 | 国内仓 Admin/设备 | `WAIT_MEASURE` | 测量批次待审核 | 是 |
| F10 | 测量审核与初始计价 | 审核员 Admin | 测量完整 | 普通 `WAIT_SPLIT`;一件代发 `WAIT_PALLETIZE` | 是 |
| F11 | 费用发布与客户确认 | 平台 Admin、客户 Client | 已有计费快照 | `SENT`、客户确认快照 | 并行 |
| F12 | 国内入库扣货/解除 | 平台 Admin | 尚未发生不可逆物理履约 | HOLD/RELEASE 历史 | 异常 |
| F13 | 一件代发国内清点组托 | 国内 PDA、Admin | `WAIT_PALLETIZE` | 封托并进入 `WAIT_SPLIT` | 条件主链 |
| F14 | 模拟柜与智能排柜 | 平台 Admin | 候选可分配 | 柜次+预装计划+分配明细 | 是 |
| F15 | 海运订舱与放舱 | 平台 Admin | 订舱草稿 | `RELEASED` | 是 |
| F16 | 国内订舱级拖车 | 平台 Admin | 有效放舱和提单资料 | 拖车单,离仓后自动完成 | 并行 |
| F17 | 柜次关联与真实柜号 | 平台 Admin | 模拟柜+放舱需求 | 订舱分配关系+真实柜号 | 是 |
| F18 | 预装确认及动态增减 | 平台 Admin | 成员满足门禁 | 预装确认/PDA 任务 | 是 |
| F19 | 国内 PDA 拣货装柜 | 国内 PDA | 可领取任务 | 全部货物核平 | 是 |
| F20 | 国内装柜证据与铅封 | 国内 PDA/Admin | 装柜作业中 | 四步照片+SEAL | 是 |
| F21 | 装柜完成和分片 | 国内 PDA/系统 | 货物、证据、柜号齐全 | 计划 `COMPLETED`、入库单 `LOADED` | 是 |
| F22 | 客户退税资料和整柜报关 | 客户 Client、平台 Admin | 装柜完成 | 整柜报关 `COMPLETED` | 是 |
| F23 | 国内出仓 | 系统/平台 Admin | 整柜门禁通过 | 离仓历史+船节点任务 | 是 |
| F24 | 海运十节点 | 第三方/船务 Admin | 国内出仓 | `PICKUP_READY` 至 `EMPTY_RETURNED` | 是 |
| F25 | 目的港清关、车队、到仓和还空 | 平台 Admin | 海外段柜次可操作 | 清关/提柜/到仓/还空事实 | 并行主链 |
| F26 | 跨区尾程建单 | 系统 | 国内最终实装事件 | `PENDING_ASSIGNMENT` 尾程订单 | 是 |
| F27 | 尾程计划维护/整柜转换 | 海外运营 Admin | 待分配尾程订单 | `INBOUND_PLANNED`+逐票计划+资源 | 是 |
| F28 | 海外 PDA 柜号签收 | 海外 PDA | `PICKUP_READY`+资源齐全 | 柜级收货会话 | 是 |
| F29 | 海外 PDA 三类实收 | 海外 PDA | 已签收 | 各入仓码实收核平 | 是 |
| F30 | 海外整柜入仓与国内回写 | 海外 PDA/系统 | 全柜待收数为 0 | `INBOUND_COMPLETED`+库存+卸柜回写 | 是 |
| F31 | 海外库存、盘点、移库和托盘 | 海外 PDA/Admin | 已有库存 | 库存余额/位置/流水变化 | 支撑 |
| F32 | 海外暂扣与解除 | 平台 Admin | 已完成入仓 | 阻断/恢复排车出库资格 | 异常 |
| F33 | 换标、转运、废弃 | 客户/平台/PDA | 可用库存 | 新库存/待出库资格/废弃流水 | 分支 |
| F34 | 智能预排与排车维护 | 海外运营 Admin | 可用托盘/计划 | `PENDING_DEPARTURE` 排车单 | 是 |
| F35 | 排车转派送 | 海外运营 Admin | 未发车排车 | `WAIT_OUTBOUND` 出库单+派送单 | 是 |
| F36 | 排车 PDA 拣货与发车 | 海外 PDA | 排车任务 | `READY_TO_DEPART → DEPARTED` | 是 |
| F37 | 独立出库任务 | 平台 Admin、海外 PDA | 一件代发/换标/转运/退货库存 | `OUTBOUNDED` | 分支 |
| F38 | 派送和签收 | 平台 Admin、客户 Client | `WAIT_DELIVERY` | `DELIVERED` | 主链后续 |
| F39 | 派送异常退回 | 平台 Admin、海外仓 | 拒签/退回 | `RETURNED`+恢复库存 | 异常 |
| F40 | 客户回访和最终国内回写 | 平台 Admin、系统 | 派送完成或异常处理 | 国内入库单/订单最终完成 | 收口 |
## 3. 客户、订单与国内入库流程
### F01 企业资料与下单资格
1. 企业注册成功后可以登录,不因注册审核阻断登录。
2. 客户在公司信息维护企业基本资料、联系方式和资质文件。
3. 平台在客户管理审核企业资料;只有审核通过的企业可提交订单。
4. 企业管理员给成员分配头程订单查看、创建、提交等权限。
5. 提交订单时还会校验 Finance 资金账户;实际余额小于 0 时拒绝提交。
数据结果:IAM/Auth 保存企业、成员、角色和资格;Order 只消费认证客户身份和资格结果,不接受页面传入客户 ID 作为授权。
### F02 基础资料准备
平台应先维护:国内收货仓、FBA/海外目的仓、物流渠道、客户价格、尺寸规则、供应商、订舱代理、车辆及海外实际收货仓。停用主数据不能用于新单,但历史订单继续显示冻结快照。
### F03 客户单票下单
入口:Client → 头程订单 → 新建。
操作顺序:
1. 选择运输模式、业务类型、国内收货仓、渠道和税务/计价方式。
2. 选择交货方式:快递自送、平台上门提货或卡车自送。
3. 平台上门提货时选择/保存本企业提货地址并填写预约提货时间。
4. 选择目的类型和目的仓;私人仓填写本企业私人目的地址。
5. 填写货物明细、SKU、品名、箱数、件数、重量、体积及申报资料。
6. 保存草稿。
输出:`DRAFT`。草稿可以编辑,不生成入仓码,也不进入国内物理履约。
### F04 客户批量导入
1. 下载当前客户动态 XLSX 模板。
2. 使用模板下拉填写有效仓库、渠道、交货方式和目的仓。
3. 单次最多 50 行,上传后服务端逐行校验。
4. 支持部分成功或整批原子模式;成功行只创建草稿。
5. 客户逐票检查草稿后另行提交。
批量导入不等于批量提交审核,也不能绕过企业、资金和主数据门禁。
### F05 订单提交与入仓码
1. 客户点击“提交订单”。
2. 服务端重新校验企业资格、资金账户、仓库、渠道、目的仓、地址和货物。
3. 首次成功提交生成全局唯一且固定的 `inboundCode`。
4. Order 进入 `SUBMITTED`;统一订单主状态固定显示 `PENDING_REVIEW`。
5. 提交、入仓码和时间线写入同一业务结果;重复提交不得生成第二个入仓码。
### F06 草稿编辑与取消
- 草稿可编辑、提交或取消。
- 已提交后的可编辑字段和取消资格以服务端动作返回为准。
- 已进入物理履约的订单不能由客户自行取消。
- 平台修订申报不得修改客户、国内收货仓、订单号、入仓码,不得增删或重排货物行。
### F07 平台审核与正式入库单
入口:Admin → 统一订单 → 待审核。
1. 单票查看或一次调用批量审核预览。
2. 核对入仓码、客户、货物、渠道、目的仓和逐票阻断原因。
3. 必要时修订当前允许的客户申报字段并保留变更审计。
4. 审核通过。
5. Order 进入 `APPROVED`;Firstleg 幂等创建正式国内入库单,沿用原入仓码。
6. 对需要箱码的订单生成稳定箱码/箱唛和 SKU 快照。
待审核订单即使 Firstleg 已有准备投影,统一主状态仍是待审核,不能提前作为审核通过事实。
### F08 国内到仓与箱码
- 仓库通过入仓码或稳定箱码识别货物。
- 普通散货使用稳定箱码逐箱关联;一件代发箱码还冻结箱内 SKU。
- 客户是否下载/打印箱唛不决定箱码是否有效。
- 设备、人工测量和 PDA 必须复用同一箱码映射,不得临时造码。
### F09 尺寸测量与称重
1. 第三方设备按入仓码/箱码上传长宽高、箱数、设备和事件号;重复事件幂等。
2. 国内仓上传称重图片,系统执行 OCR。
3. 操作员保留 OCR 原值并填写修正值/原因。
4. 每张有效图片只能计重一次;全部称重图片确认且总重量大于 0。
5. 提交测量审核。
数据并行保存:客户申报值、设备原始值、人工修正值、计费采用值和证据文件,不互相覆盖。
### F10 测量审核与初始计价
1. 审核员检查箱数、尺寸、重量、证据和客户尺寸规则。
2. 审核通过后执行初始价格匹配和计价。
3. 普通订单主状态直接进入 `WAIT_SPLIT`。
4. 一件代发进入 `WAIT_PALLETIZE`,完成国内实际组托后再进入 `WAIT_SPLIT`。
5. 审核失败按原因修正后重新提交;异常放行必须同时满足配置、权限和原因。
### F11 费用发布与客户确认
1. 平台查看系统基准量、价格簿、计价模型和费用明细。
2. 允许范围内的人工调整必须带版本、原因和二次确认。
3. 审核、发布费用,客户在 Client 查看 `SENT` 的当前版本。
4. 客户带费用版本和幂等键确认。
5. 新版本会清空旧确认;只有客户已确认且本票装柜完成后费用最终冻结。
费用是并行状态,不阻断 `WAIT_SPLIT`,也不把订单主流程回退到计费状态。
### F12 国内扣货与解除
1. 平台在统一订单对正式入库单执行扣货,填写原因并上传有效凭证。
2. HOLD 阻断后续物理履约写入。
3. 解除扣货同样使用版本和幂等键,保留不可变历史。
4. 已发生拣货、装柜或出仓时禁止手工扣货/解除。
### F13 一件代发国内清点组托
1. 测量审核后按预计托数生成 `DP` 托盘码。
2. Admin 打印每托信息单。
3. 国内 PDA 可先扫箱或先扫托盘,逐箱清点完整 SKU 后归托。
4. 同仓多票可共用开放托盘;参与本票的开放托盘阻断本票完成。
5. 操作员逐托封托;未进入装柜计划前可按规则解封。
6. 全部箱和实际参与托盘核平后进入 `WAIT_SPLIT`。
## 4. 国内货柜履约流程
### F14 模拟柜与智能排柜
1. Admin 创建模拟柜,选择正式装柜仓、计划渠道组、柜型、容量和限重。
2. 系统生成稳定 `containerBookingId`、`cabinetNo` 和唯一预装计划。
3. 工作台查询 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 候选;状态只用于展示,资格取服务端 `canAssign`。
4. 待审核/待测量可提前占位,但确认预装和启动 PDA 前必须进入 `WAIT_SPLIT`。
5. 普通货按箱数分配;一件代发按封托托盘不可拆分分配;同柜组按完整组处理。
6. 服务端校验剩余箱数、容量、重量、渠道、组锁和版本。
### F15 海运订舱与放舱
1. Admin 新建订舱草稿,可先部分保存柜型需求。
2. 补齐提单号、船名、航次、ETD、ETA、运输条款、渠道和有效订舱资料。
3. 点击放舱后状态进入 `RELEASED`,冻结订舱运输快照。
4. 订舱只记录柜型需求,不在订舱阶段自动创建另一批货柜。
### F16 国内订舱级拖车
1. 从服务端候选选择 `RELEASED` 且资料完整的订舱。
2. 选择 `TRUCKING/DOMESTIC` 供应商,维护计划装柜时间和附件。
3. 每个订舱最多一张非终态拖车单;重复创建回到既有单。
4. 拖车不绑定客户、柜次、装柜计划或真实柜号。
5. PDA 装柜完成不推进拖车;订舱全部要求货柜确认离开国内仓后,拖车自动完成并生成按实装体积分摊的应付费用。
### F17 柜次关联与真实柜号
1. 港口取得真实柜号后,选择模拟柜和已放舱订舱的未满配柜型需求。
2. 服务端校验仓库、渠道、柜型、容量、限重、客户范围和柜号唯一性。
3. 在原模拟柜上补订舱、需求和真实柜号;稳定柜次 ID、计划和明细不变。
4. 正式操作统一在预装确认和 PDA 启动前补齐真实柜号;最终装柜完成必须有柜号。
### F18 预装确认及动态增减
1. Admin 打开预装详情核对入仓码、箱数、托盘和备注。
2. 计划级确认整份预装,生成或开放唯一 PDA 任务。
3. PDA 最终完成前,`EDITING/LOADING` 计划可继续新增满足条件的明细。
4. 未拣明细可调整或移除;已拣数量形成最小值,不能删除或减到已拣以下。
5. 新增明细会递增计划/任务版本;PDA 旧版本必须刷新。
### F19 国内 PDA 拣货装柜
普通散货:扫描稳定箱码,一箱一码,批量提交;不能用 SKU、件数或入仓码替代箱码。
一件代发:扫描托盘码或托盘成员箱码定位整托,以 `PICK_PALLET` 一次提交整托;不能部分拣货。
共同流程:领取任务 → 扫描/确认 → 批量提交 → 未完成项处理异常 → 全部核平 → 最终完成。
### F20 装柜证据与铅封
需要四步现场照片 `PHOTO_STEP_1..4` 和一条带铅封号的 `SEAL`。同类可追加;错误照片作废而非硬删除。PDA 现场拍摄,Admin 可按权限补充上传。
### F21 装柜完成和分片
1. 服务端重新校验真实柜号、订舱、计划、PDA 明细和五类证据。
2. 全部通过后计划进入 `COMPLETED`,来源入库单进入 `LOADED`。
3. 部分装载时数量守恒:已装数量形成子入库单,余量保留后续子单;只允许一层分片。
4. 生成最终实装清单、托盘/SKU/箱唛快照和跨区 Outbox。
### F22 客户资料与整柜出口报关
1. 客户按订单上传本客户退税/报关资料。
2. 平台按整柜汇总多个入仓码资料,开始报关后冻结当前资料。
3. 平台线下完成报关后逐资料上传完成文件并完成整柜报关。
4. 全买单柜由平台确认并通过导出中心获取明细。
5. 报关完成后系统立即尝试确认国内出仓;其他门禁未齐时由受控补偿重试。
### F23 国内出仓
当前硬门禁:未扣货、所有入库单 `LOADED`、装柜计划完成、国内 PDA 完成、订舱 `RELEASED`、四步照片和铅封完整、整柜报关完成。
操作结果:
- 系统自动尝试;未自动收敛时平台在柜次/国内出仓页点击确认离仓。
- 整柜写入唯一离仓历史并创建 `CarrierScheduleQuery`。
- 产生第一条实际 `DEPARTED_WAREHOUSE`。
- 尝试完成订舱级拖车。
- 产生实际离仓节点后禁止撤销。
独立拖车状态和独立船期只做参考,不是当前出仓硬门禁。
## 5. 海运、目的港和跨区流程
### F24 海运十节点
实际节点依次为:
`DEPARTED_WAREHOUSE → AT_ORIGIN_PORT → LOADED_ON_VESSEL → DEPARTED_ORIGIN_PORT → IN_TRANSIT → ARRIVED_DESTINATION_PORT → DISCHARGED_FROM_VESSEL → PICKUP_READY → PICKED_UP → EMPTY_RETURNED`
- 第三方轨迹或人工补录只能单调推进实际节点。
- 预计事实不推进最新实际节点。
- `PICKUP_READY` 才开放海外 PDA 候选。
- 实际 `EMPTY_RETURNED` 后查询停止。
### F25 目的港清关、车队、到仓和还空
1. Admin 在“清关提柜”查看海外段柜次并维护目的港清关、DO 和 PU。
2. 选择海外车队后发送提柜请求邮件;换车队时保存旧车队取消和新车队提柜两封邮件。
3. 预计到仓由平台维护,并按 `sourceContainerBookingId` 同步整柜尾程订单;已有入仓计划后锁定。
4. 实际到仓只取海外 PDA 首次开始收货时间。
5. 海外卸柜完成后生成还空截止和预警;可按权限登记真实还柜时间。
6. 还柜时间不得早于卸柜完成或已有提柜时间;成功后同步 `EMPTY_RETURNED`。
目的港码头拖车与国内订舱级拖车、海外配送排车是三个独立生命周期。
### F26 跨区尾程建单
1. Firstleg 按最终实装发布版本化装柜完成事件。
2. Event Bridge 跨区域投递,Lastleg Inbox 幂等消费。
3. 每票生成尾程订单和冻结货物明细,状态 `PENDING_ASSIGNMENT`。
4. 建立 `FIRST_LEG` 在途库存快照,计划生成前以来源入仓码展示。
5. 事件只建订单,不自动建入仓计划。
6. 同一来源事件重放不能生成第二张尾程订单。
## 6. 海外入仓与库存流程
### F27 尾程计划维护和整柜转换
主入口:Admin → 海外仓 → 尾程订单 → 柜级工作台。
1. 按 `sourceContainerBookingId` 查看整柜全部订单,不使用当前分页拼柜。
2. 可逐票维护托数、托盘类型、分配、可合托、加急和备注;入仓码、接柜仓和预计到仓只读。
3. 单票柜保存计划后立即生成资源;多票柜在最后一票计划齐全时生成全柜资源。
4. 柜级转换前读取服务端预览:分组、头程已有托数、海外补充托数、最终托数、阻断项、快照版本/哈希。
5. 确认转换后服务端锁柜重算,逐票生成独立计划并批量确认托盘/库位资源。
6. 尾程订单进入 `INBOUND_PLANNED`;资源异常用 `resourcePendingPlanCount` 定向重试。
散货保留头程托盘码,不足部分连续补码;一件代发严格沿用国内封托托盘,不允许重算托数。
### F28 海外 PDA 柜号签收
前置:实际 `PICKUP_READY`、计划和资源齐全、操作员绑定正确收货仓。
1. PDA 在待卸货列表选择物理柜并锁定。
2. 核对同柜全部入仓码、目的仓、托数和箱数。
3. 查看国内五类装柜照片作为只读对照;单图加载失败不阻断。
4. 依次拍车牌 `LICENSE_PLATE`、封签 `CONTAINER_SEAL`、开柜现场 `DOOR_OPEN`。
5. 三类照片齐全后确认签收并进入收货。
柜级会话按物理柜聚合,可包含多客户;签收后每票 PDA 任务仍按真实客户隔离。
### F29 海外 PDA 三类实收
| 类型 | 操作方式 | 禁止事项 |
|---|---|---|
| FBA 散货 | 选入仓码,扫冻结 SKU,扫托盘,输入本次箱数 | 不能超收,不能走逐箱模式 |
| OVERSEAS | 扫最终实装箱唛自动定位入仓码,扫托盘,逐箱确认 | 不能批量输入箱数,已收箱不能重复收 |
| 一件代发 | 按国内封托来源托盘整托收货,并逐入仓码核平 | 不能拆解国内冻结托盘事实后重新猜配 |
“可合托”只提供现场选择;独立收货扫新托盘,合托扫同目的仓已使用托盘。它不是实收或整柜完成门禁。
### F30 海外整柜入仓与国内回写
1. 所有入仓码 `pendingPackages=0` 后完成整柜。
2. 同事务推进计划/任务和尾程主状态 `INBOUND_COMPLETED`。
3. 生成库存余额、库存位置、进销存流水和托盘组成。
4. 发布唯一 `OVERSEAS_CONTAINER_UNLOADED` 事件。
5. 国内写入卸柜完成、照片上传和预计报空时间;事件重放不重复写事实。
### F31 海外库存、盘点、移库和托盘
- Admin 查询库存余额、位置、流水、托盘和 CSV。
- 海外 PDA 可执行当前工作台已开放的库存/库位/托盘查询、同仓整托移库和托盘组拆换绑。
- 托盘是现场作业单位,不是库存客户归属单位;同一合托托盘可包含多票组成明细。
- 普通入仓只记录某票在托盘上的箱数,不代表每个箱唛都有精确托盘快照。
### F32 海外暂扣与解除
1. 只允许尾程订单完成入仓后新增暂扣。
2. 暂扣只作用于本票,阻断排车和出库,不影响同柜其他票。
3. 暂扣不回退入仓计划、入仓事实和库存历史。
4. 解除后恢复后续资格,保留版本化历史。
### F33 换标、转运和废弃
- 换标:客户从可用库存申请,PDA 扫箱/换标/照片,仓库复核后客户确认;客户确认前不能出库。
- 仓储转运:客户申请,平台在尾程订单审核通过/驳回;通过后进入转运出库。
- 货物废弃:创建时只冻结库存,仓库/PDA 审核确认时才实际扣减并写逐箱流水。
这些子单状态保存在 `sub_order_status`,不能覆盖尾程主流程 `INBOUND_COMPLETED`。
## 7. 海外排车和出库流程
### F34 智能预排与排车维护
1. Admin 从真实入仓计划、已收托盘或已具备资格的换标库存加载候选。
2. 托盘加入草稿立即加草稿锁;移除或关闭草稿时释放。
3. 选择车型,核对托位、箱数、重量和目的仓。
4. 点击预排,草稿锁原子转换为正式排车锁并生成排车单。
5. 编辑车牌、司机、电话、车队、预计发车/到达、FBA 预约和车辆预约。
6. 路线由已排托盘目的仓自动汇总,不能人工填写。
7. 未执行排车可取消并释放锁;普通编辑不得修改状态。
### F35 排车转派送
可在 `PENDING_DEPARTURE/PICKING/READY_TO_DEPART` 操作,但必须早于发车。
1. 填写预计派送时间、方式、提货/装车类型、车队/快递、车辆、预约和费用。
2. 服务端校验所有排车托盘已经真实入仓。
3. 一张排车单生成一张车辆级 `WAIT_OUTBOUND` 出库单。
4. 按目的 FBA 仓或私人地址生成一张或多张 `WAIT_DELIVERY` 派送单。
5. 相同排车重复转派送返回既有单据,不重复生成。
### F36 排车 PDA 拣货与发车
1. 海外 PDA 打开本仓排车任务。
2. 扫描托盘并校验排车、仓库、库存位置和计划箱数。
3. 成功扫描时执行库存分配、拣货和出库扣减;重复请求幂等。
4. 全部托盘/箱数核平后 PDA 任务 `COMPLETED`、排车 `READY_TO_DEPART`。
5. 发车门禁还要求车辆完整、已转派送且出库单仍是 `WAIT_OUTBOUND`。
6. 发车后排车 `DEPARTED`、出库单 `OUTBOUNDED`,释放托盘锁。
库存扣减发生在成功 PDA 扫码事务;发车只推进排车和出库事实,不能再次扣库存。
### F37 独立出库任务
| 出库类型 | 创建前置 | PDA 操作 | 完成结果 |
|---|---|---|---|
| 一件代发 | 已完成入仓、有可用箱唛/SKU/库存位置;一单唯一客户和仓 | 逐箱或扫 DP 托盘整托拣取 | `WAIT_PICK → PICKING/PICKED → OUTBOUNDED` |
| 换标出库 | 仓库复核且客户确认 | 按换标后箱唛/托盘拣货 | 换标单完成、出库单 `OUTBOUNDED` |
| 仓储转运 | 申请审核通过 | 按冻结来源托盘/箱数拣货 | 转运单 `OUTBOUNDED` |
| 退货出库 | 退货箱/SKU 可用库存 | 按冻结退货库存拣货 | 退货库存扣减、出库单完成 |
出库单只有未开始的 `WAIT_PICK/WAIT_OUTBOUND` 可取消;作业开始后不能通过取消恢复库存。
## 8. 派送、异常和最终回写
### F38 派送和签收
派送单状态:`WAIT_DELIVERY → DELIVERING → DELIVERED`;也可能进入 `REJECTED/CANCELLED`。
1. Admin/Client 查看按目的仓拆分的派送单。
2. 平台开始派送或维护允许的状态。
3. 上传并保存签收资料后系统自动完成派送;不提供脱离签收资料的人工“完成派送”按钮。
4. 派送完成发布跨区完成事件。
### F39 派送异常退回
1. 平台从派送创建异常单,状态 `PENDING → PROCESSING → COMPLETED`。
2. 退回明细只能从原出库拣货事实选择,不能自由填写 SKU/数量。
3. 退回海外仓时选择服务端校验的真实托盘和库位并实收。
4. 库存恢复、异常完成和派送 `RETURNED` 同事务完成。
5. 完成异常必须有 `OVERSEAS_EXCEPTION_EVIDENCE` 证据。
### F40 客户回访和国内最终回写
- 平台在客户回访记录结果和附件,作为派送后的业务闭环资料。
- 海外派送完成事件回到国内后,Firstleg 按来源订单、入仓码和目的仓校验。
- 对应国内入库单从 `LOADED` 推进到 `DELIVERED`。
- 同一 Order 的全部有效入库单都完成后,Order 才进入 `COMPLETED`。
- 任一事件重放不得重复推进、重复写历史或重复费用。
## 9. 单据、编号和数据交接关系
```text
客户 orderNo
└─ inboundCode(提交时生成)
└─ 国内 inboundId / 箱码 / 测量批次 / 费用版本
└─ containerBookingId + cabinetNo + containerNo
└─ loadingPlanId / 国内 PDA task / 装柜证据
└─ 跨区 eventId
└─ 尾程订单 + planNo
└─ 海外实物 palletNo / 库位 / 库存流水
└─ dispatchNo
└─ outboundNo
└─ deliveryNo
```
业务人员使用订单号、入仓码、柜次号、真实柜号、计划号、托盘号、排车单号、出库单号和派送单号定位。内部数据库 ID 只用于接口关联和诊断,不作为用户可见编号回退。
## 10. 状态维度关系
| 状态维度 | 归属 | 例子 | 不能做的事 |
|---|---|---|---|
| 订单交易状态 | Order | `DRAFT/SUBMITTED/APPROVED/CANCELLED/COMPLETED` | 不能用海外子单状态覆盖 |
| 国内履约主状态 | Firstleg | `WAIT_MEASURE/WAIT_PALLETIZE/WAIT_SPLIT/LOADING/LOADED/DELIVERED` | 费用变化不能回退主状态 |
| 测量审核 | Firstleg | `PENDING/APPROVED/REJECTED` | 不能代替订单审核 |
| 费用状态 | Firstleg/Finance | 试算、审核、发布、客户确认 | 不能表示货物已入库或出库 |
| 柜次/装柜计划 | Firstleg | `EDITING/LOADING/COMPLETED` | 不能替代单票入库状态 |
| 尾程主流程 | Lastleg | `PENDING_ASSIGNMENT/INBOUND_PLANNED/INBOUND_COMPLETED` | 不用出库/派送子状态覆盖 |
| 排车 | Lastleg | `PENDING_DEPARTURE/PICKING/READY_TO_DEPART/DEPARTED` | 不等于出库或派送状态 |
| 出库 | Lastleg | `WAIT_PICK/PICKING/PICKED/WAIT_OUTBOUND/OUTBOUNDED` | 不等于派送签收 |
| 派送 | Lastleg | `WAIT_DELIVERY/DELIVERING/DELIVERED/REJECTED/RETURNED` | 不反向改写库存,退回走专用事务 |
## 11. 人工动作、系统动作和异步动作
| 类型 | 代表动作 | 判断完成的证据 |
|---|---|---|
| 人工动作 | 提交订单、审核、测量确认、预装、PDA 扫码、报关、转派送 | 服务端返回新版本/状态和业务历史,不以按钮消失为准 |
| 系统同步动作 | 生成入仓码、审核建国内入库单、计划转换、库存扣减 | 同一事务内的单据和状态结果 |
| 系统异步动作 | 跨区尾程建单、海外卸柜回写、派送完成回写、费用入池 | Outbox/Inbox 消费结果和下游业务单据 |
| 第三方动作 | 测量设备、船司轨迹、邮件发送 | 带来源、事件号、观测时间和处理结果的不可变记录 |
异步页面暂未出现数据时,先检查事件状态并等待受控重试,不能手工造下游数据。
## 12. 当前明确的流程边界和冲突口径
1. 测量审核通过后主流程进入 `WAIT_SPLIT`;费用独立处理,不再以“发送费用”作为唯一入库成功节点。
2. 装柜完成跨区事件只生成待分配尾程订单,不自动生成入仓计划。
3. 国内出仓当前硬门禁不含订舱级拖车状态和独立船期;旧验收稿中的相关硬门禁已过期。
4. 真实柜号允许后补的历史指引存在差异;正式操作统一在预装确认/PDA 启动前补齐,服务端最终装柜完成必须有柜号。
5. 海外尾程主状态与换标、转运、排车、出库、派送子状态分开保存。
6. 目的港提柜时间、预计到仓和实际到仓不能相互替代。
7. 国内出仓、海外入仓、海外出库和派送完成是四个不同业务节点。
## 13. 文档评审清单
业务、产品、运营、仓库和技术应逐项确认:
- [ ] F01~F40 是否有实际业务流程遗漏
- [ ] 每一步实际操作者和终端是否正确
- [ ] 各流程入口状态、完成状态和下一责任岗位是否正确
- [ ] 三种交货方式和三种海外实收方式是否与现场一致
- [ ] 一件代发整托规则是否与国内、海外现场一致
- [ ] 国内订舱级拖车、目的港码头拖车、海外配送排车是否完全区分
- [ ] 报关、清关、DO、PU、提柜、预计/实际到仓和还空时序是否正确
- [ ] 跨区只建尾程订单、人工计划转换的口径是否确认
- [ ] 暂扣、换标、转运、废弃、退货和异常退回对出库资格的影响是否完整
- [ ] 海外扫码即扣库存、发车不重复扣减的口径是否与仓库账实一致
- [ ] 派送完成后国内 `DELIVERED/COMPLETED` 回写规则是否符合业务认知
- [ ] 当前页面是否都存在对应人工入口;缺入口项需单独登记,不能在流程图中假装可操作
评审完成后,再以本清单逐项标注“保留、合并、自动化、简化、删除”,进入易用性和数据流转优化设计。
## 14. 事实来源
- `docs/业务快速索引.md`
- `docs/订单与客户/README.md`
- `docs/订单与客户/订单与客户头程订单闭环设计.md`
- `docs/入库测量与计费/README.md`
- `docs/入库测量与计费/入库测量与计费需求优化版-v2-测量计费入库主流程.md`
- `docs/国内货柜履约/README.md`
- `docs/国内货柜履约/货柜管理全链验收矩阵-2026-08-13.md`
- `docs/海外仓业务/README.md`
- `docs/海外仓业务/虫虫出海全流程人工验收手册.md`
- `baiyue-firstleg/order/AGENTS.md`
- `baiyue-firstleg/firstleg/AGENTS.md`
- `baiyue-firstleg/lastleg-service/AGENTS.md`
- `apps/admin-web/AGENTS.md`
- `apps/admin-web/src/modules/overseas-warehouse/AGENTS.md`
- `apps/client-web/AGENTS.md`
- `apps/pda-uniapp/AGENTS.md`
- `apps/pda-overseas-uniapp/AGENTS.md`
@@ -0,0 +1,296 @@
# 头程到尾程 AI 流程优化上下文包
> 用途:给 AI、产品、业务和技术提供同一份优化输入规范。本文不替代六阶段现状文档,也不直接改变状态机;它定义分析顺序、术语、证据、指标、输出格式和实施门禁,防止 AI 只根据局部页面提出脱离业务事实的建议。
## 1. 第二轮完整性审计结论
现有材料已经覆盖 F01~F40 的操作、状态、异常和交接,但若要让 AI 持续辅助优化,还需要补齐以下横向信息:
| 缺口 | 为什么重要 | 本文补充方式 |
|---|---|---|
| 文档读取优先级 | AI 容易拿历史规划覆盖当前实现 | 固定事实优先级和最小上下文 |
| 统一术语和对象关系 | “柜次、订舱、拖车、托盘”等容易混淆 | 建立术语与禁止混用表 |
| 流程变体矩阵 | 单一主链会漏掉一件代发、私人地址、异常退回 | 明确变体和分叉点 |
| 跨端 RACI | 仅写角色仍不清楚谁负责、谁批准、谁被通知 | 建立阶段级责任矩阵 |
| 输入输出合同 | AI 容易建议重复录入或跨服务改权威数据 | 固定六阶段交接合同 |
| 阻断原因分类 | “不能继续”若无分类,无法做任务卡和统计 | 定义 blocker 分类和恢复动作 |
| 指标口径与基线 | 没有分母、起止点就无法验证“更好用” | 建立指标字典和基线采集表 |
| 问题证据格式 | 主观反馈无法排序,也无法复现 | 统一问题观察记录 |
| 优化卡模板 | 建议容易停留在“做个工作台” | 强制写现状、方案、数据、验收和回滚 |
| AI 输出约束 | AI 可能新建平行状态机、引入兼容层或越权自动化 | 明确禁止项和完成定义 |
## 2. AI 读取顺序
每次只读取与目标阶段相关的最小材料,按以下顺序判断事实:
1. 当前对话中的业务确认;
2. 对应阶段文档;
3. [现状流程全景清单](头程下单到尾程出库现状流程全景清单.md);
4. 目标应用、服务最近的 `AGENTS.md`;
5. 当前 Controller、DTO、Service、页面和 OpenAPI;
6. 当前阶段设计/审核记录;
7. 原型;
8. 历史规划和旧验收记录。
优化分析的默认入口:
- 总览:[分阶段流程目录](头程到尾程分阶段流程/README.md)
- 操作:[全流程操作手册](头程下单到尾程出库全流程操作手册.md)
- 现状:[现状流程全景清单](头程下单到尾程出库现状流程全景清单.md)
- 方案:[易用性与数据流转优化方案](头程下单到尾程出库流程易用性与数据流转优化方案.md)
- 约束:本文
出现冲突时必须明确写出“来源 A、来源 B、采用哪一项及原因”,不能静默拼接两套口径。
## 3. 统一术语与禁止混用
| 术语 | 正确定义 | 不得混用为 |
|---|---|---|
| 订单号 `orderNo` | 客户交易订单业务号 | 入仓码、入库单号 |
| 入仓码 `inboundCode` | 从头程提交到海外尾程持续使用的稳定关联码 | 内部 `inboundId`、计划号 |
| 柜次号 `cabinetNo` | 平台模拟柜/柜次业务编号 | 真实集装箱号 |
| 真实柜号 `containerNo` | 船司/码头识别的物理集装箱号 | 模拟柜 ID |
| 海运订舱 | 船司运输、提单和柜型需求聚合 | 柜次、国内拖车 |
| 国内订舱级拖车 | 一个已放舱订舱的国内提柜/送柜任务 | 目的港码头拖车、海外配送排车 |
| 目的港码头拖车 | 海外清关后提柜、到仓和还空链路 | 海外配送排车 |
| 海外配送排车 | 海外库存出库后送往 FBA/私人地址的车辆任务 | 目的港提柜 |
| 头程普通托盘码 | 预装/装柜阶段按真实柜号与 FBA 仓生成 | 一件代发 `DP` 托盘 |
| 一件代发 `DP` 托盘 | 国内测量后逐箱 SKU 清点并封托的来源托盘 | 海外仓随意重打托盘 |
| 海外实物托盘 | Warehouse Overseas 管理的公共物理资源 | 库存客户归属 |
| `LOADED` | 国内实际装柜完成 | 国内实际离仓、海外入仓 |
| `DEPARTED_WAREHOUSE` | 柜子实际离开国内仓 | 船已开航 |
| `PICKUP_READY` | 目的港清关完成、可提柜 | 已提柜、已到海外仓 |
| `INBOUND_COMPLETED` | 海外实收和库存入账完成 | 已出库、已派送 |
| `OUTBOUNDED` | 对应出库单已完成出库 | 客户/目的仓已签收 |
| `DELIVERED` | 目的派送签收完成 | 仅发车或仅上传 BOL |
用户页面只展示业务号;内部 ID 只能出现在接口关联或可折叠诊断信息。
## 4. 必须覆盖的流程变体
AI 分析任何阶段时,至少检查下列适用变体,不能只优化最顺畅的一条 FBA 散货主链:
| 维度 | 变体 | 关键差异 |
|---|---|---|
| 下单方式 | 单票、批量导入 | 批量需预览、逐行错误和重试 |
| 国内交货 | 自送、快递、平台提货 | 平台提货是否有独立派车/回执仍需业务确认 |
| 目的类型 | FBA、OVERSEAS、PRIVATE | 目的仓、逐箱事实、私人地址和派送拆分不同 |
| 履约类型 | 普通、一件代发 | 一件代发国内先清点封托,海外必须沿用来源托盘 |
| 装柜 | 整票、部分装载、一票多柜、多票拼柜 | 分片、数量守恒和 Order 最终完成条件不同 |
| 海外收货 | FBA bulk、OVERSEAS carton、dropship pallet | 批量箱数、逐箱、整托三种操作不可混用 |
| 海外托盘 | 独立托盘、同目的仓合托 | 合托不改变客户和入仓单归属 |
| 后续服务 | 正常排车、换标、转运、废弃、退货出库 | 库存锁、资格和子状态不同 |
| 派送结果 | 正常签收、拒收/取消、异常退回 | 退回必须恢复原出库来源库存 |
| 系统结果 | 同步成功、异步处理中、可重试失败、永久失败 | 页面回执和人工动作不同 |
## 5. 六阶段 RACI
缩写:R=实际执行,A=最终负责/批准,C=协作或复核,I=需要知会。
| 阶段 | 企业客户 | 平台订单/运营 | 国内仓/PDA | 海外运营 | 海外仓/PDA | 财务/客服 | 系统 |
|---|---|---|---|---|---|---|---|
| 1 下单审核 | R | A/R | I | I | I | C | C |
| 2 测量计费组托 | C | A/C | R | I | I | C | R |
| 3 国内装柜出仓 | C | A/R | R | I | I | C | R |
| 4 海运与尾程计划 | I | R/C | I | A/R | I | I | R |
| 5 海外入仓与库内 | C | I | I | A/C | R | C | R |
| 6 排车出库派送 | I/C | I | I | A/R | R | R/C | R |
RACI 是评审起点,不是权限契约。具体按钮仍由服务端权限码、身份范围和当前状态决定。优化建议若改变 A/R,必须列为业务决策,不能由页面设计默认改变职责。
## 6. 阶段输入输出合同
| 阶段 | 最小输入 | 成功输出 | 下一阶段硬前置 |
|---|---|---|---|
| 1 | 客户身份、仓库/渠道/目的地、申报 | `APPROVED`、固定入仓码、正式国内入库单 | `WAIT_MEASURE` |
| 2 | 正式入库单、稳定箱码、实到货物 | 审核实测;普通/组托完成后 `WAIT_SPLIT` | 无 HOLD、数量证据完整 |
| 3 | `WAIT_SPLIT` 箱/整托、柜型与运输资料 | `LOADED`、报关放行、实际国内离仓 | `DEPARTED_WAREHOUSE` 和最终实装快照 |
| 4 | 真实柜、订舱、离仓、装柜完成事件 | 尾程订单、`FIRST_LEG` 在途、入仓计划和资源 | `INBOUND_PLANNED`、`PICKUP_READY` |
| 5 | 可收柜、每票计划、真实资源 | `INBOUND_COMPLETED`、库存余额/位置/流水 | 可用库存且无对应阻断 |
| 6 | 可用库存、目的地、车辆/服务资料 | 签收、国内 `DELIVERED`、Order `COMPLETED` | 所有有效入库单均完成 |
任何优化都必须说明是否改变合同。只改页面布局时不得暗中改变状态推进、数据所有者或阶段完成判定。
## 7. 阻断原因分类
建议服务端统一返回:`blockerCode/category/message/ownerRole/recoveryAction/deepLink/retryable/detectedAt`。
| 分类 | 示例 | 默认责任 | 恢复方式 |
|---|---|---|---|
| `DATA_MISSING` | 缺箱码、重量图片、真实柜号、车辆资料 | 当前录入岗位 | 补齐字段/附件 |
| `STATE_NOT_READY` | 未审核、未 `WAIT_SPLIT`、未完成拣货 | 上一步岗位 | 完成上一步,不强制跳过 |
| `PERMISSION_DENIED` | 无审核、放行、调整权限 | 管理员/负责人 | 授权或由有权人处理 |
| `VERSION_CONFLICT` | 页面版本过期 | 当前操作员 | 刷新并重新核对 |
| `RESOURCE_CONFLICT` | 托盘/库位/库存已占用 | 仓库/调度 | 换资源或释放合法锁 |
| `BUSINESS_HOLD` | 国内 HOLD、海外暂扣、换标冻结 | 发起阻断的岗位 | 按证据解除 |
| `EXTERNAL_WAIT` | 船司、清关、车队、客户确认 | 对应外部协作岗位 | 等待并记录 SLA |
| `ASYNC_PROCESSING` | Outbox 已发、Inbox 处理中 | 系统 | 自动重试,业务只读 |
| `ASYNC_RETRYABLE` | 跨区/资源确认暂时失败 | 技术/运营 | 原幂等键受控重试 |
| `ASYNC_PERMANENT` | 契约字段错误、哈希冲突 | 技术负责人 | 修正源事实后重放 |
| `IRREVERSIBLE` | 已拣货、已离仓、已签收 | 业务负责人 | 专用异常/冲正流程 |
## 8. 问题证据记录模板
任何“难用、慢、容易错”都先按下表记录,AI 才能比较优先级:
| 字段 | 填写要求 |
|---|---|
| 问题编号 | `UX-S阶段-序号`,例如 `UX-S3-004` |
| 发生阶段/F 编号 | 必填,例如阶段三/F18 |
| 角色与终端 | 谁在 Admin、Client 或 PDA 操作 |
| 触发条件 | 什么状态、什么类型、什么前置数据 |
| 当前操作路径 | 菜单、页面、按钮、扫码顺序 |
| 期望结果 | 操作员真正想完成的业务结果 |
| 实际结果 | 页面/接口/状态发生了什么 |
| 证据 | 截图、录屏、业务号、时间、错误码;不放密钥和敏感数据 |
| 频率 | 每票/每天次数或样本比例 |
| 影响 | 多花时间、返工、错账、漏单、需技术介入等 |
| 临时处理 | 当前怎么绕过去 |
| 权威事实 | 哪个服务/接口/文档决定正确行为 |
问题优先级建议使用 `影响人数 × 发生频率 × 单次损失 × 风险系数`,同时单独标记合规、资金、库存和数据隔离风险;高风险项不因发生频率低而降级。
## 9. 指标字典与基线
上线任何优化前至少采集两周基线,并固定分子、分母、起止事件:
| 指标 | 起点 | 终点 | 口径 |
|---|---|---|---|
| 订单提交耗时 | 打开新建页 | 服务端返回提交成功 | 中位数/P90,排除纯草稿停留 |
| 审核等待时长 | `SUBMITTED` | `APPROVED` | 区分人工等待和系统建单失败 |
| 测量作业时长 | 首条测量采集 | 提交审核 | 按箱数分层 |
| 测量返工率 | 已提交批次 | 被驳回批次 | 驳回批次/提交批次 |
| 待分柜等待 | 进入 `WAIT_SPLIT` | 首次有效分配 | 区分 HOLD 和无柜资源 |
| 国内装柜时长 | PDA 任务开始 | `LOADED` | 按普通箱/整托分层 |
| 离仓阻断时长 | `LOADED` | `DEPARTED_WAREHOUSE` | 按 blocker 分类拆分 |
| 跨区建单耗时 | Outbox 创建 | Lastleg 消费成功 | P50/P90/P99 和失败率 |
| 海外计划耗时 | `PENDING_ASSIGNMENT` | `INBOUND_PLANNED` | 区分人工录入与资源失败 |
| 海外收货时长 | `receiving_at` | `INBOUND_COMPLETED` | 按三种收货模式分层 |
| 排车等待时长 | 库存具备资格 | 正式排车创建 | 排除暂扣和换标冻结 |
| 拣货准确率 | PDA 扫码尝试 | 成功核平 | 错托/错箱/重复扫码分别统计 |
| 派送完成时长 | 排车发车 | 有签收资料的 `DELIVERED` | 按目的地/服务方式分层 |
| 人工介入率 | 系统异常/阻断 | 需后台或技术处理 | 分 blockerCode |
| 重复单据数 | 所有幂等重放 | 重复业务单 | 必须为 0 |
埋点只记录阶段、稳定动作码、阻断码、结果、耗时和匿名业务类型;不记录 Token、证件、客户敏感明细或文件内容。
## 10. AI 优化卡标准格式
AI 每提出一项优化,必须完整输出:
```text
优化编号:OPT-Sx-xxx
阶段/F 编号:
问题证据:
当前角色与操作路径:
根因类型:交互 / 数据重复 / 状态不可见 / 跨系统等待 / 权限 / 业务规则
建议方案:
保持不变的业务规则:
影响终端:Admin / Client / 国内 PDA / 海外 PDA
影响服务与权威数据:
新增或复用的动作码、阻断码:
同步/异步数据流:
异常和重试:
权限与客户隔离:
埋点和基线指标:
验收场景:正常 / 分支 / 异常 / 并发 / 幂等 / 越权
风险与回退:
优先级及依据:
待业务确认:
```
没有证据的建议标记为“假设待验证”,不得直接进入实施清单。
## 11. AI 分析约束
AI 不得:
1. 新建第二套可写全流程状态机;
2. 用子单状态覆盖 Order、Firstleg 或 Lastleg 主状态;
3. 让前端根据状态字符串自行推导写资格;
4. 以内部数据库 ID 作为业务人员定位方式;
5. 为即时显示做国内/海外数据库同步双写;
6. 用当前分页数据判断整柜或整票完成;
7. 把预计时间、创建时间或人工猜测写成实际发生时间;
8. 为旧字段/旧状态新增兼容分支;
9. 把按钮隐藏当作服务端权限和状态门禁;
10. 把自动重试包装成业务成功;
11. 把一件代发整托拆成普通散货流程;
12. 在没有现场证据和指标基线时承诺具体效率提升比例。
AI 应优先:复用服务端权威动作与门禁、减少重复输入、显示数据来源、提供修复深链、标准化操作回执、提高异步可观测性,并保证幂等、版本、权限和客户隔离。
## 12. 分阶段优化工作法
每个阶段按同一节奏推进:
1. 业务走查:按阶段文档真实操作一票普通流和适用分支;
2. 证据采集:记录点击、等待、重复输入、错误和人工交接;
3. 问题归类:使用 blocker 与根因分类;
4. 数据核对:标出每个字段的权威来源、快照时点和消费者;
5. 方案设计:生成优化卡,不先改状态机;
6. 业务评审:确认 RACI、不可逆点、异常和指标;
7. 技术影响分析:页面、API、DTO、权限、事件、SQL、测试和部署;
8. 小步实施:优先 P0 可见性、导航和回执,再做流程合并/自动化;
9. 验收对比:使用相同业务样本比较基线与上线后指标;
10. 文档收口:把确认结论回写阶段文档和优化方案,历史假设移出当前基线。
## 13. 优化完成定义
单项优化只有同时满足以下条件才算完成:
- 问题证据和目标指标明确;
- 正常、分支和异常流程都不丢失;
- 服务端动作、权限、状态、版本和幂等门禁有效;
- 权威数据和快照来源没有改变或已明确评审;
- Admin/Client/PDA 受影响入口同步;
- 异步结果和失败可见;
- 业务号可贯通查询且不泄露内部 ID;
- 自动化检查和人工操作验收均有证据;
- 上线后指标可与基线比较;
- 文档、索引和操作说明已更新。
## 14. 仍需业务现场补充的资料
代码和文档无法替代以下现场事实,建议按阶段收集:
- 各岗位实际人员数量、班次、交接方式和峰值单量;
- 每个页面真实录屏及操作员口述痛点;
- 常见失败/返工前十名及每月次数;
- 各阶段目标 SLA、超时升级对象和通知渠道;
- 纸质标签、称重秤、PDA、打印机和现场网络型号/限制;
- 平台提货是否存在系统外派车和签收环节;
- 报关、清关、车队邮件、客户确认等系统外协作耗时;
- 费用、库存、出库和签收发生争议时的最终责任人;
- 当前运营最常用的 Excel、群消息和线下登记表;
- 客户最常咨询的进度问题及客服当前查询路径。
这些资料未收集前,AI 可以提出候选方案,但应明确标注假设,不应直接决定自动化规则或 SLA。
## 15. 待确认问题索引
六份阶段文档共登记 66 个业务/体验评审问题。问题原文保留在各阶段“评审问题”章节,评审时不应只看优化草案:
| 阶段 | 数量 | 重点决策 |
|---|---:|---|
| 阶段一 | 6 | 平台提货是否有独立履约、平台代下单、审核驳回口径、旧入仓码、预估费用命名、混箱 SKU 责任 |
| 阶段二 | 12 | 到仓时间、提前测量、差异原因、OCR、库位警告、费用发布、HOLD、共托和 SKU 纠错 |
| 阶段三 | 12 | 占位与作业资格、推荐解释、柜号体系、柜级总控、PDA 原子提交、证据、报关和不可逆更正 |
| 阶段四 | 12 | 柜级时间线、预计/实际节点、船司异常责任、邮件送达语义、跨区诊断、资源失败和还空口径 |
| 阶段五 | 12 | 柜级资源诊断、照片对照、三种实收、合托、盘点入口、库存不可用来源和换标进度 |
| 阶段六 | 12 | 草稿锁、装载风险、转派送、三单时间线、扫码扣库存、异常退回、跨区延迟和最终完成解释 |
第一轮评审建议优先决定以下跨阶段问题:
1. 平台提货是否属于系统内完整履约链路;
2. 到仓时间是否需要独立、权威的实收登记;
3. HOLD/暂扣的责任人、审批、证据和 SLA;
4. 国内 PDA 批量提交的原子性和弱网恢复目标;
5. 排柜占位、预装确认和真实作业资格的统一文案;
6. 柜级总控台是否作为订舱、PDA、报关、离仓和跨区进度的统一入口;
7. 跨区事件失败的业务责任人、人工介入边界和重放权限;
8. 盘点正式入口和账实差异处置流程;
9. 海外扫码即扣库存的复核、撤销和异常边界;
10. 国内离仓、海外入仓、海外出库和最终签收的客户可见进度语义。
每项决定应记录:结论、负责人、确认日期、影响阶段、是否改变接口/状态/权限,以及需要更新的文档。未决定项保持“待确认”,不能由 AI 默认选择。
+36
View File
@@ -0,0 +1,36 @@
# 头程到尾程分阶段流程文档
本目录把“头程下单 → 尾程出库”拆成六个可独立评审、以后也可独立优化的阶段。各阶段统一描述现状操作和数据流,不在流程确认前直接决定改版方案。
当前六个阶段的现状基线均已完成。下一步按阶段组织业务、产品、运营、国内仓、海外仓和技术评审;每阶段确认后,再将评审结论转入易用性和数据流转优化方案。
## 分阶段计划
| 阶段 | 范围 | 对应全景流程 | 文档 |
|---|---|---|---|
| 阶段一 | 客户准入、基础资料、单票/批量下单、提交、审核、订单交接 | F01~F07 | [阶段一:客户准入与头程下单](阶段一-客户准入与头程下单.md) |
| 阶段二 | 国内到仓、箱码、测量、称重、费用、扣货、一件代发组托 | F08~F13 | [阶段二:国内到仓、测量计费与组托](阶段二-国内到仓测量计费与组托.md) |
| 阶段三 | 模拟柜、排柜、订舱、拖车、预装、国内 PDA、报关、出仓 | F14~F23 | [阶段三:国内货柜履约与出仓](阶段三-国内货柜履约与出仓.md) |
| 阶段四 | 海运节点、目的港、跨区尾程建单、尾程入仓计划 | F24~F27 | [阶段四:海运、目的港与尾程入仓计划](阶段四-海运目的港与尾程计划.md) |
| 阶段五 | 海外接柜、实收、库存、暂扣和库内服务 | F28~F33 | [阶段五:海外接柜、库存与库内服务](阶段五-海外接柜库存与库内服务.md) |
| 阶段六 | 海外排车、转派送、PDA 出库、派送、异常和最终回写 | F34~F40 | [阶段六:海外排车、出库、派送与最终回写](阶段六-海外排车出库派送与回写.md) |
## 每阶段统一模板
1. 阶段目标、起点和终点;
2. 角色、终端和权限边界;
3. 前置主数据和检查项;
4. 页面逐步操作;
5. 字段与条件必填规则;
6. 状态机、可用动作和不可逆节点;
7. 单据、编号、快照和数据归属;
8. 同步/异步调用和上下游交接;
9. 正常流、条件分支和异常恢复;
10. 阶段完成判定与评审清单。
总览入口:
- [现状流程全景清单](../头程下单到尾程出库现状流程全景清单.md)
- [一票主链操作手册](../头程下单到尾程出库全流程操作手册.md)
- [易用性与数据流转优化草案](../头程下单到尾程出库流程易用性与数据流转优化方案.md)
- [AI 流程优化上下文包](../头程到尾程AI流程优化上下文包.md)
@@ -0,0 +1,464 @@
# 阶段一:客户准入与头程下单
> 对应全景流程:F01~F07。
> 阶段起点:企业已注册并可以登录。
> 阶段终点:平台审核通过,Order 为 `APPROVED`,Firstleg 已创建正式国内入库单并沿用固定入仓码,统一主状态进入 `WAIT_MEASURE`。
> 本文只记录现状流程;易用性问题先登记,不在本文直接改状态、接口或页面。
## 1. 阶段目标和边界
本阶段解决六件事:
1. 确认“谁可以代表哪个客户下单”;
2. 确认客户是否具备提交资格;
3. 冻结订单的国内收货仓、目的仓、渠道、地址和货物申报;
4. 生成客户可见的订单号和全局唯一入仓码;
5. 由平台审核订单并建立正式国内入库单;
6. 把权威订单和申报快照交给下一阶段的国内收货、测量和计费。
本阶段不负责:货物是否已经到仓、实际尺寸重量、费用最终确认、分柜、装柜、出仓和海外履约。
## 2. 角色、终端与权限
| 角色 | 终端 | 可做操作 | 不可做操作 |
|---|---|---|---|
| 企业成员 | Client | 按权限查看本企业订单 | 查看其他客户、平台内部备注和成本 |
| 企业订单操作员 | Client | 新建、编辑草稿、批量导入、提交、按规则取消 | 指定客户 ID、修改审核状态、维护公共仓库/渠道 |
| 企业管理员 | Client | 管理本企业成员角色,并拥有获授权的订单操作 | 配置平台后台权限、跨企业授权 |
| 平台订单运营 | Admin | 查询授权数据、审核、维护允许的申报字段 | 修改客户归属、订单号、入仓码或实测事实 |
| 平台超级管理员 | Admin | 在平台权限范围内处理全部订单 | 绕过服务端状态机、直接指定任意目标状态 |
| Order 服务 | 后端 | 保存订单、货物、快照、状态、入仓码、审计和 Outbox | 拥有测量、仓位、柜次或海外库存 |
| Firstleg 服务 | 后端 | 审核后创建国内入库单,接收版本化申报更新 | 成为订单交易状态权威 |
客户数据范围必须来自已认证身份。页面传入的 `tenantId/customerId` 不能单独形成授权。
## 3. F02 基础资料准备
### 3.1 企业和账号
- 企业注册完成,可以用中文邮箱/手机号和密码登录;
- 企业基本资料、联系信息和资质文件已填写;
- 平台已经人工审核企业资料;
- 当前成员拥有 `order:first-leg:create`、`order:first-leg:submit` 等对应权限;
- 当前客户 Finance 资金账户存在且提交时可用。
注册成功不等于具备提交资格。资料未审核通过时仍可登录、补资料和保存草稿,但提交订单必须失败关闭。
### 3.2 下单主数据
| 主数据 | 权威来源 | 下单使用方式 |
|---|---|---|
| 国内收货仓 | Warehouse | 只能选 `ACTIVE` 且具备对应收货能力的公共仓 |
| FBA/海外目的仓 | Warehouse | 按目的类型返回匹配且启用的公共仓 |
| 私人目的地址 | Order 客户地址簿 | 只能选择本客户 `PRIVATE_DESTINATION`,或保存本单临时快照 |
| 平台提货地址 | Order 客户地址簿 | `PICKUP` 时选择本客户 `PICKUP` 地址 |
| 运输渠道与价格 | Firstleg 价格/渠道目录 | 先选国内收货仓,再加载当前有效渠道 |
| 企业资格 | IAM | 提交时实时校验 |
| 资金账户 | Finance | 提交时校验实际余额不小于 0 |
下拉列表只负责选择,提交时服务端必须重新查询权威主数据。Warehouse、IAM 或 Finance 无法返回可信结果时提交失败,不回退前端旧缓存。
## 4. F01 企业资料与下单资格流程
### 4.1 客户维护企业资料
入口:Client → 公司信息。
现状操作:
1. 填写企业名称、统一社会信用代码、企业类型和注册地址等基本信息;
2. 填写联系人、手机、邮箱、微信和联系地址;
3. 上传营业执照;法人身份证为可选资料;
4. 保存后等待平台审核。
数据归属:企业和成员属于 IAM。公司联系电话/邮箱是企业资料,不等于成员登录标识;成员手机号修改走账号链路。
### 4.2 平台审核企业资料
入口:Admin → 客户管理/企业资料审核。
1. 平台查看企业资料和资质;
2. 资料正确时点击审核通过;
3. 资料不正确时联系客户修改,不在头程订单流程伪造通过;
4. 审核通过后,客户获得订单提交资格。
### 4.3 提交资格实时检查
客户打开订单页时可以看到企业资料审核提示,但页面提示不是最终门禁。点击提交时服务端重新检查:
- 当前客户身份和成员权限;
- 企业资料审核状态;
- Finance 资金账户有效响应;
- 实际余额是否小于 0;
- 仓库、渠道、目的仓和地址是否仍有效。
草稿保存不触发资金门禁,因此“能保存”不能解释成“能提交”。
## 5. F03 客户单票下单详细操作
入口:Client → 头程订单 → 创建订单。
### 5.1 选择履约类型
当前主要履约形态:
| 履约形态 | 业务含义 | 关键限制 |
|---|---|---|
| 普通头程 | 普通 FBA、海外仓或私人仓货物 | 按箱码进入后续测量和装柜 |
| `ONE_CLICK_DROPSHIP` 一件代发 | 国内清点 SKU、组托后跨境,海外按 SKU/箱唛出库 | 每个货物明细必须有 SKU;目的类型固定走海外仓能力,不允许靠货名推导 SKU |
### 5.2 选择交货方式
| 交货方式 | 页面值 | 必填数据 | 后续含义 |
|---|---|---|---|
| 快递自送 | `SELF_EXPRESS` | 国内收货仓;快递信息按页面当前要求维护 | 客户自行寄到国内仓 |
| 平台上门提货 | `PICKUP` | 本企业提货地址、联系人、电话、城市、详细地址、预约提货时间 | 订单冻结客户要求的提货事实;不等于订舱级拖车 |
| 卡车自送 | `SELF_TRUCK` | 国内收货仓和本单自送信息 | 客户自行送到国内仓 |
`PICKUP` 地址可从地址簿选择,也可保存当前地址。停用地址不能用于新订单,但历史订单继续显示已冻结快照。
### 5.3 选择国内收货仓和渠道
1. 从平台有效国内收货仓单选;
2. 选择仓库后加载该仓可用运输渠道;
3. 选择渠道,保存稳定 `channelCode` 和服务端名称快照;
4. 选择报价包税方式;
5. 选择买单或退税;
6. 选择整柜 `FCL` 或散货 `LCL`;
7. 木制品货物要求商检时,页面和服务端必须校验。
渠道编码是业务校验依据,不能从渠道中文名称反推。
### 5.4 选择目的类型和目的地
| 目的类型 | 页面操作 | 服务端规则 |
|---|---|---|
| `FBA` | 选择平台维护的有效 FBA 仓 | 仓库类型必须匹配;货物行要求货件编号和跟踪编号 |
| `OVERSEAS` | 选择平台维护的有效海外目的仓 | 不允许用私人地址或国内仓冒充 |
| `PRIVATE` | 选择/保存本企业私人地址,填写名称、联系人、电话、国家、城市、地址和邮编 | 地址属于本客户;不创建伪公共仓 |
一件代发页面不开放 FBA/私人仓选择,使用海外仓路径承接后续海外库存。
### 5.5 录入货物明细
每张订单至少一条货物明细。当前页面覆盖:
| 分类 | 字段 |
|---|---|
| 识别 | SKU、中文品名、英文品名、产品图片、混箱组合 |
| 包装 | 包装类型(纸箱/托盘/木箱)、材质、箱数、每箱数量、总数量 |
| 申报尺寸重量 | 长宽高、总毛重、总净重、总体积 |
| 海关申报 | 报关 HS CODE、清关 HS CODE、申报单价、申报金额 |
| FBA | 货件编号、跟踪编号、UPC |
| 产品属性 | 用途、型号、品牌、产品规格、危险品、木制品 |
| 辅助 | 备注 |
关键计算和校验:
- 总数量、总重量和总体积由明细汇总,不能另传一套冲突合计;
- 箱数、件数、重量和体积必须满足正数/非负规则;
- FBA 货物必须填写货件编号和跟踪编号;
- 木制品必须满足商检选择;
- 一件代发 SKU 强制存在,同一箱可通过混箱组合保存多个 SKU;
- 图片只保存经 File 校验的文件引用,Order 不长期保存图片二进制。
### 5.6 订单级其他信息
- 期望出货日期:客户期望,不等于平台确认 ETD;
- 客户备注:客户和平台可见;
- 客户参考号/跟踪编号:用于客户内部定位,不替代平台订单号;
- 费用预估:下单阶段只作当前规则下的估算,不能当成最终费用或客户确认版本。
### 5.7 保存草稿
客户点击“保存草稿”:
- Order 保存主表、Party 快照和货物明细;
- 状态保持 `DRAFT`;
- 不生成入仓码;
- 不创建 Firstleg 正式入库单;
- 不触发资金账户校验;
- 后续可继续编辑或取消。
## 6. F04 客户批量导入详细流程
入口:Client → 头程订单 → 批量导入订单。
### 6.1 下载模板
模板按当前登录客户动态生成:
- 本客户有效提货地址和私人地址;
- 全部有效国内收货仓;
- 匹配目的类型的有效目的仓;
- 当前有效渠道和中文下拉项;
- 日期、数字和枚举校验。
模板目录必须读取全部分页候选,不能只生成第一页选项。
### 6.2 填写和上传
- 文件只接受 `.xlsx`;
- 最大 5MB;
- 最多 50 行有效数据;
- 直接选择中文下拉值,不填写内部 ID;
- 平台上门提货必须填写预约时间和提货地址;
- 木制品必须选择商检;
- 一件代发必须填写 SKU。
### 6.3 两种导入模式
| 模式 | 行失败时行为 |
|---|---|
| `PARTIAL` | 成功行创建草稿,失败行返回行号、字段和原因 |
| `ATOMIC` | 任一行失败时整批不创建订单 |
导入时服务端重新加载仓库、渠道、地址和客户归属,不信任模板隐藏页。成功结果只创建 `DRAFT`,客户仍需检查并提交。
## 7. F05 订单提交和入仓码
### 7.1 提交前检查
客户端进行表单校验后,服务端再次校验:
1. 当前订单属于登录客户且版本一致;
2. 状态允许提交;
3. 当前成员有提交权限;
4. 企业资料已审核通过;
5. Finance 账户响应有效且实际余额不小于 0;
6. 国内收货仓、目的仓和渠道仍启用并类型匹配;
7. 地址属于本客户且类型正确;
8. 货物字段、FBA 条件、木制品商检和一件代发 SKU 完整。
### 7.2 提交事务结果
成功后:
- Order 状态 `DRAFT → SUBMITTED`;
- 统一主状态展示为 `PENDING_REVIEW`;
- 生成并固定全局唯一入仓码;
- 写提交历史、操作者和服务端时间;
- 发布后续协作所需的可靠消息/内部事实;
- 同一幂等请求重放返回原结果,不产生第二个入仓码。
入仓码是订单、国内入库、箱码、装柜和海外尾程之间的稳定业务关联码。页面不得用内部订单 ID 替代。
### 7.3 F06 草稿编辑与取消
- 草稿允许有权限的企业操作员继续修改并保存;
- 已提交订单不得再通过草稿入口覆盖审核中的权威版本;
- 只有服务端当前状态允许时才能取消,页面按钮不是最终授权;
- 取消携带当前版本和幂等键,并记录操作人、时间和原因;
- 已进入不可逆履约的订单不能取消,需进入后续阶段异常流程;
- 请求超时后先查询当前状态,不换新幂等键盲目重试。
## 8. F07 平台审核和申报修订
入口:Admin → 统一订单 → 待审核。
### 8.1 单票审核
1. 按订单号、入仓码、客户、仓库、渠道或状态定位订单;
2. 核对客户、交货方式、预约时间、国内收货仓、目的类型、目的仓、货物和申报;
3. 查看平台可见内部信息和服务端返回的下一动作;
4. 必要时修改允许的客户申报字段;
5. 点击审核通过。
### 8.2 批量审核
1. 选择同一操作范围内的待审核订单;
2. 一次调用批量审核预览,不逐票循环拉详情;
3. 预览按请求顺序返回中文货物摘要、渠道、目的仓、入仓码、`canApprove` 和 `blockReason`;
4. 操作员只提交可审核项;
5. 服务端再次校验状态和版本后逐票处理。
### 8.3 平台申报修订边界
平台可在 `DRAFT/REJECTED/SUBMITTED/APPROVED` 范围内按版本修改客户原始申报,但:
- 不得修改客户归属、国内收货仓、订单号、入仓码和状态;
- 不得增删或重排货物行,使用稳定 `itemNo` 定位;
- 不得覆盖设备/仓库实测、测量审核和计费事实;
- 国内收货仓或中转仓要变化时必须重新下单;
- 只有渠道变化时才检查 Firstleg 履约可逆性和重新计价门禁;
- 内部校验不可用时失败关闭,不能只改 Order 留下跨服务不一致;
- 成功后通过 `ORDER_DECLARATION_UPDATED_V1` 向 Firstleg 同步完整申报快照和字段变化。
### 8.4 审核通过结果
- Order 状态 `SUBMITTED → APPROVED`;
- Firstleg 幂等创建正式国内入库单;
- 正式入库单沿用提交时生成的入仓码;
- 生成/补齐后续测量和 PDA 使用的稳定箱码映射;
- 统一主状态进入 `WAIT_MEASURE`;
- 审核动作和建单结果写入时间线。
审核通过不代表货物到仓,也不代表测量、计费或入库完成。
## 9. 客户订单列表和详情
### 9.1 列表筛选
当前客户可按订单号、国内收货仓、渠道、入仓码/SKU、创建时间、当前状态、目的仓类型和快递单号查询本企业订单。
### 9.2 列表核心展示
列表包含订单号、入仓码、状态、渠道、运输方式、交货方式、预约提货时间、国内收货仓、目的仓、目的类型、箱数、SKU、重量、体积、费用摘要、货件编号、跟踪编号、柜次和船期摘要等。
数据可能来自 Order 申报和 Firstleg 履约聚合;Order 为 `SUBMITTED` 时必须优先展示待审核,不能因 Firstleg 预投影显示成待测量。
### 9.3 详情和可用操作
详情展示:
- 订单主信息和客户/地址快照;
- 货物明细和图片;
- 入仓码业务关系;
- 服务端时间线;
- 当前履约摘要;
- 当前允许的编辑、提交、费用确认、报关资料等动作。
平台内部备注、成本、其他客户数据和内部事件字段不能进入客户响应。
## 10. 状态机和动作
### 10.1 Order 交易状态
```text
DRAFT --提交--> SUBMITTED --平台审核通过--> APPROVED
└--取消--> CANCELLED
APPROVED --后续全部履约可信回写--> COMPLETED
```
当前产品不以“审核驳回”作为正常业务路径;历史 `REJECTED` 数据在客户侧仍按待补充/待审核兼容展示,不能据此新增一套驳回流程。
### 10.2 统一订单主状态交接
| Order/Firstleg 事实 | 统一显示 | 下一责任人 |
|---|---|---|
| Order `DRAFT` | 草稿 | 客户 |
| Order `SUBMITTED` | `PENDING_REVIEW` | 平台订单运营 |
| Order `APPROVED` + 正式入库单 | `WAIT_MEASURE` | 国内仓/测量人员 |
交易状态和履约主状态是两个维度。平台审核后不应把 Order 交易状态继续当成仓库作业状态。
## 11. 数据流转和权威归属
```text
Auth/IAM
├─ 当前客户、成员、角色、企业审核资格
↓
Client 下单
├─ Warehouse:实时校验国内仓、目的仓和仓库快照
├─ Firstleg:读取有效渠道/价格目录
├─ Finance:提交时校验资金账户
↓
Order 本地事务
├─ 订单主表、Party快照、货物明细
├─ 状态、版本、入仓码、时间线、幂等结果
└─ Outbox/内部调用事实
↓ 审核通过
Firstleg
├─ 正式国内入库单
├─ 稳定箱码/箱唛来源
└─ 下一阶段 WAIT_MEASURE
```
### 11.1 Order 权威数据
- 客户归属、订单号、入仓码;
- 客户原始申报和货物明细;
- 下单时 Party/地址和仓库必要快照;
- 订单交易状态、版本、幂等和时间线。
### 11.2 Order 不拥有的数据
- 实际尺寸、重量和测量审核;
- 国内仓位、托盘和装柜事实;
- 柜次、船节点、海外库存和出库;
- Finance 钱包和结算主账。
### 11.3 关键编号
| 编号 | 生成时点 | 是否可改 | 用途 |
|---|---|---|---|
| `orderNo` | 创建/提交规则生成 | 否 | 客户订单定位 |
| `inboundCode` | 首次成功提交 | 否 | 国内入库至海外尾程稳定关联 |
| 客户参考号 | 客户填写 | 按状态 | 客户内部检索,不替代平台编号 |
| 货物 `itemNo` | 订单明细创建 | 不重排 | 申报更新和箱码来源定位 |
## 12. 正常流、分支和异常恢复
| 场景 | 系统行为 | 操作员处理 |
|---|---|---|
| 企业资料未审核 | 可保存草稿,提交拒绝 | 补资料并等待平台审核 |
| 资金余额小于 0 | 提交拒绝 | 充值或联系财务处理 |
| Finance/IAM/Warehouse 不可用 | 提交失败关闭 | 保留草稿,服务恢复后原单重试 |
| 仓库/渠道在草稿后停用 | 提交时拒绝 | 重新选择有效项 |
| 私人地址归属不符 | 拒绝且不泄露其他客户数据 | 选择本企业地址或新建 |
| 木制品未选商检 | 表单/服务端拒绝 | 补充商检选项 |
| FBA 缺货件号/跟踪号 | 提交拒绝 | 补齐对应货物行 |
| 一件代发缺 SKU | 保存/提交按当前门禁拒绝 | 为每个物理箱内货物补 SKU |
| 版本冲突 | 当前写入失败且不覆盖新版本 | 刷新订单后重新编辑 |
| 请求超时 | 不能换新幂等键盲重试 | 先查询订单/幂等结果,再用原操作处理 |
| 批量导入部分失败 | `PARTIAL` 只建成功行;`ATOMIC` 整批不建 | 按行号和字段修正后重新导入 |
| 审核时 Firstleg 建单失败 | 不能宣称阶段完成 | 查看服务端结果,受控重试或修复依赖 |
| 渠道变更不允许 | Order 和 Firstleg 都不写 | 根据阻断原因新建订单或处理下游占用 |
## 13. 阶段完成判定
每张进入阶段二的订单必须同时满足:
- [ ] Order 属于正确客户且状态为 `APPROVED`
- [ ] 客户可见订单号存在
- [ ] 全局唯一入仓码存在且未变化
- [ ] 国内收货仓、渠道、目的类型和目的地快照完整
- [ ] 货物明细行集合、箱数、SKU/申报字段符合订单类型
- [ ] Firstleg 正式国内入库单已创建并沿用同一入仓码
- [ ] 一件代发稳定箱码与 SKU 来源可用
- [ ] 统一主状态为 `WAIT_MEASURE`
- [ ] 审核和建单时间线可查
- [ ] 重放提交/审核不会生成重复入仓码或重复入库单
任何一项缺失都不能把订单交给国内仓作为正式测量任务。
## 14. 本阶段评审问题
这些问题不阻断现状记录,但需要业务评审确认后才能进入优化:
1. 平台上门提货当前在订单中冻结地址和预约时间,但是否存在独立派车、接单、到仓回执流程?若没有,阶段二只能从“货物实际到仓”开始,不能假设提货已被系统履约。
2. 客户是否仍需要平台代创建订单;若保留,代创建时客户授权、数据来源和通知方式需单列。
3. 当前产品不设订单审核驳回,但代码兼容 `REJECTED` 历史状态;业务是否确认继续采用“平台修改或联系客户修改、保持待审核”。
4. 旧系统入仓码模式是否仍会在任何环境启用;若不会,应从正式流程和页面移除。
5. 下单页的费用预估对客户应如何命名,避免被理解成最终账单。
6. 一件代发混箱 SKU 的实际填报责任人和现场纠错责任是否已明确。
## 15. 阶段二交接包
交给阶段二“国内到仓、测量、计费与组托”的输入应只有以下权威数据:
- 客户和订单业务身份;
- 订单号、入仓码、货物 `itemNo`;
- 交货方式和预约提货事实;
- 国内收货仓快照;
- 渠道与目的仓/私人地址快照;
- 客户申报箱数、SKU、尺寸、重量、体积和报关资料;
- 一件代发箱内 SKU 关系;
- 审核通过时间、版本和幂等历史。
阶段二新增实测和计费事实,不反向覆盖本阶段客户原始申报。
## 16. 事实来源
- `docs/订单与客户/README.md`
- `docs/订单与客户/订单与客户头程订单闭环设计.md`
- `docs/统一订单主状态与操作契约.md`
- `docs/业务快速索引.md`
- `apps/client-web/AGENTS.md`
- `apps/client-web/src/modules/first-leg/views/FirstLegOrdersView.vue`
- `apps/client-web/src/modules/first-leg/api/firstLeg.ts`
- `apps/admin-web/AGENTS.md`
- `apps/admin-web/src/modules/unified-orders/pages/UnifiedOrdersView.vue`
- `baiyue-firstleg/order/AGENTS.md`
- `baiyue-firstleg/order/src/main/resources/openapi/order-v1.yaml`
@@ -0,0 +1,424 @@
# 阶段三:国内货柜履约与出仓
> 对应全景流程:F14~F23。
> 阶段起点:入库单主状态为 `WAIT_SPLIT`,不存在有效 HOLD。
> 阶段终点:整柜满足国内出仓门禁,产生唯一实际 `DEPARTED_WAREHOUSE` 节点并开始出仓后的船节点查询。
> 本阶段复用订单、入库单、柜次和装柜计划,不新建独立“出仓单”。
## 1. 阶段目标和边界
本阶段把可分柜货物转化为可追溯的真实整柜离仓事实,覆盖:
1. 模拟柜、智能推荐、人工分配和同柜不可拆组;
2. 海运订舱草稿、资料补齐和放舱;
3. 订舱级国内拖车预约;
4. 模拟柜与订舱柜型需求、真实集装箱号关联;
5. 预装确认、动态增减和普通散货托盘标签;
6. 国内 PDA 拣货、整托装柜和异常闭环;
7. 四步装柜照片、铅封照片与铅封号;
8. 装柜完成、部分装载分片和跨区交接快照;
9. 客户资料、整柜出口报关与放行;
10. 国内出仓门禁、离仓确认和拖车费用归集。
`LOADED` 表示货物已经完成实际装柜,不等于柜子已经离开国内仓。报关放行等条件在国内出仓环节统一校验;国内 Admin/PDA 不得把订单推进到海外派送完成 `DELIVERED`。
## 2. 角色、终端和对象
| 角色 | 终端 | 主要操作 |
|---|---|---|
| 排柜操作员 | Admin | 创建模拟柜、搜索候选、推荐/人工加入、调整预装 |
| 订舱操作员 | Admin | 建订舱草稿、维护运输资料、上传文件、放舱 |
| 国内调度 | Admin | 建订舱级拖车预约、选择供应商、维护时间和附件 |
| 仓库主管 | Admin | 预装确认、真实柜号补录、任务和证据监督 |
| PDA 拣货员 | 国内 PDA | 领取唯一柜次任务、扫码、异常、拍照、完成装柜 |
| 报关操作员 | Admin | 汇总整柜资料、开始报关、上传完成文件、完成报关 |
| 企业操作员 | Client | 上传本客户退税/报关资料、查看本企业进度和回执 |
| 出仓操作员 | Admin | 查看组合门禁,必要时人工确认离仓 |
核心对象:
| 对象 | 业务作用 | 稳定标识 |
|---|---|---|
| 入库单 | 每票/分片货物履约主体 | `inboundId`、入仓码 |
| 模拟柜/柜次 | 多票货物共享的装载容器 | `containerBookingId`、`cabinetNo` |
| 装柜计划 | 柜次唯一预装/PDA 作业聚合 | `loadingPlanId` |
| 海运订舱 | 船司运输和柜型需求聚合 | `oceanBookingId`、`OB-yyMMdd-####` |
| 拖车预约 | 一个已放舱订舱的国内提柜/送柜任务 | `truckAppointmentId` |
| PDA 任务 | 单柜次单作业员的现场任务 | `taskId`、任务版本 |
| 整柜报关 | 柜次级资料冻结和回执 | 柜次及报关记录标识 |
## 3. 前置检查
- 候选入库单已完成阶段二,实际箱数、重量和体积可用;
- 一件代发已按实际托盘封托;
- 当前入库单无有效 HOLD;
- 柜型规格、国内中转仓、有效渠道和供应商主档可用;
- 操作员理解模拟柜、真实柜号、订舱和拖车是不同对象;
- 同柜不可拆组、已占用数量和版本冲突均以服务端返回为准;
- 费用仍可并行维护,但已经确认/发送或客户已确认版本不得被静默改写。
## 4. F14 模拟柜与智能排柜
### 4.1 创建模拟柜
入口:Admin → 国内货柜/智能装柜。
1. 选择正式装柜仓;
2. 选择九类计划渠道组之一;
3. 选择平台有效柜型,核对额定容量和限重;
4. 填写计划日期和必要备注;
5. 提交后系统原子创建稳定柜次、`cabinetNo` 和唯一装柜计划;
6. 此时可以没有真实集装箱号,也不会因创建模拟柜自动产生订舱。
容量和重量当前允许达到柜型额定值的 150%,超过时服务端拒绝。页面显示仅用于预判,最终以服务端重新计算为准。
### 4.2 查询候选
工作台可查询 `PENDING_REVIEW`、`WAIT_MEASURE`、`WAIT_SPLIT`、`LOADING` 等未完成候选。状态是展示维度,能否加入必须看响应中的 `canAssign` 和原因。
- 待审核或待测量订单可提前占位;
- 预装确认和启动 PDA 前,实际作业成员必须进入 `WAIT_SPLIT`;
- 普通订单按剩余箱数分配;
- 一件代发按每个未占用的已封托托盘独立返回和分配,禁止拆托;
- 同柜不可拆组必须成员完整、满箱并原子加入;
- 推荐顺序以入库时间先进先出为基础。
### 4.3 智能推荐和人工加入
智能推荐:设置筛选范围和装载目标,系统逐项校验渠道、仓库、剩余箱数、体积、重量、托盘完整性、组锁和占用,再返回推荐结果。
人工加入:选择候选,填写普通货分配箱数或选择一件代发整托,携带计划版本提交。系统重新校验资格;页面旧结果不能作为写入依据。
一件代发一票多托时逐托展示,其中一托已分配不得隐藏其他未分配托盘。容量不足时跳过整托,不能回退成按箱拆托。
### 4.4 调整与费用影响
- 未产生物理拣货的普通明细可增减或移除;
- 已拣数量是可调整下限;
- 同柜组只有全组都未拣时才能整组移除;
- 单行一件代发只能按该行 `palletId` 整托移除;
- 成员变化需要重算未冻结的柜级/客户级费用;
- 已确认、已发送或客户已确认的费用版本不得直接改写,应按费用规则生成新版本或拒绝。
## 5. F15 海运订舱与放舱
### 5.1 创建草稿
入口:Admin → 海运订舱。
1. 选择启用的国内中转仓;
2. 选择该仓当前有效渠道;
3. 选择 `ACTIVE/BOOKING` 订舱供应商;
4. 填写三类参与方和已知运输资料;
5. 至少维护一项、最多二十项柜型需求,数量为正整数;
6. 保存为 `DRAFT`,系统生成 `OB-yyMMdd-####` 订舱号。
草稿允许资料不完整、替换文件和继续编辑。柜型需求只表达需要多少个什么柜型,不创建真实货柜行。
### 5.2 补齐并放舱
放舱前核对:
- 提单号全局唯一;
- 船名、航次、起运港、目的港、ETD、ETA 和运输条款完整;
- 渠道仍有效;
- 柜型需求与平台规格一致;
- 存在有效 `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING` 文件;
- 订舱供应商仍为有效主档。
点击放舱后状态从 `DRAFT` 进入 `RELEASED`,运输快照永久只读。放舱不要求真实柜号,不得为了通过校验伪造柜号。
## 6. F16 订舱级国内拖车
入口:Admin → 国内拖车预约。
1. 从服务端候选中选择已 `RELEASED`、有提单号和有效订舱文件的订舱;
2. 选择 `ACTIVE/TRUCKING/DOMESTIC` 供应商;
3. 填写计划装柜时间、联系人和附件;
4. 保存/提交预约;
5. 重复创建时返回现有非终态预约,不生成第二张。
拖车单只绑定订舱,不绑定客户、柜次、装柜计划或真实柜号。一个订舱最多一张非终态预约,已有有效完成单不能再次创建收费。
PDA 装柜完成不推进拖车状态。只有订舱要求的全部柜型数量均已关联,并且所有柜均确认国内离仓后,拖车才自动完成。正金额应付拖车费按各柜最终实装体积守恒分摊并发送 Finance;缺供应商归属、有效体积或费用配置时失败关闭,不做伪造均分。
## 7. F17 柜次关联和真实柜号
入口:Admin → 柜次详情 → 关联订舱/补录柜号。
1. 选择已放舱订舱的未满配柜型需求;
2. 选择对应模拟柜;
3. 校验仓库、渠道映射、柜型、容量、限重和需求剩余数量;
4. 录入真实集装箱号;
5. 提交后在原柜次上冻结订舱、需求和柜号快照。
关联不会更换 `containerBookingId`、`cabinetNo`、装柜计划或明细。真实集装箱号在同一订舱内唯一,不同提单可按业务规则复用。
订舱关联、预装确认和任务创建技术上可先于柜号,但正式操作应在 PDA 最终完成前补齐。已有柜号纠正仅限计划 `EDITING`、任务未领取且没有物理作业事实。
## 8. F18 预装确认、动态增减与标签
### 8.1 预装核对与确认
入口:Admin → 预装清单。
1. 打开柜次唯一预装计划;
2. 核对每个入仓码、客户、目的仓、普通箱数或一件代发托盘;
3. 查看容量、重量、已占用和异常提示;
4. 确认所有现场成员已到 `WAIT_SPLIT` 且未 HOLD;
5. 点击计划级确认,生成或开放唯一 PDA 任务;
6. 记录确认快照、操作人和版本。
预装进入 `CONFIRMING/CONFIRMED` 只冻结确认快照,不锁死尚未完成柜次。计划仍为 `EDITING/LOADING` 时可继续增加合法明细;未拣明细可调整,已拣明细保持锁定。
新增或调整后计划和任务版本递增,PDA 旧版本必须刷新再作业。
### 8.2 普通散货托盘标签
普通、非一件代发 FBA 明细在计划 `LOADING/COMPLETED` 且真实柜号有效时生成正式托盘标签:
1. 系统按平台柜次号、FBA 地址内部编号和三位序号生成冻结托盘码;新柜次号为全局连续且不按日期重置的 `D0001`、`D0002`,托盘码不依赖可跨提单复用的真实柜号;
2. 地址内部编号从 Warehouse 启用主数据读取;
3. 缺编号或同柜重复时拒绝生成;
4. 重复生成必须与已有明细一致;
5. Admin 全部打印或按仓指定张数打印;
6. A4 横版每托一页,标签与海外补打复用同一编号。
该标签与阶段二一件代发 `DP` 托盘不同:一件代发托盘在测量后已实际清点封托,装柜时按冻结整托分配。
## 9. F19 国内 PDA 拣货装柜
### 9.1 领取和开始
入口:国内 PDA → 拣货装柜。
1. 操作员选择当前作业仓;
2. 打开或扫描柜次任务;
3. 核对真实柜号、装柜计划和任务版本;
4. 领取任务后绑定当前作业员、任务和柜次;
5. 单柜次只允许一名作业员,不支持多人并行或无审计接管;
6. 进入任务后计划/入库履约按规则进入 `LOADING`。
正式作业当前只支持在线。设备本地草稿按作业员、任务和柜次隔离;批量提交是否具备完整原子事务仍需在优化/验收中持续核实。
### 9.2 普通散货扫码
- 扫描稳定箱码,一箱一码;
- 不能用 SKU、件数、入仓码或自由文本代替箱码;
- 系统核对箱码属于当前入库单、当前任务且尚未处理;
- 连续扫描先进入当前任务草稿,操作员核对后批量提交;
- 重复扫描不重复增加实装数量。
### 9.3 一件代发整托装柜
- 扫描 `DP` 托盘码,或扫描成员箱码定位所属已封托托盘;
- 页面展示托盘、入仓码、箱数和 SKU 汇总;
- 使用 `PICK_PALLET` 一次提交整托;
- 禁止部分拣货、拆托或将同托不同订单成员分开提交;
- 装柜完成按 `palletId` 精确冻结每托箱唛和 SKU,不能按整票模糊复制。
### 9.4 部分拣货、跳拣和异常
允许分批扫描和暂时跳过,但每个未完成项必须选择/填写异常原因。存在未解决异常、缺少处理单元、数量不平或版本冲突时不能完成任务。
提交超时后先查询任务明细和操作结果,禁止更换幂等键盲目重复提交。版本冲突时刷新任务,保留未提交草稿并重新核对。
## 10. F20 装柜证据与铅封
PDA 按现场顺序拍摄:
1. `PHOTO_STEP_1`:装柜初始/空柜;
2. `PHOTO_STEP_2`:装载过程;
3. `PHOTO_STEP_3`:接近完成;
4. `PHOTO_STEP_4`:满柜/关门前;
5. `SEAL`:带有效铅封号的封柜照片。
每类至少一张有效照片,同类允许追加。PDA 仅允许现场相机拍摄;有权限的 Admin 操作员/理货员可人工补充,普通拣货员不开放后台上传。
证据先上传 File 获得合法文件引用,再绑定柜次/任务。错误图片只能作废,不能硬删除;需保留上传、作废、操作者、时间、文件哈希和幂等记录。照片及铅封号当前不对客户展示。
## 11. F21 装柜完成与分片
PDA 点击“完成装柜”时,服务端重新校验:
- 任务属于当前柜次和仓库且版本一致;
- 真实集装箱号存在;
- 所有普通箱码或一件代发整托均核平;
- 没有未解决异常;
- 四步照片和带铅封号的 `SEAL` 完整;
- 计划明细、已拣数量和最终实装数量守恒。
校验通过后:
1. PDA 任务和装柜计划进入 `COMPLETED`;
2. 实装来源入库单进入 `LOADED`;
3. 生成最终实装箱码、托盘、SKU 和证据快照;
4. 对部分装载执行一次分片;
5. 生成跨区 Outbox,但后续消费必须保持幂等。
部分装载时,已装数量形成当前柜次的 `LOADED` 子入库单,余量形成可继续 `WAIT_SPLIT` 的后续子单;只允许一层分片,所有子项总和必须等于来源数量。客户订单身份和入仓码关联不能丢失。
## 12. F22 客户资料与整柜出口报关
### 12.1 客户上传资料
入口:Client → 头程订单 → 报关资料。
客户只可对本企业订单上传退税/报关资料,选择资料类型并填写说明。文件归属、订单归属和客户身份由服务端校验,文件名不能代替合法文件 ID。
### 12.2 平台整柜报关
入口:Admin → 柜次详情 → 出口报关。
1. 查看柜内所有入仓码及其资料覆盖情况;
2. 对退税资料逐项核对,缺件时联系对应客户补充;
3. 开始报关时冻结当前参与资料和版本;
4. 线下向报关代理提交;首版不做外部系统直连;
5. 完成后为每份退税资料上传一一对应的完成文件;
6. 全买单柜由平台确认买单,并从导出中心获取最终实装买单明细;
7. 点击完成整柜报关,写入放行结果和历史。
报关完成后系统立即尝试国内出仓;若其他门禁尚未齐全,保留明确阻断项并由受控补偿重试,不能伪装为已离仓。
## 13. F23 国内出仓
### 13.1 出仓硬门禁
整柜所有关联入库单逐项检查:
- 无有效 HOLD;
- 入库单为 `LOADED`;
- 装柜计划为 `COMPLETED`;
- PDA 柜次任务为 `COMPLETED`;
- 订舱已 `RELEASED` 且分配当前柜次;
- 四步照片与有效铅封完整;
- 整柜报关已完成并放行。
独立拖车状态和独立船期字段提供调度参考,但不是当前确认离仓的硬门禁。拖车在全部订舱柜次离仓后自动完成。
### 13.2 自动与人工确认离仓
报关完成或其他门禁变化时系统自动尝试收敛。未自动完成时,平台在柜次/国内出仓页面:
1. 打开门禁清单;
2. 逐项处理未通过原因;
3. 全部通过后点击确认离仓;
4. 携带当前版本和幂等键提交;
5. 查看唯一离仓历史和第一条实际节点。
成功后系统:
- 写入整柜唯一国内离仓事实;
- 创建 `CarrierScheduleQuery`;
- 写入实际 `DEPARTED_WAREHOUSE` 节点;
- 触发出仓后船节点查询;
- 检查并尝试完成订舱级拖车;
- 保持跨区交接事件可重试且不重复建单。
实际离仓节点产生后禁止撤销。发现错误转入线下和后续阶段异常处理,不提供修改历史伪装未离仓的能力。
## 14. 状态和动作总表
| 对象 | 关键状态 | 推进动作 |
|---|---|---|
| 入库单 | `WAIT_SPLIT → LOADING → LOADED` | 分配/开始 PDA/完成装柜 |
| 装柜计划 | `EDITING → LOADING → COMPLETED` | 预装调整/PDA 作业/最终完成 |
| 订舱 | `DRAFT → RELEASED` | 补齐资料并放舱 |
| 拖车 | 非终态 → `COMPLETED` | 所有要求柜次实际离仓后自动完成 |
| 报关 | 资料收集 → 已冻结办理中 → 已完成/放行 | 客户上传、平台开始、上传回执并完成 |
| 国内出仓 | 等待门禁 → 可出仓 → 已离仓 | 自动尝试或平台确认 |
主状态只表达阶段结果。订舱、拖车、PDA、证据和报关保持关联子状态,页面应汇聚展示,但不能互相覆盖。
## 15. 异常与恢复
| 场景 | 处理方式 |
|---|---|
| 候选显示可用但提交失败 | 以服务端最新占用/版本为准,刷新后重排 |
| 待测量订单已占位 | 可保留计划占位,确认预装前必须到 `WAIT_SPLIT` |
| 一件代发整托容量不足 | 跳过整托或换柜,禁止拆托 |
| 订舱资料不全 | 保持 `DRAFT`,补齐后再放舱 |
| 放舱后资料错误 | 不原地篡改冻结快照,按业务异常处置 |
| 真实柜号暂缺 | 可先关联/确认,但最终装柜前必须补齐 |
| PDA 旧版本 | 刷新计划,不以旧版本覆盖新增明细 |
| 扫描错箱/重复箱 | 拒绝或提示已处理,核对稳定箱码 |
| 未完成项有异常 | 记录原因并解决,禁止完成任务 |
| 照片错误 | 作废并重拍,不硬删除证据 |
| 部分装载 | 一次分片,已装与余量数量守恒 |
| 报关资料缺失 | 定位对应客户/入仓码补件,办理批次不伪造完成 |
| 出仓门禁未齐 | 展示具体阻断项,条件变化后受控重试 |
| 离仓请求超时 | 查询唯一离仓历史/节点后再处理,不重复写事实 |
## 16. 数据流和阶段完成判定
```text
WAIT_SPLIT 入库单/已封托托盘
→ 模拟柜与唯一装柜计划
→ 已放舱订舱 + 真实柜号 + 订舱级拖车
→ 预装确认/动态调整
→ PDA 实装 + 五类证据
→ COMPLETED / LOADED + 数量分片 + 跨区快照
→ 整柜报关放行
→ 国内离仓 + DEPARTED_WAREHOUSE + 船节点查询任务
```
阶段完成必须同时满足:
- [ ] 最终柜次、装柜计划、订舱、真实柜号关联可追溯
- [ ] 全部实装箱/托盘/SKU 与入库单数量守恒
- [ ] PDA 任务完成且无未解决异常
- [ ] 四步照片、铅封照片和铅封号完整有效
- [ ] 装柜计划 `COMPLETED`,实装入库单 `LOADED`
- [ ] 部分装载时已装子单和余量子单正确
- [ ] 整柜报关已完成并放行
- [ ] 所有入库单无 HOLD
- [ ] 唯一国内离仓历史存在
- [ ] 第一条实际 `DEPARTED_WAREHOUSE` 存在
- [ ] 船节点查询任务和跨区事件可幂等重试
## 17. 易用性与数据流评审问题
1. 排柜工作台是否充分解释“可提前占位”和“可实际作业”的差别?
2. 推荐结果是否应逐项展示未入选原因、容量影响和费用影响?
3. 模拟柜、柜次编号、真实柜号、订舱号和提单号是否在页面上容易混淆?
4. 订舱、拖车、预装、PDA、报关是否需要一个柜次级总控清单,而不是跨页面查找?
5. 预装确认后仍可动态增减时,如何提示 PDA 版本已过期并保护未提交草稿?
6. PDA 批量提交需要补齐怎样的原子性、离线/弱网和失败整批回滚验收?
7. 五类证据是否要提供拍摄引导轮廓、清晰度检查和缺图计数?
8. 部分装载分片在 Client 应如何展示,避免客户误以为整票已经全部装柜?
9. 报关资料清单、补件责任人、截止时间和回执匹配是否需要结构化待办?
10. 出仓门禁失败是否需要直接跳转到对应处理页面?
11. 拖车不作为出仓硬门禁的现行口径,是否与现场调度责任一致?
12. 实际离仓不可撤销后,错误柜号或错误时间的合规更正流程由谁负责?
## 18. 阶段四交接包
交给阶段四的权威数据包括:
- 柜次、真实集装箱号、订舱号、提单号、船名和航次;
- 起运港、目的港、ETD/ETA 和运输条款快照;
- 最终实装订单、入仓单、箱码、托盘、SKU、重量和体积;
- 装柜五类证据引用和报关完成事实;
- 国内离仓时间和 `DEPARTED_WAREHOUSE` 实际节点;
- 跨区事件版本、事件 ID 和有效载荷哈希;
- `CarrierScheduleQuery` 及后续船节点查询上下文;
- 未冻结费用和拖车应付等并行财务结果。
## 19. 事实来源
- `docs/国内货柜履约/README.md`
- `docs/国内货柜履约/国内货柜履约页面与原型清单.md`
- `docs/头程下单到尾程出库现状流程全景清单.md`
- `docs/统一订单主状态与操作契约.md`
- `apps/admin-web/src/modules/container-loading/`
- `apps/admin-web/src/modules/loading-lists/`
- `apps/pda-uniapp/AGENTS.md`
- `apps/pda-uniapp/pages/picking/picking.vue`
- `baiyue-firstleg/firstleg/AGENTS.md`
- `baiyue-firstleg/firstleg/src/main/resources/openapi/loading-lists-v1.yaml`
- `baiyue-firstleg/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml`
- `baiyue-firstleg/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml`
- `baiyue-firstleg/firstleg/src/main/resources/openapi/customs-v1.yaml`
- `baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java`
- `baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java`
- `baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java`
@@ -0,0 +1,592 @@
# 阶段二:国内到仓、测量计费与组托
> 对应全景流程:F08~F13。
> 阶段起点:Order 已审核通过,Firstleg 正式国内入库单已创建,统一主状态为 `WAIT_MEASURE`。
> 普通订单终点:测量审核通过,主状态进入 `WAIT_SPLIT`。
> 一件代发终点:测量审核后进入 `WAIT_PALLETIZE`,完成清点、归托和封托后进入 `WAIT_SPLIT`。
> 本文记录当前可执行流程。费用是并行状态,不作为进入 `WAIT_SPLIT` 的前置条件。
## 1. 阶段目标和边界
本阶段要完成七件事:
1. 用稳定入仓码、箱码识别实际到仓货物;
2. 保存设备测量、人工测量、称重图片、OCR 原值和人工修正值;
3. 核对实收箱数、实测尺寸、重量、预计托数和现场证据;
4. 审核并冻结本次有效测量结果;
5. 生成可独立维护、发布和确认的费用版本;
6. 在需要时执行国内扣货或解除,并完整保留证据;
7. 对一件代发订单完成逐箱 SKU 清点、归托和封托。
本阶段不负责模拟柜、排柜、订舱、拖车、装柜、国内出仓、海外入库或尾程出库。普通订单进入 `WAIT_SPLIT` 仅表示已具备后续分柜条件,不表示已入柜、已离仓或已经生成海外库存。
## 2. 角色、终端与权限
| 角色 | 终端 | 主要操作 | 边界 |
|---|---|---|---|
| 国内仓收货/测量员 | Admin、测量设备 | 按入仓码定位、录入或接收尺寸、上传称重图片、确认重量、提交测量 | 不审核自己的权限外任务,不改订单客户归属和原始申报 |
| 国内仓现场主管 | Admin | 复核箱数、尺寸、重量和证据,处理现场差异 | 不直接改费用价格簿和客户账务 |
| 测量审核员 | Admin | 单票或批量审核、驳回;有专项权限时异常放行 | 不通过修改数据库绕过证据或状态门禁 |
| 费用操作员 | Admin | 查看系统试算、调整允许字段、确认并发布费用版本 | 不修改测量原始证据,不越权跨客户操作 |
| 企业费用确认人 | Client | 查看已发送的当前费用版本并确认 | 不能看到未发布版本、其他客户或内部成本 |
| 扣货操作员 | Admin | 发起 HOLD、上传凭证、按规则解除 | 已拣货、装柜或出仓后不能手工扣货/解除 |
| 国内 PDA 操作员 | PDA | 一件代发逐箱清点、扫码归托、封托、规则内解封 | 不创建临时箱码,不直接改测量和费用 |
| Firstleg 服务 | 后端 | 拥有入库单、箱码、测量、审核、费用来源、组托和主流程状态 | 不拥有客户资金主账、公共仓储主档或订单交易主数据 |
| Finance 服务 | 后端 | 接收不可变费用来源,维护后续账务 | 不成为测量、箱码或主流程状态权威 |
页面按钮是否可见只能作为操作提示,服务端仍需校验身份、数据范围、权限、当前状态、版本和幂等键。
## 3. 阶段输入与作业前检查
### 3.1 从阶段一接收的权威数据
| 数据 | 来源 | 本阶段用途 |
|---|---|---|
| 客户、订单号、入仓码 | Order/Firstleg | 识别货主和整票任务 |
| 国内收货仓 | Order 冻结快照、Warehouse 主档 | 限定实际作业仓和资源范围 |
| 渠道、目的类型、目的仓/地址 | Order 冻结快照 | 费用试算和后续分柜参考 |
| 货物 `itemNo`、SKU、申报箱数 | Order 申报快照 | 箱数核对、一件代发清点 |
| 申报尺寸、重量、体积 | Order 申报快照 | 差异对比,不覆盖实测值 |
| 履约类型 | Order/Firstleg | 判断普通订单或 `ONE_CLICK_DROPSHIP` |
| 稳定箱码及箱内 SKU | Firstleg | 测量、PDA 和装柜全过程复用 |
### 3.2 现场开始前检查
- 当前作业仓必须与入库单国内收货仓一致;
- 入仓码必须可查询到唯一正式入库单;
- 箱码必须来自系统已生成的稳定箱码池,不得用临时手写编号替代;
- 订单处于允许采集测量的状态,已提交或已审核的测量批次不可继续修改;
- 测量设备、秤、相机和网络可用;无法自动识别时必须保留图片并走人工确认;
- 一件代发必须能够读取每个物理箱与完整 SKU 的冻结关系;
- 操作员要先区分“申报箱数”和“实际确认箱数”,不得把客户申报直接当作实收事实。
订单尚处于 `PENDING_REVIEW` 时,系统可允许提前采集测量数据,但不能完成测量审核、计价或进入分柜。正式交接仍以订单审核通过和入库单有效为准。
## 4. F08 国内到仓与箱码识别
### 4.1 按入仓码收货定位
入口:Admin → 入库管理/统一入库工作台 → 货品与测量。
操作步骤:
1. 扫描或输入入仓码;
2. 核对订单号、客户、国内仓、履约类型和申报箱数;
3. 查看该票稳定箱码是否已经生成;
4. 按实际到货逐箱核对外包装上的箱码;
5. 对缺码、重码、无法识别或与本票不匹配的箱子先隔离,不直接进入测量;
6. 确认现场货物与页面显示为同一客户、同一入仓码后再开始采集。
系统中的“正式到仓”不是靠操作员另造一个新业务编号完成。入仓码负责整票定位,箱码负责物理箱身份,后续设备、人工测量、PDA 组托和装柜继续复用相同映射。
### 4.2 普通订单箱码
- 每个物理箱使用稳定 `boxCode`;
- 箱码可以由客户或平台下载打印,但是否打印不影响系统中箱码的有效性;
- 测量记录必须关联稳定箱码或明确的箱规数量,不能只写自由文本;
- 后续装柜完成时按最终实装事实冻结并向下游传递箱码/箱数。
### 4.3 一件代发箱码
- `fl_inbound_carton_label` 表示物理箱;
- 箱内一条或多条 SKU 关系在箱码生成时冻结;
- 标签展示箱内 SKU,但二维码/条码仍只携带稳定箱码;
- 缺少 SKU 的货物不能生成合格的一件代发箱唛;
- PDA 清点时扫描这个箱码读取整箱 SKU,不重新手工创建商品明细。
### 4.4 到仓差异登记
| 差异 | 现场处理 | 系统处理原则 |
|---|---|---|
| 少箱/分批到货 | 标识未到箱,暂不虚增实收 | 测量总箱数必须基于实际确认结果 |
| 多箱 | 隔离并查明所属 | 未建立合法箱码关系前不得并入本票 |
| 箱码破损 | 用入仓码和订单信息核验后补打原箱码 | 不生成第二个物理身份 |
| 箱码属于其他订单 | 隔离并通知主管 | 禁止强行绑定当前入仓单 |
| 外包装异常 | 拍照、备注并按异常规则处理 | 证据与业务事实关联,不能只在线下沟通 |
## 5. F09 尺寸测量与称重
### 5.1 页面和数据组成
入口:Admin → 入库管理 → 货品与测量。
一次完整测量至少包含:
- 测量批次:入库单、批次状态、采集来源、提交人和时间;
- 测量明细:箱码/箱规、长、宽、高、箱数、体积、设备或人工来源;
- 重量结果:称重图片、OCR 原值、采用值、毛重/净重、确认状态;
- 辅助信息:预计托数、测量时间、设备号/事件号、备注和差异原因;
- 审计字段:创建人、修改人、版本、提交与审核历史。
客户申报值、设备原始值、OCR 原值、人工修正值和最终计费采用值必须并行保留,不能用最终值覆盖原始证据。
### 5.2 设备自动测量
第三方设备按接口提交入仓码或箱码、长宽高、数量、设备标识和事件号。
操作要求:
1. 先在设备端确认扫描的是当前物理箱;
2. 设备上传后在 Admin 核对是否归入正确入仓单;
3. 核对尺寸单位、箱数和设备时间;
4. 重复回调使用同一事件号幂等处理,不生成重复测量;
5. 设备数据异常时保留原值,另行人工修正,不删除原始记录。
单次批量测量明细最多 500 条。超过时应按现场批次拆分,而不是截断数据。
### 5.3 人工补录尺寸
适用场景:设备不可用、个别箱复核、非标货或设备数据需要纠正。
操作步骤:
1. 点击新增/编辑测量明细;
2. 选择稳定箱码或填写明确箱规及对应箱数;
3. 填写长、宽、高,三项都必须大于 0;
4. 填写测量时间;
5. 上传必要的测量图片,单个文件不得超过页面限制(当前为 5 MB);
6. 写明人工补录或修正原因;
7. 保存后重新核对明细箱数合计。
人工补录可以替换可编辑批次中的业务测量项,但不得抹掉同一物理箱已经冻结的扫码身份、设备事件和原始取证。
### 5.4 称重图片与 OCR
操作步骤:
1. 上传能够清晰看到秤面读数和货物关联的称重图片;
2. 等待 OCR 返回识别值;
3. 将 OCR 原值与图片读数比对;
4. 正确时确认采用;不正确时填写人工修正值和原因;
5. 确认单位统一换算为系统采用单位;
6. 将每张有效图片设置为已确认;
7. 确保最终总重量大于 0。
即使整票只有 1 箱,也至少需要 1 张已确认重量图片。真实 OCR 凭证未配置或识别失败时,可以保留失败结果后人工确认;这是一条有证据的降级路径,不等于允许无图录重量。
每张有效重量图片只能计重一次。重复上传、同图重复确认或同一重量被不同图片重复累计时,必须先消除重复再提交。
### 5.5 保存前与提交前校验
点击“保存测量数据”时保存当前可编辑批次;点击“提交审核”前至少检查:
- 存在至少一条有效测量明细;
- 所有长宽高、箱数、时间和必要附件合法;
- 测量明细确认箱数合计等于整票实际确认总箱数;
- 至少有一张已确认重量图片,全部有效图片均已处理;
- 最终总重量大于 0;
- 预计托数为合法正数或满足页面当前规则;
- 页面没有尚未保存的修改;
- 当前批次没有被其他人提交或审核;
- 订单已经审核通过,具备提交审核资格。
系统尝试依据已确认重量和提交动作建立整票连续库位占用/预留。资源不足等非致命问题应显示明确警告并由仓库处理;不得把警告伪装成已完成物理入库,也不得让位置建议覆盖测量事实。
## 6. F10 测量审核与初始计价
### 6.1 审核入口
入口:Admin → 入库管理 → 测量审核。
审核员按单票或批量打开待审任务,核对:
- 客户、入仓码、订单号和国内仓;
- 申报箱数与实际确认箱数差异;
- 每组箱规的长宽高、数量和体积;
- 称重图片、OCR 原值、人工采用值和修正原因;
- 总重量、总体积和预计托数;
- 客户尺寸规则或价格簿所需维度;
- 是否存在重复图片、重复箱码、缺失证据或异常备注。
### 6.2 审核通过
点击审核通过后,服务端在同一受控流程中:
1. 再次校验当前状态、批次版本和证据完整性;
2. 冻结本次有效测量结果;
3. 计算或准备初始费用版本;
4. 普通订单主状态进入 `WAIT_SPLIT`;
5. 一件代发主状态进入 `WAIT_PALLETIZE`;
6. 写入审核人、审核时间和状态历史。
初始计价失败或产生警告时,不应回滚已经合法完成的测量审核,也不能把普通订单从 `WAIT_SPLIT` 拉回旧的计费状态。页面需要明确展示“测量已通过、计价待处理”这两个独立结果。
批量审核时,每票都必须返回独立结果。部分失败不能让操作员误以为整批全部成功;失败项按入仓码展示原因并保留在待处理列表。
### 6.3 审核驳回
发现数据或证据不合格时填写明确原因并驳回:
- 驳回原因应说明需要修正的字段或证据;
- 原测量批次和审核历史保留,不原地覆盖成新记录;
- 操作员按原因修正允许编辑的数据后重新提交;
- 历史 `MEASURE_REJECTED` 只作为事实和恢复入口,不代表可以绕过审核直接分柜。
### 6.4 异常放行
异常放行只适用于已驳回测量,且必须同时满足:
- 环境已开启对应开关;
- 当前用户具备专项权限;
- 被放行批次确实处于已驳回状态;
- 填写非空放行原因;
- 完整记录操作人、时间、原驳回原因和放行原因。
异常放行不能变成常规“跳过审核”按钮,也不能补造不存在的称重或测量证据。
## 7. F11 费用生成、发布与客户确认
### 7.1 费用与主流程的关系
费用是与主状态并行的业务维度:
```text
测量审核通过 ──→ 主流程:WAIT_SPLIT(普通)/ WAIT_PALLETIZE(一件代发)
└──────→ 费用:初算 → 人工维护 → 确认/发布 → 客户确认 → 条件满足后冻结
```
因此:
- 费用未发送不阻止普通订单进入 `WAIT_SPLIT`;
- 客户未确认费用不把主流程回退;
- 装柜完成也不能代替客户自动确认费用;
- 费用的最终冻结需“客户已确认”和“本票已装柜完成”同时成立。
### 7.2 系统初算
入口:Admin → 入库管理 → 费用。
系统基于已审核测量结果和有效价格簿生成版本化费用,操作员应检查:
- 计费采用的是哪一组实测重量、体积和计费量;
- 命中的渠道、目的地区域和价格簿版本;
- 是否命中客户专属 ACTIVE 价格簿;未命中时才回退公共 ACTIVE 价格簿;
- 计价模型是重量、体积、仓库费率、区域重量阶梯或其他当前有效模型;
- 基础费用、附加费、币种、税费或调整项是否完整;
- 系统警告是否说明缺价格、缺映射或数据不满足模型。
同柜区域重量阶梯可能在阶段三根据同柜、同客户的最终有效成员聚合后重算;固定模型成员不参与阶梯聚合。此处初算是当前版本事实,不承诺后续永不变化。
### 7.3 人工调整
允许调整时:
1. 选择当前费用版本;
2. 查看系统基准量、基准单价和基准金额;
3. 在服务端允许的范围内修改;当前规则以系统基准量为中心限制上下 10%,含边界;
4. 填写调整原因;
5. 完成二次确认;
6. 保存为新版本,由服务端重新计算金额。
页面不能只提交最终总额来绕开计费规则。版本、基准、调整值、原因、操作人和时间都要可追溯。
### 7.4 平台确认与发送
操作员确认费用明细无误后执行页面上的确认/发送操作:
1. 复核客户、入仓码、币种和当前版本;
2. 确认没有待处理计价警告;
3. 发布当前费用明细;
4. 将费用状态置为客户可见的 `SENT`;
5. 记录发送人、发送时间和版本。
“发送费用”表示发布当前费用版本供客户查看,不等于已经收款、核销或形成最终财务结算。
### 7.5 客户查看和确认
入口:Client → 头程订单/入库费用。
客户只有在以下条件同时满足时才能看到费用:
- 费用属于当前登录客户;
- 测量已经审核通过;
- `feeStatus=SENT`;
- 存在已发布明细。
客户确认时携带当前 `expectedFeeVersion` 和幂等键。成功后记录确认成员和时间。
若装柜完成前平台生成并重新发送新版本,旧版本客户确认快照被清空,客户需要确认新版本。旧版本、未发送版本或同一幂等键携带不同参数都必须冲突失败,不能静默覆盖。
## 8. F12 国内扣货与解除
### 8.1 适用目的
HOLD 用于在存在费用、资料、风控、货损或其他明确风险时阻断后续物理履约写入。它不是删除订单,也不修改客户和测量事实。
### 8.2 发起扣货
入口:Admin → 统一订单/入库详情 → 扣货。
操作步骤:
1. 确认当前入仓单尚未发生不允许回退的实物动作;
2. 选择或填写扣货原因;
3. 上传有效凭证;
4. 核对入仓码、客户和当前版本;
5. 二次确认后提交;
6. 查看 HOLD 状态、操作人和时间线是否写入。
扣货生效后,分柜、拣货、装柜、出仓等后续物理写操作必须由服务端阻断,不能只在前端隐藏按钮。
### 8.3 解除扣货
1. 打开当前有效 HOLD;
2. 核对风险是否已经消除;
3. 填写解除原因并补充凭证;
4. 携带当前版本和幂等键提交;
5. 系统关闭当前 HOLD,但保留发起与解除历史;
6. 重新评估主状态允许的下一步动作。
已经发生拣货、装柜或出仓时,禁止通过手工扣货/解除改变已发生的物理事实。此时应进入对应阶段的异常流程。
## 9. F13 一件代发国内清点组托
### 9.1 进入条件
一件代发测量审核通过后不直接进入 `WAIT_SPLIT`,而是:
- 主状态为 `WAIT_PALLETIZE`;
- 按预计托数预生成 `DP` 托盘码;
- 箱码与箱内 SKU 已冻结且可被 PDA 读取;
- 不创建国内库存;
- 组托完成后才交给分柜。
预计托数与实际已生成托盘数不一致时禁止开始作业,应先修正任务或重新生成合法托盘,不允许 PDA 临时造托盘码。
### 9.2 Admin 打印托盘信息单
入口:Admin → 一件代发组托任务。
1. 打开入仓码对应任务;
2. 核对预计托数和已生成 `DP` 托盘列表;
3. 逐托打印 A4 信息单,每托一页;
4. 核对 Code 128 托盘码、入仓码、总箱数和 SKU 清单;
5. 将标签贴到对应空托或托盘作业位。
信息单用于识别和作业,不展示容易过期的实时作业状态。实际状态以 PDA/服务端为准。
### 9.3 PDA 逐箱清点与归托
入口:国内 PDA → 一件代发清点组托。
允许两种顺序:
```text
先扫托盘 → 连续扫箱并清点 → 确认归托
先扫箱并清点 → 扫目标托盘 → 确认归托
```
逐箱操作:
1. 扫描稳定箱码;
2. PDA 展示整箱 SKU、商品名称和应点数量;
3. 按完整 SKU 清点实点数量;
4. 有差异时填写原因并按现场异常规则处理,不能直接改冻结 SKU;
5. 扫描目标 `DP` 托盘码;
6. 确认该箱归托;
7. 继续下一箱,直到本票全部箱处理完成。
同仓多票在当前规则允许时可以共用一个开放托盘。共托不会改变箱子所属客户、订单、入仓码和 SKU;系统必须保存逐箱归属,不能只保存托盘总数量。
### 9.4 封托与解封
封托前检查:
- 托盘上所有箱均已完成 SKU 清点;
- 物理箱数与系统箱数一致;
- 托盘标签清晰且与 PDA 当前托盘一致;
- 没有未处理差异或重复归托;
- 共托场景下各参与任务的数据均完整。
操作员在 PDA 点击封托。封托后该托成为阶段三的整托候选。
未进入装柜计划前可按权限和规则解封。共托解封时,所有参与任务和入仓单的相关状态必须同步恢复,不能只恢复其中一票。托盘进入有效装柜计划后不得在本阶段任意解封。
### 9.5 组托完成判定
系统只有在以下条件同时满足时将一件代发入仓单从 `WAIT_PALLETIZE` 推进到 `WAIT_SPLIT`:
- 本票所有稳定箱码均已清点;
- 每箱完整 SKU 均已核对;
- 每箱恰好归属一个有效参与托盘;
- 本票实际参与的所有托盘均已封托;
- 没有开放托盘仍包含本票箱子;
- 箱数、SKU 数量和托盘冻结快照核平。
预计生成但实际未使用的空托盘不应伪造为已作业托盘;最终事实以实际参与且完成的托盘为准。
## 10. 状态机与可用动作
### 10.1 主流程状态
| 当前状态 | 条件/操作 | 下一状态 | 说明 |
|---|---|---|---|
| `PENDING_REVIEW` | 可提前采集测量 | 不变 | 不能审核测量、计价或分柜 |
| `WAIT_MEASURE` | 保存测量 | 不变 | 可反复保存未提交批次 |
| `WAIT_MEASURE` | 提交测量审核 | 不变/待审维度变化 | 已提交批次冻结 |
| `WAIT_MEASURE` | 审核驳回 | 驳回事实 | 修正后重新提交 |
| `WAIT_MEASURE` | 普通订单审核通过 | `WAIT_SPLIT` | 费用并行处理 |
| `WAIT_MEASURE` | 一件代发审核通过 | `WAIT_PALLETIZE` | 必须先完成清点组托 |
| `WAIT_PALLETIZE` | 全部箱和实际托盘完成 | `WAIT_SPLIT` | 不创建国内库存 |
| `WAIT_SPLIT` | 合法解封导致组托不再完整 | `WAIT_PALLETIZE` | 仅限未进入装柜计划等允许条件 |
测量批次状态、审核状态、费用状态、HOLD 状态和托盘状态是独立维度,不能把它们拼成一条虚假的单状态流水线。
### 10.2 费用状态关键约束
| 费用事实 | 平台可修改 | 客户可见/确认 | 最终冻结 |
|---|---|---|---|
| 未生成/初算警告 | 是 | 否 | 否 |
| 已生成、未发送 | 是 | 否 | 否 |
| `SENT`、客户未确认 | 是,可生成新版本 | 可见、可确认 | 否 |
| 客户已确认、尚未装柜完成 | 仍可按规则生成新版本并重新发送 | 新版本需重新确认 | 否 |
| 客户已确认且装柜完成 | 否 | 已确认 | 是 |
### 10.3 HOLD 的正交关系
HOLD 不必改变主状态文字,但会阻断后续物理履约动作。解除后继续从原主状态判断下一步,不自动跳过测量、组托或分柜条件。
## 11. 数据归属与流转
### 11.1 关键数据所有者
| 数据 | 权威系统 | 下游消费者 |
|---|---|---|
| 客户申报、订单交易状态 | Order | Firstleg、页面查询 |
| 入库单、入仓码映射 | Firstleg | Admin、设备、PDA、后续装柜 |
| 稳定箱码、箱内 SKU | Firstleg | 测量、PDA、装柜事件、尾程 |
| 公共仓库、库位、托盘资源 | Warehouse | Firstleg 作业引用 |
| 测量原始值、人工采用值、证据 | Firstleg | 计价、审核、后续审计 |
| 费用来源和版本 | Firstleg | Client、Finance |
| 应收应付、收付款、核销 | Finance | 财务终端与对账 |
| 文件对象 | File | 各业务通过合法文件引用使用 |
### 11.2 数据流
```text
Order 审核快照
↓
Firstleg 正式入库单 + 稳定箱码
↓
设备/人工尺寸 + 称重图片/OCR + 人工确认
↓
测量审核冻结 ──────────────→ 主流程 WAIT_SPLIT / WAIT_PALLETIZE
├→ 初始费用版本 → 平台维护/发送 → 客户确认 → Finance 来源
├→ 仓位连续占用/预留尝试 → Warehouse 资源
└→ 一件代发 PDA 逐箱清点 → DP 托盘冻结 → WAIT_SPLIT
```
### 11.3 必须保留的快照和历史
- 原始客户申报与最终实测同时存在;
- 设备事件号、设备原值、人工修正值和原因;
- 原始称重图片、OCR 原值、采用值和确认人;
- 每次测量提交、驳回、通过和异常放行;
- 每个费用版本、价格簿来源、系统基准、人工调整和客户确认;
- 每次 HOLD 发起、证据、解除和版本;
- 一件代发每箱 SKU 实点、归托、封托和解封历史。
## 12. 正常分支、异常与恢复
| 场景 | 系统行为 | 操作员处理 |
|---|---|---|
| 订单未审核但货先到 | 允许按当前规则采集,禁止审核/计价/分柜 | 保留测量草稿,等待订单审核 |
| 查不到入仓码 | 不创建临时正式入库单 | 隔离货物,核对订单提交/审核结果 |
| 箱码破损 | 原身份仍有效 | 核验后补打相同箱码 |
| 设备重复上报 | 按事件号幂等,不重复计量 | 核对已有记录,不换事件号盲重试 |
| 设备值异常 | 原值保留 | 人工复测并写修正原因 |
| OCR 失败 | 失败结果和图片保留 | 人工读取、录入并确认 |
| 重量图片重复 | 禁止重复计重 | 删除/作废重复采用关系后重算 |
| 明细箱数不等于实收总箱数 | 禁止提交 | 查找漏测、重复测量或到货差异 |
| 预计托数无效 | 禁止提交或组托 | 仓库重新评估并保存 |
| 页面有未保存修改 | 禁止提交审核 | 先保存再提交 |
| 测量被并发提交 | 版本冲突 | 刷新后查看权威结果 |
| 审核通过但计价失败 | 主状态按审核结果推进,费用显示待处理 | 修复价格簿/映射后重算,不回退测量 |
| 费用版本过期 | 客户确认冲突 | 刷新并确认当前已发送版本 |
| HOLD 生效 | 后续实物写入失败关闭 | 处理风险后按证据解除 |
| 一件代发箱内 SKU 不符 | 不直接改冻结清单 | 记录差异并由有权流程纠正来源 |
| 开放共托仍含本票箱子 | 本票不能完成组托 | 完成或按规则拆解参与托盘 |
| 封托后需调整 | 仅未进入装柜计划时可解封 | 有权解封、修正、重新封托 |
| PDA 请求超时 | 不确定是否成功 | 先查询箱/托当前状态,再用原幂等语义处理 |
## 13. 阶段完成判定
### 13.1 普通订单
- [ ] 入仓码、订单、客户和国内仓对应正确
- [ ] 稳定箱码和实收箱数可追溯
- [ ] 测量明细箱数与实际确认总箱数一致
- [ ] 尺寸、重量、图片和人工修正证据完整
- [ ] 至少一张重量图片已确认且总重量大于 0
- [ ] 测量批次已审核通过并冻结
- [ ] 主状态为 `WAIT_SPLIT`
- [ ] 初始费用已生成,或计价警告已作为独立待办暴露
- [ ] 若存在 HOLD,下一阶段明确知道当前不可继续物理履约
- [ ] 重复提交和重复审核不会产生重复事实
### 13.2 一件代发订单
除普通测量条件外,还必须满足:
- [ ] 每个物理箱和完整 SKU 来源可追溯
- [ ] 每箱均已完成 SKU 清点
- [ ] 每箱恰好归属一个实际参与托盘
- [ ] 所有实际参与托盘均已封托
- [ ] 共托参与关系和逐箱归属完整
- [ ] 最终托盘冻结快照存在
- [ ] 主状态已从 `WAIT_PALLETIZE` 进入 `WAIT_SPLIT`
- [ ] 未因组托创建国内库存
费用是否已经发送、客户是否已经确认,不作为本阶段主流程交接到阶段三的硬门槛;但必须作为并行待办清晰展示和持续跟踪。
## 14. 易用性与数据流评审问题
以下问题先记录,后续优化阶段逐项决策:
1. 到仓页是否需要独立的“实收登记”动作和时间戳,还是继续由首次合法测量/扫码体现到仓?
2. 订单未审核时允许提前测量,页面应如何醒目标识“仅采集、不可审核”,避免仓库误判流程已放行?
3. 箱数差异是否需要结构化的少箱、多箱、破损和错货原因,而不是仅填备注?
4. 真实 OCR 供应商凭证、现场秤图片式样和识别准确率应由谁验收?
5. 连续库位预留失败目前作为警告时,仓库如何看到并闭环处理,是否需要独立待办?
6. “发送费用”是否应改名为“发布给客户”,以避免被误解为发送账单或已完成结算?
7. 计价失败但主流程已进入 `WAIT_SPLIT` 时,是否需要跨页面的费用红点、责任人和超时提醒?
8. HOLD 的发起责任、审批规则、证据类型和解除责任是否已经形成可审计 SOP?
9. 一件代发允许跨订单共托时,业务是否确认允许跨客户;若允许,拆托、异常和费用责任如何分摊?
10. PDA 清点发现 SKU 差异时,目前应走哪个权威纠错入口,谁能修改冻结来源并触发重新打印?
11. 预计托数与实际托数变化时,是否需要保留预测偏差指标,为仓容和装柜优化提供数据?
12. 客户费用确认与装柜完成共同冻结的规则,页面是否充分解释“已确认但尚未冻结”。
## 15. 阶段三交接包
交给阶段三“模拟柜、排柜、订舱、拖车、预装、装柜与出仓”的权威输入包括:
- 入仓单、订单号、入仓码、客户和国内仓;
- 渠道、目的类型、目的仓/地址快照;
- 已审核的最终箱数、重量、体积和箱规;
- 稳定箱码及必要的箱内 SKU 关系;
- 普通订单预计托数和库位占用/预留结果;
- 一件代发实际封托的 `DP` 托盘、逐箱归属和 SKU 冻结快照;
- 当前主状态 `WAIT_SPLIT`;
- 当前有效 HOLD 及阻断原因;
- 费用当前版本、发布/客户确认状态和未处理计价警告。
阶段三可以基于最终装柜成员重算尚未冻结的费用,但不得覆盖本阶段的原始测量、证据或一件代发逐箱组托事实。
## 16. 事实来源
- `docs/头程下单到尾程出库现状流程全景清单.md`
- `docs/入库测量与计费/README.md`
- `docs/入库测量与计费/入库测量与计费需求优化版-v2-测量计费入库主流程.md`
- `docs/统一订单主状态与操作契约.md`
- `apps/admin-web/AGENTS.md`
- `apps/admin-web/src/modules/inbounds/measurement/InboundMeasurementDialog.vue`
- `apps/admin-web/src/modules/inbounds/measurement/InboundMeasurementPanel.vue`
- `apps/admin-web/src/modules/inbounds/fee/InboundFeePanel.vue`
- `apps/admin-web/src/modules/inbounds/components/InboundWorkflowWorkspace.vue`
- `apps/admin-web/src/modules/inbounds/components/InboundTaskList.vue`
- `baiyue-firstleg/firstleg/AGENTS.md`
- `baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java`
- `baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java`
- `baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/InitialInboundPricingService.java`
- `baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java`
- `baiyue-firstleg/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml`
@@ -0,0 +1,405 @@
# 阶段五:海外接柜、库存与库内服务
> 对应全景流程:F28~F33。
> 阶段起点:实际船节点达到 `PICKUP_READY`,整柜尾程计划和海外托盘/库位资源已经就绪。
> 主链终点:整柜收货完成,库存同事务入账,尾程主状态为 `INBOUND_COMPLETED`。
> 并行范围:库存查询、托盘作业、暂扣、换标、仓储转运和货物废弃。
## 1. 阶段目标和边界
本阶段把海运在途货物转化为海外仓可核对库存:
1. 海外 PDA 操作员按真实柜号认领待卸货柜;
2. 核对国内装柜证据并拍摄海外签收三类照片;
3. 按 FBA 散货、OVERSEAS 逐箱和一件代发整托三种模式实收;
4. 整柜核平后一次完成计划、任务、库存和卸柜事件;
5. 提供库存、位置、流水、托盘以及已开放的库内作业;
6. 对单票执行暂扣/解除;
7. 处理换标、仓储转运和一件代发货物废弃。
本阶段不负责海外配送排车、出库发车和最终派送。入仓完成只是后续履约资格之一;有效暂扣、换标未完成或库存被其他子单锁定时仍不能出库。
## 2. 角色、终端和权限
| 角色 | 终端 | 主要操作 | 边界 |
|---|---|---|---|
| 海外仓 PDA 操作员 | 海外 PDA | 认领柜、拍照签收、实收、完成整柜、库内作业 | 需 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 和 `lastleg:pda:operate`,仓库由 Token 固定 |
| 海外仓主管 | Admin | 查询入仓、库存、托盘、异常,处理授权的库存/暂扣操作 | 不用内部 ID 修改他仓资源 |
| 企业操作员 | Client | 查看本企业库存,发起换标、转运、废弃等自助服务 | 不查看其他客户或直接改实物托盘 |
| 换标作业员 | 海外 PDA | 认领、扫描旧/新标、拍照、提交复核 | 不自行批准复核或客户确认 |
| 换标复核员 | Admin | 退回返工、核对作业事实 | 已由客户确认后不能退回 |
| 废弃审核员 | Admin/PDA | 审批一件代发废弃并触发实际扣减 | 创建申请时不得直接扣库存 |
| Lastleg | 后端 | 入仓作业、库存业务、子单、状态和事件权威 | 不拥有物理仓/托盘主档 |
| Warehouse Overseas | 后端 | 海外库位、实物托盘、资源锁和物理状态 | 不拥有库存客户归属和尾程订单 |
平台管理员和普通客户账号不能进入海外 PDA 柜号签收入口。PDA 请求不接收 `customerId` 或可切换的 `warehouseId`;客户和仓库范围由柜内计划、入仓码和登录 Token 解析。
## 3. 作业前检查
- 操作员账号已绑定唯一 `ACTIVE + OVERSEAS + RECEIVING` 海外仓;
- 换仓或清空绑定后旧 Token 已失效;
- 实际节点达到 `PICKUP_READY` 或更晚;
- 柜内每票订单为 `INBOUND_PLANNED`;
- 所有计划已经绑定真实托盘、库位和资源锁;
- `resourcePendingPlanCount=0`;
- 真实柜号、来源柜聚合键、预计到仓和入仓码完整;
- 普通货计划箱数、一件代发来源托盘/SKU/箱唛快照可核对;
- PDA 网络、相机和扫码能力正常。
只要任一计划仍缺真实资源,就不能准备、签收或开始该柜。PDA 首次扫码只确认计划阶段已预留资源,不在现场重新分配托盘或库位。
## 4. F28 海外 PDA 柜号签收
### 4.1 查看待卸货柜
入口:海外 PDA → 拆柜收货。
候选按物理柜跨客户聚合,仅返回达到 `PICKUP_READY` 且计划资源完整的柜。列表使用真实集装箱号、预计到仓和接柜仓,不显示内部柜次 ID。
操作员选择柜后:
1. 系统按平台账号和柜号创建/恢复唯一未完成会话;
2. 柜被原子认领到当前 Token 固定仓库;
3. PDA 锁定当前柜,不允许随意切换;
4. 展开查看柜内全部入仓码、客户隔离任务、目的仓、计划托数和箱数;
5. 核对当前仓库与现场一致。
柜级认领允许同柜多客户,但后续每票任务、扫码、库存和流水仍使用入仓码所属的真实客户。
### 4.2 查看国内装柜照片
签收前展开国内五类只读证据:空柜、装载过程、接近完成、满柜/关门前、带铅封号照片。
- 证据仅用于现场对照,不允许 PDA 修改;
- 单张图片加载失败时提供重试;
- 单图失败不阻断拆柜,但应保留加载异常;
- 现场发现柜号、铅封或外观明显不符时,应先走异常处理而不是盲目签收。
### 4.3 拍摄三类签收照片
依次现场拍摄并上传:
1. `LICENSE_PLATE`:承运车辆车牌;
2. `CONTAINER_SEAL`:开封前柜门和铅封;
3. `DOOR_OPEN`:开柜现场。
每类固定一张当前有效照片,允许在上传成功后更换。更换失败保留原照片;预览走鉴权下载,不持久化设备临时路径。三类照片全部齐全后才能确认签收。
### 4.4 确认签收
点击确认后,服务端复核柜号、仓库、会话、照片和版本,记录签收人和时间并进入收货。首次开始收货的 `receiving_at` 是整柜唯一实际到仓时间,回传 Admin 清关提柜列表;不能用预约或预计时间代替。
## 5. F29 三类实收操作
### 5.1 通用规则
每次实收都必须:
- 使用当前柜会话;
- 由业务箱码、入仓码或来源托盘定位唯一计划;
- 扫描计划已绑定的实物托盘码;
- 使用稳定幂等键和当前版本;
- 不超过该计划待收数量;
- 写入当前客户、仓库、目的仓和库存来源;
- 返回最新已收、待收和异常信息。
`assigned/mergeAllowed/urgent/mergeHint` 只消费服务端结果。可合托只是现场选择,不改变收货资格,也不是整柜完成门禁。
### 5.2 FBA 散货批量收货
1. 在柜内选择目标入仓码/目的仓分组;
2. 扫描冻结 SKU 或用于定位分组的业务码;
3. 扫描计划绑定的实物托盘码;
4. 输入本次放入托盘的箱数;
5. 核对本次、累计和剩余箱数;
6. 提交 bulk 收货;
7. 继续下一托或下一入仓码。
FBA bulk 不提交 `skuCode` 作为库存归属,服务端按计划明细分配。首次扫业务码只选择分组,不自动多收一箱。超收返回冲突且库存不变化。
### 5.3 OVERSEAS 逐箱收货
1. 扫描国内最终实装的稳定箱唛;
2. 服务端自动解析唯一入仓码和计划;
3. 扫描目标实物托盘;
4. 核对箱码、目的模式和托盘兼容性;
5. 确认单箱收货;
6. 已收箱再次扫描必须冲突,不重复入账。
OVERSEAS 必须使用跨区冻结的逐箱事实,禁止改为手工输入批量箱数。
### 5.4 一件代发整托收货
1. 选择柜内一件代发计划;
2. 扫描国内冻结的来源 `DP` 托盘码;
3. 查看该托盘所属入仓码、箱数、SKU 和箱唛;
4. 扫描海外计划绑定的实物托盘;
5. 使用 `/current/receipts/pallet` 整托确认;
6. 服务端按来源托盘原子核平全部箱件和 SKU。
禁止逐箱拆收、改写国内托盘成员或按商品名称猜配。一个国内来源托盘失败时整托不落账。
### 5.5 独立收货与合托
- 独立收货:扫描计划分配的新托盘;
- 合托:仅在 `mergeAllowed=true` 时,扫描同目的仓已使用托盘;
- 合托后同一实物托盘可以包含多张入仓单,但每票组成数量独立保存;
- 单票全部收完不要求因“可合托”再执行一次合托动作。
## 6. F30 整柜完成、库存入账与国内回写
### 6.1 完成前检查
点击完成整柜前核对:
- 柜内所有计划 `pendingPackages=0`;
- FBA 汇总数量、OVERSEAS 箱码和一件代发来源托盘均核平;
- 每个库存组成已绑定真实托盘和库位;
- 三类签收照片存在;
- 通过 `/unload-photos` 保存了非空且不重复的卸柜照片文件 ID;
- 没有结果不确定的在途命令或版本冲突。
### 6.2 原子完成
服务端在同一事务内:
1. 完成每票 PDA 任务和入仓计划;
2. 将尾程主状态推进为 `INBOUND_COMPLETED`;
3. 将 `FIRST_LEG` 在途数量转为海外仓可用/对应状态库存;
4. 写库存余额;
5. 写按托盘和入仓单拆分的库存位置;
6. 写进销存流水和实收快照;
7. 更新托盘组成;
8. 创建唯一 `OVERSEAS_CONTAINER_UNLOADED` V1 Outbox。
任一步失败时不能只完成状态、不入库存,或只入库存、不完成任务。相同完成请求重放返回既有结果,不重复增加库存。
### 6.3 国内回写
事件绑定来源头程柜次身份,由服务端冻结,不接受 PDA 拼装。Firstleg 消费后:
- 记录卸柜完成时间;
- 记录照片上传/完成事实;
- 计算预计还空时间;正式业务以卸柜完成后 72 小时为准;
- 更新目的港码头拖车的还空预警;
- 以事件 ID、哈希和聚合版本防重与防乱序。
此事件不代表已经还柜;实际还柜另行推进 `EMPTY_RETURNED`。
## 7. F31 库存、盘点、移库和托盘
### 7.1 Admin 库存查询
入口:Admin → 海外仓 → 库存管理。
核对维度:客户、实际海外仓、目的仓、入仓单、可用/冻结/占用箱数、SKU 展示、实物托盘和库位。余额唯一业务维度不以托盘为归属;多 SKU 可显示 `MIXED`,完整 SKU 保存在箱唛、实收和流水快照。
列表、详情、汇总和 CSV 必须使用同一权限范围,并默认排除所有数量桶均为零的维度。
### 7.2 托盘与位置
入口:Admin → 海外仓 → 托盘管理。
- 实物托盘和库位是平台公共资源,`customer_id=0`;
- 资源锁保存真实业务客户范围;
- 同一托盘可包含多票,每票箱数保存在库存位置组成;
- 托盘是 PDA 出库作业单位,不是库存所有权单位;
- 普通入仓只证明某票有多少箱在该托盘,不证明每个箱唛精确位于哪个托盘;
- 标签只按真实托盘编码补打,不重新生成编号。
### 7.3 PDA 现场查询
入口:海外 PDA → 库存/库位/托盘查询。
扫描稳定业务码后查看服务端最新事实,只展示箱码、托盘码、库位码、仓库名称等业务标识,不以内建 ID 回退。
### 7.4 商品移库
入口:海外 PDA → 商品移库。
1. 扫描来源托盘;
2. 查看组成和当前版本;
3. 扫描同仓目标库位;
4. 核对目标可用;
5. 提交整托移库;
6. 服务端原子释放源真实库位并占用目标库位;
7. 409 冲突后重新扫描,不能用旧版本重试覆盖。
虚拟库位保持 `ACTIVE`;真实库位与托盘同步 `IDLE/OCCUPIED`。
### 7.5 托盘组、拆、换、解绑
PDA 扫描托盘后按服务端返回的 `palletVersion`、`positionVersion` 执行。每次操作必须核对库存组成并使用稳定幂等键;跨服务业务失败走显式补偿,Lastleg 不直接修改 Warehouse Overseas 的 `wh_` 表。
### 7.6 库存盘点
盘点底层支持创建、恢复、计数、提交、幂等和版本冲突处理;当前海外 PDA 工作台不开放入口,因此不能宣称已完成现场页面验收。需要盘点时以正式开放入口、权限 `lastleg:inventory:adjust` 和最终库存流水为准。
## 8. F32 暂扣与解除
### 8.1 新增暂扣
只允许尾程订单已经 `INBOUND_COMPLETED` 且尚未进入出库流程时新增:
1. Admin 打开尾程订单;
2. 核对当前库存和后续占用;
3. 填写暂扣原因和必要凭证;
4. 携带订单版本和幂等键提交;
5. 写不可变历史并发布暂扣事件。
暂扣只作用于本票,阻断该票排车和出库;不影响同柜其他客户/订单,也不回退入仓计划、实收、库存或主状态。
### 8.2 解除暂扣
风险解除后填写原因并提交当前版本。解除不受“尚未进入出库”这一新增条件限制,但不能抹掉已发生的历史或释放其他子单仍合法持有的库存锁。
## 9. F33 换标、仓储转运和废弃
### 9.1 换标申请
入口:Client → 海外仓服务 → 换标。
1. 客户从本企业可用库存选择目标箱/数量;
2. 选择换标类型:SKU、箱唛或混合;
3. 填写旧标、新标和数量;
4. 提交后系统冻结对应库存,防止重复申请和出库占用;
5. 生成换标子单,精确状态保存在子单和 `sub_order_status`。
普通入仓没有逐箱唛到托盘的精确快照时,客户填写的我方箱唛是换标作业主键;库存位置只是冻结记账锚点和现场大概位置,不能据此声称箱唛一定在某托。
### 9.2 PDA 换标作业
入口:海外 PDA → 换标收货/执行。
1. 认领任务并进入处理中;
2. SKU 类型逐条核对旧 SKU、新 SKU 和批准件数;
3. 箱唛类型扫描仓库唛头和新 FBA 箱唛;
4. 混合类型分别完成 `MIXED_CARTON`、`MIXED_SKU`,两项齐全才计一箱完成;
5. 拍摄并上传作业照片;
6. 必要时按实际来源拆托、换托并记录 `PALLET_BIND`;
7. 提交后任务进入 `WAIT_REVIEW`,申请进入 `CUSTOMER_CONFIRM_PENDING`。
换标移动只能移动本申请 `hold_packages`,不得解冻为普通可用库存。已生成出库单或开始拣货后禁止随意改绑。
### 9.3 返工与客户确认
平台复核发现问题时,使用专用返工接口携带任务版本、幂等键和必填备注退回。上一轮事实和照片归档,当前轮计数清零;客户已确认后不能退回。
客户核对结果后确认,服务端原子完成复核并释放后续资格。换标主流程相关单据进入 `WAIT_OUTBOUND`;客户确认前不得出库。
### 9.4 仓储转运
入口:Client → 海外仓服务 → 仓储转运。
1. 客户选择本企业可用库存和目标仓/运输要求;
2. 提交转运申请;
3. 平台审核通过或驳回;
4. 通过后冻结对应库存并进入转运出库;
5. 实际移出和目标收货按转运单、库存流水和资源操作闭环。
转运状态保存在子单,不覆盖尾程主状态 `INBOUND_COMPLETED`。
### 9.5 一件代发货物废弃
废弃只使用能够核对 SKU 和箱唛的一件代发库存:
1. 客户选择箱唛并创建申请;
2. 箱从 `AVAILABLE` 锁定为 `DISCARD_PENDING`,此时不扣 SKU 箱数;
3. 有 `lastleg:cargo-discard:approve` 权限的仓库/PDA 人员现场审核;
4. 拒绝时只释放锁,不扣库存;
5. 通过时在同一事务调用专用 discard adjustment;
6. 箱进入 `DISCARDED`,写逐箱流水和唯一幂等键;
7. 校验扣减前箱数减废弃箱数等于扣减后箱数,且逐箱合计等于申请总数;
8. 任一失败整体回滚。
## 10. 状态与数据归属
| 维度 | 关键状态 | 说明 |
|---|---|---|
| 柜级收货会话 | 待签收/收货中/完成 | 绑定平台账号、柜和实际仓 |
| 入仓计划/PDA 任务 | 已计划→作业中→完成 | 每票按真实客户隔离 |
| 尾程主状态 | `INBOUND_PLANNED → INBOUND_COMPLETED` | 子单不能覆盖 |
| 库存 | `FIRST_LEG` 在途→海外仓库存各数量桶 | 入账必须与状态同事务 |
| 暂扣 | 有效/解除 | 正交阻断排车出库 |
| 换标/转运/废弃 | 各自子状态 | 汇总到 `sub_order_status` |
| 物理资源 | 托盘、库位、锁和版本 | Warehouse Overseas 权威 |
## 11. 异常与恢复
| 场景 | 处理方式 |
|---|---|
| `PICKUP_READY` 前看不到柜 | 等待真实节点,不手工绕过候选门禁 |
| 操作员仓库绑定错误 | 重新绑定并重新登录,旧 Token 失效 |
| 三类照片上传失败 | 保留旧图/现场重试,不伪造签收 |
| FBA 超收 | 冲突且库存不变,核对累计/剩余数 |
| OVERSEAS 重复箱码 | 冲突且不重复入账 |
| 一件代发来源托盘不符 | 整托拒绝,核对国内冻结快照 |
| 实收命令超时 | 查询当前实收记录,复用原幂等键 |
| 任一计划未清零 | 禁止完成整柜,定位具体入仓码 |
| 完成事务失败 | 状态和库存整体回滚后重试原命令 |
| 移库/托盘版本冲突 | 重新扫描获取最新版本 |
| 暂扣票参与排车 | 服务端拒绝,先按权限解除 |
| 换标一项未完成 | 混合单不计整箱完成 |
| 换标被退回 | 展示备注,清理设备草稿后按新轮返工 |
| 废弃审批失败 | 锁定和扣减整体回滚,不产生半套流水 |
## 12. 阶段完成判定
主链完成必须满足:
- [ ] 柜号由正确仓库操作员认领
- [ ] 国内五类证据可对照,海外三类签收照片齐全
- [ ] 实际到仓时间来自首次收货会话
- [ ] FBA、OVERSEAS、一件代发均按对应模式实收
- [ ] 每票 `pendingPackages=0`
- [ ] 库存余额、位置、流水和托盘组成数量一致
- [ ] 入仓计划和 PDA 任务完成
- [ ] 尾程主状态为 `INBOUND_COMPLETED`
- [ ] 卸柜 Outbox 唯一且国内幂等回写成功或可重试
- [ ] 重放任何实收/完成命令不会重复库存
库内服务完成以各子单独立判断;它们不改变已经成立的 `INBOUND_COMPLETED`,但未完成的冻结、暂扣或占用必须继续阻断阶段六相应库存。
## 13. 易用性与数据流评审问题
1. 柜级认领前是否需要把计划/资源缺口直接展示为可操作诊断?
2. 国内五类照片与海外三类照片是否应提供左右对照和异常快捷登记?
3. 三类收货模式是否需要在页面用颜色/图标强提示,防止操作员走错批量或逐箱入口?
4. FBA 首次扫码只选分组、不计数的规则是否足够直观?
5. 合托提示是否应同时展示托盘已有客户、入仓单组成和容量风险?
6. 整柜完成前是否需要按入仓码显示“剩余几箱/哪几个来源托盘”?
7. 卸柜完成后 72 小时还空预警应由谁接收、何时升级?
8. 库存余额、库存位置、托盘组成和箱唛明细是否需要统一钻取入口?
9. 当前盘点入口隐藏,正式业务发生账实差异时由哪个可见流程处理?
10. 暂扣原因、换标冻结、转运占用和废弃锁箱是否应统一显示“不可用数量来源”?
11. 换标混合任务双 Tab 的完成条件是否需要总进度和缺项提示?
12. 废弃从“申请锁定”到“现场实际扣减”的状态文案是否足够清楚?
## 14. 阶段六交接包
交给阶段六“海外排车、出库、派送与回写”的输入包括:
- `INBOUND_COMPLETED` 尾程订单和实际海外仓;
- 客户、入仓单、目的仓和可用库存余额;
- 每个实物托盘的多票组成、库位和版本;
- 一件代发来源托盘、SKU 和箱唛可用状态;
- 有效暂扣、换标、转运、废弃及其他库存锁;
- 可出库数量、已占用数量和相应库存流水;
- 客户/平台要求的派送地址、FBA 仓和服务属性。
## 15. 事实来源
- `docs/海外仓业务/README.md`
- `docs/海外仓业务/虫虫出海全流程人工验收手册.md`
- `apps/pda-overseas-uniapp/AGENTS.md`
- `apps/pda-overseas-uniapp/src/pages/operation/operation.vue`
- `apps/pda-overseas-uniapp/src/pages/pallet/pallet.vue`
- `apps/pda-overseas-uniapp/src/pages/move/move.vue`
- `apps/pda-overseas-uniapp/src/pages/relabel/relabel.vue`
- `baiyue-firstleg/lastleg-service/AGENTS.md`
- `baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/`
- `baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/`
- `baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/`
- `baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/`
- `baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/discard/`
- `baiyue-firstleg/warehouse-overseas-service/AGENTS.md`
@@ -0,0 +1,310 @@
# 阶段六:海外排车、出库、派送与最终回写
> 对应全景流程:F34~F40。
> 阶段起点:尾程订单已经 `INBOUND_COMPLETED`,存在可用库存且没有阻断本次履约的暂扣或库存锁。
> 阶段终点:派送完成并带签收证据,跨区回写国内;对应国内入库单为 `DELIVERED`,同一 Order 全部有效入库单完成后 Order 为 `COMPLETED`。
> 出库、排车、派送和异常是独立子状态,不覆盖尾程主状态。
## 1. 阶段目标和边界
本阶段完成海外仓最后一段实物与数据闭环:
1. 从真实库存生成智能预排和正式排车;
2. 发车前将排车转换为车辆级出库单和按目的地拆分的派送单;
3. PDA 按托盘拣货、扣减库存并在满足组合门禁后发车;
4. 执行一件代发、换标、转运、退货等独立出库任务;
5. 上传签收资料并自动完成派送;
6. 对派送异常执行来源受控的退回入仓;
7. 保存客户回访并将完成事实回写国内订单。
“扫码拣货”“出库”“发车”“开始派送”“签收完成”是不同节点。库存只在成功的 PDA 拣货/出库事务按契约扣减,发车不能再次扣库存。
## 2. 角色和终端
| 角色 | 终端 | 主要操作 |
|---|---|---|
| 海外调度 | Admin | 智能预排、排车编辑/取消、转派送、维护车辆与预约 |
| 海外仓 PDA 操作员 | 海外 PDA | 排车拣货、独立出库拣货、完成任务、发车 |
| 派送运营 | Admin | 开始派送、维护允许状态、上传 BOL/签收资料 |
| 企业成员 | Client | 查看本企业派送单、状态和允许下载的签收资料 |
| 异常处理员 | Admin | 创建异常、选择原出库事实、处理退回入仓 |
| 客服 | Admin | 客户回访、记录结果和附件 |
| Lastleg | 后端 | 排车、出库、派送、异常、库存命令和完成 Outbox 权威 |
| Firstleg/Order | 国内后端 | 幂等消费完成事件,推进 `DELIVERED/COMPLETED` |
PDA 只调用 `/api/pda/**` 专用接口,客户和仓库由 Token、Gateway 和服务端解析;派送与客户回访由 Admin/Client 承载,不进入 PDA。
## 3. 出库资格前置检查
- 尾程订单已 `INBOUND_COMPLETED`;
- 实际海外仓、目的仓和客户归属明确;
- 库存余额、位置、托盘组成和可用箱数一致;
- 无有效暂扣;
- 换标需要客户确认的,已完成确认;
- 转运、退货或废弃的库存锁与本次出库不冲突;
- 一件代发箱唛/SKU 状态为可用;
- 实物托盘和库位版本仍有效;
- 车辆、车队和目的地使用有效主数据。
任何不确定的库存、锁或归属查询都应失败关闭,不能通过前端勾选强行出库。
## 4. F34 智能预排和排车维护
### 4.1 候选和草稿锁
入口:Admin → 海外仓 → 排车管理/智能预排。
候选来自真实入仓计划、已收托盘或具备资格的换标库存。平台可跨客户查看候选,但创建时服务端从所选托盘解析并校验唯一客户范围和唯一海外仓。
1. 创建随机草稿;
2. 搜索并选择托盘;
3. 托盘加入草稿时立即按操作人和草稿标识加锁;
4. 移除托盘或关闭草稿时释放;
5. 草稿超时后锁自动失效;
6. 其他草稿/正式排车不能重复占用同一库存。
### 4.2 预排创建
1. 选择车型;
2. 核对托位、箱数、重量、目的 FBA 仓或私人地址;
3. 查看服务端汇总路线;
4. 点击预排;
5. 服务端重查托盘、库存、锁和客户范围;
6. 草稿锁原子转换为正式排车锁;
7. 生成排车单,初始为 `PENDING_DEPARTURE`。
路线由已排托盘目的仓编码去重并稳定排序生成,预排和普通编辑都不接收人工路线文本。
### 4.3 排车编辑和取消
可维护:车牌、司机、电话、车队、预计发车/到达、FBA 预约和车辆预约。普通编辑不能修改排车状态。
未执行、未产生不可逆拣货事实的排车可取消并释放正式锁。开始拣货后不能用取消恢复库存或抹掉作业历史。
## 5. F35 排车转派送
允许状态:`PENDING_DEPARTURE`、`PICKING`、`READY_TO_DEPART`,且必须早于发车。
入口:Admin → 排车详情 → 转派送。
1. 填写预计派送时间;
2. 选择派送方式、提货/装车类型;
3. 选择车队/快递、车辆和预约;
4. 维护费用来源和必要备注;
5. 核对所有托盘都已经真实入仓且仍由本排车锁定;
6. 二次确认后提交。
服务端幂等生成:
- 一张车辆级 `WAIT_OUTBOUND` 出库单;
- 按目的 FBA 仓或私人地址拆分的一张或多张 `WAIT_DELIVERY` 派送单;
- 派送费用只发布一次;
- BOL 和签收资料绑定拆分后的单仓派送单。
相同排车重放返回既有单据,不重复生成出库、派送或费用。
## 6. F36 排车 PDA 拣货和发车
### 6.1 打开任务
入口:海外 PDA → 排车拣货。
1. 查看当前 Token 仓库的任务;
2. 打开排车单,核对排车号、车辆、目的地、托盘和计划箱数;
3. 页面使用服务端返回的状态和版本,不按本地草稿推断可操作性。
### 6.2 扫托盘拣货
1. 扫描托盘业务码;
2. 服务端校验托盘属于本排车、当前仓库和有效库存位置;
3. 校验计划箱数和托盘组成;
4. 在成功事务中完成库存分配、拣货和相应出库扣减;
5. 写库存流水、任务进度和幂等记录;
6. 重复同一命令返回原结果,不再次扣减。
部分出库若不能整托核平,应先通过合法拆托或组成明细处理,不能按 FIFO 猜测某票来源。
### 6.3 完成拣货
全部托盘和箱数核平后,PDA 任务进入 `COMPLETED`,排车进入 `READY_TO_DEPART`。存在漏托、数量差异、暂扣或库存版本冲突时不能完成。
### 6.4 发车门禁
PDA 只使用详情返回的 `canDepart` 和 `departBlockReason`。服务端至少校验:
- 车辆信息完整;
- 拣货任务完成且箱数核平;
- 排车为 `READY_TO_DEPART`;
- 平台已经转派送;
- 关联出库单仍为 `WAIT_OUTBOUND`。
发车成功后排车进入 `DEPARTED`,出库单进入 `OUTBOUNDED`,释放相应托盘锁。发车只推进履约事实,不再次扣库存。
## 7. F37 独立出库任务
### 7.1 通用生命周期
独立出库通常按 `WAIT_PICK → PICKING → PICKED → OUTBOUNDED` 推进。只有尚未开始的 `WAIT_PICK/WAIT_OUTBOUND` 可以取消;作业开始后不能通过取消自动恢复库存。
PDA 操作:打开本仓任务 → 扫箱/托盘 → 核对来源 → 幂等提交 → 数量核平 → 完成出库。
### 7.2 一件代发出库
- 候选必须已完成入仓,仍有可用箱唛、SKU 和库存位置;
- 创建时必须解析为唯一客户和唯一仓,禁止跨客户/跨仓一单;
- 可逐箱扫描;
- 扫 `DP` 托盘码时原子拣取当前任务内该托全部剩余箱件和 SKU;
- 任一库存明细失败时整托回滚;
- 完成后箱唛、SKU 汇总、库存位置和流水必须核平。
### 7.3 换标、转运和退货出库
| 类型 | 前置 | 作业依据 | 结果 |
|---|---|---|---|
| 换标出库 | 仓库作业完成且客户确认 | 换标后箱唛、托盘和改绑快照 | 换标单完成、出库 `OUTBOUNDED` |
| 仓储转运 | 申请审核通过 | 冻结来源托盘和箱数 | 转运出库完成 |
| 退货出库 | 退货箱/SKU 可用 | 冻结退货库存 | 退货库存扣减、出库完成 |
退货出库不能与派送异常退回库存恢复混用;前者消费退货库存,后者恢复原出库来源库存。
## 8. F38 派送和签收
派送单状态:`WAIT_DELIVERY → DELIVERING → DELIVERED`,异常情况下可为 `REJECTED/CANCELLED/RETURNED`。
### 8.1 查看和开始派送
Admin/Client 按权限查看拆分后的单仓派送单、目的地、车辆/快递、预约、BOL 和状态历史。客户只能查看本企业。
平台核对实际出库后开始派送,使单据进入 `DELIVERING`。不能仅因排车发车就把所有拆分派送单自动视为签收。
### 8.2 上传签收资料并完成
1. 打开具体派送单;
2. 上传该目的仓对应的签收文件/照片;
3. 核对文件归属、类型和有效性;
4. 保存签收资料;
5. 系统在同一业务流程自动将该派送单推进为 `DELIVERED`;
6. 按关联 `sourceInboundId` 写 `OVERSEAS_DELIVERY_COMPLETED` Outbox。
不提供脱离签收资料的人工“完成派送”按钮。同车不同 FBA 仓的派送单互不代完成。
## 9. F39 派送异常退回
入口:Admin → 异常订单。
1. 从原派送单创建异常,状态为 `PENDING`;
2. 退回明细只能从原出库拣货事实选择,不能自由填写 SKU 或数量;
3. 开始处理后进入 `PROCESSING`;
4. 选择服务端校验的真实退回仓、托盘和库位;
5. 上传 `OVERSEAS_EXCEPTION_EVIDENCE`;
6. 现场确认退回实收;
7. 服务端按原出库来源恢复库存并写 `RETURN_STOCK_IN` 流水;
8. 同事务完成异常并将派送置为 `RETURNED`。
只有待处理异常可以取消。库存恢复、异常完成和派送退回任一步失败时整体回滚,不留下“派送已退回但库存未恢复”的半套结果。
## 10. F40 客户回访和国内最终回写
### 10.1 客户回访
入口:Admin → 客户回访。
客服选择已完成或需跟进的业务单,记录联系时间、方式、结果、问题、后续动作和附件。回访是服务闭环资料,不替代签收、异常或库存事实。
### 10.2 跨区完成事件
每张实际完成的派送单在业务事务内写 Outbox。Event Bridge 跨区投递后,Firstleg:
1. 校验内部身份、事件版本、ID 和 Payload 哈希;
2. 校验来源订单、来源入仓单、入仓码和 FBA 目的仓;
3. 防止同车其他目的仓事件误完成本票;
4. 将对应国内入库单从 `LOADED` 推进为 `DELIVERED`;
5. 写状态历史和事件消费记录。
### 10.3 Order 最终完成
Firstleg 检查同一 Order 的全部有效入库单。只有全部都为 `DELIVERED` 时,才调用 Order 推进交易状态为 `COMPLETED`。
部分装载形成的余量子单、另一柜或另一目的仓尚未完成时,Order 不能提前完成。重复事件不得重复推进、重复写历史或重复生成费用。
## 11. 状态和数据流
```text
INBOUND_COMPLETED + 可用库存
→ 草稿锁 → 正式排车锁 → PENDING_DEPARTURE
→ 转派送:车辆级 WAIT_OUTBOUND + 目的地级 WAIT_DELIVERY
→ PDA 扫托扣库存 → READY_TO_DEPART
→ 发车:DEPARTED + OUTBOUNDED
→ 派送:DELIVERING → 签收资料 → DELIVERED
→ OVERSEAS_DELIVERY_COMPLETED
→ 国内入库单 DELIVERED
→ 同一 Order 全部完成后 COMPLETED
```
尾程主状态保持 `INBOUND_COMPLETED`,排车、出库、派送和异常精确状态分别保存在各自单据并汇总到 `sub_order_status`。
## 12. 异常与恢复
| 场景 | 处理方式 |
|---|---|
| 托盘被其他草稿锁定 | 不可选择,等待释放或超时 |
| 预排时库存变化 | 服务端拒绝,刷新候选重排 |
| 排车已开始仍取消 | 拒绝,走对应异常流程 |
| 未转派送就发车 | `canDepart=false`,Admin 先完成转派送 |
| PDA 扫错托盘 | 拒绝且库存不变 |
| 扫码超时 | 保留幂等键,查询服务端任务后重试 |
| 整托一项失败 | 整托回滚,不部分扣库存 |
| 车辆资料缺失 | `departBlockReason` 明示缺项 |
| 签收资料上传失败 | 派送保持未完成,重新上传 |
| 一车多目的地仅一单签收 | 只完成对应派送单 |
| 异常退回数量超原拣货 | 拒绝,不允许自由扩量 |
| 退回库存事务失败 | 异常/派送状态同步回滚 |
| 国内消费暂时失败 | 保留 Outbox/Inbox 受控重试,不手工完成国内单 |
| 完成事件重复 | 返回既有结果,不重复历史或费用 |
## 13. 全流程最终完成判定
- [ ] 排车候选来自真实、可用且未被其他业务占用的库存
- [ ] 草稿锁、正式锁和取消/完成释放均可追溯
- [ ] 排车路线由实际目的地生成
- [ ] 转派送只发生一次且早于发车
- [ ] PDA 拣货数量、托盘组成、库存扣减和流水一致
- [ ] 发车时服务端 `canDepart=true`
- [ ] 发车没有重复扣库存
- [ ] 每个目的地拥有独立派送单和签收资料
- [ ] 派送完成只由有效签收资料触发
- [ ] 异常退回时原出库来源、恢复库存和证据完整
- [ ] 完成事件按来源入仓单跨区幂等回写
- [ ] 国内入库单为 `DELIVERED`
- [ ] 同一 Order 全部有效入库单完成后才为 `COMPLETED`
- [ ] 客户可查询本企业最终状态和允许的签收资料
## 14. 易用性与数据流评审问题
1. 智能预排候选是否应直接显示不可选原因、锁定人和锁过期时间?
2. 车型、托位、箱数、重量和目的仓能否提供装载风险提示?
3. 转派送为何必须早于发车,页面是否在拣货阶段持续提示?
4. 排车、出库、派送三个编号和状态是否需要统一时间线和相互跳转?
5. PDA 扫码即扣库存的关键动作是否需要更强确认和撤销边界说明?
6. `canDepart/departBlockReason` 能否直接跳转到车辆、拣货或转派送缺项?
7. 一件代发逐箱与整托两种模式如何防止操作员重复选择?
8. 一车多目的仓时,签收资料上传如何降低绑错派送单的风险?
9. 派送异常是否需要标准原因、责任方、时限和赔付关联?
10. 退回入仓是否需要 PDA 专用入口,而不是只依赖后台操作?
11. 跨区回写延迟时,海外“已签收”和国内“未完成”如何向客服解释?
12. Order `COMPLETED` 前是否需要展示仍未完成的具体子入库单/柜次/目的仓?
## 15. 事实来源
- `docs/海外仓业务/README.md`
- `docs/海外仓业务/虫虫出海全流程人工验收手册.md`
- `apps/pda-overseas-uniapp/AGENTS.md`
- `apps/pda-overseas-uniapp/src/pages/dispatch/dispatch.vue`
- `apps/pda-overseas-uniapp/src/pages/outbound/outbound.vue`
- `baiyue-firstleg/lastleg-service/AGENTS.md`
- `baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/`
- `baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/`
- `baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/`
- `baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/`
- `baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumer.java`
- `baiyue-firstleg/order/AGENTS.md`
@@ -0,0 +1,388 @@
# 阶段四:海运、目的港与尾程入仓计划
> 对应全景流程:F24~F27。
> 阶段起点:柜次已确认国内离仓,存在实际 `DEPARTED_WAREHOUSE` 节点和可查询的真实订舱/柜次。
> 阶段终点:跨区尾程订单完整生成,整柜每票入仓计划及托盘/库位资源已生成或有明确资源失败待办,订单进入 `INBOUND_PLANNED`。
> 本阶段同时维护船舶/货柜轨迹和尾程业务准备,但两类状态不得互相冒充。
## 1. 阶段目标和边界
本阶段完成四条相互关联但独立的链路:
1. 从国内离仓开始查询并保存十个标准船节点;
2. 管理目的港清关、海外码头车队、提柜、预计到仓和还空;
3. 将国内最终实装快照可靠投递到海外区域并创建尾程订单;
4. 按整柜维护每票计划并转换为可执行的海外入仓计划和资源。
阶段四不执行海外仓实收、库存入账、库内服务、排车或出库。`PICKUP_READY` 只表示货柜已清关可提,并开放海外 PDA 待收柜候选;它不表示已经到仓或已经产生库存。
## 2. 角色、终端和系统边界
| 角色/系统 | 终端 | 主要职责 |
|---|---|---|
| 船期操作员 | Admin | 查看查询任务、标准节点、异常与查询历史,必要时人工补录 |
| 目的港操作员 | Admin | 维护清关/DO/PU、选择海外车队、发提柜邮件、确认提柜和还柜 |
| 海外仓计划员 | Admin | 维护整柜预计到仓、每票托数与作业属性、执行柜级转换 |
| 企业成员 | Client | 查看本企业尾程订单和允许展示的船期/计划进度 |
| Firstleg | 后端 | 拥有查询任务、标准节点、目的港码头拖车和装柜完成 Outbox |
| Integration Service | 后端 | 调用船司/轨迹供应商,解析并返回标准化结果,不保存业务主数据 |
| Event Bridge | 跨区通道 | 版本化、认证地投递业务事件 |
| Lastleg | 海外后端 | Inbox 幂等消费、尾程订单、计划、在途库存和海外业务状态 |
| Warehouse | 国内主数据 | 海外共享仓、FBA 目的仓和供应商主档权威 |
| Warehouse Overseas | 海外资源 | 海外库位、实物托盘和资源锁 |
所有浏览器海外接口经 `gateway-overseas/`。国内 Auth 仍是账号和 Token 签发方;跨区业务写入通过内部身份、Inbox/Outbox 和事件契约完成,不允许浏览器伪造跨区事件。
## 3. 阶段前置检查
- 柜次存在真实集装箱号、订舱、提单号和国内离仓事实;
- 装柜完成事件包含最终实装订单、入仓单、目的类型、箱数及必要托盘/SKU/箱唛快照;
- 国内四步照片和铅封证据已经冻结;
- 接柜海外仓是 Warehouse 中 `ACTIVE + OVERSEAS + RECEIVING` 的平台共享物理仓;
- FBA 目的仓是 Warehouse 有效公共主档,不能用接柜仓或国内仓代替;
- 船司/轨迹查询标识足以唯一指向目标订舱和柜次;
- 同柜订单的预计到仓事实必须能够收敛为一个柜级日期。
## 4. F24 海运十节点
### 4.1 查询任务创建
国内出仓确认事务幂等创建 `CarrierScheduleQuery`。任务必须绑定真实订舱、柜次以及可用的船司、船名航次、提单号或柜号,不创建脱离业务单据的孤立轮询任务。
首个实际节点由国内出仓写入:
```text
10 DEPARTED_WAREHOUSE 已离开国内仓
20 AT_ORIGIN_PORT 已到起运港
30 LOADED_ON_VESSEL 已装船
40 DEPARTED_ORIGIN_PORT 已离开起运港
50 IN_TRANSIT 海运途中
60 ARRIVED_DESTINATION_PORT 已到目的港
70 DISCHARGED_FROM_VESSEL 已卸船
80 PICKUP_READY 已清关、可提柜
90 PICKED_UP 已提柜
100 EMPTY_RETURNED 已还空柜
```
### 4.2 自动查询操作与频率
入口:Admin → 船期/货柜详情 → 船节点查询。
平台查看:查询任务状态、当前最新实际节点、预计节点、最近成功时间、下次允许时间、供应商、失败原因、异常事实和每次尝试历史。
正常查询槽位按北京时间每天 `09:00`、`12:30`、`16:00`。调度器每分钟只扫描已到期任务,不代表每分钟调用船司。
- 成功、无变化、需人工或永久失败:进入下一固定槽位;
- 技术性可重试失败:当前槽内按 15/30 分钟退避,含首次最多三次;
- 重试不得跨越下一正常槽位;
- 服务恢复后错过的槽位直接顺延,不集中补打供应商;
- 实际 `EMPTY_RETURNED` 后任务停止。
### 4.3 节点时间和推进规则
| 时间 | 含义 | 推进最新实际节点 |
|---|---|---|
| `estimatedAt` | 预计发生时间 | 否 |
| `actualAt` | 已确认实际时间 | 是 |
| `observedAt` | 本次观测到事实的时间 | 否 |
| `createdAt` | 平台写入时间 | 否 |
只有带 `actualAt` 的标准节点可以推进。节点按序号单调前进;迟到的较早节点可作为历史保存,但不能回退当前节点。供应商缺少中间节点时保持缺失,不能自动补齐。
同一供应商事件按稳定 `providerEventId` 幂等;无原生 ID 时用稳定字段组合生成哈希。每条事实保留原始节点编码、来源、地点、观测时间和原始响应哈希,但业务表不保存含密钥的完整网页。
### 4.4 异常事实
以下异常与正常节点并行保存:
- `CUSTOMS_HOLD`:海关扣留;
- `TERMINAL_HOLD`:码头/堆场扣留;
- `DOCUMENT_HOLD`:单证问题;
- `UNKNOWN_HOLD`:其他无法分类扣留。
异常不能覆盖最近正常节点。解除时新增解除事实,不删除原异常。
### 4.5 人工补录
供应商缺节点、网页需要人工验证或业务已有可信凭证时,有权限人员可在查询详情补录:
1. 选择标准节点;
2. 填写实际时间,预计事实不能冒充实际时间;
3. 填写地点、来源说明和必要凭证;
4. 核对节点不会回退当前实际进度;
5. 携带版本和幂等键提交;
6. 查看历史中来源标识为人工。
人工补录 `PICKUP_READY` 会通过跨区事实开放海外 PDA 柜号候选;人工补录 `EMPTY_RETURNED` 会停止任务,因此必须使用真实还柜事实。
## 5. F25 目的港清关、车队、到仓和还空
### 5.1 清关和文件
入口:Admin → 清关提柜。
1. 按柜号、提单号、目的港和节点筛选;
2. 核对目的港清关资料、DO 和 PU 当前版本;
3. 上传或替换合法文件引用;
4. 将线下清关结果反映为可信业务事实;
5. 清关完成、码头明确可提后,确保实际 `PICKUP_READY` 已进入标准节点链。
清关资料状态和标准船节点有关联但不互相覆盖。只有明确可提事实才能推进 `PICKUP_READY`。
### 5.2 选择海外码头车队
1. 选择 Warehouse 中 `ACTIVE/TRUCKING/OVERSEAS` 供应商;
2. 服务端读取供应商名称和邮箱快照,不能用自由文本绕过主档;
3. 首次选择车队只保存选择,不自动发信;
4. 操作员确认后发送提柜请求邮件;
5. 邮件进入顺序发送队列,`SENT` 只表示 SMTP 接受,不代表车队已读或已执行;
6. 失败邮件保留原因,人工确认后重试。
更换车队时,事务内同时冻结旧车队取消邮件和新车队提柜邮件,并按顺序投递。同一车队重复选择不重复发信。
### 5.3 实际提柜
车队从码头取得重箱后,目的港操作员确认实际提柜时间。服务端推进实际 `PICKED_UP`,但不创建海外库存、不替代 PDA 到仓收货。
### 5.4 预计到仓
清关提柜列表中的“预计到仓”由平台维护:
1. 输入整柜预计抵达接柜仓的日期/时间;
2. 按 `sourceContainerBookingId` 锁定整柜;
3. 原子同步柜内全部尾程订单的 `estimated_warehouse_at`;
4. 记录版本、操作人和幂等历史;
5. 任一入仓计划已生成后锁定,不再允许修改。
预计到仓不能使用订单创建时间、预计到港时间或当前时间替代。同柜订单缺失或日期不一致时,阶段四的计划转换必须阻断。
### 5.5 实际到仓与还空
实际到仓不由 Admin 手填,也不取预约提柜时间;只取海外 PDA 首次开始收货会话的 `receiving_at`。因此本阶段只能展示“待实际到仓”,真实时间在阶段五产生。
海外 PDA 完成卸柜后,跨区 `OVERSEAS_CONTAINER_UNLOADED` 事件回到 Firstleg。预计报空/还空截止按卸柜完成时间加 72 小时计算并产生预警。
真实还柜可由供应商节点或有权限人员确认:
- 时间不得早于卸柜完成时间;
- 若已有实际提柜时间,也不得早于提柜;
- 无需为了还柜补造车队、预约或提柜事实;
- 首次命令可以版本 0 创建记录,车队字段允许为空;
- 成功后推进实际 `EMPTY_RETURNED`、解除预警并停止查询任务。
目的港码头拖车、国内订舱级拖车和海外配送排车是三张不同业务单,不得复用状态或费用。
## 6. F26 跨区尾程建单
### 6.1 国内生产事件
Firstleg 在装柜完成后按最终实装生成 `FIRST_LEG_LOADING_PLAN_COMPLETED` V3:
- 信封包含事件 ID、版本、来源区域、聚合版本和 Payload 哈希;
- Payload 包含来源柜次/计划/真实柜号和每票最终实装;
- FBA 可以按实装箱数冻结,不伪造逐箱作业账;
- `OVERSEAS` 必须逐箱冻结箱码事实;
- 一件代发必须携带一个或多个不可变来源托盘,以及每托 SKU、箱数、数量和箱唛;
- 物理柜五类国内装柜照片引用一并冻结。
Outbox 可重复投递,不能因网络失败直接丢事件或在事务外临时拼装 Payload。
### 6.2 Event Bridge 投递
Event Bridge 校验允许的事件类型、版本、来源/目标区域和内部服务身份后投递 Lastleg。IAM 安全事件使用独立入口,不能交给装柜完成消费者。
### 6.3 Lastleg Inbox 消费
Lastleg 消费步骤:
1. 常量时间校验内部令牌;
2. 校验事件 ID、版本、来源区域、客户和必要字段;
3. 校验 Payload 哈希;
4. 以事件 ID、哈希和来源入仓单唯一约束防重;
5. 在一个事务内处理整柜全部订单;
6. 任一票失败时整柜回滚,不留下半套订单;
7. 重放相同事件返回既有结果。
### 6.4 尾程订单和在途库存
成功消费后:
- 同一实际集装箱共享业务尾程单号;
- 每个 `sourceInboundId` 独立生成一条 `ll_order`;
- 保存来源订单、入仓码、客户、接柜仓、目的仓和货物冻结快照;
- 主状态为 `PENDING_ASSIGNMENT`;
- 生成 `FIRST_LEG` 在途库存;
- 计划生成前以真实来源入仓码作为库存展示快照;
- 事件只建订单,不自动生成入仓计划。
一件代发额外生成来源托盘、SKU、箱唛档案和在途库存事实;海外实物托盘资源仍由 Warehouse Overseas 分配,不覆盖国内来源托盘快照。
## 7. F27 尾程计划维护和整柜转换
### 7.1 柜级工作台
入口:Admin → 海外仓 → 尾程订单 → 柜级工作台。
列表使用 `/container-summaries` 按稳定 `sourceContainerBookingId` 服务端聚合,不允许用当前订单分页在浏览器拼柜。列表至少展示:
- 柜号、来源柜次和预计到仓;
- 订单数、已建计划数、待建计划数;
- `allPlansReady`;
- `resourcePendingPlanCount`;
- 接柜仓、目的仓分组和异常摘要。
已完成转换的柜仍保留供查询和允许范围内维护。详情接口返回柜内全部订单和计划,不受列表分页截断。
### 7.2 单票计划维护
对柜内每票填写:
- 计划托数;
- 托盘类型;
- 是否已分配;
- 是否允许合托;
- 是否加急;
- 现场备注。
入仓码、实际接柜仓和预计到仓只读。保存时校验订单版本、客户范围、Warehouse 当前仓库主档和幂等键。
“可合托”只给阶段五现场提供选择,不是实收或完成门禁:合托时扫描同目的仓已使用托盘,独立收货扫描新托盘。
### 7.3 普通货与一件代发托盘规则
普通散货:
- 保留头程已经冻结并跨区传入的托盘码;
- 海外计划托数不得少于已有托盘码数量;
- 海外装托密度不同导致托数增加时,在同柜、同 FBA 仓连续补码;
- 预览分别展示头程已有数、海外补充数和最终托数。
一件代发:
- 必须逐托沿用国内封托的 `DP` 托盘码和每托箱数;
- 不允许重算托数、重分配箱子或改写 SKU/箱唛;
- 无法绑定真实资源时禁止保存半成品计划。
### 7.4 自动触发与柜级预览
单票柜保存完整计划后立即尝试生成资源;多票柜在最后一票计划齐全时自动触发整柜资源生成。
转换前读取服务端预览,核对:
- FBA、一件代发、私人地址等分组;
- 每票来源托数、补充托数和最终托数;
- 柜内计划是否全部就绪;
- 接柜仓、预计到仓是否一致;
- 阻断项;
- `snapshotVersion` 和 `snapshotHash`。
`pendingPlanCount` 表示尚未建立完整计划;`resourcePendingPlanCount` 只表示自动物化资源失败,需要按原柜级入口重试,不能把它当作正常人工步骤。
### 7.5 确认转换
系统执行柜级 Saga:
1. 以 `sourceContainerBookingId` 锁柜;
2. 用版本/哈希重新计算预览;
3. 向 Warehouse Overseas 批量预留真实托盘、库位和资源锁;
4. 为柜内每票独立生成入仓计划;
5. 本地事务确认计划并绑定资源;
6. 尾程订单进入 `INBOUND_PLANNED`;
7. `FIRST_LEG` 在途库存展示编号从来源入仓码收口为各自 `planNo`。
整批转换原子且幂等:相同命令重放不生成第二批计划或资源;本地失败只补偿尚未确认的远端资源;远端确认失败的重放只重试确认。
## 8. 状态、编号与数据归属
| 维度 | 关键状态/编号 | 权威系统 |
|---|---|---|
| 船节点任务 | ACTIVE/人工/失败/`STOPPED` | Firstleg |
| 最新实际节点 | 十节点之一 | Firstleg |
| 目的港码头拖车 | 车队、提柜、卸柜、还柜 | Firstleg |
| 尾程主单 | `PENDING_ASSIGNMENT → INBOUND_PLANNED` | Lastleg |
| 入仓计划 | `planNo`、计划属性、资源绑定 | Lastleg |
| 在途库存 | `FIRST_LEG` | Lastleg |
| 海外实物资源 | 托盘、库位、锁 | Warehouse Overseas |
| 仓库/目的仓/供应商 | 业务主档 | Warehouse |
`containerNo` 是真实集装箱号;`sourceContainerBookingId` 是跨区稳定柜聚合键;`inboundCode` 永远保存真实来源入仓码;`planNo` 只在尾程入仓计划生成后产生。任何页面不得以内建数据库 ID 回退展示业务编号。
## 9. 异常与恢复
| 场景 | 系统行为 | 操作处理 |
|---|---|---|
| 船司无变化 | 保存查询尝试,进入下一槽位 | 无需伪造节点 |
| 船司技术失败 | 15/30 分钟受控重试 | 查看失败原因,必要时人工 |
| 只有预计到港 | 保存预计事实,不推进实际节点 | 等待真实节点 |
| 较早节点迟到 | 保存历史,不回退当前节点 | 无需修改最新进度 |
| 清关/单证扣留 | 单独保存异常 | 处理后新增解除事实 |
| 车队邮箱无效 | 阻断发信/换队 | 修正 Warehouse 供应商资料 |
| 事件重复投递 | 返回既有消费结果 | 不人工删 Inbox |
| 事件哈希不同 | 冲突失败 | 核对生产端版本和 Payload |
| 柜内任一票事件失败 | 整柜消费回滚 | 修复数据后重放原事件 |
| 预计到仓缺失/不一致 | 阻断计划生成 | 在计划前按柜统一维护 |
| 计划版本过期 | 转换冲突 | 刷新预览,重新确认 |
| 资源预留失败 | `resourcePendingPlanCount` 暴露 | 用原柜级入口定向重试 |
| 重试结果不确定 | 查询计划和资源事实 | 复用原幂等键,不另建资源 |
## 10. 阶段完成判定
- [ ] 国内离仓产生的船节点任务绑定真实订舱和柜次
- [ ] 节点预计/实际/观测时间没有混用
- [ ] 异常事实与正常节点分开保存
- [ ] 实际 `PICKUP_READY` 已到达或已有明确待处理状态
- [ ] 装柜完成 V3 事件已被 Lastleg 幂等消费
- [ ] 柜内每个来源入仓单只生成一张尾程订单
- [ ] 尾程订单冻结客户、入仓码、接柜仓、目的仓和最终实装事实
- [ ] `FIRST_LEG` 在途库存存在且归属正确
- [ ] 整柜预计到仓完整且一致
- [ ] 每票入仓计划属性完整
- [ ] 柜级转换后每票独立 `planNo` 存在
- [ ] 托盘、库位和资源锁已绑定,或存在可定位的资源失败待办
- [ ] 尾程订单状态为 `INBOUND_PLANNED`
- [ ] 重放事件/转换不会重复建单、建计划或占资源
## 11. 易用性与数据流评审问题
1. 船节点、目的港清关和尾程计划是否需要整合为一条柜级时间线,同时保留三个权威状态?
2. 预计节点与实际节点的视觉差异是否足够明显?
3. 船司失败、需人工、扣留异常分别由谁负责,是否需要责任人和 SLA?
4. 发车队邮件后,页面是否明确区分“已入队、SMTP 已接受、车队已执行”?
5. 预计到仓锁定前是否应显示会影响哪些订单和计划?
6. 跨区事件失败是否需要业务友好的“缺哪一票/哪一字段”诊断,而不是只显示事件错误?
7. 柜级工作台的 `pendingPlanCount` 与 `resourcePendingPlanCount` 是否容易混淆?
8. 普通散货的“头程已有/海外补充/最终托数”是否需要图形化对比?
9. 一件代发不可重排托盘的约束是否在编辑前就锁定控件?
10. 转换前预览是否需要展示资源占用和预计库位,减少确认后人工调整?
11. `PICKUP_READY` 开放 PDA 候选后,若预计到仓和资源计划尚未完成,现场应看到什么阻断提示?
12. 还柜预警当前同时存在“两天展示”和“72 小时业务规则”的历史描述,正式页面应统一采用服务端 72 小时口径。
## 12. 阶段五交接包
交给阶段五“海外接柜、实收、库存和库内服务”的输入包括:
- 来源柜次、真实柜号、订舱和最新实际船节点;
- `PICKUP_READY` 及以后产生的 PDA 候选门禁;
- 实际接柜海外仓和柜级预计到仓;
- 柜内全部尾程订单、来源入仓码、目的类型和冻结货物;
- 每票独立入仓计划及 `assigned/mergeAllowed/urgent/mergeHint`;
- 已预留并确认的托盘、库位和资源锁;
- 普通货头程托盘码与海外补码;
- 一件代发不可变 `DP` 托盘、箱唛和 SKU;
- 国内装柜五类照片引用及跨区事件审计历史。
## 13. 事实来源
- `docs/海外仓业务/README.md`
- `docs/海外仓业务/船节点标准化契约.md`
- `docs/海外仓业务/虫虫出海全流程人工验收手册.md`
- `docs/跨区域认证与服务通信契约.md`
- `baiyue-firstleg/firstleg/AGENTS.md`
- `baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/portschedule/`
- `baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/`
- `baiyue-firstleg/common-event-contract/AGENTS.md`
- `baiyue-firstleg/lastleg-service/AGENTS.md`
- `baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java`
- `baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/`
- `apps/admin-web/src/modules/port-schedules/`
- `apps/admin-web/src/modules/overseas-warehouse/`
+187
View File
@@ -0,0 +1,187 @@
/* 柏悦物流国内 PDA · 海外版视觉对齐层
只覆盖视觉 tokens、布局和交互状态,不参与业务逻辑。 */
:root {
--pda-blue: #0a66d8;
--pda-blue-deep: #084faa;
--pda-blue-soft: #eaf3ff;
--pda-ink: #344054;
--pda-muted: #667085;
--pda-bg: #f2f4f7;
--pda-surface: #fff;
--pda-line: #e4e7ec;
--pda-success: #087a62;
--pda-success-soft: #e8f7f2;
--pda-warning: #a75b0b;
--pda-warning-soft: #fff3e3;
--pda-danger: #b42318;
--pda-danger-soft: #fff0ee;
--pda-shadow-soft: 0 2px 8px rgba(16,24,40,.06);
--pda-shadow: 0 8px 24px rgba(16,24,40,.08);
--pda-focus: rgba(10,102,216,.24);
}
html { background: #e7ebf0; }
body {
background: var(--pda-bg);
color: var(--pda-ink);
font-size: 15px;
line-height: 1.45;
}
button, input, select { font-family: inherit; }
button { transition: background-color .16s ease, border-color .16s ease, box-shadow .16s ease, transform .16s ease, opacity .16s ease; }
button:active:not(:disabled) { transform: scale(.985); }
button:focus-visible, input:focus-visible, select:focus-visible { outline: 3px solid var(--pda-focus); outline-offset: 2px; }
/* 登录 / 工作台 */
.login-page {
justify-content: center;
min-height: 100dvh;
padding: 24px 18px;
background: linear-gradient(160deg, #eaf3ff 0%, #f2f4f7 54%, #f2f4f7 100%);
}
.login-logo {
width: min(430px, 100%);
margin: 0 auto;
padding: 28px 24px 24px;
border-radius: 18px 18px 0 0;
background: linear-gradient(140deg, var(--pda-blue-deep), var(--pda-blue));
}
.login-logo-icon { width: 54px; height: 54px; border-radius: 14px; background: rgba(255,255,255,.15); border: 1px solid rgba(255,255,255,.18); }
.login-logo-title { font-size: 20px; letter-spacing: 0; }
.login-status { margin-top: 20px; }
.login-card {
width: min(430px, 100%);
margin: 0 auto;
padding: 24px;
border: 1px solid var(--pda-line);
border-top: 0;
border-radius: 0 0 18px 18px;
background: var(--pda-surface);
box-shadow: var(--pda-shadow);
}
.login-card-title { font-size: 20px; color: #1d2939; }
.login-card-desc { color: var(--pda-muted); line-height: 1.55; }
.login-field-label { color: #475467; }
.login-field-input { height: 50px; border-color: #d0d5dd; border-radius: 11px; background: #fff; font-size: 16px; }
.login-field-input:focus { border-color: var(--pda-blue); box-shadow: 0 0 0 3px var(--pda-blue-soft); }
.login-btn { height: 50px; border-radius: 11px; background: var(--pda-blue); box-shadow: 0 6px 14px rgba(10,102,216,.2); font-weight: 750; }
.login-btn:hover { background: var(--pda-blue-deep); }
.login-demo-tip { border-radius: 9px; background: #f8fafc; color: var(--pda-muted); }
.login-accounts { border-color: var(--pda-line); }
.login-quick-btn { min-height: 56px; border-color: var(--pda-line); border-radius: 10px; background: #f8fafc; }
.login-quick-btn:hover { border-color: #b8d2f7; background: var(--pda-blue-soft); }
.app-shell { background: var(--pda-bg); }
.app-topbar {
padding: 12px 16px 18px;
border-radius: 0;
background: var(--pda-surface);
color: var(--pda-ink);
box-shadow: var(--pda-shadow-soft);
}
.app-topbar-row { min-height: 44px; }
.app-brand-mark { width: 40px; height: 40px; border-radius: 11px; color: #fff; background: var(--pda-blue); box-shadow: 0 4px 10px rgba(10,102,216,.2); }
.app-brand-title { color: #1d2939; font-size: 16px; }
.app-brand-sub { color: var(--pda-muted); opacity: 1; }
.profile-entry { width: 44px; height: 44px; border: 1px solid #d8e8ff; border-radius: 50%; color: var(--pda-blue); background: var(--pda-blue-soft); }
.profile-entry:hover { background: #dcecff; }
.welcome-card {
align-items: flex-start;
margin-top: 18px;
padding: 18px;
border-radius: 16px;
color: #fff;
background: linear-gradient(140deg, var(--pda-blue-deep), var(--pda-blue));
box-shadow: 0 8px 20px rgba(10,102,216,.18);
}
.welcome-kicker, .welcome-meta { color: rgba(255,255,255,.82); opacity: 1; }
.welcome-name { font-size: 23px; }
.shift-badge { min-height: 32px; padding: 7px 10px; border: 1px solid rgba(255,255,255,.22); border-radius: 9px; background: rgba(255,255,255,.12); }
.today-panel { gap: 8px; margin: 14px 0 20px; }
.today-stat { min-height: 88px; padding: 13px 10px; border-color: var(--pda-line); border-radius: 13px; background: var(--pda-surface); box-shadow: none; }
.today-stat-num { color: #1d2939; font-size: 25px; }
.today-stat-label { color: var(--pda-muted); font-size: 12px; }
.section-heading { margin: 22px 2px 10px; }
.section-heading-title { color: #1d2939; font-size: 16px; }
.section-heading-title::before { width: 4px; height: 16px; border-radius: 4px; background: var(--pda-blue); }
.module-card { padding: 0; border: 0; background: transparent; box-shadow: none; }
.module-grid { gap: 10px; }
.module-item, .module-item.active-menu {
min-height: 116px;
margin: 0;
padding: 14px;
border: 1px solid var(--pda-line);
border-radius: 14px;
color: #344054;
background: var(--pda-surface);
box-shadow: var(--pda-shadow-soft);
}
.module-item:hover { border-color: #b8d2f7; background: #fbfdff; }
.module-icon { width: 42px; height: 42px; margin: 0 auto 10px; border-radius: 12px; color: var(--pda-blue); background: var(--pda-blue-soft); box-shadow: none; font-size: 21px; }
.module-name { color: #475467; font-size: 13px; font-weight: 700; }
.module-badge { top: 10px; right: 10px; min-width: 22px; height: 22px; border-radius: 99px; background: var(--pda-warning); font-size: 10px; line-height: 22px; }
/* 个人中心 */
.profile-cover { height: 150px; background: linear-gradient(140deg, var(--pda-blue-deep), var(--pda-blue)); }
.profile-cover .page-back { color: #fff; background: rgba(255,255,255,.14); }
.profile-card { margin: -58px 14px 14px; border-radius: 16px; box-shadow: var(--pda-shadow); }
.profile-avatar { border-radius: 20px; background: var(--pda-blue-soft); color: var(--pda-blue); }
.profile-name { color: #1d2939; }
.profile-role, .profile-list-value { color: var(--pda-muted); }
.profile-list { margin: 12px 14px; border-color: var(--pda-line); border-radius: 14px; box-shadow: none; }
.profile-list-item { min-height: 60px; padding: 13px 0; color: #475467; border-color: #edf1f5; }
.profile-list-icon { border-radius: 10px; background: var(--pda-blue-soft); }
.profile-logout { width: calc(100% - 28px); margin: 6px 14px 24px; min-height: 48px; border-radius: 11px; color: var(--pda-danger); background: var(--pda-surface); }
/* 入库 / 合托公共工作面 */
.page-topbar { height: 62px; padding: 8px 14px; border-color: var(--pda-line); background: rgba(255,255,255,.96); box-shadow: var(--pda-shadow-soft); backdrop-filter: saturate(160%) blur(16px); }
.page-back { width: 44px; height: 44px; border-radius: 10px; color: var(--pda-blue); background: transparent; font-size: 30px; line-height: 1; }
.page-back:hover { background: var(--pda-blue-soft); }
.page-title { color: #1d2939; font-size: 18px; font-weight: 750; }
.page-user { width: 44px; height: 44px; border: 1px solid #d8e8ff; border-radius: 50%; color: var(--pda-blue); background: var(--pda-blue-soft); }
.inbound-content { padding: 18px 14px calc(28px + env(safe-area-inset-bottom)); }
.inbound-card { margin-bottom: 10px; padding: 16px; border-color: var(--pda-line); border-radius: 14px; background: var(--pda-surface); box-shadow: none; }
.inbound-card-title { color: #1d2939; font-size: 16px; }
.inbound-card-sub, .inbound-grid span, .inbound-sku-row span { color: var(--pda-muted); }
.inbound-status { padding: 6px 9px; border-radius: 99px; color: var(--pda-warning); background: var(--pda-warning-soft); }
.inbound-status.good { color: var(--pda-success); background: var(--pda-success-soft); }
.inbound-grid div { border-color: #edf1f5; }
.inbound-grid strong { color: #344054; }
.inbound-btn { min-height: 46px; border-radius: 10px; background: var(--pda-blue); }
.inbound-btn:hover { background: var(--pda-blue-deep); }
.inbound-btn.secondary { color: var(--pda-blue); background: var(--pda-blue-soft); }
.inbound-scan { margin-top: 14px; }
.inbound-scan input, .consolidation-field select, .consolidation-field input { height: 48px; border-color: #d0d5dd; border-radius: 10px; background: #fff; font-size: 16px; }
.inbound-scan input:focus, .consolidation-field select:focus, .consolidation-field input:focus { border-color: var(--pda-blue); box-shadow: 0 0 0 3px var(--pda-blue-soft); outline: 0; }
.inbound-scan button { width: 48px; border-radius: 10px; background: var(--pda-blue); }
.inbound-notice { border-radius: 10px; color: var(--pda-warning); background: var(--pda-warning-soft); }
.inbound-notice.error { color: var(--pda-danger); background: var(--pda-danger-soft); }
.consolidation-footer { border-color: var(--pda-line); background: rgba(255,255,255,.96); box-shadow: 0 -4px 14px rgba(16,24,40,.06); }
/* 集装箱拣货工作面 */
.header { padding: 12px 14px 14px; border-radius: 0 0 16px 16px; background: linear-gradient(140deg, var(--pda-blue-deep), var(--pda-blue)); box-shadow: 0 8px 20px rgba(10,102,216,.18); }
.header-title { font-size: 18px; }
.header-badge { padding: 6px 9px; border-radius: 99px; }
.box-select-wrap, .scan-section, .info-cards, .progress-section, .pick-list { margin-left: 14px; margin-right: 14px; }
.box-select-btn { min-height: 48px; border-color: #c7dbf7; border-radius: 11px; background: var(--pda-surface); color: #174d91; box-shadow: var(--pda-shadow-soft); }
.scan-section, .info-card, .progress-section, .pick-item { border-color: var(--pda-line); border-radius: 14px; box-shadow: none; }
.scan-section { padding: 14px; background: var(--pda-surface); }
.scan-input { height: 48px; border-color: #d0d5dd; border-radius: 10px; background: #fff; }
.scan-btn { width: 48px; height: 48px; border-radius: 10px; background: var(--pda-blue); }
.progress-bar-fill { background: var(--pda-success); }
.pick-item { padding: 15px; }
.pick-item.active { border-left-color: var(--pda-blue); box-shadow: 0 3px 12px rgba(10,102,216,.1); }
.pick-btn { min-height: 44px; border-radius: 9px; }
.pick-btn-confirm { background: var(--pda-success); }
.pick-btn-partial { color: var(--pda-blue); background: var(--pda-blue-soft); border-color: #9ebcec; }
.submit-section { box-shadow: 0 -4px 14px rgba(16,24,40,.07); }
.submit-btn { min-height: 50px; border-radius: 10px; background: var(--pda-blue); }
@media (max-width: 360px) {
.login-page { padding-inline: 14px; }
.login-logo { padding-inline: 18px; }
.login-card { padding-inline: 18px; }
.module-item, .module-item.active-menu { min-height: 104px; padding-inline: 8px; }
.module-name { font-size: 12px; }
.inbound-content { padding-inline: 12px; }
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
<!--
Copyright (c) Microsoft Corporation.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<!DOCTYPE html>
<html lang="en" translate="no">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="/playwright-logo.svg" type="image/svg+xml">
<title>Playwright Dashboard</title>
<script type="module" crossorigin src="/assets/index-CyWAfh-p.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DhC616m4.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html style='scrollbar-gutter: stable both-edges;'>
<head>
<meta charset='UTF-8'>
<meta name='color-scheme' content='dark light'>
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
<title>Playwright Test Report</title>
<script type="module" crossorigin src="./report.js"></script>
<link rel="stylesheet" crossorigin href="./report.css">
</head>
<body>
<div id='root'></div>
</body>
</html>
@@ -0,0 +1,29 @@
<!--
Copyright (c) Microsoft Corporation.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<!DOCTYPE html>
<html lang="en" translate="no">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="/playwright-logo.svg" type="image/svg+xml">
<title>Playwright Inspector</title>
<script type="module" crossorigin src="/assets/index-DYjdXIbE.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-l_lX622x.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en" translate="no">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="./playwright-logo.svg" type="image/svg+xml">
<link rel="manifest" href="./manifest.webmanifest">
<title>Playwright Trace Viewer</title>
<script type="module" crossorigin src="./index.KZ4wOW1K.js"></script>
<link rel="modulepreload" crossorigin href="./assets/urlMatch-L3liM589.js">
<link rel="modulepreload" crossorigin href="./assets/defaultSettingsView-B-dXF5JN.js">
<link rel="stylesheet" crossorigin href="./defaultSettingsView.BLFoOugd.css">
<link rel="stylesheet" crossorigin href="./index.B_TqY17P.css">
</head>
<body>
<div id="root"></div>
<dialog id="fallback-error">
<p>The Playwright Trace Viewer must be loaded over the <code>http://</code> or <code>https://</code> protocols.</p>
<p>For more information, please see the <a href="https://aka.ms/playwright/trace-viewer-file-protocol">docs</a>.</p>
</dialog>
<script>
if (!/^https?:/.test(window.location.protocol)) {
const fallbackErrorDialog = document.getElementById('fallback-error');
const isTraceViewerInsidePlaywrightReport = window.location.protocol === 'file:' && window.location.pathname.endsWith('/trace/index.html');
// Best-effort to show the report path in the dialog.
if (isTraceViewerInsidePlaywrightReport) {
const reportPath = (() => {
const base = decodeURIComponent(window.location.pathname).replace(/\/trace\/index\.html$/, '');
if (navigator.platform === 'Win32')
return base.replace(/^\//, '').replace(/\//g, '\\\\');
return base;
})();
const reportLink = document.createElement('div');
const command = `npx playwright show-report "${reportPath}"`;
reportLink.innerHTML = `You can open the report via <code>${command}</code> from your Playwright project. <button type="button">Copy Command</button>`;
fallbackErrorDialog.insertBefore(reportLink, fallbackErrorDialog.children[1]);
reportLink.querySelector('button').addEventListener('click', () => navigator.clipboard.writeText(command));
}
fallbackErrorDialog.show();
}
</script>
</body>
</html>
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<script type="module" crossorigin src="./snapshot.B_Jk1wbt.js"></script>
<link rel="modulepreload" crossorigin href="./assets/urlMatch-L3liM589.js">
<body>
<iframe src="about:blank" sandbox="allow-same-origin allow-scripts" style="position:absolute;top:0;left:0;right:0;bottom:0;width:100%;height:100%;border:none;"></iframe>
</body>
</html>
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en" translate="no">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="./playwright-logo.svg" type="image/svg+xml">
<title>Playwright Test</title>
<script type="module" crossorigin src="./uiMode.Dzuouizj.js"></script>
<link rel="modulepreload" crossorigin href="./assets/urlMatch-L3liM589.js">
<link rel="modulepreload" crossorigin href="./assets/defaultSettingsView-B-dXF5JN.js">
<link rel="stylesheet" crossorigin href="./defaultSettingsView.BLFoOugd.css">
<link rel="stylesheet" crossorigin href="./uiMode.C7UW1sC9.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+51
View File
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>柏悦物流 PDA · 仓储作业平台</title>
<link rel="icon" href="data:,">
<style>
:root { --primary:#1769e0; --primary-deep:#123b72; --primary-soft:#eaf2ff; --bg:#f3f6fa; --card:#fff; --text:#172b4d; --text-secondary:#6b788c; --border:#d9e4f2; --danger:#dc2626; --success:#0d9488; --shadow:0 4px 14px rgba(18,59,114,.08); }
* { box-sizing:border-box; margin:0; padding:0; }
body { min-height:100vh; max-width:480px; margin:0 auto; overflow-x:hidden; background:var(--bg); color:var(--text); font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Noto Sans SC",sans-serif; }
button,input { font:inherit; }
.hidden { display:none !important; }
.toast { position:fixed; top:70px; left:50%; z-index:400; padding:10px 20px; border-radius:8px; transform:translateX(-50%) translateY(-20px); opacity:0; color:#fff; background:#1f2937; transition:all .3s; pointer-events:none; }
.toast.show { transform:translateX(-50%) translateY(0); opacity:1; }
.toast.success { background:var(--success); }
.login-page { display:flex; flex-direction:column; justify-content:center; min-height:100vh; padding:32px 24px; background:linear-gradient(160deg,#1769e0 0%,#0d47a1 100%); }
.login-logo { margin:0 -22px; padding:54px 22px 42px; text-align:left; }.login-brand-line { display:flex; align-items:center; gap:14px; }.login-logo-icon { display:flex; align-items:center; justify-content:center; width:62px; height:62px; border-radius:16px; background:rgba(255,255,255,.15); }.login-logo-icon svg { width:31px; height:31px; }.login-logo-title { color:#fff; font-size:21px; font-weight:700; }.login-logo-sub { margin-top:5px; color:rgba(255,255,255,.72); font-size:12px; }.login-status { display:flex; align-items:center; gap:6px; margin-top:25px; color:rgba(255,255,255,.8); font-size:11px; }.login-status::before { width:7px; height:7px; border-radius:50%; background:#48d5bc; box-shadow:0 0 0 4px rgba(72,213,188,.12); content:""; }
.login-card { padding:22px; border-radius:12px; background:#fff; box-shadow:0 8px 32px rgba(0,0,0,.18); }.login-card-title { color:#172033; font-size:18px; font-weight:750; }.login-card-desc { margin:5px 0 20px; color:#78869a; font-size:12px; }.login-field { margin-bottom:15px; }.login-field-label { margin-bottom:7px; color:#445168; font-size:13px; font-weight:600; }.login-input-wrap { position:relative; }.login-input-icon { position:absolute; top:50%; left:13px; display:flex; color:#8290a4; transform:translateY(-50%); }.login-input-icon svg,.password-toggle svg { width:18px; height:18px; }.login-field-input { width:100%; height:46px; padding:0 42px; border:1px solid #d7e0eb; border-radius:8px; outline:0; color:#243b53; background:#f9fbfd; font-size:14px; }.login-field-input:focus { border-color:var(--primary); background:#fff; box-shadow:0 0 0 3px rgba(23,105,224,.08); }.password-toggle { position:absolute; top:50%; right:10px; display:flex; align-items:center; justify-content:center; width:28px; height:28px; border:0; color:#8290a4; background:transparent; transform:translateY(-50%); cursor:pointer; }.login-error { display:none; margin-bottom:12px; padding:9px 11px; border-radius:7px; color:var(--danger); background:#fff1f1; font-size:13px; }.login-error.show { display:block; }.login-btn { width:100%; height:48px; margin-top:5px; border:0; border-radius:8px; color:#fff; background:#1769e0; box-shadow:0 6px 14px rgba(23,105,224,.2); font-size:16px; font-weight:600; cursor:pointer; }.login-demo-tip { margin-top:13px; padding:10px 12px; border-radius:7px; color:#6b788c; background:#f2f5f9; font-size:11px; text-align:center; }.login-accounts { margin-top:16px; padding-top:14px; border-top:1px solid #e3e8ef; }.login-accounts-title { margin-bottom:9px; color:#78869a; font-size:11px; text-align:left; }.login-quick-btns { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }.login-quick-btn { padding:10px; border:1px solid #dbe4ee; border-radius:8px; color:#334e68; background:#fff; text-align:left; cursor:pointer; }.login-quick-btn-name,.login-quick-btn-role { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }.login-quick-btn-name { font-size:13px; font-weight:650; }.login-quick-btn-role { margin-top:3px; color:#78869a; font-size:11px; }
.workbench-page { --wb-brand:#155eef; --wb-brand-deep:#123b72; --wb-brand-surface:#f4f8ff; --wb-brand-border:#a7c4f4; --wb-surface:#fff; --wb-border:#d9e4f2; --wb-text:#172b4d; --wb-text-secondary:#627388; --wb-danger:#e5484d; }.app-shell { min-height:100vh; padding-bottom:28px; background:var(--bg); }.app-topbar { padding:15px 16px 24px; border-radius:0 0 16px 16px; color:#fff; background:linear-gradient(140deg,var(--wb-brand-deep),var(--wb-brand)); box-shadow:0 8px 20px rgba(18,59,114,.18); }.app-topbar-row,.welcome-card,.section-heading { display:flex; align-items:center; justify-content:space-between; }.app-topbar-row { gap:12px; }.app-brand { display:flex; align-items:center; gap:10px; }.app-brand-mark { display:flex; align-items:center; justify-content:center; width:36px; height:36px; border-radius:9px; background:rgba(255,255,255,.18); font-size:18px; }.app-brand-title { font-size:17px; font-weight:700; }.app-brand-sub { margin-top:2px; font-size:11px; opacity:.72; }.profile-entry { display:flex; align-items:center; justify-content:center; width:40px; height:40px; border:1px solid rgba(255,255,255,.25); border-radius:10px; color:#fff; background:rgba(255,255,255,.16); font-size:17px; font-weight:700; cursor:pointer; }.welcome-card { align-items:flex-end; gap:12px; margin-top:18px; }.welcome-kicker { font-size:12px; opacity:.75; }.welcome-name { margin-top:3px; font-size:21px; font-weight:700; }.welcome-meta { margin-top:5px; font-size:12px; opacity:.82; }.shift-badge { flex:none; padding:7px 10px; border-radius:10px; background:rgba(255,255,255,.15); font-size:11px; }.shift-badge::before { display:inline-block; width:7px; height:7px; margin-right:5px; border-radius:50%; background:#24b886; content:""; }.workbench-content { padding:14px 12px 22px; }.today-panel { display:grid; grid-template-columns:repeat(3,1fr); gap:8px; margin-bottom:14px; }.today-stat { padding:12px 8px; border:1px solid var(--wb-border); border-radius:12px; background:var(--wb-surface); box-shadow:var(--shadow); text-align:center; }.today-stat-num { color:var(--wb-brand-deep); font-size:20px; font-weight:750; line-height:1.1; }.today-stat-label { margin-top:5px; color:var(--wb-text-secondary); font-size:11px; }.section-heading { margin:18px 2px 8px; }.section-heading-title { display:flex; align-items:center; gap:8px; color:var(--wb-text); font-size:15px; font-weight:700; }.section-heading-title::before { width:4px; height:16px; border-radius:3px; background:var(--wb-brand); content:""; }.module-card { padding:4px 6px; border:1px solid var(--wb-border); border-radius:10px; background:var(--wb-surface); box-shadow:var(--shadow); }.module-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }.module-item { position:relative; min-height:78px; margin:3px; padding:7px 3px; border:1px solid var(--wb-brand-border); border-radius:9px; color:#174ea6; background:var(--wb-brand-surface); box-shadow:0 4px 10px rgba(18,59,114,.1); cursor:pointer; }.module-icon { display:flex; align-items:center; justify-content:center; width:40px; height:40px; margin:0 auto 7px; border-radius:10px; color:#fff; background:var(--wb-brand); box-shadow:0 5px 12px rgba(18,59,114,.16); font-size:20px; }.module-name { font-size:12px; line-height:1.3; white-space:nowrap; }.module-badge { position:absolute; top:7px; right:5px; min-width:17px; height:17px; padding:0 4px; border-radius:9px; color:#fff; background:var(--wb-danger); font-size:9px; line-height:17px; }
.page-back { width:38px; height:38px; border:0; border-radius:11px; color:#fff; background:rgba(255,255,255,.14); font-size:20px; cursor:pointer; }.profile-cover { height:132px; padding:16px; color:#fff; background:linear-gradient(140deg,#123b72,#1769e0); }.profile-card { position:relative; margin:-49px 12px 12px; padding:18px; border-radius:12px; background:#fff; box-shadow:0 10px 28px rgba(20,49,82,.13); text-align:center; }.profile-avatar { display:flex; align-items:center; justify-content:center; width:68px; height:68px; margin:-50px auto 10px; border:4px solid #fff; border-radius:16px; color:#155eef; background:#eaf2ff; font-size:25px; font-weight:800; }.profile-name { color:#172b4d; font-size:19px; font-weight:700; }.profile-role { margin-top:4px; color:#728196; font-size:12px; }.profile-stats { display:grid; grid-template-columns:repeat(3,1fr); margin-top:16px; padding-top:14px; border-top:1px solid #edf1f5; }.profile-stat strong { display:block; color:#163b68; font-size:17px; }.profile-stat span { color:#8a97a8; font-size:10px; }.profile-list { margin:12px; padding:2px 14px; border:1px solid #e3e8ef; border-radius:10px; background:#fff; }.profile-list-item { display:flex; align-items:center; gap:12px; padding:15px 0; border-bottom:1px solid #edf1f5; color:#334e68; font-size:13px; }.profile-list-item:last-child { border-bottom:0; }.profile-list-icon { display:flex; align-items:center; justify-content:center; width:32px; height:32px; border-radius:8px; background:#eef4ff; }.profile-list-value { margin-left:auto; color:#829ab1; font-size:12px; }.profile-logout { width:calc(100% - 24px); margin:4px 12px 24px; padding:13px; border:1px solid #fecaca; border-radius:9px; color:#dc2626; background:#fff; font-size:14px; font-weight:600; cursor:pointer; }
@media (max-width:360px) { .login-page { padding-right:16px; padding-left:16px; } .login-logo { margin-right:-16px; margin-left:-16px; padding-right:18px; padding-left:18px; } .module-name { font-size:11px; } }
</style>
<link rel="stylesheet" href="domestic-overseas-theme.css">
</head>
<body>
<div class="toast" id="toast"></div>
<div class="login-page" id="loginPage"><div class="login-logo"><div class="login-brand-line"><div class="login-logo-icon"><svg viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 3v18"/></svg></div><div><div class="login-logo-title">柏悦物流 PDA</div><div class="login-logo-sub">仓储移动作业平台</div></div></div><div class="login-status">作业系统运行正常</div></div><div class="login-card"><div class="login-card-title">账号登录</div><div class="login-card-desc">请输入作业人员账号进入工作台</div><div class="login-field"><div class="login-field-label">用户名</div><div class="login-input-wrap"><span class="login-input-icon">♙</span><input class="login-field-input" id="loginUser" placeholder="请输入用户名" autocomplete="username"></div></div><div class="login-field"><div class="login-field-label">密码</div><div class="login-input-wrap"><span class="login-input-icon">▣</span><input class="login-field-input" id="loginPass" type="password" placeholder="请输入密码" autocomplete="current-password"><button class="password-toggle" id="passwordToggle" type="button" onclick="togglePassword()" aria-label="显示密码">◉</button></div></div><div class="login-error" id="loginError">用户名或密码错误</div><button class="login-btn" type="button" onclick="handleLogin()">登录</button><div class="login-demo-tip">演示账号:W001 密码:123456</div><div class="login-accounts"><div class="login-accounts-title">快捷登录</div><div class="login-quick-btns"><button class="login-quick-btn" type="button" onclick="quickLogin('W001','张伟','拣货员')"><span class="login-quick-btn-name">W001 · 张伟</span><span class="login-quick-btn-role">拣货员 · 洛杉矶仓</span></button><button class="login-quick-btn" type="button" onclick="quickLogin('W002','李敏','拣货组长')"><span class="login-quick-btn-name">W002 · 李敏</span><span class="login-quick-btn-role">拣货组长 · 洛杉矶仓</span></button></div></div></div></div>
<div class="workbench-page hidden" id="workbenchPage"><div class="app-shell"><div class="app-topbar"><div class="app-topbar-row"><div class="app-brand"><div class="app-brand-mark">▦</div><div><div class="app-brand-title">柏悦物流 PDA</div><div class="app-brand-sub">仓储移动作业平台</div></div></div><button class="profile-entry" id="workbenchAvatar" type="button" onclick="openProfile()" aria-label="个人中心">张</button></div><div class="welcome-card"><div><div class="welcome-kicker">欢迎回来</div><div class="welcome-name" id="workbenchUserName">张伟</div><div class="welcome-meta"><span id="workbenchRole">拣货员</span> · <span id="workbenchWarehouse">洛杉矶仓</span></div></div><div class="shift-badge">白班 · 作业中</div></div></div><div class="workbench-content"><div class="today-panel"><div class="today-stat"><div class="today-stat-num">3</div><div class="today-stat-label">待拣集装箱</div></div><div class="today-stat"><div class="today-stat-num">0</div><div class="today-stat-label">今日已完成</div></div><div class="today-stat"><div class="today-stat-num">17</div><div class="today-stat-label">待拣货品</div></div></div><div class="section-heading"><div class="section-heading-title">入库</div></div><div class="module-card"><div class="module-grid"><button class="module-item active-menu" id="receiveEntry" type="button" onclick="navigateToModule('入库/收货登记.html')"><span class="module-badge">3</span><span class="module-icon">📥</span><span class="module-name">收货登记</span></button><button class="module-item active-menu" id="palletEntry" type="button" onclick="navigateToModule('入库/入库打托.html')"><span class="module-badge">2</span><span class="module-icon">🪵</span><span class="module-name">入库打托</span></button></div></div><div class="section-heading"><div class="section-heading-title">出库</div></div><div class="module-card"><div class="module-grid" id="outboundModuleGrid"><button class="module-item active-menu" id="consolidationEntry" type="button" onclick="navigateToModule('出库/合托.html')"><span class="module-badge">1</span><span class="module-icon">🪵</span><span class="module-name">合托</span></button><button class="module-item active-menu" id="pickingEntry" type="button" onclick="navigateToModule('出库/集装箱拣货.html')"><span class="module-badge">3</span><span class="module-icon">📦</span><span class="module-name">集装箱拣货</span></button></div></div></div></div></div>
<div class="profile-page hidden" id="profilePage"><div class="app-shell"><div class="profile-cover"><button class="page-back" type="button" onclick="backFromProfile()">‹</button></div><div class="profile-card"><div class="profile-avatar" id="profileAvatar">张</div><div class="profile-name" id="profileName">张伟</div><div class="profile-role" id="profileRole">W001 · 拣货员</div><div class="profile-stats"><div class="profile-stat"><strong>28</strong><span>今日完成</span></div><div class="profile-stat"><strong>156</strong><span>本月任务</span></div><div class="profile-stat"><strong>99.2%</strong><span>作业准确率</span></div></div></div><div class="profile-list"><div class="profile-list-item"><span class="profile-list-icon">🏢</span><span>当前仓库</span><span class="profile-list-value" id="profileWarehouse">洛杉矶仓</span></div><div class="profile-list-item"><span class="profile-list-icon">🕘</span><span>当前班次</span><span class="profile-list-value">白班 08:00–17:00</span></div><div class="profile-list-item"><span class="profile-list-icon">🔐</span><span>修改密码</span><span class="profile-list-value">›</span></div><div class="profile-list-item"><span class="profile-list-icon">⚙️</span><span>系统设置</span><span class="profile-list-value">›</span></div><div class="profile-list-item"><span class="profile-list-icon">ℹ️</span><span>版本信息</span><span class="profile-list-value">V1.0.0</span></div></div><button class="profile-logout" type="button" onclick="handleLogout()">退出登录</button></div></div>
<script>
const DEMO_ACCOUNTS = { W001:{pass:'123456',name:'张伟',role:'拣货员',warehouse:'洛杉矶仓'}, W002:{pass:'123456',name:'李敏',role:'拣货组长',warehouse:'洛杉矶仓'}, W003:{pass:'123456',name:'王磊',role:'仓管员',warehouse:'新泽西仓'} };
const USER_STORAGE_KEY = 'pdaCurrentUser'; let currentUser = null; let toastTimer = null;
function buildUserParams(user) { return new URLSearchParams({ id:user.id, name:user.name, role:user.role, warehouse:user.warehouse }); }
function persistCurrentUser() { localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(currentUser)); }
function navigateToModule(relativePath) { persistCurrentUser(); location.href = `${relativePath}?${buildUserParams(currentUser)}`; }
function togglePassword() { const input = document.getElementById('loginPass'); const showing = input.type === 'text'; input.type = showing ? 'password' : 'text'; document.getElementById('passwordToggle').setAttribute('aria-label', showing ? '显示密码' : '隐藏密码'); }
function showToast(message,type='') { const toast=document.getElementById('toast'); toast.textContent=message; toast.className=`toast show ${type}`; clearTimeout(toastTimer); toastTimer=setTimeout(()=>{toast.className='toast';},2200); }
function showLoginError(message) { const error=document.getElementById('loginError'); error.textContent=message; error.classList.add('show'); } function hideLoginError() { document.getElementById('loginError').classList.remove('show'); }
function updateUserUI() { const initial=currentUser.name.slice(0,1); document.getElementById('workbenchAvatar').textContent=initial; document.getElementById('workbenchUserName').textContent=currentUser.name; document.getElementById('workbenchRole').textContent=currentUser.role; document.getElementById('workbenchWarehouse').textContent=currentUser.warehouse; document.getElementById('profileAvatar').textContent=initial; document.getElementById('profileName').textContent=currentUser.name; document.getElementById('profileRole').textContent=`${currentUser.id} · ${currentUser.role}`; document.getElementById('profileWarehouse').textContent=currentUser.warehouse; }
function showAppPage(pageId) { ['workbenchPage','profilePage'].forEach(id=>document.getElementById(id).classList.toggle('hidden',id!==pageId)); window.scrollTo(0,0); }
function enterMainPage(showWelcome=true) { document.getElementById('loginPage').classList.add('hidden'); showAppPage('workbenchPage'); updateUserUI(); hideLoginError(); if(showWelcome) showToast(`${currentUser.name},欢迎登录`,'success'); }
function handleLogin() { const userId=document.getElementById('loginUser').value.trim(); const password=document.getElementById('loginPass').value; if(!userId) return showLoginError('请输入用户名'); if(!password) return showLoginError('请输入密码'); const account=DEMO_ACCOUNTS[userId]; if(!account||account.pass!==password) return showLoginError('用户名或密码错误'); currentUser={id:userId,name:account.name,role:account.role,warehouse:account.warehouse}; persistCurrentUser(); enterMainPage(); }
function quickLogin(id,name,role) { currentUser={id,name,role,warehouse:DEMO_ACCOUNTS[id].warehouse}; persistCurrentUser(); enterMainPage(); } function openProfile() { showAppPage('profilePage'); } function backFromProfile() { showAppPage('workbenchPage'); }
function handleLogout() { currentUser=null; localStorage.removeItem(USER_STORAGE_KEY); document.getElementById('loginPage').classList.remove('hidden'); document.getElementById('workbenchPage').classList.add('hidden'); document.getElementById('profilePage').classList.add('hidden'); document.getElementById('loginUser').value=''; document.getElementById('loginPass').value=''; hideLoginError(); }
function readUserFromParams(params) { if(!params.get('id')) return null; return Object.fromEntries(['id','name','role','warehouse'].map(key=>[key,params.get(key)||''])); } function readStoredUser() { try { const stored=JSON.parse(localStorage.getItem(USER_STORAGE_KEY)); return stored?.id ? stored : null; } catch { return null; } }
function restoreWorkbenchSession() { const params=new URLSearchParams(location.search); if(params.get('resume')!=='1') return; currentUser=readUserFromParams(params)||readStoredUser(); if(!currentUser) return; persistCurrentUser(); enterMainPage(false); }
document.getElementById('loginPass').addEventListener('keydown',event=>{if(event.key==='Enter')handleLogin();}); document.getElementById('loginUser').addEventListener('keydown',event=>{if(event.key==='Enter')document.getElementById('loginPass').focus();}); restoreWorkbenchSession();
</script>
</body>
</html>
+282
View File
@@ -0,0 +1,282 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>柏悦物流 PDA · 入库打托</title>
<link rel="icon" href="data:,">
<style>
:root { --primary:#1769e0; --success:#0d9488; --danger:#dc2626; --bg:#f5f7fa; --text:#1f2937; --border:#e5e7eb; --shadow:0 1px 3px rgba(0,0,0,.08); }
* { margin:0; padding:0; box-sizing:border-box; }
body { min-height:100vh; max-width:480px; margin:0 auto; overflow-x:hidden; background:var(--bg); color:var(--text); font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'PingFang SC','Noto Sans SC',sans-serif; }
.toast { position:fixed; top:70px; left:50%; z-index:400; padding:10px 20px; border-radius:8px; background:var(--text); color:#fff; font-size:14px; opacity:0; pointer-events:none; transform:translateX(-50%) translateY(-20px); transition:all .3s; }
.toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
.toast.success { background:var(--success); }
.toast.error { background:var(--danger); }
.inbound-page.hidden, .profile-page.hidden { display:none; }
.app-shell { min-height:100vh; padding-bottom:28px; background:var(--bg); }
.page-topbar { position:sticky; top:0; z-index:90; display:flex; align-items:center; justify-content:space-between; height:60px; padding:0 12px; background:#fff; border-bottom:1px solid var(--border); box-shadow:0 2px 8px rgba(20,49,82,.05); }
.page-back { width:38px; height:38px; border:0; border-radius:11px; background:#f1f5f9; color:#334155; font-size:20px; cursor:pointer; }
.page-title { color:#172b4d; font-size:17px; font-weight:700; }
.page-user { width:36px; height:36px; border:0; border-radius:12px; background:#eaf2ff; color:#155eef; font-size:13px; font-weight:700; cursor:pointer; }
.inbound-content { padding:14px 12px 30px; }
.inbound-card { margin-bottom:12px; padding:15px; border:1px solid #e4eaf2; border-radius:15px; background:#fff; box-shadow:var(--shadow); }
.inbound-card-head { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; }
.inbound-card-title { color:#243b53; font-size:15px; font-weight:700; }
.inbound-card-sub { margin-top:4px; color:#829ab1; font-size:11px; line-height:1.5; }
.inbound-status { flex:none; padding:5px 8px; border-radius:8px; background:#fff4e5; color:#bd6b10; font-size:10px; font-weight:700; }
.inbound-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:9px; margin-top:13px; }
.inbound-grid div { min-width:0; padding-top:9px; border-top:1px solid #edf1f5; }
.inbound-grid span { display:block; color:#829ab1; font-size:10px; }
.inbound-grid strong { display:block; margin-top:3px; overflow:hidden; color:#334e68; font-size:12px; text-overflow:ellipsis; white-space:nowrap; }
.inbound-actions { display:flex; gap:8px; margin-top:13px; }
.inbound-btn { flex:1; min-height:42px; border:0; border-radius:10px; background:#1769e0; color:#fff; font-size:13px; font-weight:700; cursor:pointer; }
.inbound-btn.secondary { background:#eaf2ff; color:#1769e0; }
.inbound-scan { display:flex; gap:8px; margin-top:13px; }
.inbound-scan input { flex:1; min-width:0; height:44px; padding:0 12px; border:1px solid #dfe5ee; border-radius:9px; font-size:13px; }
.inbound-scan button { width:46px; border:0; border-radius:9px; background:#1769e0; color:#fff; font-size:19px; cursor:pointer; }
.order-list-page .inbound-content { padding-bottom:30px; }
.order-list-toolbar { margin-bottom:12px; }
.order-list-summary { display:flex; align-items:center; justify-content:space-between; gap:12px; }
.order-list-summary strong { color:#243b53; font-size:15px; }
.order-list-summary span { color:#627d98; font-size:12px; }
.order-list-table-wrap { overflow-x:auto; border:1px solid #e4eaf2; border-radius:12px; background:#fff; box-shadow:var(--shadow); }
.order-list-table { width:100%; border-collapse:collapse; table-layout:fixed; font-size:11px; }
.order-list-table th { padding:11px 7px; background:#f7f9fc; color:#627d98; font-size:10px; font-weight:700; text-align:left; white-space:nowrap; }
.order-list-table td { padding:11px 7px; border-top:1px solid #edf1f5; color:#334e68; vertical-align:middle; overflow-wrap:anywhere; }
.order-list-table th:nth-child(1), .order-list-table td:nth-child(1) { width:29%; }
.order-list-table th:nth-child(2), .order-list-table td:nth-child(2) { width:29%; }
.order-list-table th:nth-child(3), .order-list-table td:nth-child(3) { width:14%; text-align:center; }
.order-list-table th:nth-child(4), .order-list-table td:nth-child(4) { width:28%; text-align:center; }
.order-list-status { display:inline-flex; align-items:center; padding:4px 7px; border-radius:7px; font-size:10px; font-weight:700; white-space:nowrap; }
.order-list-status.done { background:#e1f6f1; color:#078775; }
.order-list-status.pending { background:#fff4e5; color:#bd6b10; }
.order-list-message { display:none; margin-top:10px; padding:11px 12px; border-radius:9px; background:#f1f5f9; color:#627d98; font-size:12px; text-align:center; }
.order-list-message.show { display:block; }
.pallet-form-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; margin-top:13px; }
.pallet-field { min-width:0; }
.pallet-field label { display:block; margin-bottom:6px; color:#627d98; font-size:11px; }
.pallet-field input { width:100%; height:42px; padding:0 11px; border:1px solid #dfe5ee; border-radius:9px; color:#243b53; font-size:15px; font-weight:600; outline:none; }
.pallet-field input:focus { border-color:#1769e0; box-shadow:0 0 0 3px rgba(23,105,224,.12); }
.pallet-count-panel { display:flex; align-items:center; justify-content:space-between; margin-top:12px; padding:12px; border-radius:9px; background:#f7f9fc; color:#627d98; font-size:12px; }
.pallet-count-panel strong { color:#1769e0; font-size:20px; }
.pallet-box-row { display:grid; grid-template-columns:1.2fr 1.2fr .8fr; gap:8px; padding:11px 0; border-top:1px solid #edf1f5; }
.pallet-box-header { display:grid; grid-template-columns:1.2fr 1.2fr .8fr; gap:8px; padding:0 0 8px; color:#829ab1; font-size:10px; }
.pallet-box-cell { min-width:0; color:#829ab1; font-size:12px; }
.pallet-box-cell strong { display:block; overflow-wrap:anywhere; color:#829ab1; font-size:12px; font-weight:600; }
.inbound-notice { margin:0 0 12px; padding:11px 12px; border-radius:9px; background:#e1f6f1; color:#078775; font-size:12px; }
.inbound-notice.error { background:#fee2e2; color:#b42318; }
.profile-cover { height:132px; padding:16px; background:linear-gradient(140deg,#123b72,#1769e0); }
.profile-cover .page-back { background:rgba(255,255,255,.14); color:#fff; }
.profile-card { position:relative; margin:-49px 12px 12px; padding:18px; border-radius:12px; background:#fff; box-shadow:0 10px 28px rgba(20,49,82,.13); text-align:center; }
.profile-avatar { display:flex; align-items:center; justify-content:center; width:68px; height:68px; margin:-50px auto 10px; border:4px solid #fff; border-radius:16px; background:#eaf2ff; color:#155eef; font-size:25px; font-weight:800; }
.profile-name { color:#172b4d; font-size:19px; font-weight:700; }
.profile-role { margin-top:4px; color:#728196; font-size:12px; }
.profile-stats { display:grid; grid-template-columns:repeat(3,1fr); margin-top:16px; padding-top:14px; border-top:1px solid #edf1f5; }
.profile-stat strong { display:block; color:#163b68; font-size:17px; }
.profile-stat span { color:#8a97a8; font-size:10px; }
.profile-list { margin:12px; padding:2px 14px; border:1px solid #e3e8ef; border-radius:10px; background:#fff; }
.profile-list-item { display:flex; align-items:center; gap:12px; padding:15px 0; border-bottom:1px solid #edf1f5; color:#334e68; font-size:13px; }
.profile-list-item:last-child { border-bottom:0; }
.profile-list-icon { display:flex; align-items:center; justify-content:center; width:32px; height:32px; border-radius:8px; background:#eef4ff; }
.profile-list-value { margin-left:auto; color:#829ab1; font-size:12px; }
.profile-logout { width:calc(100% - 24px); margin:4px 12px 24px; padding:13px; border:1px solid #fecaca; border-radius:9px; background:#fff; color:#dc2626; font-size:14px; font-weight:600; cursor:pointer; }
</style>
<link rel="stylesheet" href="../domestic-overseas-theme.css">
</head>
<body>
<div class="toast" id="toast"></div>
<div class="inbound-page order-list-page hidden" id="orderListPage">
<div class="app-shell">
<div class="page-topbar"><button class="page-back" type="button" onclick="returnToWorkbench()" aria-label="返回工作台">‹</button><div class="page-title">入库单列表</div><button class="page-user" id="orderListAvatar" type="button" onclick="openProfile()" aria-label="个人中心">张</button></div>
<div class="inbound-content">
<div class="inbound-card order-list-toolbar"><div class="inbound-card-head"><div><div class="inbound-card-title">扫描</div><div class="inbound-card-sub">扫描订单号、入库单号或 SKU 快速定位</div></div></div><div class="inbound-scan"><input id="orderListScanInput" placeholder="扫描或输入订单号/入库单号/SKU" autocomplete="off" onkeydown="if(event.key==='Enter')handleOrderListScan()"><button type="button" onclick="openPalletFromScan()" aria-label="扫描订单">▦</button></div></div>
<div class="inbound-card order-list-summary"><div><strong>入库单列表</strong><div class="inbound-card-sub">按入库状态查看当前入库单明细</div></div><span id="orderListCount">5 条</span></div>
<div class="order-list-table-wrap"><table class="order-list-table" id="orderListTable"><thead><tr><th>入库单号</th><th>SKU</th><th>箱数</th><th>入库状态</th></tr></thead><tbody></tbody></table></div>
<div class="order-list-message" id="orderListMessage" aria-live="polite"></div>
</div>
</div>
</div>
<div class="inbound-page hidden" id="palletOrdersPage">
<div class="app-shell">
<div class="page-topbar"><button class="page-back" type="button" onclick="returnToWorkbench()" aria-label="返回工作台">‹</button><div class="page-title">订单列表</div><button class="page-user" id="palletOrdersAvatar" type="button" onclick="openProfile()" aria-label="个人中心">张</button></div>
<div class="inbound-content"><div class="inbound-card"><div class="inbound-card-head"><div><div class="inbound-card-title">入库订单</div><div class="inbound-card-sub">选择已收货订单开始打托</div></div><span class="inbound-status" id="palletOrderCount">2 单</span></div></div><div id="palletOrderList"></div></div>
</div>
</div>
<div class="inbound-page hidden" id="palletPage">
<div class="app-shell"><div class="page-topbar"><button class="page-back" type="button" onclick="openPalletOrders()" aria-label="返回订单列表">‹</button><div class="page-title">入库打托</div><button class="page-user" id="palletAvatar" type="button" onclick="openProfile()" aria-label="个人中心">张</button></div><div class="inbound-content" id="palletContent"></div></div>
</div>
<div class="profile-page hidden" id="profilePage">
<div class="app-shell"><div class="profile-cover"><button class="page-back" type="button" onclick="backFromProfile()" aria-label="返回">‹</button></div><div class="profile-card"><div class="profile-avatar" id="profileAvatar">张</div><div class="profile-name" id="profileName">张伟</div><div class="profile-role" id="profileRole">W001 · 拣货员</div><div class="profile-stats"><div class="profile-stat"><strong>28</strong><span>今日完成</span></div><div class="profile-stat"><strong>156</strong><span>本月任务</span></div><div class="profile-stat"><strong>99.2%</strong><span>作业准确率</span></div></div></div><div class="profile-list"><div class="profile-list-item"><span class="profile-list-icon">🏢</span><span>当前仓库</span><span class="profile-list-value" id="profileWarehouse">洛杉矶仓</span></div><div class="profile-list-item"><span class="profile-list-icon">🕘</span><span>当前班次</span><span class="profile-list-value">白班 08:00–17:00</span></div><div class="profile-list-item"><span class="profile-list-icon">🔐</span><span>修改密码</span><span class="profile-list-value">›</span></div><div class="profile-list-item"><span class="profile-list-icon">⚙️</span><span>系统设置</span><span class="profile-list-value">›</span></div><div class="profile-list-item"><span class="profile-list-icon">ℹ️</span><span>版本信息</span><span class="profile-list-value">V1.0.0</span></div></div><button class="profile-logout" type="button" onclick="handleLogout()">退出登录</button></div>
</div>
<script>
function readCurrentUser() {
const params = new URLSearchParams(location.search);
if (!params.get('id')) return null;
return Object.fromEntries(['id','name','role','warehouse'].map(key => [key, params.get(key) || '']));
}
function buildUserParams(user) {
return new URLSearchParams({ id:user.id, name:user.name, role:user.role, warehouse:user.warehouse });
}
function returnToWorkbench() {
const params = buildUserParams(currentUser);
params.set('resume', '1');
location.href = `../pda-picking.html?${params}`;
}
const currentUser = readCurrentUser() || { id:'W001', name:'张伟', role:'拣货员', warehouse:'洛杉矶仓' };
let profileReturnPage = 'orderListPage';
let toastTimer = null;
let selectedPalletOrderId = null;
let palletItems = [];
let palletSequence = 1;
let palletCartonCount = 0;
let palletCartonsPerPallet = 1;
function showAppPage(pageId) {
['orderListPage', 'palletOrdersPage', 'palletPage', 'profilePage'].forEach(id => document.getElementById(id).classList.toggle('hidden', id !== pageId));
window.scrollTo(0, 0);
}
function updateUserUI() {
const initial = currentUser.name.slice(0, 1) || '我';
document.getElementById('profileAvatar').textContent = initial;
document.getElementById('profileName').textContent = currentUser.name;
document.getElementById('profileRole').textContent = `${currentUser.id} · ${currentUser.role}`;
document.getElementById('profileWarehouse').textContent = currentUser.warehouse;
['orderListAvatar', 'palletOrdersAvatar', 'palletAvatar'].forEach(id => document.getElementById(id).textContent = initial);
}
function showToast(message, type = '') {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.className = `toast show ${type}`;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { toast.className = 'toast'; }, 2200);
}
function openProfile() {
profileReturnPage = ['orderListPage', 'palletOrdersPage', 'palletPage'].find(id => !document.getElementById(id).classList.contains('hidden')) || 'orderListPage';
showAppPage('profilePage');
}
function backFromProfile() { showAppPage(profileReturnPage); }
function handleLogout() {
try { localStorage.removeItem('pdaCurrentUser'); } catch {}
location.href = '../pda-picking.html';
}
const ORDER_LIST_ROWS = [
{ orderNo:'SO-260901-030', inboundNo:'IN-260901-030', sku:'YQ-CASE-01', cartons:2, status:'已完成' },
{ orderNo:'SO-260901-030', inboundNo:'IN-260901-030', sku:'YQ-CABLE-02', cartons:1, status:'未完成' },
{ orderNo:'SO-260901-031', inboundNo:'IN-260901-031', sku:'NH-BAG-04', cartons:1, status:'未完成' },
{ orderNo:'SO-260901-031', inboundNo:'IN-260901-031', sku:'NH-BAG-06', cartons:1, status:'未完成' },
{ orderNo:'SO-260901-032', inboundNo:'IN-260901-032', sku:'YQ-HOLDER-03', cartons:2, status:'未完成' }
];
const PALLET_ORDERS = [
{ id:'IN-260901-030', customer:'杭州积乐贸易', destination:'义乌仓', ticket:'SO-260901-030', total:6, cartons:[['BX-030-01','YQ-CASE-01','手机壳套装',120],['BX-030-02','YQ-CABLE-02','USB-C数据线',80],['BX-030-03','YQ-HOLDER-03','手机支架',60]] },
{ id:'IN-260901-031', customer:'宁波远航电商', destination:'宁波仓', ticket:'SO-260901-031', total:4, cartons:[['BX-031-01','NH-BAG-04','旅行包',32],['BX-031-02','NH-BAG-06','收纳袋',48],['BX-031-03','NH-TAG-07','行李牌',24]] }
];
let orderListRows = ORDER_LIST_ROWS.slice();
function openOrderList() {
showAppPage('orderListPage');
orderListRows = ORDER_LIST_ROWS.slice();
document.getElementById('orderListScanInput').value = '';
renderOrderList();
setTimeout(() => document.getElementById('orderListScanInput').focus(), 0);
}
function renderOrderList() {
const tableBody = document.querySelector('#orderListTable tbody');
const count = document.getElementById('orderListCount');
const message = document.getElementById('orderListMessage');
count.textContent = orderListRows.length + ' 条';
tableBody.innerHTML = orderListRows.map(row => `<tr><td>${row.inboundNo}</td><td>${row.sku}</td><td>${row.cartons}</td><td><span class="order-list-status ${row.status === '已完成' ? 'done' : 'pending'}">${row.status}</span></td></tr>`).join('');
message.textContent = orderListRows.length ? '' : '未找到匹配订单,请核对扫描内容';
message.classList.toggle('show', !orderListRows.length);
}
function handleOrderListScan() {
const input = document.getElementById('orderListScanInput');
const value = input.value.trim().toLowerCase();
orderListRows = value ? ORDER_LIST_ROWS.filter(row => [row.orderNo, row.inboundNo, row.sku].some(field => field.toLowerCase().includes(value))) : ORDER_LIST_ROWS.slice();
renderOrderList();
}
function openPalletFromScan() {
const input = document.getElementById('orderListScanInput');
if (!input.value.trim()) { openPallet(PALLET_ORDERS[0].id); return; }
handleOrderListScan();
const inboundNos = [...new Set(orderListRows.map(row => row.inboundNo))];
if (inboundNos.length !== 1) {
const message = document.getElementById('orderListMessage');
message.textContent = orderListRows.length ? '请扫描更精确的 SKU 或订单号后进入打托' : '未找到匹配订单,请核对扫描内容';
message.classList.add('show');
return;
}
const order = PALLET_ORDERS.find(item => item.id === inboundNos[0]);
if (!order) {
const message = document.getElementById('orderListMessage');
message.textContent = '该订单暂无可打托明细';
message.classList.add('show');
return;
}
openPallet(order.id);
}
function openPalletOrders() {
showAppPage('palletOrdersPage');
renderPalletOrders();
}
function renderPalletOrders() {
document.getElementById('palletOrderCount').textContent = PALLET_ORDERS.length + ' 单';
document.getElementById('palletOrderList').innerHTML = PALLET_ORDERS.map(order => `<div class="inbound-card"><div class="inbound-card-head"><div><div class="inbound-card-title">${order.id}</div><div class="inbound-card-sub">${order.customer} · ${order.ticket}</div></div><span class="inbound-status">待打托</span></div><div class="inbound-grid"><div><span>目的仓</span><strong>${order.destination}</strong></div><div><span>计划托数</span><strong>${order.total} 托</strong></div><div><span>待打托箱数</span><strong>${order.cartons.length} 箱</strong></div><div><span>收货状态</span><strong>已收货</strong></div></div><div class="inbound-actions"><button class="inbound-btn" type="button" onclick="openPallet('${order.id}')">开始打托</button></div></div>`).join('');
}
function openPallet(id) {
selectedPalletOrderId = id;
palletItems = [];
const order = PALLET_ORDERS.find(item => item.id === id) || PALLET_ORDERS[0];
palletCartonCount = order.cartons.length;
palletCartonsPerPallet = order.cartons.length || 1;
showAppPage('palletPage');
renderPallet();
}
function renderPallet() {
const order = PALLET_ORDERS.find(order => order.id === selectedPalletOrderId) || PALLET_ORDERS[0];
const palletCount = Math.max(1, Math.ceil((Number(palletCartonCount) || 0) / Math.max(1, Number(palletCartonsPerPallet) || 1)));
const boxDetails = order.cartons.reduce((rows, carton) => {
const sku = carton[1]; const quantity = carton[3]; const existing = rows.find(row => row.sku === sku && row.quantity === quantity);
if (existing) existing.count += 1; else rows.push({ sku, quantity, count:1 });
return rows;
}, []);
document.getElementById('palletContent').innerHTML = `<div class="inbound-card pallet-scan-section"><div class="inbound-scan"><input id="palletScanInput" placeholder="扫描箱号或 SKU" onkeydown="if(event.key==='Enter')handlePalletScan()"><button type="button" onclick="handlePalletScan()" aria-label="扫码">▦</button></div></div><div class="inbound-card pallet-box-section"><div class="inbound-card-head"><div><div class="inbound-card-title">箱子详情</div></div></div><div id="palletBoxDetails"><div class="pallet-box-header"><span>SKU</span><span>每箱数量</span><span>箱数</span></div>${boxDetails.map(row => `<div class="pallet-box-row"><div class="pallet-box-cell"><strong>${row.sku}</strong></div><div class="pallet-box-cell"><strong>${row.quantity}</strong></div><div class="pallet-box-cell"><strong>${row.count}</strong></div></div>`).join('')}</div></div><div class="inbound-card"><div class="inbound-card-title">打托数量</div><div class="pallet-form-grid"><div class="pallet-field"><label for="palletCartonCountInput">箱数</label><input id="palletCartonCountInput" type="number" min="1" value="${palletCartonCount}" oninput="updatePalletCount()"></div><div class="pallet-field"><label for="palletCartonsPerPalletInput">每托箱数</label><input id="palletCartonsPerPalletInput" type="number" min="1" value="${palletCartonsPerPallet}" oninput="updatePalletCount()"></div></div><div class="pallet-count-panel"><span>托数</span><strong id="palletCountValue">${palletCount}</strong></div></div><div id="palletMessage"></div><div class="inbound-actions"><button class="inbound-btn secondary" type="button" onclick="openOrderList()">返回订单列表</button><button class="inbound-btn" type="button" onclick="confirmPalletInbound()">确认入库</button></div>`;
}
function updatePalletCount() {
palletCartonCount = Number(document.getElementById('palletCartonCountInput')?.value) || 0;
palletCartonsPerPallet = Number(document.getElementById('palletCartonsPerPalletInput')?.value) || 0;
const output = document.getElementById('palletCountValue');
if (output) output.textContent = Math.max(0, Math.ceil(palletCartonCount / Math.max(1, palletCartonsPerPallet)));
}
function handlePalletScan() {
const input = document.getElementById('palletScanInput');
const value = (input?.value || '').trim();
const order = PALLET_ORDERS.find(order => order.id === selectedPalletOrderId);
if (!order || !value) { showPalletMessage('请输入或扫描箱号/SKU', true); return; }
const found = order.cartons.find(carton => carton[0].toLowerCase() === value.toLowerCase() || carton[1].toLowerCase() === value.toLowerCase());
if (!found) { showPalletMessage('未找到匹配货物:' + value, true); return; }
if (palletItems.some(carton => carton[0] === found[0])) { showPalletMessage('该箱号已加入当前托盘', true); return; }
palletItems.push(found);
showToast('已加入托盘:' + found[0], 'success');
renderPallet();
}
function showPalletMessage(message, error) { const element = document.getElementById('palletMessage'); if (element) element.innerHTML = `<div class="inbound-notice ${error ? 'error' : ''}">${message}</div>`; }
function confirmPalletInbound() {
if (!palletCartonCount || !palletCartonsPerPallet) { showPalletMessage('请输入有效的箱数和每托箱数', true); return; }
palletSequence += Math.max(1, Math.ceil(palletCartonCount / palletCartonsPerPallet));
showToast('入库确认成功', 'success');
openOrderList();
}
updateUserUI();
openOrderList();
</script>
</body>
</html>
+219
View File
@@ -0,0 +1,219 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>收货登记 · 柏悦物流 PDA</title>
<link rel="icon" href="data:,">
<style>
:root { --bg:#f5f7fa; --card:#fff; --text:#1f2937; --text-secondary:#6b7280; --border:#e5e7eb; --success:#0d9488; --warning:#f59e0b; --danger:#dc2626; --shadow:0 1px 3px rgba(0,0,0,.08); }
* { box-sizing:border-box; margin:0; padding:0; }
body { max-width:480px; min-height:100vh; margin:0 auto; overflow-x:hidden; background:var(--bg); color:var(--text); font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'PingFang SC','Noto Sans SC',sans-serif; }
.hidden { display:none!important; }
.app-shell { min-height:100vh; padding-bottom:28px; background:var(--bg); }
.inbound-content { padding:14px 12px 30px; }
.inbound-card { margin-bottom:12px; padding:15px; border:1px solid #e4eaf2; border-radius:15px; background:#fff; box-shadow:var(--shadow); }
.inbound-card-head { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; }
.inbound-card-title { color:#243b53; font-size:15px; font-weight:700; }
.inbound-card-sub { margin-top:4px; color:#829ab1; font-size:11px; line-height:1.5; }
.inbound-status { flex:none; padding:5px 8px; border-radius:8px; background:#fff4e5; color:#bd6b10; font-size:10px; font-weight:700; }
.inbound-status.good { background:#e1f6f1; color:#078775; }
.inbound-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:9px; margin-top:13px; }
.inbound-grid div { min-width:0; padding-top:9px; border-top:1px solid #edf1f5; }
.inbound-grid span { display:block; color:#829ab1; font-size:10px; }
.inbound-grid strong { display:block; margin-top:3px; overflow:hidden; color:#334e68; font-size:12px; text-overflow:ellipsis; white-space:nowrap; }
.receive-pickup { display:inline-flex; align-items:center; gap:3px; color:#627d98; font-size:10px; white-space:nowrap; }
.receive-pickup strong { color:#334e68; font-size:11px; }
.receive-sku-field { grid-column:1/-1; }
.receive-sku-list { display:grid; gap:5px; margin-top:6px; }
.receive-sku-row { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:10px; min-width:0; }
.receive-sku-row strong { margin-top:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.receive-sku-row .receive-sku-count { color:#1769e0; white-space:nowrap; }
.inbound-actions { display:flex; gap:8px; margin-top:13px; }
.inbound-btn { flex:1; min-height:42px; border:0; border-radius:10px; background:#1769e0; color:#fff; cursor:pointer; font-size:13px; font-weight:700; }
.inbound-btn.secondary { background:#eaf2ff; color:#1769e0; }
.inbound-scan { display:flex; gap:8px; margin-top:13px; }
.inbound-scan input { flex:1; min-width:0; height:44px; padding:0 12px; border:1px solid #dfe5ee; border-radius:9px; font-size:13px; }
.inbound-scan button { width:46px; border:0; border-radius:9px; background:#1769e0; color:#fff; cursor:pointer; font-size:19px; }
.unload-photo-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; margin-top:12px; }
.unload-photo-card { min-width:0; padding:10px; border:1px solid #dfe5ee; border-radius:11px; background:#fff; }
.unload-photo-preview { display:flex; align-items:center; justify-content:center; height:70px; overflow:hidden; border-radius:9px; background:#eef0f3; color:#68758a; font-size:23px; }
.unload-photo-preview img { width:100%; height:100%; object-fit:cover; }
.unload-photo-name { margin-top:8px; color:#25364d; font-size:12px; font-weight:700; }
.unload-photo-btn { display:block; width:100%; margin-top:8px; padding:10px 8px; border:0; border-radius:9px; background:#1769e0; color:#fff; cursor:pointer; font-size:12px; font-weight:700; text-align:center; }
.unload-photo-btn input { display:none; }
.inbound-notice { margin-top:10px; padding:10px 11px; border-radius:9px; background:#fff4e5; color:#a45c0b; font-size:11px; line-height:1.45; }
.inbound-notice.error { background:#fee2e2; color:#b42318; }
.page-topbar { position:sticky; top:0; z-index:90; display:flex; align-items:center; justify-content:space-between; height:60px; padding:0 12px; border-bottom:1px solid var(--border); background:#fff; box-shadow:0 2px 8px rgba(20,49,82,.05); }
.page-back { width:38px; height:38px; border:0; border-radius:11px; background:#f1f5f9; color:#334155; cursor:pointer; font-size:20px; }
.page-title { color:#172b4d; font-size:17px; font-weight:700; }
.page-user { width:36px; height:36px; border:0; border-radius:12px; background:#eaf2ff; color:#155eef; cursor:pointer; font-size:13px; font-weight:700; }
.profile-cover { height:132px; padding:16px; background:linear-gradient(140deg,#123b72,#1769e0); color:#fff; }
.profile-cover .page-back { background:rgba(255,255,255,.14); color:#fff; }
.profile-card { position:relative; margin:-49px 12px 12px; padding:18px; border-radius:12px; background:#fff; box-shadow:0 10px 28px rgba(20,49,82,.13); text-align:center; }
.profile-avatar { display:flex; align-items:center; justify-content:center; width:68px; height:68px; margin:-50px auto 10px; border:4px solid #fff; border-radius:16px; background:#eaf2ff; color:#155eef; font-size:25px; font-weight:800; }
.profile-name { color:#172b4d; font-size:19px; font-weight:700; }
.profile-role { margin-top:4px; color:#728196; font-size:12px; }
.profile-stats { display:grid; grid-template-columns:repeat(3,1fr); margin-top:16px; padding-top:14px; border-top:1px solid #edf1f5; }
.profile-stat strong { display:block; color:#163b68; font-size:17px; }
.profile-stat span { color:#8a97a8; font-size:10px; }
.profile-list { margin:12px; padding:2px 14px; border:1px solid #e3e8ef; border-radius:10px; background:#fff; }
.profile-list-item { display:flex; align-items:center; gap:12px; padding:15px 0; border-bottom:1px solid #edf1f5; color:#334e68; font-size:13px; }
.profile-list-item:last-child { border-bottom:0; }
.profile-list-icon { display:flex; align-items:center; justify-content:center; width:32px; height:32px; border-radius:8px; background:#eef4ff; }
.profile-list-value { margin-left:auto; color:#829ab1; font-size:12px; }
.profile-logout { width:calc(100% - 24px); margin:4px 12px 24px; padding:13px; border:1px solid #fecaca; border-radius:9px; background:#fff; color:#dc2626; cursor:pointer; font-size:14px; font-weight:600; }
.toast { position:fixed; top:70px; left:50%; z-index:400; padding:10px 20px; border-radius:8px; transform:translateX(-50%) translateY(-20px); background:var(--text); color:#fff; opacity:0; pointer-events:none; transition:all .3s; font-size:14px; }
.toast.show { transform:translateX(-50%) translateY(0); opacity:1; }
.toast.success { background:var(--success); } .toast.warning { background:var(--warning); } .toast.error { background:var(--danger); }
</style>
<link rel="stylesheet" href="../domestic-overseas-theme.css">
</head>
<body>
<div class="toast" id="toast"></div>
<div class="inbound-page hidden" id="receiveListPage">
<div class="app-shell">
<div class="page-topbar"><button class="page-back" onclick="returnToWorkbench()" aria-label="返回工作台">‹</button><div class="page-title">待签收列表</div><button class="page-user" id="receiveListAvatar" onclick="openProfile()" aria-label="个人中心">张</button></div>
<div class="inbound-content">
<div class="inbound-card"><div class="inbound-card-head"><div><div class="inbound-card-title">待签收订单</div><div class="inbound-card-sub">选择订单完成签收</div></div><span class="inbound-status" id="receivePendingCount">3 单</span></div></div>
<div id="receiveVehicleList"></div>
</div>
</div>
</div>
<div class="inbound-page hidden" id="unloadPage">
<div class="app-shell">
<div class="page-topbar"><button class="page-back" onclick="openReceiveList()" aria-label="返回待签收列表">‹</button><div class="page-title">签收登记</div><button class="page-user" id="unloadAvatar" onclick="openProfile()" aria-label="个人中心">张</button></div>
<div class="inbound-content" id="unloadContent"></div>
</div>
</div>
<div class="profile-page hidden" id="profilePage">
<div class="app-shell">
<div class="profile-cover"><button class="page-back" onclick="backFromProfile()" aria-label="返回">‹</button></div>
<div class="profile-card"><div class="profile-avatar" id="profileAvatar">张</div><div class="profile-name" id="profileName">张伟</div><div class="profile-role" id="profileRole">W001 · 拣货员</div><div class="profile-stats"><div class="profile-stat"><strong>28</strong><span>今日完成</span></div><div class="profile-stat"><strong>156</strong><span>本月任务</span></div><div class="profile-stat"><strong>99.2%</strong><span>作业准确率</span></div></div></div>
<div class="profile-list"><div class="profile-list-item"><span class="profile-list-icon">🏢</span><span>当前仓库</span><span class="profile-list-value" id="profileWarehouse">洛杉矶仓</span></div><div class="profile-list-item"><span class="profile-list-icon">🕘</span><span>当前班次</span><span class="profile-list-value">白班 08:00–17:00</span></div><div class="profile-list-item"><span class="profile-list-icon">🔐</span><span>修改密码</span><span class="profile-list-value">›</span></div><div class="profile-list-item"><span class="profile-list-icon">⚙️</span><span>系统设置</span><span class="profile-list-value">›</span></div><div class="profile-list-item"><span class="profile-list-icon">ℹ️</span><span>版本信息</span><span class="profile-list-value">V1.0.0</span></div></div>
<button class="profile-logout" onclick="returnToWorkbench()">退出登录</button>
</div>
</div>
<script>
function readCurrentUser() {
const params = new URLSearchParams(location.search);
if (!params.get('id')) return null;
return Object.fromEntries(['id','name','role','warehouse'].map(key => [key, params.get(key) || '']));
}
function buildUserParams(user) {
return new URLSearchParams({
id: user.id,
name: user.name,
role: user.role,
warehouse: user.warehouse
});
}
let currentUser = readCurrentUser() || { id:'', name:'', role:'', warehouse:'' };
let profileReturnPage = 'receiveListPage';
let selectedReceiveId = null;
let receivePhotos = {};
let toastTimer = null;
function returnToWorkbench() {
const params = buildUserParams(currentUser);
params.set('resume', '1');
location.href = `../pda-picking.html?${params}`;
}
function showAppPage(pageId) {
['receiveListPage', 'unloadPage', 'profilePage'].forEach(id => document.getElementById(id).classList.toggle('hidden', id !== pageId));
window.scrollTo(0, 0);
}
function updateUserUI() {
const initial = currentUser.name.slice(0, 1) || '用';
document.getElementById('profileName').textContent = currentUser.name;
document.getElementById('profileRole').textContent = `${currentUser.id} · ${currentUser.role}`;
document.getElementById('profileWarehouse').textContent = currentUser.warehouse;
['profileAvatar', 'receiveListAvatar', 'unloadAvatar'].forEach(id => { document.getElementById(id).textContent = initial; });
}
function openProfile() {
profileReturnPage = ['receiveListPage', 'unloadPage'].find(id => !document.getElementById(id).classList.contains('hidden')) || 'receiveListPage';
showAppPage('profilePage');
}
function backFromProfile() { showAppPage(profileReturnPage); }
function showToast(msg, type = '') {
const toast = document.getElementById('toast');
toast.textContent = msg;
toast.className = `toast show ${type}`;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { toast.className = 'toast'; }, 2200);
}
const RECEIVE_VEHICLES = [
{ id:'RV-260901-018', container:'D2222', plate:'浙A·7K218', driver:'陈师傅', carrier:'义乌欧业物流', order:'IN-260901-018', pickupMethod:'自送', destination:'义乌仓', total:18, received:0, boxes:[['BX-018-01','电子元件套装',120],['BX-018-02','USB-C数据线',80],['BX-018-03','手机支架',60]] },
{ id:'RV-260901-021', container:'D3333', plate:'鲁B·6M392', driver:'王师傅', carrier:'青岛远航物流', order:'IN-260901-021', pickupMethod:'自送', destination:'青岛仓', total:12, received:1, boxes:[['BX-021-01','桌面台灯',60],['BX-021-02','平板支架',40],['BX-021-03','无线鼠标',90]] },
{ id:'RV-260901-026', container:'D4444', plate:'粤B·3Q816', driver:'李师傅', carrier:'深圳璟智供应链', order:'IN-260901-026', pickupMethod:'自送', destination:'深圳仓', total:8, received:0, boxes:[['BX-026-01','旅行包',32],['BX-026-02','收纳袋',48],['BX-026-03','行李牌',24]] }
];
function openReceiveList() { showAppPage('receiveListPage'); renderReceiveList(); }
function renderReceiveList() {
const pending = RECEIVE_VEHICLES.filter(vehicle => vehicle.received < vehicle.total);
document.getElementById('receivePendingCount').textContent = `${pending.length} 单`;
document.getElementById('receiveVehicleList').innerHTML = pending.length ? pending.map(vehicle => `
<div class="inbound-card"><div class="inbound-card-head"><div><div class="inbound-card-title">${vehicle.order}</div><div class="inbound-card-sub"><span class="receive-pickup">提货方式:<strong>${vehicle.pickupMethod || '自送'}</strong></span> · ${vehicle.plate} · ${vehicle.driver}</div></div><span class="inbound-status">待签收</span></div><div class="inbound-grid"><div class="receive-sku-field"><span>SKU / 箱数</span><div class="receive-sku-list">${formatReceiveSkuRows(vehicle.boxes)}</div></div></div><div class="inbound-actions"><button class="inbound-btn receive-sign-button" type="button" onclick="openUnload('${vehicle.id}')">签收</button></div></div>`).join('') : '<div class="inbound-card"><div style="padding:24px;color:#829ab1;font-size:12px;text-align:center">暂无待卸货车辆</div></div>';
}
function formatReceiveSkuRows(boxes) {
const skuCounts = boxes.reduce((counts, box) => {
counts.set(box[1], (counts.get(box[1]) || 0) + 1);
return counts;
}, new Map());
return [...skuCounts].map(([sku, count]) => `<div class="receive-sku-row"><strong>${sku}</strong><strong class="receive-sku-count">${count} 箱</strong></div>`).join('');
}
function openUnload(id) { selectedReceiveId = id; showAppPage('unloadPage'); renderUnload(); }
function renderUnload() {
const vehicle = RECEIVE_VEHICLES.find(item => item.id === selectedReceiveId) || RECEIVE_VEHICLES[0];
const photos = receivePhotos[vehicle.id] || {};
const photoSteps = [{ key:'plate', label:'车牌照片', id:'licensePlatePhoto' }, { key:'waybill', label:'快递面单', id:'waybillPhoto' }, { key:'other', label:'其他照片', id:'otherPhoto' }];
document.getElementById('unloadContent').innerHTML = `<div class="inbound-card"><div class="inbound-card-head"><div><div class="inbound-card-title">现场照片</div><div class="inbound-card-sub">照片均为选填,可按需补充</div></div><span class="inbound-status good">可提交</span></div><div class="unload-photo-grid">${photoSteps.map(step => { const photo = photos[step.key]; return `<div class="unload-photo-card"><div class="unload-photo-preview">${photo ? `<img src="${photo}" alt="${step.label}">` : '▣'}</div><div class="unload-photo-name">${step.label}</div><label class="unload-photo-btn">${photo ? '重新拍摄' : '拍照'}<input id="${step.id}" type="file" accept="image/*" capture="environment" onchange="handleUnloadPhoto('${step.key}',this)"></label></div>`; }).join('')}</div></div><div id="unloadMessage"></div><div class="inbound-actions"><button class="inbound-btn secondary" onclick="openReceiveList()">返回列表</button><button class="inbound-btn" onclick="confirmReceive()">确认签收</button></div>`;
}
function handleReceiveScan() {
const input = document.getElementById('receiveScanInput');
const value = (input?.value || '').trim();
const vehicle = RECEIVE_VEHICLES.find(item => item.id === selectedReceiveId);
if (!vehicle || !value) { showUnloadMessage('请输入或扫描箱号', true); return; }
const found = vehicle.boxes.find(box => box[0].toLowerCase() === value.toLowerCase());
if (!found) { showUnloadMessage(`未找到匹配箱号:${value}`, true); return; }
if (vehicle.received >= vehicle.total || vehicle.boxes.slice(0, vehicle.received).some(box => box[0] === found[0])) { showUnloadMessage('该箱号已收货', true); return; }
vehicle.received += 1;
showToast(`已收货:${found[0]}`, 'success');
renderUnload();
}
function handleUnloadPhoto(key, input) {
const file = input.files && input.files[0];
if (!file || !selectedReceiveId) return;
const reader = new FileReader();
reader.onload = () => { receivePhotos[selectedReceiveId] = receivePhotos[selectedReceiveId] || {}; receivePhotos[selectedReceiveId][key] = reader.result; renderUnload(); };
reader.readAsDataURL(file);
}
function handlePlatePhoto(input) { handleUnloadPhoto('plate', input); }
function showUnloadMessage(message, error) { const element = document.getElementById('unloadMessage'); if (element) element.innerHTML = `<div class="inbound-notice ${error ? 'error' : ''}">${message}</div>`; }
function confirmReceive() { completeUnload(); }
function completeUnload() { const vehicle = RECEIVE_VEHICLES.find(item => item.id === selectedReceiveId); if (!vehicle) return; vehicle.received = vehicle.total; showToast('收货登记已完成', 'success'); openReceiveList(); }
updateUserUI();
openReceiveList();
</script>
</body>
</html>
+60
View File
@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>柏悦物流 PDA · 合托</title>
<link rel="icon" href="data:,">
<style>
:root { --brand:#1769e0; --brand-soft:#eaf2ff; --success:#078775; --danger:#bd3b3b; --warning:#bd6b10; --text:#243b53; --muted:#829ab1; --border:#e4eaf2; --bg:#f3f6fa; }
* { box-sizing:border-box; } body { max-width:480px; min-height:100vh; margin:0 auto; background:var(--bg); color:var(--text); font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Noto Sans SC",sans-serif; } button,input,select { font:inherit; }.hidden { display:none!important; }
.toast { position:fixed; top:70px; left:50%; z-index:300; padding:10px 20px; border-radius:8px; transform:translateX(-50%) translateY(-20px); opacity:0; color:#fff; background:#243b53; transition:all .25s; pointer-events:none; }.toast.show { transform:translateX(-50%) translateY(0); opacity:1; }.toast.success { background:var(--success); }.toast.error { background:var(--danger); }
.app-shell { min-height:100vh; padding-bottom:82px; }.page-topbar { position:sticky; top:0; z-index:10; display:flex; align-items:center; justify-content:space-between; height:60px; padding:0 12px; border-bottom:1px solid var(--border); background:#fff; box-shadow:0 2px 8px rgba(20,49,82,.05); }.page-back,.page-user { border:0; cursor:pointer; }.page-back { width:38px; height:38px; border-radius:11px; color:#334e68; background:#f1f5f9; font-size:20px; }.page-title { font-size:17px; font-weight:700; }.page-user { width:36px; height:36px; border-radius:12px; color:#155eef; background:#eaf2ff; font-size:13px; font-weight:700; }
.inbound-content { padding:14px 12px 30px; }.inbound-card { padding:15px; margin-bottom:12px; border:1px solid var(--border); border-radius:15px; background:#fff; box-shadow:0 1px 3px rgba(0,0,0,.08); }.inbound-card-head { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; }.inbound-card-title { color:var(--text); font-size:15px; font-weight:700; }.inbound-card-sub { margin-top:4px; color:var(--muted); font-size:11px; line-height:1.5; }.inbound-status { flex:none; padding:5px 8px; border-radius:8px; color:var(--success); background:#e1f6f1; font-size:10px; font-weight:700; }.inbound-scan { display:flex; gap:8px; margin-top:13px; }.inbound-scan input { flex:1; min-width:0; height:44px; padding:0 12px; border:1px solid #dfe5ee; border-radius:9px; font-size:13px; }.inbound-scan button { width:46px; border:0; border-radius:9px; color:#fff; background:var(--brand); font-size:19px; cursor:pointer; }
.consolidation-message,.consolidation-selection-message { display:none; margin-top:10px; padding:10px 11px; border-radius:8px; font-size:12px; }.consolidation-message.show,.consolidation-selection-message.show { display:block; }.consolidation-message.error { color:var(--danger); background:#fff1f1; }.consolidation-message.success { color:var(--success); background:#e8f7f3; }.consolidation-selection-message.warn { color:var(--warning); background:#fff4e5; }
.consolidation-box-info { padding-bottom:10px; }.consolidation-box { padding:12px 0; border-bottom:1px solid #edf1f5; }.consolidation-box:last-child { border-bottom:0; }.consolidation-box-head { display:flex; align-items:center; justify-content:space-between; gap:8px; }.consolidation-box-mark { color:#1769e0; font-size:13px; }.consolidation-box-meta { margin-top:4px; color:#627d98; font-size:11px; }.inbound-chip { padding:4px 7px; border:0; border-radius:7px; color:#bd6b10; background:#fff4e5; font-size:10px; cursor:pointer; }.consolidation-empty { padding:16px 0; color:var(--muted); font-size:12px; text-align:center; }.consolidation-box-details.two-box-state { padding-right:1px; padding-left:1px; }
.consolidation-sku-heading { margin:16px 0 8px; color:#334e68; font-size:13px; font-weight:700; }.consolidation-sku-table-wrap { overflow-x:auto; border:1px solid var(--border); border-radius:10px; }.consolidation-sku-table { width:100%; border-collapse:collapse; table-layout:fixed; font-size:11px; }.consolidation-sku-table th { padding:10px 6px; color:#627d98; background:#f7f9fc; font-size:10px; text-align:left; white-space:nowrap; }.consolidation-sku-table td { padding:10px 6px; border-top:1px solid #edf1f5; color:#334e68; overflow-wrap:anywhere; }.consolidation-empty-cell { color:var(--muted); text-align:center; }
.consolidation-form-grid { display:grid; grid-template-columns:1fr 92px; gap:10px; margin-top:14px; }.consolidation-field span { display:block; margin-bottom:6px; color:#627d98; font-size:11px; }.consolidation-field select,.consolidation-field input { width:100%; height:42px; padding:0 10px; border:1px solid #dfe5ee; border-radius:9px; outline:0; color:#243b53; background:#fff; font-size:13px; }.consolidation-field select:disabled,.consolidation-field input:disabled { color:#94a3b8; background:#f7f9fc; }.consolidation-footer { position:fixed; right:0; bottom:0; left:0; max-width:480px; margin:auto; padding:12px; border-top:1px solid var(--border); background:#fff; }.inbound-btn { width:100%; min-height:44px; border:0; border-radius:10px; color:#fff; background:var(--brand); font-size:14px; font-weight:700; cursor:pointer; }.inbound-btn:disabled { opacity:.55; cursor:not-allowed; }
.profile-cover { height:132px; padding:16px; color:#fff; background:linear-gradient(140deg,#123b72,#1769e0); }.profile-cover .page-back { color:#fff; background:rgba(255,255,255,.14); }.profile-card { margin:-49px 12px 12px; padding:18px; border-radius:12px; background:#fff; box-shadow:0 10px 28px rgba(20,49,82,.13); text-align:center; }.profile-avatar { display:flex; align-items:center; justify-content:center; width:68px; height:68px; margin:-50px auto 10px; border:4px solid #fff; border-radius:16px; color:#155eef; background:#eaf2ff; font-size:25px; font-weight:800; }.profile-name { font-size:19px; font-weight:700; }.profile-role { margin-top:4px; color:#728196; font-size:12px; }.profile-stats { display:grid; grid-template-columns:repeat(3,1fr); margin-top:16px; padding-top:14px; border-top:1px solid #edf1f5; }.profile-stat strong { display:block; color:#163b68; font-size:17px; }.profile-stat span { color:#8a97a8; font-size:10px; }.profile-list { margin:12px; padding:2px 14px; border:1px solid #e3e8ef; border-radius:10px; background:#fff; }.profile-list-item { display:flex; gap:12px; align-items:center; padding:15px 0; border-bottom:1px solid #edf1f5; color:#334e68; font-size:13px; }.profile-list-item:last-child { border-bottom:0; }.profile-list-icon { display:flex; align-items:center; justify-content:center; width:32px; height:32px; border-radius:8px; background:#eef4ff; }.profile-list-value { margin-left:auto; color:#829ab1; font-size:12px; }.profile-logout { width:calc(100% - 24px); margin:4px 12px 24px; padding:13px; border:1px solid #fecaca; border-radius:9px; color:#dc2626; background:#fff; font-size:14px; font-weight:600; cursor:pointer; }
/* 与原合托页面一致的作业布局与两箱卡片状态。 */
.consolidation-page .app-shell { display:flex; flex-direction:column; min-height:100vh; padding-bottom:72px; }.consolidation-page .inbound-content { flex:1; width:100%; padding-bottom:28px; }.consolidation-page .page-back,.consolidation-page .page-user { width:44px; height:44px; }.consolidation-footer { position:fixed; right:0; bottom:0; left:0; z-index:4; max-width:480px; margin:auto; padding:10px 12px max(12px,env(safe-area-inset-bottom)); border-top:1px solid #e4eaf2; background:#fff; box-shadow:0 -4px 14px rgba(23,32,51,.08); }.consolidation-footer .inbound-btn { min-height:48px; }.consolidation-page .inbound-scan input { font-size:16px; }.consolidation-page .inbound-scan input:focus,.consolidation-field select:focus,.consolidation-field input:focus { outline:2px solid rgba(23,105,224,.2); border-color:#1769e0; }.consolidation-scan-card .inbound-card-head { align-items:flex-start; }.consolidation-scan-card .inbound-status { flex:none; }.consolidation-box-info { padding-bottom:14px; }.consolidation-box { padding:12px 0; border-bottom:1px solid #e5ebf3; }.consolidation-box:first-child { padding-top:2px; }.consolidation-box:last-child { padding-bottom:4px; border-bottom:0; }.consolidation-box-head { gap:10px; }.consolidation-box-mark { color:#243b53; font-size:14px; font-weight:700; }.consolidation-box-meta { color:#627d98; font-size:12px; }.consolidation-box .inbound-chip { min-width:44px; min-height:36px; }.consolidation-sku-heading { margin-top:14px; padding-top:12px; border-top:1px solid #e5ebf3; color:#243b53; font-size:13px; font-weight:700; }.consolidation-sku-table-wrap { margin-top:8px; overflow-x:auto; border:0; }.consolidation-sku-table { table-layout:auto; font-size:12px; }.consolidation-sku-table th,.consolidation-sku-table td { padding:10px 8px; border-bottom:1px solid #edf1f5; text-align:left; white-space:nowrap; }.consolidation-sku-table th { color:#627d98; background:#f8fafc; font-weight:600; }.consolidation-sku-table td { color:#243b53; }.consolidation-sku-table td:nth-child(3),.consolidation-sku-table td:nth-child(4) { text-align:right; }.consolidation-form-card { padding-bottom:14px; }.consolidation-form-grid { grid-template-columns:minmax(0,1fr) 112px; margin-top:12px; }.consolidation-field { display:flex; flex-direction:column; gap:6px; color:#627d98; font-size:12px; font-weight:600; }.consolidation-field select,.consolidation-field input { height:44px; padding:0 11px; border-color:#cfd9e7; border-radius:8px; background:#f8fafc; font-size:14px; }.consolidation-selection-message { color:#b42318; }.consolidation-selection-message.warn { background:transparent; }.consolidation-message.success { color:#087568; background:#e8f8f4; }.consolidation-message.error { color:#b42318; background:#fff1f0; } #consolidationBoxDetails.two-box-state { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; padding:8px; border:1px solid #cfe0f7; border-radius:9px; background:#f6f9ff; } #consolidationBoxDetails.two-box-state .consolidation-box { min-width:0; padding:8px; border:1px solid #d9e5f5; border-radius:8px; background:#fff; } #consolidationBoxDetails.two-box-state .consolidation-box-head { flex-direction:column; align-items:flex-start; gap:6px; } #consolidationBoxDetails.two-box-state .consolidation-box .inbound-chip { align-self:flex-end; min-height:30px; }
</style>
<link rel="stylesheet" href="../domestic-overseas-theme.css">
</head>
<body>
<div class="toast" id="toast"></div>
<div class="inbound-page consolidation-page" id="consolidationPage"><div class="app-shell"><div class="page-topbar"><button class="page-back" type="button" onclick="returnToWorkbench()" aria-label="返回工作台">‹</button><div class="page-title">合托</div><button class="page-user" id="consolidationAvatar" type="button" onclick="openProfile()" aria-label="个人中心">张</button></div><div class="inbound-content"><div class="inbound-card consolidation-scan-card"><div class="inbound-card-head"><div><div class="inbound-card-title">扫描箱唛</div><div class="inbound-card-sub">扫描箱唛加入合托,目的仓需保持一致</div></div><span class="inbound-status" id="consolidationAvailableCount">4 箱</span></div><div class="inbound-scan"><input id="consolidationScanInput" placeholder="扫描或输入箱唛" autocomplete="off"><button type="button" onclick="handleConsolidationScan()" aria-label="扫描箱唛">▦</button></div><div class="consolidation-message" id="consolidationMessage" aria-live="polite"></div></div><div class="inbound-card consolidation-box-info"><div class="inbound-card-head"><div><div class="inbound-card-title">箱子信息</div><div class="inbound-card-sub">已扫描箱唛与箱内 SKU 明细</div></div></div><div id="consolidationBoxDetails"><div class="consolidation-empty">暂无箱子,请先扫描箱唛</div></div><div class="consolidation-sku-heading">SKU 列表</div><div class="consolidation-sku-table-wrap"><table class="consolidation-sku-table" id="consolidationSkuTable"><thead><tr><th>箱唛</th><th>SKU</th><th>每箱数量</th><th>可合托箱数</th></tr></thead><tbody><tr><td colspan="4" class="consolidation-empty-cell">暂无 SKU</td></tr></tbody></table></div></div><div class="inbound-card consolidation-form-card"><div class="inbound-card-head"><div><div class="inbound-card-title">合托信息</div><div class="inbound-card-sub">选择 SKU 并输入本次合托箱数</div></div></div><div class="consolidation-form-grid"><label class="consolidation-field"><span>SKU</span><select id="consolidationSkuSelect" onchange="updateConsolidationSelection()" disabled><option value="">扫描箱唛后选择 SKU</option></select></label><label class="consolidation-field"><span>箱数</span><input id="consolidationCartonCountInput" type="number" min="1" value="1" inputmode="numeric" oninput="updateConsolidationSelection()" disabled></label></div><div class="consolidation-selection-message" id="consolidationSelectionMessage" aria-live="polite"></div></div></div><div class="consolidation-footer"><button class="inbound-btn" id="submitConsolidationButton" type="button" onclick="submitConsolidation()" disabled>合托</button></div></div></div>
<div class="profile-page hidden" id="profilePage"><div class="app-shell"><div class="profile-cover"><button class="page-back" type="button" onclick="closeProfile()">‹</button></div><div class="profile-card"><div class="profile-avatar" id="profileAvatar">张</div><div class="profile-name" id="profileName">张伟</div><div class="profile-role" id="profileRole">W001 · 拣货员</div><div class="profile-stats"><div class="profile-stat"><strong>28</strong><span>今日完成</span></div><div class="profile-stat"><strong>156</strong><span>本月任务</span></div><div class="profile-stat"><strong>99.2%</strong><span>作业准确率</span></div></div></div><div class="profile-list"><div class="profile-list-item"><span class="profile-list-icon">🏢</span><span>当前仓库</span><span class="profile-list-value" id="profileWarehouse">洛杉矶仓</span></div><div class="profile-list-item"><span class="profile-list-icon">🕘</span><span>当前班次</span><span class="profile-list-value">白班 08:00–17:00</span></div><div class="profile-list-item"><span class="profile-list-icon">🔐</span><span>修改密码</span><span class="profile-list-value">›</span></div><div class="profile-list-item"><span class="profile-list-icon">⚙️</span><span>系统设置</span><span class="profile-list-value">›</span></div><div class="profile-list-item"><span class="profile-list-icon">ℹ️</span><span>版本信息</span><span class="profile-list-value">V1.0.0</span></div></div><button class="profile-logout" type="button" onclick="handleLogout()">退出登录</button></div></div>
<script>
const CONSOLIDATION_SOURCE_PALLETS = [
{ id:'TP-YW-001', destination:'义乌仓', items:[{ boxNo:'BX-030-01', order:'SO-260901-030', product:'手机壳套装', sku:'YQ-CASE-01', qty:120 },{ boxNo:'BX-030-02', order:'SO-260901-030', product:'USB-C数据线', sku:'YQ-CABLE-02', qty:80 }] },
{ id:'TP-YW-002', destination:'义乌仓', items:[{ boxNo:'BX-032-01', order:'SO-260901-032', product:'手机支架', sku:'YQ-HOLDER-03', qty:60 }] },
{ id:'TP-YW-003', destination:'义乌仓', items:[{ boxNo:'BX-033-01', order:'SO-260901-033', product:'蓝牙耳机', sku:'YQ-BUDS-08', qty:48 },{ boxNo:'BX-033-02', order:'SO-260901-033', product:'充电底座', sku:'YQ-DOCK-09', qty:36 }] },
{ id:'TP-YW-004', destination:'义乌仓', items:[{ boxNo:'BX-034-01', order:'SO-260901-034', product:'平板保护套', sku:'YQ-PAD-10', qty:50 }] },
{ id:'TP-NB-001', destination:'宁波仓', items:[{ boxNo:'BX-031-01', order:'SO-260901-031', product:'旅行包', sku:'NH-BAG-04', qty:32 }] }
];
let consolidatedPallets = [{ id:'HT-260901-001', destination:'义乌仓', sourcePallets:['TP-YW-001','TP-YW-002'], cartonCount:3, pieceCount:260, items:CONSOLIDATION_SOURCE_PALLETS.slice(0,2).flatMap(pallet=>pallet.items.map(item=>({...item,sourcePallet:pallet.id}))) }];
let currentConsolidation = null; let consolidationSelection = { sku:'', count:1 }; let currentUser = null; let toastTimer = null;
function readCurrentUser() { const params=new URLSearchParams(location.search); if(!params.get('id')) return null; return Object.fromEntries(['id','name','role','warehouse'].map(key=>[key,params.get(key)||''])); }
function buildUserParams(user) { return new URLSearchParams({ id:user.id, name:user.name, role:user.role, warehouse:user.warehouse }); }
function returnToWorkbench() { const params=buildUserParams(currentUser); params.set('resume','1'); location.href=`../pda-picking.html?${params}`; }
function handleLogout() { localStorage.removeItem('pdaCurrentUser'); location.href='../pda-picking.html'; }
function showToast(message,type='') { const toast=document.getElementById('toast'); toast.textContent=message; toast.className=`toast show ${type}`; clearTimeout(toastTimer); toastTimer=setTimeout(()=>toast.className='toast',2200); }
function allBoxes() { return CONSOLIDATION_SOURCE_PALLETS.flatMap(pallet=>pallet.items.map(item=>({...item,sourcePallet:pallet.id,destination:pallet.destination}))); }
function existingOwner(boxNo) { return consolidatedPallets.find(pallet=>pallet.items.some(item=>item.boxNo===boxNo)); }
function availableBoxes() { const selected=new Set(currentConsolidation.items.map(item=>item.boxNo)); return allBoxes().filter(item=>!selected.has(item.boxNo)&&!existingOwner(item.boxNo)); }
function setMessage(text,type) { const message=document.getElementById('consolidationMessage'); message.textContent=text; message.className=`consolidation-message show ${type}`; }
function resetView() { document.getElementById('consolidationScanInput').value=''; document.getElementById('consolidationMessage').className='consolidation-message'; document.getElementById('consolidationSelectionMessage').className='consolidation-selection-message'; }
function openConsolidation() { currentConsolidation={id:'',destination:'',items:[]}; consolidationSelection={sku:'',count:1}; resetView(); renderCurrentConsolidation(); setTimeout(()=>document.getElementById('consolidationScanInput').focus(),0); }
function handleConsolidationScan() { const input=document.getElementById('consolidationScanInput'); const boxNo=input.value.trim().toUpperCase(); if(!boxNo) return setMessage('请扫描或输入箱唛','error'); const source=allBoxes().find(item=>item.boxNo===boxNo); if(!source) return setMessage('未找到该箱唛,请核对箱唛后重试','error'); const owner=existingOwner(boxNo); if(owner) return setMessage(`该箱唛已归属合托托盘 ${owner.id},不能重复合托`,'error'); if(currentConsolidation.items.some(item=>item.boxNo===boxNo)) return setMessage('该箱唛已加入当前合托,请勿重复扫描','error'); if(currentConsolidation.destination&&currentConsolidation.destination!==source.destination) return setMessage(`目的仓不一致:当前为${currentConsolidation.destination},该箱为${source.destination}`,'error'); currentConsolidation.destination=source.destination; currentConsolidation.items.push(source); input.value=''; setMessage(`箱唛 ${source.boxNo} 已加入当前合托`,'success'); renderCurrentConsolidation(); }
function renderCurrentConsolidation() { const items=currentConsolidation.items; const available=availableBoxes(); document.getElementById('consolidationAvailableCount').textContent=`${available.length} 箱`; const details=document.getElementById('consolidationBoxDetails'); details.className=items.length===2?'two-box-state':''; details.innerHTML=items.length?items.map((item,index)=>`<div class="consolidation-box"><div class="consolidation-box-head"><strong class="consolidation-box-mark">${item.boxNo}</strong><button class="inbound-chip warn" type="button" onclick="removeConsolidationItem(${index})">移除</button></div><div class="consolidation-box-meta">${item.product} · ${item.sourcePallet} · ${item.order}</div></div>`).join(''):'<div class="consolidation-empty">暂无箱子,请先扫描箱唛</div>'; const tbody=document.querySelector('#consolidationSkuTable tbody'); tbody.innerHTML=items.length?items.map(item=>`<tr><td>${item.boxNo}</td><td>${item.sku}</td><td>${item.qty}</td><td>${available.length}</td></tr>`).join(''):'<tr><td colspan="4" class="consolidation-empty-cell">暂无 SKU</td></tr>'; const select=document.getElementById('consolidationSkuSelect'); const input=document.getElementById('consolidationCartonCountInput'); const skus=[...new Set(items.map(item=>item.sku))]; if(!skus.includes(consolidationSelection.sku)) consolidationSelection.sku=skus[0]||''; select.innerHTML=skus.length?skus.map(sku=>`<option value="${sku}">${sku}</option>`).join(''):'<option value="">扫描箱唛后选择 SKU</option>'; select.value=consolidationSelection.sku; select.disabled=!skus.length; input.disabled=!skus.length; input.value=String(Math.max(1,Number(consolidationSelection.count)||1)); document.getElementById('submitConsolidationButton').disabled=items.length<2; }
function updateConsolidationSelection() { const select=document.getElementById('consolidationSkuSelect'); const input=document.getElementById('consolidationCartonCountInput'); const message=document.getElementById('consolidationSelectionMessage'); if(!select||!input) return; consolidationSelection.sku=select.value; const requested=Number(input.value); const max=Math.max(1,availableBoxes().length); if(!Number.isInteger(requested)||requested<1) { message.textContent='请输入至少 1 箱'; message.className='consolidation-selection-message show'; return; } consolidationSelection.count=Math.min(requested,max); input.value=String(consolidationSelection.count); message.textContent=requested>max?`最多可输入 ${max} 箱`:''; message.className=requested>max?'consolidation-selection-message show':'consolidation-selection-message'; }
function removeConsolidationItem(index) { if(!currentConsolidation?.items[index]) return; currentConsolidation.items.splice(index,1); if(!currentConsolidation.items.length) currentConsolidation.destination=''; setMessage('已从当前合托移除该箱','success'); renderCurrentConsolidation(); }
function nextConsolidationId(now=new Date()) { const two=value=>String(value).padStart(2,'0'); const prefix=`HT-${String(now.getFullYear()).slice(-2)}${two(now.getMonth()+1)}${two(now.getDate())}-`; const max=consolidatedPallets.reduce((highest,pallet)=>pallet.id.startsWith(prefix)?Math.max(highest,Number.parseInt(pallet.id.slice(prefix.length),10)||0):highest,0); return `${prefix}${String(max+1).padStart(3,'0')}`; }
function submitConsolidation() { const items=currentConsolidation?.items||[]; if(items.length<2) return setMessage('至少扫描两个箱唛后才能合托','error'); const saved={id:currentConsolidation.id||nextConsolidationId(),destination:currentConsolidation.destination,sourcePallets:[...new Set(items.map(item=>item.sourcePallet))],cartonCount:items.length,pieceCount:items.reduce((sum,item)=>sum+item.qty,0),items:items.map(item=>({...item}))}; const existingIndex=consolidatedPallets.findIndex(pallet=>pallet.id===saved.id); if(existingIndex>=0) consolidatedPallets[existingIndex]=saved; else consolidatedPallets.push(saved); currentConsolidation=null; showToast(existingIndex>=0?'合托已更新':'合托已提交','success'); setTimeout(returnToWorkbench,1200); }
function updateUserUI() { const initial=currentUser.name.slice(0,1); document.getElementById('consolidationAvatar').textContent=initial; document.getElementById('profileAvatar').textContent=initial; document.getElementById('profileName').textContent=currentUser.name; document.getElementById('profileRole').textContent=`${currentUser.id} · ${currentUser.role}`; document.getElementById('profileWarehouse').textContent=currentUser.warehouse; }
function openProfile() { document.getElementById('consolidationPage').classList.add('hidden'); document.getElementById('profilePage').classList.remove('hidden'); } function closeProfile() { document.getElementById('profilePage').classList.add('hidden'); document.getElementById('consolidationPage').classList.remove('hidden'); }
document.getElementById('consolidationScanInput').addEventListener('keydown',event=>{if(event.key==='Enter')handleConsolidationScan();}); currentUser=readCurrentUser(); if(!currentUser) location.href='../pda-picking.html'; else { updateUserUI(); openConsolidation(); }
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="180" viewBox="0 0 180 180" fill="none">
<rect width="180" height="180" rx="16" fill="#F8FAFC"/>
<rect x="12" y="12" width="156" height="156" rx="12" stroke="#CBD5E1" stroke-width="2"/>
<g fill="#1565C0">
<path d="M28 28h38v38H28V28Zm9 9v20h20V37H37ZM114 28h38v38h-38V28Zm9 9v20h20V37h-20ZM28 114h38v38H28v-38Zm9 9v20h20v-20H37Z"/>
<rect x="82" y="28" width="10" height="10" rx="2"/>
<rect x="82" y="46" width="18" height="10" rx="2"/>
<rect x="74" y="70" width="10" height="10" rx="2"/>
<rect x="92" y="70" width="18" height="10" rx="2"/>
<rect x="118" y="78" width="10" height="18" rx="2"/>
<rect x="136" y="82" width="16" height="10" rx="2"/>
<rect x="74" y="100" width="18" height="10" rx="2"/>
<rect x="100" y="92" width="10" height="18" rx="2"/>
<rect x="82" y="118" width="10" height="10" rx="2"/>
<rect x="100" y="118" width="28" height="10" rx="2"/>
<rect x="136" y="110" width="16" height="18" rx="2"/>
<rect x="74" y="136" width="18" height="16" rx="2"/>
<rect x="108" y="138" width="10" height="14" rx="2"/>
<rect x="128" y="136" width="24" height="16" rx="2"/>
</g>
<rect x="46" y="74" width="88" height="32" rx="16" fill="#0F172A" fill-opacity=".88"/>
<path d="M60 90h60" stroke="#FFF" stroke-width="3" stroke-linecap="round" opacity=".72"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+198 -5
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>柏悦物流 CHONGCHONG GLOBAL | 跨境物流一站式解决方案</title>
<meta name="description" content="柏悦物流 - 专业货代服务,提供海外采购、美本土卡车、美本土散板、海运专线、一件代发等跨境物流解决方案。">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%231565c0'/%3E%3Cpath d='M18 14h19c9 0 14 4 14 11 0 5-3 8-8 9 6 1 9 4 9 10 0 8-6 12-16 12H18V14zm10 9v8h8c4 0 6-1 6-4s-2-4-6-4h-8zm0 16v9h9c4 0 6-2 6-5s-2-4-7-4h-8z' fill='white'/%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;600;700;900&family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
@@ -508,6 +509,54 @@
.dash-sidebar.open { transform: translateX(0); }
.dash-menu-btn { display: inline-flex; }
}
/* ===== 官网内认证流程 ===== */
body.auth-modal-open { overflow: hidden; }
.auth-modal { position: fixed; inset: 0; z-index: 80; display: grid; place-items: center; padding: 24px; }
.auth-modal[hidden] { display: none; }
.auth-modal-backdrop { position: absolute; inset: 0; border: 0; background: rgba(5, 18, 34, .76); cursor: default; }
.auth-dialog { position: relative; width: min(1040px, 100%); max-height: calc(100vh - 48px); overflow: hidden; border-radius: 16px; background: #fff; box-shadow: 0 28px 80px rgba(4, 18, 34, .34); }
.auth-close { position: absolute; top: 16px; right: 16px; z-index: 3; display: inline-grid; width: 40px; height: 40px; place-items: center; border: 1px solid #d9e2ec; border-radius: 10px; background: #fff; color: #334155; transition: background-color .2s ease, border-color .2s ease, color .2s ease; }
.auth-close:hover { border-color: #1565c0; background: #f0f7ff; color: #0d47a1; }
.auth-close:focus-visible, .auth-dialog button:focus-visible, .auth-dialog input:focus-visible { outline: 3px solid rgba(21, 101, 192, .28); outline-offset: 2px; }
.auth-layout { display: grid; grid-template-columns: minmax(320px, .82fr) minmax(0, 1.18fr); min-height: 620px; }
.auth-story { position: relative; display: flex; min-width: 0; flex-direction: column; justify-content: space-between; overflow: hidden; padding: 48px 42px; background: #0b2d55 url('assets/core-ocean.jpg') center/cover no-repeat; color: #fff; }
.auth-modal[data-mode="register"] .auth-story { background-image: url('assets/core-fulfillment.jpg'); }
.auth-story::before { content: ''; position: absolute; inset: 0; background: linear-gradient(155deg, rgba(7, 33, 64, .9), rgba(13, 71, 161, .78)); }
.auth-story > * { position: relative; z-index: 1; }
.auth-story-logo { width: 161px; height: 60px; object-fit: contain; object-position: left center; margin-bottom: 56px; }
.auth-story h2 { max-width: 12ch; margin: 0 0 14px; font-size: clamp(30px, 3vw, 42px); line-height: 1.15; letter-spacing: -.02em; }
.auth-story p { max-width: 34ch; color: rgba(255, 255, 255, .82); line-height: 1.75; }
.auth-benefits { display: grid; gap: 14px; margin-top: 36px; }
.auth-benefit { display: flex; align-items: center; gap: 10px; color: rgba(255, 255, 255, .94); font-size: 14px; }
.auth-benefit i { color: #65d46e; }
.auth-form-side { min-width: 0; overflow-y: auto; padding: 58px 64px 48px; }
.auth-panel { max-width: 470px; margin: 0 auto; }
.auth-panel[hidden] { display: none; }
.auth-panel h2 { margin: 0 0 8px; color: #0f172a; font-size: 30px; font-weight: 800; letter-spacing: -.02em; }
.auth-switch-copy { margin-bottom: 28px; color: #64748b; }
.auth-link { color: #1565c0; font-weight: 700; }
.auth-link:hover { color: #0d47a1; text-decoration: underline; }
.auth-form { display: grid; gap: 18px; }
.auth-field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.auth-field label { display: block; margin-bottom: 7px; color: #334155; font-size: 14px; font-weight: 600; }
.auth-input-wrap { position: relative; }
.auth-input-wrap > i { position: absolute; top: 50%; left: 14px; color: #64748b; transform: translateY(-50%); pointer-events: none; }
.auth-input-wrap .input-field { padding-left: 42px; }
.auth-input-wrap .toggle-password { position: absolute; top: 50%; right: 10px; display: grid; width: 36px; height: 36px; place-items: center; color: #64748b; transform: translateY(-50%); }
.auth-dialog .input-field { min-height: 48px; border: 1px solid #cbd5e1; border-radius: 10px; background: #fff; color: #0f172a; font-size: 16px; }
.auth-dialog .input-field::placeholder { color: #64748b; }
.auth-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; color: #64748b; font-size: 14px; }
.auth-check { display: flex; min-width: 0; align-items: flex-start; gap: 9px; color: #64748b; font-size: 14px; line-height: 1.55; }
.auth-check input { flex: 0 0 auto; width: 16px; height: 16px; margin-top: 2px; accent-color: #1565c0; }
.auth-submit { min-height: 50px; border-radius: 10px; }
.auth-divider { display: flex; align-items: center; gap: 14px; margin: 24px 0 18px; color: #64748b; font-size: 13px; }
.auth-divider::before, .auth-divider::after { content: ''; height: 1px; flex: 1; background: #e2e8f0; }
.auth-socials { display: flex; justify-content: center; gap: 12px; }
.auth-socials button { display: grid; width: 44px; height: 44px; place-items: center; border: 1px solid #dbe4ee; border-radius: 12px; background: #fff; }
.auth-socials button:hover { border-color: #9fb7ca; background: #f6f9fb; }
@media (max-width: 900px) { .auth-layout { grid-template-columns: minmax(0, 1fr); min-height: 0; } .auth-story { display: none; } .auth-form-side { padding: 54px 34px 36px; } }
@media (max-width: 520px) { .auth-modal { align-items: end; padding: 0; } .auth-dialog { width: 100%; max-height: 94vh; border-radius: 16px 16px 0 0; } .auth-form-side { padding: 54px 20px 28px; } .auth-panel h2 { font-size: 26px; } .auth-field-grid { grid-template-columns: minmax(0, 1fr); } .auth-row { align-items: flex-start; flex-direction: column; gap: 10px; } }
</style>
</head>
<body class="text-gray-800 antialiased">
@@ -540,8 +589,8 @@
</div>
<!-- 未登录:登录注册按钮 -->
<div id="navAuthOut" class="hidden lg:flex items-center space-x-4">
<button onclick="window.location.href='login.html'" class="nav-link text-white/90 hover:text-white font-medium px-4 py-2 transition-colors" data-i18n="nav_login">登录</button>
<button onclick="window.location.href='register.html'" class="bg-white text-brand-blue hover:bg-brand-green hover:text-white font-semibold px-6 py-2.5 rounded-full transition-all duration-300 transform hover:scale-105" data-i18n="nav_register">
<button onclick="openAuthModal('login')" class="nav-link text-white/90 hover:text-white font-medium px-4 py-2 transition-colors" data-i18n="nav_login">登录</button>
<button onclick="openAuthModal('register')" class="bg-white text-brand-blue hover:bg-brand-green hover:text-white font-semibold px-6 py-2.5 rounded-full transition-all duration-300 transform hover:scale-105" data-i18n="nav_register">
免费注册
</button>
</div>
@@ -578,8 +627,8 @@
<a href="#about" onclick="navigateTo('about'); closeMobileMenu();" class="text-gray-700 hover:text-brand-blue font-medium py-2 border-b border-gray-100" data-i18n="nav_about">关于</a>
<a href="#contact" onclick="navigateTo('contact'); closeMobileMenu();" class="text-gray-700 hover:text-brand-blue font-medium py-2 border-b border-gray-100" data-i18n="nav_contact">联系</a>
<div id="navAuthOutMobile">
<button onclick="window.location.href='login.html'; closeMobileMenu();" class="text-left text-gray-700 hover:text-brand-blue font-medium py-2 border-b border-gray-100" data-i18n="nav_login">登录</button>
<button onclick="window.location.href='register.html'; closeMobileMenu();" class="btn-primary text-white font-semibold py-3 rounded-xl mt-4" data-i18n="nav_register">免费注册</button>
<button onclick="closeMobileMenu(); openAuthModal('login');" class="text-left text-gray-700 hover:text-brand-blue font-medium py-2 border-b border-gray-100" data-i18n="nav_login">登录</button>
<button onclick="closeMobileMenu(); openAuthModal('register');" class="btn-primary text-white font-semibold py-3 rounded-xl mt-4" data-i18n="nav_register">免费注册</button>
</div>
<div id="navAuthInMobile" class="hidden">
<button onclick="window.location.href='workbench.html'; closeMobileMenu();" class="text-left text-gray-700 hover:text-brand-blue font-medium py-2 border-b border-gray-100" data-i18n="nav_dashboard">工作台</button>
@@ -1177,7 +1226,7 @@
<h2 class="text-4xl lg:text-5xl font-bold text-white mb-6" data-i18n="cta_title">开启您的出海之旅</h2>
<p class="text-xl text-white/80 mb-10" data-i18n="cta_desc">立即注册,获取专属物流方案与报价,让柏悦物流成为您的跨境物流伙伴。</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<button id="ctaPrimary" onclick="window.location.href='register.html'" class="btn-accent text-white font-bold px-10 py-4 rounded-full text-lg" data-i18n="cta_btn1">
<button id="ctaPrimary" onclick="openAuthModal('register')" class="btn-accent text-white font-bold px-10 py-4 rounded-full text-lg" data-i18n="cta_btn1">
免费注册账号
</button>
<button onclick="showToast(t('toast_contact'))" class="bg-white/10 backdrop-blur-sm border-2 border-white/30 text-white hover:bg-white/20 font-bold px-10 py-4 rounded-full text-lg transition-all" data-i18n="cta_btn2">
@@ -1271,6 +1320,82 @@
</div>
</footer>
<!-- 官网内登录 / 注册 -->
<div id="authModal" class="auth-modal" data-mode="login" role="dialog" aria-modal="true" aria-labelledby="authDialogTitle" aria-hidden="true" hidden>
<button type="button" class="auth-modal-backdrop" aria-label="关闭登录注册窗口" onclick="closeAuthModal()"></button>
<div class="auth-dialog" role="document">
<button type="button" class="auth-close" aria-label="关闭" onclick="closeAuthModal()"><i class="fas fa-xmark" aria-hidden="true"></i></button>
<div class="auth-layout">
<aside class="auth-story" aria-hidden="true">
<div>
<img src="assets/logo.png" alt="" class="auth-story-logo">
<div id="authLoginStory">
<h2 data-i18n="login_welcome">欢迎回来</h2>
<p data-i18n="login_desc">登录柏悦物流平台,实时追踪货物轨迹,管理订单与库存。</p>
</div>
<div id="authRegisterStory" hidden>
<h2 data-i18n="reg_welcome">加入我们</h2>
<p data-i18n="reg_welcome_desc">注册柏悦物流企业账号,开启高效跨境物流体验。</p>
</div>
</div>
<div id="authLoginBenefits" class="auth-benefits">
<div class="auth-benefit"><i class="fas fa-circle-check"></i><span data-i18n="login_feat1">全程可视化追踪</span></div>
<div class="auth-benefit"><i class="fas fa-circle-check"></i><span data-i18n="login_feat2">智能订单管理</span></div>
<div class="auth-benefit"><i class="fas fa-circle-check"></i><span data-i18n="login_feat3">在线询价下单</span></div>
</div>
<div id="authRegisterBenefits" class="auth-benefits" hidden>
<div class="auth-benefit"><i class="fas fa-circle-check"></i><span data-i18n="reg_feat1">新用户专属优惠</span></div>
<div class="auth-benefit"><i class="fas fa-circle-check"></i><span data-i18n="reg_feat2">免费获取物流方案</span></div>
<div class="auth-benefit"><i class="fas fa-circle-check"></i><span data-i18n="reg_feat3">专属客户经理对接</span></div>
</div>
</aside>
<div class="auth-form-side">
<section id="loginPanel" class="auth-panel">
<h2 id="authDialogTitle" data-i18n="login_title">账号登录</h2>
<p class="auth-switch-copy"><span data-i18n="login_no_account">还没有账号?</span> <button type="button" class="auth-link" onclick="switchAuthMode('register')" data-i18n="login_register_link">立即注册</button></p>
<form id="loginForm" class="auth-form">
<div class="auth-field">
<label for="loginAccount" data-i18n="login_account_label">邮箱 / 手机号</label>
<div class="auth-input-wrap"><i class="fas fa-user"></i><input id="loginAccount" class="input-field w-full px-4" type="text" name="account" required autocomplete="username" placeholder="请输入邮箱或手机号" data-i18n-ph="login_account_ph"></div>
</div>
<div class="auth-field">
<label for="loginPassword" data-i18n="login_password_label">密码</label>
<div class="auth-input-wrap"><i class="fas fa-lock"></i><input id="loginPassword" class="input-field w-full px-4 pr-12" type="password" name="password" required autocomplete="current-password" placeholder="请输入密码" data-i18n-ph="login_password_ph"><button type="button" class="toggle-password" aria-label="显示或隐藏密码"><i class="fas fa-eye"></i></button></div>
</div>
<div class="auth-row">
<label class="auth-check"><input type="checkbox" name="remember"><span data-i18n="login_remember">记住我</span></label>
<button type="button" class="auth-link" onclick="showToast(t('toast_resetpw'))" data-i18n="login_forgot">忘记密码?</button>
</div>
<button type="submit" class="auth-submit btn-primary text-white font-bold" data-i18n="login_submit">登 录</button>
</form>
<div class="auth-divider" data-i18n="login_other">其他方式登录</div>
<div class="auth-socials">
<button type="button" aria-label="微信登录" onclick="showToast('微信登录即将上线')"><i class="fab fa-weixin text-green-600 text-xl"></i></button>
<button type="button" aria-label="企业微信登录" onclick="showToast('企业微信登录即将上线')"><i class="fas fa-briefcase text-blue-600"></i></button>
</div>
</section>
<section id="registerPanel" class="auth-panel" hidden>
<h2 id="registerDialogTitle" data-i18n="reg_title">企业注册</h2>
<p class="auth-switch-copy"><span data-i18n="reg_have_account">已有账号?</span> <button type="button" class="auth-link" onclick="switchAuthMode('login')" data-i18n="reg_login_link">立即登录</button></p>
<form id="registerForm" class="auth-form">
<div class="auth-field-grid">
<div class="auth-field"><label for="registerCompany" data-i18n="reg_company_label">企业名称</label><input id="registerCompany" class="input-field w-full px-4" type="text" name="company" required autocomplete="organization" placeholder="请输入企业名称" data-i18n-ph="reg_company_ph"></div>
<div class="auth-field"><label for="registerContact" data-i18n="reg_contact_label">联系人</label><input id="registerContact" class="input-field w-full px-4" type="text" name="contact" required autocomplete="name" placeholder="请输入联系人姓名" data-i18n-ph="reg_contact_ph"></div>
</div>
<div class="auth-field"><label for="registerEmail" data-i18n="reg_email_label">邮箱</label><div class="auth-input-wrap"><i class="fas fa-envelope"></i><input id="registerEmail" class="input-field w-full px-4" type="email" name="email" required autocomplete="email" placeholder="请输入企业邮箱" data-i18n-ph="reg_email_ph"></div></div>
<div class="auth-field"><label for="registerPhone" data-i18n="reg_phone_label">手机号</label><div class="auth-input-wrap"><i class="fas fa-phone"></i><input id="registerPhone" class="input-field w-full px-4" type="tel" name="phone" required autocomplete="tel" placeholder="请输入手机号" data-i18n-ph="reg_phone_ph"></div></div>
<div class="auth-field"><label for="registerPassword" data-i18n="reg_password_label">密码</label><div class="auth-input-wrap"><i class="fas fa-lock"></i><input id="registerPassword" class="input-field w-full px-4 pr-12" type="password" name="password" required minlength="8" maxlength="20" autocomplete="new-password" placeholder="请设置8-20位密码" data-i18n-ph="reg_password_ph"><button type="button" class="toggle-password" aria-label="显示或隐藏密码"><i class="fas fa-eye"></i></button></div></div>
<label class="auth-check" for="registerAgree"><input id="registerAgree" type="checkbox" required><span><span data-i18n="reg_agree">我已阅读并同意</span> <button type="button" class="auth-link" onclick="showToast(t('toast_tos'))" data-i18n="reg_agree_tos">《用户服务协议》</button> 和 <button type="button" class="auth-link" onclick="showToast(t('toast_pp'))" data-i18n="reg_agree_pp">《隐私政策》</button></span></label>
<button type="submit" class="auth-submit btn-accent text-white font-bold" data-i18n="reg_submit">立即注册</button>
</form>
</section>
</div>
</div>
</div>
</div>
<!-- Toast 提示 -->
<div id="toast" class="fixed top-24 left-1/2 -translate-x-1/2 z-[60] opacity-0 pointer-events-none transition-all duration-300 transform -translate-y-4">
<div class="bg-gray-900 text-white px-6 py-3 rounded-full shadow-2xl flex items-center space-x-2">
@@ -1554,6 +1679,74 @@
}
});
});
let authReturnFocus = null;
function switchAuthMode(mode) {
const normalizedMode = mode === 'register' ? 'register' : 'login';
const modal = document.getElementById('authModal');
const isRegister = normalizedMode === 'register';
modal.dataset.mode = normalizedMode;
document.getElementById('loginPanel').hidden = isRegister;
document.getElementById('registerPanel').hidden = !isRegister;
document.getElementById('authLoginStory').hidden = isRegister;
document.getElementById('authRegisterStory').hidden = !isRegister;
document.getElementById('authLoginBenefits').hidden = isRegister;
document.getElementById('authRegisterBenefits').hidden = !isRegister;
modal.setAttribute('aria-labelledby', isRegister ? 'registerDialogTitle' : 'authDialogTitle');
requestAnimationFrame(() => {
const firstField = document.querySelector(isRegister ? '#registerForm input' : '#loginForm input');
if (firstField) firstField.focus();
});
}
function openAuthModal(mode) {
const modal = document.getElementById('authModal');
authReturnFocus = document.activeElement;
modal.hidden = false;
modal.setAttribute('aria-hidden', 'false');
document.body.classList.add('auth-modal-open');
switchAuthMode(mode);
}
function closeAuthModal() {
const modal = document.getElementById('authModal');
if (modal.hidden) return;
modal.hidden = true;
modal.setAttribute('aria-hidden', 'true');
document.body.classList.remove('auth-modal-open');
if (authReturnFocus && typeof authReturnFocus.focus === 'function') authReturnFocus.focus();
}
document.getElementById('authModal').addEventListener('keydown', function(event) {
if (event.key === 'Escape') { closeAuthModal(); return; }
if (event.key !== 'Tab') return;
const focusable = [...this.querySelectorAll('button:not([hidden]), input:not([hidden])')]
.filter(element => !element.closest('[hidden]') && !element.disabled && element.offsetParent !== null);
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
});
document.getElementById('loginForm').addEventListener('submit', function(event) {
event.preventDefault();
isLoggedIn = true;
updateAuthUI();
closeAuthModal();
showToast(t('toast_login_ok'));
setTimeout(() => { window.location.href = 'workbench.html'; }, 700);
});
document.getElementById('registerForm').addEventListener('submit', function(event) {
event.preventDefault();
isLoggedIn = true;
updateAuthUI();
closeAuthModal();
showToast(t('toast_register_ok'));
setTimeout(() => { window.location.href = 'workbench.html'; }, 700);
});
// 退出登录
function doLogout() {
@@ -0,0 +1,67 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const html = fs.readFileSync('workbench.html', 'utf8');
function extractFunction(name) {
const start = html.indexOf(`function ${name}(`);
assert.notEqual(start, -1, `${name} should exist`);
const bodyStart = html.indexOf('{', start);
let depth = 0;
for (let index = bodyStart; index < html.length; index += 1) {
if (html[index] === '{') depth += 1;
if (html[index] === '}') depth -= 1;
if (depth === 0) return html.slice(start, index + 1);
}
throw new Error(`Unable to extract ${name}`);
}
assert.match(html, /<th>地址名称<\/th>/);
assert.match(html, /<label for="profile-address-name"><span class="required">\*<\/span>地址名称<\/label>/);
assert.match(html, /id="pickup-address-select"/);
assert.match(html, /提货时间[\s\S]*?选择地址/);
assert.match(html, /class="pickup-time-address-row"[\s\S]*?提货时间[\s\S]*?选择地址/);
const normalizeProfileAddress = vm.runInNewContext(`(${extractFunction('normalizeProfileAddress')})`);
const legacy = normalizeProfileAddress({id:'legacy-1', privateWarehouse:'旧地址', warehouseCode:'WH-1', country:'美国'});
assert.equal(legacy.addressName, '旧地址');
const select = { innerHTML: '' };
const renderPickupAddressOptions = vm.runInNewContext(`(${extractFunction('renderPickupAddressOptions')})`, {
document: { getElementById: id => id === 'pickup-address-select' ? select : null },
loadProfileAddresses: () => [
{id:'enabled-1', addressName:'洛杉矶仓', enabled:true},
{id:'disabled-1', addressName:'停用地址', enabled:false}
],
escapeProfileAddressHtml: value => String(value == null ? '' : value)
});
renderPickupAddressOptions();
assert.match(select.innerHTML, /value="enabled-1"[^>]*>洛杉矶仓<\/option>/);
assert.doesNotMatch(select.innerHTML, /停用地址/);
const controls = {
'pickup-address-select': { value: '' },
'pickup-contact': { value: '' },
'pickup-phone': { value: '' },
'pickup-address-detail': { value: '' }
};
const applyPickupAddress = vm.runInNewContext(`(${extractFunction('applyPickupAddress')})`, {
document: { getElementById: id => controls[id] || null },
formatPickupAddress: address => address.detailAddress || [address.stateProvince, address.city, address.country, address.postalCode, address.warehouseCode].filter(Boolean).join(','),
pickupAutoFilledValues: null,
loadProfileAddresses: () => [{
id:'address-1', addressName:'纽约仓', contact:'李雷', phone:'13800000000',
warehouseCode:'WH-NY', detailAddress:'123 Main St', city:'纽约', stateProvince:'纽约州', country:'美国', postalCode:'10001', enabled:true
}]
});
applyPickupAddress('address-1');
assert.equal(controls['pickup-contact'].value, '李雷');
assert.equal(controls['pickup-phone'].value, '13800000000');
assert.equal(controls['pickup-address-detail'].value, '123 Main St');
applyPickupAddress('');
assert.equal(controls['pickup-contact'].value, '');
assert.equal(controls['pickup-phone'].value, '');
assert.equal(controls['pickup-address-detail'].value, '');
console.log('address selection: ok');
@@ -0,0 +1,29 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const html = fs.readFileSync('workbench.html', 'utf8');
const detail = html.match(/<div class="page-content" id="page-cargo-disposal-detail">([\s\S]*?)<!-- ========== PAGE: 退货换标/)?.[1] || '';
assert.ok(detail, '货物废弃详情页 should exist');
assert.doesNotMatch(detail, /business-detail-tabs-shell/, '货物废弃详情不应再使用标签页容器');
assert.doesNotMatch(detail, /business-detail-panel|cargo-disposal-panel-|cargo-disposal-tab-/);
assert.match(detail, /货物废弃单号:<strong id="cargo-disposal-detail-order-no">-<\/strong>/);
assert.match(detail, /class="page-header-actions cargo-disposal-detail-header"/);
assert.match(detail, /class="page-action-group cargo-disposal-detail-actions"/);
assert.match(detail, /class="labeling-detail-status" id="cargo-disposal-detail-status"/);
assert.match(detail, /class="fas fa-arrow-left"/);
assert.match(detail, /class="labeling-detail-grid"/);
assert.equal((detail.match(/class="card labeling-detail-card"/g) || []).length, 4);
for (const title of ['单据信息', '货物明细', '费用明细', '关联单据']) {
assert.match(detail, new RegExp(`class="info-section-title">${title}<\\/div>`));
}
assert.match(detail, /<dl id="cargo-disposal-detail-document"><\/dl>/);
assert.match(detail, /<tbody id="cargo-disposal-detail-items"><\/tbody>/);
assert.match(detail, /<dl id="cargo-disposal-detail-fees"><\/dl>/);
assert.match(detail, /<dl id="cargo-disposal-detail-relations">/);
assert.doesNotMatch(detail, /cargo-disposal-(?:summary-grid|relation-grid|relation-item)/);
console.log('cargo disposal detail layout requirements: ok');
@@ -0,0 +1,88 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const html = fs.readFileSync('workbench.html', 'utf8');
const listPage = html.match(/<div class="page-content" id="page-cargo-disposal-list">([\s\S]*?)<!-- ========== PAGE: 新增货物废弃/)?.[1] || '';
assert.match(listPage, /<th>操作<\/th>/, 'view and edit should share an operation column');
function extractFunction(name) {
const start = html.indexOf(`function ${name}(`);
assert.notEqual(start, -1, `${name} should exist`);
const bodyStart = html.indexOf('{', start);
let depth = 0;
for (let index = bodyStart; index < html.length; index += 1) {
if (html[index] === '{') depth += 1;
if (html[index] === '}') depth -= 1;
if (depth === 0) return html.slice(start, index + 1);
}
throw new Error(`Unable to extract ${name}`);
}
const editModal = html.match(/<div class="modal-overlay" id="cargo-disposal-edit-modal"([\s\S]*?)<!-- ========== MODAL:/)?.[1] || '';
assert.ok(editModal, 'cargo disposal edit modal should exist');
assert.match(editModal, /id="cargo-disposal-edit-inbound-search"/);
assert.match(editModal, /id="cargo-disposal-edit-inbound-cards"/);
assert.match(editModal, /id="cargo-disposal-edit-fee"/);
assert.match(editModal, /id="cargo-disposal-edit-error"/);
assert.match(editModal, /onsubmit="submitCargoDisposalEdit\(event\)"/);
const tbody = { innerHTML: '' };
const controls = {
'cargo-disposal-list-body': tbody,
'cargo-disposal-total': { textContent: '' },
'cargo-disposal-page-number': { textContent: '' },
'cargo-disposal-prev': { disabled: false },
'cargo-disposal-next': { disabled: false }
};
const renderCargoDisposalList = vm.runInNewContext(
`(${extractFunction('renderCargoDisposalList')})`,
{
document: { getElementById: id => controls[id] || null },
cargoDisposalFilteredOrders: null,
loadCargoDisposalOrders: () => [{
id: 'order-1', orderNo: 'FH001', inboundNo: 'RK001', disposalBoxes: 2,
warehouse: '美西洛杉矶仓', feeAmount: 10, outboundOrderNo: 'CK001',
billingNo: 'JF001', status: '待处理', createdAt: '2026-09-04 09:00'
}],
CARGO_DISPOSAL_PAGE_SIZE: 10,
cargoDisposalCurrentPage: 1,
escapeCargoDisposalHtml: value => String(value == null ? '' : value)
}
);
renderCargoDisposalList();
assert.match(tbody.innerHTML, />查看<\/button>[\s\S]*?>编辑<\/button>/, 'each row should show edit after view');
assert.match(tbody.innerHTML, /openCargoDisposalEdit\(this\.dataset\.cargoDisposalId\)/);
const mergeCargoDisposalOrder = vm.runInNewContext(`(${extractFunction('mergeCargoDisposalOrder')})`);
const original = {
id: 'order-1', orderNo: 'FH001', tailOrderNo: 'LC001', outboundOrderNo: 'CK001',
billingNo: 'JF001', status: '处理中', createdAt: '2026-09-01 08:00', currency: 'USD',
inboundNo: 'OLD', warehouse: '旧仓', disposalBoxes: 2, feeAmount: 10,
items: [{ sku: 'OLD-SKU', name: '旧商品', availableBoxes: 4, disposalBoxes: 2 }]
};
const updated = mergeCargoDisposalOrder(original, {
inbound: {
inboundNo: 'RK009', warehouse: '美东新泽西仓',
items: [
{ id: 'one', sku: 'SKU-1', name: '商品一', availableBoxes: 8 },
{ id: 'two', sku: 'SKU-2', name: '商品二', availableBoxes: 5 }
]
},
items: [
{ id: 'one', disposalBoxes: 3 },
{ id: 'two', disposalBoxes: 1 }
]
}, 5);
assert.deepEqual(JSON.parse(JSON.stringify(updated)), {
id: 'order-1', orderNo: 'FH001', tailOrderNo: 'LC001', outboundOrderNo: 'CK001',
billingNo: 'JF001', status: '处理中', createdAt: '2026-09-01 08:00', currency: 'USD',
inboundNo: 'RK009', warehouse: '美东新泽西仓', disposalBoxes: 4, feeAmount: 20,
items: [
{ sku: 'SKU-1', name: '商品一', availableBoxes: 8, disposalBoxes: 3 },
{ sku: 'SKU-2', name: '商品二', availableBoxes: 5, disposalBoxes: 1 }
]
});
console.log('cargo disposal edit: ok');
@@ -0,0 +1,66 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const html = fs.readFileSync('workbench.html', 'utf8');
const listPage = html.match(/<div class="page-content" id="page-cargo-disposal-list">([\s\S]*?)<!-- ========== PAGE: 新增货物废弃/)?.[1] || '';
function textContent(markup) {
return markup.replace(/<[^>]+>/g, '').trim();
}
const headers = Array.from(listPage.matchAll(/<th(?:\s[^>]*)?>([\s\S]*?)<\/th>/g), match => textContent(match[1]));
const orderNoIndex = headers.indexOf('货物废弃单号');
assert.ok(orderNoIndex >= 0, '货物废弃单号列应存在');
assert.equal(headers[orderNoIndex + 1], '入仓码', '入仓码应紧跟货物废弃单号');
assert.equal(headers.includes('计费单号'), false, '列表不应显示计费单号');
assert.equal(headers.includes('关联出库单'), true, '列表应显示关联出库单');
assert.equal(headers.includes('废弃出库单号'), false, '列表不应再显示废弃出库单号');
const detailPage = html.match(/<div class="page-content" id="page-cargo-disposal-detail">([\s\S]*?)<!-- ========== PAGE:/)?.[1] || '';
assert.match(detailPage, /<dt>关联出库单<\/dt>/, '详情页应显示关联出库单');
assert.doesNotMatch(detailPage, /<dt>废弃出库单号<\/dt>/, '详情页不应再显示废弃出库单号');
function extractFunction(name) {
const start = html.indexOf(`function ${name}(`);
assert.notEqual(start, -1, `${name} should exist`);
const bodyStart = html.indexOf('{', start);
let depth = 0;
for (let index = bodyStart; index < html.length; index += 1) {
if (html[index] === '{') depth += 1;
if (html[index] === '}') depth -= 1;
if (depth === 0) return html.slice(start, index + 1);
}
throw new Error(`Unable to extract ${name}`);
}
const tbody = { innerHTML: '' };
const controls = {
'cargo-disposal-list-body': tbody,
'cargo-disposal-total': { textContent: '' },
'cargo-disposal-page-number': { textContent: '' },
'cargo-disposal-prev': { disabled: false },
'cargo-disposal-next': { disabled: false }
};
const renderCargoDisposalList = vm.runInNewContext(
`(${extractFunction('renderCargoDisposalList')})`,
{
document: { getElementById: id => controls[id] || null },
cargoDisposalFilteredOrders: null,
loadCargoDisposalOrders: () => [{
id: 'order-1', orderNo: 'FH001', inboundNo: 'RK001', disposalBoxes: 2,
warehouse: '美西洛杉矶仓', feeAmount: 10, outboundOrderNo: 'CK001',
billingNo: 'JF001', status: '待处理', createdAt: '2026-09-04 09:00'
}],
CARGO_DISPOSAL_PAGE_SIZE: 10,
cargoDisposalCurrentPage: 1,
escapeCargoDisposalHtml: value => String(value == null ? '' : value)
}
);
renderCargoDisposalList();
const orderNoCellStart = tbody.innerHTML.indexOf('<td>FH001</td>');
assert.ok(orderNoCellStart >= 0, '行中应输出货物废弃单号');
assert.equal(tbody.innerHTML.slice(orderNoCellStart).indexOf('<td>RK001</td>'), '<td>RK001</td>'.length, '行中入仓码应紧跟货物废弃单号');
assert.doesNotMatch(tbody.innerHTML, /JF001/, '行中不应输出计费单号');
console.log('cargo disposal list columns: ok');
@@ -0,0 +1,38 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const html = fs.readFileSync('workbench.html', 'utf8');
const pageOrder = html.match(/<div class="page-content active" id="page-order">([\s\S]*?)<\/div>\n\n<!-- ========== PAGE 2:/)?.[1] || '';
assert.ok(pageOrder, 'page-order markup should exist');
assert.match(pageOrder, /onclick="setPickupTab\(this,'truck'\)"[^>]*>自送货<\/button>/);
assert.doesNotMatch(pageOrder, />自送货-快递<\/button>|>自送货-卡车<\/button>/);
assert.match(pageOrder, /<div class="form-label">快递单号<\/div>[\s\S]*?请输入快递单号/);
assert.match(pageOrder, /class="pickup-driver-row"[\s\S]*?司机姓名[\s\S]*?司机电话/);
assert.match(html, /\.pickup-driver-row \.form-group\s*\{\s*grid-template-columns:\s*96px minmax\(0, 1fr\);/);
assert.match(pageOrder, /onclick="setDestTab\(this,'fulfillment'\)"[^>]*>一件代发<\/button>/);
const fulfillmentPanel = pageOrder.match(/<div class="dest-content" id="dest-fulfillment">([\s\S]*?)<\/div>\s*<\/div>\s*<\/section>/)?.[1] || '';
assert.ok(fulfillmentPanel, 'fulfillment destination panel should exist');
assert.match(fulfillmentPanel, /LX入库单号/);
assert.match(fulfillmentPanel, /name="lxInboundNo"/);
assert.match(fulfillmentPanel, /<span class="required">\*<\/span>LX入库单号/);
assert.doesNotMatch(fulfillmentPanel, /一件代发仓|收件人姓名|收件人电话|收件地址/);
const taxSelect = pageOrder.match(/<select name="tax"[^>]*>/)?.[0] || '';
assert.ok(taxSelect, 'tax selector should exist');
assert.match(taxSelect, /\brequired\b/);
const fullContainerSelect = pageOrder.match(/<select name="fullContainer"[^>]*>[\s\S]*?<\/select>/)?.[0] || '';
assert.ok(fullContainerSelect, 'full container selector should exist');
assert.match(fullContainerSelect, /<option value="">请选择<\/option>/);
assert.match(fullContainerSelect, /<option value="是">是<\/option>/);
assert.match(fullContainerSelect, /<option value="否">否<\/option>/);
assert.doesNotMatch(fullContainerSelect.match(/^<select[^>]*>/)?.[0] || '', /\brequired\b/);
const inspectionSelect = pageOrder.match(/<select name="inspection"[^>]*>[\s\S]*?<\/select>/)?.[0] || '';
assert.ok(inspectionSelect, 'inspection selector should exist');
assert.match(inspectionSelect, /<option value="">请选择<\/option>/);
assert.match(inspectionSelect, /<option value="是">是<\/option>/);
assert.match(inspectionSelect, /<option value="否">否<\/option>/);
assert.doesNotMatch(inspectionSelect.match(/^<select[^>]*>/)?.[0] || '', /\brequired\b/);
assert.match(pageOrder, /<div class="form-label"><span class="required">\*<\/span>买单\/退税<\/div>/);
assert.doesNotMatch(pageOrder, /<div class="form-label"><span class="required">\*<\/span>是否整柜<\/div>|<div class="form-label"><span class="required">\*<\/span>是否商检<\/div>/);
console.log('create-order requirements: ok');
@@ -0,0 +1,130 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const html = fs.readFileSync('workbench.html', 'utf8');
function extractFunction(name) {
const start = html.indexOf(`function ${name}(`);
assert.notEqual(start, -1, `${name} should exist`);
const bodyStart = html.indexOf('{', start);
let depth = 0;
for (let index = bodyStart; index < html.length; index += 1) {
if (html[index] === '{') depth += 1;
if (html[index] === '}') depth -= 1;
if (depth === 0) return html.slice(start, index + 1);
}
throw new Error(`Unable to extract ${name}`);
}
const inboundPage = html.match(/<div class="page-content" id="page-inbound">([\s\S]*?)<!-- ========== PAGE: 头程订单详情/)?.[1] || '';
assert.ok(inboundPage, 'inbound order list should exist');
assert.match(inboundPage, /onclick="openInboundBatchOrder\(\)"[^>]*>[\s\S]*?批量下单<\/button>/);
assert.doesNotMatch(inboundPage, /导出订单尾程信息/);
assert.match(inboundPage, /class="filter-item inbound-destination-filter"[\s\S]*?id="inbound-filter-destination-type"[\s\S]*?onclick="filterInboundOrders\(\)"[\s\S]*?>查询<\/button>/);
const modalStart = html.indexOf('<div class="topup-overlay hidden" id="inbound-batch-order-modal"');
const modalEnd = html.indexOf('<div class="sea-edit-overlay"', modalStart);
const modal = modalStart >= 0 && modalEnd > modalStart ? html.slice(modalStart, modalEnd) : '';
assert.ok(modal, 'batch order modal should exist');
assert.match(modal, /下载模板/);
assert.match(modal, /id="inbound-batch-order-file"[^>]*accept="\.xlsx,\.xls"/);
assert.match(modal, /onclick="submitInboundBatchOrder\(\)"[^>]*>提交订单<\/button>/);
const getTemplateSheets = vm.runInNewContext(`(${extractFunction('getInboundBatchTemplateSheets')})`);
const sheets = getTemplateSheets();
assert.deepEqual(Array.from(sheets, sheet => sheet.name), ['订单信息', '商品信息', '箱子信息']);
assert.deepEqual(Array.from(sheets, sheet => sheet.headers[0]), ['批次编号*', '批次编号*', '批次编号*']);
for (const sheet of sheets) {
const headers = Array.from(sheet.headers);
const firstOptional = headers.findIndex(header => !header.endsWith('*'));
assert.notEqual(firstOptional, -1, `${sheet.name} should include optional fields`);
assert.ok(headers.slice(0, firstOptional).every(header => header.endsWith('*')), `${sheet.name} required fields should come first`);
assert.ok(headers.slice(firstOptional).every(header => !header.endsWith('*')), `${sheet.name} optional fields should follow required fields`);
}
assert.ok(sheets[0].headers.includes('取货方式*'));
assert.ok(sheets[0].headers.includes('目的仓类型*'));
assert.ok(sheets[0].headers.includes('收货仓*'));
assert.ok(sheets[0].headers.includes('渠道*'));
assert.ok(sheets[0].headers.includes('买单/退税*'));
assert.ok(sheets[0].headers.includes('LX入库单号*'));
assert.ok(!sheets[0].headers.includes('一件代发仓*'));
assert.ok(sheets[0].headers.includes('订单备注'));
assert.ok(sheets[1].headers.includes('SKU NO.*'));
assert.ok(sheets[1].headers.includes('中文品名'));
assert.ok(sheets[2].headers.includes('关联 SKU*'));
assert.ok(sheets[2].headers.includes('箱数*'));
assert.ok(sheets[2].headers.includes('总体积(m³)'));
const validateWorkbookData = vm.runInNewContext(`(${extractFunction('validateInboundBatchWorkbookData')})`, {
getInboundBatchTemplateSheets: getTemplateSheets
});
const valid = validateWorkbookData({
订单信息: [sheets[0].headers, sheets[0].headers.map(header => header.endsWith('*') ? '示例值' : '')],
商品信息: [sheets[1].headers, sheets[1].headers.map(header => header.endsWith('*') ? '示例值' : '')],
箱子信息: [sheets[2].headers, sheets[2].headers.map(header => header.endsWith('*') ? '1' : '')]
});
assert.equal(valid.ok, true);
assert.deepEqual(Array.from(valid.counts), [1, 1, 1]);
const invalid = validateWorkbookData({
订单信息: [sheets[0].headers, sheets[0].headers.map(header => header === '订单备注' ? '测试备注' : '')],
商品信息: [sheets[1].headers],
箱子信息: [sheets[2].headers]
});
assert.equal(invalid.ok, false);
assert.match(invalid.message, /订单信息.*第 2 行.*批次编号/);
function makeRow(headers, values) {
return headers.map(header => values[header] || '');
}
const conditionalInvalid = validateWorkbookData({
订单信息: [sheets[0].headers, makeRow(sheets[0].headers, {
'批次编号*':'B001', '取货方式*':'我司提货(有取货费)', '目的仓类型*':'FBA仓', '收货仓*':'义乌仓', '渠道*':'美线快线', '买单/退税*':'买单', 'FBA仓编码*':'ONT8'
})],
商品信息: [sheets[1].headers, makeRow(sheets[1].headers, {'批次编号*':'B001', 'SKU NO.*':'SKU-01'})],
箱子信息: [sheets[2].headers, makeRow(sheets[2].headers, {'批次编号*':'B001', '关联 SKU*':'SKU-01', '箱数*':'2'})]
});
assert.equal(conditionalInvalid.ok, false);
assert.match(conditionalInvalid.message, /提货时间/);
const fulfillmentInvalid = validateWorkbookData({
订单信息: [sheets[0].headers, makeRow(sheets[0].headers, {
'批次编号*':'B002', '取货方式*':'自送货', '目的仓类型*':'一件代发', '收货仓*':'义乌仓', '渠道*':'美线快线', '买单/退税*':'买单', '预计到达时间*':'2026-09-10 10:00'
})],
商品信息: [sheets[1].headers, makeRow(sheets[1].headers, {'批次编号*':'B002', 'SKU NO.*':'SKU-02'})],
箱子信息: [sheets[2].headers, makeRow(sheets[2].headers, {'批次编号*':'B002', '关联 SKU*':'SKU-02', '箱数*':'1'})]
});
assert.equal(fulfillmentInvalid.ok, false);
assert.match(fulfillmentInvalid.message, /LX入库单号/);
const createOrders = vm.runInNewContext(`(${extractFunction('createInboundOrdersFromBatchData')})`);
const orders = createOrders({
订单信息: [sheets[0].headers, makeRow(sheets[0].headers, {
'批次编号*':'B001', '取货方式*':'自送货', '目的仓类型*':'FBA仓', '收货仓*':'义乌仓', '渠道*':'美线快线', '买单/退税*':'买单', '预计到达时间*':'2026-09-10 10:00', 'FBA仓编码*':'ONT8', '订单备注':'测试订单'
})],
商品信息: [sheets[1].headers, makeRow(sheets[1].headers, {'批次编号*':'B001', 'SKU NO.*':'SKU-01'})],
箱子信息: [sheets[2].headers, makeRow(sheets[2].headers, {'批次编号*':'B001', '关联 SKU*':'SKU-01', '箱数*':'2', '总毛重(kg)':'20', '总净重(kg)':'18', '总体积(m³)':'0.2'})]
}, new Date('2026-09-04T12:30:00'));
assert.equal(orders.length, 1);
assert.equal(orders[0].destinationWarehouse, 'ONT8');
assert.equal(orders[0].skus, 'SKU-01');
assert.equal(orders[0].boxes, 2);
assert.equal(orders[0].grossWeight, '20 kg');
assert.equal(orders[0].volume, '0.2 m³');
assert.equal(orders[0].status, '待处理');
const fulfillmentOrders = createOrders({
订单信息: [sheets[0].headers, makeRow(sheets[0].headers, {
'批次编号*':'B003', '取货方式*':'自送货', '目的仓类型*':'一件代发', '收货仓*':'深圳仓', '渠道*':'美线快线', '买单/退税*':'买单', '预计到达时间*':'2026-09-10 10:00', 'LX入库单号*':'LX20260904001'
})],
商品信息: [sheets[1].headers, makeRow(sheets[1].headers, {'批次编号*':'B003', 'SKU NO.*':'SKU-03'})],
箱子信息: [sheets[2].headers, makeRow(sheets[2].headers, {'批次编号*':'B003', '关联 SKU*':'SKU-03', '箱数*':'1'})]
}, new Date('2026-09-04T12:30:00'));
assert.equal(fulfillmentOrders[0].destinationWarehouse, 'LX20260904001');
assert.equal(fulfillmentOrders[0].lxInboundNo, 'LX20260904001');
console.log('inbound batch order requirements: ok');
@@ -0,0 +1,67 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const html = fs.readFileSync('workbench.html', 'utf8');
function extractFunction(name) {
const start = html.indexOf(`function ${name}(`);
assert.notEqual(start, -1, `${name} should exist`);
const bodyStart = html.indexOf('{', start);
let depth = 0;
for (let index = bodyStart; index < html.length; index += 1) {
if (html[index] === '{') depth += 1;
if (html[index] === '}') depth -= 1;
if (depth === 0) return html.slice(start, index + 1);
}
throw new Error(`Unable to extract ${name}`);
}
function extractConstValue(name) {
const declaration = `const ${name} = `;
const start = html.indexOf(declaration);
assert.notEqual(start, -1, `${name} should exist`);
const valueStart = start + declaration.length;
const end = html.indexOf(';', valueStart);
assert.notEqual(end, -1, `${name} should have a value`);
return html.slice(valueStart, end);
}
function textContent(markup) {
return markup.replace(/<[^>]+>/g, '').trim();
}
assert.match(html, /(?=[^>]*data-tab="container")(?=[^>]*aria-controls="inbound-panel-container")[^>]*>货柜节点<\/button>/);
assert.match(html, /id="inbound-panel-container"[\s\S]*id="inbound-detail-container-nodes"/);
const body = { innerHTML: '' };
const context = {
document: { getElementById: id => id === 'inbound-detail-container-nodes' ? body : null },
escapeInboundHtml: value => String(value == null ? '' : value),
};
const render = vm.runInNewContext(`(${extractFunction('renderInboundContainerNodes')})`, context);
render([]);
const defaultNodeNames = Array.from(body.innerHTML.matchAll(/<div class="inbound-container-node-name">([\s\S]*?)<\/div>/g), match => textContent(match[1]));
assert.equal(defaultNodeNames[6], '等待提柜');
const inboundOrders = vm.runInNewContext(`(${extractConstValue('INBOUND_ORDER_SEED')})`);
render(inboundOrders[0].containerNodes);
const seededNodeNames = Array.from(body.innerHTML.matchAll(/<div class="inbound-container-node-name">([\s\S]*?)<\/div>/g), match => textContent(match[1]));
assert.equal(seededNodeNames[6], '等待提柜');
render([
{ name: '国内报关完成', status: 'done', time: '2026-07-22 11:13' },
{ name: '实际开船', status: 'current', time: '-' },
{ name: '实际到港', status: 'pending', time: '-' },
]);
assert.equal((body.innerHTML.match(/class="inbound-container-node is-/g) || []).length, 3);
assert.match(body.innerHTML, /国内报关完成/);
assert.match(body.innerHTML, /实际开船/);
assert.match(body.innerHTML, /实际到港/);
assert.match(body.innerHTML, /inbound-container-node is-done/);
assert.match(body.innerHTML, /inbound-container-node is-current/);
assert.match(body.innerHTML, /inbound-container-node is-pending/);
assert.equal(textContent(body.innerHTML).includes('2026-07-22 11:13'), true);
console.log('inbound container nodes requirements: ok');
@@ -0,0 +1,35 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const html = fs.readFileSync('workbench.html', 'utf8');
function extractFunction(name) {
const start = html.indexOf(`function ${name}(`);
assert.notEqual(start, -1, `${name} should exist`);
const bodyStart = html.indexOf('{', start);
let depth = 0;
for (let index = bodyStart; index < html.length; index += 1) {
if (html[index] === '{') depth += 1;
if (html[index] === '}') depth -= 1;
if (depth === 0) return html.slice(start, index + 1);
}
throw new Error(`Unable to extract ${name}`);
}
const target = { innerHTML: '' };
const context = {
document: { getElementById: id => id === 'inbound-detail-fees' ? target : null },
escapeInboundHtml: value => String(value == null ? '' : value),
};
const render = vm.runInNewContext(`(${extractFunction('renderInboundFees')})`, context);
render({ unitPrice:'$0.85/kg', billableVolume:'1.26 m³', billableWeight:'186.4 kg', totalFee:'$158.44', paymentStatus:'已付款' });
const labels = Array.from(target.innerHTML.matchAll(/<span>([^<]+)<\/span>/g), match => match[1]);
assert.deepEqual(labels, ['计费单价', '计费体积', '计费重量', '费用总计', '费用状态']);
assert.match(target.innerHTML, /<span>费用总计<\/span><strong>\$158\.44<\/strong>/);
render({});
assert.match(target.innerHTML, /<span>费用总计<\/span><strong>—<\/strong>/);
console.log('inbound fee info requirements: ok');
@@ -0,0 +1,51 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const html = fs.readFileSync('workbench.html', 'utf8');
function extractFunction(name) {
const start = html.indexOf(`function ${name}(`);
assert.notEqual(start, -1, `${name} should exist`);
const bodyStart = html.indexOf('{', start);
let depth = 0;
for (let index = bodyStart; index < html.length; index += 1) {
if (html[index] === '{') depth += 1;
if (html[index] === '}') depth -= 1;
if (depth === 0) return html.slice(start, index + 1);
}
throw new Error(`Unable to extract ${name}`);
}
assert.match(html, /inbound-order-info-columns/);
const renderFunctionSource = html.slice(html.indexOf('function renderInboundOrderInfo('), html.indexOf('function handleInboundCustomsUpload('));
for (const label of ['入仓码', '开船时间', '到港时间', '提货方式', '提货时间', '提货联系人电话', '提货地址', '提货邮编', '提货联系人邮箱', '预计到货时间', '货物到仓时间', '订单类型', '渠道', '集装箱号', '目的仓类型', '目的仓', '邮编', '联系人', '联系人电话', '是否整柜', '商检验', '买单/退税', '总箱数', '总毛重(T.K.GW)', '总净重(T.K.NW)', '总体积(m³)', '计费毛重', '计费体积(m³)', '预估重量', '创建时间', '备注']) {
const escapedLabel = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
assert.match(renderFunctionSource, new RegExp(`['\"]${escapedLabel}['\"]`), `missing order info label: ${label}`);
}
const order = {
orderNo: 'RK-1', inboundNo: 'RK-1', channel: '美线快线', destinationWarehouse: 'ONT8', boxes: 4,
grossWeight: '79 kg', volume: '0.384 m³', billableWeight: '79 kg', billableVolume: '0.384 m³',
createdAt: '2026-09-02 10:47:09', remark: '-'
};
const target = { innerHTML: '' };
const context = {
document: { getElementById: id => id === 'inbound-detail-order-info' ? target : null },
escapeInboundHtml: value => String(value == null ? '' : value),
inboundDetailField: (label, value) => `<div class="inbound-detail-field"><dt>${label}</dt><dd>${value || '-'}</dd></div>`,
};
const render = vm.runInNewContext(`(${extractFunction('renderInboundOrderInfo')})`, context);
render(order);
assert.equal((target.innerHTML.match(/class="inbound-order-info-column/g) || []).length, 3);
assert.match(target.innerHTML, /<dt>入仓码<\/dt><dd>RK-1<\/dd>/);
assert.match(target.innerHTML, /<dt>订单类型<\/dt><dd>头程订单<\/dd>/);
assert.match(target.innerHTML, /<dt>渠道<\/dt><dd>美线快线<\/dd>/);
assert.match(target.innerHTML, /<dt>总箱数<\/dt><dd>4<\/dd>/);
assert.match(target.innerHTML, /<dt>创建时间<\/dt><dd>2026-09-02 10:47:09<\/dd>/);
assert.doesNotMatch(target.innerHTML, /<dt>客户<\/dt>/);
assert.doesNotMatch(renderFunctionSource, /['"]客户['"]/);
assert.doesNotMatch(renderFunctionSource, /['"]公司['"]/);
console.log('inbound order info requirements: ok');
@@ -0,0 +1,108 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const html = fs.readFileSync('workbench.html', 'utf8');
function extractFunction(name) {
const start = html.indexOf(`function ${name}(`);
assert.notEqual(start, -1, `${name} should exist`);
const bodyStart = html.indexOf('{', start);
let depth = 0;
for (let index = bodyStart; index < html.length; index += 1) {
if (html[index] === '{') depth += 1;
if (html[index] === '}') depth -= 1;
if (depth === 0) return html.slice(start, index + 1);
}
throw new Error(`Unable to extract ${name}`);
}
function textContent(markup) {
return markup.replace(/<[^>]+>/g, '').trim();
}
const inboundPage = html.match(/<div class="page-content" id="page-inbound">([\s\S]*?)<!-- ========== PAGE: 头程订单详情/)?.[1] || '';
assert.ok(inboundPage, 'inbound order list should exist');
const headers = Array.from(inboundPage.matchAll(/<th(?:\s[^>]*)?>([\s\S]*?)<\/th>/g), match => textContent(match[1]));
assert.deepEqual(headers.slice(0, 5), ['序号', '', '操作', '入仓码', '订单状态']);
const body = { innerHTML: '' };
const total = { textContent: '' };
const order = {
id: 'order-1', orderNo: 'RK-1', inboundNo: 'RK-1', channel: '美线快线', destinationWarehouse: 'ONT8',
boxes: 1, carrier: '顺丰', expressNo: 'SF-1', skus: 'SKU-1', grossWeight: '10 kg', netWeight: '9 kg',
volume: '0.1 m3', quantity: 10, unitPrice: '$1/kg', billableVolume: '0.1 m3', billableWeight: '10 kg',
remark: '-', shipmentNo: '-', trackingNo: '-', receivedAt: '-', businessDate: '2026-09-02',
status: '待确认', measurement: { rows: [] }, measurementConfirmed: false
};
const renderContext = {
document: { getElementById: id => ({ 'inbound-table-body': body, 'inbound-total': total })[id] || null },
inboundFilteredOrders: null,
INBOUND_ORDER_SEED: [order],
escapeInboundHtml: value => String(value == null ? '' : value),
inboundStatusClass: () => 'status-pending'
};
const renderInboundOrders = vm.runInNewContext(`(${extractFunction('renderInboundOrders')})`, renderContext);
renderInboundOrders();
const rowCells = Array.from(body.innerHTML.matchAll(/<td(?:\s[^>]*)?>([\s\S]*?)<\/td>/g), match => match[1]);
assert.equal(textContent(rowCells[0]), '1');
assert.match(rowCells[2], />查看<\/button>/);
assert.match(rowCells[2], /class="inbound-action"[^>]*>确认计费重<\/button>/);
assert.doesNotMatch(rowCells[2], /inbound-action-confirm/);
assert.match(rowCells[2], /onclick="showInboundOrderDetail\('order-1','measure'\)"[^>]*>确认计费重<\/button>/);
assert.equal(textContent(rowCells[3]), 'RK-1');
assert.equal(textContent(rowCells[4]), '待确认');
assert.match(inboundPage, />待确认<\/option>/);
assert.doesNotMatch(inboundPage, /待客户确认/);
assert.match(html, /<b>待发送数据<\/b><small id="inbound-node-shipping-data">/);
assert.doesNotMatch(html, /<b>待发运数据<\/b><small id="inbound-node-shipping-data">/);
const measurementModal = html.match(/<div class="topup-overlay hidden" id="inbound-measurement-modal"[^>]*>([\s\S]*?)<\/div>\s*<\/div>/)?.[1] || '';
assert.ok(measurementModal, 'measurement confirmation modal should exist');
assert.match(measurementModal, /<h3 id="inbound-measurement-title">确认<\/h3>/);
assert.match(measurementModal, /请确认是否接受后台测量结果?确认后将按测量结果计费,且不可修改。/);
assert.doesNotMatch(measurementModal, /inbound-modal-measure-summary|inbound-modal-measure-rows|箱规长\(cm\)/);
assert.match(measurementModal, />确认<\/button>/);
const selectedTabs = [];
const visitedPages = [];
const detailContext = {
inboundOrderById: () => order,
switchPage: page => visitedPages.push(page),
switchInboundDetailTab: tab => selectedTabs.push(tab),
renderInboundMeasureSummary() {},
renderInboundMeasureRows() {},
renderInboundCustomsDocs() {},
inboundDetailField: () => '',
escapeInboundHtml: value => String(value == null ? '' : value),
inboundDetailOrderId: null,
document: {
getElementById: id => id === 'inbound-detail-confirm-btn'
? { textContent: '', innerHTML: '', hidden: false, disabled: false }
: { textContent: '', innerHTML: '', hidden: false }
}
};
const showInboundOrderDetail = vm.runInNewContext(`(${extractFunction('showInboundOrderDetail')})`, detailContext);
showInboundOrderDetail('order-1', 'measure');
assert.deepEqual(visitedPages, ['inbound-detail']);
assert.deepEqual(selectedTabs, ['measure']);
order.measurementConfirmed = true;
const confirmedButton = { textContent: '', innerHTML: '', hidden: false, disabled: false };
const confirmedDetailContext = {
...detailContext,
inboundOrderById: () => order,
document: {
getElementById: id => id === 'inbound-detail-confirm-btn' ? confirmedButton : { textContent: '', innerHTML: '', hidden: false }
}
};
const showConfirmedInboundOrderDetail = vm.runInNewContext(`(${extractFunction('showInboundOrderDetail')})`, confirmedDetailContext);
showConfirmedInboundOrderDetail('order-1', 'measure');
assert.equal(confirmedButton.disabled, true);
assert.equal(confirmedButton.hidden, false);
assert.equal(confirmedButton.textContent, '已确认');
console.log('inbound order list requirements: ok');
@@ -0,0 +1,47 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const html = fs.readFileSync('workbench.html', 'utf8');
function extractFunction(name) {
const start = html.indexOf(`function ${name}(`);
assert.notEqual(start, -1, `${name} should exist`);
const bodyStart = html.indexOf('{', start);
let depth = 0;
for (let index = bodyStart; index < html.length; index += 1) {
if (html[index] === '{') depth += 1;
if (html[index] === '}') depth -= 1;
if (depth === 0) return html.slice(start, index + 1);
}
throw new Error(`Unable to extract ${name}`);
}
const productPanel = html.match(/id="inbound-panel-product"[\s\S]*?<\/section><\/div>/)?.[0] || '';
assert.ok(productPanel, 'product panel should exist');
const headers = Array.from(productPanel.matchAll(/<th>([^<]+)<\/th>/g), match => match[1]);
assert.deepEqual(headers, ['SKU NO.', '货件编号', '跟踪编号', 'UPC NO.', '中文品名', '图片', '英文品名', '产品规格', '每箱数量', '每箱重量', '每箱体积', '箱数', '材质', '用途', '型号', '品牌', '报关 HS CODE', '清关 HS CODE', '清关申报单价', '清关申报金额', '备注']);
const body = { innerHTML: '' };
const count = { textContent: '' };
const context = {
document: { getElementById: id => ({ 'inbound-detail-product-rows': body, 'inbound-detail-product-count': count })[id] || null },
escapeInboundHtml: value => String(value == null ? '' : value),
};
const render = vm.runInNewContext(`(${extractFunction('renderInboundProductItems')})`, context);
render({ productItems: [{ skuNo:'SKU-001', shipmentNo:'FBA-1', chineseName:'陀螺', englishName:'Fidget spinner', boxes:3, declaredAmount:'$42.00' }] });
assert.equal((body.innerHTML.match(/<tr>/g) || []).length, 1);
assert.match(body.innerHTML, /SKU-001/);
assert.match(body.innerHTML, /Fidget spinner/);
assert.match(body.innerHTML, /\$42\.00/);
assert.match(body.innerHTML, />—<\/td>/);
assert.equal(count.textContent, '共 1 条');
render({ skus:'SKU-A, SKU-B' });
assert.equal((body.innerHTML.match(/<tr>/g) || []).length, 2);
assert.match(body.innerHTML, /SKU-A/);
assert.match(body.innerHTML, /SKU-B/);
assert.equal(count.textContent, '共 2 条');
console.log('inbound product info requirements: ok');
@@ -0,0 +1,68 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const html = fs.readFileSync('workbench.html', 'utf8');
function extractFunction(name) {
const start = html.indexOf(`function ${name}(`);
assert.notEqual(start, -1, `${name} should exist`);
const bodyStart = html.indexOf('{', start);
let depth = 0;
for (let index = bodyStart; index < html.length; index += 1) {
if (html[index] === '{') depth += 1;
if (html[index] === '}') depth -= 1;
if (depth === 0) return html.slice(start, index + 1);
}
throw new Error(`Unable to extract ${name}`);
}
const createPage = html.match(/<div class="page-content" id="page-labeling">([\s\S]*?)<!-- ========== MODAL: 编辑贴标申请/)?.[1] || '';
const editModal = html.match(/<div class="modal-overlay" id="labeling-edit-modal"([\s\S]*?)<!-- ========== PAGE: 仓储转运单列表/)?.[1] || '';
assert.ok(editModal, 'labeling edit modal should exist');
const fieldNames = source => Array.from(source.matchAll(/\bname="([^"]+)"/g), match => match[1]).sort();
assert.deepEqual(fieldNames(editModal), fieldNames(createPage), 'edit modal should expose the same fields as create order');
assert.match(editModal, /onsubmit="submitLabelingEdit\(event\)"/);
const tbody = { innerHTML: '' };
const controls = {
'labeling-list-body': tbody,
'labeling-total': { textContent: '' }
};
const renderLabelingList = vm.runInNewContext(
`(${extractFunction('renderLabelingList')})`,
{
document: { getElementById: id => controls[id] || null },
labelingFilteredOrders: null,
loadLabelingOrders: () => [{
id: 'order-1', orderNo: 'TB001', inboundNo: 'RK001', warehouse: '美西洛杉矶仓',
labelRequirement: '换箱唛', skuCount: 1, totalBoxes: 2, status: '待处理', createdAt: '2026-09-04 09:00'
}],
escapeLabelingHtml: value => String(value == null ? '' : value),
labelingStatusClass: () => 'status-pending'
}
);
renderLabelingList();
assert.match(tbody.innerHTML, />查看<\/button>[\s\S]*?>编辑<\/button>/, 'each row should show edit after view');
assert.match(tbody.innerHTML, /openLabelingEdit\(this\.dataset\.labelingId\)/);
const mergeLabelingOrder = vm.runInNewContext(`(${extractFunction('mergeLabelingOrder')})`);
const original = {
id: 'order-1', orderNo: 'TB001', status: '处理中', createdAt: '2026-09-01 08:00', updatedAt: '2026-09-01 08:00',
inboundNo: 'OLD', warehouse: '旧仓', deadline: '2026-09-05T10:00', contact: '旧联系人', phone: '100', remark: '旧备注',
labelRequirement: '换箱唛', skuCount: 1, totalBoxes: 2, items: [{ sku: 'OLD-SKU', name: '旧商品', boxes: 2, requirement: '换箱唛', fileName: 'old.pdf', relationFileName: 'old.xlsx' }],
fees: { opening: '-', picking: '-', relabel: '-', carton: '-', total: '-' }
};
const updated = mergeLabelingOrder(original, {
inboundNo: 'RK009', warehouse: '美东新泽西仓', deadline: '2026-09-10T10:30', contact: '新联系人', phone: '200', remark: '新备注',
items: [{ sku: 'SKU-1', name: '商品一', boxes: 3, requirement: '换SKU标', fileName: '', relationFileName: '' }]
});
assert.deepEqual(JSON.parse(JSON.stringify(updated)), {
id: 'order-1', orderNo: 'TB001', status: '处理中', createdAt: '2026-09-01 08:00', updatedAt: updated.updatedAt,
inboundNo: 'RK009', warehouse: '美东新泽西仓', deadline: '2026-09-10T10:30', contact: '新联系人', phone: '200', remark: '新备注',
labelRequirement: '换SKU标', skuCount: 1, totalBoxes: 3, items: [{ sku: 'SKU-1', name: '商品一', boxes: 3, requirement: '换SKU标', fileName: 'old.pdf', relationFileName: 'old.xlsx' }],
fees: { opening: '-', picking: '-', relabel: '-', carton: '-', total: '-' }
});
console.log('labeling edit: ok');
@@ -0,0 +1,152 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const html = fs.readFileSync('workbench.html', 'utf8');
function extractFunction(name) {
const start = html.indexOf(`function ${name}(`);
assert.notEqual(start, -1, `${name} should exist`);
const bodyStart = html.indexOf('{', start);
let depth = 0;
for (let index = bodyStart; index < html.length; index += 1) {
if (html[index] === '{') depth += 1;
if (html[index] === '}') depth -= 1;
if (depth === 0) return html.slice(start, index + 1);
}
throw new Error(`Unable to extract ${name}`);
}
const getLabelingRelationTemplateRows = vm.runInNewContext(
`(${extractFunction('getLabelingRelationTemplateRows')})`
);
assert.deepEqual(
JSON.parse(JSON.stringify(getLabelingRelationTemplateRows())),
[['箱唛', '需贴箱唛', '需贴SKU标', '备注']],
'the downloaded relation template should expose the required columns in order'
);
const createPage = html.match(/<div class="page-content" id="page-labeling">([\s\S]*?)<!-- ========== MODAL: 编辑贴标申请/)?.[1] || '';
assert.match(
createPage,
/onclick="downloadLabelingRelationTemplate\(\)"[\s\S]*?onclick="addLabelingRow\(\)"/,
'the relation template download should appear before Add row'
);
let writtenWorkbook = null;
let writtenFileName = '';
const downloadLabelingRelationTemplate = vm.runInNewContext(
`(${extractFunction('downloadLabelingRelationTemplate')})`,
{
getLabelingRelationTemplateRows,
showToast: () => {},
XLSX: {
utils: {
aoa_to_sheet: rows => ({ rows }),
book_new: () => ({ sheets: [] }),
book_append_sheet: (workbook, worksheet, name) => workbook.sheets.push({ worksheet, name })
},
writeFile: (workbook, fileName) => {
writtenWorkbook = workbook;
writtenFileName = fileName;
}
}
}
);
downloadLabelingRelationTemplate();
assert.equal(writtenFileName, '贴标关联模板.xlsx');
assert.deepEqual(
JSON.parse(JSON.stringify(writtenWorkbook.sheets)),
[{ worksheet: { rows: [['箱唛', '需贴箱唛', '需贴SKU标', '备注']], '!cols': [{ wch: 24 }, { wch: 14 }, { wch: 14 }, { wch: 32 }] }, name: '关联明细' }]
);
const generatedRow = {
innerHTML: '',
querySelector: selector => selector === '.labeling-requirement' ? { value: '' } : null
};
const createLabelingRow = vm.runInNewContext(
`(${extractFunction('createLabelingRow')})`,
{
document: { createElement: () => generatedRow },
escapeLabelingHtml: value => String(value == null ? '' : value)
}
);
createLabelingRow({ relationFileName: '关联表.xlsx', itemRemark: '优先处理 A 箱' });
assert.match(generatedRow.innerHTML, /class="[^"]*labeling-relation-file[^"]*"[\s\S]*?关联表\.xlsx/);
assert.match(generatedRow.innerHTML, /class="form-input labeling-item-remark"[\s\S]*?value="优先处理 A 箱"/);
const fileControl = (selectedName, existingName) => ({
files: selectedName ? [{ name: selectedName }] : [],
dataset: { existingFile: existingName || '' }
});
const controls = {
'.labeling-sku': { value: ' SKU-100 ' },
'.labeling-name': { value: ' 商品一 ' },
'.labeling-boxes': { value: '3' },
'.labeling-transfer-boxes': { value: '2' },
'.labeling-shipment-no': { value: ' FBA-1 ' },
'.labeling-tracking-no': { value: ' TRACK-1 ' },
'.labeling-new-sku': { value: ' NEW-100 ' },
'.labeling-requirement': { value: '换SKU标' },
'.labeling-label-file input[type="file"]': fileControl('', '贴标文件.pdf'),
'.labeling-relation-file input[type="file"]': fileControl('新关联表.xlsx', '旧关联表.xlsx'),
'.labeling-item-remark': { value: ' 易碎品 ' }
};
const collectLabelingRows = vm.runInNewContext(`(${extractFunction('collectLabelingRows')})`);
const collectedRows = collectLabelingRows({
querySelectorAll: () => [{ querySelector: selector => controls[selector] || null }]
});
assert.deepEqual(JSON.parse(JSON.stringify(collectedRows)), [{
sku: 'SKU-100',
name: '商品一',
boxes: 3,
transferBoxes: 2,
shipmentNo: 'FBA-1',
trackingNo: 'TRACK-1',
newSku: 'NEW-100',
requirement: '换SKU标',
fileName: '贴标文件.pdf',
relationFileName: '新关联表.xlsx',
itemRemark: '易碎品'
}]);
let savedOrders = null;
const dataRow = { querySelector: selector => controls[selector] || null };
const labelingTbody = {
querySelector: () => null,
querySelectorAll: () => [dataRow]
};
const labelingForm = {
checkValidity: () => true,
elements: {
inboundNo: { value: 'RK001' },
deadline: { value: '2026-09-10T10:00' },
contact: { value: '联系人' },
phone: { value: '13800000000' },
remark: { value: '订单备注' }
}
};
const submitLabeling = vm.runInNewContext(
`(${extractFunction('submitLabeling')})`,
{
document: { getElementById: id => id === 'labeling-form' ? labelingForm : labelingTbody },
getLabelingInboundByNo: () => ({ warehouse: '美西洛杉矶仓' }),
loadLabelingOrders: () => [],
collectLabelingRows,
saveLabelingOrders: orders => { savedOrders = orders; return true; },
renderLabelingList: () => {},
openLabelingList: () => {},
showToast: () => {},
labelingFilteredOrders: null
}
);
submitLabeling({ preventDefault: () => {} });
assert.deepEqual(
JSON.parse(JSON.stringify(savedOrders[0].items)),
JSON.parse(JSON.stringify(collectedRows)),
'creating an order should persist all per-row labeling data'
);
console.log('labeling relation fields: ok');
@@ -0,0 +1,93 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const html = fs.readFileSync('workbench.html', 'utf8');
function textContent(markup) {
return markup.replace(/<[^>]+>/g, '').trim();
}
function firstHeaders(pageMarkup) {
return Array.from(pageMarkup.matchAll(/<th(?:\s[^>]*)?>([\s\S]*?)<\/th>/g), match => textContent(match[1]));
}
function page(id, endMarker) {
return html.match(new RegExp(`<div class="page-content" id="${id}">([\\s\\S]*?)${endMarker}`))?.[1] || '';
}
assert.equal(firstHeaders(page('page-inbound', '<!-- ========== PAGE: 头程订单详情'))[0], '序号');
assert.equal(firstHeaders(page('page-delivery', '<!-- ========== PAGE: 异常订单列表'))[0], '序号');
assert.equal(firstHeaders(page('page-exception-orders', '<!-- ========== PAGE: 异常订单详情'))[0], '序号');
assert.equal(firstHeaders(page('page-labeling-list', '<!-- ========== PAGE: 贴标申请'))[0], '序号');
assert.equal(firstHeaders(page('page-storage-transfer-list', '<!-- ========== PAGE: 仓储转运'))[0], '序号');
assert.equal(firstHeaders(page('page-cargo-disposal-list', '<!-- ========== PAGE: 新增货物废弃'))[0], '序号');
assert.equal(firstHeaders(page('page-return-relabel-list', '<div class="page-content" id="page-return-relabel-create">'))[0], '序号');
for (const id of ['sea-procurement-orders-body', 'sea-truck-orders-body', 'sea-pallet-orders-body']) {
const table = html.match(new RegExp(`<tbody id="${id}">`))?.[0] || '';
assert.ok(table, `${id} should exist`);
}
function extractFunction(name) {
const start = html.indexOf(`function ${name}(`);
assert.notEqual(start, -1, `${name} should exist`);
const bodyStart = html.indexOf('{', start);
let depth = 0;
for (let index = bodyStart; index < html.length; index += 1) {
if (html[index] === '{') depth += 1;
if (html[index] === '}') depth -= 1;
if (depth === 0) return html.slice(start, index + 1);
}
throw new Error(`Unable to extract ${name}`);
}
function firstCell(markup) {
return textContent(markup.match(/<td(?:\s[^>]*)?>([\s\S]*?)<\/td>/)?.[1] || '');
}
const seaBody = { innerHTML: '' };
const seaContext = {
document: {
getElementById: id => ({
'sea-procurement-orders-body': seaBody,
'sea-procurement-total': { textContent: '' },
'sea-procurement-page-size': { value: '' },
'sea-procurement-page-number': { textContent: '' },
'sea-procurement-prev': { disabled: false },
'sea-procurement-next': { disabled: false }
})[id] || null
},
seaBusinessSlug: () => 'procurement',
seaOrders: { Procurement: [{ id: 'one', orderNo: 'PO-1', fields: { '采购品类': '箱子' }, status: '处理中' }] },
seaPageState: { Procurement: { orderNo: '', customer: '', status: '' } },
seaPaginationState: { Procurement: { currentPage: 1, pageSize: 10 } },
paginateSeaOrders: (orders, currentPage, pageSize) => ({ total: orders.length, pages: 1, currentPage, pageSize, rows: orders }),
seaOrderCells: () => ['PO-1', '箱子', '-', '-', '-', '处理中'],
escapeSeaOrderHtml: value => String(value == null ? '' : value),
seaBusinessName: () => '采购'
};
const renderSeaOrders = vm.runInNewContext(`(${extractFunction('renderSeaOrders')})`, seaContext);
renderSeaOrders('Procurement');
assert.equal(firstCell(seaBody.innerHTML), '1');
const storageBody = { innerHTML: '' };
const storageControls = {
'storage-transfer-list-body': storageBody,
'storage-transfer-total': { textContent: '' },
'storage-transfer-page-number': { textContent: '' },
'storage-transfer-prev': { disabled: false },
'storage-transfer-next': { disabled: false }
};
const renderStorageTransferList = vm.runInNewContext(`(${extractFunction('renderStorageTransferList')})`, {
document: { getElementById: id => storageControls[id] || null },
storageTransferFilteredOrders: null,
loadStorageTransferOrders: () => [{ id: 'one', orderNo: 'ZY-1', inboundNo: 'RK-1', boxCount: 1, warehouse: '仓', fbaCode: 'ONT8', shipmentId: 'FBA-1', deliveryMethod: '卡车', relabel: 'no', tailOrderNo: 'LC-1', status: '待处理', createdAt: '2026-09-01' }],
STORAGE_TRANSFER_PAGE_SIZE: 10,
storageTransferCurrentPage: 1,
escapeStorageTransferHtml: value => String(value == null ? '' : value)
});
renderStorageTransferList();
assert.equal(firstCell(storageBody.innerHTML), '1');
console.log('order list index requirements: ok');
@@ -0,0 +1,21 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const html = fs.readFileSync('workbench.html', 'utf8');
const detail = html.match(/<section class="sea-business-detail-page" id="sea-pallet-detail"[\s\S]*?<div class="sea-business-form-panel" data-order-panel="Pallet"[^>]*>/)?.[0] || '';
assert.ok(detail, '美本土散板详情视图 should exist');
assert.match(detail, /class="pallet-detail-breadcrumb"/);
assert.match(detail, /class="pallet-detail-header"/);
assert.match(detail, /data-detail-content="status"/);
assert.match(detail, /class="pallet-detail-grid"/);
for (const card of ['transport', 'contact', 'cargo', 'timing']) {
assert.match(detail, new RegExp(`data-detail-card="${card}"`));
}
assert.doesNotMatch(detail, /business-detail-tabs-shell/);
assert.match(detail, /<dt>运输方式<\/dt>/);
assert.match(detail, /<dt>是否打盘<\/dt>/);
assert.match(detail, /<dt>总重量<\/dt>/);
assert.match(detail, /<dt>预算总价<\/dt>/);
console.log('pallet detail layout requirements: ok');
@@ -0,0 +1,33 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const html = fs.readFileSync('workbench.html', 'utf8');
const createPage = html.match(/<div class="page-content" id="page-return-relabel-create">([\s\S]*?)<\/div>\s*<!-- ========== MODAL: 编辑退货换标单/)?.[1] || '';
const editModal = html.match(/<div class="modal-overlay" id="return-relabel-edit-modal"([\s\S]*?)<!-- ========== MODAL:/)?.[1] || '';
const detailScript = html.match(/function openReturnRelabelDetail\(orderId\)\{([\s\S]*?)\n \}/)?.[1] || '';
const submitScript = html.match(/function submitReturnRelabelOrder\(event\)\{([\s\S]*?)\n \}/)?.[1] || '';
const editSubmitScript = html.match(/function submitReturnRelabelEdit\(event\)\{([\s\S]*?)\n \}/)?.[1] || '';
const associatedUiScript = html.match(/function ensureReturnRelabelAssociatedFields\(\)\{([\s\S]*?)\n \}/)?.[1] || '';
assert.ok(createPage, '新建退货换标页 should exist');
assert.ok(editModal, '退货换标编辑弹框 should exist');
assert.match(editModal, /关联文件模板/, '编辑换标设置应包含关联文件模板入口');
assert.match(editModal, /name="relationFile"/, '编辑换标设置应包含上传关联文件');
assert.match(editModal, /name="relationRemark"/, '编辑换标设置应包含关联文件备注');
assert.match(associatedUiScript, /关联文件模板/, '新建换标设置应包含关联文件模板入口');
assert.match(associatedUiScript, /name="relationFile"/, '新建换标设置应包含上传关联文件');
assert.match(associatedUiScript, /name="relationRemark"/, '新建换标设置应包含关联文件备注');
assert.match(createPage, /贴标要求[\s\S]*上传箱唛[\s\S]*上传 SKU 标签/);
assert.match(associatedUiScript, /关联文件模板[\s\S]*上传关联文件[\s\S]*备注/);
assert.match(editModal, /贴标要求[\s\S]*关联文件模板[\s\S]*上传箱唛[\s\S]*上传 SKU 标签[\s\S]*上传关联文件[\s\S]*备注/);
assert.match(associatedUiScript, /downloadReturnRelabelRelationTemplate\(\)/);
assert.match(editModal, /downloadReturnRelabelRelationTemplate\(\)/);
assert.match(html, /submitReturnRelabelOrder=function[\s\S]*relationFile/);
assert.match(html, /submitReturnRelabelEdit=function[\s\S]*relationFile/);
assert.match(detailScript, /return-relabel-detail-relation-file/);
assert.match(detailScript, /return-relabel-detail-relation-remark/);
console.log('return relabel associated fields: ok');
@@ -0,0 +1,27 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const html = fs.readFileSync('workbench.html', 'utf8');
assert.match(
html,
/\.client-shell #page-return-relabel-create \.return-relabel-form-grid\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*repeat\(3,\s*minmax\(0,\s*1fr\)\)/,
'新建退货换标页应恢复三列表单布局'
);
assert.match(
html,
/\.client-shell #page-return-relabel-create :is\(\.return-relabel-input,\s*\.return-relabel-select,\s*\.return-relabel-textarea\)\s*\{[^}]*width:\s*100%;[^}]*border:\s*1px solid #dbe3ef/,
'新建退货换标页应恢复表单控件样式'
);
assert.match(
html,
/\.client-shell #page-return-relabel-create \.return-relabel-settings-panel\s*\{[^}]*border:\s*1px solid #dce6ee;[^}]*background:\s*#fbfdfe/,
'新建退货换标页应恢复换标设置面板样式'
);
assert.match(
html,
/@media\s*\(max-width:\s*760px\)[\s\S]*?\.client-shell #page-return-relabel-create \.return-relabel-form-grid[\s\S]*?grid-template-columns:\s*1fr/,
'新建退货换标页在窄屏应恢复单列布局'
);
console.log('return relabel create styles: ok');
@@ -0,0 +1,74 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const html = fs.readFileSync('workbench.html', 'utf8');
const list = html.match(/<div class="page-content" id="page-return-relabel-list">([\s\S]*?)<div class="page-content" id="page-return-relabel-create">/)?.[1] || '';
const detail = html.match(/<div class="page-content" id="page-return-relabel-detail">([\s\S]*?)<!-- ========== PAGE: 旧退货入库/)?.[1] || '';
const detailScript = html.match(/function openReturnRelabelDetail\(orderId\)\{([\s\S]*?)\n \}/)?.[1] || '';
function extractDivByClass(source, className) {
const opening = `<div class="${className}">`;
const start = source.indexOf(opening);
assert.notEqual(start, -1, `${className} should exist`);
const tags = /<\/?div\b[^>]*>/g;
tags.lastIndex = start;
let depth = 0;
let match;
while ((match = tags.exec(source))) {
depth += match[0].startsWith('</') ? -1 : 1;
if (depth === 0) return source.slice(start, tags.lastIndex);
}
throw new Error(`Unable to extract ${className}`);
}
assert.ok(list, '退货换标列表页 should exist');
assert.ok(detail, '退货换标详情页 should exist');
assert.match(list, /class="storage-transfer-filter-grid"/);
assert.match(list, /class="storage-transfer-filter-item"/);
assert.match(list, /class="storage-transfer-filter-label"/);
assert.match(list, /class="storage-transfer-input"/);
assert.match(list, /class="storage-transfer-select"/);
assert.match(list, /class="storage-transfer-date-range"/);
assert.match(list, /class="storage-transfer-filter-actions"/);
const filterGrid = extractDivByClass(list, 'storage-transfer-filter-grid');
const createdEndIndex = filterGrid.indexOf('id="return-relabel-filter-created-end"');
const actionsIndex = filterGrid.indexOf('<div class="storage-transfer-filter-actions">');
assert.ok(createdEndIndex >= 0, '创建时间结束日期应存在');
assert.ok(actionsIndex > createdEndIndex, '查询/重置按钮应紧跟创建时间筛选项,并位于筛选网格内');
assert.doesNotMatch(list, /return-relabel-filter-(?:card|grid|actions)/);
assert.doesNotMatch(list, /return-relabel-(?:input|select|list-card|pagination)/);
assert.match(list, /class="card"><div class="table-wrap-simple"><table class="data-table return-relabel-table"/);
assert.match(list, /class="pagination-bar"/);
assert.doesNotMatch(list, /storage-transfer-list-card|storage-transfer-pagination/);
assert.doesNotMatch(detail, /business-detail-tabs-shell/, '退货换标详情不应再使用标签页容器');
assert.match(detail, /class="labeling-detail-grid return-relabel-detail-grid"/);
assert.match(detail, /class="card labeling-detail-card"/);
assert.match(detail, /class="card labeling-detail-card return-relabel-detail-order-card"/);
assert.match(detail, /class="return-relabel-detail-order-grid" id="return-relabel-detail-summary"/);
assert.match(detail, /class="info-section-title">订单信息<\/div>/);
assert.match(detail, /class="info-section-title">商品信息<\/div>/);
assert.match(detail, /class="info-section-title">换标数据<\/div>/);
assert.match(detail, /id="return-relabel-detail-order-no"/);
assert.match(detail, /class="labeling-detail-status"[^>]*id="return-relabel-detail-status"/);
assert.match(detailScript, /return-relabel-detail-order-no/);
assert.match(detailScript, /labeling-detail-field/);
assert.match(detailScript, /return-relabel-detail-status/);
assert.match(detail, /id="return-relabel-detail-relabel-data"[\s\S]*return-relabel-detail-relabel-enabled/);
assert.match(detail, /id="return-relabel-detail-relabel-data"[\s\S]*return-relabel-detail-label-case/);
assert.match(detail, /id="return-relabel-detail-relabel-data"[\s\S]*return-relabel-detail-carton-label/);
assert.match(detail, /id="return-relabel-detail-relabel-data"[\s\S]*return-relabel-detail-sku-label/);
assert.doesNotMatch(detail, /return-relabel-detail-tail-order-no/);
assert.doesNotMatch(detail, /return-relabel-detail-tail-status/);
assert.doesNotMatch(detailScript, /\['是否换标'/);
assert.doesNotMatch(detailScript, /\['贴标要求'/);
assert.doesNotMatch(detailScript, /\['上传箱唛'/);
assert.doesNotMatch(detailScript, /\['上传 SKU 标签'/);
assert.doesNotMatch(detail, /storage-transfer-section|storage-transfer-summary-grid|storage-transfer-relation-grid/);
assert.doesNotMatch(detail, /return-relabel-(?:section|summary-grid|relation-grid|table-wrap)/);
assert.doesNotMatch(detail, /后台关联单据/);
assert.match(html, /\.return-relabel-detail-order-card\s*\{[^}]*grid-column:\s*1\s*\/\s*-1/);
assert.match(html, /\.return-relabel-detail-order-grid\s*\{[^}]*grid-template-columns:\s*repeat\(2,\s*minmax\(0,\s*1fr\)\)/);
assert.match(html, /@media\s*\(max-width:\s*760px\)[\s\S]*\.return-relabel-detail-order-grid\s*\{[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)/);
console.log('return relabel detail layout requirements: ok');
@@ -0,0 +1,98 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const html = fs.readFileSync('workbench.html', 'utf8');
function extractFunction(name) {
const start = html.indexOf(`function ${name}(`);
assert.notEqual(start, -1, `${name} should exist`);
const bodyStart = html.indexOf('{', start);
let depth = 0;
for (let index = bodyStart; index < html.length; index += 1) {
if (html[index] === '{') depth += 1;
if (html[index] === '}') depth -= 1;
if (depth === 0) return html.slice(start, index + 1);
}
throw new Error(`Unable to extract ${name}`);
}
const editModal = html.match(/<div class="modal-overlay" id="return-relabel-edit-modal"([\s\S]*?)<!-- ========== MODAL:/)?.[1] || '';
assert.ok(editModal, '退货换标编辑弹框 should exist');
assert.match(editModal, /onsubmit="submitReturnRelabelEdit\(event\)"/);
for (const field of ['sourceFba', 'reason', 'carrier', 'trackingNo', 'returnRemark', 'relabel', 'labelCase', 'cartonLabel', 'skuLabel', 'destinationFba', 'address', 'expectedArrival', 'tailRemark']) {
assert.match(editModal, new RegExp(`name="${field}"`), `edit modal should include ${field}`);
}
assert.match(editModal, /id="return-relabel-edit-sku-body"/);
assert.match(editModal, /id="return-relabel-edit-error"/);
assert.match(editModal, /当前文件/);
const tbody = { innerHTML: '' };
const controls = {
'return-relabel-list-body': tbody,
'return-relabel-total': { textContent: '' },
'return-relabel-page-number': { textContent: '' },
'return-relabel-prev': { disabled: false },
'return-relabel-next': { disabled: false }
};
const renderReturnRelabelList = vm.runInNewContext(
`(${extractFunction('renderReturnRelabelList')})`,
{
document: { getElementById: id => controls[id] || null },
returnRelabelFilteredOrders: null,
loadReturnRelabelOrders: () => [{
id: 'order-1', orderNo: 'THHB001', sourceFba: 'ONT8', destinationFba: 'LAX9',
relabel: true, labelCase: 'Case1', backendTailOrderNo: 'LC001',
backendRelabelOrderNo: 'HB001', relabelStatus: '待换标', tailStatus: '待入库',
createdAt: '2026-09-04 09:00', skus: [{ sku: 'SKU-1', quantity: 2 }]
}],
RETURN_RELABEL_PAGE_SIZE: 10,
returnRelabelCurrentPage: 1,
returnRelabelLabelText: () => '换箱唛',
escapeReturnRelabelHtml: value => String(value == null ? '' : value)
}
);
renderReturnRelabelList();
assert.match(tbody.innerHTML, />查看<\/button>[\s\S]*?>编辑<\/button>/, '编辑 should follow 查看');
assert.match(tbody.innerHTML, /openReturnRelabelEdit\(this\.dataset\.returnRelabelEditId\)/);
const mergeReturnRelabelOrder = vm.runInNewContext(`(${extractFunction('mergeReturnRelabelOrder')})`);
const original = {
id: 'order-1', orderNo: 'THHB001', backendTailOrderNo: 'LC001', backendRelabelOrderNo: 'HB001',
relabelStatus: '换标中', tailStatus: '待入库', createdAt: '2026-09-01 08:00',
sourceFba: 'ONT8', carrier: 'UPS', cartonLabel: 'old-carton.pdf', skuLabel: ''
};
const updated = mergeReturnRelabelOrder(original, {
sourceFba: 'ABE8', destinationFba: 'ONT8', reason: '客户退货', carrier: 'FedEx',
trackingNo: 'NEW-TRACK', returnRemark: '已更新', relabel: true, labelCase: 'Case3',
cartonLabel: 'old-carton.pdf', skuLabel: 'new-sku.pdf', address: 'New address',
expectedArrival: '2026-09-10T10:30', tailRemark: 'new remark',
skus: [{ sku: 'SKU-2', name: '新品', fnsku: 'FN-2', newSku: 'NEW-2', quantity: 5 }]
});
assert.deepEqual(JSON.parse(JSON.stringify(updated)), {
id: 'order-1', orderNo: 'THHB001', backendTailOrderNo: 'LC001', backendRelabelOrderNo: 'HB001',
relabelStatus: '换标中', tailStatus: '待入库', createdAt: '2026-09-01 08:00',
sourceFba: 'ABE8', destinationFba: 'ONT8', reason: '客户退货', carrier: 'FedEx',
trackingNo: 'NEW-TRACK', returnRemark: '已更新', relabel: true, labelCase: 'Case3',
cartonLabel: 'old-carton.pdf', skuLabel: 'new-sku.pdf', address: 'New address',
expectedArrival: '2026-09-10T10:30', tailRemark: 'new remark',
skus: [{ sku: 'SKU-2', name: '新品', fnsku: 'FN-2', newSku: 'NEW-2', quantity: 5 }]
});
const saved = [];
const storageContext = {
localStorage: {
getItem: () => JSON.stringify([{ id: 'return-relabel-seed-1', orderNo: 'EDITED', skus: [{ sku: 'SKU', name: '商品', fnsku: 'FN', quantity: 1 }] }]),
setItem: (_key, value) => saved.push(JSON.parse(value))
},
RETURN_RELABEL_KEY: 'key',
RETURN_RELABEL_SEED: [{ id: 'return-relabel-seed-1', orderNo: 'SEED', skus: [{ sku: 'SKU', name: '商品', fnsku: 'FN', quantity: 1 }] }],
showToast() {}
};
const loadReturnRelabelOrders = vm.runInNewContext(`(${extractFunction('loadReturnRelabelOrders')})`, storageContext);
const saveReturnRelabelOrders = vm.runInNewContext(`(${extractFunction('saveReturnRelabelOrders')})`, storageContext);
assert.equal(loadReturnRelabelOrders()[0].orderNo, 'EDITED', 'saved seed override should win');
assert.equal(saveReturnRelabelOrders([{ id: 'return-relabel-seed-1', orderNo: 'EDITED', skus: [] }]), true);
assert.equal(saved[0][0].id, 'return-relabel-seed-1', 'seed edits should be persisted');
console.log('return relabel edit: ok');
@@ -0,0 +1,42 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const html = fs.readFileSync('workbench.html', 'utf8');
const createPage = html.match(/<div class="page-content" id="page-return-relabel-create">([\s\S]*?)<\/div>\s*<!-- ========== MODAL: 编辑退货换标单/)?.[1] || '';
const editModal = html.match(/<div class="modal-overlay" id="return-relabel-edit-modal"([\s\S]*?)<!-- ========== MODAL:/)?.[1] || '';
const submitScript = html.match(/function submitReturnRelabelOrder\(event\)\{([\s\S]*?)\n \}/)?.[1] || '';
const editSubmitScript = html.match(/function submitReturnRelabelEdit\(event\)\{([\s\S]*?)\n \}/)?.[1] || '';
const detailScript = html.match(/function openReturnRelabelDetail\(orderId\)\{([\s\S]*?)\n \}/)?.[1] || '';
const validateScript = html.match(/function validateReturnRelabelForm\(\)\{([\s\S]*?)\n \}/)?.[1] || '';
const editValidateScript = html.match(/function validateReturnRelabelEditForm\(\)\{([\s\S]*?)\n \}/)?.[1] || '';
assert.ok(createPage, '新建退货换标页 should exist');
assert.ok(editModal, '编辑退货换标弹框 should exist');
const inboundIndex = createPage.indexOf('name="inboundCode"');
const sourceIndex = createPage.indexOf('name="sourceFba"');
const reasonIndex = createPage.indexOf('name="reason"');
const receivingIndex = createPage.indexOf('name="receivingWarehouse"');
assert.ok(inboundIndex >= 0, '新建页应包含入仓码');
assert.ok(createPage.match(/name="inboundCode"[^>]*required/), '入仓码应必填');
assert.ok(sourceIndex >= 0 && inboundIndex < sourceIndex, '入仓码应在来源 FBA 仓前');
assert.ok(receivingIndex >= 0, '新建页应包含收货仓');
assert.ok(createPage.match(/name="receivingWarehouse"[^>]*required/), '收货仓应必填');
assert.ok(reasonIndex >= 0 && receivingIndex > reasonIndex, '收货仓应在退货原因后');
assert.match(createPage, /<option>洛杉矶仓<\/option>[\s\S]*<option>纽约仓<\/option>/);
for (const field of ['inboundCode', 'receivingWarehouse']) {
assert.match(editModal, new RegExp(`name="${field}"`), `编辑弹框应包含 ${field}`);
}
assert.match(submitScript, /inboundCode:form\.elements\.inboundCode\.value\.trim\(\)/);
assert.match(submitScript, /receivingWarehouse:form\.elements\.receivingWarehouse\.value/);
assert.match(editSubmitScript, /inboundCode:form\.elements\.inboundCode\.value\.trim\(\)/);
assert.match(editSubmitScript, /receivingWarehouse:form\.elements\.receivingWarehouse\.value/);
assert.match(validateScript, /inboundCode/);
assert.match(validateScript, /receivingWarehouse/);
assert.match(editValidateScript, /inboundCode/);
assert.match(editValidateScript, /receivingWarehouse/);
assert.match(detailScript, /入仓码/);
assert.match(detailScript, /收货仓/);
console.log('return relabel inbound receiving: ok');
@@ -0,0 +1,21 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const html = fs.readFileSync('workbench.html', 'utf8');
const list = html.match(/<div class="page-content" id="page-return-relabel-list">([\s\S]*?)<div class="page-content" id="page-return-relabel-create">/)?.[1] || '';
const tableHead = list.match(/<table class="data-table return-relabel-table">[\s\S]*?<thead><tr>([\s\S]*?)<\/tr><\/thead>/)?.[1] || '';
const headers = [...tableHead.matchAll(/<th[^>]*>([\s\S]*?)<\/th>/g)].map(([, label]) => label.replace(/<[^>]+>/g, '').trim());
const renderScript = html.match(/function renderReturnRelabelList\(\)\{([\s\S]*?)\n function ensureReturnRelabelAssociatedFields/)?.[1] || '';
assert.deepEqual(headers, [
'序号', '退货换标单号', '入仓码', '来源FBA仓', '目的FBA仓', 'SKU汇总',
'退件总数', '是否换标', '贴标要求', '换标单号', '换标状态', '创建时间', '操作'
], '退货换标列表列顺序应包含入仓码并隐藏尾程字段');
assert.equal((list.match(/<th>尾程订单号<\/th>/g) || []).length, 0, '列表不应显示尾程订单号列');
assert.equal((list.match(/<th>尾程订单状态<\/th>/g) || []).length, 0, '列表不应显示尾程订单状态列');
assert.match(renderScript, /escapeReturnRelabelHtml\(o\.inboundCode\|\|'—'\)/, '列表行应渲染入仓码');
assert.doesNotMatch(renderScript, /backendTailOrderNo/, '列表行不应渲染尾程订单号');
assert.doesNotMatch(renderScript, /o\.tailStatus/, '列表行不应渲染尾程订单状态');
assert.match(renderScript, /<td colspan="13">暂无退货换标订单<\/td>/, '空列表列数应与新表头一致');
console.log('return relabel list columns: ok');
@@ -0,0 +1,76 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const html = fs.readFileSync('workbench.html', 'utf8');
const createPage = html.match(/<div class="page-content" id="page-return-relabel-create">([\s\S]*?)<div class="page-content" id="page-return-relabel-detail">/)?.[1] || '';
assert.ok(createPage, '新建退货换标页 should exist');
assert.match(createPage, /name="inboundCode"[^>]*required/, '入仓码应必填');
assert.match(createPage, /<label><span[^>]*color:\s*#ff4d4f[^>]*>\*<\/span>入仓码<\/label>/, '入仓码前应显示红色星号');
assert.doesNotMatch(createPage, /<th>承运商<span[^>]*>\*<\/span><\/th>/, '承运商不应显示必填标记');
assert.doesNotMatch(createPage, /<th>运单号<span[^>]*>\*<\/span><\/th>/, '运单号不应显示必填标记');
function extractFunction(name) {
const start = html.indexOf(`function ${name}(`);
assert.notEqual(start, -1, `${name} should exist`);
const bodyStart = html.indexOf('{', start);
let depth = 0;
for (let index = bodyStart; index < html.length; index += 1) {
if (html[index] === '{') depth += 1;
if (html[index] === '}') depth -= 1;
if (depth === 0) return html.slice(start, index + 1);
}
throw new Error(`Unable to extract ${name}`);
}
function control(value, extra = {}) {
return { value, ...extra };
}
function cargoRow({ carrier = '', trackingNo = '' } = {}) {
const fields = {
'.return-relabel-carrier': control(carrier),
'.return-relabel-tracking-no': control(trackingNo),
'.return-relabel-sku': control('SKU-1'),
'.return-relabel-name': control('商品一'),
'.return-relabel-fnsku': control('FN-1'),
'.return-relabel-quantity': control('1')
};
return { querySelector: selector => fields[selector] || null };
}
const form = {
checkValidity: () => true,
reportValidity() {},
elements: {
inboundCode: control('IN-001'),
receivingWarehouse: control('洛杉矶仓'),
address: control('123 Main St'),
relabel: control('', { checked: false }),
labelCase: control('Case1'),
cartonLabel: control('', { files: [] }),
skuLabel: control('', { files: [] })
}
};
const error = { textContent: '' };
const rows = [cargoRow()];
const context = {
document: {
getElementById: id => ({
'return-relabel-form': form,
'return-relabel-error': error
}[id] || null),
querySelectorAll: selector => selector === '#return-relabel-sku-body tr' ? rows : []
}
};
const validateReturnRelabelForm = vm.runInNewContext(
`(${extractFunction('validateReturnRelabelForm')})`,
context
);
assert.equal(validateReturnRelabelForm(), true, '承运商和运单号为空时仍应允许提交');
form.elements.inboundCode.value = '';
assert.equal(validateReturnRelabelForm(), false, '入仓码为空时应阻止提交');
assert.equal(error.textContent, '请完整填写退货和尾程信息');
console.log('return relabel required fields: ok');
@@ -0,0 +1,138 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const html = fs.readFileSync('workbench.html', 'utf8');
function extractFunction(name) {
const start = html.indexOf(`function ${name}(`);
assert.notEqual(start, -1, `${name} should exist`);
const bodyStart = html.indexOf('{', start);
let depth = 0;
for (let index = bodyStart; index < html.length; index += 1) {
if (html[index] === '{') depth += 1;
if (html[index] === '}') depth -= 1;
if (depth === 0) return html.slice(start, index + 1);
}
throw new Error(`Unable to extract ${name}`);
}
// Regression caught: logistics fields drift back to order-level controls or
// appear after the SKU instead of being the first two cargo columns.
const createPage = html.match(/<div class="page-content" id="page-return-relabel-create">([\s\S]*?)<div class="page-content" id="page-return-relabel-detail">/)?.[1] || '';
const returnInfo = createPage.match(/<div class="return-relabel-section-title">退货信息<\/div>([\s\S]*?)<div class="card return-relabel-section">/)?.[1] || '';
const cargoHeader = createPage.match(/id="return-relabel-sku-table">\s*<thead><tr>([\s\S]*?)<\/tr><\/thead>/)?.[1] || '';
assert.ok(returnInfo, '退货信息区域 should exist');
assert.doesNotMatch(returnInfo, /name="(?:carrier|trackingNo)"/, '物流字段不应再作为整单输入');
assert.match(
cargoHeader,
/^<th>承运商[\s\S]*?<th>运单号[\s\S]*?<th>商品SKU/,
'承运商、运单号应为商品 SKU 前两列'
);
// Carrier/tracking controls remain available on each SKU row but are optional.
const appendedRows = [];
const rowContext = {
document: {
getElementById: id => id === 'return-relabel-sku-body' ? { appendChild: row => appendedRows.push(row) } : null,
createElement: () => ({ innerHTML: '' })
},
escapeReturnRelabelHtml: value => String(value == null ? '' : value)
};
const addReturnRelabelSkuRow = vm.runInNewContext(
`(${extractFunction('addReturnRelabelSkuRow')})`,
rowContext
);
addReturnRelabelSkuRow({ carrier: 'UPS', trackingNo: '1Z-001', sku: 'SKU-1' });
assert.match(
appendedRows[0].innerHTML,
/^<td><input[^>]*return-relabel-carrier[^>]*value="UPS"[^>]*><\/td><td><input[^>]*return-relabel-tracking-no[^>]*value="1Z-001"[^>]*><\/td><td><input[^>]*return-relabel-sku/,
'每条 SKU 行都应先生成可选的承运商和运单号输入框'
);
assert.doesNotMatch(appendedRows[0].innerHTML, /return-relabel-carrier[^>]*required/, '承运商不应设置 required');
assert.doesNotMatch(appendedRows[0].innerHTML, /return-relabel-tracking-no[^>]*required/, '运单号不应设置 required');
function control(value, extra = {}) {
return { value, ...extra };
}
function cargoRow(values) {
const fields = {
'.return-relabel-carrier': control(values.carrier),
'.return-relabel-tracking-no': control(values.trackingNo),
'.return-relabel-sku': control(values.sku),
'.return-relabel-name': control(values.name),
'.return-relabel-fnsku': control(values.fnsku),
'.return-relabel-new-sku': control(values.newSku || ''),
'.return-relabel-quantity': control(String(values.quantity))
};
return { querySelector: selector => fields[selector] || null };
}
const rows = [
cargoRow({ carrier: 'UPS', trackingNo: '1Z-001', sku: 'SKU-1', name: '商品一', fnsku: 'FN-1', quantity: 2 }),
cargoRow({ carrier: 'FedEx', trackingNo: 'FX-002', sku: 'SKU-2', name: '商品二', fnsku: 'FN-2', quantity: 3 })
];
const error = { textContent: '' };
const submitButton = { disabled: false };
const form = {
checkValidity: () => true,
reportValidity() {},
elements: {
inboundCode: control('IN-001'),
sourceFba: control('ONT8'),
reason: control('客户退货'),
receivingWarehouse: control('洛杉矶仓'),
destinationFba: control('LAX9'),
relabel: control('', { checked: false }),
labelCase: control('Case1'),
cartonLabel: control('', { files: [] }),
skuLabel: control('', { files: [] }),
address: control('123 Main St'),
expectedArrival: control('2026-09-10T10:00'),
returnRemark: control(''),
tailRemark: control('')
}
};
const saved = [];
const runtime = {
document: {
getElementById: id => ({
'return-relabel-form': form,
'return-relabel-error': error,
'return-relabel-submit': submitButton
}[id] || null),
querySelectorAll: selector => selector === '#return-relabel-sku-body tr' ? rows : []
},
loadReturnRelabelOrders: () => [],
saveReturnRelabelOrders: orders => { saved.push(orders); return true; },
renderReturnRelabelList() {},
showToast() {},
openReturnRelabelList() {},
returnRelabelFilteredOrders: null,
returnRelabelSubmitting: false
};
vm.runInNewContext(
`${extractFunction('validateReturnRelabelForm')}
${extractFunction('summarizeReturnRelabelSkuField')}
${extractFunction('submitReturnRelabelOrder')}`,
runtime
);
// Optional logistics fields must not block a later SKU from submitting.
rows[1].querySelector('.return-relabel-tracking-no').value = '';
assert.equal(runtime.validateReturnRelabelForm(), true);
assert.equal(error.textContent, '');
rows[1].querySelector('.return-relabel-tracking-no').value = 'FX-002';
// Regression caught: only the first row's logistics data is persisted.
assert.equal(runtime.submitReturnRelabelOrder({ preventDefault() {} }), false);
const order = saved[0][0];
assert.deepEqual(JSON.parse(JSON.stringify(order.skus)), [
{ carrier: 'UPS', trackingNo: '1Z-001', sku: 'SKU-1', name: '商品一', fnsku: 'FN-1', newSku: '', quantity: 2 },
{ carrier: 'FedEx', trackingNo: 'FX-002', sku: 'SKU-2', name: '商品二', fnsku: 'FN-2', newSku: '', quantity: 3 }
]);
assert.equal(order.carrier, 'UPS、FedEx', '整单兼容字段应汇总去重后的承运商');
assert.equal(order.trackingNo, '1Z-001、FX-002', '整单兼容字段应汇总去重后的运单号');
console.log('return relabel SKU logistics: ok');
@@ -0,0 +1,22 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const html = fs.readFileSync('workbench.html', 'utf8');
function detailActions(id, actionClass) {
const detail = html.match(new RegExp(`<section class="sea-business-detail-page" id="${id}"[\\s\\S]*?<div class="${actionClass}">([\\s\\S]*?)</div>`))?.[1] || '';
assert.ok(detail, `${id} detail actions should exist`);
return detail;
}
for (const [id, actionClass] of [
['sea-truck-detail', 'truck-detail-actions'],
['sea-pallet-detail', 'pallet-detail-actions']
]) {
const actions = detailActions(id, actionClass);
assert.doesNotMatch(actions, /data-detail-content="status"/);
assert.doesNotMatch(actions, /openSeaOrderEditor/);
assert.match(actions, /返回列表/);
}
console.log('sea business detail actions requirements: ok');
@@ -0,0 +1,110 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const html = fs.readFileSync('workbench.html', 'utf8');
function extractFunction(name) {
const start = html.indexOf(`function ${name}(`);
assert.notEqual(start, -1, `${name} should exist`);
const bodyStart = html.indexOf('{', start);
let depth = 0;
for (let index = bodyStart; index < html.length; index += 1) {
if (html[index] === '{') depth += 1;
if (html[index] === '}') depth -= 1;
if (depth === 0) return html.slice(start, index + 1);
}
throw new Error(`Unable to extract ${name}`);
}
const createPage = html.match(/<div class="page-content" id="page-storage-transfer-create">([\s\S]*?)<!-- ========== PAGE: 仓储转运单详情/)?.[1] || '';
const editModal = html.match(/<div class="modal-overlay" id="storage-transfer-edit-modal"([\s\S]*?)<!-- ========== MODAL:/)?.[1] || '';
assert.match(
createPage,
/换标要求[\s\S]*关联文件模板[\s\S]*上传箱唛[\s\S]*上传 SKU 标签[\s\S]*上传关联文件[\s\S]*备注/,
'create order should place the relation template and associated fields inside relabel settings'
);
assert.match(
editModal,
/换标要求[\s\S]*关联文件模板[\s\S]*上传箱唛[\s\S]*上传 SKU 标签[\s\S]*上传关联文件[\s\S]*备注/,
'edit order should expose the same associated fields as create order'
);
assert.match(
createPage,
/class="storage-transfer-upload-card" id="storage-transfer-relation-upload-card"/,
'create relation upload should use the same background card as carton upload'
);
assert.match(
editModal,
/class="storage-transfer-upload-card" id="storage-transfer-edit-relation-upload-card"/,
'edit relation upload should use the same background card as carton upload'
);
const getStorageTransferRelationTemplateRows = vm.runInNewContext(
`(${extractFunction('getStorageTransferRelationTemplateRows')})`
);
assert.deepEqual(
JSON.parse(JSON.stringify(getStorageTransferRelationTemplateRows())),
[['箱唛', '需贴箱唛', '需贴SKU标', '备注']]
);
let writtenWorkbook = null;
let writtenFileName = '';
const downloadStorageTransferRelationTemplate = vm.runInNewContext(
`(${extractFunction('downloadStorageTransferRelationTemplate')})`,
{
getStorageTransferRelationTemplateRows,
showToast: () => {},
XLSX: {
utils: {
aoa_to_sheet: rows => ({ rows }),
book_new: () => ({ sheets: [] }),
book_append_sheet: (workbook, worksheet, name) => workbook.sheets.push({ worksheet, name })
},
writeFile: (workbook, fileName) => {
writtenWorkbook = workbook;
writtenFileName = fileName;
}
}
}
);
downloadStorageTransferRelationTemplate();
assert.equal(writtenFileName, '仓储转运关联模板.xlsx');
assert.deepEqual(
JSON.parse(JSON.stringify(writtenWorkbook.sheets)),
[{ worksheet: { rows: [['箱唛', '需贴箱唛', '需贴SKU标', '备注']], '!cols': [{ wch: 24 }, { wch: 14 }, { wch: 14 }, { wch: 32 }] }, name: '关联明细' }]
);
const fileControl = name => ({ files: name ? [{ name }] : [] });
const form = {
elements: {
relabel: { checked: true },
relabelRequirement: { value: 'Case3' },
relabelCartonLabel: fileControl('carton.pdf'),
relabelSkuLabel: fileControl('sku.pdf'),
relabelRelationFile: fileControl('关联明细.xlsx'),
relabelRemark: { value: ' 优先处理 A 箱 ' }
}
};
const getStorageTransferRelabelConfig = vm.runInNewContext(`(${extractFunction('getStorageTransferRelabelConfig')})`);
assert.deepEqual(JSON.parse(JSON.stringify(getStorageTransferRelabelConfig(form))), {
relabel: 'yes',
relabelRequirement: 'Case3',
relabelCartonLabel: 'carton.pdf',
relabelSkuLabel: 'sku.pdf',
relabelRelationFile: '关联明细.xlsx',
relabelRemark: '优先处理 A 箱'
});
form.elements.relabel.checked = false;
assert.deepEqual(JSON.parse(JSON.stringify(getStorageTransferRelabelConfig(form))), {
relabel: 'no',
relabelRequirement: '',
relabelCartonLabel: '',
relabelSkuLabel: '',
relabelRelationFile: '',
relabelRemark: ''
});
console.log('storage transfer associated fields: ok');
@@ -0,0 +1,125 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const html = fs.readFileSync('workbench.html', 'utf8');
function extractFunction(name) {
const start = html.indexOf(`function ${name}(`);
assert.notEqual(start, -1, `${name} should exist`);
const bodyStart = html.indexOf('{', start);
let depth = 0;
for (let index = bodyStart; index < html.length; index += 1) {
if (html[index] === '{') depth += 1;
if (html[index] === '}') depth -= 1;
if (depth === 0) return html.slice(start, index + 1);
}
throw new Error(`Unable to extract ${name}`);
}
const createPage = html.match(/<div class="page-content" id="page-storage-transfer-create">([\s\S]*?)<!-- ========== PAGE: 仓储转运单详情/)?.[1] || '';
const editModal = html.match(/<div class="modal-overlay" id="storage-transfer-edit-modal"([\s\S]*?)<!-- ========== MODAL:/)?.[1] || '';
assert.ok(editModal, 'storage transfer edit modal should exist');
const fieldNames = source => Array.from(source.matchAll(/\bname="([^"]+)"/g), match => match[1]).sort();
assert.deepEqual(fieldNames(editModal), fieldNames(createPage), 'edit modal should expose the same fields as create order');
const tbody = { innerHTML: '' };
const controls = {
'storage-transfer-list-body': tbody,
'storage-transfer-total': { textContent: '' },
'storage-transfer-page-number': { textContent: '' },
'storage-transfer-prev': { disabled: false },
'storage-transfer-next': { disabled: false }
};
const renderStorageTransferList = vm.runInNewContext(
`(${extractFunction('renderStorageTransferList')})`,
{
document: { getElementById: id => controls[id] || null },
storageTransferFilteredOrders: null,
loadStorageTransferOrders: () => [{
id: 'order-1', orderNo: 'ZY001', inboundNo: 'RK001', boxCount: 2,
warehouse: '美西洛杉矶仓', fbaCode: 'ONT8', shipmentId: 'FBA001',
deliveryMethod: '卡车派送', relabel: 'yes', tailOrderNo: 'LC001',
relabelOrderNo: 'HB001', status: '待处理', createdAt: '2026-09-04 09:00'
}],
STORAGE_TRANSFER_PAGE_SIZE: 10,
storageTransferCurrentPage: 1,
escapeStorageTransferHtml: value => String(value == null ? '' : value)
}
);
renderStorageTransferList();
assert.match(tbody.innerHTML, />查看<\/button>[\s\S]*?>编辑<\/button>/, 'each row should show edit after view');
assert.match(tbody.innerHTML, /openStorageTransferEdit\(this\.dataset\.storageTransferId\)/);
const mergeStorageTransferOrder = vm.runInNewContext(`(${extractFunction('mergeStorageTransferOrder')})`);
const original = {
id: 'order-1', orderNo: 'ZY001', tailOrderNo: 'LC001', relabelOrderNo: '',
status: '处理中', createdAt: '2026-09-01 08:00', cargo: [{ sku: 'KEEP' }],
inboundNo: 'OLD', fbaCode: 'OLD1', relabel: 'no'
};
const updated = mergeStorageTransferOrder(original, {
inboundNo: 'RK009', warehouse: '美东新泽西仓', fbaCode: 'ABE8',
address: 'New address', boxCount: 9, deliveryMethod: '快递派送',
expectedArrival: '2026-09-10T10:30', shipmentId: 'FBA009', trackingId: '1Z009',
boxId: 'B001-B009', note: 'updated', relabel: 'yes',
relabelRequirement: 'Case2', relabelCartonLabel: '', relabelSkuLabel: 'sku-new.pdf',
relabelRelationFile: '关联明细.xlsx', relabelRemark: '优先处理 A 箱',
relabelOrderNo: 'HB009'
});
assert.deepEqual(JSON.parse(JSON.stringify(updated)), {
id: 'order-1', orderNo: 'ZY001', tailOrderNo: 'LC001', relabelOrderNo: 'HB009',
status: '处理中', createdAt: '2026-09-01 08:00', cargo: [{ sku: 'KEEP' }],
inboundNo: 'RK009', warehouse: '美东新泽西仓', fbaCode: 'ABE8',
address: 'New address', boxCount: 9, deliveryMethod: '快递派送',
expectedArrival: '2026-09-10T10:30', shipmentId: 'FBA009', trackingId: '1Z009',
boxId: 'B001-B009', note: 'updated', relabel: 'yes',
relabelRequirement: 'Case2', relabelCartonLabel: '', relabelSkuLabel: 'sku-new.pdf',
relabelRelationFile: '关联明细.xlsx', relabelRemark: '优先处理 A 箱'
});
const getStorageTransferLegacyRelabelFile = vm.runInNewContext(`(${extractFunction('getStorageTransferLegacyRelabelFile')})`);
const getStorageTransferEditExistingFiles = vm.runInNewContext(
`(${extractFunction('getStorageTransferEditExistingFiles')})`,
{ getStorageTransferLegacyRelabelFile }
);
assert.deepEqual(
JSON.parse(JSON.stringify(getStorageTransferEditExistingFiles({
relabel: 'yes', relabelRequirement: 'Case2', cargo: [{ fileName: 'legacy-sku.pdf' }]
}, 'Case2'))),
{ carton: '', sku: 'legacy-sku.pdf' },
'legacy Case2 files should refill the SKU upload instead of the carton upload'
);
assert.deepEqual(
JSON.parse(JSON.stringify(getStorageTransferEditExistingFiles({
relabel: 'yes', relabelRequirement: 'Case1', relabelCartonLabel: 'carton.pdf', relabelSkuLabel: 'old-sku.pdf'
}, 'Case1'))),
{ carton: 'carton.pdf', sku: 'old-sku.pdf' },
'explicit stored files should win over legacy cargo data'
);
let filterCalls = 0;
let renderCalls = 0;
const refreshContext = {
document: { getElementById: id => ({ value: id === 'storage-transfer-filter-status' ? '处理中' : '' }) },
filterStorageTransferOrders: () => { filterCalls += 1; },
renderStorageTransferList: () => { renderCalls += 1; },
storageTransferFilteredOrders: [{ id: 'stale' }]
};
const refreshStorageTransferListAfterEdit = vm.runInNewContext(
`(${extractFunction('refreshStorageTransferListAfterEdit')})`,
refreshContext
);
refreshStorageTransferListAfterEdit();
assert.equal(filterCalls, 1, 'saving under an active filter should reapply that filter');
assert.equal(renderCalls, 0);
filterCalls = 0;
renderCalls = 0;
refreshContext.document.getElementById = () => ({ value: '' });
refreshStorageTransferListAfterEdit();
assert.equal(filterCalls, 0);
assert.equal(renderCalls, 1, 'saving without filters should render the full list');
assert.equal(refreshContext.storageTransferFilteredOrders, null);
console.log('storage transfer edit: ok');
@@ -0,0 +1,137 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const html = fs.readFileSync('workbench.html', 'utf8');
function extractFunction(name) {
const start = html.indexOf(`function ${name}(`);
assert.notEqual(start, -1, `${name} should exist`);
const bodyStart = html.indexOf('{', start);
let depth = 0;
for (let index = bodyStart; index < html.length; index += 1) {
if (html[index] === '{') depth += 1;
if (html[index] === '}') depth -= 1;
if (depth === 0) return html.slice(start, index + 1);
}
throw new Error(`Unable to extract ${name}`);
}
const detailPage = html.match(/<div class="page-content" id="page-storage-transfer-detail">([\s\S]*?)<!-- ========== PAGE: 货物废弃列表/)?.[1] || '';
assert.ok(detailPage, 'storage transfer detail page should exist');
assert.doesNotMatch(detailPage, /business-detail-tabs-shell|data-detail-tab|data-detail-panel/);
assert.match(detailPage, /class="page-header-actions storage-transfer-detail-header"/);
assert.match(detailPage, /id="storage-transfer-detail-order-no"/);
assert.match(detailPage, /class="labeling-detail-status" id="storage-transfer-detail-status"/);
assert.match(detailPage, /class="labeling-detail-grid"[\s\S]*?class="info-section-title">单据信息<\/div>[\s\S]*?id="storage-transfer-detail-document"/);
assert.match(detailPage, /class="info-section-title">货物信息<\/div>[\s\S]*?id="storage-transfer-detail-cargo"/);
assert.match(detailPage, /class="info-section-title">派送信息<\/div>[\s\S]*?id="storage-transfer-detail-delivery"/);
assert.match(detailPage, /class="info-section-title">换标信息<\/div>[\s\S]*?id="storage-transfer-detail-relabel"/);
const storageTransferRelabelRequirementText = vm.runInNewContext(`(${extractFunction('storageTransferRelabelRequirementText')})`);
assert.equal(storageTransferRelabelRequirementText('Case1'), '换箱唛');
assert.equal(storageTransferRelabelRequirementText('Case2'), '换SKU标');
assert.equal(storageTransferRelabelRequirementText('Case3'), '既换箱唛,又换SKU标');
const getStorageTransferRelabelDetail = vm.runInNewContext(
`(${extractFunction('getStorageTransferRelabelDetail')})`,
{ storageTransferRelabelRequirementText }
);
const getStorageTransferDetailSections = vm.runInNewContext(`(${extractFunction('getStorageTransferDetailSections')})`);
const sections = getStorageTransferDetailSections({
orderNo: 'ZY202608050002', inboundNo: 'RK20260805023', status: '处理中', createdAt: '2026-08-05 14:35',
boxCount: 8, shipmentId: 'FBA18R2M6X4D', trackingId: '1Z80526039', boxId: 'E001-E008',
warehouse: '美东新泽西仓', fbaCode: 'ABE8', deliveryMethod: '快递派送',
expectedArrival: '2026-08-13T14:00', address: '309 Cedar Ln, Florence, NJ 08518'
});
assert.deepEqual(JSON.parse(JSON.stringify(sections)), {
document: [
['转运单号', 'ZY202608050002', true], ['进仓编号', 'RK20260805023'],
['状态', '处理中', true], ['创建时间', '2026-08-05 14:35']
],
cargo: [
['发货箱数', 8, true], ['FBA 货件编号', 'FBA18R2M6X4D'],
['FBA 追踪编号', '1Z80526039'], ['FBA BOX 编号', 'E001-E008']
],
delivery: [
['操作仓', '美东新泽西仓'], ['FBA 目的仓代码', 'ABE8', true],
['派送方式', '快递派送'], ['期望到达时间', '2026-08-13 14:00'],
['详细派送地址', '309 Cedar Ln, Florence, NJ 08518']
]
});
const enabledDetail = getStorageTransferRelabelDetail({
relabel: 'yes',
relabelRequirement: 'Case3',
relabelCartonLabel: '箱唛-A1001.pdf',
relabelSkuLabel: 'SKU-A1001.pdf',
relabelRelationFile: '关联明细.xlsx',
relabelRemark: '请按要求完成换标。',
cargo: []
});
assert.deepEqual(JSON.parse(JSON.stringify(enabledDetail)), {
enabled: true,
rows: [
['是否换标', '是', true],
['贴标要求', '既换箱唛,又换SKU标', true],
['上传箱唛', '箱唛-A1001.pdf', true, true],
['上传 SKU 标签', 'SKU-A1001.pdf', true, true],
['上传关联文件', '关联明细.xlsx', true, true],
['备注', '请按要求完成换标。']
]
});
const legacyDetail = getStorageTransferRelabelDetail({
relabel: 'yes',
note: '',
cargo: [{ requirement: '覆盖原标签', fileName: 'FNSKU-2002.pdf' }]
});
assert.deepEqual(JSON.parse(JSON.stringify(legacyDetail)), {
enabled: true,
rows: [
['是否换标', '是', true],
['贴标要求', '覆盖原标签', true],
['上传文件', 'FNSKU-2002.pdf', true, true],
['备注', '-']
]
});
assert.deepEqual(
JSON.parse(JSON.stringify(getStorageTransferRelabelDetail({ relabel: 'no' }))),
{ enabled: false, rows: [['是否换标', '否', true]] }
);
const list = { innerHTML: '', hidden: false };
const empty = { hidden: true };
const escapeStorageTransferHtml = value => String(value == null ? '' : value)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
const labelingDetailField = vm.runInNewContext(
`(${extractFunction('labelingDetailField')})`,
{ escapeLabelingHtml: escapeStorageTransferHtml }
);
const renderStorageTransferRelabelDetail = vm.runInNewContext(
`(${extractFunction('renderStorageTransferRelabelDetail')})`,
{
document: { getElementById: id => id === 'storage-transfer-detail-relabel' ? list : id === 'storage-transfer-detail-relabel-empty' ? empty : null },
getStorageTransferRelabelDetail,
labelingDetailField
}
);
renderStorageTransferRelabelDetail({
relabel: 'yes', relabelRequirement: 'Case1', relabelCartonLabel: 'carton.pdf', note: '<核对>', cargo: []
});
assert.equal(list.hidden, false);
assert.equal(empty.hidden, true);
assert.match(list.innerHTML, /贴标要求[\s\S]*?换箱唛/);
assert.match(list.innerHTML, /labeling-detail-file[\s\S]*?carton\.pdf/);
assert.match(list.innerHTML, /&lt;核对&gt;/);
renderStorageTransferRelabelDetail({ relabel: 'no' });
assert.equal(list.hidden, false);
assert.equal(empty.hidden, false);
assert.match(list.innerHTML, /是否换标[\s\S]*?否/);
console.log('storage transfer relabel detail: ok');
@@ -0,0 +1,91 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const html = fs.readFileSync('workbench.html', 'utf8');
function extractFunction(name) {
const start = html.indexOf(`function ${name}(`);
assert.notEqual(start, -1, `${name} should exist`);
const bodyStart = html.indexOf('{', start);
let depth = 0;
for (let index = bodyStart; index < html.length; index += 1) {
if (html[index] === '{') depth += 1;
if (html[index] === '}') depth -= 1;
if (depth === 0) return html.slice(start, index + 1);
}
throw new Error(`Unable to extract ${name}`);
}
const createPage = html.match(/<div class="page-content" id="page-storage-transfer-create">([\s\S]*?)<!-- ========== PAGE: 仓储转运单详情/)?.[1] || '';
assert.ok(createPage, 'storage transfer create page should exist');
assert.match(createPage, /<input[^>]+id="storage-transfer-relabel-toggle"[^>]+name="relabel"[^>]+type="checkbox"[^>]+checked/);
assert.match(createPage, /id="storage-transfer-relabel-status"[^>]*>已开启</);
assert.match(createPage, /name="relabelRequirement"[\s\S]*?换箱唛[\s\S]*?换SKU标[\s\S]*?既换箱唛,又换SKU标/);
function classList() {
const values = new Set();
return {
toggle(name, enabled) {
if (enabled) values.add(name);
else values.delete(name);
},
contains(name) { return values.has(name); }
};
}
const cartonCard = { classList: classList() };
const skuCard = { classList: classList() };
const hint = { textContent: '' };
const controls = {
relabel: { checked: true },
relabelRequirement: { value: 'Case1', disabled: false },
relabelCartonLabel: { disabled: false, required: false, files: [], closest: () => cartonCard },
relabelSkuLabel: { disabled: false, required: false, files: [], closest: () => skuCard },
relabelRelationFile: { disabled: false, files: [] },
relabelRemark: { disabled: false, value: '' }
};
const form = { elements: controls };
const updateStorageTransferRelabelRequirements = vm.runInNewContext(
`(${extractFunction('updateStorageTransferRelabelRequirements')})`,
{ document: { getElementById: id => id === 'storage-transfer-form' ? form : id === 'storage-transfer-relabel-hint' ? hint : null } }
);
updateStorageTransferRelabelRequirements();
assert.equal(controls.relabelCartonLabel.disabled, false);
assert.equal(controls.relabelCartonLabel.required, true);
assert.equal(controls.relabelSkuLabel.disabled, true);
assert.equal(controls.relabelSkuLabel.required, false);
assert.equal(hint.textContent, '换箱唛:需要上传箱唛');
controls.relabelRequirement.value = 'Case3';
updateStorageTransferRelabelRequirements();
assert.equal(controls.relabelCartonLabel.disabled, false);
assert.equal(controls.relabelSkuLabel.disabled, false);
assert.equal(controls.relabelCartonLabel.required, true);
assert.equal(controls.relabelSkuLabel.required, true);
controls.relabel.checked = false;
updateStorageTransferRelabelRequirements();
assert.equal(controls.relabelCartonLabel.disabled, true);
assert.equal(controls.relabelSkuLabel.disabled, true);
assert.equal(controls.relabelCartonLabel.required, false);
assert.equal(controls.relabelSkuLabel.required, false);
assert.equal(hint.textContent, '未开启换标,无需上传标签文件');
const getStorageTransferRelabelConfig = vm.runInNewContext(`(${extractFunction('getStorageTransferRelabelConfig')})`);
controls.relabel.checked = true;
controls.relabelRequirement.value = 'Case2';
controls.relabelCartonLabel.files = [{ name: 'carton.pdf' }];
controls.relabelSkuLabel.files = [{ name: 'sku.pdf' }];
assert.deepEqual(
JSON.parse(JSON.stringify(getStorageTransferRelabelConfig(form))),
{ relabel: 'yes', relabelRequirement: 'Case2', relabelCartonLabel: '', relabelSkuLabel: 'sku.pdf', relabelRelationFile: '', relabelRemark: '' }
);
controls.relabel.checked = false;
assert.deepEqual(
JSON.parse(JSON.stringify(getStorageTransferRelabelConfig(form))),
{ relabel: 'no', relabelRequirement: '', relabelCartonLabel: '', relabelSkuLabel: '', relabelRelationFile: '', relabelRemark: '' }
);
console.log('storage transfer relabel: ok');
@@ -0,0 +1,18 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const html = fs.readFileSync('workbench.html', 'utf8');
const detail = html.match(/<section class="sea-business-detail-page" id="sea-truck-detail"[\s\S]*?<\/section>\s*<div class="sea-business-form-panel" data-order-panel="Truck"[^>]*>/)?.[0] || '';
assert.ok(detail, '美国本土卡详情视图 should exist');
assert.match(detail, /class="truck-detail-header"/);
assert.match(detail, /data-detail-content="orderNo"/);
assert.match(detail, /data-detail-content="status"/);
assert.match(detail, /class="truck-detail-grid"/);
assert.match(detail, /data-detail-card="transport"/);
assert.match(detail, /data-detail-card="contact"/);
assert.match(detail, /data-detail-card="cargo"/);
assert.match(detail, /data-detail-card="timing"/);
assert.doesNotMatch(detail, /business-detail-tabs-shell/);
console.log('truck detail layout requirements: ok');
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -8880,7 +8880,8 @@ FORM: 通用模块中的管理清单加分区编辑弹窗,作为既有后台
<th style="padding:9px 10px;text-align:right;white-space:nowrap">计费体积(m³)</th>
<th style="padding:9px 10px;text-align:right;white-space:nowrap">总托盘数</th>
<th style="padding:9px 10px;text-align:left;white-space:nowrap">托盘类型</th>
<th style="padding:9px 10px;text-align:center;white-space:nowrap">是否外配</th>
<th style="padding:9px 10px;text-align:center;white-space:nowrap">分配</th>
<th style="padding:9px 10px;text-align:center;white-space:nowrap">合托</th>
<th style="padding:9px 10px;text-align:center;white-space:nowrap">是否加急</th>
<th style="padding:9px 10px;text-align:center;white-space:nowrap">状态</th>
<th style="padding:9px 10px;text-align:center;white-space:nowrap">FBA预约</th>
@@ -9302,7 +9303,7 @@ FORM: 采用现有后台的列表加标准详情结构,保持高信息密度
<div class="modal-overlay" id="modal-lmo-manual-nodes" role="dialog" aria-modal="true" aria-labelledby="lmo-manual-nodes-title"><div class="modal" style="max-width:700px"><div class="modal-header"><span id="lmo-manual-nodes-title">人工修改节点</span><button type="button" aria-label="关闭" onclick="hideModal('modal-lmo-manual-nodes')" style="display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;padding:0;border:0;border-radius:6px;background:transparent;color:#64748b;font-size:22px;cursor:pointer">&times;</button></div><div class="modal-body"><div class="lmo-manual-node-order"><span>尾程单号</span><strong id="lmo-manual-nodes-order">—</strong></div><div class="lmo-manual-node-wrap"><table class="lmo-manual-node-table"><thead><tr><th style="width:42%">节点</th><th>节点时间</th></tr></thead><tbody id="tbody-lmo-manual-nodes"></tbody></table></div></div><div class="modal-footer"><button type="button" class="btn btn-default" onclick="hideModal('modal-lmo-manual-nodes')">取消</button><button type="button" class="btn btn-primary" onclick="saveLastMileManualNodes()">保存</button></div></div></div>
<!-- 尾程订单 - 入库计划 -->
<div class="modal-overlay" id="modal-lmo-inbound-plan"><div class="modal" style="max-width:720px"><div class="modal-header"><span>入库计划</span><span onclick="hideModal('modal-lmo-inbound-plan')" style="cursor:pointer;font-size:20px">&times;</span></div><div class="modal-body"><div class="lmo-modal-note">保存后生成入库单;清关完成后入库计划不可更改。</div><div class="form-row"><div class="form-group"><label>计划入库仓 *</label><select id="lmo-inbound-warehouse"><option>洛杉矶长滩海外仓</option><option>纽约海外仓</option><option>汉堡海外仓</option></select></div><div class="form-group"><label>预计到仓日期 *</label><input id="lmo-inbound-eta" type="date"/></div></div><div class="form-row"><div class="form-group"><label>入仓码 *</label><input id="lmo-inbound-code" placeholder="请输入入仓码"/></div><div class="form-group"><label>托数 *</label><input id="lmo-inbound-pallets" type="number" min="1" step="1" placeholder="请输入托数"/></div></div><div class="form-row"><div class="form-group"><label for="lmo-inbound-pallet-type">托盘类型</label><select id="lmo-inbound-pallet-type"><option value="标准托盘" selected>标准托盘</option><option value="木托盘">木托盘</option><option value="纸托盘">纸托盘</option></select></div><div class="form-group"><label for="lmo-inbound-outsourced">是否外配</label><label class="lmo-checkbox-option"><input id="lmo-inbound-outsourced" type="checkbox"/> 是</label></div></div><div class="form-row"><div class="form-group"><label for="lmo-inbound-consolidated">是否合托</label><label class="lmo-checkbox-option"><input id="lmo-inbound-consolidated" type="checkbox"/> 是</label></div><div class="form-group"><label for="lmo-inbound-urgent">是否加急</label><label class="lmo-checkbox-option"><input id="lmo-inbound-urgent" type="checkbox"/> 是</label></div></div><div class="form-row single"><div class="form-group"><label>备注</label><textarea id="lmo-inbound-remark" rows="3" placeholder="请输入备注"></textarea></div></div></div><div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-lmo-inbound-plan')">取消</button><button class="btn btn-primary" onclick="saveLastMileInboundPlan()">保存并生成入库单</button></div></div></div>
<div class="modal-overlay" id="modal-lmo-inbound-plan"><div class="modal" style="max-width:720px"><div class="modal-header"><span>入库计划</span><span onclick="hideModal('modal-lmo-inbound-plan')" style="cursor:pointer;font-size:20px">&times;</span></div><div class="modal-body"><div class="lmo-modal-note">保存后生成入库单;清关完成后入库计划不可更改。</div><div class="form-row"><div class="form-group"><label>计划入库仓 *</label><select id="lmo-inbound-warehouse"><option>洛杉矶长滩海外仓</option><option>纽约海外仓</option><option>汉堡海外仓</option></select></div><div class="form-group"><label>预计到仓日期 *</label><input id="lmo-inbound-eta" type="date"/></div></div><div class="form-row"><div class="form-group"><label>入仓码 *</label><input id="lmo-inbound-code" placeholder="请输入入仓码"/></div><div class="form-group"><label>托数 *</label><input id="lmo-inbound-pallets" type="number" min="1" step="1" placeholder="请输入托数"/></div></div><div class="form-row"><div class="form-group"><label for="lmo-inbound-pallet-type">托盘类型</label><select id="lmo-inbound-pallet-type"><option value="标准托盘" selected>标准托盘</option><option value="木托盘">木托盘</option><option value="纸托盘">纸托盘</option></select></div><div class="form-group"><label for="lmo-inbound-outsourced">是否分配</label><label class="lmo-checkbox-option"><input id="lmo-inbound-outsourced" type="checkbox"/> 是</label></div></div><div class="form-row"><div class="form-group"><label for="lmo-inbound-consolidated">允许合托</label><label class="lmo-checkbox-option"><input id="lmo-inbound-consolidated" type="checkbox"/> 是</label><small>现场可选择合托或独立收货,不强制合托。</small></div><div class="form-group"><label for="lmo-inbound-urgent">是否加急</label><label class="lmo-checkbox-option"><input id="lmo-inbound-urgent" type="checkbox"/> 是</label></div></div><div class="form-row single"><div class="form-group"><label>备注</label><textarea id="lmo-inbound-remark" rows="3" placeholder="请输入备注"></textarea></div></div></div><div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-lmo-inbound-plan')">取消</button><button class="btn btn-primary" onclick="saveLastMileInboundPlan()">保存并生成入库单</button></div></div></div>
<!-- 尾程订单 - 合托 -->
<div class="modal-overlay" id="modal-lmo-consolidate"><div class="modal" style="max-width:680px"><div class="modal-header"><span>合托</span><span onclick="hideModal('modal-lmo-consolidate')" style="cursor:pointer;font-size:20px">&times;</span></div><div class="modal-body"><div class="form-row"><div class="form-group"><label>合托批次 *</label><input id="lmo-consolidate-batch" placeholder="如 CT20260810001"/></div><div class="form-group"><label>合托后托数 *</label><input id="lmo-consolidate-pallets" type="number" min="1"/></div></div><div class="form-row single"><div class="form-group"><label>备注</label><textarea id="lmo-consolidate-remark" rows="3" placeholder="请输入合托说明"></textarea></div></div></div><div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-lmo-consolidate')">取消</button><button class="btn btn-primary" onclick="saveLastMileConsolidate()">保存</button></div></div></div>
@@ -20973,7 +20974,7 @@ function saveLastMileInboundPlan(){
const now=lmoNow();
order.pallets=pallets;order.isConsolidated=isConsolidated;order.inboundPlan={warehouse,eta,inboundCode,pallets,palletType,isOutsourced,isUrgent,remark,createdAt:order.inboundPlan&&order.inboundPlan.createdAt||now};
let linked=order.inboundNo&&fbaInboundOrderData.find(item=>item.inboundNo===order.inboundNo);
if(!linked){if(!order.inboundNo)order.inboundNo='FBI'+now.slice(0,10).replace(/-/g,'')+String(fbaInboundOrderData.length+1).padStart(3,'0');fbaInboundOrderData.unshift({inboundNo:order.inboundNo,bolNo:order.firstLegNo,type:'海外订单',wh:warehouse,owner:order.owner,totalBoxes:order.boxes,chargeVol:order.volume,palletTotal:order.pallets,palletType,isOutsourced,isUrgent,status:'待审核',warehouseCode:inboundCode,eta,containerNo:order.containerNo,createdDate:now.slice(0,10)});}else{Object.assign(linked,{wh:warehouse,warehouseCode:inboundCode,eta,totalBoxes:order.boxes,chargeVol:order.volume,palletTotal:order.pallets,palletType,isOutsourced,isUrgent});}
if(!linked){if(!order.inboundNo)order.inboundNo='FBI'+now.slice(0,10).replace(/-/g,'')+String(fbaInboundOrderData.length+1).padStart(3,'0');fbaInboundOrderData.unshift({inboundNo:order.inboundNo,bolNo:order.firstLegNo,type:'海外订单',wh:warehouse,owner:order.owner,totalBoxes:order.boxes,chargeVol:order.volume,palletTotal:order.pallets,palletType,isOutsourced,isConsolidated,isUrgent,status:'待审核',warehouseCode:inboundCode,eta,containerNo:order.containerNo,createdDate:now.slice(0,10)});}else{Object.assign(linked,{wh:warehouse,warehouseCode:inboundCode,eta,totalBoxes:order.boxes,chargeVol:order.volume,palletTotal:order.pallets,palletType,isOutsourced,isConsolidated,isUrgent});}
order.logs.push({time:now,node:'入库计划',operator:'张经理',result:'已保存并关联入库单 '+order.inboundNo});hideModal('modal-lmo-inbound-plan');refreshLastMileViews(order);showToast('入库计划已保存,已生成入库单 '+order.inboundNo,'success');
}
function getSelectedLastMileOrders(){return [...selectedLastMileOrderNos].map(no=>lastMileOrderData.find(order=>order.no===no)).filter(Boolean);}
@@ -21067,6 +21068,7 @@ function renderFbaInboundOrder(list){
<td style="padding:9px 10px;text-align:right">${d.palletTotal}</td>
<td style="padding:9px 10px;white-space:nowrap">${lmoEsc(getInboundPalletType(d))}</td>
<td style="padding:9px 10px;text-align:center"><span class="tag ${isInboundOutsourced(d)?'tag-blue':'tag-gray'}">${formatInboundOutsourced(d)}</span></td>
<td style="padding:9px 10px;text-align:center"><span class="tag ${d.isConsolidated?'tag-blue':'tag-gray'}">${d.isConsolidated?'可合托':'不合托'}</span></td>
<td style="padding:9px 10px;text-align:center"><span class="tag ${isInboundUrgent(d)?'tag-red':'tag-gray'}">${formatInboundUrgent(d)}</span></td>
<td style="padding:9px 10px;text-align:center"><span class="tag ${d.status==='已入库'?'tag-green':d.status==='已审核'?'tag-blue':'tag-orange'}">${d.status}</span></td>
<td style="padding:9px 10px;text-align:center"><span class="tag ${d.appointmentStatus==='预约成功'?'tag-green':d.appointmentStatus==='预约中'?'tag-blue':'tag-gray'}">${lmoEsc(d.appointmentStatus||'未预约')}</span></td>
+2 -1
View File
@@ -8571,7 +8571,7 @@ function saveLastMileInboundPlan(){
const now=lmoNow();
order.pallets=pallets;order.isConsolidated=isConsolidated;order.inboundPlan={warehouse,eta,inboundCode,pallets,palletType,isOutsourced,isUrgent,remark,createdAt:order.inboundPlan&&order.inboundPlan.createdAt||now};
let linked=order.inboundNo&&fbaInboundOrderData.find(item=>item.inboundNo===order.inboundNo);
if(!linked){if(!order.inboundNo)order.inboundNo='FBI'+now.slice(0,10).replace(/-/g,'')+String(fbaInboundOrderData.length+1).padStart(3,'0');fbaInboundOrderData.unshift({inboundNo:order.inboundNo,bolNo:order.firstLegNo,type:'海外订单',wh:warehouse,owner:order.owner,totalBoxes:order.boxes,chargeVol:order.volume,palletTotal:order.pallets,palletType,isOutsourced,isUrgent,status:'待审核',warehouseCode:inboundCode,eta,containerNo:order.containerNo,createdDate:now.slice(0,10)});}else{Object.assign(linked,{wh:warehouse,warehouseCode:inboundCode,eta,totalBoxes:order.boxes,chargeVol:order.volume,palletTotal:order.pallets,palletType,isOutsourced,isUrgent});}
if(!linked){if(!order.inboundNo)order.inboundNo='FBI'+now.slice(0,10).replace(/-/g,'')+String(fbaInboundOrderData.length+1).padStart(3,'0');fbaInboundOrderData.unshift({inboundNo:order.inboundNo,bolNo:order.firstLegNo,type:'海外订单',wh:warehouse,owner:order.owner,totalBoxes:order.boxes,chargeVol:order.volume,palletTotal:order.pallets,palletType,isOutsourced,isConsolidated,isUrgent,status:'待审核',warehouseCode:inboundCode,eta,containerNo:order.containerNo,createdDate:now.slice(0,10)});}else{Object.assign(linked,{wh:warehouse,warehouseCode:inboundCode,eta,totalBoxes:order.boxes,chargeVol:order.volume,palletTotal:order.pallets,palletType,isOutsourced,isConsolidated,isUrgent});}
order.logs.push({time:now,node:'入库计划',operator:'张经理',result:'已保存并关联入库单 '+order.inboundNo});hideModal('modal-lmo-inbound-plan');refreshLastMileViews(order);showToast('入库计划已保存,已生成入库单 '+order.inboundNo,'success');
}
function getSelectedLastMileOrders(){return [...selectedLastMileOrderNos].map(no=>lastMileOrderData.find(order=>order.no===no)).filter(Boolean);}
@@ -8665,6 +8665,7 @@ function renderFbaInboundOrder(list){
<td style="padding:9px 10px;text-align:right">${d.palletTotal}</td>
<td style="padding:9px 10px;white-space:nowrap">${lmoEsc(getInboundPalletType(d))}</td>
<td style="padding:9px 10px;text-align:center"><span class="tag ${isInboundOutsourced(d)?'tag-blue':'tag-gray'}">${formatInboundOutsourced(d)}</span></td>
<td style="padding:9px 10px;text-align:center"><span class="tag ${d.isConsolidated?'tag-blue':'tag-gray'}">${d.isConsolidated?'可合托':'不合托'}</span></td>
<td style="padding:9px 10px;text-align:center"><span class="tag ${isInboundUrgent(d)?'tag-red':'tag-gray'}">${formatInboundUrgent(d)}</span></td>
<td style="padding:9px 10px;text-align:center"><span class="tag ${d.status==='已入库'?'tag-green':d.status==='已审核'?'tag-blue':'tag-orange'}">${d.status}</span></td>
<td style="padding:9px 10px;text-align:center"><span class="tag ${d.appointmentStatus==='预约成功'?'tag-green':d.appointmentStatus==='预约中'?'tag-blue':'tag-gray'}">${lmoEsc(d.appointmentStatus||'未预约')}</span></td>
@@ -533,7 +533,8 @@
<th style="padding:9px 10px;text-align:right;white-space:nowrap">计费体积(m³)</th>
<th style="padding:9px 10px;text-align:right;white-space:nowrap">总托盘数</th>
<th style="padding:9px 10px;text-align:left;white-space:nowrap">托盘类型</th>
<th style="padding:9px 10px;text-align:center;white-space:nowrap">是否外配</th>
<th style="padding:9px 10px;text-align:center;white-space:nowrap">分配</th>
<th style="padding:9px 10px;text-align:center;white-space:nowrap">合托</th>
<th style="padding:9px 10px;text-align:center;white-space:nowrap">是否加急</th>
<th style="padding:9px 10px;text-align:center;white-space:nowrap">状态</th>
<th style="padding:9px 10px;text-align:center;white-space:nowrap">FBA预约</th>
@@ -940,7 +941,7 @@ FORM: 采用现有后台的列表加标准详情结构,保持高信息密度
<div class="modal-overlay" id="modal-lmo-manual-nodes" role="dialog" aria-modal="true" aria-labelledby="lmo-manual-nodes-title"><div class="modal" style="max-width:700px"><div class="modal-header"><span id="lmo-manual-nodes-title">人工修改节点</span><button type="button" aria-label="关闭" onclick="hideModal('modal-lmo-manual-nodes')" style="display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;padding:0;border:0;border-radius:6px;background:transparent;color:#64748b;font-size:22px;cursor:pointer">&times;</button></div><div class="modal-body"><div class="lmo-manual-node-order"><span>尾程单号</span><strong id="lmo-manual-nodes-order">—</strong></div><div class="lmo-manual-node-wrap"><table class="lmo-manual-node-table"><thead><tr><th style="width:42%">节点</th><th>节点时间</th></tr></thead><tbody id="tbody-lmo-manual-nodes"></tbody></table></div></div><div class="modal-footer"><button type="button" class="btn btn-default" onclick="hideModal('modal-lmo-manual-nodes')">取消</button><button type="button" class="btn btn-primary" onclick="saveLastMileManualNodes()">保存</button></div></div></div>
<!-- 尾程订单 - 入库计划 -->
<div class="modal-overlay" id="modal-lmo-inbound-plan"><div class="modal" style="max-width:720px"><div class="modal-header"><span>入库计划</span><span onclick="hideModal('modal-lmo-inbound-plan')" style="cursor:pointer;font-size:20px">&times;</span></div><div class="modal-body"><div class="lmo-modal-note">保存后生成入库单;清关完成后入库计划不可更改。</div><div class="form-row"><div class="form-group"><label>计划入库仓 *</label><select id="lmo-inbound-warehouse"><option>洛杉矶长滩海外仓</option><option>纽约海外仓</option><option>汉堡海外仓</option></select></div><div class="form-group"><label>预计到仓日期 *</label><input id="lmo-inbound-eta" type="date"/></div></div><div class="form-row"><div class="form-group"><label>入仓码 *</label><input id="lmo-inbound-code" placeholder="请输入入仓码"/></div><div class="form-group"><label>托数 *</label><input id="lmo-inbound-pallets" type="number" min="1" step="1" placeholder="请输入托数"/></div></div><div class="form-row"><div class="form-group"><label for="lmo-inbound-pallet-type">托盘类型</label><select id="lmo-inbound-pallet-type"><option value="标准托盘" selected>标准托盘</option><option value="木托盘">木托盘</option><option value="纸托盘">纸托盘</option></select></div><div class="form-group"><label for="lmo-inbound-outsourced">是否外配</label><label class="lmo-checkbox-option"><input id="lmo-inbound-outsourced" type="checkbox"/> 是</label></div></div><div class="form-row"><div class="form-group"><label for="lmo-inbound-consolidated">是否合托</label><label class="lmo-checkbox-option"><input id="lmo-inbound-consolidated" type="checkbox"/> 是</label></div><div class="form-group"><label for="lmo-inbound-urgent">是否加急</label><label class="lmo-checkbox-option"><input id="lmo-inbound-urgent" type="checkbox"/> 是</label></div></div><div class="form-row single"><div class="form-group"><label>备注</label><textarea id="lmo-inbound-remark" rows="3" placeholder="请输入备注"></textarea></div></div></div><div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-lmo-inbound-plan')">取消</button><button class="btn btn-primary" onclick="saveLastMileInboundPlan()">保存并生成入库单</button></div></div></div>
<div class="modal-overlay" id="modal-lmo-inbound-plan"><div class="modal" style="max-width:720px"><div class="modal-header"><span>入库计划</span><span onclick="hideModal('modal-lmo-inbound-plan')" style="cursor:pointer;font-size:20px">&times;</span></div><div class="modal-body"><div class="lmo-modal-note">保存后生成入库单;清关完成后入库计划不可更改。</div><div class="form-row"><div class="form-group"><label>计划入库仓 *</label><select id="lmo-inbound-warehouse"><option>洛杉矶长滩海外仓</option><option>纽约海外仓</option><option>汉堡海外仓</option></select></div><div class="form-group"><label>预计到仓日期 *</label><input id="lmo-inbound-eta" type="date"/></div></div><div class="form-row"><div class="form-group"><label>入仓码 *</label><input id="lmo-inbound-code" placeholder="请输入入仓码"/></div><div class="form-group"><label>托数 *</label><input id="lmo-inbound-pallets" type="number" min="1" step="1" placeholder="请输入托数"/></div></div><div class="form-row"><div class="form-group"><label for="lmo-inbound-pallet-type">托盘类型</label><select id="lmo-inbound-pallet-type"><option value="标准托盘" selected>标准托盘</option><option value="木托盘">木托盘</option><option value="纸托盘">纸托盘</option></select></div><div class="form-group"><label for="lmo-inbound-outsourced">是否分配</label><label class="lmo-checkbox-option"><input id="lmo-inbound-outsourced" type="checkbox"/> 是</label></div></div><div class="form-row"><div class="form-group"><label for="lmo-inbound-consolidated">允许合托</label><label class="lmo-checkbox-option"><input id="lmo-inbound-consolidated" type="checkbox"/> 是</label><small>现场可选择合托或独立收货,不强制合托。</small></div><div class="form-group"><label for="lmo-inbound-urgent">是否加急</label><label class="lmo-checkbox-option"><input id="lmo-inbound-urgent" type="checkbox"/> 是</label></div></div><div class="form-row single"><div class="form-group"><label>备注</label><textarea id="lmo-inbound-remark" rows="3" placeholder="请输入备注"></textarea></div></div></div><div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-lmo-inbound-plan')">取消</button><button class="btn btn-primary" onclick="saveLastMileInboundPlan()">保存并生成入库单</button></div></div></div>
<!-- 尾程订单 - 合托 -->
<div class="modal-overlay" id="modal-lmo-consolidate"><div class="modal" style="max-width:680px"><div class="modal-header"><span>合托</span><span onclick="hideModal('modal-lmo-consolidate')" style="cursor:pointer;font-size:20px">&times;</span></div><div class="modal-body"><div class="form-row"><div class="form-group"><label>合托批次 *</label><input id="lmo-consolidate-batch" placeholder="如 CT20260810001"/></div><div class="form-group"><label>合托后托数 *</label><input id="lmo-consolidate-pallets" type="number" min="1"/></div></div><div class="form-row single"><div class="form-group"><label>备注</label><textarea id="lmo-consolidate-remark" rows="3" placeholder="请输入合托说明"></textarea></div></div></div><div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-lmo-consolidate')">取消</button><button class="btn btn-primary" onclick="saveLastMileConsolidate()">保存</button></div></div></div>
@@ -0,0 +1,25 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const test = require('node:test');
const page = fs.readFileSync('货柜管理/订舱列表.html', 'utf8');
test('订舱列表查看详情进入只读订舱详情页并按订舱编号加载', () => {
assert.match(page, /id="page-booking-view"/);
assert.match(page, /onclick="openBookingView\(this\)"/);
assert.match(page, /function openBookingView\(el\)/);
assert.match(page, /data-booking-view-id/);
});
test('订舱详情页提供三类页签及货物信息完整字段', () => {
const start = page.indexOf('id="page-booking-view"');
const end = page.indexOf('<!-- ====================', start + 1);
const detail = page.slice(start, end === -1 ? page.length : end);
assert.match(detail, />订舱信息<\/button>/);
assert.match(detail, />货物信息<\/button>/);
assert.match(detail, />补充信息<\/button>/);
for (const field of ['序号', '唛头', '提单件数', '包装类型', 'HS编码', '英文品名/货物描述', '毛重', '体积', '件数(大写)']) {
assert.match(detail, new RegExp(`<th[^>]*>${field}<\\/th>`));
}
});
@@ -0,0 +1,57 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const test = require('node:test');
const page = fs.readFileSync('财务管理/客户账户.html', 'utf8');
function extractFunction(name) {
const marker = `function ${name}`;
const start = page.lastIndexOf(marker);
assert.notEqual(start, -1, `${name} should be defined in the customer account page`);
const nextFunction = page.indexOf('\nfunction ', start + marker.length);
const end = nextFunction === -1 ? page.length : nextFunction;
return eval(`(${page.slice(start, end).trim()})`);
}
function extractCreditHelpers() {
const start = page.lastIndexOf('function normalizeCustomerCreditStatus');
const end = page.indexOf('\nfunction calculateCustomerDeduction', start);
assert.notEqual(start, -1, 'credit helpers should be defined in the customer account page');
assert.notEqual(end, -1, 'credit deduction should follow credit helpers');
return eval(`(()=>{${page.slice(start, end)};return {normalizeCustomerCreditStatus,customerCreditAvailable};})()`);
}
test('expired credit is automatically disabled after the validity date', () => {
const { normalizeCustomerCreditStatus: normalize } = extractCreditHelpers();
const account = { creditStatus: '启用', creditExpireDate: '2026-09-07' };
normalize(account, '2026-09-08');
assert.equal(account.creditStatus, '停用');
});
test('credit remains enabled on its validity date and is available when enabled', () => {
const { normalizeCustomerCreditStatus: normalize, customerCreditAvailable: available } = extractCreditHelpers();
const account = { creditStatus: '启用', creditExpireDate: '2026-09-08', creditLimit: 2000, creditUsed: 500 };
normalize(account, '2026-09-08');
assert.equal(account.creditStatus, '启用');
assert.equal(available(account, '2026-09-08'), 1500);
});
test('stopped credit cannot be used even when amount remains', () => {
const { customerCreditAvailable: available } = extractCreditHelpers();
const account = { creditStatus: '停用', creditExpireDate: '2026-12-31', creditLimit: 2000, creditUsed: 0 };
assert.equal(available(account, '2026-09-08'), 0);
});
test('customer account UI exposes credit validity and status fields', () => {
assert.match(page, /id="ca-credit-expire-date"/);
assert.match(page, /id="ca-credit-status"/);
assert.match(page, /<option value="启用">启用<\/option>/);
assert.match(page, /<option value="停用">停用<\/option>/);
assert.match(page, /<th>授信有效期<\/th>/);
assert.match(page, /<th>授信状态<\/th>/);
});
@@ -0,0 +1,17 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const page = fs.readFileSync('财务管理/客户应收应付.html', 'utf8');
assert.match(
page,
/#page-customer-arap \.arap-table\{width:1810px;min-width:1810px;/,
'客户应收应付表格总宽应与运行时序号列保持一致'
);
assert.match(
page,
/<colgroup><col style="width:32px"\/><col style="width:48px"\/><col style="width:190px"\/>/,
'客户应收应付列表首列、序号列和操作列应为 32px、48px 和 190px'
);
console.log('customer-arap column widths are compact');
@@ -0,0 +1,109 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const test = require('node:test');
const pagePaths = [
'海外仓/尾程订单.html',
'index.html',
'通用/船期价格表.html',
];
function extractLastMileHeaderLabels(page) {
const tbodyStart = page.indexOf('<tbody id="tbody-last-mile-orders">');
assert.notEqual(tbodyStart, -1, 'last-mile order table body should exist');
const tableStart = page.lastIndexOf('<table', tbodyStart);
assert.notEqual(tableStart, -1, 'last-mile order table should exist');
const table = page.slice(tableStart, tbodyStart);
return [...table.matchAll(/<th[^>]*>([\s\S]*?)<\/th>/g)].map(match =>
match[1].replace(/<[^>]+>/g, '').trim(),
);
}
function extractFunction(page, name) {
const marker = `function ${name}`;
const start = page.indexOf(marker);
assert.notEqual(start, -1, `${name} should be defined`);
const end = page.indexOf('\nfunction ', start + marker.length);
return page.slice(start, end === -1 ? page.length : end).trim();
}
function renderRepresentativeRow(page) {
const tbody = {
innerHTML: '',
closest: () => ({ classList: { toggle() {} } }),
};
const total = { textContent: '' };
const document = {
getElementById(id) {
if (id === 'tbody-last-mile-orders') return tbody;
if (id === 'lmo-total') return total;
return null;
},
};
const dependencies = {
document,
lastMileOrderData: [],
currentLastMileTab: '全部',
selectedLastMileOrderNos: new Set(),
isLastMileInboundLocked: () => false,
isLastMileOrderDetained: () => false,
getLastMileRelabelOrder: () => null,
renderLastMileWarningTags: () => 'WARNING-MARKER',
findContainerByNo: () => null,
renderContainerStatusTag: () => 'CONTAINER-STATUS',
getContainerAbnormalState: () => null,
renderAbnormalTag: () => 'ABNORMAL-STATUS',
isLastMileConsolidated: () => false,
lastMileStatusClass: () => 'tag-gray',
getLastMileDisplayStatus: () => 'CURRENT-STATUS',
renderLastMileDirectActions: () => 'OPERATIONS',
updateLastMileSelectionSummary() {},
lmoEsc: value => String(value ?? ''),
};
const names = Object.keys(dependencies);
const render = Function(...names, `return (${extractFunction(page, 'renderLastMileOrders')})`)(
...names.map(name => dependencies[name]),
);
render([{
no: 'ORDER-NO',
serviceType: 'ORDER-TYPE',
rejectionTime: 'REJECTION-TIME',
inboundCode: 'INBOUND-CODE',
owner: 'OWNER-NAME',
warehouse: 'WAREHOUSE',
transferWarehouse: 'TRANSFER-WAREHOUSE',
fbaWarehouse: 'FBA-WAREHOUSE',
containerNo: 'CONTAINER-NO',
pallets: 1,
boxes: 2,
volume: 3,
pieces: 4,
volumeWeight: 5,
portEta: 'PORT-ETA',
actualArrivalTime: 'ACTUAL-ARRIVAL',
eta: 'WAREHOUSE-ETA',
noteSelf: '',
noteCustomer: '',
noteWarehouse: '',
}]);
return [...tbody.innerHTML.matchAll(/<td[^>]*>([\s\S]*?)<\/td>/g)].map(match => match[1]);
}
for (const pagePath of pagePaths) {
test(`${pagePath} 尾程订单列表按业务优先级排列列`, () => {
const page = fs.readFileSync(pagePath, 'utf8');
const labels = extractLastMileHeaderLabels(page);
const cells = renderRepresentativeRow(page);
assert.deepEqual(labels.slice(0, 6), ['', '操作', '预警', '尾程单号', '订单类型', '货主']);
assert.equal(labels[labels.indexOf('预计到仓') + 1], '拒收时间');
assert.match(cells[2], /WARNING-MARKER/);
assert.match(cells[3], /ORDER-NO/);
assert.match(cells[4], /ORDER-TYPE/);
assert.match(cells[5], /OWNER-NAME/);
assert.match(cells[cells.findIndex(cell => cell.includes('WAREHOUSE-ETA')) + 1], /REJECTION-TIME/);
});
}
@@ -0,0 +1,141 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const test = require('node:test');
const page = fs.readFileSync('海外仓/排车列表.html', 'utf8');
function extractFunction(name, dependencies = {}) {
const marker = `function ${name}`;
const start = page.indexOf(marker);
assert.notEqual(start, -1, `${name} should be defined`);
const nextFunction = page.indexOf('\nfunction ', start + marker.length);
const end = nextFunction === -1 ? page.length : nextFunction;
const names = Object.keys(dependencies);
const values = Object.values(dependencies);
return Function(...names, `return (${page.slice(start, end).trim()})`)(...values);
}
function createElement(id) {
const classes = new Set();
return {
id,
hidden: false,
tabIndex: -1,
attributes: {},
classList: {
toggle(name, enabled) {
if (enabled) classes.add(name);
else classes.delete(name);
},
contains(name) {
return classes.has(name);
},
},
setAttribute(name, value) {
this.attributes[name] = value;
},
focus() {
this.focused = true;
},
scrollIntoView() {
this.scrolledIntoView = true;
},
};
}
test('排车详情以车辆信息、关联订单和关联单据三个标签页组织内容', () => {
const start = page.indexOf('id="page-outbound-vehicle-detail"');
const end = page.indexOf('<!-- ====================', start + 1);
const detail = page.slice(start, end === -1 ? page.length : end);
assert.match(detail, />车辆信息<\/button>[\s\S]*?>关联订单<\/button>[\s\S]*?>关联单据<\/button>/);
assert.match(detail, /role="tablist"/);
assert.equal((detail.match(/role="tabpanel"/g) || []).length, 3);
assert.match(detail, /<th>排车单号<\/th><th>提货方式<\/th><th>关联派送单号<\/th><th>关联出库单号<\/th>/);
});
test('排车详情标签切换只显示选中的内容并同步无障碍状态', () => {
const activateTab = extractFunction('activateOutboundVehicleDetailTab');
const keys = ['vehicle', 'orders', 'documents'];
const nodes = new Map();
keys.forEach(key => {
nodes.set(`ovd-tab-${key}`, createElement(`ovd-tab-${key}`));
nodes.set(`ovd-panel-${key}`, createElement(`ovd-panel-${key}`));
});
const previousDocument = global.document;
global.document = { getElementById: id => nodes.get(id) || null };
try {
activateTab('documents', true);
assert.equal(nodes.get('ovd-tab-documents').attributes['aria-selected'], 'true');
assert.equal(nodes.get('ovd-tab-documents').tabIndex, 0);
assert.equal(nodes.get('ovd-tab-documents').focused, true);
assert.equal(nodes.get('ovd-tab-documents').scrolledIntoView, true);
assert.equal(nodes.get('ovd-panel-documents').hidden, false);
assert.equal(nodes.get('ovd-panel-vehicle').hidden, true);
assert.equal(nodes.get('ovd-panel-orders').hidden, true);
} finally {
global.document = previousDocument;
}
});
test('排车详情标签支持左右方向键循环切换', () => {
let activated = null;
const handleKeydown = extractFunction('handleOutboundVehicleDetailTabKeydown', {
activateOutboundVehicleDetailTab(key, focusTab) {
activated = { key, focusTab };
},
});
let prevented = false;
handleKeydown({
key: 'ArrowRight',
currentTarget: { id: 'ovd-tab-documents' },
preventDefault() {
prevented = true;
},
});
assert.equal(prevented, true);
assert.deepEqual(activated, { key: 'vehicle', focusTab: true });
});
test('关联单据按每个出库单生成一行并带出排车与转派送信息', () => {
const getDocuments = extractFunction('getOutboundVehicleDocuments');
const documents = getDocuments({
vehicleNo: 'PC20260908001',
deliveryNo: 'PS20260908002',
delivery: { pickupMethod: '一提多卸' },
orders: [{ orderNo: 'CK20260908003' }, { orderNo: 'CK20260908004' }],
});
assert.deepEqual(documents, [
{
vehicleNo: 'PC20260908001',
pickupMethod: '一提多卸',
dispatchNo: 'PS20260908002',
outboundNo: 'CK20260908003',
},
{
vehicleNo: 'PC20260908001',
pickupMethod: '一提多卸',
dispatchNo: 'PS20260908002',
outboundNo: 'CK20260908004',
},
]);
});
test('未生成转派送单时关联单据保留出库关系并显示占位值', () => {
const getDocuments = extractFunction('getOutboundVehicleDocuments');
assert.deepEqual(getDocuments({
vehicleNo: 'PC20260908001',
orders: [{ orderNo: 'CK20260908003' }],
}), [{
vehicleNo: 'PC20260908001',
pickupMethod: '—',
dispatchNo: '—',
outboundNo: 'CK20260908003',
}]);
});
@@ -0,0 +1,31 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const test = require('node:test');
const page = fs.readFileSync('海外仓/入库单.html', 'utf8');
test('海外仓入库列表在入库单号后展示客户并绑定记录字段', () => {
const start = page.indexOf('id="page-fba-inbound-order"');
const end = page.indexOf('<!-- ==================== FBA RETURN - 换标单', start);
const section = page.slice(start, end);
assert.match(
section,
/<th[^>]*>入库单号<\/th>\s*<th[^>]*>客户<\/th>\s*<th[^>]*>入仓码<\/th>/,
'客户列应紧跟在入库单号之后'
);
assert.match(
page,
/<td[^>]*>\$\{lmoEsc\(d\.customer\|\|d\.owner\|\|'—'\)\}<\/td>/,
'入库列表行应展示客户字段'
);
assert.match(page, /fbaInboundOrderData\.forEach\(record=>\{if\(!record\.customer\)record\.customer=record\.owner\}\)/, '入库记录应补齐客户字段');
});
test('编辑入库单可维护客户字段', () => {
assert.match(page, /id="fbio-edit-customer"/);
assert.match(page, /originalOpenFbioEditModal\(inboundNo\)/);
assert.match(page, /record\.customer=customer/);
});
console.log('overseas inbound customer column checks are defined');
@@ -0,0 +1,138 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const test = require('node:test');
const pages = [
['入库单', fs.readFileSync('海外仓/入库单.html', 'utf8')],
['出库单', fs.readFileSync('海外仓/出库单.html', 'utf8')],
];
function extractFunction(page, name) {
const marker = `function ${name}`;
const start = page.indexOf(marker);
assert.notEqual(start, -1, `${name} should be defined`);
const nextFunction = page.indexOf('\nfunction ', start + marker.length);
const end = nextFunction === -1 ? page.length : nextFunction;
return Function('lmoEsc', 'fbaDetailDisplay', `return (${page.slice(start, end).trim()})`)(
value => String(value).replace(/[&<>"']/g, character => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
})[character]),
value => value === null || value === undefined || value === '' ? '—' : String(value),
);
}
function createElement(id) {
const classes = new Set();
return {
id,
hidden: false,
tabIndex: -1,
attributes: {},
classList: {
toggle(name, enabled) {
if (enabled) classes.add(name);
else classes.delete(name);
},
contains(name) {
return classes.has(name);
},
},
setAttribute(name, value) {
this.attributes[name] = value;
},
focus() {
this.focused = true;
},
scrollIntoView() {
this.scrolledIntoView = true;
},
};
}
for (const [name, page] of pages) {
test(`${name}详情在应收费用后提供操作记录页签与面板`, () => {
const detailMarker = name === '入库单' ? 'page-fba-inbound-order-detail' : 'page-fba-outbound-order-detail';
const detailStart = page.indexOf(`id="${detailMarker}"`);
const detailEnd = page.indexOf('<!-- ====================', detailStart + detailMarker.length);
const detail = page.slice(detailStart, detailEnd);
assert.match(detail, />应收费用<\/button>[\s\S]*?>操作记录<\/button>/);
assert.match(detail, /<th>操作时间<\/th><th>操作内容<\/th><th>操作人<\/th><th>操作结果<\/th><th>备注<\/th>/);
});
test(`${name}操作记录渲染真实记录、结果标签和空状态`, () => {
const render = extractFunction(page, 'renderFbaOperationRecords');
const body = { innerHTML: '' };
const previousDocument = global.document;
global.document = { getElementById: id => id === 'operation-body' ? body : null };
try {
render('operation-body', [{
time: '2026-09-08 10:30',
action: '审核应收费用',
operator: '张经理',
result: '成功',
remark: '费用审核通过',
}]);
assert.match(body.innerHTML, /2026-09-08 10:30/);
assert.match(body.innerHTML, /审核应收费用/);
assert.match(body.innerHTML, /张经理/);
assert.match(body.innerHTML, /tag-green[^>]*>成功/);
assert.match(body.innerHTML, /费用审核通过/);
render('operation-body', []);
assert.match(body.innerHTML, /colspan="5"[^>]*>暂无操作记录/);
} finally {
global.document = previousDocument;
}
});
test(`${name}详情切换操作记录时激活面板并将页签带入可视区域`, () => {
const inboundSwitch = extractFunction(page, 'switchFbaInboundDetailTab');
const outboundSwitch = extractFunction(page, 'activateFbaOutboundDetailTab');
const inboundKeys = ['basic', 'pallet', 'appointment', 'fee', 'operations'];
const outboundKeys = ['outbound', 'handover', 'pallet', 'fees', 'operations'];
const inboundTabs = inboundKeys.map(key => createElement(`fbd-inbound-tab-${key}`));
const inboundPanels = inboundKeys.map(key => createElement(`fbd-inbound-panel-${key}`));
const outboundNodes = new Map();
outboundKeys.forEach(key => {
outboundNodes.set(`fbd-outbound-tab-${key}`, createElement(`fbd-outbound-tab-${key}`));
outboundNodes.set(`fbd-outbound-panel-${key}`, createElement(`fbd-outbound-panel-${key}`));
});
const inboundPage = {
querySelectorAll(selector) {
return selector === '.fbd-inbound-tab' ? inboundTabs : inboundPanels;
},
};
const previousDocument = global.document;
global.document = {
getElementById(id) {
if (id === 'page-fba-inbound-order-detail') return inboundPage;
return inboundTabs.find(tab => tab.id === id) || outboundNodes.get(id) || null;
},
};
try {
inboundSwitch('operations');
outboundSwitch('operations', false);
const inboundTab = inboundTabs.at(-1);
const inboundPanel = inboundPanels.at(-1);
const outboundTab = outboundNodes.get('fbd-outbound-tab-operations');
const outboundPanel = outboundNodes.get('fbd-outbound-panel-operations');
assert.equal(inboundTab.attributes['aria-selected'], 'true');
assert.equal(inboundPanel.hidden, false);
assert.equal(inboundTab.scrolledIntoView, true);
assert.equal(outboundTab.attributes['aria-selected'], 'true');
assert.equal(outboundPanel.hidden, false);
assert.equal(outboundTab.scrolledIntoView, true);
} finally {
global.document = previousDocument;
}
});
}
@@ -0,0 +1,63 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const test = require('node:test');
const page = fs.readFileSync('海外仓/退货单.html', 'utf8');
function extractFunction(name) {
const start = page.indexOf(`function ${name}`);
assert.notEqual(start, -1, `${name} should be defined in the return order page`);
const nextFunction = page.indexOf('\nfunction ', start + 1);
const dataDeclaration = page.indexOf('\nconst fbaReturnOrderData', start + 1);
const endCandidates = [nextFunction, dataDeclaration].filter(index => index !== -1);
const end = endCandidates.length ? Math.min(...endCandidates) : page.length;
return eval(`(${page.slice(start, end).trim()})`);
}
function extractReturnNumberHelpers() {
const start = page.indexOf('function fbaReturnDateStamp');
const end = page.indexOf('\nconst fbaReturnOrderData', start);
assert.notEqual(start, -1, 'return number helpers should be defined in the return order page');
assert.notEqual(end, -1, 'return order data should follow return number helpers');
return eval(`(()=>{${page.slice(start, end)};return {formatFbaReturnOrderNo,nextFbaReturnOrderNo};})()`);
}
test('return order number follows BYTH date, fixed 01 segment, and four digit sequence', () => {
const { formatFbaReturnOrderNo: formatNo } = extractReturnNumberHelpers();
const result = formatNo(new Date(2026, 7, 12), 1);
assert.equal(result, 'BYTH260812010001');
});
test('return order number sequence increments per date without reusing existing numbers', () => {
const { nextFbaReturnOrderNo: nextNo } = extractReturnNumberHelpers();
const existing = [
{ returnNo: 'BYTH260812010001' },
{ returnNo: 'BYTH260812010003' },
{ returnNo: 'BYTH260811010099' },
];
assert.equal(nextNo(new Date(2026, 7, 12), existing), 'BYTH260812010004');
});
test('return list columns include customer and inbound code, hide last-mile order, and separate statuses', () => {
const header = page.match(/<table class="fbrt-table"[\s\S]*?<thead><tr>([\s\S]*?)<\/tr><\/thead>/)?.[1] || '';
assert.match(header, /<th>客户<\/th>/);
assert.match(header, /<th>入仓码<\/th>/);
assert.match(header, /<th>换标单号<\/th><th>换标状态<\/th><th>是否签收<\/th>/);
assert.doesNotMatch(header, /<th>尾程订单号<\/th>/);
});
test('relabel status and sign status use independent option sets', () => {
const relabelStatusOptions = [...page.matchAll(/id="fbrt-edit-relabel-status"[^>]*>[\s\S]*?<\/select>/g)]
.map(match => match[0])
.join('');
const signStatusOptions = [...page.matchAll(/id="fbrt-edit-sign-status"[^>]*>[\s\S]*?<\/select>/g)]
.map(match => match[0])
.join('');
assert.match(relabelStatusOptions, /待处理|换标中|已完成/);
assert.doesNotMatch(relabelStatusOptions, /部分签收|全部签收/);
assert.match(signStatusOptions, /部分签收/);
assert.match(signStatusOptions, /全部签收/);
assert.doesNotMatch(signStatusOptions, /换标中|已完成|待处理/);
assert.match(page, /relabelStatus:'换标中'/);
assert.match(page, /signStatus:'部分签收'/);
});
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

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