This commit is contained in:
2026-08-21 09:20:26 +08:00
parent c9c6d5a0f9
commit 49465315e2
89 changed files with 16413 additions and 1776 deletions
+4
View File
@@ -1,2 +1,6 @@
.DS_Store
**/.DS_Store
.playwright-cli
.superpowers
output
docs
+4 -3
View File
@@ -4,7 +4,7 @@
- 页面、字段和局部交互先按下方“原型模块快速索引”定位,再只读取命中的 `prototype-src/modules/*.html`。
- 页面涉及弹窗、抽屉或跨模块交互时,再读取 `prototype-src/overlays.html` 和 `prototype-src/app.js` 中对应 ID/函数;视觉样式按需读取 `prototype-src/shell.html`。
- `index.html` 是由拆分源码生成的完整可运行入口,本地预览直接双击它。`货代SaaS平台原型-完整备份.html` 仅作为迁移前的大文件备份,不作为日常入口。
- `index.html` 是由拆分源码生成的完整可运行入口,本地预览直接双击它。
- WMS 字段未在原型中明确时,再读取 `wms_full_data.json` 和 `柏悦WMS系统文档.md`。
## 怎么看
@@ -117,15 +117,16 @@ node tests/verify-prototype-modules.cjs
|---|---|---|
| 首页数据看板 | `prototype-src/modules/00-dashboard.html` | `dashboard` |
| 客户管理 | `prototype-src/modules/01-customers.html` | `tenant-list`、`tenant-detail`、`warehouse-tier-fee` |
| 国内仓 | `prototype-src/modules/02-domestic-warehouse.html` | 入库订单与详情、测量/收货审核、WMS 基础/入库/盘点/出库/库存/加工/移库/上架、库位、托盘、国内整柜 |
| 国内仓 | `prototype-src/modules/02-domestic-warehouse.html` | 入库订单与详情、测量/收货审核、WMS 基础/入库/盘点/出库/库存/加工/移库/上架、库位、托盘及详情、国内整柜 |
| 海外仓 | `prototype-src/modules/03-overseas-warehouse.html` | 尾程订单,海外仓入库/出库/换标,产品/托盘/箱号/库存,采购库存及详情 |
| 海外派送 | `prototype-src/modules/04-overseas-delivery.html` | `outbound-dispatch`、BOL/自提历史页、排车列表/编辑/详情 |
| 客户服务 | `prototype-src/modules/04a-customer-service.html` | `outbound-exception`(异常订单) |
| 供应商中心 | `prototype-src/modules/05-suppliers.html` | 供应商列表/编辑、快递公司、往来单位、海运渠道 |
| 货柜管理 | `prototype-src/modules/06-containers.html` | 订舱、拖车、货柜、装柜清单、预装清单、装柜看板 |
| 海外业务 | `prototype-src/modules/07-overseas-business.html` | 采购订单、本土卡车、本土散板及新增/编辑/详情 |
| 财务管理 | `prototype-src/modules/08-finance.html` | 客户账户、应收应付、对账单、结算单、费用分析、利润表及历史财务报表页 |
| 销售管理 | `prototype-src/modules/09-sales.html` | 拜访记录新增/详情、销售提成 |
| 通用资料 | `prototype-src/modules/10-general.html` | 产品、费用项目、仓库、下载、船期价格、车辆及历史库位/托盘/路线页 |
| 通用资料 | `prototype-src/modules/10-general.html` | 产品、费用项目、汇率管理、仓库(`gen-owned-warehouse`、`gen-fba`、`gen-partner-warehouse`)、下载、船期价格、车辆及详情、派送车队报价表、历史库位/托盘/路线页 |
| 系统与消息 | `prototype-src/modules/11-system-messages.html` | 用户、角色、菜单权限、品牌/客户历史页、消息管理/模板/我的消息、海外用户 |
| 历史订单与库存 | `prototype-src/modules/12-legacy-orders.html` | 旧运单、OMS、库存及库龄页面;当前侧栏无直接入口,使用前核对需求 |
| 运价、报关与清关 | `prototype-src/modules/13-freight-customs.html` | 旧运价/报价/空运/铁路/对账/报表、报关基础资料、清关页面;当前侧栏无直接入口,使用前核对需求 |
File diff suppressed because one or more lines are too long
+47
View File
@@ -0,0 +1,47 @@
# Product
<!-- impeccable:product-schema 1 -->
## Platform
web
## Users
货代 SaaS 平台的后台管理、运营、仓储、财务及相关业务人员。他们在桌面端为主的管理环境中,重复处理客户、仓库、运输、货柜、海外业务、财务、销售、通用资料和系统配置等操作。
## Product Purpose
平台集中承载货代业务的资料维护、订单执行、仓储作业、派送、结算和管理流程。成功意味着用户能够快速定位业务模块,准确查看和维护字段,并在列表、详情、弹窗和状态流转之间完成可追溯操作。
## Operating Context
- 产品以完整可运行的后台原型交付,`index.html` 是验收入口。
- 维护源码按一级业务导航拆分到 `prototype-src/modules/*.html`,共用弹窗位于 `prototype-src/overlays.html`,交互位于 `prototype-src/app.js`。
- 页面通过侧栏进入;没有导航入口的页面按历史或隐藏页面处理。
- 表格、筛选、弹窗、状态、审计字段和跨模块数据联动是主要交互形式。
## Capabilities and Constraints
- 当前交付是原生 HTML、CSS 和 JavaScript 构成的静态后台原型,不包含后端持久化。
- 页面 ID、模块归属、导航入口和生成顺序由 `prototype-src/manifest.json` 约束。
- 新增或修改页面后必须重建 `index.html` 并运行模块一致性校验。
- 产品文案使用中文;业务代码、币种、仓号等保留约定的英文缩写。
- 未在原型、阶段需求或已确认契约中定义的业务流程不得猜测补齐。
## Evidence on Hand
- 完整后台原型及拆分源码:`index.html`、`prototype-src/`
- 页面与模块维护规则:`AGENTS.md`
- WMS 字段资料:`wms_full_data.json`、`柏悦WMS系统文档.md`
- 自动验证脚本:`tests/`
仓库中没有可用于对外宣传的客户证明、商业指标或品牌素材,后续界面不得虚构此类内容。
## Product Principles
- 业务契约优先:页面字段、状态和流程以已确认需求与原型为准。
- 操作效率优先:后台界面保持紧凑、可扫描,并支持高频重复操作。
- 数据可追溯:关键维护动作保留时间、人员、状态和历史记录。
- 模块边界稳定:列表、详情和编辑保持在同一业务域,共用交互放入共享文件。
- 验证后交付:源码修改、完整入口构建和交互验收必须一致。
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>尾程订单详情 - 费用明细布局</title>
<style>
*{box-sizing:border-box}body{margin:0;background:#f3f6fa;color:#273548;font:14px Arial,"Microsoft YaHei",sans-serif}.page{max-width:1120px;margin:28px auto;padding:0 20px}.heading{margin-bottom:16px}.heading h1{margin:0 0 6px;font-size:20px}.heading p{margin:0;color:#718096;font-size:13px}.choice{border:1px solid #cfd9e7;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 4px 16px rgba(34,51,84,.06)}.choice-header{display:flex;align-items:center;gap:10px;padding:13px 16px;border-bottom:1px solid #e7edf5;background:#f8fbff}.badge{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:50%;background:#2f80ed;color:#fff;font-weight:700}.choice-header strong{font-size:15px}.choice-header span{margin-left:auto;color:#6583a8;font-size:12px}.detail{padding:16px;background:#f6f8fb}.grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}.section{border:1px solid #e0e7f0;border-radius:6px;background:#fff;overflow:hidden}.wide{grid-column:1/-1}.title{padding:11px 14px;border-bottom:1px solid #e9eef5;font-weight:700;color:#273548;background:#fff}.hint{float:right;font-size:12px;color:#8b9aae;font-weight:400}.info{display:grid;grid-template-columns:repeat(2,1fr);padding:6px 14px}.info div{display:grid;grid-template-columns:112px 1fr;padding:8px 0;border-bottom:1px solid #f0f3f7;font-size:12px}.info div:nth-last-child(-n+2){border-bottom:0}.label{color:#8795a8}.table-wrap{overflow:auto}.fee-table{width:100%;border-collapse:collapse;min-width:820px;font-size:12px}.fee-table th{padding:10px 12px;background:#f8fafc;color:#65758b;text-align:left;font-weight:600;white-space:nowrap}.fee-table td{padding:10px 12px;border-top:1px solid #edf1f5;white-space:nowrap}.fee-table .num{text-align:right}.amount{font-weight:700;color:#1f5fbf}.total{display:flex;justify-content:flex-end;gap:28px;padding:11px 14px;border-top:1px solid #e5ebf2;background:#fbfcfe;font-size:12px}.total strong{font-size:15px;color:#1f5fbf}.doc-list{padding:0 14px 12px}.doc{display:flex;align-items:center;padding:10px 0;border-bottom:1px solid #f0f3f7;font-size:12px}.doc:last-child{border-bottom:0}.doc a{margin-left:auto;color:#2f80ed;text-decoration:none}.flow{display:flex;gap:8px;align-items:center;margin:16px 0 10px;color:#6c7e92;font-size:12px}.flow b{padding:5px 9px;border-radius:4px;background:#eaf3ff;color:#2767bf}.flow i{font-style:normal;color:#a3afbf}@media(max-width:700px){.page{padding:0 12px;margin:16px auto}.grid{grid-template-columns:1fr}.info{grid-template-columns:1fr}.info div:nth-last-child(-n+2){border-bottom:1px solid #f0f3f7}.info div:last-child{border-bottom:0}.choice-header{align-items:flex-start}.choice-header span{margin-left:0}}
</style>
</head>
<body>
<main class="page">
<div class="heading"><h1>尾程订单详情:费用明细布局草图</h1><p>按你的要求,将“关联单据”移至“计划信息”之后,费用明细紧随其后。</p></div>
<div class="choice" data-choice="recommended">
<div class="choice-header"><span class="badge">A</span><strong>推荐:关联单据后使用全宽费用表</strong><span>便于核对多项费用及合计</span></div>
<div class="detail">
<div class="flow"><b>订单信息</b><i>→</i><b>订单明细</b><i>→</i><b>计划信息</b><i>→</i><b>关联单据</b><i>→</i><b>费用明细</b><i>→</i><b>货物信息 / 操作记录</b></div>
<div class="grid">
<section class="section"><div class="title">计划信息</div><div class="info"><div><span class="label">入仓码</span><span>LA-ONT8-0813</span></div><div><span class="label">计划出库日期</span><span>2026-08-15</span></div><div><span class="label">合托批次</span><span>CT20260813001</span></div><div><span class="label">派送方式</span><span>卡车派送</span></div></div></section>
<section class="section"><div class="title">关联单据</div><div class="doc-list"><div class="doc">入库单 <a>FBI20260813001</a></div><div class="doc">出库单 <a>FBO20260815001</a></div><div class="doc">派送单 <a>FBD20260815001</a></div></div></section>
<section class="section wide"><div class="title">费用明细 <span class="hint">费用项目按订单实际发生展示</span></div><div class="table-wrap"><table class="fee-table"><thead><tr><th>费用项目</th><th>计费说明</th><th>币种</th><th class="num">单价</th><th class="num">数量</th><th class="num">金额</th><th>费用状态</th></tr></thead><tbody><tr><td>仓储费</td><td>在库 3 天</td><td>USD</td><td class="num">2.00</td><td class="num">8 托</td><td class="num amount">48.00</td><td>待结算</td></tr><tr><td>贴标费</td><td>FNSKU 贴标</td><td>USD</td><td class="num">0.15</td><td class="num">2,304 件</td><td class="num amount">345.60</td><td>待结算</td></tr><tr><td>出库费</td><td>整托出库</td><td>USD</td><td class="num">12.00</td><td class="num">8 托</td><td class="num amount">96.00</td><td>待结算</td></tr><tr><td>派送费</td><td>FBA ONT8</td><td>USD</td><td class="num">—</td><td class="num">1 单</td><td class="num amount">180.00</td><td>待结算</td></tr></tbody></table></div><div class="total"><span>已发生 4 项</span><span>费用合计 <strong>USD 669.60</strong></span></div></section>
</div>
</div>
</div>
</main>
</body>
</html>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" viewBox="0 0 240 240" role="img" aria-labelledby="title desc">
<title id="title">示例收款二维码</title>
<desc id="desc">仅用于原型展示,不包含真实收款信息</desc>
<rect width="240" height="240" fill="#fff"/>
<g fill="#111827">
<path d="M16 16h64v64H16zm10 10v44h44V26zm10 10h24v24H36zM160 16h64v64h-64zm10 10v44h44V26zm10 10h24v24h-24zM16 160h64v64H16zm10 10v44h44v-44zm10 10h24v24H36z"/>
<path d="M96 16h12v12H96zm24 0h12v24h-12zm16 8h12v12h-12zM88 44h20v12H88zm28 8h12v20h-12zm20-8h12v28h-12zM88 84h12v20H88zm24 0h28v12h-28zm36 4h12v20h-12zm20 0h20v12h-20zm28 0h28v12h-28zM16 96h20v12H16zm32-8h12v28H48zm20 8h20v12H68zm96 16h20v12h-20zm32-8h28v12h-28zM16 124h12v20H16zm20-8h20v12H36zm32 4h20v20H68zm92 16h12v16h-12zm20-4h20v12h-20zm28 4h16v20h-16zM92 160h20v12H92zm28-8h12v28h-12zm16 8h20v12h-20zm28 0h20v20h-20zm32 4h28v12h-28zM88 188h12v20H88zm20-4h20v12h-20zm28 4h16v20h-16zm28 0h12v12h-12zm20 0h20v12h-20zm28 4h12v28h-12zM96 216h24v8H96zm36-4h28v12h-28zm48 4h20v8h-20z"/>
</g>
<rect x="88" y="104" width="64" height="34" rx="4" fill="#fff" stroke="#111827" stroke-width="3"/>
<text x="120" y="127" text-anchor="middle" font-family="Arial, sans-serif" font-size="17" font-weight="700" fill="#111827">示例</text>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -12,11 +12,15 @@
"outbound-dispatch",
"outbound-bol",
"outbound-selfpick",
"outbound-exception",
"gen-product",
"gen-fee",
"gen-exchange-rate",
"outbound-vehicle",
"outbound-vehicle-edit",
"gen-owned-warehouse",
"gen-fba",
"gen-partner-warehouse",
"gen-overseas-quote",
"gen-download",
"gen-pallet",
"gen-location",
@@ -92,6 +96,7 @@
"customer-account-detail",
"finance-cost",
"customer-arap",
"finance-ar-detail-report",
"customer-arap-detail",
"customer-recon-stmt",
"settlement-ar",
@@ -116,6 +121,7 @@
"ct-list",
"ct-manifest",
"fba-stock-management",
"overseas-location",
"inventory-overview",
"warehouse-stock",
"overseas-stock",
@@ -124,6 +130,8 @@
"overseas-relabel",
"profit",
"vehicle",
"delivery-fleet-quote",
"vehicle-detail",
"fba-return-inbound",
"fba-return-relabel",
"fba-return-outbound",
@@ -147,11 +155,12 @@
"inventory-transactions",
"purchase-stock-detail",
"fba-return-stock",
"overseas-location",
"domestic-location",
"domestic-stock-management",
"domestic-container-business",
"domestic-container-detail",
"pallet-manage",
"pallet-detail",
"ct-preload",
"ct-preload-detail",
"ct-board",
@@ -205,11 +214,12 @@
"wh-process",
"wh-transfer",
"wh-shelve",
"overseas-location",
"domestic-location",
"domestic-stock-management",
"domestic-container-business",
"domestic-container-detail",
"pallet-manage"
"pallet-manage",
"pallet-detail"
]
},
{
@@ -217,6 +227,7 @@
"title": "海外仓",
"pages": [
"fba-stock-management",
"overseas-location",
"inventory-overview",
"warehouse-stock",
"overseas-stock",
@@ -255,10 +266,16 @@
"outbound-bol",
"outbound-selfpick",
"outbound-vehicle",
"outbound-vehicle-edit",
"outbound-vehicle-detail"
]
},
{
"file": "modules/04a-customer-service.html",
"title": "客户服务",
"pages": [
"outbound-exception"
]
},
{
"file": "modules/05-suppliers.html",
"title": "供应商中心",
@@ -332,6 +349,7 @@
"customer-account-detail",
"finance-cost",
"customer-arap",
"finance-ar-detail-report",
"customer-arap-detail",
"customer-recon-stmt",
"settlement-ar",
@@ -356,13 +374,19 @@
"pages": [
"gen-product",
"gen-fee",
"gen-exchange-rate",
"gen-owned-warehouse",
"gen-fba",
"gen-partner-warehouse",
"gen-overseas-quote",
"gen-download",
"gen-pallet",
"gen-location",
"gen-route",
"gen-voyage",
"vehicle"
"vehicle",
"delivery-fleet-quote",
"vehicle-detail"
]
},
{
@@ -56,32 +56,6 @@
</div>
<div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-wtf-edit')">取消</button><button class="btn btn-primary" onclick="updateWarehouseTierFee()">保存</button></div></div></div>
<!-- 仓库管理新增弹窗 -->
<div class="modal-overlay" id="modal-fba-add">
<div class="modal" style="max-width:560px">
<div class="modal-header">新增<span onclick="hideModal('modal-fba-add')" style="cursor:pointer;font-size:20px">&times;</span></div>
<div class="modal-body" id="fba-add-body">
<style>
#fba-add-body .form-row.single .form-group{flex-direction:row;align-items:center;gap:12px;margin-bottom:0;}
#fba-add-body .form-row.single .form-group label{width:180px;text-align:right;flex-shrink:0;margin:0;font-size:14px;color:#52525b;font-weight:600;}
#fba-add-body .form-row.single .form-group label .required{color:#ef4444;margin-right:4px;}
#fba-add-body .form-row.single .form-group input,
#fba-add-body .form-row.single .form-group select{flex:1;padding:10px 14px;border:1px solid rgba(102,126,234,.2);border-radius:12px;font-size:14px;outline:none;background:rgba(255,255,255,.9);transition:all .3s cubic-bezier(.4,0,.2,1);}
#fba-add-body .form-row.single .form-group input:focus,
#fba-add-body .form-row.single .form-group select:focus{border-color:#667eea;box-shadow:0 0 0 4px rgba(102,126,234,.1);transform:translateY(-1px);}
</style>
<div class="form-row single"><div class="form-group"><label><span class="required">*</span>国家:</label><select id="fba-add-country"><option value="">请输入国家</option><option>美国</option><option>英国</option><option>德国</option><option>日本</option><option>加拿大</option><option>澳大利亚</option></select></div></div>
<div class="form-row single"><div class="form-group"><label><span class="required">*</span>FBA编码:</label><input id="fba-add-code" placeholder="请输入FBA编码"/></div></div>
<div class="form-row single"><div class="form-group"><label>City:</label><input id="fba-add-city" placeholder="请输入City"/></div></div>
<div class="form-row single"><div class="form-group"><label>State:</label><input id="fba-add-state" placeholder="请输入State"/></div></div>
<div class="form-row single"><div class="form-group"><label><span class="required">*</span>详细地址:</label><input id="fba-add-addr" placeholder="请输入详细地址"/></div></div>
<div class="form-row single"><div class="form-group"><label><span class="required">*</span>邮编:</label><input id="fba-add-zip" placeholder="请输入邮编"/></div></div>
<div class="form-row single"><div class="form-group"><label>包税补运费标准(按毛重每公斤/美元):</label><input id="fba-add-tax-rate" placeholder="请输入包税补运费标准(按毛重每公斤/美元)"/></div></div>
<div class="form-row single"><div class="form-group"><label>不包税补运费标准(按立方每立方米/美元):</label><input id="fba-add-no-tax-rate" placeholder="请输入不包税补运费标准(按立方每立方米/美元)"/></div></div>
<div class="form-row single"><div class="form-group"><label>内部编号:</label><input id="fba-add-inner-code" placeholder="请输入内部编号"/></div></div>
</div>
<div class="modal-footer"><button class="btn btn-primary" onclick="saveFbaAdd()">确定</button><button class="btn btn-default" onclick="hideModal('modal-fba-add')">关闭</button></div></div></div>
<!-- ==================== INBOUND LIST ==================== -->
<div class="page" id="page-tenant-detail">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px">
@@ -109,6 +109,16 @@
</tr>
</thead>
<tbody>
<tr data-inbound-order-type="快递订单">
<td><input type="checkbox"/></td>
<td><a href="javascript:void(0)" onclick="openDomesticInboundDetail('EXP260814001')">详情</a> | <div class="more-dropdown"><span class="more-dropdown-btn" onclick="toggleMoreDropdown(this)">更多 ∨</span><div class="more-dropdown-menu"><a href="javascript:void(0)" class="more-dropdown-item" onclick="openDomesticInboundFeeModal('courier','EXP260814001',this)">填写快递费</a><a href="javascript:void(0)" class="more-dropdown-item" onclick="showToast('备注','info')">备注</a></div></div></td>
<td>EXP260814001</td>
<td><span class="tag tag-green">审核通过</span></td>
<td><span class="tag tag-green">审核通过</span></td>
<td>2026-08-14</td>
<td data-inbound-fee-status="EXP260814001"><span class="tag tag-gray">未录入费用</span></td>
<td>义乌卓越</td><td>义乌仓</td><td>上海分拨仓</td><td><span class="tag tag-gray">未配载</span></td><td>否</td><td>-</td><td>-</td><td>-</td><td>义乌卓越物流</td><td>快递订单</td><td>快递</td><td>2026-08-14</td><td>13800138000</td><td>义乌市稠州北路</td><td>322000</td><td>ops@example.com</td><td>国内快递</td><td>-</td><td>否</td><td>不涉及</td><td>-</td><td>-</td><td>3</td><td>42</td><td>42</td><td>0.25</td><td>0.25</td><td>0</td><td>A1</td><td>41.67</td><td>40</td><td>国内地址</td><td>上海分拨仓</td><td>上海市青浦区</td><td>201700</td><td>王师傅</td><td>13900139000</td><td>2026-08-14 10:20</td><td>2026-08-14 11:05</td><td>-</td><td>-</td><td>-</td><td>顺丰速运</td><td>SF260814001</td><td>当天寄出</td><td>优先处理</td><td>admin</td><td>2026-08-14</td>
</tr>
<tr><td><input type="checkbox"/></td><td><a href="javascript:void(0)" onclick="switchPage('inbound-detail')">详情</a> | <div class="more-dropdown"><span class="more-dropdown-btn" onclick="toggleMoreDropdown(this)">更多 ∨</span><div class="more-dropdown-menu"><a href="javascript:void(0)" class="more-dropdown-item" onclick="showToast('编辑测量数据','info')">编辑测量数据</a><a href="javascript:void(0)" class="more-dropdown-item" onclick="showToast('导出单票入库清单','info')">导出单票入库清单</a><a href="javascript:void(0)" class="more-dropdown-item" onclick="showModal('modal-inbound-measure-review')">审核测量数据</a><a href="javascript:void(0)" class="more-dropdown-item" onclick="showToast('作废','info')">作废</a><a href="javascript:void(0)" class="more-dropdown-item" onclick="showToast('打印唛头','info')"><span class="icon">🖨️</span>打印唛头</a><a href="javascript:void(0)" class="more-dropdown-item" onclick="showToast('备注','info')"><span class="icon">📝</span>备注</a><a href="javascript:void(0)" class="more-dropdown-item" onclick="showToast('导出FBA流转单','info')">导出FBA流转单</a></div></div></td><td>PCKHK2606151047</td><td><span class="tag tag-orange">待接收审核</span></td><td><span class="tag tag-gray">未录入测量数据</span></td><td><input type="date"/></td><td><span class="tag tag-gray">未录入费用</span></td><td>杭州积乐</td><td>义乌仓</td><td>义乌仓</td><td><span class="tag tag-gray">未配载</span></td><td>否</td><td>-</td><td>CSLU1234567</td><td>ABC123</td><td>杭州积乐贸易</td><td>海运拼箱</td><td>自提</td><td>2024-01-15</td><td>13800138000</td><td>杭州市江干区XX路XX号</td><td>310000</td><td>contact@example.com</td><td>海运</td><td>买单</td><td>否</td><td>已完成</td><td>2024-01-20</td><td>2024-02-10</td><td>5</td><td>500</td><td>480</td><td>2.5</td><td>2.4</td><td>2</td><td>A1</td><td>520</td><td>450</td><td>FBA仓</td><td>美国仓</td><td>USA Address</td><td>90210</td><td>John</td><td>123456789</td><td>-</td><td>-</td><td>file.pdf</td><td>mark.pdf</td><td>order.pdf</td><td>顺丰</td><td>SF123456789</td><td>无</td><td>内部备注</td><td>admin</td><td>2024-01-10</td></tr>
<tr><td><input type="checkbox"/></td><td><a href="javascript:void(0)" onclick="switchPage('inbound-detail')">详情</a> | <div class="more-dropdown"><span class="more-dropdown-btn" onclick="toggleMoreDropdown(this)">更多 ∨</span><div class="more-dropdown-menu"><a href="javascript:void(0)" class="more-dropdown-item" onclick="showToast('编辑测量数据','info')">编辑测量数据</a><a href="javascript:void(0)" class="more-dropdown-item" onclick="showToast('导出单票入库清单','info')">导出单票入库清单</a><a href="javascript:void(0)" class="more-dropdown-item" onclick="showModal('modal-inbound-measure-review')">审核测量数据</a><a href="javascript:void(0)" class="more-dropdown-item" onclick="showToast('作废','info')">作废</a><a href="javascript:void(0)" class="more-dropdown-item" onclick="showToast('打印唛头','info')"><span class="icon">🖨️</span>打印唛头</a><a href="javascript:void(0)" class="more-dropdown-item" onclick="showToast('备注','info')"><span class="icon">📝</span>备注</a><a href="javascript:void(0)" class="more-dropdown-item" onclick="showToast('导出FBA流转单','info')">导出FBA流转单</a></div></div></td><td>PCKHK2606151046</td><td><span class="tag tag-orange">待接收审核</span></td><td><span class="tag tag-gray">未录入测量数据</span></td><td><input type="date"/></td><td><span class="tag tag-gray">未录入费用</span></td><td>杭州积乐</td><td>义乌仓</td><td>义乌仓</td><td><span class="tag tag-gray">未配载</span></td><td>否</td><td>-</td><td>CSLU1234568</td><td>ABC124</td><td>杭州积乐贸易</td><td>海运拼箱</td><td>送货上门</td><td>2024-01-16</td><td>13900139000</td><td>杭州市西湖区XX路XX号</td><td>310001</td><td>contact2@example.com</td><td>海运</td><td>退税</td><td>否</td><td>已完成</td><td>2024-01-21</td><td>2024-02-11</td><td>8</td><td>800</td><td>780</td><td>4.0</td><td>3.8</td><td>3</td><td>A2</td><td>850</td><td>720</td><td>FBA仓</td><td>美国仓</td><td>USA Address 2</td><td>90211</td><td>Mike</td><td>987654321</td><td>-</td><td>-</td><td>file2.pdf</td><td>mark2.pdf</td><td>order2.pdf</td><td>圆通</td><td>YT123456789</td><td>加急</td><td>内部备注2</td><td>user1</td><td>2024-01-11</td></tr>
<tr><td><input type="checkbox"/></td><td><a href="javascript:void(0)" onclick="switchPage('inbound-detail')">详情</a> | <div class="more-dropdown"><span class="more-dropdown-btn" onclick="toggleMoreDropdown(this)">更多 ∨</span><div class="more-dropdown-menu"><a href="javascript:void(0)" class="more-dropdown-item" onclick="showToast('编辑测量数据','info')">编辑测量数据</a><a href="javascript:void(0)" class="more-dropdown-item" onclick="showToast('导出单票入库清单','info')">导出单票入库清单</a><a href="javascript:void(0)" class="more-dropdown-item" onclick="showModal('modal-inbound-measure-review')">审核测量数据</a><a href="javascript:void(0)" class="more-dropdown-item" onclick="showToast('作废','info')">作废</a><a href="javascript:void(0)" class="more-dropdown-item" onclick="showToast('打印唛头','info')"><span class="icon">🖨️</span>打印唛头</a><a href="javascript:void(0)" class="more-dropdown-item" onclick="showToast('备注','info')"><span class="icon">📝</span>备注</a><a href="javascript:void(0)" class="more-dropdown-item" onclick="showToast('导出FBA流转单','info')">导出FBA流转单</a></div></div></td><td>HDHK2606151045</td><td><span class="tag tag-orange">待接收审核</span></td><td><span class="tag tag-gray">未录入测量数据</span></td><td><input type="date"/></td><td><span class="tag tag-gray">未录入费用</span></td><td>岩盛</td><td>义乌仓</td><td>义乌仓</td><td><span class="tag tag-gray">未配载</span></td><td>否</td><td>-</td><td>CSLU1234569</td><td>ABC125</td><td>岩盛进出口</td><td>空运</td><td>自提</td><td>2024-01-17</td><td>13700137000</td><td>深圳市南山区XX路XX号</td><td>518000</td><td>ys@rocksheng.com</td><td>空运</td><td>买单</td><td>否</td><td>已完成</td><td>2024-01-22</td><td>2024-01-25</td><td>3</td><td>150</td><td>145</td><td>0.5</td><td>0.45</td><td>4</td><td>A3</td><td>160</td><td>130</td><td>FBA仓</td><td>德国仓</td><td>Germany Address</td><td>10178</td><td>Tom</td><td>456789012</td><td>-</td><td>-</td><td>file3.pdf</td><td>mark3.pdf</td><td>order3.pdf</td><td>EMS</td><td>EMS123456789</td><td>无</td><td>内部备注3</td><td>user2</td><td>2024-01-12</td></tr>
@@ -707,7 +717,7 @@
<!-- Header with icon -->
<div style="display:flex;align-items:center;gap:10px;margin-bottom:20px">
<div style="width:36px;height:36px;background:#1e293b;border-radius:8px;display:flex;align-items:center;justify-content:center"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7.5 4.27 9 5.15"/><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg></div>
<span style="font-size:16px;font-weight:700;color:#1f2937">入库码:PCKK2606151065</span>
<span id="domestic-inbound-detail-code" style="font-size:16px;font-weight:700;color:#1f2937">入库码:PCKK2606151065</span>
</div>
<!-- Top Tabs -->
@@ -1044,7 +1054,7 @@
<div id="detail-fee" class="detail-panel" style="display:none">
<div class="inbound-stats-bar" style="margin-bottom:12px">
<span class="selected-info">已选择 0项 <a href="javascript:void(0)">清空</a></span>
<span class="total-info"><a href="javascript:void(0)">🔄刷新</a> | <a href="javascript:void(0)">⚙️设置</a></span>
<span class="total-info" style="display:flex;align-items:center;gap:12px"><button type="button" class="btn btn-sm btn-primary" onclick="openDomesticInboundFeeModal('pickup')">新增费用</button><a href="javascript:void(0)" onclick="renderDomesticInboundFees()">刷新</a> | <a href="javascript:void(0)">设置</a></span>
</div>
<div class="inbound-table-container">
<table>
@@ -1076,14 +1086,11 @@
<th>账单编号</th>
<th>结算起始日</th>
<th>结算结束日</th>
<th>付款凭证</th>
<th>备注</th>
</tr>
</thead>
<tbody>
<tr><td colspan="25" style="padding:60px 0;text-align:center;color:#9ca3af;border:none">
<div style="font-size:40px;margin-bottom:12px">📂</div>
<div style="font-size:13px">暂无数据</div>
</td></tr>
</tbody>
<tbody id="domestic-inbound-fee-body"></tbody>
</table>
</div>
</div>
@@ -1329,6 +1336,12 @@
<label style="display:flex;align-items:center;gap:6px;cursor:pointer"><input type="radio" name="detainFlag" value="0" checked style="accent-color:#3b82f6;width:16px;height:16px"/><span>否</span></label>
</div>
<div style="margin-bottom:18px;display:flex;align-items:center;gap:16px;font-size:14px;color:#374151">
<span style="font-weight:600;white-space:nowrap">箱子是否计费:</span>
<label style="display:flex;align-items:center;gap:6px;cursor:pointer"><input type="radio" name="boxChargeFlag" value="1" style="accent-color:#3b82f6;width:16px;height:16px"/><span>是</span></label>
<label style="display:flex;align-items:center;gap:6px;cursor:pointer"><input type="radio" name="boxChargeFlag" value="0" checked style="accent-color:#3b82f6;width:16px;height:16px"/><span>否</span></label>
</div>
<div style="margin-bottom:18px;font-size:14px;color:#374151">
<span style="font-weight:600;display:block;margin-bottom:8px">扣货理由:</span>
<textarea id="detainReason" placeholder="请输入扣货理由" style="width:100%;min-height:80px;padding:10px 14px;border:1px solid #d1d5db;border-radius:6px;font-size:13.5px;outline:none;resize:vertical;font-family:inherit;color:#374151"></textarea>
@@ -1784,56 +1797,48 @@
<div class="pagination"><span>共 18 条</span><button class="page-btn active">1</button><button class="page-btn">2</button><button class="page-btn">→</button></div>
</div></div>
<!-- ==================== CLEARANCE LIST ==================== -->
<div class="page" id="page-overseas-location">
<div class="breadcrumb">首页 <span class="sep">&gt;</span> 海外仓库存 <span class="sep">&gt;</span> <strong>库位管理</strong></div>
<!-- ==================== DOMESTIC LOCATION MANAGEMENT ==================== -->
<div class="page" id="page-domestic-location">
<div class="breadcrumb">首页 <span class="sep">&gt;</span> 国内仓 <span class="sep">&gt;</span> <strong>库位管理</strong></div>
<div class="table-card">
<div class="table-toolbar"><span class="title">库位管理</span><div class="toolbar-actions"><button class="btn btn-primary" onclick="openLocModal(-1)">+ 新增库位</button><button class="btn btn-default">导出Excel</button></div></div>
<div class="search-row" style="flex-wrap:wrap;gap:8px">
<select id="loc-wh" style="width:160px"><option value="">全部海外仓</option><option>洛杉矶长滩海外仓</option><option>纽约海外仓</option><option>汉堡海外仓</option></select>
<select id="loc-zone" style="width:140px"><option value="">全部库区</option><option>常规货</option><option>一件代发</option><option>海派</option></select>
<input id="loc-code" placeholder="库位编码" style="width:160px"/>
<select id="loc-status" style="width:120px"><option value="">全部状态</option><option>启用</option><option>禁用</option><option>冻结</option></select>
<button class="btn btn-sm btn-primary" onclick="locSearch()">查询</button>
<button class="btn btn-sm btn-default" onclick="locReset()">重置</button>
<div class="table-toolbar"><span class="title">库位管理</span><div class="toolbar-actions"><button class="btn btn-primary" onclick="openDomesticLocationModal(-1)">+ 新增库位</button><button class="btn btn-default" onclick="exportLocationData('domestic')">导出Excel</button></div></div>
<div class="search-row location-search-row">
<select id="dom-loc-wh"><option value="">全部国内仓</option><option>上海外高桥仓</option><option>深圳盐田仓</option><option>宁波北仑仓</option></select>
<select id="dom-loc-zone"><option value="">全部库区</option><option>常规货</option><option>一件代发</option><option>海派</option></select>
<input id="dom-loc-code" placeholder="库位编码"/>
<select id="dom-loc-status"><option value="">全部状态</option><option>闲置</option><option>占用</option></select>
<button class="btn btn-sm btn-primary" onclick="searchDomesticLocation()">查询</button>
<button class="btn btn-sm btn-default" onclick="resetDomesticLocation()">重置</button>
</div>
<div style="overflow-x:auto">
<table style="min-width:1160px;font-size:12px"><thead><tr style="background:#fafafa;color:#666">
<th style="padding:9px 10px;text-align:left;white-space:nowrap">库位编码</th>
<th style="padding:9px 10px;text-align:left;white-space:nowrap">仓库</th>
<th style="padding:9px 10px;text-align:left;white-space:nowrap">库区</th>
<th style="padding:9px 10px;text-align:left;white-space:nowrap">库位类型</th>
<th style="padding:9px 10px;text-align:right;white-space:nowrap">最大承重(kg)</th>
<th style="padding:9px 10px;text-align:left;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;width:120px">操作</th>
</tr></thead><tbody id="tbody-overseas-location"></tbody></table>
<div class="location-table-scroll">
<table><thead><tr><th>操作</th><th>库位编码</th><th>仓库</th><th>库区</th><th>库位类型</th><th>最大承重(kg)</th><th>状态</th><th>备注</th>
</tr></thead><tbody id="tbody-domestic-location"></tbody></table>
</div>
<div class="pagination"><span id="loc-total">共 0 条</span><button class="page-btn active">1</button></div>
<div class="pagination"><span id="dom-loc-total">共 0 条</span><button class="page-btn active">1</button></div>
</div></div>
<!-- 库位新增/编辑弹窗 -->
<div class="modal-overlay" id="modal-location">
<div class="modal" style="max-width:560px"><div class="modal-header"><span id="loc-modal-title">新增库位</span><span onclick="hideModal('modal-location')" style="cursor:pointer;font-size:20px">&times;</span></div>
<!-- 国内仓库位新增/编辑弹窗 -->
<div class="modal-overlay" id="modal-domestic-location">
<div class="modal" style="max-width:560px"><div class="modal-header"><span id="dom-loc-modal-title">新增库位</span><span onclick="hideModal('modal-domestic-location')" 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="loc-code-in" placeholder="如 A1"/></div>
<div class="form-group"><label>仓库 *</label><select id="loc-wh-in"><option>洛杉矶长滩海外仓</option><option>纽约海外仓</option><option>汉堡海外仓</option></select></div>
<div class="form-group"><label>库位编码 *</label><input id="dom-loc-code-in" placeholder="如 A1"/></div>
<div class="form-group"><label>仓库 *</label><select id="dom-loc-wh-in"><option>上海外高桥仓</option><option>深圳盐田仓</option><option>宁波北仑仓</option></select></div>
</div>
<div class="form-row">
<div class="form-group"><label>库区 *</label><select id="loc-zone-in"><option>常规货</option><option>一件代发</option><option>海派</option></select></div>
<div class="form-group"><label>库位类型 *</label><select id="loc-type-in"><option>货架位</option><option>托盘位</option><option>地堆位</option><option>流利架位</option></select></div>
<div class="form-group"><label>库区 *</label><select id="dom-loc-zone-in"><option>常规货</option><option>一件代发</option><option>海派</option></select></div>
<div class="form-group"><label>库位类型 *</label><select id="dom-loc-type-in"><option>货架位</option><option>托盘位</option><option>地堆位</option><option>流利架位</option></select></div>
</div>
<div class="form-row">
<div class="form-group"><label>最大承重(kg)</label><input id="loc-weight-in" type="number" min="0" placeholder="500"/></div>
<div class="form-group"><label>状态</label><select id="loc-status-in"><option>闲置</option><option>占用</option></select></div>
<div class="form-group"><label>最大承重(kg)</label><input id="dom-loc-weight-in" type="number" min="0" placeholder="500"/></div>
<div class="form-group"><label>状态</label><select id="dom-loc-status-in"><option>闲置</option><option>占用</option></select></div>
</div>
<div class="form-row">
<div class="form-group"><label>备注</label><input id="loc-remark-in" placeholder="选填"/></div>
<div class="form-group"><label>备注</label><input id="dom-loc-remark-in" placeholder="选填"/></div>
<div class="form-group" style="visibility:hidden"></div>
</div>
</div>
<div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-location')">取消</button><button class="btn btn-primary" onclick="saveLocation()">保存</button></div></div></div>
<div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-domestic-location')">取消</button><button class="btn btn-primary" onclick="saveDomesticLocation()">保存</button></div></div></div>
<!-- ==================== DOMESTIC WAREHOUSE - 库存管理 ==================== -->
<div class="page" id="page-domestic-stock-management">
@@ -1939,9 +1944,10 @@
<th style="padding:9px 10px;text-align:left;white-space:nowrap">托盘类型</th>
<th style="padding:9px 10px;text-align:right;white-space:nowrap">托盘容积(m³)</th>
<th style="padding:9px 10px;text-align:right;white-space:nowrap">托盘重量(kg)</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:left;white-space:nowrap">备注</th>
<th style="padding:9px 10px;text-align:center;white-space:nowrap;width:120px">操作</th>
<th style="padding:9px 10px;text-align:center;white-space:nowrap;width:170px">操作</th>
</tr></thead><tbody id="tbody-pallet-manage"></tbody></table>
</div>
<div class="pagination"><span id="pallet-total">共 0 条</span><button class="page-btn active">1</button></div>
@@ -1960,12 +1966,27 @@
<div class="form-group"><label>托盘重量(kg) *</label><input id="pallet-weight-in" type="number" min="0" step="0.01" placeholder="如 22.5"/></div>
</div>
<div class="form-row">
<div class="form-group"><label>采购价格(元) *</label><input id="pallet-purchase-price-in" type="number" min="0" step="0.01" placeholder="如 120.00"/></div>
<div class="form-group"><label>状态</label><select id="pallet-status-in"><option>闲置</option><option>占用</option></select></div>
</div>
<div class="form-row">
<div class="form-group"><label>备注</label><input id="pallet-remark-in" placeholder="选填"/></div>
</div>
</div>
<div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-pallet')">取消</button><button class="btn btn-primary" onclick="savePallet()">保存</button></div></div></div>
<!-- 国内仓托盘详情 -->
<div class="page" id="page-pallet-detail">
<div class="breadcrumb">首页 <span class="sep">&gt;</span> 国内仓 <span class="sep">&gt;</span> 托盘管理 <span class="sep">&gt;</span> <strong>详情</strong></div>
<div class="table-card fbd-card">
<div class="fbd-head">
<div><div class="fbd-title">托盘详情</div><div class="fbd-subtitle">托盘编码 <strong id="pallet-detail-no">—</strong></div></div>
<div class="fbd-actions"><span id="pallet-detail-status" class="tag tag-gray">—</span><button class="btn btn-default" onclick="backToPalletManage()">返回</button></div>
</div>
<div class="fbd-body"><div class="fbd-grid" id="pallet-detail-content"></div></div>
</div>
</div>
<!-- 装柜清单备注编辑弹窗 -->
<div class="modal-overlay" id="modal-mf-note">
<div class="modal" style="max-width:620px"><div class="modal-header"><span id="mf-note-title">备注信息</span><span onclick="hideModal('modal-mf-note')" style="cursor:pointer;font-size:20px">&times;</span></div>
@@ -4,6 +4,49 @@
<div class="inventory-management-panels" id="fba-stock-management-panels"></div>
</div>
<!-- ==================== OVERSEAS LOCATION MANAGEMENT ==================== -->
<div class="page" id="page-overseas-location">
<div class="breadcrumb">首页 <span class="sep">&gt;</span> 海外仓 <span class="sep">&gt;</span> <strong>库位管理</strong></div>
<div class="table-card">
<div class="table-toolbar"><span class="title">库位管理</span><div class="toolbar-actions"><button class="btn btn-primary" onclick="openOverseasLocationModal(-1)">+ 新增库位</button><button class="btn btn-default" onclick="exportLocationData('overseas')">导出Excel</button></div></div>
<div class="search-row location-search-row">
<select id="ovs-loc-wh"><option value="">全部海外仓</option><option>洛杉矶长滩海外仓</option><option>纽约海外仓</option><option>汉堡海外仓</option></select>
<select id="ovs-loc-zone"><option value="">全部库区</option><option>常规货</option><option>一件代发</option><option>海派</option></select>
<input id="ovs-loc-code" placeholder="库位编码"/>
<select id="ovs-loc-status"><option value="">全部状态</option><option>闲置</option><option>占用</option></select>
<button class="btn btn-sm btn-primary" onclick="searchOverseasLocation()">查询</button>
<button class="btn btn-sm btn-default" onclick="resetOverseasLocation()">重置</button>
</div>
<div class="location-table-scroll">
<table><thead><tr><th>操作</th><th>库位编码</th><th>仓库</th><th>库区</th><th>库位类型</th><th>最大承重(kg)</th><th>状态</th><th>备注</th>
</tr></thead><tbody id="tbody-overseas-location"></tbody></table>
</div>
<div class="pagination"><span id="ovs-loc-total">共 0 条</span><button class="page-btn active">1</button></div>
</div></div>
<!-- 海外仓库位新增/编辑弹窗 -->
<div class="modal-overlay" id="modal-overseas-location">
<div class="modal" style="max-width:560px"><div class="modal-header"><span id="ovs-loc-modal-title">新增库位</span><span onclick="hideModal('modal-overseas-location')" 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="ovs-loc-code-in" placeholder="如 A1"/></div>
<div class="form-group"><label>仓库 *</label><select id="ovs-loc-wh-in"><option>洛杉矶长滩海外仓</option><option>纽约海外仓</option><option>汉堡海外仓</option></select></div>
</div>
<div class="form-row">
<div class="form-group"><label>库区 *</label><select id="ovs-loc-zone-in"><option>常规货</option><option>一件代发</option><option>海派</option></select></div>
<div class="form-group"><label>库位类型 *</label><select id="ovs-loc-type-in"><option>货架位</option><option>托盘位</option><option>地堆位</option><option>流利架位</option></select></div>
</div>
<div class="form-row">
<div class="form-group"><label>最大承重(kg)</label><input id="ovs-loc-weight-in" type="number" min="0" placeholder="500"/></div>
<div class="form-group"><label>状态</label><select id="ovs-loc-status-in"><option>闲置</option><option>占用</option></select></div>
</div>
<div class="form-row">
<div class="form-group"><label>备注</label><input id="ovs-loc-remark-in" placeholder="选填"/></div>
<div class="form-group" style="visibility:hidden"></div>
</div>
</div>
<div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-overseas-location')">取消</button><button class="btn btn-primary" onclick="saveOverseasLocation()">保存</button></div></div></div>
<!-- ==================== INVENTORY OVERVIEW (统一库存总览) ==================== -->
<div class="page" id="page-inventory-overview">
<div class="breadcrumb">首页 <span class="sep">&gt;</span> 海外仓 <span class="sep">&gt;</span> <strong>库存管理</strong></div>
@@ -28,14 +71,12 @@
<div class="wms-search-actions"><button class="btn btn-sm btn-primary" onclick="unifiedInventorySearch()">查询</button><button class="btn btn-sm btn-default" onclick="unifiedInventoryReset()">重置</button></div>
</div>
<div style="overflow-x:auto">
<table style="min-width:2140px;font-size:12px"><thead><tr style="background:#fafafa;color:#666">
<table style="min-width:1860px;font-size:12px"><thead><tr style="background:#fafafa;color:#666">
<th style="padding:9px 10px;text-align:center;white-space:nowrap;width:90px">操作</th>
<th style="padding:9px 10px;text-align:left;white-space:nowrap">客户</th>
<th style="padding:9px 10px;text-align:left;white-space:nowrap">海外仓</th>
<th style="padding:9px 10px;text-align:left;white-space:nowrap">SKU编码</th>
<th style="padding:9px 10px;text-align:left;white-space:nowrap">中文品名</th>
<th style="padding:9px 10px;text-align:right;white-space:nowrap">批次数</th>
<th style="padding:9px 10px;text-align:right;white-space:nowrap">库位数</th>
<th style="padding:9px 10px;text-align:right;white-space:nowrap">在库总量</th>
<th style="padding:9px 10px;text-align:right;white-space:nowrap">可用库存</th>
<th style="padding:9px 10px;text-align:right;white-space:nowrap">暂扣库存</th>
@@ -439,7 +480,13 @@
#page-fba-inbound-order .fbio-list-actions{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 20px;border-top:1px solid #eef2f7;border-bottom:1px solid #e5e7eb;background:#fafbfc}
#page-fba-inbound-order .fbio-list-action-buttons{display:flex;align-items:center;justify-content:flex-end;gap:8px}
#page-fba-inbound-order .fbio-list-action-buttons .btn{min-width:76px}
@media(max-width:720px){#page-fba-inbound-order .fbio-list-actions{align-items:flex-start;flex-direction:column;padding:10px 14px}#page-fba-inbound-order .fbio-list-action-buttons{width:100%;justify-content:flex-start}}
.fbd-inbound-fee-table th,.fbd-inbound-fee-table td{text-align:center!important}
.fbd-receivable-fee-table th,.fbd-receivable-fee-table td{text-align:center!important}
.fbio-edit-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px 20px}
.fbio-edit-grid .form-group{min-width:0;margin:0}
.fbio-edit-grid .form-group input,.fbio-edit-grid .form-group select{width:100%;box-sizing:border-box}
.fbio-readonly-input{background:#f5f7fa!important;color:#64748b!important;cursor:not-allowed}
@media(max-width:720px){#page-fba-inbound-order .fbio-list-actions{align-items:flex-start;flex-direction:column;padding:10px 14px}#page-fba-inbound-order .fbio-list-action-buttons{width:100%;justify-content:flex-start}.fbio-edit-grid{grid-template-columns:1fr}}
</style>
<div class="page" id="page-fba-inbound-order">
<div class="breadcrumb">首页 <span class="sep">&gt;</span> 海外仓 <span class="sep">&gt;</span> <strong>入库单</strong></div>
@@ -476,7 +523,8 @@
</div>
<div class="pagination"><span id="fbio-total">共 0 条</span><button class="page-btn active">1</button></div>
</div></div>
<div class="modal-overlay" id="modal-fbio-appointment"><div class="modal" style="max-width:720px"><div class="modal-header"><span>预约</span><span onclick="hideModal('modal-fbio-appointment')" style="cursor:pointer;font-size:20px">&times;</span></div><div class="modal-body"><div id="fbio-appointment-summary" class="lmo-modal-note">请选择入库单</div><div class="form-row"><div class="form-group"><label>预约号 *</label><input id="fbio-appointment-no" placeholder="请输入预约号"></div><div class="form-group"><label>预约日期 *</label><input id="fbio-appointment-date" type="datetime-local" step="60"></div></div><div class="form-row"><div class="form-group"><label>预约状态 *</label><select id="fbio-appointment-status"><option>预约成功</option><option>预约中</option></select></div><div class="form-group"><label>预约备注</label><input id="fbio-appointment-remark" placeholder="请输入预约备注"></div></div></div><div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-fbio-appointment')">取消</button><button class="btn btn-primary" onclick="saveFbioBatchAppointment()">保存预约</button></div></div></div>
<div class="modal-overlay" id="modal-fbio-appointment"><div class="modal" style="max-width:720px"><div class="modal-header"><span>预约</span><span onclick="hideModal('modal-fbio-appointment')" style="cursor:pointer;font-size:20px">&times;</span></div><div class="modal-body"><div id="fbio-appointment-summary" class="lmo-modal-note">请选择入库单</div><div class="form-row"><div class="form-group"><label>预约号 *</label><input id="fbio-appointment-no" placeholder="请输入预约号"></div><div class="form-group"><label>预约送仓时间 *</label><input id="fbio-appointment-date" type="datetime-local" step="60"></div></div><div class="form-row"><div class="form-group"><label>预计装车时间 *</label><input id="fbio-appointment-loading-time" type="datetime-local" step="60"></div><div class="form-group"><label>预约状态 *</label><select id="fbio-appointment-status"><option>预约成功</option><option>预约中</option></select></div></div><div class="form-row"><div class="form-group" style="flex:1"><label>预约备注</label><input id="fbio-appointment-remark" placeholder="请输入预约备注"></div></div></div><div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-fbio-appointment')">取消</button><button class="btn btn-primary" onclick="saveFbioBatchAppointment()">保存预约</button></div></div></div>
<div class="modal-overlay" id="modal-fbio-edit"><div class="modal" style="max-width:880px;width:94%"><div class="modal-header"><span>编辑入库单</span><span onclick="hideModal('modal-fbio-edit')" style="cursor:pointer;font-size:20px">&times;</span></div><div class="modal-body"><div class="fbio-edit-grid"><div class="form-group"><label>入库单号</label><input id="fbio-edit-inbound-no" class="fbio-readonly-input" readonly aria-readonly="true"></div><div class="form-group"><label>入仓码 *</label><input id="fbio-edit-warehouse-code" maxlength="64" placeholder="请输入入仓码"></div><div class="form-group"><label>类型 *</label><select id="fbio-edit-type"><option>国内订单</option><option>海外订单</option></select></div><div class="form-group"><label>海外仓 *</label><select id="fbio-edit-wh"><option>洛杉矶长滩海外仓</option><option>纽约海外仓</option><option>汉堡海外仓</option></select></div><div class="form-group"><label>FBA 仓库 *</label><select id="fbio-edit-fba-warehouse"><option>FBA ONT8</option><option>FBA ABE8</option><option>DE Frankfurt</option><option>FBA LGB8</option><option>FBA JFK8</option></select></div><div class="form-group"><label>货主 *</label><input id="fbio-edit-owner" maxlength="100" placeholder="请输入货主"></div><div class="form-group"><label>总箱数 *</label><input id="fbio-edit-total-boxes" type="number" min="0" step="1"></div><div class="form-group"><label>计费体积(m³) *</label><input id="fbio-edit-charge-vol" type="number" min="0" step="0.01"></div><div class="form-group"><label>总托盘数 *</label><input id="fbio-edit-pallet-total" type="number" min="0" step="1"></div><div class="form-group"><label>状态 *</label><select id="fbio-edit-status"><option>待审核</option><option>已审核</option><option>已入库</option></select></div><div class="form-group"><label>预约送仓时间</label><input id="fbio-edit-appointment-date" type="datetime-local" step="60"></div><div class="form-group"><label>预计装车时间</label><input id="fbio-edit-loading-time" type="datetime-local" step="60"></div></div></div><div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-fbio-edit')">取消</button><button class="btn btn-primary" onclick="saveFbioEdit()">保存</button></div></div></div>
<div class="modal-overlay" id="modal-fbio-schedule"><div class="modal" style="max-width:720px"><div class="modal-header"><span>批量排车</span><span onclick="hideModal('modal-fbio-schedule')" style="cursor:pointer;font-size:20px">&times;</span></div><div class="modal-body"><div id="fbio-schedule-summary" class="lmo-modal-note">排车前必须完成 FBA 预约</div><div class="form-row"><div class="form-group"><label>车次号 *</label><input id="fbio-schedule-vehicle-no" maxlength="64" placeholder="请输入车次号"></div><div class="form-group"><label>车牌号 *</label><input id="fbio-schedule-plate" placeholder="请输入车牌号"></div></div><div class="form-row"><div class="form-group"><label>司机</label><input id="fbio-schedule-driver" placeholder="请输入司机姓名"></div><div class="form-group"><label>计划发车时间 *</label><input id="fbio-schedule-depart" type="datetime-local"></div></div></div><div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-fbio-schedule')">取消</button><button class="btn btn-primary" onclick="saveFbioBatchSchedule()">保存排车</button></div></div></div>
<!-- ==================== FBA RETURN - 换标单 ==================== -->
@@ -491,11 +539,12 @@
<button class="btn btn-sm btn-default" onclick="fbroReset()">重置</button>
</div>
<div style="overflow-x:auto">
<table style="min-width:1180px;font-size:12px"><thead><tr style="background:#fafafa;color:#666">
<table style="min-width:1280px;font-size:12px"><thead><tr style="background:#fafafa;color:#666">
<th style="padding:9px 10px;text-align:left;white-space:nowrap">换标单号</th>
<th style="padding:9px 10px;text-align:left;white-space:nowrap">商品SKU</th>
<th style="padding:9px 10px;text-align:left;white-space:nowrap">中文品名</th>
<th style="padding:9px 10px;text-align:right;white-space:nowrap">箱数</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>
@@ -508,18 +557,20 @@
<div class="section-title" style="margin-top:0">订单信息</div>
<div class="form-row"><div class="form-group"><label>换标单号</label><input id="fbro-form-order-no" readonly value="系统自动生成" style="background:#f8fafc;color:#64748b"></div><div class="form-group"><label>关联订单 *</label><input id="fbro-form-related-order" maxlength="64" placeholder="请输入关联订单号"></div></div>
<div class="form-row"><div class="form-group"><label>商品SKU *</label><input id="fbro-form-sku" maxlength="64" placeholder="请输入商品SKU"></div><div class="form-group"><label>中文品名 *</label><input id="fbro-form-cn-name" maxlength="100" placeholder="请输入中文品名"></div></div>
<div class="form-row"><div class="form-group"><label>箱数 *</label><input id="fbro-form-boxes" type="number" min="1" step="1" placeholder="请输入箱数"></div><div class="form-group"><label>状态</label><select id="fbro-form-status"><option>待处理</option><option>处理中</option><option>已完成</option></select></div></div>
<div class="form-row"><div class="form-group"><label>箱数 *</label><input id="fbro-form-boxes" type="number" min="1" step="1" placeholder="请输入箱数"></div><div class="form-group"><label>预算总价(USD) *</label><input id="fbro-form-budget-total" type="number" min="0" step="0.01" placeholder="请输入预算总价"></div></div>
<div class="form-row"><div class="form-group"><label>状态</label><select id="fbro-form-status"><option>待处理</option><option>处理中</option><option>已完成</option></select></div><div class="form-group"></div></div>
<div class="section-title">作业要求</div>
<div class="form-row"><div class="form-group"><label>贴标要求 *</label><select id="fbro-form-label-req"><option value="">请选择贴标要求</option><option>全覆盖换标</option><option>加贴新标</option><option>移除原标+换标</option></select></div><div class="form-group"><label>上传文件</label><input id="fbro-form-file" type="file" accept=".pdf,.jpg,.jpeg,.png,.xls,.xlsx"></div></div>
<div class="form-row" id="fbro-form-cost-row" style="display:none"><div class="form-group"><label>预算报价(USD)</label><input id="fbro-form-relabel-cost" type="number" min="0" step="0.01" placeholder="请输入预算报价"></div><div class="form-group"></div></div>
<div class="form-group"><label>备注</label><textarea id="fbro-form-remark" rows="3" maxlength="500" placeholder="请输入备注"></textarea></div>
</div><div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-fba-relabel-form')">取消</button><button class="btn btn-primary" onclick="saveFbaRelabelOrder()">保存换标单</button></div></div></div>
</div><div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-fba-relabel-form')">取消</button><button class="btn btn-primary" id="fbro-form-submit" onclick="saveFbaRelabelOrder()">保存换标单</button></div></div></div>
<!-- ==================== FBA RETURN - 出库单 ==================== -->
<style>
#page-fba-outbound-order .fboo-table-wrap{overflow:auto;max-width:100%;border-top:1px solid #eef1f5}
#page-fba-outbound-order .fboo-table{table-layout:fixed;min-width:1500px}
#page-fba-outbound-order .fboo-table{table-layout:fixed;min-width:1240px}
#page-fba-outbound-order .fboo-table th,#page-fba-outbound-order .fboo-table td{padding:10px 12px;vertical-align:middle}
#page-fba-outbound-order .fboo-table th:nth-child(7),#page-fba-outbound-order .fboo-table th:nth-child(8),#page-fba-outbound-order .fboo-table th:nth-child(9){text-align:right}
#page-fba-outbound-order .fboo-table th:nth-child(3),#page-fba-outbound-order .fboo-table th:nth-child(4),#page-fba-outbound-order .fboo-table th:nth-child(5),#page-fba-outbound-order .fboo-table th:nth-child(6),#page-fba-outbound-order .fboo-table th:nth-child(7),#page-fba-outbound-order .fboo-table th:nth-child(8){text-align:right}
#page-fba-outbound-order .fboo-operation{position:sticky;right:0;z-index:2;background:#fff;box-shadow:-8px 0 12px rgba(15,23,42,.04)}
#page-fba-outbound-order thead .fboo-operation{z-index:3;background:#fafafa}
</style>
@@ -530,21 +581,19 @@
<div class="search-row" style="flex-wrap:wrap;gap:8px">
<input id="fboo-no" placeholder="出库单号" style="width:170px"/>
<select id="fboo-dest" style="width:150px"><option value="">全部目的仓</option><option>FBA ONT8</option><option>FBA CLT2</option><option>FBA MEM1</option><option>DE Frankfurt</option></select>
<select id="fboo-status" style="width:130px"><option value="">全部状态</option><option>待发货</option><option>已发货</option><option>已签收</option></select>
<select id="fboo-status" style="width:130px"><option value="">全部状态</option><option>待发货</option><option>已发货</option><option>已签收</option><option>已取消</option></select>
<button class="btn btn-sm btn-primary" onclick="fbooSearch()">查询</button>
<button class="btn btn-sm btn-default" onclick="fbooReset()">重置</button>
</div>
<div class="fboo-table-wrap">
<table class="fboo-table" style="min-width:1500px;font-size:12px"><thead><tr style="background:#fafafa;color:#666">
<table class="fboo-table" style="min-width:1240px;font-size:12px"><thead><tr style="background:#fafafa;color:#666">
<th style="padding:9px 10px;text-align:left;white-space:nowrap">出库单号</th>
<th style="padding:9px 10px;text-align:left;white-space:nowrap">出库类型</th>
<th style="padding:9px 10px;text-align:left;white-space:nowrap">目的仓</th>
<th style="padding:9px 10px;text-align:left;white-space:nowrap">转仓</th>
<th style="padding:9px 10px;text-align:left;white-space:nowrap">商品SKU</th>
<th style="padding:9px 10px;text-align:left;white-space:nowrap">中文品名</th>
<th style="padding:9px 10px;text-align:right;white-space:nowrap">出库数量</th>
<th style="padding:9px 10px;text-align:right;white-space:nowrap">箱数</th>
<th style="padding:9px 10px;text-align:right;white-space:nowrap">总净重(kg)</th>
<th style="padding:9px 10px;text-align:right;white-space:nowrap">SKU种数</th>
<th style="padding:9px 10px;text-align:right;white-space:nowrap">总托数</th>
<th style="padding:9px 10px;text-align:right;white-space:nowrap">总箱数</th>
<th style="padding:9px 10px;text-align:right;white-space:nowrap">总重量(kg)</th>
<th style="padding:9px 10px;text-align:right;white-space:nowrap">总体积(m3)</th>
<th style="padding:9px 10px;text-align:center;white-space:nowrap">状态</th>
<th class="fboo-operation" style="padding:9px 10px;text-align:center;white-space:nowrap;width:120px">操作</th>
</tr></thead><tbody id="tbody-fba-outbound-order"></tbody></table>
@@ -562,23 +611,38 @@
<div class="form-group" style="margin-top:14px"><label>备注</label><textarea id="fboo-create-remark" class="fboo-remark" placeholder="请输入出库备注"></textarea></div>
</div><div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-fboo-create')">取消</button><button class="btn btn-primary" onclick="saveFbaOutboundOrder()">确认出库</button></div></div></div>
<!-- 海外仓出库单 - 编辑 -->
<div class="modal-overlay" id="modal-fboo-edit"><div class="modal" style="max-width:1180px"><div class="modal-header"><span>编辑出库单</span><span onclick="hideModal('modal-fboo-edit')" 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="fboo-edit-no" readonly style="background:#f8fafc;color:#64748b"></div><div class="form-group"><label>出库类型</label><input id="fboo-edit-type" readonly style="background:#f8fafc;color:#64748b"></div></div>
<div class="form-group"><label id="fboo-edit-destination-label">目的信息 *</label><input id="fboo-edit-destination" placeholder="请输入目的信息"></div>
<div id="fboo-edit-note" class="fboo-create-note">原商品数量会先释放,再按本次编辑结果重新占用库存。</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px"><strong style="font-size:13px;color:#334155">选择出库商品</strong><span style="font-size:12px;color:#8491a3">可调整原商品或添加其他库存商品</span></div>
<div class="fboo-inventory-wrap"><table><thead><tr><th style="width:54px;text-align:center">选择</th><th>SKU</th><th>商品名称</th><th>批次号</th><th>托盘号</th><th>仓库</th><th style="text-align:right">可编辑库存</th><th style="text-align:right">出库数量</th></tr></thead><tbody id="tbody-fboo-edit-inventory"></tbody></table></div>
<div class="fboo-create-summary"><span>已选 <strong id="fboo-edit-selected-count">0</strong> 个商品</span><span>出库总数量:<strong id="fboo-edit-selected-qty">0</strong></span></div>
<div class="form-group" style="margin-top:14px"><label>备注</label><textarea id="fboo-edit-remark" class="fboo-remark" placeholder="请输入出库备注"></textarea></div>
</div><div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-fboo-edit')">取消</button><button id="fboo-edit-save" class="btn btn-primary" onclick="saveFbaOutboundEdit()">保存</button></div></div></div>
<!-- ==================== FBA RETURN - 托盘管理 ==================== -->
<div class="page" id="page-fba-pallet">
<div class="breadcrumb">首页 <span class="sep">&gt;</span> FBA退货 <span class="sep">&gt;</span> <strong>托盘管理</strong></div>
<div class="table-card">
<div class="table-toolbar"><span class="title">托盘管理</span><div class="toolbar-actions"><button class="btn btn-primary">+ 新增托盘</button><button class="btn btn-default">导出Excel</button></div></div>
<div class="table-toolbar"><span class="title">托盘管理</span><div class="toolbar-actions"><button class="btn btn-primary" onclick="openFbaPalletForm()">+ 新增托盘</button><button class="btn btn-default">导出Excel</button></div></div>
<div class="search-row" style="flex-wrap:wrap;gap:8px">
<input id="fbp-code" placeholder="托盘编码" style="width:150px"/>
<select id="fbp-type" style="width:140px"><option value="">全部类型</option><option>木托盘</option><option>塑料托盘</option><option>金属托盘</option><option>纸托盘</option><option>免熏蒸</option></select>
<select id="fbp-wh" style="width:160px"><option value="">全部仓库</option><option>洛杉矶长滩海外仓</option><option>纽约海外仓</option><option>汉堡海外仓</option></select>
<select id="fbp-status" style="width:130px"><option value="">全部状态</option><option>闲置</option><option>占用</option></select>
<button class="btn btn-sm btn-primary" onclick="fbpSearch()">查询</button>
<button class="btn btn-sm btn-default" onclick="fbpReset()">重置</button>
</div>
<div style="overflow-x:auto">
<table style="min-width:1080px;font-size:12px"><thead><tr style="background:#fafafa;color:#666">
<table style="min-width:1220px;font-size:12px"><thead><tr style="background:#fafafa;color:#666">
<th style="padding:9px 10px;text-align:left;white-space:nowrap">托盘编码</th>
<th style="padding:9px 10px;text-align:left;white-space:nowrap">托盘类型</th>
<th style="padding:9px 10px;text-align:left;white-space:nowrap">仓库</th>
<th style="padding:9px 10px;text-align:right;white-space:nowrap">容积(m³)</th>
<th style="padding:9px 10px;text-align:right;white-space:nowrap">托盘重量(kg)</th>
<th style="padding:9px 10px;text-align:right;white-space:nowrap">采购价格</th>
<th style="padding:9px 10px;text-align:left;white-space:nowrap">所属FBA仓</th>
<th style="padding:9px 10px;text-align:center;white-space:nowrap">状态</th>
<th style="padding:9px 10px;text-align:left;white-space:nowrap">备注</th>
@@ -588,14 +652,16 @@
<div class="pagination"><span id="fbp-total">共 0 条</span><button class="page-btn active">1</button></div>
</div></div>
<div class="modal-overlay" id="modal-fba-pallet-form"><div class="modal" style="max-width:760px"><div class="modal-header"><span id="fbp-form-title">新增托盘</span><span onclick="hideModal('modal-fba-pallet-form')" 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="fbp-form-code" maxlength="64" placeholder="请输入托盘编码"></div><div class="form-group"><label>托盘类型 *</label><select id="fbp-form-type"><option>木托盘</option><option>塑料托盘</option><option>金属托盘</option><option>纸托盘</option><option>免熏蒸</option></select></div></div><div class="form-row"><div class="form-group"><label>容积(m³) *</label><input id="fbp-form-volume" type="number" min="0.01" step="0.01" placeholder="请输入容积"></div><div class="form-group"><label>托盘重量(kg) *</label><input id="fbp-form-weight" type="number" min="0.01" step="0.01" required aria-required="true" placeholder="请输入托盘重量"></div></div><div class="form-row"><div class="form-group"><label>采购价格(USD) *</label><input id="fbp-form-price" type="number" min="0" step="0.01" placeholder="请输入采购价格"></div><div class="form-group" style="visibility:hidden"></div></div><div class="form-row"><div class="form-group"><label>仓库 *</label><select id="fbp-form-overseas-warehouse"><option>洛杉矶长滩海外仓</option><option>纽约海外仓</option><option>汉堡海外仓</option></select></div><div class="form-group"><label>所属 FBA 仓 *</label><select id="fbp-form-warehouse"><option>FBA ONT8</option><option>FBA CLT2</option><option>FBA MEM1</option><option>DE Frankfurt</option><option>FBA IND9</option></select></div></div><div class="form-row"><div class="form-group"><label>状态 *</label><select id="fbp-form-status"><option>闲置</option><option>占用</option></select></div><div class="form-group" style="visibility:hidden"></div></div><div class="form-group"><label>备注</label><textarea id="fbp-form-remark" placeholder="请输入备注"></textarea></div></div><div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-fba-pallet-form')">取消</button><button class="btn btn-primary" onclick="saveFbaPalletForm()">保存</button></div></div></div>
<!-- ==================== FBA RETURN - 箱号管理 ==================== -->
<div class="page" id="page-fba-carton">
<div class="breadcrumb">首页 <span class="sep">&gt;</span> FBA退货 <span class="sep">&gt;</span> <strong>箱号管理</strong></div>
<div class="table-card">
<div class="table-toolbar"><span class="title">箱号管理</span><div class="toolbar-actions"><button class="btn btn-primary">+ 新增箱号</button><button class="btn btn-default">导出Excel</button></div></div>
<div class="table-toolbar"><span class="title">箱号管理</span><div class="toolbar-actions"><button class="btn btn-primary" onclick="openFbaCartonForm()">+ 新增箱号</button><button class="btn btn-default" onclick="exportFbaCartons()">导出Excel</button></div></div>
<div class="search-row" style="flex-wrap:wrap;gap:8px">
<input id="fbc-carton" placeholder="箱号" style="width:170px"/>
<select id="fbc-wh" style="width:150px"><option value="">全部FBA仓</option><option>FBA ONT8</option><option>FBA CLT2</option><option>FBA MEM1</option><option>DE Frankfurt</option></select>
<select id="fbc-wh" style="width:150px"><option value="">全部FBA仓</option><option>FBA ONT8</option><option>FBA CLT2</option><option>FBA MEM1</option><option>DE Frankfurt</option><option>FBA IND9</option></select>
<select id="fbc-status" style="width:130px"><option value="">全部状态</option><option>在库</option><option>已出库</option></select>
<button class="btn btn-sm btn-primary" onclick="fbcSearch()">查询</button>
<button class="btn btn-sm btn-default" onclick="fbcReset()">重置</button>
@@ -618,13 +684,13 @@
<!-- ==================== OVERSEAS WAREHOUSE - 入库单详情 ==================== -->
<div class="page" id="page-fba-inbound-order-detail">
<div class="breadcrumb">首页 <span class="sep">&gt;</span> 海外仓 <span class="sep">&gt;</span> 入库单 <span class="sep">&gt;</span> <strong>详情</strong></div>
<div class="table-card fbd-card"><div class="fbd-head"><div><div class="fbd-title">入库单详情</div><div class="fbd-subtitle">入库单号 <strong id="fbd-inbound-no">—</strong></div></div><div class="fbd-actions"><span id="fbd-inbound-status" class="tag tag-gray">—</span><button class="btn btn-default" onclick="backToFbaInboundOrders()">返回</button></div></div><div class="fbd-body"><div class="fbd-grid" id="fbd-inbound-content"></div></div></div>
<div class="table-card fbd-card"><div class="fbd-head"><div><div class="fbd-title">入库单详情</div><div class="fbd-subtitle">入库单号 <strong id="fbd-inbound-no">—</strong></div></div><div class="fbd-actions"><span id="fbd-inbound-status" class="tag tag-gray">—</span><button class="btn btn-default" onclick="backToFbaInboundOrders()">返回</button></div></div><div class="fbd-body"><div class="fbd-grid" id="fbd-inbound-content"></div><section class="fbd-section fbd-section-wide" aria-labelledby="fbd-inbound-fee-title"><div class="fbd-section-title" id="fbd-inbound-fee-title">应收费用明细</div><div class="fbd-table-wrap"><table class="fbd-detail-table fbd-inbound-fee-table"><thead><tr><th style="width:70px">序号</th><th>费用项目</th><th style="width:100px">币种</th><th style="width:140px">金额</th><th>备注</th></tr></thead><tbody id="fbd-inbound-fees"><tr><td colspan="5" class="fbd-empty">暂无应收费用明细</td></tr></tbody><tfoot><tr><td colspan="3" style="padding:12px;font-weight:700;color:#374151">应收费用合计(USD)</td><td id="fbd-inbound-fee-total" style="padding:12px;font-weight:700;color:#111827">$0.00</td><td></td></tr></tfoot></table></div></section></div></div>
</div>
<!-- ==================== OVERSEAS WAREHOUSE - 出库单详情 ==================== -->
<div class="page" id="page-fba-outbound-order-detail">
<div class="breadcrumb">首页 <span class="sep">&gt;</span> 海外仓 <span class="sep">&gt;</span> 出库单 <span class="sep">&gt;</span> <strong>详情</strong></div>
<div class="table-card fbd-card"><div class="fbd-head"><div><div class="fbd-title">出库单详情</div><div class="fbd-subtitle">出库单号 <strong id="fbd-outbound-no">—</strong></div></div><div class="fbd-actions"><span id="fbd-outbound-status" class="tag tag-gray">—</span><button class="btn btn-default" onclick="backToFbaOutboundOrders()">返回</button></div></div><div class="fbd-body"><div class="fbd-grid" id="fbd-outbound-content"></div></div></div>
<div class="table-card fbd-card"><div class="fbd-head"><div><div class="fbd-title">出库单详情</div><div class="fbd-subtitle">出库单号 <strong id="fbd-outbound-no">—</strong></div></div><div class="fbd-actions"><span id="fbd-outbound-status" class="tag tag-gray">—</span><button class="btn btn-default" onclick="backToFbaOutboundOrders()">返回</button></div></div><div class="fbd-body"><div class="fbd-grid" id="fbd-outbound-content"></div><section class="fbd-section fbd-section-wide" aria-labelledby="fbd-outbound-sku-title"><div class="fbd-section-title" id="fbd-outbound-sku-title">托盘信息</div><div class="fbd-table-wrap"><table class="fbd-detail-table"><thead><tr><th>托盘号</th><th>入仓码</th><th>箱数</th><th>重量</th><th>体积</th></tr></thead><tbody id="fbd-outbound-sku-items"><tr><td colspan="5" class="fbd-empty">暂无托盘信息</td></tr></tbody></table></div></section><section class="fbd-section fbd-section-wide" aria-labelledby="fbd-outbound-fee-title"><div class="fbd-section-title" id="fbd-outbound-fee-title">应收费用明细</div><div class="fbd-table-wrap"><table class="fbd-detail-table fbd-receivable-fee-table"><thead><tr><th style="width:70px">序号</th><th>费用项目</th><th style="width:100px">币种</th><th style="width:140px">金额</th><th>备注</th></tr></thead><tbody id="fbd-outbound-fees"><tr><td colspan="5" class="fbd-empty">暂无应收费用明细</td></tr></tbody><tfoot><tr><td colspan="3" style="padding:12px;font-weight:700;color:#374151">应收费用合计(USD)</td><td id="fbd-outbound-fee-total" style="padding:12px;font-weight:700;color:#111827">$0.00</td><td></td></tr></tfoot></table></div></section></div></div>
</div>
<!-- ==================== OVERSEAS WAREHOUSE - 换标单详情 ==================== -->
@@ -748,6 +814,8 @@ FORM: 采用现有后台的列表加标准详情结构,保持高信息密度
-->
<style>
#page-last-mile-orders .lmo-search{gap:8px;align-items:center}
#page-last-mile-orders .lmo-warning-list{display:flex;flex-wrap:wrap;gap:4px;min-width:128px;max-width:260px}
#page-last-mile-orders .lmo-warning-tag{margin:0;white-space:nowrap}
#page-last-mile-orders .lmo-search-date-range{display:flex;align-items:center;gap:6px;color:#94a3b8}#page-last-mile-orders .lmo-search-date-range input{flex:0 0 150px;width:150px}#page-last-mile-orders .lmo-search-date-range span{font-size:12px}
#page-last-mile-orders .lmo-selection-summary{height:56px;display:flex;align-items:center;padding:0 14px;border-top:1px solid #e8edf3;border-bottom:1px solid #e8edf3;background:#fff;color:#64748b;font-size:12px}
#page-last-mile-orders .lmo-status-tabs{display:flex;align-items:center;gap:0;min-height:46px;padding:0 18px;border-bottom:1px solid #e5eaf1;background:#f8fafc;overflow-x:auto}
@@ -779,9 +847,17 @@ FORM: 采用现有后台的列表加标准详情结构,保持高信息密度
#modal-fboo-create .fboo-qty-input{width:92px;height:32px;padding:0 8px;border:1px solid #d7dee8;border-radius:6px;text-align:right;box-sizing:border-box}#modal-fboo-create .fboo-qty-input:disabled{background:#f4f6f8;color:#9ca3af}
#modal-fboo-create .fboo-create-summary{display:flex;justify-content:space-between;align-items:center;margin-top:12px;padding:11px 14px;border-radius:6px;background:#f8fafc;color:#526071;font-size:13px}#modal-fboo-create .fboo-create-summary strong{color:#2563eb;font-size:15px}
#modal-fboo-create .fboo-remark{width:100%;min-height:72px;padding:10px 12px;border:1px solid #d7dee8;border-radius:7px;box-sizing:border-box;resize:vertical;color:#334155;font:13px inherit}
#modal-fboo-edit .modal-body{padding:18px 22px 20px}#modal-fboo-edit .fboo-create-note{margin:12px 0 14px;padding:10px 12px;border-radius:6px;background:#f1f6ff;color:#4b6b9c;font-size:12px}
#modal-fboo-edit .fboo-inventory-wrap{max-height:330px;overflow:auto;border:1px solid #e5eaf1;border-radius:7px}#modal-fboo-edit .fboo-inventory-wrap table{min-width:980px}
#modal-fboo-edit .fboo-qty-input{width:92px;height:32px;padding:0 8px;border:1px solid #d7dee8;border-radius:6px;text-align:right;box-sizing:border-box}#modal-fboo-edit .fboo-qty-input:disabled{background:#f4f6f8;color:#9ca3af}
#modal-fboo-edit .fboo-create-summary{display:flex;justify-content:space-between;align-items:center;margin-top:12px;padding:11px 14px;border-radius:6px;background:#f8fafc;color:#526071;font-size:13px}#modal-fboo-edit .fboo-create-summary strong{color:#2563eb;font-size:15px}
#modal-fboo-edit .fboo-remark{width:100%;min-height:72px;padding:10px 12px;border:1px solid #d7dee8;border-radius:7px;box-sizing:border-box;resize:vertical;color:#334155;font:13px inherit}
.fboo-operation-disabled{color:#aeb6c2;cursor:not-allowed}
@media(max-width:600px){#modal-fboo-edit .modal{width:calc(100% - 24px)!important;max-width:none!important}#modal-fboo-edit .modal-body{padding:16px}#modal-fboo-edit .form-row{grid-template-columns:minmax(0,1fr);gap:12px;margin-bottom:12px}#modal-fboo-edit .form-group{min-width:0}#modal-fboo-edit .fboo-create-summary{gap:8px;align-items:flex-start;flex-direction:column}#modal-fboo-edit .modal-footer{padding:12px 16px;flex-wrap:wrap}}
.lmo-operation-disabled{color:#aeb6c2!important;cursor:not-allowed!important;pointer-events:none}
.lmo-detail-head{display:flex;align-items:center;gap:12px;margin-bottom:14px}.lmo-detail-head h2{margin:0;font-size:19px;color:#1f2937}.lmo-detail-head .lmo-detail-actions{display:flex;gap:8px;margin-left:auto;flex-wrap:wrap}.lmo-detail-head .btn:disabled{opacity:.45;cursor:not-allowed}
.lmo-detail-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;margin-top:14px}.lmo-section{background:#fff;border:1px solid #e5eaf1;border-radius:8px;overflow:hidden}.lmo-section.wide{grid-column:1/-1}.lmo-section-title{min-height:44px;padding:12px 16px;border-bottom:1px solid #e8edf3;box-sizing:border-box;font-size:14px;font-weight:700;color:#1f2937}.lmo-info-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));padding:4px 16px 12px}.lmo-info-item{display:grid;grid-template-columns:118px minmax(0,1fr);gap:10px;padding:10px 4px;border-bottom:1px solid #f0f2f5;font-size:13px}.lmo-info-item .label{color:#8491a3}.lmo-info-item .value{color:#273548;font-weight:500;word-break:break-word}.lmo-empty{padding:28px;text-align:center;color:#94a3b8;font-size:13px}
.lmo-departure-fee-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.lmo-departure-fee-hint{font-size:12px;font-weight:400;color:#8491a3}.lmo-departure-fee-scroll{overflow-x:auto}.lmo-departure-fee-table{width:100%;min-width:620px;border-collapse:collapse;font-size:12px}.lmo-departure-fee-table th{padding:10px 12px;background:#f8fafc;border-bottom:1px solid #e8edf3;color:#526071;font-weight:650;text-align:left;white-space:nowrap}.lmo-departure-fee-table td{padding:10px 12px;border-bottom:1px solid #f0f2f5;color:#334155;white-space:nowrap;vertical-align:middle}.lmo-departure-fee-table tbody tr:last-child td{border-bottom:0}.lmo-departure-fee-table .supplier-cell{color:#475569}.lmo-departure-fee-table .amount-cell{text-align:right;color:#1f5fbf;font-weight:700}.lmo-departure-fee-table tfoot td{border-top:1px solid #dce3ec;border-bottom:0;background:#f8fafc;font-weight:700}.lmo-departure-fee-table .total-label{text-align:right;color:#526071}.lmo-departure-fee-table .total-amount{color:#111827}.lmo-departure-fee-empty{padding:28px!important;text-align:center;color:#94a3b8!important}
.lmo-modal-note{margin-bottom:14px;padding:10px 12px;border:1px solid #dbeafe;border-radius:6px;background:#f7faff;color:#526071;font-size:12px}.lmo-modal-note.warning{border-color:#fde2b8;background:#fffaf0;color:#9a5b13}
.lmo-checkbox-option{display:flex;align-items:center;gap:8px;min-height:36px;color:#334155;font-size:13px;cursor:pointer}.lmo-checkbox-option input{width:16px;height:16px;margin:0;accent-color:#667eea}
@media(max-width:900px){.lmo-detail-grid{grid-template-columns:1fr}.lmo-section.wide{grid-column:auto}.lmo-detail-head{align-items:flex-start;flex-wrap:wrap}.lmo-detail-head .lmo-detail-actions{width:100%;margin-left:0}.lmo-info-grid{grid-template-columns:1fr}.lmo-field-selector-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
@@ -793,7 +869,7 @@ FORM: 采用现有后台的列表加标准详情结构,保持高信息密度
<div class="page" id="page-last-mile-orders">
<div class="breadcrumb">首页 <span class="sep">&gt;</span> 海外仓 <span class="sep">&gt;</span> <strong>尾程订单</strong></div>
<div class="table-card">
<div class="table-toolbar"><span class="title">尾程订单</span><div class="toolbar-actions"><button class="btn btn-default" onclick="openLastMileImport()">导入</button><button class="btn btn-default" onclick="openLastMileFieldSelector('export')">导出Excel</button><button class="btn btn-default" onclick="openLastMileBatchTransfer()">批量转仓</button><button class="btn btn-primary" onclick="openLastMileBatchConsolidate()">批量合托</button></div></div>
<div class="table-toolbar"><span class="title">尾程订单</span><div class="toolbar-actions"><button class="btn btn-default" onclick="openLastMileImport()">导入</button><button class="btn btn-default" onclick="openLastMileFieldSelector('export')">导出Excel</button><button class="btn btn-default" onclick="openLastMileBatchTransfer()">批量转仓</button></div></div>
<div class="lmo-status-tabs" role="tablist" aria-label="尾程订单状态">
<button type="button" class="lmo-status-tab active" data-lmo-tab="全部" role="tab" aria-selected="true" onclick="switchLastMileStatusTab('全部',this)">全部</button>
<button type="button" class="lmo-status-tab" data-lmo-tab="待分配" role="tab" aria-selected="false" onclick="switchLastMileStatusTab('待分配',this)">待分配</button>
@@ -803,7 +879,7 @@ FORM: 采用现有后台的列表加标准详情结构,保持高信息密度
<input id="lmo-first-leg-no" placeholder="入仓码" style="width:150px"/>
<select id="lmo-warehouse" style="width:160px"><option value="">全部海外仓</option><option>洛杉矶长滩海外仓</option><option>纽约海外仓</option><option>汉堡海外仓</option></select>
<select id="lmo-fba-warehouse" style="width:150px"><option value="">全部FBA仓库</option><option>FBA ONT8</option><option>FBA CLT2</option><option>FBA MEM1</option><option>FBA IND9</option><option>DE Frankfurt</option></select>
<select id="lmo-order-type" style="width:140px"><option value="">全部订单类型</option><option>一件代发</option><option>头程</option></select>
<select id="lmo-order-type" style="width:140px"><option value="">全部订单类型</option><option>一件代发</option><option>头程</option><option>拒收订单</option><option>快递订单</option></select>
<input id="lmo-owner" placeholder="货主" style="width:150px"/>
<input id="lmo-container" placeholder="集装箱号" style="width:160px"/>
<select id="lmo-status" style="width:140px"><option value="">全部状态</option><option>待入库计划</option><option>待清关</option><option>清关完成</option><option>已合托</option><option>待预约</option><option>预约中</option><option>预约成功</option></select>
@@ -813,8 +889,8 @@ FORM: 采用现有后台的列表加标准详情结构,保持高信息密度
</div>
<div class="lmo-selection-summary"><span id="lmo-selected-count">已选 0 条</span></div>
<div style="overflow-x:auto">
<table style="min-width:3660px;font-size:12px"><thead><tr>
<th style="width:42px;text-align:center"><input type="checkbox" id="lmo-select-all" onchange="toggleAllLastMileOrders(this.checked)" aria-label="全选尾程订单"></th><th class="lmo-operation-col" style="width:126px">操作</th><th>尾程单号</th><th>订单类型</th><th>入仓码</th><th>货主</th><th>海外仓</th><th>转仓</th><th>FBA仓库</th><th>集装箱号</th><th style="text-align:center;white-space:nowrap">货柜状态</th><th style="text-align:center;white-space:nowrap">是否异常</th><th style="text-align:center;white-space:nowrap">是否暂扣</th><th style="text-align:right">托数</th><th style="text-align:right">箱数</th><th style="text-align:right">体积(m³)</th><th style="text-align:right">数量</th><th style="text-align:right">体积重量</th><th>预计到港时间</th><th>实际到港时间</th><th>预计到仓</th><th>入库单号</th><th>出库单号</th><th>派送单号</th><th>换标单号</th><th style="text-align:center">换标状态</th><th style="text-align:center">合托标记</th><th style="text-align:center">当前状态</th><th>备注(仅自己)</th><th>备注(客户)</th><th>备注(仓库)</th>
<table style="min-width:3940px;font-size:12px"><thead><tr>
<th style="width:42px;text-align:center"><input type="checkbox" id="lmo-select-all" onchange="toggleAllLastMileOrders(this.checked)" aria-label="全选尾程订单"></th><th class="lmo-operation-col" style="width:126px">操作</th><th>尾程单号</th><th>预警</th><th>订单类型</th><th>拒收时间</th><th>入仓码</th><th>货主</th><th>海外仓</th><th>转仓</th><th>FBA仓库</th><th>集装箱号</th><th style="text-align:center;white-space:nowrap">货柜状态</th><th style="text-align:center;white-space:nowrap">是否异常</th><th style="text-align:center;white-space:nowrap">是否暂扣</th><th style="text-align:right">托数</th><th style="text-align:right">箱数</th><th style="text-align:right">体积(m³)</th><th style="text-align:right">数量</th><th style="text-align:right">体积重量</th><th>预计到港时间</th><th>实际到港时间</th><th>预计到仓</th><th>入库单号</th><th>出库单号</th><th>派送单号</th><th>换标单号</th><th style="text-align:center">换标状态</th><th style="text-align:center">合托标记</th><th style="text-align:center">当前状态</th><th>备注(仅自己)</th><th>备注(客户)</th><th>备注(仓库)</th>
</tr></thead><tbody id="tbody-last-mile-orders"></tbody></table>
</div>
<div class="pagination"><span id="lmo-total">共 0 条</span><button class="page-btn active">1</button></div>
@@ -828,8 +904,8 @@ FORM: 采用现有后台的列表加标准详情结构,保持高信息密度
<section class="lmo-section"><div class="lmo-section-title">订单信息</div><div class="lmo-info-grid" id="lmo-detail-order-info"></div></section>
<section class="lmo-section"><div class="lmo-section-title">订单明细</div><div class="lmo-info-grid" id="lmo-detail-order-details"></div></section>
<section class="lmo-section"><div class="lmo-section-title">计划信息</div><div class="lmo-info-grid" id="lmo-detail-plan-info"></div></section>
<section class="lmo-section"><div class="lmo-section-title">关联单据</div><div style="overflow-x:auto"><table><thead><tr><th>单据类型</th><th>单据编号</th><th>生成时间</th><th>报价</th></tr></thead><tbody id="tbody-lmo-detail-documents"></tbody></table></div></section>
<section class="lmo-section wide"><div class="lmo-section-title">货物信息</div><div style="overflow-x:auto"><table style="min-width:980px"><thead><tr><th>SKU</th><th>中文品名</th><th style="text-align:right">托数</th><th style="text-align:right">箱数</th><th style="text-align:right">件数</th><th style="text-align:right">重量(kg)</th><th style="text-align:right">体积(m³)</th></tr></thead><tbody id="tbody-lmo-detail-cargo"></tbody></table></div></section>
<section class="lmo-section"><div class="lmo-section-title">关联单据</div><div style="overflow-x:auto"><table><thead><tr><th>单据类型</th><th>单据编号</th><th>生成时间</th></tr></thead><tbody id="tbody-lmo-detail-documents"></tbody></table></div></section>
<section class="lmo-section"><div class="lmo-section-title">操作记录</div><div style="overflow-x:auto"><table><thead><tr><th>时间</th><th>操作节点</th><th>操作人</th><th>处理结果</th></tr></thead><tbody id="tbody-lmo-detail-logs"></tbody></table></div></section>
</div></div>
@@ -924,8 +1000,8 @@ FORM: 采用现有后台的列表加标准详情结构,保持高信息密度
<input id="inventory-transaction-outbound" placeholder="出库单号"/>
<div class="wms-search-actions"><button class="btn btn-sm btn-primary" onclick="inventoryTransactionSearch()">查询</button><button class="btn btn-sm btn-default" onclick="inventoryTransactionReset()">重置</button></div>
</div>
<div style="overflow-x:auto"><table style="min-width:2350px;font-size:12px"><thead><tr>
<th>发生时间</th><th>客户</th><th>海外仓</th><th>SKU编码</th><th>中文品名</th><th>业务类型</th><th>业务订单号</th><th>入库单号</th><th>出库单号</th><th>批次号</th><th>库位</th><th style="text-align:right">入库数量</th><th style="text-align:right">出库数量</th><th style="text-align:right">结存</th><th>操作人</th><th>备注</th>
<div style="overflow-x:auto"><table style="min-width:2050px;font-size:12px"><thead><tr>
<th>发生时间</th><th>客户</th><th>海外仓</th><th>SKU编码</th><th>中文品名</th><th>业务类型</th><th>业务订单号</th><th>入库单号</th><th>出库单号</th><th style="text-align:right">入库数量</th><th style="text-align:right">出库数量</th><th style="text-align:right">结存</th><th>操作人</th><th>备注</th>
</tr></thead><tbody id="tbody-inventory-transactions"></tbody></table></div>
<div class="pagination"><span id="inventory-transaction-total">共 0 条</span><button class="page-btn active">1</button></div>
</div>
@@ -11,7 +11,7 @@
<div class="inbound-search-section" style="overflow-x:auto">
<div class="dispatch-search-customize"><span>拖动查询项左侧手柄可自定义排列</span><button type="button" class="dispatch-search-reset" onclick="resetDispatchSearchOrder()">恢复默认</button></div>
<div class="inbound-search-row" id="dispatch-search-fields" style="grid-template-columns:repeat(6,minmax(160px,1fr));min-width:max-content">
<div class="inbound-search-item" data-search-key="status"><label>状态:</label><select><option>派送中</option><option>已签收</option><option>待派送</option></select></div>
<div class="inbound-search-item" data-search-key="status"><label>状态:</label><select><option>待派送</option><option>派送中</option><option>已签收</option><option>已取消</option><option>拒签</option></select></div>
<div class="inbound-search-item" data-search-key="waybill"><label>运单号:</label><input placeholder="请输入运单号"/></div>
<div class="inbound-search-item" data-search-key="warehouse"><label>仓库:</label><input placeholder="请输入入库"/></div>
<div class="inbound-search-item" data-search-key="orderType"><label>订单类型:</label><select><option>请选择</option><option>标准订单</option></select></div>
@@ -82,7 +82,7 @@
</tr>
</thead>
<tbody id="tbody-outbound-dispatch">
<tr>
<tr data-dispatch-no="YWBHK200615001">
<td><div class="dispatch-actions"><a href="javascript:void(0)" style="color:#3b82f6;font-size:12px">详情</a><a href="javascript:void(0)" onclick="showEditFleet('BOL20260615001')">编辑车队</a><a href="javascript:void(0)" onclick="showSendConfirm('BOL20260615001')">发送完成消息</a></div></td>
<td><div class="dispatch-doc-cell" data-dispatch-docs="YWBHK200615001">资料加载中…</div></td>
<td><span class="tag tag-blue">派送中</span></td>
@@ -119,7 +119,7 @@
<td>52800.00</td>
<td>7260.00</td>
</tr>
<tr>
<tr data-dispatch-no="YWBHK200615002">
<td><div class="dispatch-actions"><a href="javascript:void(0)" style="color:#3b82f6;font-size:12px">详情</a><a href="javascript:void(0)" onclick="showEditFleet('BOL20260615002')">编辑车队</a><a href="javascript:void(0)" onclick="showSendConfirm('BOL20260615002')">发送完成消息</a></div></td>
<td><div class="dispatch-doc-cell" data-dispatch-docs="YWBHK200615002">资料加载中…</div></td>
<td><span class="tag tag-green">已签收</span></td>
@@ -156,7 +156,7 @@
<td>42000.00</td>
<td>5775.00</td>
</tr>
<tr>
<tr data-dispatch-no="YWBHK200615003">
<td><div class="dispatch-actions"><a href="javascript:void(0)" style="color:#3b82f6;font-size:12px">详情</a><a href="javascript:void(0)" onclick="showEditFleet('BOL20260615003')">编辑车队</a><a href="javascript:void(0)" onclick="showSendConfirm('BOL20260615003')">发送完成消息</a></div></td>
<td><div class="dispatch-doc-cell" data-dispatch-docs="YWBHK200615003">资料加载中…</div></td>
<td><span class="tag tag-blue">派送中</span></td>
@@ -193,7 +193,7 @@
<td>25600.00</td>
<td>3520.00</td>
</tr>
<tr>
<tr data-dispatch-no="YWBHK200615004">
<td><div class="dispatch-actions"><a href="javascript:void(0)" style="color:#3b82f6;font-size:12px">详情</a><a href="javascript:void(0)" onclick="showEditFleet('BOL20260615004')">编辑车队</a><a href="javascript:void(0)" onclick="showSendConfirm('BOL20260615004')">发送完成消息</a></div></td>
<td><div class="dispatch-doc-cell" data-dispatch-docs="YWBHK200615004">资料加载中…</div></td>
<td><span class="tag tag-green">已签收</span></td>
@@ -230,10 +230,10 @@
<td>7800.00</td>
<td>1072.50</td>
</tr>
<tr>
<tr data-dispatch-no="YWBHK200615005">
<td><div class="dispatch-actions"><a href="javascript:void(0)" style="color:#3b82f6;font-size:12px">详情</a><a href="javascript:void(0)" onclick="showEditFleet('BOL20260615005')">编辑车队</a><a href="javascript:void(0)" onclick="showSendConfirm('BOL20260615005')">发送完成消息</a></div></td>
<td><div class="dispatch-doc-cell" data-dispatch-docs="YWBHK200615005">资料加载中…</div></td>
<td><span class="tag tag-orange">待派送</span></td>
<td><span class="tag tag-blue">派送中</span></td>
<td>海卡</td>
<td style="color:#3b82f6;font-weight:500">YWBHK200615005</td>
<td>标准订单</td>
@@ -375,7 +375,7 @@
<th>审核时间</th>
</tr>
</thead>
<tbody>
<tbody id="tbody-dispatch-fee">
<tr>
<td><a href="javascript:void(0)" style="color:#3b82f6;font-size:12px">编辑</a></td>
<td><span class="tag tag-green">已审核</span></td>
@@ -424,7 +424,7 @@
</tbody>
</table>
</div>
<div class="pagination"><span>共 3 条</span><button class="page-btn active">1</button></div>
<div class="pagination"><span id="dispatch-fee-total">共 3 条</span><button class="page-btn active">1</button></div>
</div>
</div>
</div>
@@ -1116,44 +1116,6 @@
<table><thead><tr><th style="width:90px;white-space:nowrap;text-align:center">操作</th><th>排车单号</th><th>车牌号</th><th>司机</th><th>联系电话</th><th>托车公司名称</th><th>派送单</th><th>路线</th><th>发车时间</th><th>FBA预约</th><th>车辆预约</th><th>预计到达时间</th><th>车辆状态</th></tr></thead><tbody id="tbody-outbound-vehicle"></tbody></table>
<div class="pagination"><span>共 0 条</span><button class="page-btn active">1</button></div></div></div>
<!-- ==================== OUTBOUND VEHICLE EDIT ==================== -->
<div class="page" id="page-outbound-vehicle-edit">
<div style="font-size:14px;color:#666;margin-bottom:12px"><a href="javascript:void(0)" onclick="switchPage('outbound-vehicle')" style="color:#1890ff;text-decoration:none">← 返回列表</a></div>
<div class="table-card" style="max-width:780px">
<div style="font-size:15px;font-weight:700;color:#1e293b;margin-bottom:20px">编辑排车</div>
<div style="background:#f7f7f7;border-radius:8px;padding:28px 32px">
<input type="hidden" id="ve-vehicleNo"/>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0 24px;margin-bottom:18px">
<div><label style="font-size:13px;font-weight:600;color:#333;margin-bottom:8px;display:block">车牌号</label><input id="ve-plate" style="width:100%;padding:11px 14px;border-radius:8px;border:1px solid #e0e0e0;background:#fff;font-size:13px;outline:none;box-sizing:border-box"/></div>
<div><label style="font-size:13px;font-weight:600;color:#333;margin-bottom:8px;display:block">托车公司名称</label><input id="ve-company" style="width:100%;padding:11px 14px;border-radius:8px;border:1px solid #e0e0e0;background:#fff;font-size:13px;outline:none;box-sizing:border-box"/></div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0 24px;margin-bottom:18px">
<div><label style="font-size:13px;font-weight:600;color:#333;margin-bottom:8px;display:block">司机</label><input id="ve-driver" style="width:100%;padding:11px 14px;border-radius:8px;border:1px solid #e0e0e0;background:#fff;font-size:13px;outline:none;box-sizing:border-box"/></div>
<div><label style="font-size:13px;font-weight:600;color:#333;margin-bottom:8px;display:block">联系电话</label><input id="ve-phone" style="width:100%;padding:11px 14px;border-radius:8px;border:1px solid #e0e0e0;background:#fff;font-size:13px;outline:none;box-sizing:border-box"/></div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0 24px;margin-bottom:18px">
<div><label style="font-size:13px;font-weight:600;color:#333;margin-bottom:8px;display:block">派送单</label><input id="ve-dispatch" style="width:100%;padding:11px 14px;border-radius:8px;border:1px solid #e0e0e0;background:#fff;font-size:13px;outline:none;box-sizing:border-box"/></div>
<div><label style="font-size:13px;font-weight:600;color:#333;margin-bottom:8px;display:block">路线</label><input id="ve-route" style="width:100%;padding:11px 14px;border-radius:8px;border:1px solid #e0e0e0;background:#fff;font-size:13px;outline:none;box-sizing:border-box"/></div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0 24px;margin-bottom:18px">
<div><label style="font-size:13px;font-weight:600;color:#333;margin-bottom:8px;display:block">发车时间</label><input id="ve-depart" type="datetime-local" style="width:100%;padding:11px 14px;border-radius:8px;border:1px solid #e0e0e0;background:#fff;font-size:13px;outline:none;box-sizing:border-box"/></div>
<div><label style="font-size:13px;font-weight:600;color:#333;margin-bottom:8px;display:block">预计到达时间</label><input id="ve-eta" type="datetime-local" style="width:100%;padding:11px 14px;border-radius:8px;border:1px solid #e0e0e0;background:#fff;font-size:13px;outline:none;box-sizing:border-box"/></div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0 24px;margin-bottom:18px">
<div><label style="font-size:13px;font-weight:600;color:#333;margin-bottom:8px;display:block">FBA预约</label><input id="ve-fbaBooking" style="width:100%;padding:11px 14px;border-radius:8px;border:1px solid #e0e0e0;background:#fff;font-size:13px;outline:none;box-sizing:border-box"/></div>
<div><label style="font-size:13px;font-weight:600;color:#333;margin-bottom:8px;display:block">车辆预约</label><input id="ve-carBooking" style="width:100%;padding:11px 14px;border-radius:8px;border:1px solid #e0e0e0;background:#fff;font-size:13px;outline:none;box-sizing:border-box"/></div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0 24px;margin-bottom:0">
<div><label style="font-size:13px;font-weight:600;color:#333;margin-bottom:8px;display:block">车辆状态</label><select id="ve-status" style="width:100%;padding:11px 14px;border-radius:8px;border:1px solid #e0e0e0;background:#fff;font-size:13px;outline:none;box-sizing:border-box"><option>待发车</option><option>配送中</option><option>已送达</option><option>已返回</option></select></div>
<div><label style="font-size:13px;font-weight:600;color:#333;margin-bottom:8px;display:block">排车单号</label><input id="ve-vehicleNoShow" disabled style="width:100%;padding:11px 14px;border-radius:8px;border:1px solid #e0e0e0;background:#eee;font-size:13px;outline:none;box-sizing:border-box"/></div>
</div>
</div>
<div style="display:flex;justify-content:flex-end;gap:12px;margin-top:20px;padding-top:16px;border-top:1px solid #eee">
<button class="btn btn-default" style="padding:8px 28px;border-radius:6px" onclick="switchPage('outbound-vehicle')">取消</button>
<button class="btn btn-primary" style="padding:8px 28px;border-radius:6px" onclick="saveVehicleEdit()">保存修改</button>
</div>
</div></div>
<div class="page" id="page-outbound-vehicle-detail">
<div class="breadcrumb">首页 <span class="sep">&gt;</span> 海外仓 <span class="sep">&gt;</span> 排车列表 <span class="sep">&gt;</span> <strong>详情</strong></div>
<div class="table-card fbd-card">
@@ -0,0 +1,19 @@
<div class="page" id="page-outbound-exception">
<div class="table-card">
<div class="breadcrumb">首页 <span class="sep">&gt;</span> 客户服务 <span class="sep">&gt;</span> <strong>异常订单</strong></div>
<div class="table-toolbar"><span class="title">异常订单</span><div class="toolbar-actions"><button class="btn btn-default btn-sm" onclick="switchPage('outbound-dispatch',this)">返回派送单</button></div></div>
<div class="dispatch-exception-summary"><div class="dispatch-exception-kpi"><strong id="dispatch-exception-total">0</strong><span>异常订单</span></div><div class="dispatch-exception-kpi"><strong id="dispatch-exception-pending">0</strong><span>待处理</span></div><div class="dispatch-exception-kpi"><strong id="dispatch-exception-processing">0</strong><span>处理中</span></div></div>
<div class="inbound-search-section">
<div class="search-row" style="margin-bottom:0;display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<input id="dispatch-exception-keyword" placeholder="异常订单号 / 关联订单号 / 客户 / 原因" style="width:320px"/>
<select id="dispatch-exception-type" style="width:140px"><option value="">异常类型</option><option value="退货">退货</option><option value="拒收">拒收</option></select>
<select id="dispatch-exception-status" style="width:140px"><option value="">处理状态</option><option value="待处理">待处理</option><option value="处理中">处理中</option><option value="已完成">已完成</option></select>
<button class="btn btn-sm btn-primary" onclick="searchDispatchExceptions()">查询</button><button class="btn btn-sm btn-default" onclick="resetDispatchExceptions()">重置</button>
</div>
</div>
<div class="inbound-table-container">
<table style="min-width:1520px;font-size:12px"><thead><tr><th style="width:150px">操作</th><th>异常订单号</th><th>关联订单号</th><th>异常类型</th><th>客户</th><th>仓库</th><th>异常原因</th><th>异常时间</th><th>处理状态</th><th>处理动作</th><th>退回物流单号</th><th>处理人</th><th>更新时间</th></tr></thead>
<tbody id="tbody-outbound-exception"></tbody></table>
</div><div class="pagination"><span id="dispatch-exception-page-total">共 0 条</span><button class="page-btn active">1</button></div>
</div>
</div>
@@ -211,9 +211,14 @@
<span class="nav-item active">拖车列表</span>
</div>
<div class="page-tabs truck-region-tabs" id="truck-region-tabs">
<div class="tab-item active" data-region="domestic" onclick="switchTruckRegion('domestic',this)">国内</div>
<div class="tab-item" data-region="overseas" onclick="switchTruckRegion('overseas',this)">海外</div>
</div>
<div class="table-card">
<div class="table-toolbar">
<span class="title">拖车列表</span>
<span class="title" id="truck-list-title">拖车列表</span>
<div class="toolbar-actions">
<button class="btn btn-primary" onclick="switchPage('truck-add',this)">+ 新增拖车</button>
<button class="btn btn-default">导出</button>
@@ -234,8 +239,8 @@
<th>司机</th>
<th>车头号</th>
<th>门店</th>
<th>应付拖车费</th>
<th>应收拖车费</th>
<th id="truck-payable-header">应付拖车费</th>
<th id="truck-receivable-header">应收拖车费</th>
<th>预计装箱日期</th>
<th>状态</th>
<th>附件</th>
@@ -320,10 +325,10 @@
<div class="truck-col">
<div class="tf-section">费用信息</div>
<div class="tf"><label>应付拖车费</label>
<div class="tf-amount"><input id="truck-payable" value="0.00" step="0.01"/><span class="u">RMB</span><button class="calc" title="计算">&#128269;</button></div>
<div class="tf-amount"><input id="truck-payable" value="0.00" step="0.01"/><span class="u" id="truck-payable-unit">RMB</span><button class="calc" title="计算">&#128269;</button></div>
</div>
<div class="tf"><label>应收拖车费</label>
<div class="tf-amount"><input id="truck-receivable" value="0.00" step="0.01"/><span class="u">RMB</span><button class="calc" title="计算">&#128269;</button></div>
<div class="tf-amount"><input id="truck-receivable" value="0.00" step="0.01"/><span class="u" id="truck-receivable-unit">RMB</span><button class="calc" title="计算">&#128269;</button></div>
</div>
<div class="tf"><label>派车单号</label><input id="truck-dispatch-no" readonly placeholder="保存后自动生成"/></div>
@@ -421,10 +426,10 @@
<div class="truck-col">
<div class="tf-section">费用信息</div>
<div class="tf"><label>应付拖车费</label>
<div class="tf-amount"><input id="et-payable" step="0.01"/><span class="u">RMB</span><button class="calc" title="计算">&#128269;</button></div>
<div class="tf-amount"><input id="et-payable" step="0.01"/><span class="u" id="et-payable-unit">RMB</span><button class="calc" title="计算">&#128269;</button></div>
</div>
<div class="tf"><label>应收拖车费</label>
<div class="tf-amount"><input id="et-receivable" step="0.01"/><span class="u">RMB</span><button class="calc" title="计算">&#128269;</button></div>
<div class="tf-amount"><input id="et-receivable" step="0.01"/><span class="u" id="et-receivable-unit">RMB</span><button class="calc" title="计算">&#128269;</button></div>
</div>
<div class="tf"><label>派车单号</label><input id="et-dispatch-no" readonly/></div>
@@ -491,7 +496,7 @@
<div class="bk-section-title">船期信息</div>
<div class="bk-row-2col"><div class="bk-field-new"><label>预计开船</label><input type="date"/></div><div class="bk-field-new"><label>实际开船</label><input type="date"/></div></div>
<div class="bk-row-2col"><div class="bk-field-new"><label>预计到港</label><input type="date"/></div><div class="bk-field-new"><label>实际到港</label><input type="date"/></div></div>
<div class="bk-row-2col"><div class="bk-field-new"><label style="color:#f5222d">船名(Vessel) *</label><input placeholder="船名"/></div><div class="bk-field-new"><label style="color:#f5222d">航次(Voyage) *</label><input placeholder="航次"/></div></div>
<div class="bk-row-2col"><div class="bk-field-new"><label>船名(Vessel)</label><input placeholder="船名"/></div><div class="bk-field-new"><label>航次(Voyage)</label><input placeholder="航次"/></div></div>
</div>
<div class="bk-section">
<div class="bk-section-title">港口与类型</div>
@@ -964,7 +969,7 @@
<!-- ==================== CONTAINER LIST ==================== -->
<div class="page" id="page-ct-list">
<div class="table-card"><div class="table-toolbar"><span class="title">货柜列表</span><div class="toolbar-actions"><button class="btn btn-primary" onclick="openCtForm()">+ 新增</button><button class="btn btn-outline-primary">↓导出柜号费用</button><button class="btn btn-outline-primary">↓导出柜号费用明细</button><button class="btn btn-outline-primary">▼高级查询</button></div></div>
<div class="table-card"><div class="table-toolbar"><span class="title">货柜列表</span><div class="toolbar-actions"><button class="btn btn-primary" onclick="openCtForm()">+ 新增</button><button class="btn btn-primary" onclick="openCtPalletLabelPreview()">生成托盘标</button><button class="btn btn-outline-primary">↓导出柜号费用</button><button class="btn btn-outline-primary">↓导出柜号费用明细</button><button class="btn btn-outline-primary">▼高级查询</button></div></div>
<div class="search-row" style="flex-wrap:wrap;gap:8px">
<input placeholder="请输入集装箱号" style="width:180px"/>
<input placeholder="请输入柜号" style="width:180px"/>
@@ -982,13 +987,13 @@
<span><a href="#" style="color:#666;text-decoration:none;font-size:12px;margin-right:10px">⟳ 刷新</a><a href="#" style="color:#666;text-decoration:none;font-size:12px">⚙ 设置</a></span>
</div>
<div style="overflow-x:auto;-webkit-overflow-scrolling:touch">
<table style="min-width:2420px"><thead><tr><th width="36"><input type="checkbox" onclick="toggleCtAll(this)"/></th><th style="width:520px;white-space:nowrap;text-align:center">操作</th><th>集装箱号</th><th>装柜清单号</th><th>货柜状态</th><th style="white-space:nowrap">是否异常</th><th>柜号</th><th>目的港清关资料</th><th>DO资料</th><th>PU资料</th><th style="white-space:nowrap">码头车队</th><th>仓库</th><th>提单号</th><th>船名航次</th><th>箱型(货柜类型)</th><th>船公司</th><th>始发港</th><th>目的港</th><th>货柜容量(m³)</th><th>装柜时间</th><th>预计开船时间</th><th>实际开船时间</th><th>预计到港时间</th><th>实际到港时间</th></tr></thead><tbody id="tbody-ct-list"></tbody></table>
<table style="min-width:2800px"><thead><tr><th width="36"><input type="checkbox" onclick="toggleCtAll(this)"/></th><th style="width:300px;white-space:nowrap;text-align:center">操作</th><th>报空预警</th><th>集装箱号</th><th>装柜清单号</th><th>货柜状态</th><th style="white-space:nowrap">是否异常</th><th>柜号</th><th>目的港清关资料</th><th>DO资料</th><th>PU资料</th><th style="white-space:nowrap">码头车队</th><th>仓库</th><th>提单号</th><th>船名航次</th><th>箱型(货柜类型)</th><th>船公司</th><th>始发港</th><th>目的港</th><th>货柜容量(m³)</th><th>装柜时间</th><th>预计开船时间</th><th>实际开船时间</th><th>预计到港时间</th><th>实际到港时间</th><th>预计提柜时间</th><th>约车时间</th><th>报空时间</th><th>还柜时间</th></tr></thead><tbody id="tbody-ct-list"></tbody></table>
</div>
<div class="pagination"><span>共 685 条</span><button class="page-btn active">1</button><button class="page-btn">2</button><button class="page-btn">3</button><button class="page-btn">...</button><button class="page-btn">137</button></div></div>
<!-- ===== 货柜费用 ===== -->
<div class="table-card" style="margin-top:20px">
<div class="table-toolbar"><span class="title">货柜费用</span><div class="toolbar-actions"><button class="btn btn-primary" onclick="showModal('modal-ct-cost-add')">+ 新增费用</button><button class="btn btn-default">导出</button></div></div>
<div class="table-toolbar"><span class="title">货柜费用</span><div class="toolbar-actions"><button class="btn btn-primary" onclick="openCtCostAdd()">+ 新增费用</button><button class="btn btn-default">导出</button></div></div>
<div class="search-row" style="flex-wrap:wrap;gap:8px">
<input placeholder="集装箱号" style="width:160px"/>
<select style="width:130px"><option value="">全部费用类型</option><option>港口费</option><option>堆存费</option><option>修箱费</option><option>吊机费</option><option>拖车费</option><option>报关费</option><option>其他</option></select>
@@ -1008,6 +1013,7 @@
<!-- ==================== CONTAINER MANIFEST ==================== -->
<div class="page" id="page-ct-manifest">
<div class="table-card">
<div class="table-toolbar"><span class="title">装柜清单</span></div>
<!-- 搜索栏 -->
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px">
<label style="font-size:13px;color:#333;font-weight:500;white-space:nowrap">仓库:</label>
@@ -1018,8 +1024,6 @@
<button class="btn btn-sm btn-default" style="padding:7px 18px;border-radius:6px" onclick="mfReset()">🔄 重置</button>
<a href="javascript:void(0)" style="color:#1890ff;text-decoration:none;font-size:13px;white-space:nowrap" onclick="this.textContent=this.textContent.includes('展开')?'收起 ▲':'展开 ▼'">展开 ▼</a>
</div>
<!-- 查看查询按钮 -->
<div style="margin-bottom:14px"><button class="btn btn-primary" style="padding:7px 20px;border-radius:6px;font-size:13px" onclick="showModal('modal-mf-query')">▼ 查看查询</button></div>
<!-- 选择条 -->
<div style="background:#e6f4ff;border:1px solid #91caff;border-radius:6px;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;margin-bottom:14px">
<span style="font-size:13px;color:#1890ff">已选择 <b id="mf-sel-count">0</b> 项 &nbsp;&nbsp;<a href="javascript:void(0)" style="color:#1890ff;text-decoration:none" onclick="mfClearSel()">清空</a></span>
@@ -1032,7 +1036,7 @@
<div style="overflow-x:auto">
<table style="min-width:1600px;font-size:12px"><thead><tr style="background:#fafafa;color:#666">
<th style="padding:9px 10px;text-align:center;width:36px"><input type="checkbox" onclick="mfToggleAll(this)"/></th>
<th style="padding:9px 10px;text-align:center;white-space:nowrap;width:130px">操作</th>
<th style="padding:9px 10px;text-align:center;white-space:nowrap;width:180px">操作</th>
<th style="padding:9px 10px;text-align:left;white-space:nowrap">仓库</th>
<th style="padding:9px 10px;text-align:left;white-space:nowrap">装柜清单号</th>
<th style="padding:9px 10px;text-align:left;white-space:nowrap">集装箱号</th>
@@ -1156,6 +1160,7 @@
<div class="modal" style="max-width:460px">
<div class="modal-header"><span>放舱信息</span><span onclick="cancelRelease()" style="cursor:pointer;font-size:20px">&times;</span></div>
<div class="modal-body" style="padding:20px 24px">
<div class="form-row"><div class="form-group"><label><span style="color:#ef4444">*</span> 船名</label><input id="release-vessel" placeholder="请输入船名,如 OOCL PORTUGAL"/></div></div>
<div class="form-row"><div class="form-group"><label><span style="color:#ef4444">*</span> 航次</label><input id="release-voyage" placeholder="请输入航次,如 00TW"/></div></div>
<div class="form-row"><div class="form-group"><label><span style="color:#ef4444">*</span> 提单号</label><input id="release-bl" placeholder="请输入提单号,如 COSU6058282"/></div></div>
</div>
@@ -198,11 +198,11 @@
<!-- ==================== OVERSEAS PURCHASE DETAIL ==================== -->
<div class="page" id="page-domestic-purchase-detail">
<div class="purchase-edit-shell"><div class="purchase-detail-head"><div class="purchase-detail-title">国内采购订单详情</div><button class="btn btn-default" onclick="switchPage('overseas-purchase');switchPurchaseOrderTab('domestic')">返回</button></div><div class="purchase-detail-body"><div class="purchase-detail-grid"><section class="purchase-detail-section"><h3>商品信息</h3><div id="domestic-detail-product" class="purchase-detail-fields"></div></section><section class="purchase-detail-section"><h3>库存信息</h3><div id="domestic-detail-stock" class="purchase-detail-fields"></div></section><section class="purchase-detail-section"><h3>订单信息</h3><div id="domestic-detail-order" class="purchase-detail-fields"></div></section><section class="purchase-detail-section"><h3>快递信息</h3><div id="domestic-detail-courier" class="purchase-detail-fields"></div></section><section class="purchase-detail-section full"><h3>进销存明细</h3><div id="domestic-detail-ledger" class="purchase-ledger-wrap"></div></section></div></div></div>
<div class="purchase-edit-shell"><div class="purchase-detail-head"><div class="purchase-detail-title">国内采购订单详情</div><button class="btn btn-default" onclick="switchPage('overseas-purchase');switchPurchaseOrderTab('domestic')">返回</button></div><div class="purchase-detail-body"><div class="purchase-detail-grid"><section class="purchase-detail-section"><h3>商品信息</h3><div id="domestic-detail-product" class="purchase-detail-fields"></div></section><section class="purchase-detail-section"><h3>库存信息</h3><div id="domestic-detail-stock" class="purchase-detail-fields"></div></section><section class="purchase-detail-section"><h3>订单信息</h3><div id="domestic-detail-order" class="purchase-detail-fields"></div></section><section class="purchase-detail-section"><h3>快递信息</h3><div id="domestic-detail-courier" class="purchase-detail-fields"></div></section><section class="purchase-detail-section full"><h3>操作记录</h3><div id="domestic-detail-operation-records" class="purchase-ledger-wrap"></div></section></div></div></div>
</div>
<div class="page" id="page-overseas-purchase-detail">
<div class="purchase-edit-shell"><div class="purchase-detail-head"><div class="purchase-detail-title">海外采购订单详情</div><button class="btn btn-default" onclick="switchPage('overseas-purchase');switchPurchaseOrderTab('overseas')">返回</button></div><div class="purchase-detail-body"><div class="purchase-detail-grid"><section class="purchase-detail-section"><h3>商品信息</h3><div id="overseas-detail-product" class="purchase-detail-fields"></div></section><section class="purchase-detail-section"><h3>库存信息</h3><div id="overseas-detail-stock" class="purchase-detail-fields"></div></section><section class="purchase-detail-section"><h3>订单信息</h3><div id="overseas-detail-order" class="purchase-detail-fields"></div></section><section class="purchase-detail-section"><h3>快递信息</h3><div id="overseas-detail-courier" class="purchase-detail-fields"></div></section><section class="purchase-detail-section full"><h3>进销存明细</h3><div id="overseas-detail-ledger" class="purchase-ledger-wrap"></div></section></div></div></div>
<div class="purchase-edit-shell"><div class="purchase-detail-head"><div class="purchase-detail-title">海外采购订单详情</div><button class="btn btn-default" onclick="switchPage('overseas-purchase');switchPurchaseOrderTab('overseas')">返回</button></div><div class="purchase-detail-body"><div class="purchase-detail-grid"><section class="purchase-detail-section"><h3>商品信息</h3><div id="overseas-detail-product" class="purchase-detail-fields"></div></section><section class="purchase-detail-section"><h3>库存信息</h3><div id="overseas-detail-stock" class="purchase-detail-fields"></div></section><section class="purchase-detail-section"><h3>订单信息</h3><div id="overseas-detail-order" class="purchase-detail-fields"></div></section><section class="purchase-detail-section"><h3>快递信息</h3><div id="overseas-detail-courier" class="purchase-detail-fields"></div></section><section class="purchase-detail-section full"><h3>操作记录</h3><div id="overseas-detail-operation-records" class="purchase-ledger-wrap"></div></section></div></div></div>
</div>
<!-- ==================== LEGACY PURCHASE DETAIL (UNLINKED) ==================== -->
@@ -428,4 +428,3 @@
<button class="btn btn-primary" style="padding:8px 28px;border-radius:6px" onclick="saveLooseboardOrderEdit()">保存修改</button>
</div>
</div></div>
@@ -639,16 +639,15 @@
<div class="stat-row" style="grid-template-columns:repeat(4,1fr)"><div class="stat-card"><div class="label">客户账户数</div><div class="value" id="ca-stat-count">0<span>个</span></div></div><div class="stat-card"><div class="label">账户实际余额</div><div class="value" id="ca-stat-balance">¥0</div></div><div class="stat-card"><div class="label">冻结金额</div><div class="value" id="ca-stat-frozen" style="color:#f59e0b">¥0</div></div><div class="stat-card"><div class="label">本月累计扣款</div><div class="value" id="ca-stat-deduction" style="color:#ef4444">¥0</div></div></div>
<div class="table-card"><div class="table-toolbar"><span class="title">客户账户</span><div class="toolbar-actions"><button class="btn btn-default" onclick="showToast('客户账户数据已导出','success')">导出</button></div></div>
<div class="search-row"><input id="ca-filter-keyword" placeholder="客户名称/客户编号" style="width:210px"><select id="ca-filter-status" style="width:140px"><option value="">全部状态</option><option>正常</option><option>冻结</option><option>停用</option></select><select id="ca-filter-balance" style="width:150px"><option value="">全部余额</option><option value="positive">余额大于0</option><option value="zero">余额为0</option><option value="negative">余额小于0</option></select><button class="btn btn-sm btn-primary" onclick="renderCustomerAccounts()">查询</button><button class="btn btn-sm btn-default" onclick="resetCustomerAccountFilters()">重置</button></div>
<div class="inbound-table-container"><table><thead><tr><th>操作</th><th>客户基本信息</th><th>客户编号</th><th>联系人</th><th>联系电话</th><th>实际余额</th><th>冻结金额</th><th>可用余额</th><th>账户状态</th><th>最后变动时间</th></tr></thead><tbody id="tbody-customer-account"></tbody></table></div><div class="pagination"><span id="ca-total-count">共 0 条</span><button class="page-btn active">1</button></div></div></div>
<div class="inbound-table-container"><table><thead><tr><th>操作</th><th>客户基本信息</th><th>客户编号</th><th>联系人</th><th>联系电话</th><th>实际余额</th><th>冻结金额</th><th>可用余额</th><th>账户状态</th><th>最后变动时间</th></tr></thead><tbody id="tbody-customer-account"></tbody></table></div><div class="pagination"><span id="ca-total-count">共 0 条</span><button class="page-btn active">1</button></div></div>
<div class="table-card" style="margin-top:16px"><div class="table-toolbar"><span class="title">充值申请记录</span></div><div class="search-row"><input id="customer-recharge-application-keyword" placeholder="申请单号/客户名称" style="width:210px"><select id="customer-recharge-application-status" style="width:140px"><option value="">全部状态</option><option>待审核</option><option>已通过</option><option>已驳回</option></select><button class="btn btn-sm btn-primary" onclick="renderCustomerRechargeApplications()">查询</button><button class="btn btn-sm btn-default" onclick="resetCustomerRechargeApplicationFilters()">重置</button></div><div class="inbound-table-container"><table><thead><tr><th>操作</th><th>申请单号</th><th>客户名称</th><th>充值金额</th><th>充值方式</th><th>到账日期</th><th>凭证数量</th><th>申请时间</th><th>申请状态</th><th>审核人</th><th>审核时间</th><th>审核备注</th></tr></thead><tbody id="tbody-customer-recharge-application"></tbody></table></div><div class="pagination"><span id="customer-recharge-application-total">共 0 条</span></div></div></div>
<div class="page" id="page-customer-account-detail">
<div class="page-nav"><span class="nav-item" onclick="switchPage('customer-account',this)">财务管理</span><span class="nav-sep">&gt;</span><span class="nav-item" onclick="switchPage('customer-account',this)">客户账户</span><span class="nav-sep">&gt;</span><span class="nav-item active">账户详情</span></div>
<div class="table-card" style="margin-bottom:16px"><div class="table-toolbar"><div><span class="title" id="ca-detail-name">客户账户详情</span><span class="tag tag-green" id="ca-detail-status" style="margin-left:10px">正常</span></div><div class="toolbar-actions"><button class="btn btn-default" onclick="switchPage('customer-account',this)">返回列表</button><button class="btn btn-primary" id="ca-detail-recharge-btn">手动充值</button></div></div>
<div class="table-card" style="margin-bottom:16px"><div class="table-toolbar"><div><span class="title" id="ca-detail-name">客户账户详情</span><span class="tag tag-green" id="ca-detail-status" style="margin-left:10px">正常</span></div><div class="toolbar-actions"><button class="btn btn-default" onclick="switchPage('customer-account',this)">返回列表</button><button class="btn btn-primary" id="ca-detail-recharge-btn" onclick="showCustomerRechargeApplication(currentCustomerAccountId)">充值申请</button><button class="btn btn-default" id="ca-detail-deduction-btn">手动扣款</button></div></div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin:0 20px 18px"><div style="padding:18px;border-radius:12px;background:#f0fdf4;border:1px solid #d7f4df"><div style="font-size:12px;color:#64748b">实际余额</div><div id="ca-detail-balance" style="font-size:26px;font-weight:800;color:#16a34a;margin-top:8px">¥0.00</div></div><div style="padding:18px;border-radius:12px;background:#fffaf0;border:1px solid #ffebc0"><div style="font-size:12px;color:#64748b">冻结金额</div><div id="ca-detail-frozen" style="font-size:26px;font-weight:800;color:#f59e0b;margin-top:8px">¥0.00</div></div><div style="padding:18px;border-radius:12px;background:#f6f7ff;border:1px solid #e4e7ff"><div style="font-size:12px;color:#64748b">可用余额</div><div id="ca-detail-available" style="font-size:26px;font-weight:800;color:#667eea;margin-top:8px">¥0.00</div></div></div><div class="ca-info-grid" id="ca-detail-info" style="margin:0 20px 20px"></div></div>
<div class="table-card"><div class="table-toolbar"><span class="title">账户明细(充值/扣款)</span><div class="toolbar-actions"><button class="btn btn-default" onclick="showToast('账户明细已导出','success')">导出明细</button></div></div><div class="search-row"><select id="ca-detail-type" style="width:150px"><option value="">全部扣款类型</option><option>充值</option><option>贴牌</option><option>出库</option><option>仓储费用</option><option>自提</option><option>暂扣</option><option>弃货</option><option>其它</option></select><input id="ca-detail-start" type="date" style="width:150px"><span style="color:#94a3b8">至</span><input id="ca-detail-end" type="date" style="width:150px"><button class="btn btn-sm btn-primary" onclick="renderCustomerAccountTransactions()">查询</button><button class="btn btn-sm btn-default" onclick="resetCustomerAccountTransactions()">重置</button></div><div class="inbound-table-container"><table><thead><tr><th>流水号</th><th>发生时间</th><th>扣款类型</th><th>关联单据</th><th>收入</th><th>支出</th><th>变动后余额</th><th>操作人</th><th>备注</th></tr></thead><tbody id="tbody-customer-account-detail"></tbody></table></div><div class="pagination"><span id="ca-detail-total">共 0 条</span><button class="page-btn active">1</button></div></div></div>
<div class="modal-overlay" id="modal-customer-recharge"><div class="modal" style="max-width:620px"><div class="modal-header"><span>手动充值</span><span onclick="hideModal('modal-customer-recharge')" style="cursor:pointer;font-size:20px">&times;</span></div><div class="modal-body" style="padding:22px 26px"><div style="padding:12px 14px;border-radius:8px;background:#f6f7ff;color:#475569;font-size:13px;margin-bottom:18px">充值后金额将立即计入客户实际余额,并自动生成一条“充值”账户明细。</div><input type="hidden" id="ca-recharge-id"><div class="form-row"><div class="form-group"><label>客户名称</label><input id="ca-recharge-customer" readonly style="background:#f5f6f8"></div><div class="form-group"><label>当前实际余额</label><input id="ca-recharge-current" readonly style="background:#f5f6f8;color:#16a34a;font-weight:700"></div></div><div class="form-row"><div class="form-group"><label>充值金额 <span style="color:#ef4444">*</span></label><input id="ca-recharge-amount" type="number" min="0.01" step="0.01" placeholder="请输入充值金额"></div><div class="form-group"><label>充值方式 <span style="color:#ef4444">*</span></label><select id="ca-recharge-method"><option>银行转账</option><option>支付宝</option><option>微信支付</option><option>现金</option><option>其它</option></select></div></div><div class="form-row"><div class="form-group"><label>到账日期 <span style="color:#ef4444">*</span></label><input id="ca-recharge-date" type="date"></div><div class="form-group"><label>收款凭证号</label><input id="ca-recharge-ref" placeholder="请输入银行流水/凭证号"></div></div><div class="form-row"><div class="form-group" style="width:100%"><label>充值备注</label><textarea id="ca-recharge-remark" rows="3" placeholder="请输入充值说明" style="resize:vertical"></textarea></div></div></div><div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-customer-recharge')">取消</button><button class="btn btn-primary" onclick="saveCustomerRecharge()">确认充值</button></div></div></div>
<!-- ==================== FINANCE COST ANALYSIS ==================== -->
<div class="page" id="page-finance-cost">
<div class="chart-row equal" style="margin-bottom:16px">
@@ -670,7 +669,7 @@
<!-- 工具栏 -->
<div class="table-toolbar">
<div style="display:flex;gap:10px">
<button class="btn btn-primary" style="border-radius:6px;padding:7px 20px;font-size:13px;background:#1890ff;border-color:#1890ff;color:#fff;font-weight:500">+ 新增</button>
<button class="btn btn-primary" onclick="openArapForm()" style="border-radius:6px;padding:7px 20px;font-size:13px;background:#1890ff;border-color:#1890ff;color:#fff;font-weight:500">+ 新增</button>
<button class="btn btn-default" style="border-radius:6px;padding:7px 16px;font-size:13px;background:#1890ff;border-color:#1890ff;color:#fff;font-weight:500;display:flex;align-items:center;gap:4px"><input type="checkbox" style="margin:0;width:14px;height:14px"/> 按截止时间生成账单</button>
<button class="btn btn-default" style="border-radius:6px;padding:7px 16px;font-size:13px;background:#1890ff;border-color:#1890ff;color:#fff;font-weight:500;display:flex;align-items:center;gap:4px"><input type="checkbox" style="margin:0;width:14px;height:14px"/> 按选择生成账单</button>
</div>
@@ -696,7 +695,7 @@
<label style="font-size:13px;color:#555;white-space:nowrap;min-width:44px;text-align:right">柜号</label>
<input placeholder="请输入柜号" style="width:140px;height:34px;border:1px solid #d9d9d9;border-radius:6px;padding:0 10px;font-size:13px;background:#fff"/>
<label style="font-size:13px;color:#555;white-space:nowrap;min-width:72px;text-align:right">审核状态</label>
<select style="width:120px;height:34px;border:1px solid #d9d9d9;border-radius:6px;padding:0 10px;font-size:13px;background:#fff"><option value="">全部</option><option>财务通过</option><option>客服提交</option><option>新增</option></select>
<select id="arap-filter-audit-status" style="width:120px;height:34px;border:1px solid #d9d9d9;border-radius:6px;padding:0 10px;font-size:13px;background:#fff"><option value="">全部</option><option>待审核</option><option>审核通过</option><option>审核驳回</option></select>
</div>
<div style="padding:10px 16px;display:flex;gap:12px;align-items:center;flex-wrap:wrap">
<label style="font-size:13px;color:#555;white-space:nowrap;min-width:72px;text-align:right">订单状态</label>
@@ -720,7 +719,7 @@
<!-- 数据表格 -->
<div class="arap-table-wrap">
<table class="arap-table">
<colgroup><col style="width:40px"/><col style="width:255px"/><col style="width:520px"/><col style="width:120px"/><col style="width:80px"/><col style="width:96px"/><col style="width:84px"/><col style="width:160px"/><col style="width:110px"/><col style="width:140px"/><col style="width:90px"/><col style="width:130px"/><col style="width:180px"/></colgroup>
<colgroup><col style="width:40px"/><col style="width:220px"/><col style="width:240px"/><col style="width:120px"/><col style="width:80px"/><col style="width:96px"/><col style="width:84px"/><col style="width:160px"/><col style="width:110px"/><col style="width:140px"/><col style="width:90px"/><col style="width:110px"/><col style="width:130px"/><col style="width:180px"/></colgroup>
<thead><tr style="background:#fafafa;border-bottom:2px solid #eee">
<th style="padding:10px 5px;text-align:left;font-weight:600;color:#333;font-size:12px"><input type="checkbox"/></th>
<th style="padding:10px 5px;text-align:left;font-weight:600;color:#333;font-size:12px">操作</th>
@@ -733,6 +732,7 @@
<th style="padding:10px 5px;text-align:left;font-weight:600;color:#333;font-size:12px">订单状态</th>
<th style="padding:10px 5px;text-align:left;font-weight:600;color:#333;font-size:12px">派送单号</th>
<th style="padding:10px 5px;text-align:left;font-weight:600;color:#333;font-size:12px">费用类型</th>
<th style="padding:10px 5px;text-align:left;font-weight:600;color:#333;font-size:12px">金额</th>
<th style="padding:10px 5px;text-align:left;font-weight:600;color:#333;font-size:12px">集装箱号</th>
<th style="padding:10px 5px;text-align:left;font-weight:600;color:#333;font-size:12px">柜号</th>
</tr></thead>
@@ -757,6 +757,43 @@
</div>
</div>
<!-- ==================== RECEIVABLE DETAIL REPORT ==================== -->
<div class="page" id="page-finance-ar-detail-report">
<style>
#page-finance-ar-detail-report .ardr-titlebar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px}
#page-finance-ar-detail-report .ardr-titlebar h3{margin:0;font-size:18px;color:#1f2937;letter-spacing:0}
#page-finance-ar-detail-report .ardr-search{display:flex;align-items:flex-end;gap:10px;flex-wrap:wrap;padding:14px 16px;margin-bottom:14px;border:1px solid #e5e7eb;background:#f8fafc}
#page-finance-ar-detail-report .ardr-field{display:flex;flex-direction:column;gap:5px}
#page-finance-ar-detail-report .ardr-field label{font-size:12px;color:#64748b}
#page-finance-ar-detail-report .ardr-field select,#page-finance-ar-detail-report .ardr-field input{width:170px}
#page-finance-ar-detail-report .ardr-search-actions{display:flex;align-items:center;gap:8px}
#page-finance-ar-detail-report .ardr-summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-bottom:14px}
#page-finance-ar-detail-report .ardr-summary-card{padding:14px 16px;border:1px solid #e5e7eb;background:#fff}
#page-finance-ar-detail-report .ardr-summary-card span{display:block;margin-bottom:6px;font-size:12px;color:#64748b}
#page-finance-ar-detail-report .ardr-summary-card strong{display:block;font-size:18px;color:#1f2937;overflow-wrap:anywhere}
#page-finance-ar-detail-report .ardr-summary-card.received strong{color:#16803d}
#page-finance-ar-detail-report .ardr-summary-card.balance strong{color:#cf1322}
#page-finance-ar-detail-report .ardr-empty{text-align:center!important;color:#64748b;height:96px!important}
@media(max-width:1200px){#page-finance-ar-detail-report .ardr-summary{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:640px){#page-finance-ar-detail-report .ardr-summary{grid-template-columns:1fr}#page-finance-ar-detail-report .ardr-search{align-items:stretch}#page-finance-ar-detail-report .ardr-field select,#page-finance-ar-detail-report .ardr-field input{width:100%}}
</style>
<div class="page-nav"><span class="nav-item" onclick="switchPage('dashboard',this)">首页</span><span class="nav-sep">&gt;</span><span class="nav-item">财务管理</span><span class="nav-sep">&gt;</span><span class="nav-item active">应收款统计表</span></div>
<div class="ardr-titlebar"><h3>应收款统计表</h3><div class="toolbar-actions"><button class="btn btn-default" onclick="printReceivableDetailReport()">打印</button><button class="btn btn-primary" onclick="exportReceivableDetailReport()">导出 Excel</button></div></div>
<div class="ardr-search">
<div class="ardr-field"><label for="ardr-month">统计月份</label><input id="ardr-month" type="month" value="2026-07"/></div>
<div class="ardr-field"><label for="ardr-entity">应收单位</label><select id="ardr-entity"><option value="">全部应收单位</option></select></div>
<div class="ardr-field"><label for="ardr-currency">币种</label><select id="ardr-currency"><option value="">全部币种</option></select></div>
<div class="ardr-search-actions"><button class="btn btn-primary" onclick="renderReceivableDetailReport()">查询</button><button class="btn btn-default" onclick="resetReceivableDetailReportFilters()">重置</button></div>
</div>
<div class="ardr-summary">
<div class="ardr-summary-card"><span>应收总金额</span><strong id="ardr-total-receivable">CNY 0.00</strong></div>
<div class="ardr-summary-card received"><span>已收合计</span><strong id="ardr-total-received">CNY 0.00</strong></div>
<div class="ardr-summary-card"><span>零头减免</span><strong id="ardr-total-deduction">CNY 0.00</strong></div>
<div class="ardr-summary-card balance"><span>余额未收</span><strong id="ardr-total-balance">CNY 0.00</strong></div>
</div>
<div class="table-card"><div class="table-toolbar"><span class="title" id="ardr-table-title">应收款明细</span><span id="ardr-table-count" style="font-size:12px;color:#64748b">客户 / 备注随明细行展示</span></div><div class="inbound-table-container"><table><thead id="thead-receivable-detail-report"></thead><tbody id="tbody-receivable-detail-report"></tbody></table></div></div>
</div>
<!-- ==================== CUSTOMER ARAP DETAIL ==================== -->
<div class="page" id="page-customer-arap-detail">
<div class="page-nav"><span class="nav-item" onclick="backFromArapDetail()">财务管理</span><span class="nav-sep">&gt;</span><span class="nav-item" onclick="backFromArapDetail()">客户应收应付</span><span class="nav-sep">&gt;</span><span class="nav-item active" id="arap-detail-nav-no">单据详情</span></div>
@@ -765,7 +802,7 @@
<span id="arap-detail-title" style="font-size:18px;font-weight:750;color:#1e293b">费用单据详情</span>
<span id="arap-detail-audit-badge" class="tag tag-gray">—</span>
<span id="arap-detail-type-badge" class="tag tag-blue">—</span>
<div style="margin-left:auto;display:flex;gap:8px"><button class="btn btn-default btn-sm" onclick="showToast('进入编辑页面','info')">编辑</button><button class="btn btn-primary btn-sm" id="arap-detail-audit-btn" onclick="showToast('审核操作已提交','success')">审核</button><button class="btn btn-default btn-sm" style="color:#d97706;border-color:#fcd9a8" onclick="showToast('该费用已禁止参与结算','warning')">禁止结算</button></div>
<div style="margin-left:auto;display:flex;gap:8px"><button class="btn btn-default btn-sm" id="arap-detail-edit-btn" onclick="openArapForm(currentArapDetailIndex)">编辑</button><button class="btn btn-primary btn-sm" id="arap-detail-audit-btn" onclick="openArapAudit(currentArapDetailIndex)">审核</button><button class="btn btn-default btn-sm" style="color:#d97706;border-color:#fcd9a8" onclick="showToast('该费用已禁止参与结算','warning')">禁止结算</button></div>
</div>
<div class="stat-row" style="grid-template-columns:repeat(4,1fr);margin-bottom:14px">
<div class="stat-card"><div class="label">应收/应付</div><div class="value" id="arap-detail-type" style="font-size:27px">—</div></div>
@@ -813,8 +850,9 @@
<div class="recon-main-table-wrap">
<table class="recon-main-table">
<thead><tr>
<th style="min-width:150px;text-align:center">操作</th>
<th style="min-width:260px;text-align:center">操作</th>
<th>审核状态</th>
<th>客户是否确认</th>
<th>账单编号</th>
<th>客户公司名</th>
<th>结算起始日</th>
@@ -934,7 +972,6 @@
<th>操作</th>
<th>结算单号</th>
<th>客户公司名</th>
<th>费用类型</th>
<th>币种</th>
<th>应收金额</th>
<th>已收</th>
@@ -975,7 +1012,7 @@
</div>
</div>
<div class="search-row">
<select id="sap-status"><option>全部状态</option><option>待付款</option><option>部分付款</option><option>已结清</option></select>
<span class="search-label">付款状态:</span><select id="sap-status"><option>全部付款状态</option><option>待付款</option><option>部分付款</option><option>已结清</option></select>
<select id="sap-supplier"><option>全部供应商</option><option>中远海运集装箱运输有限公司</option><option>马士基(中国)航运有限公司</option><option>宁波中基惠通报关有限公司</option><option>宁波北仑顺达集装箱运输</option><option>华测检测认证集团</option></select>
<select id="sap-currency"><option>全部币种</option><option>CNY</option><option>USD</option></select>
<input id="sap-no" placeholder="结算单号" style="width:160px"/>
@@ -994,7 +1031,6 @@
<th>操作</th>
<th>结算单号</th>
<th>供应商</th>
<th>费用类型</th>
<th>币种</th>
<th>应付金额</th>
<th>已付</th>
@@ -1058,13 +1094,28 @@
<!-- ==================== SYSTEM USERS ==================== -->
<div class="page" id="page-profit">
<style>#page-profit tbody tr[onclick]{cursor:pointer}#page-profit tbody tr[onclick]:hover{background:#f0f7ff}</style>
<style>
#page-profit tbody tr[onclick]{cursor:pointer}
#page-profit tbody tr[onclick]:hover{background:#f0f7ff}
#page-profit .profit-business-tabs{display:flex;gap:26px;flex-wrap:wrap;border-bottom:2px solid #e5e7eb;margin:4px 0 16px;padding:0 4px}
#page-profit .profit-business-tab{appearance:none;border:0;border-bottom:2px solid transparent;background:transparent;color:#4b5563;cursor:pointer;font-size:14px;margin-bottom:-2px;padding:11px 3px}
#page-profit .profit-business-tab:hover{color:#1677ff}
#page-profit .profit-business-tab.active{border-bottom-color:#1677ff;color:#1677ff;font-weight:600}
#page-profit .profit-dimension-tabs{display:inline-flex;max-width:100%;gap:2px;flex-wrap:wrap;background:#f5f7fa;border:1px solid #e5e7eb;border-radius:6px;margin:14px 0 12px;padding:3px}
#page-profit .profit-stab{border:1px solid transparent;border-radius:4px;color:#4b5563;cursor:pointer;font-size:13px;padding:7px 18px}
#page-profit .profit-stab.active{background:#fff;border-color:#dbe3ee;color:#1677ff;font-weight:600;box-shadow:0 1px 2px rgba(15,23,42,.04)}
</style>
<div class="breadcrumb">首页 <span class="sep">&gt;</span> 财务管理 <span class="sep">&gt;</span> <strong>利润表</strong></div>
<div class="table-card">
<div class="table-toolbar"><span class="title">财务利润表</span><div class="toolbar-actions"><button class="btn btn-default">导出Excel</button></div></div>
<div id="profit-business-tabs" class="profit-business-tabs">
<button type="button" class="profit-business-tab active" data-profit-business="firstMile" onclick="switchProfitBusiness('firstMile',this)">头程</button>
<button type="button" class="profit-business-tab" data-profit-business="lastMile" onclick="switchProfitBusiness('lastMile',this)">尾程</button>
</div>
<div class="search-row" style="flex-wrap:wrap;gap:8px">
<span style="color:#666;align-self:center">查询维度</span>
<select id="profit-scope" style="width:130px" onchange="profitScopeChange()"><option value="customer">客户</option><option value="container">货柜</option><option value="ticket">单票</option><option value="month">单月</option></select>
<select id="profit-order-type" style="width:150px"><option value="">全部订单类型</option><option value="暂扣">暂扣</option><option value="退货">退货</option><option value="仓储转运">仓储转运</option><option value="换标">换标</option></select>
<input id="profit-kw" placeholder="请输入客户名称" style="width:230px"/>
<span style="color:#999;align-self:center">时间</span>
<input id="profit-start" type="month" style="width:150px" title="开始月份"/>
@@ -1080,11 +1131,11 @@
<div style="padding:14px 16px;border:1px solid #e5e7eb;border-radius:8px;background:#f6ffed"><div style="font-size:12px;color:#888">利润合计</div><div id="profit-sum-profit" style="font-size:20px;font-weight:700;color:#52c41a;margin-top:4px">¥0</div></div>
<div style="padding:14px 16px;border:1px solid #e5e7eb;border-radius:8px;background:#f9f0ff"><div style="font-size:12px;color:#888">综合利润率</div><div id="profit-sum-rate" style="font-size:20px;font-weight:700;color:#722ed1;margin-top:4px">0.0%</div></div>
</div>
<div style="display:flex;gap:0;border-bottom:2px solid #e5e7eb;margin:14px 0 12px;padding:0 4px">
<div class="profit-stab active" data-profit-tab="customer" onclick="switchProfitTab(this,'customer')" style="padding:10px 22px;font-size:14px;cursor:pointer;color:#1677ff;border-bottom:2px solid #1677ff;margin-bottom:-2px;font-weight:600">客户</div>
<div class="profit-stab" data-profit-tab="container" onclick="switchProfitTab(this,'container')" style="padding:10px 22px;font-size:14px;cursor:pointer;color:#374151;border-bottom:2px solid transparent;margin-bottom:-2px">货柜</div>
<div class="profit-stab" data-profit-tab="ticket" onclick="switchProfitTab(this,'ticket')" style="padding:10px 22px;font-size:14px;cursor:pointer;color:#374151;border-bottom:2px solid transparent;margin-bottom:-2px">单票</div>
<div class="profit-stab" data-profit-tab="month" onclick="switchProfitTab(this,'month')" style="padding:10px 22px;font-size:14px;cursor:pointer;color:#374151;border-bottom:2px solid transparent;margin-bottom:-2px">单月</div>
<div class="profit-dimension-tabs">
<div class="profit-stab active" data-profit-tab="customer" onclick="switchProfitTab(this,'customer')">客户</div>
<div class="profit-stab" data-profit-tab="container" onclick="switchProfitTab(this,'container')">货柜</div>
<div class="profit-stab" data-profit-tab="ticket" onclick="switchProfitTab(this,'ticket')">单票</div>
<div class="profit-stab" data-profit-tab="month" onclick="switchProfitTab(this,'month')">单月</div>
</div>
<!-- 客户维度 -->
@@ -86,7 +86,7 @@
<button class="btn btn-sm btn-primary" onclick="renderSalesCommission()">查询</button>
<button class="btn btn-sm btn-default" onclick="scReset()">重置</button>
</div>
<table><thead><tr><th>销售编号</th><th>销售姓名</th><th>区域/团队</th><th>客户数</th><th>订单数</th><th>销售额(元)</th><th>综合提成率</th><th>提成金额(元)</th><th>操作</th></tr></thead><tbody id="tbody-sales-commission"></tbody><tfoot id="tfoot-sales-commission"></tfoot></table>
<table><thead><tr><th>销售编号</th><th>销售姓名</th><th>区域/团队</th><th>客户数</th><th>订单数</th><th>销售额(元)</th><th>综合提成率</th><th>固定提成(元)</th><th>溢价提成(元)</th><th>垫款利息(元)</th><th>提成金额(元)</th><th>操作</th></tr></thead><tbody id="tbody-sales-commission"></tbody><tfoot id="tfoot-sales-commission"></tfoot></table>
</div>
</div>
@@ -11,13 +11,205 @@
<table><thead><tr><th>分类</th><th>国内国际</th><th>费用类型名称</th><th>货柜应付</th><th>货柜应收</th><th>仓储应付</th><th>仓储应收</th><th>平摊费用</th><th>说明</th><th>创建人</th><th>创建日期</th><th>更新人</th><th>更新日期</th><th>操作</th></tr></thead><tbody id="tbody-gen-fee"></tbody></table>
<div class="pagination"><span>共 110 条</span><button class="page-btn active">1</button><button class="page-btn">2</button><button class="page-btn">3</button><button class="page-btn">...</button><button class="page-btn">11</button></div></div></div>
<!-- ==================== OUTBOUND VEHICLE (排车列表) ==================== -->
<!--
THESIS: 月度汇率是可追溯的业务基准,不是一次性的成本输入;页面拒绝实时多币种行情布局。
OWN-WORLD: 继承现有后台的白色工作台、蓝色主操作、紧凑筛选与数据表格语言。
STORY: 管理员先确认最新维护记录,再筛选历史月份,并从唯一主操作更新月平均汇率。
FIRST VIEWPORT: 四项紧凑概览在上,查询与更新操作紧接其下,月度历史表占据主要扫描区域。
FORM: 已建立后台列表页的精确扩展;本次需求已明确,因此不使用概念随机种子。
-->
<div class="page" id="page-gen-exchange-rate">
<div class="stat-row exchange-rate-summary">
<div class="stat-card"><div class="decorator"></div><div class="label">最新维护汇率</div><div class="value" id="exchange-rate-latest">-</div></div>
<div class="stat-card"><div class="decorator"></div><div class="label">汇率月份</div><div class="value" id="exchange-rate-latest-month">-</div></div>
<div class="stat-card"><div class="decorator"></div><div class="label">最后更新人</div><div class="value" id="exchange-rate-latest-user">-</div></div>
<div class="stat-card"><div class="decorator"></div><div class="label">最后更新时间</div><div class="value" id="exchange-rate-latest-time">-</div></div>
</div>
<div class="table-card">
<div class="table-toolbar"><div><span class="title">美元兑人民币月平均汇率</span><span style="font-size:12px;color:#94a3b8;margin-left:10px">统一口径:1 USD = X CNY</span></div><div class="toolbar-actions"><button class="btn btn-primary" onclick="openExchangeRateModal()">+ 更新月度汇率</button></div></div>
<div class="search-row"><input id="exchange-rate-month-filter" type="month" aria-label="查询月份"/><button class="btn btn-sm btn-primary" onclick="renderExchangeRates()">查询</button><button class="btn btn-sm btn-default" onclick="resetExchangeRateFilters()">重置</button></div>
<div style="overflow-x:auto"><table style="min-width:980px"><thead><tr><th>汇率月份</th><th>平均汇率</th><th>换算说明</th><th>状态</th><th>更新人</th><th>更新时间</th><th>备注</th><th>操作</th></tr></thead><tbody id="tbody-exchange-rate"></tbody></table></div>
</div>
</div>
<!-- ==================== WAREHOUSE MANAGEMENT ==================== -->
<div class="page" id="page-gen-owned-warehouse">
<div class="table-card"><div class="table-toolbar"><span class="title">仓库管理</span><div class="toolbar-actions"><button class="btn btn-primary" onclick="openWarehouseAdd('owned')">+ 新增</button><button class="btn btn-default">导入</button><button class="btn btn-default">导出</button></div></div>
<div class="warehouse-page-tabs" role="tablist" aria-label="仓库分类"><button type="button" class="warehouse-page-tab active" role="tab" aria-selected="true" onclick="switchWarehousePage('gen-owned-warehouse',this)">自有仓</button><button type="button" class="warehouse-page-tab" role="tab" aria-selected="false" onclick="switchWarehousePage('gen-fba',this)">FBA仓库</button><button type="button" class="warehouse-page-tab" role="tab" aria-selected="false" onclick="switchWarehousePage('gen-partner-warehouse',this)">合作仓</button></div>
<div class="search-row"><input id="gen-owned-warehouse-keyword" placeholder="自有仓名称/地址" style="width:200px"/><select id="gen-owned-warehouse-country"><option value="">全部国家</option><option>美国</option><option>英国</option><option>德国</option><option>日本</option></select><button class="btn btn-sm btn-primary" onclick="renderOwnedWarehouse()">查询</button></div>
<div class="warehouse-table-wrap"><table><thead><tr><th>自有仓名称</th><th>仓库地址</th><th>城市</th><th>州/省</th><th>邮编</th><th>国家</th><th>状态</th><th>操作</th></tr></thead><tbody id="tbody-gen-owned-warehouse"></tbody></table></div>
<div class="pagination"><span>共 2 条</span><button class="page-btn active">1</button></div></div></div>
<div class="page" id="page-gen-fba">
<div class="table-card"><div class="table-toolbar"><span class="title">仓库管理</span><div class="toolbar-actions"><button class="btn btn-primary" onclick="showModal('modal-fba-add')">+ 新增</button><button class="btn btn-default">导入</button><button class="btn btn-default">导出</button></div></div>
<div class="search-row"><input placeholder="仓库代码/地址" style="width:200px"/><select><option>全部国家</option><option>美国</option><option>英国</option><option>德国</option><option>日本</option></select><button class="btn btn-sm btn-primary">查询</button></div>
<table><thead><tr><th>仓库代码</th><th>仓库地址</th><th>城市</th><th>州/省</th><th>邮编</th><th>国家</th><th>状态</th><th>操作</th></tr></thead><tbody id="tbody-gen-fba"></tbody></table>
<div class="table-card"><div class="table-toolbar"><span class="title">仓库管理</span><div class="toolbar-actions"><button class="btn btn-primary" onclick="openWarehouseAdd('fba')">+ 新增</button><button class="btn btn-default">导入</button><button class="btn btn-default">导出</button></div></div>
<div class="warehouse-page-tabs" role="tablist" aria-label="仓库分类"><button type="button" class="warehouse-page-tab" role="tab" aria-selected="false" onclick="switchWarehousePage('gen-owned-warehouse',this)">自有仓</button><button type="button" class="warehouse-page-tab active" role="tab" aria-selected="true" onclick="switchWarehousePage('gen-fba',this)">FBA仓库</button><button type="button" class="warehouse-page-tab" role="tab" aria-selected="false" onclick="switchWarehousePage('gen-partner-warehouse',this)">合作仓</button></div>
<div class="search-row"><span style="font-size:12px;color:#475569;font-weight:600">自有仓</span><select id="gen-fba-owned-warehouse" data-owned-warehouse-select aria-label="自有仓"></select><input id="gen-fba-keyword" placeholder="仓库代码/地址" style="width:200px"/><select id="gen-fba-country"><option value="">全部国家</option><option>美国</option><option>英国</option><option>德国</option><option>日本</option></select><button class="btn btn-sm btn-primary" onclick="renderGenFba()">查询</button></div>
<div class="warehouse-table-wrap"><table><thead><tr><th>仓库代码</th><th>仓库地址</th><th>城市</th><th>州/省</th><th>邮编</th><th>国家</th><th>距离(MILES)</th><th>状态</th><th>操作</th></tr></thead><tbody id="tbody-gen-fba"></tbody></table></div>
<div class="pagination"><span>共 56 条</span><button class="page-btn active">1</button><button class="page-btn">2</button><button class="page-btn">3</button><button class="page-btn">...</button><button class="page-btn">6</button></div></div></div>
<div class="page" id="page-gen-partner-warehouse">
<div class="table-card"><div class="table-toolbar"><span class="title">仓库管理</span><div class="toolbar-actions"><button class="btn btn-primary" onclick="openWarehouseAdd('partner')">+ 新增</button><button class="btn btn-default">导入</button><button class="btn btn-default">导出</button></div></div>
<div class="warehouse-page-tabs" role="tablist" aria-label="仓库分类"><button type="button" class="warehouse-page-tab" role="tab" aria-selected="false" onclick="switchWarehousePage('gen-owned-warehouse',this)">自有仓</button><button type="button" class="warehouse-page-tab" role="tab" aria-selected="false" onclick="switchWarehousePage('gen-fba',this)">FBA仓库</button><button type="button" class="warehouse-page-tab active" role="tab" aria-selected="true" onclick="switchWarehousePage('gen-partner-warehouse',this)">合作仓</button></div>
<div class="search-row"><span style="font-size:12px;color:#475569;font-weight:600">自有仓</span><select id="gen-partner-warehouse-owned-warehouse" data-owned-warehouse-select aria-label="自有仓"></select><input id="gen-partner-warehouse-keyword" placeholder="仓库代码/地址" style="width:200px"/><select id="gen-partner-warehouse-country"><option value="">全部国家</option><option>美国</option><option>英国</option><option>德国</option><option>日本</option></select><button class="btn btn-sm btn-primary" onclick="renderPartnerWarehouse()">查询</button></div>
<div class="warehouse-table-wrap"><table><thead><tr><th>仓库代码</th><th>仓库地址</th><th>城市</th><th>州/省</th><th>邮编</th><th>国家</th><th>距离(MILES)</th><th>状态</th><th>操作</th></tr></thead><tbody id="tbody-gen-partner-warehouse"></tbody></table></div>
<div class="pagination"><span>共 1 条</span><button class="page-btn active">1</button></div></div></div>
<style>
#page-gen-overseas-quote .goq-table-wrap{overflow-x:auto;border-top:1px solid #edf0f4;border-bottom:1px solid #edf0f4}
#page-gen-overseas-quote .goq-table{min-width:1480px;table-layout:fixed;font-size:12px}
#page-gen-overseas-quote .goq-table th,#page-gen-overseas-quote .goq-table td{padding:10px 9px;text-align:left;white-space:nowrap}
#page-gen-overseas-quote .goq-table th:first-child,#page-gen-overseas-quote .goq-table td:first-child{width:250px}
#page-gen-overseas-quote .goq-table th:nth-child(2){width:190px}
#page-gen-overseas-quote .goq-table th:nth-child(3){width:170px}
#page-gen-overseas-quote .goq-table th:nth-child(4){width:180px}
#page-gen-overseas-quote .goq-table th:nth-child(5){width:74px}
#page-gen-overseas-quote .goq-table th:nth-child(6),#page-gen-overseas-quote .goq-table th:nth-child(7){width:112px}
#page-gen-overseas-quote .goq-table th:nth-child(8){width:92px}
#page-gen-overseas-quote .goq-table th:nth-child(9){width:76px}
#page-gen-overseas-quote .goq-table th:nth-child(10){width:142px}
#page-gen-overseas-quote .goq-table td{overflow:hidden;text-overflow:ellipsis}
#page-gen-overseas-quote .goq-table td:first-child{overflow:visible}
#page-gen-overseas-quote .goq-name{font-weight:700;color:#1e293b}
#page-gen-overseas-quote .goq-actions{display:flex;align-items:center;gap:6px}
#page-gen-overseas-quote .goq-actions .btn{padding:6px 10px;font-size:12px;border-radius:7px}
#modal-gen-overseas-quote .modal{display:flex;flex-direction:column;overflow:hidden}
#modal-gen-overseas-quote .modal-header,#modal-gen-overseas-quote .modal-footer{flex:0 0 auto}
#modal-gen-overseas-quote .goq-modal-body{min-height:0;padding:22px 26px;overflow-y:auto}
#modal-gen-overseas-quote .goq-form-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px 16px}
#modal-gen-overseas-quote .goq-form-grid .form-group{min-width:0;margin:0}
#modal-gen-overseas-quote .goq-form-grid .goq-span-2{grid-column:span 2}
#modal-gen-overseas-quote .goq-form-grid input,#modal-gen-overseas-quote .goq-form-grid select,#modal-gen-overseas-quote .goq-form-grid textarea{width:100%;box-sizing:border-box}
#modal-gen-overseas-quote .goq-section{margin-top:20px;border:1px solid #e5eaf1;border-radius:8px;background:#fff;overflow:hidden}
#modal-gen-overseas-quote .goq-section-head{min-height:48px;padding:10px 14px;display:flex;align-items:center;justify-content:space-between;gap:12px;background:#f7f8fc;border-bottom:1px solid #e5eaf1}
#modal-gen-overseas-quote .goq-section-title{font-size:14px;font-weight:700;color:#334155}
#modal-gen-overseas-quote .goq-detail-wrap{overflow-x:auto}
#modal-gen-overseas-quote .goq-detail-table{min-width:980px;table-layout:fixed;font-size:12px}
#modal-gen-overseas-quote .goq-transit-table{min-width:1220px}
#modal-gen-overseas-quote .goq-detail-table th{padding:9px 8px;background:#fff;color:#64748b;font-weight:650}
#modal-gen-overseas-quote .goq-detail-table td{padding:7px 8px;vertical-align:middle}
#modal-gen-overseas-quote .goq-detail-table input,#modal-gen-overseas-quote .goq-detail-table select{width:100%;height:34px;padding:0 9px;border:1px solid #dce1ea;border-radius:6px;background:#fff;color:#334155;font:12px inherit;outline:none}
#modal-gen-overseas-quote .goq-detail-table input:focus,#modal-gen-overseas-quote .goq-detail-table select:focus{border-color:#667eea;box-shadow:0 0 0 2px rgba(102,126,234,.1)}
#modal-gen-overseas-quote .goq-detail-table th:last-child,#modal-gen-overseas-quote .goq-detail-table td:last-child{width:54px;text-align:center}
#modal-gen-overseas-quote .goq-row-delete{width:32px;height:32px;padding:0;border:1px solid #fecaca;border-radius:6px;background:#fff;color:#dc2626;font-size:18px;line-height:30px;cursor:pointer}
#modal-gen-overseas-quote .goq-row-delete:hover{background:#fff1f2;border-color:#fda4af}
#modal-gen-overseas-quote-detail .goq-detail-modal{max-width:980px;width:96%;display:flex;flex-direction:column;overflow:hidden}
#modal-gen-overseas-quote-detail .modal-header,#modal-gen-overseas-quote-detail .modal-footer{flex:0 0 auto}
#modal-gen-overseas-quote-detail .goq-detail-modal-body{min-height:0;padding:22px 26px;overflow-y:auto}
#modal-gen-overseas-quote-detail .goq-detail-summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px 16px;padding:14px;border:1px solid #e5eaf1;border-radius:8px;background:#f8fafc}
#modal-gen-overseas-quote-detail .goq-detail-summary-item{min-width:0}
#modal-gen-overseas-quote-detail .goq-detail-summary-key{display:block;margin-bottom:4px;color:#64748b;font-size:12px}
#modal-gen-overseas-quote-detail .goq-detail-summary-value{display:block;overflow:hidden;color:#1e293b;font-size:13px;font-weight:600;text-overflow:ellipsis;white-space:nowrap}
#modal-gen-overseas-quote-detail .goq-section{margin-top:20px;border:1px solid #e5eaf1;border-radius:8px;background:#fff;overflow:hidden}
#modal-gen-overseas-quote-detail .goq-section-head{min-height:48px;padding:10px 14px;display:flex;align-items:center;background:#f7f8fc;border-bottom:1px solid #e5eaf1}
#modal-gen-overseas-quote-detail .goq-section-title{font-size:14px;font-weight:700;color:#334155}
#modal-gen-overseas-quote-detail .goq-detail-wrap{overflow-x:auto}
#modal-gen-overseas-quote-detail .goq-detail-table{min-width:760px;table-layout:fixed;font-size:12px}
#modal-gen-overseas-quote-detail .goq-transit-table{min-width:1080px}
#modal-gen-overseas-quote-detail .goq-detail-table th{padding:9px 8px;background:#fff;color:#64748b;font-weight:650;text-align:left;white-space:nowrap}
#modal-gen-overseas-quote-detail .goq-detail-table td{padding:9px 8px;color:#334155;vertical-align:top;white-space:nowrap}
#modal-gen-overseas-quote-detail .goq-detail-table td:last-child{white-space:normal}
@media(max-width:720px){#modal-gen-overseas-quote-detail .goq-detail-modal{width:96%;max-height:92vh;border-radius:14px}#modal-gen-overseas-quote-detail .goq-detail-modal-body{padding:18px}#modal-gen-overseas-quote-detail .goq-detail-summary{grid-template-columns:repeat(2,minmax(0,1fr));gap:12px} }
@media(max-width:1100px){#modal-gen-overseas-quote .goq-form-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:720px){#modal-gen-overseas-quote .modal{width:96%;max-height:92vh;border-radius:14px}#modal-gen-overseas-quote .goq-modal-body{padding:18px}#modal-gen-overseas-quote .goq-form-grid{grid-template-columns:1fr}#modal-gen-overseas-quote .goq-form-grid .goq-span-2{grid-column:auto}#modal-gen-overseas-quote .goq-section-head{align-items:flex-start;flex-direction:column}}
#page-delivery-fleet-quote .dfq-table-wrap{margin:0 20px;overflow-x:auto;border:1px solid #e8ebf2;border-radius:10px;background:#fff}
#page-delivery-fleet-quote .dfq-table{min-width:1380px;font-size:12px}
#page-delivery-fleet-quote .dfq-table th{padding:11px 9px;background:#f7f8fc;color:#475569;white-space:nowrap;text-align:left}
#page-delivery-fleet-quote .dfq-table td{padding:10px 9px;white-space:nowrap;text-align:left;border-bottom:1px solid #f0f2f5}
#page-delivery-fleet-quote .dfq-table tbody tr:last-child td{border-bottom:0}
#page-delivery-fleet-quote .dfq-table .dfq-origin-cell,#page-delivery-fleet-quote .dfq-table .dfq-warehouse-cell{font-weight:650;color:#1e293b;text-align:left}
#page-delivery-fleet-quote .dfq-table .dfq-mile-cell{font-variant-numeric:tabular-nums;color:#475569}
#page-delivery-fleet-quote .dfq-table .dfq-price-cell{font-variant-numeric:tabular-nums;color:#2563eb;font-weight:700;text-align:left}
#page-delivery-fleet-quote .dfq-table .dfq-quote-empty{color:#94a3b8;font-weight:400}
#page-delivery-fleet-quote .dfq-table .dfq-actions{display:flex;gap:6px;justify-content:flex-start}
#page-delivery-fleet-quote .dfq-helper{padding:12px 22px 18px;color:#8490a3;font-size:12px}
#modal-delivery-fleet-quote .dfq-modal-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:15px 18px}
#modal-delivery-fleet-quote .dfq-modal-grid .form-group.full{grid-column:1 / -1}
#modal-delivery-fleet-quote .dfq-modal-grid input,#modal-delivery-fleet-quote .dfq-modal-grid select,#modal-delivery-fleet-quote .dfq-modal-grid textarea{width:100%;box-sizing:border-box}
#modal-delivery-fleet-quote-import .dfq-import-picker{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:14px 16px;border:1px dashed #b8c7da;border-radius:8px;background:#f8fafc}
#modal-delivery-fleet-quote-import .dfq-import-copy{min-width:0}
#modal-delivery-fleet-quote-import .dfq-import-title{margin-bottom:5px;color:#334155;font-size:13px;font-weight:650}
#modal-delivery-fleet-quote-import .dfq-import-hint{color:#8491a3;font-size:12px;line-height:1.6}
#modal-delivery-fleet-quote-import .dfq-import-actions{display:flex;align-items:center;gap:8px;min-width:0}
#modal-delivery-fleet-quote-import .dfq-import-actions input{min-width:0;max-width:100%;font-size:13px;color:#334155}
#modal-delivery-fleet-quote-import .dfq-import-summary{margin:12px 0;padding:10px 12px;border-radius:6px;background:#f1f6ff;color:#3b5b8a;font-size:12px;line-height:1.6}
#modal-delivery-fleet-quote-import .dfq-import-summary strong{color:#2563eb}
#modal-delivery-fleet-quote-import .dfq-import-preview{max-height:300px;overflow:auto;border:1px solid #e5eaf1;border-radius:7px}
#modal-delivery-fleet-quote-import .dfq-import-preview table{min-width:1120px;font-size:12px}
#modal-delivery-fleet-quote-import .dfq-import-preview th{position:sticky;top:0;z-index:1;background:#f7f8fc;text-align:left;white-space:nowrap}
#modal-delivery-fleet-quote-import .dfq-import-preview td{max-width:220px;overflow-wrap:anywhere;text-align:left;vertical-align:top}
#modal-delivery-fleet-quote-import .dfq-import-empty{padding:42px!important;text-align:center!important;color:#94a3b8}
#modal-delivery-fleet-quote-import .btn:disabled{border-color:#d9dee8;background:#eef1f5;color:#9aa4b2;box-shadow:none;cursor:not-allowed}
@media(max-width:720px){#modal-delivery-fleet-quote .dfq-modal-grid{grid-template-columns:1fr}#modal-delivery-fleet-quote .dfq-modal-grid .form-group.full{grid-column:auto}#modal-delivery-fleet-quote-import .dfq-import-picker{align-items:stretch;flex-direction:column}#modal-delivery-fleet-quote-import .dfq-import-actions{align-items:stretch;flex-direction:column}#modal-delivery-fleet-quote-import .dfq-import-actions .btn{align-self:flex-start}}
</style>
<!--
THESIS: 把分散的仓储作业费与中转派送费收束为可扫描、可启停的报价方案,拒绝在主列表中铺开全部费用列。
OWN-WORLD: 继承平台白底密集表格、紫色主操作、低饱和状态标签与紧凑表单语言。
STORY: 操作员先筛选报价方案,再在单一弹窗中维护基础信息、仓储费用和 FBA 中转派送价格。
FIRST VIEWPORT: 面包屑后直接呈现工具栏、五项筛选和方案表格,新增报价位于右上主操作位。
FORM: 通用模块中的管理清单加分区编辑弹窗,作为既有后台视觉体系的局部扩展。
-->
<div class="page" id="page-gen-overseas-quote">
<div class="breadcrumb">首页 <span class="sep">&gt;</span> 通用 <span class="sep">&gt;</span> <strong>海外仓报价</strong></div>
<div class="table-card">
<div class="table-toolbar"><span class="title">海外仓报价</span><div class="toolbar-actions"><button class="btn btn-primary" onclick="openOverseasWarehouseQuoteModal()">+ 新增报价</button><button class="btn btn-default" onclick="showToast('海外仓报价已导出','success')">导出Excel</button></div></div>
<div class="search-row" aria-label="海外仓报价查询">
<input id="goq-filter-keyword" placeholder="报价名称 / 版本" style="width:180px"/>
<select id="goq-filter-warehouse" aria-label="海外仓" style="width:180px"><option value="">全部海外仓</option><option>洛杉矶长滩海外仓</option><option>纽约海外仓</option><option>芝加哥海外仓</option></select>
<input id="goq-filter-provider" placeholder="服务商" style="width:170px"/>
<select id="goq-filter-currency" aria-label="币种" style="width:120px"><option value="">全部币种</option><option>USD</option><option>CNY</option></select>
<select id="goq-filter-status" aria-label="状态" style="width:120px"><option value="">全部状态</option><option>启用</option><option>停用</option></select>
<button class="btn btn-sm btn-primary" onclick="renderOverseasWarehouseQuotes()">查询</button>
<button class="btn btn-sm btn-default" onclick="resetOverseasWarehouseQuoteFilters()">重置</button>
</div>
<div class="goq-table-wrap"><table class="goq-table"><thead><tr><th>操作</th><th>报价名称</th><th>海外仓</th><th>服务商</th><th>币种</th><th>生效日期</th><th>失效日期</th><th>报价版本</th><th>状态</th><th>更新时间</th><th>备注</th></tr></thead><tbody id="tbody-gen-overseas-quote"></tbody></table></div>
<div class="pagination"><span id="goq-total">共 0 条</span><button class="page-btn active">1</button></div>
</div>
</div>
<div class="modal-overlay" id="modal-gen-overseas-quote">
<div class="modal" style="max-width:1180px;width:96%">
<div class="modal-header"><span id="goq-modal-title">新增海外仓报价</span><span onclick="hideModal('modal-gen-overseas-quote')" style="cursor:pointer;font-size:20px" aria-label="关闭">&times;</span></div>
<div class="modal-body goq-modal-body">
<input type="hidden" id="goq-edit-id"/>
<div class="goq-form-grid">
<div class="form-group goq-span-2"><label>报价名称 <span style="color:#ef4444">*</span></label><input id="goq-name" placeholder="例如:美国西部标准仓报价"/></div>
<div class="form-group"><label>海外仓 <span style="color:#ef4444">*</span></label><select id="goq-warehouse" onchange="syncOverseasTransitWarehouse(this.value)"><option value="">请选择海外仓</option><option>洛杉矶长滩海外仓</option><option>纽约海外仓</option><option>芝加哥海外仓</option></select></div>
<div class="form-group"><label>服务商 <span style="color:#ef4444">*</span></label><input id="goq-provider" placeholder="请输入服务商"/></div>
<div class="form-group"><label>币种 <span style="color:#ef4444">*</span></label><select id="goq-currency"><option>USD</option><option>CNY</option></select></div>
<div class="form-group"><label>生效日期 <span style="color:#ef4444">*</span></label><input id="goq-effective-start" type="date"/></div>
<div class="form-group"><label>失效日期</label><input id="goq-effective-end" type="date"/></div>
<div class="form-group"><label>报价版本 <span style="color:#ef4444">*</span></label><input id="goq-version" placeholder="例如:V1.0"/></div>
<div class="form-group"><label>状态</label><select id="goq-status"><option>启用</option><option>停用</option></select></div>
<div class="form-group goq-span-2"><label>备注</label><textarea id="goq-remark" rows="2" placeholder="请输入报价说明"></textarea></div>
</div>
<section class="goq-section" aria-labelledby="goq-fee-title">
<div class="goq-section-head"><span class="goq-section-title" id="goq-fee-title">仓储作业费用</span><button type="button" class="btn btn-sm btn-default" onclick="addOverseasWarehouseFeeRow()">+ 添加费用</button></div>
<div class="goq-detail-wrap"><table class="goq-detail-table"><thead><tr><th style="width:170px">费用项目</th><th style="width:145px">计费单位</th><th style="width:130px">单价</th><th style="width:130px">最低收费</th><th>备注</th><th>操作</th></tr></thead><tbody id="tbody-goq-warehouse-fees"></tbody></table></div>
</section>
<section class="goq-section" aria-labelledby="goq-transit-title">
<div class="goq-section-head"><span class="goq-section-title" id="goq-transit-title">中转派送价格</span><button type="button" class="btn btn-sm btn-default" onclick="addOverseasTransitPriceRow()">+ 添加派送价格</button></div>
<div class="goq-detail-wrap"><table class="goq-detail-table goq-transit-table"><thead><tr><th style="width:185px">海外仓</th><th style="width:130px">目的 FBA 仓</th><th style="width:155px">车型/提货方式</th><th style="width:120px">预估迈数</th><th style="width:125px">报价</th><th style="width:125px">最低收费</th><th>备注</th><th>操作</th></tr></thead><tbody id="tbody-goq-transit-prices"></tbody></table></div>
</section>
</div>
<div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-gen-overseas-quote')">取消</button><button class="btn btn-primary" onclick="saveOverseasWarehouseQuote()">保存报价</button></div>
</div>
</div>
<div class="modal-overlay" id="modal-gen-overseas-quote-detail">
<div class="modal goq-detail-modal">
<div class="modal-header"><span id="goq-detail-title">海外仓报价详情</span><span onclick="hideModal('modal-gen-overseas-quote-detail')" style="cursor:pointer;font-size:20px" aria-label="关闭">&times;</span></div>
<div class="modal-body goq-detail-modal-body">
<div id="goq-detail-summary" class="goq-detail-summary"></div>
<section class="goq-section" aria-labelledby="goq-detail-fees-title"><div class="goq-section-head"><span class="goq-section-title" id="goq-detail-fees-title">仓储作业费用</span></div><div class="goq-detail-wrap"><table class="goq-detail-table"><thead><tr><th style="width:170px">费用项目</th><th style="width:145px">计费单位</th><th style="width:130px">单价</th><th style="width:130px">最低收费</th><th>备注</th></tr></thead><tbody id="tbody-goq-detail-fees"></tbody></table></div></section>
<section class="goq-section" aria-labelledby="goq-detail-transit-title"><div class="goq-section-head"><span class="goq-section-title" id="goq-detail-transit-title">中转派送价格</span></div><div class="goq-detail-wrap"><table class="goq-detail-table goq-transit-table"><thead><tr><th style="width:185px">海外仓</th><th style="width:130px">目的 FBA 仓</th><th style="width:155px">车型/提货方式</th><th style="width:120px">预估迈数</th><th style="width:125px">报价</th><th style="width:125px">最低收费</th><th>备注</th></tr></thead><tbody id="tbody-goq-detail-transit"></tbody></table></div></section>
</div>
<div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-gen-overseas-quote-detail')">关闭</button><button class="btn btn-primary" id="goq-detail-edit">编辑</button></div>
</div>
</div>
<div class="page" id="page-gen-download">
<div class="table-card">
<div class="table-toolbar"><span class="title">下载中心</span></div>
@@ -91,42 +283,40 @@
<div class="cost-fields">
<div class="cost-field"><label>渠道</label><select id="cost-channel"><option>芝加哥普船海铁</option><option>纽约EXX海铁</option><option>洛杉矶普船海卡</option></select></div>
<div class="cost-field"><label>计税方式</label><select id="cost-tax-mode" onchange="toggleCostTaxMode()"><option value="tax">包税(按重量)</option><option value="noTax">不包税(按立方)</option></select></div>
<div class="cost-field"><label>船期</label><input id="cost-voyage-date" type="date" value="2026-08-13"/></div>
<div class="cost-field"><label>船期</label><input id="cost-voyage-date" type="date" value="2026-08-13" onchange="applyPreviousMonthRate(this.value)"/></div>
<div class="cost-field"><label>海运费成本(USD)</label><input id="cost-ocean" type="number" value="8700" step="100"/></div>
<div class="cost-field"><label>汇率</label><input id="cost-rate" type="number" value="6.9" step="0.01"/></div>
<div class="cost-field"><label>汇率</label><input id="cost-rate" type="number" value="6.9" step="0.0001"/><small id="cost-rate-source" style="display:block;margin-top:5px;color:#64748b;line-height:1.35">等待读取上月平均汇率</small></div>
<div class="cost-field"><label id="cost-markup-label">报价加成(元/kg)</label><input id="cost-markup" type="number" value="0.6" step="0.1"/></div>
<div class="cost-field"><label>参考时效</label><input id="cost-time" value="船开后约35天"/></div>
</div>
</div>
<div class="cost-table-wrap"><table class="cost-table"><thead><tr><th>操作</th><th>亚马逊仓</th><th>AMS+换单费</th><th>内陆</th><th>清关/报关费</th><th>关税</th><th>提柜费</th><th>拆柜费</th><th>托盘费</th><th>派送费</th><th>费用小计</th><th>海运费成本</th><th>总成本(USD)</th><th>汇率</th><th>折人民币</th><th id="cost-base-header">重量(kg)</th><th id="cost-unit-header">成本单价(元/kg)</th><th>建议报价</th></tr></thead><tbody id="tbody-voyage-cost"></tbody></table></div>
<div class="cost-footnote">计算公式:费用小计 = AMS/换单 + 内陆 + 清关/报关 + 关税 + 提柜 + 拆柜 + 托盘 + 派送;总成本 = 小计 + 海运费;成本单价 = 总成本 × 汇率 ÷ 计费基数。</div>
<div class="cost-table-wrap"><table class="cost-table"><thead><tr><th>操作</th><th>亚马逊仓</th><th>AMS+换单费</th><th>内陆</th><th>清关/报关费</th><th>关税</th><th>提柜费</th><th>拆柜费</th><th>托盘费</th><th>派送费</th><th>费用小计</th><th>海运费成本</th><th>总成本(USD)</th><th>汇率</th><th>折人民币</th><th id="cost-base-header">重量(kg)</th><th id="cost-unit-header">成本单价(元/kg)</th><th>销售底价</th><th>建议报价</th></tr></thead><tbody id="tbody-voyage-cost"></tbody></table></div>
<div class="cost-footnote">计算公式:费用小计 = AMS/换单 + 内陆 + 清关/报关 + 关税 + 提柜 + 拆柜 + 托盘 + 派送;总成本 = 小计 + 海运费;成本单价 = 总成本 × 汇率 ÷ 计费基数;销售底价 = 建议报价 - 0.6。</div>
</div>
</div></div>
<!-- ==================== SALES MANAGEMENT ==================== -->
<div class="page" id="page-vehicle">
<div class="breadcrumb">首页 <span class="sep">&gt;</span> 车辆管理 <span class="sep">&gt;</span> <strong>车辆列表</strong></div>
<div class="breadcrumb">首页 <span class="sep">&gt;</span> 通用 <span class="sep">&gt;</span> <strong>车辆列表</strong></div>
<div class="table-card">
<div class="table-toolbar"><span class="title">车辆列表</span><div class="toolbar-actions"><button class="btn btn-primary" onclick="openVehicleModal('add')">+ 添加车辆</button><button class="btn btn-default">导出Excel</button></div></div>
<div class="vehicle-region-tabs" role="tablist" aria-label="车辆区域">
<button type="button" class="vehicle-region-tab active" data-region="domestic" role="tab" aria-selected="true" onclick="switchVehicleRegion(this,'domestic')">国内车辆</button>
<button type="button" class="vehicle-region-tab" data-region="overseas" role="tab" aria-selected="false" onclick="switchVehicleRegion(this,'overseas')">海外车辆</button>
</div>
<div class="search-row" style="flex-wrap:wrap;gap:8px">
<input id="vehicle-kw" placeholder="车牌 / 司机 / 车型" style="width:220px"/>
<select id="vehicle-status" style="width:140px"><option value="">全部状态</option><option>在役</option><option>外派中</option><option>维修中</option><option>停用</option><option>已归还</option></select>
<button class="btn btn-sm btn-primary" onclick="vehicleSearch()">查询</button>
<button class="btn btn-sm btn-default" onclick="vehicleReset()">重置</button>
</div>
<div style="display:flex;gap:0;border-bottom:2px solid #e5e7eb;margin:14px 0 12px;padding:0 4px">
<div class="vehicle-stab active" onclick="switchVehicleTab(this,'own')" style="padding:10px 22px;font-size:14px;cursor:pointer;color:#1677ff;border-bottom:2px solid #1677ff;margin-bottom:-2px;font-weight:600">自有车辆</div>
<div class="vehicle-stab" onclick="switchVehicleTab(this,'dispatch')" style="padding:10px 22px;font-size:14px;cursor:pointer;color:#374151;border-bottom:2px solid transparent;margin-bottom:-2px">外派车辆</div>
<div class="vehicle-owner-tabs" role="tablist" aria-label="车辆归属">
<button type="button" class="vehicle-owner-tab active" data-owner="own" role="tab" aria-selected="true" onclick="switchVehicleTab(this,'own')">自有车辆</button>
<button type="button" class="vehicle-owner-tab" data-owner="dispatch" role="tab" aria-selected="false" onclick="switchVehicleTab(this,'dispatch')">外派车辆</button>
</div>
<!-- 自有车辆 -->
<div class="vehicle-panel" id="panel-vehicle-own">
<div style="display:flex;gap:8px;align-items:center;margin:2px 2px 12px">
<span style="font-size:13px;color:#666">区域:</span>
<span class="vregion-pill" data-key="own" data-reg="all" onclick="setVehicleRegion('own','all',this)" style="cursor:pointer;padding:5px 16px;border-radius:16px;font-size:13px;color:#fa8c16;background:#fff7e6;border:1px solid #ffd591">全部</span>
<span class="vregion-pill" data-key="own" data-reg="domestic" onclick="setVehicleRegion('own','domestic',this)" style="cursor:pointer;padding:5px 16px;border-radius:16px;font-size:13px;color:#374151;background:#fff;border:1px solid #e5e7eb">国内</span>
<span class="vregion-pill" data-key="own" data-reg="overseas" onclick="setVehicleRegion('own','overseas',this)" style="cursor:pointer;padding:5px 16px;border-radius:16px;font-size:13px;color:#374151;background:#fff;border:1px solid #e5e7eb">国外</span>
</div>
<div style="overflow-x:auto"><table style="min-width:1140px;font-size:12px"><thead><tr style="background:#fafafa;color:#666">
<th style="padding:9px 10px;text-align:left;white-space:nowrap">车牌</th>
<th style="padding:9px 10px;text-align:left;white-space:nowrap">车型</th>
@@ -134,6 +324,7 @@
<th style="padding:9px 10px;text-align:right;white-space:nowrap">当前公里码(km)</th>
<th style="padding:9px 10px;text-align:left;white-space:nowrap">司机</th>
<th style="padding:9px 10px;text-align:left;white-space:nowrap">联系电话</th>
<th class="vehicle-overseas-only" style="display:none;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;width:160px">操作</th>
@@ -142,19 +333,16 @@
<!-- 外派车辆 -->
<div class="vehicle-panel" id="panel-vehicle-dispatch" style="display:none">
<div style="display:flex;gap:8px;align-items:center;margin:2px 2px 12px">
<span style="font-size:13px;color:#666">区域:</span>
<span class="vregion-pill" data-key="dispatch" data-reg="all" onclick="setVehicleRegion('dispatch','all',this)" style="cursor:pointer;padding:5px 16px;border-radius:16px;font-size:13px;color:#fa8c16;background:#fff7e6;border:1px solid #ffd591">全部</span>
<span class="vregion-pill" data-key="dispatch" data-reg="domestic" onclick="setVehicleRegion('dispatch','domestic',this)" style="cursor:pointer;padding:5px 16px;border-radius:16px;font-size:13px;color:#374151;background:#fff;border:1px solid #e5e7eb">国内</span>
<span class="vregion-pill" data-key="dispatch" data-reg="overseas" onclick="setVehicleRegion('dispatch','overseas',this)" style="cursor:pointer;padding:5px 16px;border-radius:16px;font-size:13px;color:#374151;background:#fff;border:1px solid #e5e7eb">国外</span>
</div>
<div style="overflow-x:auto"><table style="min-width:1140px;font-size:12px"><thead><tr style="background:#fafafa;color:#666">
<div style="overflow-x:auto"><table style="min-width:1420px;font-size:12px"><thead><tr style="background:#fafafa;color:#666">
<th style="padding:9px 10px;text-align:left;white-space:nowrap">车牌</th>
<th style="padding:9px 10px;text-align:left;white-space:nowrap">车型</th>
<th style="padding:9px 10px;text-align:right;white-space:nowrap">油耗(L/100km)</th>
<th style="padding:9px 10px;text-align:right;white-space:nowrap">当前公里码(km)</th>
<th style="padding:9px 10px;text-align:left;white-space:nowrap">外派单位</th>
<th style="padding:9px 10px;text-align:left;white-space:nowrap">联系电话</th>
<th class="vehicle-overseas-only" style="display:none;padding:9px 10px;text-align:left;white-space:nowrap">邮箱</th>
<th style="padding:9px 10px;text-align:left;white-space:nowrap">当前派送单</th>
<th style="padding:9px 10px;text-align:left;white-space:nowrap;width:180px">最近更换原因</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;width:160px">操作</th>
@@ -183,7 +371,11 @@
</div>
<div class="form-row">
<div class="form-group"><label>司机 / 外派单位</label><input id="vehicle-m-driver" placeholder="司机姓名 或 外派单位"/></div>
<div class="form-group"><label>区域</label><select id="vehicle-m-region" style="height:40px"><option value="domestic">国内</option><option value="overseas">国外</option></select></div>
<div class="form-group"><label>区域</label><select id="vehicle-m-region" style="height:40px" onchange="syncVehicleEmailFieldVisibility()"><option value="domestic">国内</option><option value="overseas">海外</option></select></div>
</div>
<div class="form-row">
<div class="form-group" id="vehicle-email-group" style="display:none"><label>邮箱</label><input id="vehicle-m-email" type="email" placeholder="海外车队联系邮箱"/></div>
<div class="form-group"></div>
</div>
<div class="form-row">
<div class="form-group"><label>状态</label><select id="vehicle-m-status" style="height:40px"><option>在役</option><option>外派中</option><option>维修中</option><option>停用</option><option>已归还</option></select></div>
@@ -196,12 +388,64 @@
</div>
</div></div>
<!-- 车辆 查看 弹窗 -->
<div class="modal-overlay" id="modal-vehicle-view">
<div class="modal" style="width:600px;max-width:95vw;max-height:88vh;overflow-y:auto">
<div class="modal-header"><span id="vehicle-view-title">车辆详情</span><span onclick="hideModal('modal-vehicle-view')" style="cursor:pointer;font-size:20px">&times;</span></div>
<div class="modal-body" id="vehicle-view-body" style="padding:20px 24px"></div>
<div class="modal-footer" style="justify-content:flex-end;padding:14px 24px;border-top:1px solid #f0f0f0"><button class="btn btn-default" onclick="hideModal('modal-vehicle-view')">关闭</button></div>
</div></div>
<!-- ==================== DELIVERY FLEET QUOTE ==================== -->
<div class="page" id="page-delivery-fleet-quote">
<div class="breadcrumb">首页 <span class="sep">&gt;</span> 通用 <span class="sep">&gt;</span> <strong>派送车队报价</strong></div>
<div class="table-card">
<div class="table-toolbar"><span class="title">派送车队报价</span><div class="toolbar-actions"><button class="btn btn-primary" onclick="openDeliveryFleetQuoteModal()">+ 新增报价</button><button class="btn btn-default" onclick="openDeliveryFleetQuoteImport()">批量导入</button><button class="btn btn-default" onclick="showToast('派送车队报价已导出','success')">导出Excel</button></div></div>
<div class="search-row">
<input id="dfq-search-origin" placeholder="发货城市" style="width:150px">
<input id="dfq-search-warehouse" placeholder="目的 FBA 仓点" style="width:160px">
<input id="dfq-search-fleet" placeholder="车队渠道" style="width:150px">
<input id="dfq-search-keyword" placeholder="关键词:城市/仓点/车队" style="width:190px">
<select id="dfq-search-method" style="width:145px"><option value="">全部提货方式</option><option value="selfPickup">整车自提</option><option value="fullDelivery">整车派送</option><option value="looseDelivery">散板派送</option></select>
<select id="dfq-search-status" style="width:120px"><option value="">全部状态</option><option>启用</option><option>停用</option></select>
<button class="btn btn-sm btn-primary" onclick="renderDeliveryFleetQuotes()">查询</button>
<button class="btn btn-sm btn-default" onclick="resetDeliveryFleetQuoteSearch()">重置</button>
</div>
<div class="dfq-table-wrap"><table class="dfq-table"><thead><tr><th>操作</th><th>发货地(城市)</th><th>目的仓(FBA仓点)</th><th>迈数(预估距离)</th><th>车队渠道</th><th>整车自提</th><th>整车派送</th><th>散板派送</th><th>状态</th><th>更新时间</th><th>备注</th></tr></thead><tbody id="tbody-delivery-fleet-quote"></tbody></table></div>
<div class="pagination"><span id="dfq-total">共 0 条</span><button class="page-btn active">1</button></div>
<div class="dfq-helper">报价金额按美元记录;迈数为预估距离,实际派送费用以订单确认时的有效报价为准。</div>
</div>
</div>
<div class="modal-overlay" id="modal-delivery-fleet-quote"><div class="modal" style="max-width:780px"><div class="modal-header"><span id="dfq-modal-title">新增派送车队报价</span><span onclick="hideModal('modal-delivery-fleet-quote')" style="cursor:pointer;font-size:20px">&times;</span></div><div class="modal-body" style="padding:22px 26px"><input type="hidden" id="dfq-edit-id"><div class="dfq-modal-grid">
<div class="form-group"><label>发货城市 <span style="color:#ef4444">*</span></label><input id="dfq-origin" placeholder="例如:洛杉矶, CA"></div>
<div class="form-group"><label>目的 FBA 仓点 <span style="color:#ef4444">*</span></label><input id="dfq-warehouse" placeholder="例如:LAX9"></div>
<div class="form-group"><label>迈数(预估距离) <span style="color:#ef4444">*</span></label><input id="dfq-miles" type="number" min="0" step="0.1" placeholder="请输入英里数"></div>
<div class="form-group"><label>车队渠道 <span style="color:#ef4444">*</span></label><input id="dfq-fleet" placeholder="例如:SMART / RIGLL"></div>
<div class="form-group"><label>整车自提(USD)</label><input id="dfq-self-pickup" type="number" min="0" step="0.01" placeholder="可留空"></div>
<div class="form-group"><label>整车派送(USD)</label><input id="dfq-full-delivery" type="number" min="0" step="0.01" placeholder="可留空"></div>
<div class="form-group"><label>散板派送(USD)</label><input id="dfq-loose-delivery" type="number" min="0" step="0.01" placeholder="可留空"></div>
<div class="form-group"><label>状态</label><select id="dfq-status"><option>启用</option><option>停用</option></select></div>
<div class="form-group full"><label>备注</label><textarea id="dfq-remark" rows="3" placeholder="请输入报价说明" style="resize:vertical"></textarea></div>
</div></div><div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-delivery-fleet-quote')">取消</button><button class="btn btn-primary" onclick="saveDeliveryFleetQuote()">保存报价</button></div></div></div>
<div class="modal-overlay" id="modal-delivery-fleet-quote-import"><div class="modal" style="max-width:1040px"><div class="modal-header"><span>批量导入派送车队报价</span><span onclick="hideModal('modal-delivery-fleet-quote-import')" style="cursor:pointer;font-size:20px">&times;</span></div><div class="modal-body"><div class="dfq-import-picker"><div class="dfq-import-copy"><div class="dfq-import-title">选择 CSV 文件</div><div class="dfq-import-hint">请使用模板填写;Excel 文件可另存为 UTF-8 CSV 后导入。系统会校验必填项、报价格式和重复组合。</div></div><div class="dfq-import-actions"><button class="btn btn-default btn-sm" onclick="downloadDeliveryFleetQuoteImportTemplate()">下载模板</button><input id="dfq-import-file" type="file" accept=".csv" onchange="handleDeliveryFleetQuoteImportFile(this)"></div></div><div id="dfq-import-summary" class="dfq-import-summary" aria-live="polite">尚未选择文件</div><div class="dfq-import-preview"><table><thead><tr><th>校验</th><th>发货城市</th><th>目的 FBA 仓点</th><th>迈数</th><th>车队渠道</th><th>整车自提</th><th>整车派送</th><th>散板派送</th><th>状态</th><th>备注</th></tr></thead><tbody id="tbody-dfq-import-preview"><tr><td colspan="10" class="dfq-import-empty">选择文件后显示前 10 条数据</td></tr></tbody></table></div></div><div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-delivery-fleet-quote-import')">取消</button><button id="dfq-import-confirm" class="btn btn-primary" onclick="confirmDeliveryFleetQuoteImport()" disabled>确认导入</button></div></div></div>
<!-- 车辆详情 -->
<div class="page" id="page-vehicle-detail">
<div class="breadcrumb">首页 <span class="sep">&gt;</span> 通用 <span class="sep">&gt;</span> 车辆列表 <span class="sep">&gt;</span> <strong>车辆详情</strong></div>
<div class="table-card vehicle-detail-card">
<div class="vehicle-detail-head">
<div><div class="vehicle-detail-title">车辆详情</div><div class="vehicle-detail-meta"><span id="vehicle-detail-plate">—</span><span id="vehicle-detail-status" class="tag tag-gray">—</span></div></div>
<button type="button" class="btn btn-default" onclick="backToVehicleList()">返回列表</button>
</div>
<div class="vehicle-detail-body">
<section class="vehicle-detail-section" aria-labelledby="vehicle-detail-info-title">
<h3 id="vehicle-detail-info-title">车辆信息</h3>
<div id="vehicle-detail-info" class="vehicle-detail-grid"></div>
</section>
<section class="vehicle-detail-section" aria-labelledby="vehicle-detail-orders-title">
<h3 id="vehicle-detail-orders-title">派送订单明细</h3>
<div class="vehicle-detail-table-scroll"><table class="vehicle-detail-table"><thead><tr><th>派送单号</th><th>BOL / 来源单号</th><th>客户</th><th>目的仓</th><th>预约日期</th><th>派送状态</th><th>关联关系</th></tr></thead><tbody id="vehicle-detail-orders-body"></tbody></table></div>
</section>
<section class="vehicle-detail-section" aria-labelledby="vehicle-detail-history-title">
<h3 id="vehicle-detail-history-title">派送更换记录</h3>
<div class="vehicle-detail-table-scroll"><table class="vehicle-detail-table"><thead><tr><th>时间</th><th>派送单号</th><th>动作</th><th>关联车辆</th><th>原因</th><th>操作人</th></tr></thead><tbody id="vehicle-detail-history-body"></tbody></table></div>
</section>
</div>
</div>
</div>
<!-- ==================== FBA RETURN - 退货入库 ==================== -->
@@ -12,7 +12,7 @@
<option value="全美">全部渠道</option><option value="洛杉矶">洛杉矶</option><option value="纽约">纽约</option>
</select>
<select id="ds-q-type" style="width:140px;height:32px;padding:0 8px;border:1px solid #d9d9d9;border-radius:6px;font-size:13px">
<option value="全部">全部类型</option><option value="合德正班">合德正班</option><option value="合德海卡">合德海卡</option><option value="洛杉矶普通">洛杉矶普通</option>
<option value="全部">全部类型</option><option value="一件代发">一件代发</option><option value="头程">头程</option><option value="仓储转运">仓储转运</option>
</select>
<select id="ds-q-dest" style="width:130px;height:32px;padding:0 8px;border:1px solid #d9d9d9;border-radius:6px;font-size:13px">
<option value="">全部FBA仓</option><option value="ONT8">ONT8</option><option value="LAX9">LAX9</option><option value="FTW1">FTW1</option><option value="CLT2">CLT2</option>
@@ -35,12 +35,24 @@
<div class="ds-panel-title">出库单列表 <span id="ds-orders-count" style="color:#999;font-weight:400"></span></div>
<div class="ds-type-filter" style="display:flex;gap:6px;margin-bottom:8px;flex-wrap:wrap">
<button class="ds-tf active" data-tf="全部" onclick="setDispatchType('全部',this)">全部</button>
<button class="ds-tf" data-tf="合德正班" onclick="setDispatchType('合德正班',this)">合德正班</button>
<button class="ds-tf" data-tf="合德海卡" onclick="setDispatchType('合德海卡',this)">合德海卡</button>
<button class="ds-tf" data-tf="洛杉矶普通" onclick="setDispatchType('洛杉矶普通',this)">洛杉矶普通</button>
<button class="ds-tf" data-tf="一件代发" onclick="setDispatchType('一件代发',this)">一件代发</button>
<button class="ds-tf" data-tf="头程" onclick="setDispatchType('头程',this)">头程</button>
<button class="ds-tf" data-tf="仓储转运" onclick="setDispatchType('仓储转运',this)">仓储转运</button>
</div>
<div id="ds-orders-list" class="ds-orders-list"></div>
</div>
<!-- 中间托盘明细 -->
<div class="ds-pallets-wrap">
<div id="ds-pallet-detail" class="ds-pallet-detail" aria-live="polite">
<div class="ds-pallet-detail-title">托盘明细 <span>订单号:<b id="ds-pallet-detail-order"></b></span></div>
<div class="ds-pallet-detail-table-wrap">
<table class="ds-pallet-detail-table">
<thead><tr><th>托盘号</th><th class="num">箱数</th></tr></thead>
<tbody id="ds-pallet-detail-body"><tr><td colspan="2" class="ds-pallet-empty">请选择订单查看托盘明细</td></tr></tbody>
</table>
</div>
</div>
</div>
<!-- 右侧装托盘类型 -->
<div class="ds-trucks-wrap">
<div class="ds-panel-title" style="display:flex;justify-content:space-between;align-items:center">
@@ -52,7 +64,7 @@
</div>
</div>
<div class="modal-footer" style="display:flex;justify-content:space-between;align-items:center;padding:14px 20px;border-top:1px solid #f0f0f0">
<span style="font-size:12px;color:#999">提示:将左侧订单拖入右侧装托盘,托数/立方自动汇总;可手动添加不同容量的装托盘。</span>
<span style="font-size:12px;color:#999">提示:选择订单后,将中间托盘明细拖入右侧装托盘类型,托数/立方自动汇总。</span>
<div style="display:flex;gap:10px">
<button class="btn btn-default" onclick="hideModal('modal-dispatch-schedule')">取消</button>
<button class="btn btn-primary" onclick="preScheduleVehicle()">预排车</button>
@@ -549,6 +561,10 @@
<div class="form-row single"><div class="form-group"><label>开船时间:</label><input id="ct-add-etd" type="date" placeholder="选择开船时间"/></div></div>
<div class="form-row single"><div class="form-group"><label>到港时间:</label><input id="ct-add-eta" type="date" placeholder="选择到港时间"/></div></div>
<div class="form-row single"><div class="form-group"><label>到库时间:</label><input id="ct-add-etw" type="date" placeholder="选择到库时间"/></div></div>
<div class="form-row single"><div class="form-group"><label>预计提柜时间:</label><input id="ct-add-pickup-expected" type="datetime-local"/></div></div>
<div class="form-row single"><div class="form-group"><label>约车时间:</label><input id="ct-add-appointment-at" type="datetime-local"/></div></div>
<div class="form-row single"><div class="form-group"><label>报空时间:</label><input id="ct-add-emptied-at" type="datetime-local"/></div></div>
<div class="form-row single"><div class="form-group"><label>还柜时间:</label><input id="ct-add-returned-at" type="datetime-local"/></div></div>
<div class="form-row single"><div class="form-group"><label>清关申报金额(USD):</label><input id="ct-add-declare" type="number" placeholder="请输入清关申报金额(USD)"/></div></div>
</div>
<div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-ct-add')">关闭</button><button class="btn btn-primary" id="ct-form-submit" onclick="saveCtAdd()">确定</button></div></div></div>
@@ -570,6 +586,7 @@
<div><label style="font-size:12px;font-weight:600;color:#333;display:block;margin-bottom:4px">集装箱号 <span style="color:#f5222d">*</span></label><input id="cost-ctNo" style="width:100%;padding:9px 12px;border:1px solid #d9d9d9;border-radius:6px;font-size:13px;box-sizing:border-box"/></div>
<div><label style="font-size:12px;font-weight:600;color:#333;display:block;margin-bottom:4px">费用类型</label><select id="cost-type" style="width:100%;padding:9px 12px;border:1px solid #d9d9d9;border-radius:6px;font-size:13px;box-sizing:border-box"><option>港口费</option><option>堆存费</option><option>修箱费</option><option>吊机费</option><option>拖车费</option><option>报关费</option><option>其他</option></select></div>
</div>
<div id="cost-supplier-field" style="margin-bottom:14px"><label style="font-size:12px;font-weight:600;color:#333;display:block;margin-bottom:4px">供应商 <span style="color:#f5222d">*</span></label><select id="cost-supplier" required style="width:100%;padding:9px 12px;border:1px solid #d9d9d9;border-radius:6px;font-size:13px;box-sizing:border-box"><option value="">请选择供应商</option></select></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0 16px;margin-bottom:14px">
<div><label style="font-size:12px;font-weight:600;color:#333;display:block;margin-bottom:4px">金额 <span style="color:#f5222d">*</span></label><input id="cost-amount" type="number" step="0.01" min="0" placeholder="0.00" style="width:100%;padding:9px 12px;border:1px solid #d9d9d9;border-radius:6px;font-size:13px;box-sizing:border-box"/></div>
<div><label style="font-size:12px;font-weight:600;color:#333;display:block;margin-bottom:4px">币种</label><select id="cost-currency" style="width:100%;padding:9px 12px;border:1px solid #d9d9d9;border-radius:6px;font-size:13px;box-sizing:border-box"><option>USD</option><option>CNY</option><option>EUR</option></select></div>
@@ -1168,25 +1185,42 @@
.ds-channels .ds-channel{font-size:13px;padding:7px 18px;border:1px solid #d9d9d9;background:#fff;color:#374151;border-radius:6px;cursor:pointer;transition:.15s}
.ds-channels .ds-channel:hover{border-color:#722ed1;color:#722ed1}
.ds-channels .ds-channel.active{background:#722ed1;border-color:#722ed1;color:#fff}
.ds-layout{display:flex;gap:16px;align-items:stretch}
.ds-orders{flex:0 0 60%;max-width:60%;display:flex;flex-direction:column}
.ds-orders-list{max-height:520px;overflow-y:auto;border:1px solid #e5e7eb;border-radius:8px;background:#fff}
.ds-layout{display:flex;gap:12px;align-items:stretch}
.ds-orders{flex:44 1 0;display:flex;flex-direction:column;min-width:0}
.ds-orders-list{max-height:390px;overflow:auto;border:1px solid #e5e7eb;border-radius:8px;background:#fff}
/* 订单表格样式 */
.ds-order-table{width:100%;border-collapse:collapse;font-size:12px}
.ds-order-table{width:100%;min-width:980px;border-collapse:collapse;font-size:12px}
.ds-order-table thead th{background:#f5f6f8;color:#4b5563;font-weight:600;padding:9px 8px;border-bottom:1px solid #e5e7eb;white-space:nowrap;font-size:12px;position:sticky;top:0;z-index:1}
.ds-order-table tbody tr{border-bottom:1px solid #f0f0f0;cursor:grab;transition:.15s}
.ds-order-table tbody tr{border-bottom:1px solid #f0f0f0;cursor:pointer;transition:.15s}
.ds-order-table tbody tr:hover{background:#f0f5ff}
.ds-order-table tbody tr.placed{opacity:.35;cursor:not-allowed}
.ds-order-table tbody tr.selected{background:#eff6ff}
.ds-order-table tbody tr.selected td:first-child{box-shadow:inset 1px 0 #2563eb}
.ds-order-table tbody td{padding:8px 6px;color:#374151;white-space:nowrap;font-size:12px}
.ds-cn-select{display:inline-block;padding:2px 8px;border:1px solid #d9d9d9;border-radius:4px;font-size:11px;color:#1f2937;cursor:pointer;background:#fafafa}
.ds-type-filter .ds-tf{font-size:12px;padding:3px 10px;border:1px solid #e5e7eb;border-radius:14px;background:#fff;color:#6b7280;cursor:pointer;transition:.15s}
.ds-type-filter .ds-tf:hover{border-color:#722ed1;color:#722ed1}
.ds-type-filter .ds-tf.active{background:#722ed1;border-color:#722ed1;color:#fff}
.ds-otype{display:inline-block;font-size:10px;padding:1px 6px;border-radius:4px;margin-left:6px;vertical-align:middle}
.otype-合德正班{background:#e6f0ff;color:#1677ff}
.otype-合德海卡{background:#f6f0ff;color:#722ed1}
.otype-洛杉矶普通{background:#e8f7ee;color:#16a34a}
.ds-trucks-wrap{flex:0 0 40%;max-width:40%;display:flex;flex-direction:column;min-width:0}
.otype-一件代发{background:#e6f0ff;color:#1677ff}
.otype-头程{background:#f6f0ff;color:#722ed1}
.otype-仓储转运{background:#e8f7ee;color:#16a34a}
.ds-pallets-wrap{flex:22 1 0;display:flex;flex-direction:column;min-width:0}
.ds-pallet-detail{border:1px solid #dbe4f0;border-radius:8px;background:#fff;overflow:hidden;display:flex;flex-direction:column;min-height:170px;max-height:560px}
.ds-pallet-detail-title{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 10px;background:#f8fafc;border-bottom:1px solid #e5e7eb;color:#334155;font-size:12px;font-weight:600}
.ds-pallet-detail-title span{min-width:0;color:#64748b;font-weight:400;overflow-wrap:anywhere}
.ds-pallet-detail-table-wrap{max-height:500px;overflow:auto}
.ds-pallet-detail-table{width:100%;border-collapse:collapse;table-layout:fixed;font-size:12px}
.ds-pallet-detail-table th,.ds-pallet-detail-table td{padding:7px 10px;border-bottom:1px solid #eef2f7;text-align:left;color:#475569;overflow-wrap:anywhere}
.ds-pallet-detail-table tr:last-child td{border-bottom:0}
.ds-pallet-detail-table .num{text-align:right;width:90px}
.ds-pallet-row{cursor:grab;background:#fff;transition:.15s}
.ds-pallet-row:hover{background:#f0f5ff}
.ds-pallet-row.assigned{cursor:not-allowed;background:#f8fafc;opacity:.62}
.ds-pallet-no{display:flex;align-items:center;gap:6px;min-width:0}
.ds-pallet-no>span:first-child{min-width:0;overflow-wrap:anywhere}
.ds-pallet-badge{flex:0 0 auto;padding:1px 5px;border-radius:4px;background:#e8f7ee;color:#15803d;font-size:10px;white-space:nowrap}
.ds-pallet-empty{text-align:center!important;color:#94a3b8!important;padding:14px 10px!important}
.ds-trucks-wrap{flex:34 1 0;display:flex;flex-direction:column;min-width:0}
.ds-trucks{flex:1;display:flex;flex-direction:column;gap:12px;overflow-y:auto;max-height:560px;padding-right:2px}
.ds-truck{border:2px dashed #d1d5db;border-radius:8px;background:#fafafa;display:flex;flex-direction:column;transition:.15s}
.ds-truck.ds-over{border-color:#722ed1;background:#f6f0ff}
@@ -1197,12 +1231,14 @@
.ds-truck-sum b{color:#722ed1;font-weight:700}
.ds-truck-sum b.over{color:#ef4444}
.ds-truck-body{flex:1;min-height:70px;padding:8px;overflow-y:auto}
.ds-truck-item{background:#fff;border:1px solid #e5e7eb;border-radius:6px;padding:6px 8px;margin-bottom:6px;font-size:12px;display:flex;justify-content:space-between;align-items:center;gap:6px}
.ds-truck-item{background:#fff;border:1px solid #e5e7eb;border-radius:6px;padding:6px 8px;margin-bottom:6px;font-size:12px;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:2px 8px;cursor:grab}
.ds-truck-item .ds-zi-ono{font-weight:600;color:#374151}
.ds-truck-item .ds-zi-meta{color:#9ca3af;font-size:11px}
.ds-truck-item .ds-zi-del{color:#ef4444;cursor:pointer;font-size:14px;line-height:1}
.ds-truck-item .ds-zi-meta{color:#9ca3af;font-size:11px;grid-column:1;overflow-wrap:anywhere}
.ds-truck-item .ds-zi-del{color:#ef4444;cursor:pointer;font-size:14px;line-height:1;grid-column:2;grid-row:1/3}
.ds-truck-del{font-size:11px;color:#ef4444;cursor:pointer;text-align:right;padding:4px 12px}
.ds-truck-del:hover{text-decoration:underline}
@media(min-width:901px){.ds-pallet-detail{margin-top:57px}.ds-pallet-detail-table-wrap{padding-top:0}.ds-trucks{padding:24px 2px 0}}
@media(max-width:900px){.ds-layout{flex-direction:column}.ds-orders,.ds-pallets-wrap,.ds-trucks-wrap{flex:1 1 auto;max-width:100%;width:100%}.ds-orders-list{max-height:320px}.ds-pallet-detail{max-height:320px}.ds-pallet-detail-table-wrap{max-height:260px}.ds-trucks{max-height:360px}}
</style>
<div class="dispatch-form-grid">
<div class="dispatch-form-group"><label>入仓码:</label><input id="dc-warehouse-code" placeholder="请输入入仓码"/></div>
@@ -1259,7 +1295,7 @@
<label>派送单号<input id="dispatch-docs-dispatch-no" readonly/></label>
<label>BOL号<input id="dispatch-docs-bol-no" readonly/></label>
</div>
<div class="dispatch-doc-modal-summary"><span>资料上传后会回写当前派送单,并沿用历史 BOL 资料</span><strong id="dispatch-docs-summary">0/5 已上传</strong></div>
<div class="dispatch-doc-modal-summary"><span>资料上传后会回写当前派送单,并沿用历史 BOL 资料</span><strong id="dispatch-docs-summary">0/7 已上传</strong></div>
<div class="dispatch-doc-list" id="dispatch-docs-list"></div>
</div>
<div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-dispatch-docs')">关闭</button><button class="btn btn-primary" onclick="saveDispatchDocs()">保存资料</button></div>
@@ -1375,12 +1411,12 @@
<!-- 编辑BOL车队信息弹窗 -->
<div class="modal-overlay" id="modal-edit-fleet">
<div class="modal" style="max-width:520px"><div class="modal-header">编辑BOL车队信息<span onclick="hideModal('modal-edit-fleet')" style="cursor:pointer;font-size:20px">&times;</span></div>
<div class="modal-body">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px"><label style="width:100px;text-align:right;color:#333;flex-shrink:0">BOL单号:</label><input id="ef-bol-no" readonly style="flex:1;background:#f5f5f5"/></div>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px"><label style="width:100px;text-align:right;color:#333;flex-shrink:0">车队/司机名:</label><input id="ef-fleet-name" style="flex:1"/></div>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px"><label style="width:100px;text-align:right;color:#333;flex-shrink:0">车队联系电话:</label><input id="ef-fleet-phone" style="flex:1" placeholder="请输入车队联系电话"/></div>
<div style="display:flex;align-items:center;gap:12px"><label style="width:100px;text-align:right;color:#333;flex-shrink:0">车队邮箱:</label><input id="ef-fleet-email" style="flex:1" placeholder="请输入车队邮箱"/></div>
<div class="modal" style="max-width:560px"><div class="modal-header">编辑车队<span onclick="hideModal('modal-edit-fleet')" style="cursor:pointer;font-size:20px">&times;</span></div>
<div class="modal-body edit-fleet-form">
<div class="edit-fleet-field"><label for="ef-bol-no">BOL单号</label><input id="ef-bol-no" readonly/></div>
<div class="edit-fleet-field"><label>当前车辆</label><div class="edit-fleet-current" id="ef-current-vehicle">暂未分配外派车辆</div></div>
<div class="edit-fleet-field"><label for="ef-vehicle-id"><span class="edit-fleet-required">*</span> 外派车辆</label><select id="ef-vehicle-id" onchange="updateEditFleetReasonState()"><option value="">请选择外派车辆</option></select></div>
<div class="edit-fleet-field edit-fleet-field-top"><label for="ef-change-reason">更换原因</label><div class="edit-fleet-reason-wrap"><textarea id="ef-change-reason" maxlength="200" rows="3" placeholder="请输入更换原因,最多200字"></textarea><div class="edit-fleet-reason-hint" id="ef-change-reason-hint">首次分配车辆,无需填写原因</div></div></div>
</div>
<div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-edit-fleet')">关闭</button><button class="btn btn-primary" onclick="saveEditFleet()">确定</button></div></div></div>
@@ -1417,6 +1453,66 @@
</div>
</div>
<!-- 拒收货物弹窗 -->
<div class="modal-overlay" id="modal-dispatch-reject">
<div class="modal" style="max-width:560px">
<div class="modal-header"><span>拒收货物</span><span onclick="hideModal('modal-dispatch-reject')" class="dispatch-reject-close" aria-label="关闭">&times;</span></div>
<div class="modal-body">
<div class="dispatch-reject-meta">
<div><span>派送单号</span><strong id="dispatch-reject-no">—</strong></div>
<div><span>所属客户</span><strong id="dispatch-reject-customer">—</strong></div>
<div><span>客户联系人</span><strong id="dispatch-reject-contact">—</strong></div>
</div>
<div class="form-row single"><div class="form-group"><label>拒收原因 <span style="color:#ef4444">*</span></label><textarea id="dispatch-reject-reason" rows="4" placeholder="请输入拒收原因"></textarea></div></div>
<label class="dispatch-reject-notify-row"><input id="dispatch-reject-notify" type="checkbox" checked onchange="updateDispatchRejectNotice()"><span><strong>通知客户</strong><small id="dispatch-reject-notice">将同时发送系统消息和短信</small></span></label>
</div>
<div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-dispatch-reject')">取消</button><button class="btn btn-danger" onclick="confirmDispatchReject()">确认拒收</button></div>
</div>
</div>
<!-- 取消派送单弹窗 -->
<div class="modal-overlay" id="modal-dispatch-cancel">
<div class="modal" style="max-width:560px">
<div class="modal-header"><span>取消派送单</span><button type="button" aria-label="关闭" onclick="hideModal('modal-dispatch-cancel')" class="dispatch-reject-close" style="border:0;background:transparent">&times;</button></div>
<div class="modal-body">
<div class="dispatch-reject-meta"><div><span>派送单号</span><strong id="dispatch-cancel-no">—</strong></div><div><span>所属客户</span><strong id="dispatch-cancel-customer">—</strong></div></div>
<div class="dispatch-cancel-picking"><span>拣货状态</span><strong id="dispatch-cancel-picking">—</strong></div>
<div class="form-row single"><div class="form-group"><label>取消原因 <span style="color:#ef4444">*</span></label><textarea id="dispatch-cancel-reason" rows="4" placeholder="请输入取消原因"></textarea></div></div>
<label class="dispatch-reject-notify-row"><input id="dispatch-cancel-notify" type="checkbox" checked><span><strong>通知客户</strong><small>取消后同时发送系统消息和短信</small></span></label>
</div>
<div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-dispatch-cancel')">返回</button><button class="btn btn-danger" onclick="confirmDispatchCancel()">确认取消</button></div>
</div>
</div>
<!-- 标记退货弹窗 -->
<div class="modal-overlay" id="modal-dispatch-return">
<div class="modal" style="max-width:580px">
<div class="modal-header"><span>标记退货</span><button type="button" aria-label="关闭" onclick="hideModal('modal-dispatch-return')" class="dispatch-reject-close" style="border:0;background:transparent">&times;</button></div>
<div class="modal-body">
<div class="dispatch-reject-meta"><div><span>派送单号</span><strong id="dispatch-return-no">—</strong></div><div><span>所属客户</span><strong id="dispatch-return-customer">—</strong></div></div>
<div class="form-row single"><div class="form-group"><label>退货原因 <span style="color:#ef4444">*</span></label><textarea id="dispatch-return-reason" rows="3" placeholder="请输入退货原因"></textarea></div></div>
<div class="form-row"><div class="form-group"><label>退货去向 <span style="color:#ef4444">*</span></label><select id="dispatch-return-route"><option>退回海外仓</option><option>退回发件人</option><option>转运至其他仓库</option></select></div><div class="form-group"><label>退回物流单号</label><input id="dispatch-return-tracking" placeholder="可在安排物流后补充"/></div></div>
<label class="dispatch-reject-notify-row"><input id="dispatch-return-notify" type="checkbox" checked><span><strong>通知客户</strong><small>保存后发送系统消息,告知退货原因和去向</small></span></label>
</div>
<div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-dispatch-return')">取消</button><button class="btn btn-primary" onclick="confirmDispatchReturn()">确认转入退货</button></div>
</div>
</div>
<!-- 异常订单处理弹窗 -->
<div class="modal-overlay" id="modal-dispatch-exception-process">
<div class="modal" style="max-width:620px">
<div class="modal-header"><span id="dispatch-exception-process-title">异常订单处理</span><button type="button" aria-label="关闭" onclick="hideModal('modal-dispatch-exception-process')" class="dispatch-reject-close" style="border:0;background:transparent">&times;</button></div>
<div class="modal-body">
<div class="dispatch-reject-meta dispatch-exception-process-meta"><div><span>异常订单号</span><strong id="dispatch-exception-process-exception-no">—</strong></div><div><span>异常类型</span><strong id="dispatch-exception-process-type">—</strong></div><div class="dispatch-exception-related-meta"><span>关联订单号</span><div id="dispatch-exception-process-relations">—</div></div></div>
<div class="dispatch-exception-process-note" id="dispatch-exception-process-hint">请补充处理结果。</div>
<div class="form-row"><div class="form-group"><label>处理动作 <span style="color:#ef4444">*</span></label><select id="dispatch-exception-process-action"><option>安排退回</option><option>重新派送</option><option>客户确认取消</option><option>报废处理</option></select></div><div class="form-group"><label>处理状态 <span style="color:#ef4444">*</span></label><select id="dispatch-exception-process-status"><option>待处理</option><option>处理中</option><option>已完成</option></select></div></div>
<div class="form-row single"><div class="form-group"><label>退回物流单号</label><input id="dispatch-exception-process-tracking" placeholder="安排退回并完成时必填"/></div></div>
<div class="form-row single"><div class="form-group"><label>处理备注</label><textarea id="dispatch-exception-process-note" rows="3" placeholder="记录与客户或承运商的沟通结果"></textarea></div></div>
</div>
<div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-dispatch-exception-process')">取消</button><button class="btn btn-primary" onclick="confirmDispatchExceptionProcess()">保存处理结果</button></div>
</div>
</div>
<!-- 发送派送完成消息确认弹窗 -->
<div class="modal-overlay" id="modal-send-confirm">
<div class="modal" style="max-width:360px"><div class="modal-header" style="border-bottom:none;padding-bottom:8px"><span></span><span onclick="hideModal('modal-send-confirm')" style="cursor:pointer;font-size:20px">&times;</span></div>
@@ -1428,7 +1524,196 @@
</div>
<div class="modal-footer" style="border-top:none;justify-content:center;gap:12px"><button class="btn btn-default" onclick="hideModal('modal-send-confirm')">取消</button><button class="btn btn-primary" onclick="confirmSend()">确定</button></div></div></div>
<!-- FBA 箱号管理 - 新增/编辑 -->
<div class="modal-overlay" id="modal-fba-carton-form">
<div class="modal" style="max-width:860px;width:94%">
<div class="modal-header"><span id="fbc-form-title">新增箱号</span><span onclick="hideModal('modal-fba-carton-form')" style="cursor:pointer;font-size:20px" aria-label="关闭">&times;</span></div>
<div class="modal-body">
<div class="form-row"><div class="form-group"><label>箱号 *</label><input id="fbc-form-carton" maxlength="64" placeholder="请输入箱号"></div><div class="form-group"><label>所属 FBA 仓 *</label><select id="fbc-form-wh"><option value="">请选择 FBA 仓</option><option>FBA ONT8</option><option>FBA CLT2</option><option>FBA MEM1</option><option>DE Frankfurt</option><option>FBA IND9</option></select></div></div>
<div class="form-row"><div class="form-group"><label>商品 SKU *</label><input id="fbc-form-sku" maxlength="64" placeholder="请输入商品 SKU"></div><div class="form-group"><label>中文品名 *</label><input id="fbc-form-cn-name" maxlength="100" placeholder="请输入中文品名"></div></div>
<div class="form-row"><div class="form-group"><label>数量 *</label><input id="fbc-form-qty" type="number" min="1" step="1" placeholder="请输入数量"></div><div class="form-group"><label>重量(kg) *</label><input id="fbc-form-weight" type="number" min="0.01" step="0.01" placeholder="请输入重量"></div></div>
<div class="form-row"><div class="form-group"><label>关联托盘</label><input id="fbc-form-pallet" maxlength="64" placeholder="请输入关联托盘号"></div><div class="form-group"><label>关联入库单</label><input id="fbc-form-inbound" maxlength="64" placeholder="请输入关联入库单号"></div></div>
<div class="form-row"><div class="form-group"><label>关联出库单</label><input id="fbc-form-outbound" readonly aria-readonly="true" style="background:#f8fafc;color:#64748b"></div><div class="form-group"><label>状态</label><input id="fbc-form-status" readonly aria-readonly="true" style="background:#f8fafc;color:#64748b"></div></div>
<div class="form-group"><label>备注</label><textarea id="fbc-form-remark" rows="3" maxlength="500" placeholder="请输入备注"></textarea></div>
</div>
<div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-fba-carton-form')">取消</button><button class="btn btn-primary" onclick="saveFbaCartonForm()">保存</button></div>
</div>
</div>
<!-- TOAST -->
<!-- 编辑排车弹窗 -->
<div class="modal-overlay" id="modal-outbound-vehicle-edit">
<div class="modal outbound-vehicle-modal outbound-vehicle-edit-modal" style="max-width:920px">
<div class="modal-header"><span>编辑排车</span><button type="button" class="outbound-vehicle-modal-close" onclick="cancelOutboundVehicleEdit()" aria-label="关闭" style="cursor:pointer;font-size:20px;border:0;background:transparent;line-height:1"><span aria-hidden="true">&times;</span></button></div>
<div class="outbound-vehicle-tabs outbound-vehicle-edit-tabs" role="tablist" style="display:flex;border-bottom:1px solid #e5e7eb;padding:0 24px">
<button type="button" class="detail-tab active" data-tab="info" onclick="switchOutboundVehicleEditTab('info',this)" style="border:none;background:none;cursor:pointer">排车信息</button>
<button type="button" class="detail-tab" data-tab="orders" onclick="switchOutboundVehicleEditTab('orders',this)" style="border:none;background:none;cursor:pointer">关联订单 (<span id="ove-order-tab-count">0</span>)</button>
</div>
<div class="modal-body outbound-vehicle-modal-body outbound-vehicle-edit-modal-body" style="max-height:68vh;overflow:auto">
<div id="ove-panel-info" class="outbound-vehicle-edit-panel outbound-vehicle-edit-info-panel">
<div class="outbound-vehicle-edit-form outbound-vehicle-info-form">
<div class="form-row"><div class="form-group"><label>排车单号</label><input id="ove-vehicle-no" readonly style="background:#f5f5f5"/></div><div class="form-group"><label>车牌号</label><input id="ove-plate" placeholder="请输入车牌号"/></div></div>
<div class="form-row"><div class="form-group"><label>托车公司名称</label><input id="ove-company" placeholder="请输入托车公司名称"/></div><div class="form-group"><label>司机</label><input id="ove-driver" placeholder="请输入司机姓名"/></div></div>
<div class="form-row"><div class="form-group"><label>联系电话</label><input id="ove-phone" placeholder="请输入联系电话"/></div><div class="form-group"><label>车辆状态</label><select id="ove-status"><option>待发车</option><option>配送中</option><option>已送达</option><option>已返回</option></select></div></div>
<div class="form-row"><div class="form-group"><label>发车时间</label><input id="ove-depart" type="datetime-local"/></div><div class="form-group"><label>预计到达时间</label><input id="ove-eta" type="datetime-local"/></div></div>
<div class="form-row"><div class="form-group"><label>FBA预约</label><input id="ove-fba-booking" placeholder="请输入FBA预约信息"/></div><div class="form-group"><label>车辆预约</label><input id="ove-car-booking" placeholder="请输入车辆预约信息"/></div></div>
<div class="form-row single"><div class="form-group"><label>路线</label><input id="ove-route" placeholder="请输入配送路线"/></div></div>
</div>
</div>
<div id="ove-panel-orders" class="outbound-vehicle-edit-panel outbound-vehicle-edit-orders-panel" hidden>
<div class="outbound-vehicle-edit-form outbound-vehicle-orders-form">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px"><div style="font-size:14px;font-weight:600;color:#334155">关联订单明细</div><button type="button" class="btn btn-primary" onclick="openOutboundVehicleOrderSelect()">+ 添加订单</button></div>
<div style="border:1px solid #e8e8e8;border-radius:6px;overflow:auto"><table><thead><tr><th>订单号</th><th>重量(kg)</th><th>托数</th><th>目的FBA仓</th><th>体积(m³)</th><th>集装箱号</th><th>操作</th></tr></thead><tbody id="ove-orders-body"><tr><td colspan="7" style="padding:36px 16px;text-align:center;color:#94a3b8">暂无关联订单</td></tr></tbody></table></div>
</div>
</div>
</div>
<div class="modal-footer"><button class="btn btn-default" onclick="cancelOutboundVehicleEdit()">取消</button><button class="btn btn-primary" onclick="saveVehicleEdit()">保存</button></div>
</div>
</div>
<!-- 选择待派送订单弹窗 -->
<div class="modal-overlay" id="modal-outbound-vehicle-order-select">
<div class="modal outbound-vehicle-modal outbound-vehicle-order-select-modal" style="max-width:980px">
<div class="modal-header"><span>选择待派送订单</span><button type="button" class="outbound-vehicle-modal-close" onclick="hideModal('modal-outbound-vehicle-order-select')" aria-label="关闭" style="cursor:pointer;font-size:20px;border:0;background:transparent;line-height:1"><span aria-hidden="true">&times;</span></button></div>
<div class="modal-body outbound-vehicle-order-select-modal-body" style="max-height:68vh;overflow:auto">
<div class="outbound-vehicle-order-select-form">
<div class="search-row" style="margin-bottom:12px"><input id="ove-order-keyword" placeholder="订单号/集装箱号" oninput="renderOutboundVehicleOrderOptions()" style="width:220px"/><input id="ove-order-fba" placeholder="目的FBA仓" oninput="renderOutboundVehicleOrderOptions()" style="width:180px"/></div>
<div id="ove-order-option-summary" style="font-size:13px;color:#64748b;margin-bottom:10px">共 0 条待派送订单,已选择 0 条</div>
<div style="border:1px solid #e8e8e8;border-radius:6px;overflow:auto"><table><thead><tr><th style="width:56px;text-align:center">选择</th><th>订单号</th><th>重量(kg)</th><th>托数</th><th>目的FBA仓</th><th>体积(m³)</th><th>集装箱号</th></tr></thead><tbody id="ove-order-options-body"><tr><td colspan="7" style="padding:36px 16px;text-align:center;color:#94a3b8">暂无符合条件的待派送订单</td></tr></tbody></table></div>
</div>
</div>
<div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-outbound-vehicle-order-select')">取消</button><button class="btn btn-primary" onclick="confirmOutboundVehicleOrderSelection()">加入所选订单</button></div>
</div>
</div>
<!-- 客户应收应付新增/编辑 -->
<div class="modal-overlay" id="modal-arap-form">
<div class="modal" style="max-width:760px;width:94%">
<div class="modal-header"><span id="arap-form-title">新增客户应收应付</span><span onclick="hideModal('modal-arap-form')" style="cursor:pointer;font-size:20px" aria-label="关闭">&times;</span></div>
<div class="modal-body" style="max-height:70vh;overflow:auto">
<input type="hidden" id="arap-form-index" value="-1"/>
<div class="form-row"><div class="form-group"><label>客户公司名 *</label><input id="arap-form-customer" maxlength="120" placeholder="请输入客户公司名"/></div><div class="form-group"><label>参与结算 *</label><select id="arap-form-join-settle"><option value="">请选择</option><option>是</option><option>否</option></select></div></div>
<div class="form-row"><div class="form-group"><label>应收/应付 *</label><select id="arap-form-type"><option value="">请选择</option><option>应收</option><option>应付</option></select></div><div class="form-group"><label>入仓码 *</label><input id="arap-form-wh-code" maxlength="64" placeholder="请输入入仓码"/></div></div>
<div class="form-row"><div class="form-group"><label>订单状态 *</label><input id="arap-form-order-status" maxlength="40" placeholder="请输入订单状态"/></div><div class="form-group"><label>派送单号</label><input id="arap-form-dispatch-no" maxlength="64" placeholder="选填"/></div></div>
<div class="form-row"><div class="form-group"><label>费用类型 *</label><input id="arap-form-fee-type" maxlength="40" placeholder="请输入费用类型"/></div><div class="form-group"><label>金额 *</label><input id="arap-form-amount" type="number" min="0" step="0.01" placeholder="请输入金额"/></div></div>
<div class="form-row"><div class="form-group"><label>集装箱号</label><input id="arap-form-container-no" maxlength="32" placeholder="选填"/></div><div class="form-group"><label>柜号</label><input id="arap-form-cabinet-no" maxlength="80" placeholder="选填"/></div></div>
<div style="font-size:12px;color:#64748b;line-height:1.6">保存后记录将进入“待审核”,审核通过后不可编辑。</div>
</div>
<div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-arap-form')">取消</button><button class="btn btn-primary" onclick="saveArapForm()">保存并提交审核</button></div>
</div>
</div>
<!-- 客户应收应付审核 -->
<div class="modal-overlay" id="modal-arap-audit">
<div class="modal" style="max-width:560px;width:94%">
<div class="modal-header"><span>审核客户应收应付</span><span onclick="hideModal('modal-arap-audit')" style="cursor:pointer;font-size:20px" aria-label="关闭">&times;</span></div>
<div class="modal-body">
<input type="hidden" id="arap-audit-index" value="-1"/>
<div style="display:grid;grid-template-columns:90px 1fr;gap:10px 14px;padding:14px;background:#f8fafc;border:1px solid #e5e7eb;border-radius:8px;margin-bottom:16px;font-size:13px"><span style="color:#64748b">费用单号</span><strong id="arap-audit-doc-no">—</strong><span style="color:#64748b">客户公司</span><strong id="arap-audit-customer">—</strong><span style="color:#64748b">费用类型</span><strong id="arap-audit-fee-type">—</strong><span style="color:#64748b">金额</span><strong id="arap-audit-amount">—</strong><span style="color:#64748b">当前状态</span><strong id="arap-audit-status">—</strong></div>
<div class="form-group"><label>审核备注</label><textarea id="arap-audit-remark" rows="3" maxlength="300" placeholder="审核通过时可填写备注"></textarea></div>
<div class="form-group"><label>驳回原因(驳回时必填)</label><textarea id="arap-audit-reject-reason" rows="3" maxlength="300" placeholder="选择审核驳回时必填"></textarea></div>
</div>
<div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-arap-audit')">取消</button><button class="btn btn-default" style="color:#dc2626;border-color:#fecaca" onclick="submitArapAudit('reject')">审核驳回</button><button class="btn btn-primary" onclick="submitArapAudit('pass')">审核通过</button></div>
</div>
</div>
<!-- 结算单应付付款凭证 -->
<div class="modal-overlay" id="modal-settlement-ap-voucher">
<div class="modal" style="max-width:680px;width:94%">
<div class="modal-header"><span>上传付款凭证</span><span onclick="closeSettlementApVoucher()" style="cursor:pointer;font-size:20px" aria-label="关闭">&times;</span></div>
<div class="modal-body" style="max-height:70vh;overflow:auto">
<div style="display:grid;grid-template-columns:88px 1fr;gap:10px 14px;padding:14px;background:#f8fafc;border:1px solid #e5e7eb;border-radius:8px;margin-bottom:16px;font-size:13px"><span style="color:#64748b">结算单号</span><strong id="settlement-ap-voucher-no">—</strong><span id="settlement-voucher-party-label" style="color:#64748b">供应商</span><strong id="settlement-ap-voucher-supplier">—</strong></div>
<div class="form-group"><label>付款水单 *</label><input id="settlement-ap-voucher-files" type="file" multiple accept=".pdf,.jpg,.jpeg,.png" onchange="handleSettlementApVoucherFiles(this.files)"/><div style="margin-top:6px;font-size:12px;color:#94a3b8">支持 PDF、JPG、JPEG、PNG,可一次选择多张,单个文件不超过 10MB。</div></div>
<div style="display:flex;justify-content:space-between;align-items:center;margin:16px 0 8px"><div style="font-size:13px;font-weight:600;color:#334155">付款凭证列表</div><span style="font-size:12px;color:#64748b">共 <span id="settlement-voucher-count">0</span> 个</span></div><div id="settlement-ap-voucher-list" style="border:1px solid #e5e7eb;border-radius:6px;overflow:hidden"></div>
</div>
<div class="modal-footer"><button class="btn btn-default" onclick="closeSettlementApVoucher()">取消</button><button class="btn btn-primary" onclick="saveSettlementApVouchers()">保存</button></div>
</div>
</div>
<!-- 结算单应付审批 -->
<div class="modal-overlay" id="modal-settlement-ap-approval">
<div class="modal" style="max-width:600px;width:94%">
<div class="modal-header"><span>结算单审批</span><span onclick="closeSettlementApApproval()" style="cursor:pointer;font-size:20px" aria-label="关闭">&times;</span></div>
<div class="modal-body">
<div style="display:grid;grid-template-columns:88px 1fr;gap:10px 14px;padding:14px;background:#f8fafc;border:1px solid #e5e7eb;border-radius:8px;margin-bottom:16px;font-size:13px"><span style="color:#64748b">结算单号</span><strong id="settlement-ap-approval-no">—</strong><span style="color:#64748b">供应商</span><strong id="settlement-ap-approval-supplier">—</strong><span style="color:#64748b">应付金额</span><strong id="settlement-ap-approval-amount">—</strong><span style="color:#64748b">付款状态</span><strong id="settlement-ap-approval-pay-status">—</strong><span style="color:#64748b">审批状态</span><strong id="settlement-ap-approval-status">—</strong></div>
<div id="settlement-ap-approval-readonly" style="display:none;padding:10px 12px;margin-bottom:14px;background:#f0fdf4;color:#15803d;border-radius:6px;font-size:13px">该结算单已审批通过,仅可查看审批结果。</div>
<div class="form-group"><label>审批备注</label><textarea id="settlement-ap-approval-remark" rows="3" maxlength="300" placeholder="审批通过时可填写备注"></textarea></div>
<div class="form-group"><label>驳回原因(驳回时必填)</label><textarea id="settlement-ap-approval-reject-reason" rows="3" maxlength="300" placeholder="选择审批驳回时必填"></textarea></div>
</div>
<div class="modal-footer"><button class="btn btn-default" onclick="closeSettlementApApproval()">取消</button><span id="settlement-ap-approval-actions"><button class="btn btn-default" style="color:#dc2626;border-color:#fecaca" onclick="submitSettlementApApproval('reject')">审批驳回</button><button class="btn btn-primary" onclick="submitSettlementApApproval('pass')">审批通过</button></span></div>
</div>
</div>
<!-- 通用:月度美元兑人民币汇率维护 -->
<div class="modal-overlay" id="modal-exchange-rate-edit">
<div class="modal" style="width:560px;max-width:calc(100vw - 32px)">
<div class="modal-header"><span id="exchange-rate-modal-title">更新月度汇率</span><span onclick="hideModal('modal-exchange-rate-edit')" style="cursor:pointer;font-size:20px" aria-label="关闭">&times;</span></div>
<div class="modal-body">
<div class="form-group"><label><span style="color:#ff4d4f">*</span> 汇率月份</label><input id="exchange-rate-month" type="month" onchange="updateExchangeRateOverwriteHint()"/></div>
<div class="form-group"><label><span style="color:#ff4d4f">*</span> 美元兑人民币平均汇率</label><input id="exchange-rate-value" type="number" min="0.0001" step="0.0001" placeholder="如 7.1846"/><div style="margin-top:6px;font-size:12px;color:#64748b">汇率方向:1 USD = X CNY</div></div>
<div class="form-group"><label>备注</label><textarea id="exchange-rate-remark" maxlength="100" rows="3" placeholder="选填,最多 100 个字符"></textarea></div>
<div id="exchange-rate-overwrite-hint" style="display:none;padding:10px 12px;background:#fff7e6;border:1px solid #ffd591;color:#ad6800;border-radius:6px;font-size:12px">该月份已有记录,保存后将覆盖原记录。</div>
</div>
<div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-exchange-rate-edit')">取消</button><button class="btn btn-primary" onclick="saveExchangeRate()">保存</button></div>
</div>
</div>
<!-- 应收款统计表:编辑明细 -->
<div class="modal-overlay" id="modal-receivable-report-edit">
<div class="modal" style="width:560px;max-width:calc(100vw - 32px)">
<div class="modal-header"><span>编辑应收款明细</span><span onclick="hideModal('modal-receivable-report-edit')" style="cursor:pointer;font-size:20px" aria-label="关闭">&times;</span></div>
<div class="modal-body">
<div class="form-group"><label for="ardr-edit-entity">应收单位</label><input id="ardr-edit-entity" readonly style="background:#f5f6f8;color:#64748b"/></div>
<div class="form-group"><label for="ardr-edit-customer"><span style="color:#ff4d4f">*</span> 客户</label><input id="ardr-edit-customer" required maxlength="50" placeholder="请输入客户"/></div>
<div class="form-group"><label for="ardr-edit-remark">备注</label><textarea id="ardr-edit-remark" maxlength="200" rows="4" placeholder="选填,最多 200 个字符" style="resize:vertical"></textarea></div>
</div>
<div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-receivable-report-edit')">取消</button><button class="btn btn-primary" onclick="saveReceivableReportEdit()">保存</button></div>
</div>
</div>
<!-- 海外仓出库单 - 取消 -->
<div class="modal-overlay" id="modal-fboo-cancel"><div class="modal" style="max-width:560px;width:94%"><div class="modal-header"><span>取消出库单</span><span onclick="hideModal('modal-fboo-cancel')" style="cursor:pointer;font-size:20px" aria-label="关闭">&times;</span></div><div class="modal-body">
<div id="fboo-cancel-summary" class="lmo-modal-note">请选择待取消的出库单</div>
<input id="fboo-cancel-no" type="hidden">
<div class="form-group"><label>取消原因 *</label><textarea id="fboo-cancel-reason" rows="3" maxlength="200" placeholder="请输入取消原因"></textarea></div>
</div><div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-fboo-cancel')">返回</button><button class="btn btn-danger" onclick="confirmFbaOutboundCancellation()">确认取消</button></div></div></div>
<!-- 客户账户:充值申请 -->
<style>
.customer-recharge-layout{display:grid;grid-template-columns:minmax(0,1fr) 260px;gap:22px}.customer-recharge-layout>*{min-width:0}.customer-recharge-qr{padding:16px;border:1px solid #e5e7eb;border-radius:8px;text-align:center;background:#fafafa}.customer-recharge-qr img{display:block;width:min(100%,220px);height:auto;margin:0 auto}.customer-recharge-voucher-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:9px 10px;border-bottom:1px solid #eef0f4}.customer-recharge-voucher-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.customer-recharge-review-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px 20px}.customer-recharge-review-item{min-width:0}.customer-recharge-review-item label{display:block;font-size:12px;color:#94a3b8;margin-bottom:4px}.customer-recharge-review-item div{font-size:13px;color:#334155;word-break:break-word}@media(max-width:700px){.customer-recharge-layout,.customer-recharge-review-grid{grid-template-columns:minmax(0,1fr)}.customer-recharge-layout{gap:16px}.customer-recharge-layout .form-row{display:block}.customer-recharge-layout .form-group{width:100%;min-width:0}.customer-recharge-layout input,.customer-recharge-layout select,.customer-recharge-layout textarea{box-sizing:border-box;max-width:100%}}
</style>
<div class="modal-overlay" id="modal-customer-recharge-application">
<div class="modal" style="max-width:900px">
<div class="modal-header"><span>提交充值申请</span><span onclick="closeCustomerRechargeApplication()" style="cursor:pointer;font-size:20px" aria-label="关闭">&times;</span></div>
<div class="modal-body" style="padding:22px 26px"><input id="customer-recharge-application-id" type="hidden"><div class="customer-recharge-layout"><div>
<div class="form-row"><div class="form-group"><label>客户名称</label><input id="customer-recharge-customer" readonly style="background:#f5f6f8"></div><div class="form-group"><label>当前余额</label><input id="customer-recharge-current" readonly style="background:#f5f6f8;color:#16a34a;font-weight:700"></div></div>
<div class="form-row"><div class="form-group"><label>充值金额 <span style="color:#ef4444">*</span></label><input id="customer-recharge-amount" type="number" min="0.01" step="0.01" placeholder="请输入充值金额"></div><div class="form-group"><label>充值方式 <span style="color:#ef4444">*</span></label><select id="customer-recharge-method"><option>银行转账</option><option>支付宝</option><option>微信支付</option><option>其它</option></select></div></div>
<div class="form-row"><div class="form-group"><label>到账日期 <span style="color:#ef4444">*</span></label><input id="customer-recharge-date" type="date"></div><div class="form-group"><label>收款凭证号</label><input id="customer-recharge-ref" maxlength="80" placeholder="请输入银行流水/凭证号"></div></div>
<div class="form-group"><label>充值备注</label><textarea id="customer-recharge-remark" rows="3" maxlength="300" placeholder="请输入充值说明" style="resize:vertical"></textarea></div>
<div class="form-group"><label>上传充值凭证 <span style="color:#ef4444">*</span></label><input id="customer-recharge-vouchers" type="file" multiple accept=".pdf,.jpg,.jpeg,.png" onchange="handleCustomerRechargeVouchers(this.files)"><div style="font-size:12px;color:#94a3b8;margin-top:6px">支持 PDF、JPG、JPEG、PNG,单个文件不超过 10 MB,可多选。</div><div id="customer-recharge-voucher-list" style="margin-top:8px;border:1px solid #eef0f4;border-radius:6px"></div></div>
</div><div class="customer-recharge-qr"><img id="customer-recharge-qr" src="prototype-src/assets/customer-recharge-qr.svg" alt="示例收款二维码"><div style="font-weight:700;color:#334155;margin-top:10px">柏悦物流(示例)</div><div style="font-size:12px;line-height:1.6;color:#ef4444;margin-top:5px">示例收款二维码,仅用于原型展示</div></div></div></div>
<div class="modal-footer"><button class="btn btn-default" onclick="closeCustomerRechargeApplication()">取消</button><button class="btn btn-primary" onclick="submitCustomerRechargeApplication()">提交申请</button></div></div></div>
<!-- 客户账户:充值申请审核 -->
<div class="modal-overlay" id="modal-customer-recharge-review"><div class="modal" style="max-width:720px"><div class="modal-header"><span id="customer-recharge-review-title">充值申请审核</span><span onclick="hideModal('modal-customer-recharge-review')" style="cursor:pointer;font-size:20px" aria-label="关闭">&times;</span></div><div class="modal-body" style="padding:22px 26px"><div id="customer-recharge-review-summary" class="customer-recharge-review-grid"></div><div class="form-group" style="margin-top:18px"><label>充值凭证</label><div id="customer-recharge-review-vouchers" style="border:1px solid #eef0f4;border-radius:6px"></div></div><div class="form-group" style="margin-top:18px"><label>审核备注 <span id="customer-recharge-review-required" style="color:#ef4444">*</span></label><textarea id="customer-recharge-review-remark" rows="3" maxlength="300" placeholder="驳回时必须填写原因" style="resize:vertical"></textarea></div></div><div class="modal-footer" id="customer-recharge-review-actions"><button class="btn btn-default" onclick="hideModal('modal-customer-recharge-review')">取消</button><button class="btn btn-danger" onclick="handleCustomerRechargeReview('驳回')">审核驳回</button><button class="btn btn-primary" onclick="handleCustomerRechargeReview('通过')">审核通过</button></div></div></div>
<!-- 客户账户:手动扣款 -->
<div class="modal-overlay" id="modal-customer-deduction">
<div class="modal" style="max-width:620px">
<div class="modal-header"><span>手动扣款</span><span onclick="hideModal('modal-customer-deduction')" style="cursor:pointer;font-size:20px" aria-label="关闭">&times;</span></div>
<div class="modal-body" style="padding:22px 26px">
<div style="padding:12px 14px;border-radius:8px;background:#fff7ed;color:#9a3412;font-size:13px;margin-bottom:18px">扣款金额不得超过当前可用余额,冻结或停用账户不能扣款。</div>
<input type="hidden" id="ca-deduction-id">
<div class="form-row"><div class="form-group"><label>客户名称</label><input id="ca-deduction-customer" readonly style="background:#f5f6f8"></div><div class="form-group"><label>当前实际余额</label><input id="ca-deduction-current" readonly style="background:#f5f6f8;font-weight:700"></div></div>
<div class="form-row"><div class="form-group"><label>当前可用余额</label><input id="ca-deduction-available" readonly style="background:#f5f6f8;color:#16a34a;font-weight:700"></div><div class="form-group"><label>扣款金额 <span style="color:#ef4444">*</span></label><input id="ca-deduction-amount" type="number" min="0.01" step="0.01" placeholder="请输入扣款金额"></div></div>
<div class="form-row"><div class="form-group"><label>扣款类型 <span style="color:#ef4444">*</span></label><select id="ca-deduction-type"><option>贴牌</option><option>出库</option><option>仓储费用</option><option>自提</option><option>暂扣</option><option>弃货</option><option>其它</option></select></div><div class="form-group"><label>扣款日期 <span style="color:#ef4444">*</span></label><input id="ca-deduction-date" type="date"></div></div>
<div class="form-row"><div class="form-group" style="width:100%"><label>关联单据</label><input id="ca-deduction-order" maxlength="80" placeholder="请输入关联单据号"></div></div>
<div class="form-row"><div class="form-group" style="width:100%"><label>扣款备注</label><textarea id="ca-deduction-remark" rows="3" maxlength="300" placeholder="请输入扣款说明" style="resize:vertical"></textarea></div></div>
</div>
<div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-customer-deduction')">取消</button><button class="btn btn-primary" onclick="saveCustomerDeduction()">确认扣款</button></div>
</div>
</div>
<div class="toast" id="toast"></div>
<!-- MODAL: 新增海外用户 -->
@@ -1445,5 +1730,82 @@
</div>
</div>
</div>
<div class="modal-overlay" id="modal-domestic-inbound-fee">
<div class="modal" style="max-width:560px">
<div class="modal-header">新增订单费用<span onclick="hideModal('modal-domestic-inbound-fee')" style="cursor:pointer;font-size:20px" aria-label="关闭">&times;</span></div>
<div class="modal-body" style="padding:20px 24px">
<div style="padding:10px 12px;margin-bottom:16px;background:#f8fafc;border:1px solid #e5e7eb;border-radius:6px;font-size:13px;color:#64748b">订单入仓码:<strong id="domestic-fee-order-code" style="color:#334155">PCKK2606151065</strong></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px">
<div><label for="domestic-fee-type" style="font-size:13px;font-weight:600;color:#374151;display:block;margin-bottom:6px">费用类型 <span style="color:#ef4444">*</span></label><select id="domestic-fee-type" onchange="syncDomesticFeeVoucherRequirement()" style="width:100%;padding:9px 12px;border:1px solid #d1d5db;border-radius:6px;background:#fff"><option value="取货费">取货费</option><option value="快递费">快递费</option></select></div>
<div><label for="domestic-fee-currency" style="font-size:13px;font-weight:600;color:#374151;display:block;margin-bottom:6px">币种 <span style="color:#ef4444">*</span></label><select id="domestic-fee-currency" style="width:100%;padding:9px 12px;border:1px solid #d1d5db;border-radius:6px;background:#fff"><option value="CNY">CNY</option><option value="USD">USD</option></select></div>
</div>
<div style="margin-bottom:16px"><label for="domestic-fee-amount" style="font-size:13px;font-weight:600;color:#374151;display:block;margin-bottom:6px">金额 <span style="color:#ef4444">*</span></label><input id="domestic-fee-amount" type="number" min="0.01" step="0.01" placeholder="请输入大于 0 的金额" style="width:100%;padding:9px 12px;border:1px solid #d1d5db;border-radius:6px;box-sizing:border-box"/></div>
<div id="domestic-fee-voucher-wrap" style="margin-bottom:16px"><label for="domestic-fee-voucher" style="font-size:13px;font-weight:600;color:#374151;display:block;margin-bottom:6px">付款凭证 <span id="domestic-fee-voucher-required" style="color:#ef4444">*</span></label><div style="display:flex;align-items:center;gap:10px;min-width:0"><label class="btn btn-sm btn-default" style="cursor:pointer;margin:0">选择文件<input id="domestic-fee-voucher" type="file" accept="image/*,.pdf" onchange="handleDomesticFeeVoucher(this)" style="display:none"/></label><span id="domestic-fee-voucher-name" style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#94a3b8;font-size:12px">未选择文件</span><button type="button" id="domestic-fee-voucher-remove" class="btn btn-sm btn-default" onclick="removeDomesticFeeVoucher()" style="display:none">移除</button></div><div style="font-size:12px;color:#94a3b8;margin-top:6px">支持图片或 PDF 文件</div></div>
<div><label for="domestic-fee-remark" style="font-size:13px;font-weight:600;color:#374151;display:block;margin-bottom:6px">备注</label><textarea id="domestic-fee-remark" maxlength="200" placeholder="请输入备注(最多 200 字)" style="width:100%;min-height:76px;padding:9px 12px;border:1px solid #d1d5db;border-radius:6px;box-sizing:border-box;resize:vertical;font-family:inherit"></textarea></div>
</div>
<div class="modal-footer"><button type="button" class="btn btn-default" onclick="hideModal('modal-domestic-inbound-fee')">取消</button><button type="button" class="btn btn-primary" onclick="saveDomesticInboundFee()">保存</button></div>
</div>
</div>
<!-- 仓库管理新增/编辑弹窗 -->
<div class="modal-overlay" id="modal-fba-add">
<div class="modal" style="max-width:560px">
<div class="modal-header"><span id="warehouse-form-title">新增仓库</span><span onclick="hideModal('modal-fba-add')" style="cursor:pointer;font-size:20px">&times;</span></div>
<div class="modal-body" id="fba-add-body">
<style>
#fba-add-body .form-row.single .form-group{flex-direction:row;align-items:center;gap:12px;margin-bottom:0;}
#fba-add-body .form-row.single .form-group label{width:180px;text-align:right;flex-shrink:0;margin:0;font-size:14px;color:#52525b;font-weight:600;}
#fba-add-body .form-row.single .form-group label .required{color:#ef4444;margin-right:4px;}
#fba-add-body .form-row.single .form-group input,
#fba-add-body .form-row.single .form-group select{flex:1;padding:10px 14px;border:1px solid rgba(102,126,234,.2);border-radius:12px;font-size:14px;outline:none;background:rgba(255,255,255,.9);transition:all .3s cubic-bezier(.4,0,.2,1);}
#fba-add-body .form-row.single .form-group input:focus,
#fba-add-body .form-row.single .form-group select:focus{border-color:#667eea;box-shadow:0 0 0 4px rgba(102,126,234,.1);transform:translateY(-1px);}
</style>
<div class="form-row single" id="warehouse-owned-name-row" style="display:none"><div class="form-group"><label><span class="required">*</span>自有仓名称:</label><input id="warehouse-owned-name" placeholder="请输入自有仓名称"/></div></div>
<div class="form-row single"><div class="form-group"><label><span class="required">*</span>国家:</label><select id="fba-add-country"><option value="">请输入国家</option><option>美国</option><option>英国</option><option>德国</option><option>日本</option><option>加拿大</option><option>澳大利亚</option></select></div></div>
<div class="form-row single" id="warehouse-code-row"><div class="form-group"><label><span class="required">*</span>仓库代码:</label><input id="fba-add-code" placeholder="请输入仓库代码"/></div></div>
<div class="form-row single"><div class="form-group"><label>City:</label><input id="fba-add-city" placeholder="请输入City"/></div></div>
<div class="form-row single"><div class="form-group"><label>State:</label><input id="fba-add-state" placeholder="请输入State"/></div></div>
<div class="form-row single"><div class="form-group"><label><span class="required">*</span>详细地址:</label><input id="fba-add-addr" placeholder="请输入详细地址"/></div></div>
<div class="form-row single"><div class="form-group"><label><span class="required">*</span>邮编:</label><input id="fba-add-zip" placeholder="请输入邮编"/></div></div>
<div class="form-row single" id="warehouse-distance-row" style="display:none"><div class="form-group"><label>距离(MILES):</label><div style="flex:1"><div style="font-size:12px;color:#64748b;margin-bottom:6px">当前筛选自有仓:<strong id="warehouse-distance-origin" style="color:#334155"></strong></div><input id="warehouse-distance-miles" type="number" min="0" step="0.1" placeholder="留空则不维护距离" style="width:100%"/></div></div></div>
<div class="form-row single"><div class="form-group"><label>包税补运费标准(按毛重每公斤/美元):</label><input id="fba-add-tax-rate" placeholder="请输入包税补运费标准(按毛重每公斤/美元)"/></div></div>
<div class="form-row single"><div class="form-group"><label>不包税补运费标准(按立方每立方米/美元):</label><input id="fba-add-no-tax-rate" placeholder="请输入不包税补运费标准(按立方每立方米/美元)"/></div></div>
<div class="form-row single"><div class="form-group"><label>内部编号:</label><input id="fba-add-inner-code" placeholder="请输入内部编号"/></div></div>
</div>
<div class="modal-footer"><button class="btn btn-primary" onclick="saveWarehouseForm()">确定</button><button class="btn btn-default" onclick="hideModal('modal-fba-add')">关闭</button></div></div></div>
<!-- THESIS:批量标签直入打印。 OWN-WORLD:继承后台控件,深色工作台托白纸。 STORY:选柜、调字号、打印。 FIRST VIEWPORT:稳定工具栏与横向A4纸。 FORM:参考深色全屏预览的Operate局部扩展。 -->
<div class="modal-overlay ct-pallet-preview-modal" id="modal-ct-pallet-label-preview" role="dialog" aria-modal="true" aria-labelledby="ct-pallet-preview-title">
<div class="modal ct-pallet-preview-panel">
<div class="ct-pallet-preview-toolbar">
<div class="ct-pallet-preview-meta">
<span>打印数据</span>
<strong id="ct-pallet-preview-title"><span id="ct-pallet-preview-count" aria-live="polite">0</span> 张托盘标</strong>
</div>
<div class="ct-pallet-preview-controls">
<div class="ct-pallet-font-control">
<span>托盘标签字号</span>
<div class="ct-pallet-font-stepper">
<button type="button" aria-label="减小托盘标签字号" title="减小托盘标签字号" onclick="adjustCtPalletLabelFont('code',-10)">-</button>
<output id="ct-pallet-code-size" aria-live="polite">120</output>
<button type="button" aria-label="增大托盘标签字号" title="增大托盘标签字号" onclick="adjustCtPalletLabelFont('code',10)">+</button>
</div>
</div>
<div class="ct-pallet-font-control">
<span>目的仓字号</span>
<div class="ct-pallet-font-stepper">
<button type="button" aria-label="减小目的仓字号" title="减小目的仓字号" onclick="adjustCtPalletLabelFont('destination',-10)">-</button>
<output id="ct-pallet-destination-size" aria-live="polite">180</output>
<button type="button" aria-label="增大目的仓字号" title="增大目的仓字号" onclick="adjustCtPalletLabelFont('destination',10)">+</button>
</div>
</div>
<button type="button" class="btn btn-default" onclick="closeCtPalletLabelPreview()">取消</button>
<button type="button" class="btn btn-primary ct-pallet-print-action" onclick="printCtPalletLabels()">打印</button>
<button type="button" class="ct-pallet-preview-close" id="ct-pallet-preview-close" aria-label="关闭托盘标预览" title="关闭" onclick="closeCtPalletLabelPreview()">&times;</button>
</div>
</div>
<div class="ct-pallet-preview-scroll">
<div id="ct-pallet-label-list" aria-live="polite"></div>
</div>
</div>
</div>
@@ -37,6 +37,8 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Helv
.top-message-dropdown button span{width:20px;text-align:center}
.hamburger{color:#fff;font-size:24px;cursor:pointer;transition:all .3s;position:relative;z-index:1;padding:8px;border-radius:8px}
.hamburger:hover{background:rgba(255,255,255,.15);transform:scale(1.1)}
@media(max-width:1100px){.top-nav .tenant-switch{display:none}}
@media(max-width:820px){.top-nav{padding:0 12px;gap:8px}.top-nav .logo{min-width:0}.top-nav .nav-right{display:none}}
.layout{display:flex;margin-top:64px;height:calc(100vh - 64px);overflow:hidden}
.sidebar{width:240px;background:rgba(255,255,255,.9);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);border-right:1px solid rgba(255,255,255,.3);position:fixed;top:64px;left:0;bottom:0;overflow-y:auto;z-index:90;transition:transform .35s cubic-bezier(.4,0,.2,1);padding:12px 0;box-shadow:4px 0 32px rgba(0,0,0,.08)}
.sidebar.collapsed{transform:translateX(-240px)}
@@ -81,6 +83,12 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Helv
.stat-card:nth-child(3) .decorator{background:linear-gradient(135deg,#f59e0b,#ea580c)}
.stat-card:nth-child(4) .decorator{background:linear-gradient(135deg,#7c3aed,#6d28d9)}
.stat-card:nth-child(5) .decorator{background:linear-gradient(135deg,#ec4899,#db2777)}
#page-gen-exchange-rate .exchange-rate-summary{grid-template-columns:repeat(4,minmax(0,1fr))}
#page-gen-exchange-rate .exchange-rate-summary .stat-card{min-width:0}
#page-gen-exchange-rate .exchange-rate-summary .value{font-size:20px;line-height:1.35;letter-spacing:0;overflow-wrap:anywhere}
#page-gen-exchange-rate .table-toolbar{gap:12px;flex-wrap:wrap}
@media(max-width:1200px){#page-gen-exchange-rate .exchange-rate-summary{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:640px){#page-gen-exchange-rate .exchange-rate-summary{grid-template-columns:1fr}}
/* CHART */
.chart-row{display:grid;grid-template-columns:2fr 1fr;gap:20px;margin-bottom:20px}
.chart-row.equal{grid-template-columns:1fr 1fr}
@@ -139,7 +147,7 @@ canvas{max-height:280px}
#rsq-wrap.rsq-collapsed .rsq-toggle svg{transform:rotate(180deg)}
.recon-main-table-wrap{overflow-x:auto}
#page-customer-arap .arap-table-wrap{width:100%;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch}
#page-customer-arap .arap-table{width:2005px;min-width:2005px;table-layout:fixed;border-collapse:collapse;font-size:12.5px}
#page-customer-arap .arap-table{width:1800px;min-width:1800px;table-layout:fixed;border-collapse:collapse;font-size:12.5px}
#page-customer-arap .arap-table th{height:44px;padding:10px 8px;background:#f5f6fb;color:#333;font-size:12px;font-weight:600;text-align:left;white-space:nowrap}
#page-customer-arap .arap-table td{height:55px;padding:10px 8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#page-customer-arap .arap-table th:nth-child(1),#page-customer-arap .arap-table td:nth-child(1){padding-left:5px;padding-right:5px}
@@ -147,26 +155,32 @@ canvas{max-height:280px}
#page-customer-arap .arap-table-wrap::-webkit-scrollbar{height:8px}
#page-customer-arap .arap-table-wrap::-webkit-scrollbar-thumb{background:#cfd6df;border-radius:8px}
#page-customer-arap .arap-table-wrap::-webkit-scrollbar-track{background:#f3f4f6}
#page-customer-recon-stmt .recon-main-table{font-size:11px;min-width:1740px;table-layout:fixed}
#page-customer-recon-stmt .recon-main-table{font-size:11px;min-width:1880px;table-layout:fixed}
#page-customer-recon-stmt .recon-main-table th{padding:9px 6px;font-size:11px;line-height:1.25;text-align:center;white-space:nowrap}
#page-customer-recon-stmt .recon-main-table td{height:34px;padding:7px 6px;font-size:11px;line-height:1.25;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#page-customer-recon-stmt .recon-main-table th:nth-child(1){width:190px;min-width:190px!important}
#page-customer-recon-stmt .recon-main-table td{height:38px;padding:7px 6px;font-size:11px;line-height:1.25;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#page-customer-recon-stmt .recon-main-table th:nth-child(1){width:260px;min-width:260px!important}
#page-customer-recon-stmt .recon-main-table th:nth-child(2){width:70px}
#page-customer-recon-stmt .recon-main-table th:nth-child(3){width:80px}
#page-customer-recon-stmt .recon-main-table th:nth-child(4){width:150px}
#page-customer-recon-stmt .recon-main-table th:nth-child(5),#page-customer-recon-stmt .recon-main-table th:nth-child(6){width:86px}
#page-customer-recon-stmt .recon-main-table th:nth-child(7){width:55px}
#page-customer-recon-stmt .recon-main-table th:nth-child(8){width:45px}
#page-customer-recon-stmt .recon-main-table th:nth-child(9){width:88px}
#page-customer-recon-stmt .recon-main-table th:nth-child(10),#page-customer-recon-stmt .recon-main-table th:nth-child(11){width:80px}
#page-customer-recon-stmt .recon-main-table th:nth-child(12){width:115px}
#page-customer-recon-stmt .recon-main-table th:nth-child(13){width:55px}
#page-customer-recon-stmt .recon-main-table th:nth-child(14){width:68px}
#page-customer-recon-stmt .recon-main-table th:nth-child(15){width:60px}
#page-customer-recon-stmt .recon-main-table th:nth-child(16),#page-customer-recon-stmt .recon-main-table th:nth-child(18){width:55px}
#page-customer-recon-stmt .recon-main-table th:nth-child(17),#page-customer-recon-stmt .recon-main-table th:nth-child(19){width:120px}
#page-customer-recon-stmt .recon-main-table th:nth-child(3){width:86px}
#page-customer-recon-stmt .recon-main-table th:nth-child(4){width:80px}
#page-customer-recon-stmt .recon-main-table th:nth-child(5){width:150px}
#page-customer-recon-stmt .recon-main-table th:nth-child(6),#page-customer-recon-stmt .recon-main-table th:nth-child(7){width:86px}
#page-customer-recon-stmt .recon-main-table th:nth-child(8){width:55px}
#page-customer-recon-stmt .recon-main-table th:nth-child(9){width:45px}
#page-customer-recon-stmt .recon-main-table th:nth-child(10){width:88px}
#page-customer-recon-stmt .recon-main-table th:nth-child(11),#page-customer-recon-stmt .recon-main-table th:nth-child(12){width:80px}
#page-customer-recon-stmt .recon-main-table th:nth-child(13){width:115px}
#page-customer-recon-stmt .recon-main-table th:nth-child(14){width:55px}
#page-customer-recon-stmt .recon-main-table th:nth-child(15){width:68px}
#page-customer-recon-stmt .recon-main-table th:nth-child(16){width:60px}
#page-customer-recon-stmt .recon-main-table th:nth-child(17),#page-customer-recon-stmt .recon-main-table th:nth-child(19){width:55px}
#page-customer-recon-stmt .recon-main-table th:nth-child(18),#page-customer-recon-stmt .recon-main-table th:nth-child(20){width:120px}
#page-customer-recon-stmt .recon-main-table td:first-child{overflow:visible;text-overflow:clip;text-align:left;font-size:9px}
#page-customer-recon-stmt .recon-main-table td:nth-child(12),#page-customer-recon-stmt .recon-main-table td:nth-child(14),#page-customer-recon-stmt .recon-main-table td:nth-child(17),#page-customer-recon-stmt .recon-main-table td:nth-child(19){font-size:10px}
#page-customer-recon-stmt .recon-main-table td:nth-child(13),#page-customer-recon-stmt .recon-main-table td:nth-child(15),#page-customer-recon-stmt .recon-main-table td:nth-child(18),#page-customer-recon-stmt .recon-main-table td:nth-child(20){font-size:10px}
#page-customer-recon-stmt .recon-action-link{border:0;background:transparent;padding:2px 3px;color:#1677ff;font:10px inherit;cursor:pointer}
#page-customer-recon-stmt .recon-action-link:hover{text-decoration:underline}
#page-customer-recon-stmt .recon-action-link.is-danger{color:#f5222d}
#page-customer-recon-stmt .recon-action-link.is-success{color:#16a34a}
#page-customer-recon-stmt .recon-action-link:disabled{color:#b8b8b8;cursor:not-allowed;text-decoration:none}
#page-customer-recon-stmt .recon-detail-empty{padding:16px!important}
@media(max-width:1500px){.rsq-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.rsq-span-2{grid-column:span 2}.rsq-actions{grid-column:span 2;justify-content:flex-end}}
@media(max-width:900px){.rsq-grid{grid-template-columns:1fr}.rsq-span-2,.rsq-actions{grid-column:span 1}.rsq-field{align-items:flex-start;flex-direction:column}.rsq-field>input,.rsq-field>select,.rsq-date{width:100%}}
@@ -545,6 +559,45 @@ body.dashboard-fullscreen #page-dashboard>.table-card .btn{padding:4px 10px;font
.dispatch-actions{display:flex;flex-direction:column;align-items:flex-start;gap:5px;min-width:86px}
.dispatch-actions a{color:#2563eb;font-size:11px;line-height:1.35;text-decoration:none;white-space:nowrap}
.dispatch-actions a:hover{text-decoration:underline}
.dispatch-actions .dispatch-action-danger{color:#dc2626}
.dispatch-actions .dispatch-action-danger:hover{color:#b91c1c}
.dispatch-actions .dispatch-cancel-action.is-blocked{color:#9ca3af;text-decoration:none;cursor:not-allowed}
.dispatch-cancel-picking{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 12px;margin-bottom:14px;border:1px solid #e5e7eb;border-radius:6px;background:#f9fafb}
.dispatch-cancel-picking span{color:#6b7280;font-size:12px}
.dispatch-cancel-picking strong{font-size:13px;color:#1f2937}
.dispatch-waybill{color:#2563eb;font-weight:500}
.dispatch-waybill-alert{color:#dc2626!important;text-decoration:underline;text-decoration-style:dotted;text-underline-offset:3px;cursor:help}
.dispatch-waybill-alert:hover,.dispatch-waybill-alert:focus-visible{color:#b91c1c!important;outline:none}
.dispatch-reject-close{font-size:20px;line-height:1;cursor:pointer;color:#6b7280}
.dispatch-reject-close:hover{color:#111827}
.dispatch-reject-meta{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:18px}
.dispatch-reject-meta>div{min-width:0;padding:10px 12px;border:1px solid #e5e7eb;border-radius:6px;background:#f9fafb}
.dispatch-reject-meta>div:first-child{grid-column:1/-1}
.dispatch-reject-meta span{display:block;margin-bottom:4px;color:#6b7280;font-size:12px}
.dispatch-reject-meta strong{display:block;overflow-wrap:anywhere;color:#1f2937;font-size:13px;font-weight:600}
.dispatch-reject-notify-row{display:flex;align-items:flex-start;gap:10px;padding:12px;border:1px solid #dbeafe;border-radius:6px;background:#eff6ff;cursor:pointer}
.dispatch-reject-notify-row input{width:16px;height:16px;margin:2px 0 0;accent-color:#2563eb;flex:0 0 auto}
.dispatch-reject-notify-row span{display:flex;flex-direction:column;gap:3px;min-width:0}
.dispatch-reject-notify-row strong{color:#1e3a8a;font-size:13px}
.dispatch-reject-notify-row small{color:#1e40af;font-size:12px;line-height:1.45}
.dispatch-exception-summary{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:12px}
.dispatch-exception-kpi{display:flex;align-items:baseline;gap:6px;padding:10px 14px;border:1px solid #e5e7eb;border-radius:6px;background:#fff}
.dispatch-exception-kpi strong{font-size:18px;color:#111827}
.dispatch-exception-kpi span{font-size:12px;color:#6b7280}
.dispatch-exception-no{color:#1f2937;font-weight:600;white-space:nowrap}
.dispatch-exception-relations{display:flex;min-width:190px;flex-direction:column;gap:3px}
.dispatch-exception-relation{display:grid;grid-template-columns:34px minmax(0,1fr);align-items:baseline;gap:6px;line-height:1.4}
.dispatch-exception-relation-label{color:#9ca3af;font-size:11px;white-space:nowrap}
.dispatch-exception-relation-no{min-width:0;color:#374151;font-size:12px;overflow-wrap:anywhere}
.dispatch-exception-related-meta{grid-column:1/-1}
.dispatch-exception-related-meta .dispatch-exception-relations{min-width:0}
.dispatch-exception-type{font-weight:600;white-space:nowrap}
.dispatch-exception-reason{max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#4b5563}
.dispatch-exception-actions{display:flex;flex-wrap:wrap;gap:6px;white-space:nowrap}
.dispatch-exception-actions a{color:#2563eb;font-size:12px;text-decoration:none}
.dispatch-exception-actions a:hover{text-decoration:underline}
.dispatch-exception-actions a.danger{color:#dc2626}
.dispatch-exception-process-note{padding:10px 12px;border:1px solid #fde68a;border-radius:6px;background:#fffbeb;color:#92400e;font-size:12px;line-height:1.5;margin-bottom:14px}
.dispatch-doc-cell{width:312px;min-width:312px;padding:2px 0}
.dispatch-doc-summary{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:7px}
.dispatch-doc-count{font-size:11px;font-weight:600;color:#374151;white-space:nowrap}
@@ -574,7 +627,62 @@ body.dashboard-fullscreen #page-dashboard>.table-card .btn{padding:4px 10px;font
.dispatch-doc-upload-label:hover,.dispatch-doc-upload-label:focus-within{border-color:#2563eb;background:#eff6ff}
.dispatch-doc-delete{padding:4px 7px;border:1px solid #fecaca;border-radius:4px;background:#fff;color:#b91c1c;font-size:11px;cursor:pointer}
.dispatch-doc-delete:disabled{border-color:#e5e7eb;color:#9ca3af;cursor:not-allowed}
@media(max-width:700px){.dispatch-doc-modal-meta{grid-template-columns:1fr}.dispatch-doc-row{grid-template-columns:1fr}.dispatch-doc-controls{justify-content:flex-start}}
.edit-fleet-form{display:flex;flex-direction:column;gap:14px}
.edit-fleet-field{display:grid;grid-template-columns:100px minmax(0,1fr);align-items:center;gap:12px}
.edit-fleet-field-top{align-items:start}
.edit-fleet-field>label{color:#374151;font-size:13px;text-align:right;white-space:nowrap}
.edit-fleet-field input,.edit-fleet-field select,.edit-fleet-field textarea{width:100%;padding:8px 10px;border:1px solid #d1d5db;border-radius:6px;background:#fff;color:#374151;font:inherit;outline:none}
.edit-fleet-field input:focus,.edit-fleet-field select:focus,.edit-fleet-field textarea:focus{border-color:#3b82f6;box-shadow:0 0 0 2px rgba(59,130,246,.08)}
.edit-fleet-field input[readonly]{background:#f5f7fa;color:#6b7280}
.edit-fleet-current{padding:9px 11px;border:1px solid #dbeafe;border-radius:6px;background:#eff6ff;color:#1e40af;font-size:13px;font-weight:600;min-height:38px;display:flex;align-items:center}
.edit-fleet-required{color:#ef4444;margin-right:2px}
.edit-fleet-reason-wrap{min-width:0}
.edit-fleet-reason-wrap textarea{resize:vertical;min-height:78px}
.edit-fleet-reason-wrap textarea:disabled{background:#f5f7fa;color:#9ca3af;cursor:not-allowed}
.edit-fleet-reason-hint{margin-top:5px;color:#6b7280;font-size:11px;line-height:1.4}
.edit-fleet-reason-hint.is-required{color:#b45309}
.vehicle-fleet-reason{max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#374151}
.vehicle-region-tabs{display:flex;align-items:center;gap:0;margin:0 0 14px;border-bottom:1px solid #dfe5ee}
.vehicle-region-tab{min-width:116px;height:42px;padding:0 22px;border:0;border-bottom:2px solid transparent;background:#fff;color:#5f6b7a;font-size:14px;font-weight:500;cursor:pointer}
.vehicle-region-tab:hover{color:#1677ff;background:#f7faff}
.vehicle-region-tab.active{border-bottom-color:#1677ff;color:#1677ff;font-weight:650}
.warehouse-page-tabs{display:flex;align-items:center;gap:0;margin:0 0 14px;border-bottom:1px solid #dfe5ee;overflow-x:auto}
.warehouse-page-tab{min-width:116px;height:42px;padding:0 22px;border:0;border-bottom:2px solid transparent;background:#fff;color:#5f6b7a;font-size:14px;font-weight:500;cursor:pointer}
.warehouse-page-tab:hover{color:#1677ff;background:#f7faff}
.warehouse-page-tab.active{border-bottom-color:#1677ff;color:#1677ff;font-weight:650}
.warehouse-page-tab:focus-visible{outline:2px solid #1677ff;outline-offset:2px}
#page-gen-owned-warehouse .warehouse-table-wrap,#page-gen-fba .warehouse-table-wrap,#page-gen-partner-warehouse .warehouse-table-wrap{width:100%;overflow-x:auto}
.warehouse-table-wrap table{min-width:1040px}
.vehicle-owner-tabs{display:inline-flex;align-items:center;margin:14px 0 12px;padding:3px;border:1px solid #dfe5ee;border-radius:6px;background:#f4f6f9}
.vehicle-owner-tab{min-width:104px;height:32px;padding:0 16px;border:0;border-radius:4px;background:transparent;color:#5f6b7a;font-size:13px;cursor:pointer}
.vehicle-owner-tab:hover{color:#1f2937}
.vehicle-owner-tab.active{background:#fff;color:#1f2937;font-weight:650;box-shadow:0 1px 3px rgba(15,23,42,.12)}
.vehicle-region-tab:focus-visible,.vehicle-owner-tab:focus-visible{outline:2px solid #1677ff;outline-offset:2px}
.vehicle-detail-card{width:100%;max-width:1280px;overflow:hidden}
.vehicle-detail-head{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;padding:18px 24px;border-bottom:1px solid #edf0f4;background:#fff}
.vehicle-detail-title{color:#1f2937;font-size:18px;font-weight:700;line-height:1.4}
.vehicle-detail-meta{display:flex;align-items:center;gap:10px;margin-top:6px;color:#64748b;font-size:13px}
.vehicle-detail-meta>span:first-child{color:#334155;font-weight:600}
.vehicle-detail-body{padding:20px 24px 24px}
.vehicle-detail-section{margin:0;padding:0}
.vehicle-detail-section+.vehicle-detail-section{margin-top:26px}
.vehicle-detail-section h3{margin:0 0 12px;color:#273449;font-size:14px;font-weight:650;line-height:1.5}
.vehicle-detail-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 36px;border-top:1px solid #edf0f4}
.vehicle-detail-field{display:grid;grid-template-columns:124px minmax(0,1fr);align-items:start;min-height:44px;padding:11px 0;border-bottom:1px solid #edf0f4;font-size:13px;line-height:1.65}
.vehicle-detail-label{color:#768398}
.vehicle-detail-value{min-width:0;color:#273449;font-weight:500;overflow-wrap:anywhere}
.vehicle-detail-table-scroll{overflow-x:auto;border:1px solid #dfe5ee;border-radius:6px;background:#fff}
.vehicle-detail-table{width:100%;min-width:820px;border-collapse:collapse;font-size:12px}
.vehicle-detail-table th,.vehicle-detail-table td{padding:10px 12px;border-bottom:1px solid #edf0f4;text-align:left;white-space:nowrap}
.vehicle-detail-table th{background:#f5f7fa;color:#5f6b7a;font-weight:600}
.vehicle-detail-table td{color:#374151}
.vehicle-detail-table tbody tr:last-child td{border-bottom:0}
.vehicle-detail-empty{padding:24px!important;text-align:center!important;color:#94a3b8!important}
.vehicle-detail-relation{display:inline-block;padding:2px 9px;border-radius:10px;font-size:12px;line-height:1.5}
.vehicle-detail-relation.is-current{color:#1677ff;background:#eaf3ff}
.vehicle-detail-relation.is-history{color:#64748b;background:#f1f5f9}
@media(max-width:900px){.vehicle-detail-head{align-items:flex-start;flex-direction:column;padding:16px 18px}.vehicle-detail-head .btn{align-self:flex-end}.vehicle-detail-body{padding:16px 18px 20px}.vehicle-detail-grid{grid-template-columns:1fr}.vehicle-region-tabs{overflow-x:auto}.vehicle-region-tab,.warehouse-page-tab{flex:1;min-width:104px;padding:0 14px}}
@media(max-width:700px){.dispatch-doc-modal-meta,.dispatch-reject-meta{grid-template-columns:1fr}.dispatch-reject-meta>div:first-child{grid-column:auto}.dispatch-doc-row{grid-template-columns:1fr}.dispatch-doc-controls{justify-content:flex-start}.vehicle-detail-field{grid-template-columns:96px minmax(0,1fr)}.vehicle-detail-empty{position:sticky;left:0;background:#fff;text-align:left!important}}
.inbound-action-row{display:flex;gap:8px;margin-top:12px;padding-top:12px;border-top:1px solid #e5e7eb}
.inbound-action-row .btn{padding:7px 14px;font-size:12px;border-radius:4px;font-weight:600}
.inbound-button-group{display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap}
@@ -888,6 +996,20 @@ body.dashboard-fullscreen #page-dashboard>.table-card .btn{padding:4px 10px;font
/* ===== 全局列表统一规范(以海外仓库存列表为基准) ===== */
.page .table-card{background:#fff;border:1px solid #e5eaf1;border-radius:12px;box-shadow:0 3px 12px rgba(15,23,42,.05);backdrop-filter:none}
.location-search-row{flex-wrap:wrap;gap:8px}
.location-search-row select,.location-search-row input{width:160px}
.location-search-row select:nth-child(2){width:140px}
.location-search-row select:nth-child(4){width:120px}
.location-table-scroll{overflow-x:auto}
.location-table-scroll table{min-width:1120px;font-size:12px}
.location-table-scroll th,.location-table-scroll td{white-space:nowrap}
.location-table-scroll .location-operation{width:92px}
.location-table-scroll .location-operation a{color:#1890ff;text-decoration:none;margin-right:14px}
.location-table-scroll .location-operation .location-delete{color:#f5222d;margin-right:0}
.location-table-scroll .location-code{font-weight:600;color:#1f2937}
.location-table-scroll .location-weight{text-align:right}
.location-table-scroll .location-remark{color:#94a3b8}
@media(max-width:600px){#modal-domestic-location .modal,#modal-overseas-location .modal{width:calc(100% - 24px);max-height:calc(100vh - 24px);border-radius:16px}#modal-domestic-location .modal-header,#modal-overseas-location .modal-header{padding:16px 18px}#modal-domestic-location .modal-body,#modal-overseas-location .modal-body{padding:18px}#modal-domestic-location .modal-footer,#modal-overseas-location .modal-footer{padding:14px 18px}#modal-domestic-location .form-row,#modal-overseas-location .form-row{grid-template-columns:minmax(0,1fr);gap:14px;margin-bottom:14px}#modal-domestic-location .form-group,#modal-overseas-location .form-group{min-width:0}#modal-domestic-location .form-group input,#modal-domestic-location .form-group select,#modal-domestic-location .form-group textarea,#modal-overseas-location .form-group input,#modal-overseas-location .form-group select,#modal-overseas-location .form-group textarea{width:100%;min-width:0}#modal-domestic-location .form-group[style*="visibility:hidden"],#modal-overseas-location .form-group[style*="visibility:hidden"]{display:none}}
.page .table-toolbar{min-height:56px;box-sizing:border-box;padding:13px 18px;border-bottom:1px solid #e8edf3;background:#fff}
.page .table-toolbar .title{font-size:15px;font-weight:700;color:#1f2937}
.inventory-management-tabs{display:flex;align-items:center;gap:0;min-height:50px;margin-bottom:12px;padding:0 18px;overflow-x:auto;background:#fff;border:1px solid #e5eaf1;border-radius:8px;box-shadow:0 2px 8px rgba(15,23,42,.04)}
@@ -979,6 +1101,82 @@ body.dashboard-fullscreen #page-dashboard>.table-card .btn{padding:4px 10px;font
body.dashboard-fullscreen #page-dashboard table:has(>thead)>thead>tr>th{background:rgba(38,76,109,.35)!important;color:#8fa8be!important;border-color:rgba(92,141,184,.16)!important}
body.dashboard-fullscreen #page-dashboard table:has(>thead)>tbody>tr>td{background:transparent!important;color:#c8d8e7!important;border-color:rgba(92,141,184,.12)!important}
.outbound-vehicle-modal{display:flex;flex-direction:column;width:min(1080px,calc(100vw - 32px));max-width:1080px;max-height:calc(100vh - 32px);border-radius:8px;overflow:hidden}
.outbound-vehicle-modal>.modal-header{flex:0 0 auto;padding:18px 20px;background:#fff;border-bottom:1px solid #e5e7eb;font-size:16px}
.outbound-vehicle-modal-body{flex:1 1 auto;min-height:0;max-height:none;overflow-y:auto;overflow-x:hidden;padding:18px 20px}
.outbound-vehicle-modal>.modal-footer{flex:0 0 auto;padding:14px 20px;border-top:1px solid #e5e7eb;background:#fff}
.outbound-vehicle-tabs{display:flex;border-bottom:1px solid #e5e7eb;padding:0 20px;background:#fff}
.outbound-vehicle-tabs button{min-height:42px;padding:0 16px;border:0;border-bottom:2px solid transparent;background:transparent;color:#64748b;cursor:pointer}
.outbound-vehicle-tabs button.active{border-bottom-color:#2563eb;color:#2563eb;font-weight:600}
.outbound-vehicle-modal-close{border:0;background:transparent;color:#64748b;cursor:pointer;font-size:22px;line-height:1}
.outbound-vehicle-form-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px 16px}
.outbound-vehicle-form-grid .form-group{min-width:0;margin:0}.outbound-vehicle-form-grid input,.outbound-vehicle-form-grid select{width:100%;box-sizing:border-box}
.outbound-vehicle-modal [hidden]{display:none!important}
.outbound-vehicle-section-title{margin:18px 0 12px;padding-bottom:8px;border-bottom:1px solid #e5e7eb;color:#334155;font-size:14px;font-weight:700}.outbound-vehicle-section-title:first-child{margin-top:0}
.outbound-vehicle-table-wrap{overflow:auto;border:1px solid #e5e7eb;border-radius:6px}.outbound-vehicle-table-wrap table{min-width:780px}.outbound-vehicle-empty{padding:36px 16px;text-align:center;color:#94a3b8}
.dispatch-row-located{animation:dispatch-row-located 1.8s ease-out}@keyframes dispatch-row-located{0%,45%{background:#fef3c7}100%{background:transparent}}
@media(max-width:760px){.outbound-vehicle-modal{width:calc(100vw - 16px);max-height:calc(100vh - 16px)}.outbound-vehicle-modal>.modal-header{padding:14px}.outbound-vehicle-modal-body{padding:14px}.outbound-vehicle-form-grid,.outbound-vehicle-edit-modal .form-row{grid-template-columns:1fr}.outbound-vehicle-section-title{margin-top:16px}.outbound-vehicle-modal>.modal-footer{padding:12px 14px}.outbound-vehicle-modal .modal-footer .btn{min-width:0}}
#modal-fba-carton-form .form-group{min-width:0}
#modal-fba-carton-form input,#modal-fba-carton-form select,#modal-fba-carton-form textarea{box-sizing:border-box;max-width:100%}
@media(max-width:600px){#modal-fba-carton-form .modal{display:flex;flex-direction:column;width:calc(100vw - 24px)!important;max-height:calc(100vh - 24px);overflow:hidden}#modal-fba-carton-form .modal-body{overflow-x:hidden;overflow-y:auto;padding:18px}#modal-fba-carton-form .form-row{grid-template-columns:minmax(0,1fr);gap:14px;margin-bottom:14px}#modal-fba-carton-form .modal-footer{flex:0 0 auto;padding:12px 18px}#modal-fba-carton-form .modal-footer .btn{min-width:0}}
/* ===== 货柜托盘标全屏预览 ===== */
.ct-pallet-preview-modal{--ct-pallet-code-size:120px;--ct-pallet-destination-size:180px;inset:0;padding:0;background:#1b1f22;backdrop-filter:none;align-items:stretch;justify-content:stretch}
.ct-pallet-preview-panel{width:100vw;max-width:none;height:100vh;max-height:none;border:0;border-radius:0;box-shadow:none;overflow:hidden;display:flex;flex-direction:column;background:#1b1f22;animation:none}
.ct-pallet-preview-toolbar{min-height:72px;flex:0 0 auto;display:flex;align-items:center;justify-content:space-between;gap:20px;padding:12px 18px;border-bottom:1px solid #343a3f;background:#252a2e;color:#fff;box-sizing:border-box}
.ct-pallet-preview-meta{flex:0 0 176px;display:grid;gap:3px;min-width:176px}
.ct-pallet-preview-meta>span{font-size:12px;color:#b9c0c5}
.ct-pallet-preview-meta strong{font-size:15px;color:#fff;white-space:nowrap}
.ct-pallet-preview-controls{display:flex;align-items:center;justify-content:flex-end;gap:10px;min-width:0;flex-wrap:wrap}
.ct-pallet-font-control{display:flex;align-items:center;gap:8px;white-space:nowrap;font-size:12px;color:#d9dee2}
.ct-pallet-font-stepper{height:34px;display:grid;grid-template-columns:34px 48px 34px;align-items:stretch;border:1px solid #535b61;border-radius:6px;overflow:hidden;background:#1b1f22}
.ct-pallet-font-stepper button{border:0;background:#343a3f;color:#fff;font-size:18px;line-height:1;cursor:pointer}
.ct-pallet-font-stepper button:hover{background:#454d53}
.ct-pallet-font-stepper button:focus-visible,.ct-pallet-preview-close:focus-visible,.ct-pallet-preview-toolbar .btn:focus-visible{outline:2px solid #69b1ff;outline-offset:2px}
.ct-pallet-font-stepper output{display:flex;align-items:center;justify-content:center;border-inline:1px solid #535b61;color:#fff;font-size:12px;font-variant-numeric:tabular-nums}
.ct-pallet-preview-toolbar .btn{height:34px;padding:0 14px;border-radius:6px;font-size:12px;box-shadow:none;transform:none}
.ct-pallet-preview-toolbar .btn-default{background:#fff;color:#374151;border:1px solid #d1d5db}
.ct-pallet-preview-toolbar .btn-primary{background:#1677ff;color:#fff;border:1px solid #1677ff}
.ct-pallet-print-action{background:#0958d9!important;border:1px solid #0958d9!important;color:#fff}
.ct-pallet-print-action:hover,.ct-pallet-print-action:focus{background:#003eb3!important;border-color:#003eb3!important;color:#fff}
.ct-pallet-preview-toolbar .btn:hover{transform:none;box-shadow:none}
.ct-pallet-preview-close{width:34px;height:34px;display:inline-flex;align-items:center;justify-content:center;border:0;border-radius:6px;background:transparent;color:#d9dee2;font-size:24px;line-height:1;cursor:pointer}
.ct-pallet-preview-close:hover{background:#343a3f;color:#fff}
.ct-pallet-preview-scroll{flex:1;min-width:0;min-height:0;overflow:auto;padding:28px;background:#1b1f22;box-sizing:border-box}
#ct-pallet-label-list{width:max-content;min-width:100%;display:grid;justify-items:center;gap:28px}
.ct-pallet-label-sheet{page:ctPalletLabel;position:relative;width:297mm;height:210mm;box-sizing:border-box;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12mm;padding:14mm;background:#fff;color:#000;box-shadow:0 8px 28px rgba(0,0,0,.34);overflow:hidden;page-break-after:always;break-after:page;font-family:"Microsoft YaHei","SimHei",sans-serif}
.ct-pallet-label-sheet:last-child{page-break-after:auto;break-after:auto}
.ct-pallet-label-code,.ct-pallet-label-destination{width:100%;max-width:100%;margin:0;text-align:center;font-weight:900;line-height:.95;letter-spacing:0;overflow-wrap:anywhere;word-break:break-word}
.ct-pallet-label-code{font-size:var(--ct-pallet-code-size)}
.ct-pallet-label-destination{font-size:var(--ct-pallet-destination-size)}
.ct-pallet-label-empty{width:min(520px,calc(100vw - 48px));padding:28px;text-align:center;color:#c8cdd1;border:1px solid #454b50;border-radius:6px;background:#252a2e;box-sizing:border-box}
@media(max-width:900px){
.ct-pallet-preview-toolbar{align-items:flex-start;gap:10px;padding:10px 12px;flex-wrap:wrap}
.ct-pallet-preview-meta{flex-basis:100%;min-width:0}
.ct-pallet-preview-controls{width:100%;justify-content:flex-start}
.ct-pallet-preview-scroll{padding:16px 12px}
}
@media(max-width:760px){
.ct-pallet-preview-controls{gap:8px}
.ct-pallet-preview-controls .btn,.ct-pallet-preview-controls .booking-print-close,.ct-pallet-preview-controls .ct-pallet-preview-close,.ct-pallet-font-control button{min-height:40px}
.ct-pallet-preview-controls .ct-pallet-preview-close{min-width:40px}
.ct-pallet-font-stepper{height:40px;grid-template-columns:40px 48px 40px}
.ct-pallet-font-stepper button{width:40px;height:40px}
}
@page ctPalletLabel{size:A4 landscape;margin:0}
@media print{
html:has(body.ct-pallet-printing),body.ct-pallet-printing{width:297mm!important;min-width:0!important;height:auto!important;min-height:0!important;margin:0!important;padding:0!important;background:#fff!important;overflow:visible!important}
body.ct-pallet-printing>*:not(#modal-ct-pallet-label-preview){display:none!important}
body.ct-pallet-printing #modal-booking-print-preview{display:none!important}
body.ct-pallet-printing #modal-ct-pallet-label-preview{display:block!important;position:static!important;inset:auto!important;width:297mm!important;height:auto!important;padding:0!important;background:#fff!important}
body.ct-pallet-printing #modal-ct-pallet-label-preview,body.ct-pallet-printing #modal-ct-pallet-label-preview *{visibility:visible!important}
body.ct-pallet-printing .ct-pallet-preview-panel{display:block!important;position:static!important;width:297mm!important;height:auto!important;max-width:none!important;max-height:none!important;margin:0!important;background:#fff!important;overflow:visible!important}
body.ct-pallet-printing .ct-pallet-preview-toolbar{display:none!important}
body.ct-pallet-printing .ct-pallet-preview-scroll{display:block!important;position:static!important;width:297mm!important;height:auto!important;padding:0!important;background:#fff!important;overflow:visible!important}
body.ct-pallet-printing #ct-pallet-label-list{display:block!important;width:297mm!important;min-width:0!important;margin:0!important}
body.ct-pallet-printing .ct-pallet-label-sheet{display:flex!important;width:297mm!important;height:210mm!important;margin:0!important;box-shadow:none!important;page-break-after:always;break-after:page}
body.ct-pallet-printing .ct-pallet-label-sheet:last-child{page-break-after:auto;break-after:auto}
}
</style>
</head>
<body>
@@ -1035,8 +1233,8 @@ body.dashboard-fullscreen #page-dashboard table:has(>thead)>tbody>tr>td{backgrou
<div class="sub-menu-item active" onclick="switchPage('inbound-list',this)">订单列表</div>
<div class="sub-menu-item" onclick="switchPage('inbound-dedup',this)">订单测量</div>
<div class="sub-menu-item" onclick="switchPage('inbound-measure',this)">测量数据审核</div>
<div class="sub-menu-item" onclick="switchPage('overseas-location',this)">库位管理</div>
<div class="sub-menu-item" onclick="switchPage('pallet-manage',this)">托盘管理</div>
<div class="sub-menu-item" onclick="switchPage('domestic-location',this)">库位管理</div>
<div class="sub-menu-item" onclick="switchPage('domestic-container-business',this)">整柜业务</div>
</div>
</div>
@@ -1049,7 +1247,6 @@ body.dashboard-fullscreen #page-dashboard table:has(>thead)>tbody>tr>td{backgrou
<div class="sub-menu-item" onclick="switchPage('fba-outbound-order',this)">出库单</div>
<div class="sub-menu-item" onclick="switchPage('fba-relabel-order',this)">换标单</div>
<div class="sub-menu-item" onclick="switchPage('fba-pallet',this)">托盘管理</div>
<div class="sub-menu-item" onclick="switchPage('fba-carton',this)">箱号管理</div>
<div class="sub-menu-item" onclick="switchPage('fba-product',this)">产品库</div>
<div class="sub-menu-item" onclick="switchPage('fba-stock-management',this)">库存管理</div>
</div>
@@ -1060,6 +1257,12 @@ body.dashboard-fullscreen #page-dashboard table:has(>thead)>tbody>tr>td{backgrou
<div class="sub-menu-item" onclick="switchPage('outbound-dispatch',this)">派送单列表</div>
</div>
</div>
<div class="sidebar-item" data-menu="customer-service">
<div class="menu-label" onclick="toggleMenu(this)"><span class="icon"><svg viewBox="0 0 24 24" fill="none"><path d="M4 13v-2a8 8 0 0116 0v2" stroke="#13c2c2" stroke-width="2" stroke-linecap="round"/><path d="M4 13a2 2 0 012-2h1v6H6a2 2 0 01-2-2v-2zM20 13a2 2 0 00-2-2h-1v6h1a2 2 0 002-2v-2z" stroke="#13c2c2" stroke-width="2" stroke-linejoin="round"/><path d="M17 17c0 2-2 3-5 3" stroke="#13c2c2" stroke-width="2" stroke-linecap="round"/></svg></span>客户服务<span class="arrow">▶</span></div>
<div class="sub-menu">
<div class="sub-menu-item" onclick="switchPage('outbound-exception',this)">异常订单</div>
</div>
</div>
<div class="sidebar-item" data-menu="supplier">
<div class="menu-label" onclick="toggleMenu(this)"><span class="icon"><svg viewBox="0 0 24 24" fill="none"><path d="M3 21V9l9-6 9 6v12" stroke="#eb2f96" stroke-width="2" stroke-linejoin="round"/><path d="M9 21v-6h6v6" stroke="#eb2f96" stroke-width="2" stroke-linejoin="round"/></svg></span>供应商中心<span class="arrow">▶</span></div>
<div class="sub-menu">
@@ -1091,6 +1294,7 @@ body.dashboard-fullscreen #page-dashboard table:has(>thead)>tbody>tr>td{backgrou
<div class="sub-menu">
<div class="sub-menu-item" onclick="switchPage('customer-account',this)">客户账户</div>
<div class="sub-menu-item" onclick="switchPage('customer-arap',this)">客户应收应付</div>
<div class="sub-menu-item" onclick="switchPage('finance-ar-detail-report',this)">应收款统计表</div>
<div class="sub-menu-item" onclick="switchPage('customer-recon-stmt',this)">客户对账单</div>
<div class="sub-menu-item" onclick="switchPage('settlement-ar',this)">结算单应收</div>
<div class="sub-menu-item" onclick="switchPage('settlement-ap',this)">结算单应付</div>
@@ -1110,10 +1314,13 @@ body.dashboard-fullscreen #page-dashboard table:has(>thead)>tbody>tr>td{backgrou
<div class="sub-menu">
<div class="sub-menu-item" onclick="switchPage('gen-product',this)">产品信息</div>
<div class="sub-menu-item" onclick="switchPage('gen-fee',this)">费用项目</div>
<div class="sub-menu-item" onclick="switchPage('gen-fba',this)">仓库管理</div>
<div class="sub-menu-item" onclick="switchPage('gen-download',this)">下载中心</div>
<div class="sub-menu-item" onclick="switchPage('gen-exchange-rate',this)">汇率管理</div>
<div class="sub-menu-item" onclick="switchPage('gen-owned-warehouse',this)">仓库管理</div>
<div class="sub-menu-item" onclick="switchPage('gen-overseas-quote',this)">海外仓报价</div>
<div class="sub-menu-item" onclick="switchPage('gen-voyage',this)">船期价格表</div>
<div class="sub-menu-item" onclick="switchPage('vehicle',this)">车辆列表</div>
<div class="sub-menu-item" onclick="switchPage('delivery-fleet-quote',this)">派送车队报价</div>
<div class="sub-menu-item" onclick="switchPage('gen-download',this)">下载中心</div>
</div>
</div>
<div class="sidebar-item" data-menu="system">
@@ -2,8 +2,33 @@
<!-- ==================== 转BOL / 创建托盘BOL MODAL ==================== -->
<div class="modal-overlay" id="modal-bol">
<div class="modal" style="max-width:980px"><div class="modal-header">洛杉矶仓 + 创建托盘BOL<span onclick="hideModal('modal-bol')" style="cursor:pointer;font-size:20px">&times;</span></div>
<div style="padding:20px 24px">
<div class="modal outbound-vehicle-modal outbound-vehicle-delivery-modal" style="max-width:1080px"><div class="modal-header"><span>转派送</span><button type="button" class="outbound-vehicle-modal-close" aria-label="关闭" onclick="hideModal('modal-bol')"><span aria-hidden="true">&times;</span></button></div>
<div class="modal-body outbound-vehicle-modal-body">
<div class="outbound-vehicle-section-title">派送信息</div>
<div class="outbound-vehicle-form-grid">
<div class="form-group"><label>排车单号</label><input id="bol-vehicle-no" readonly/></div>
<div class="form-group"><label>预派送日期 *</label><input id="bol-deliveryDate" type="date" required/></div>
<div class="form-group"><label>派送方式 *</label><select id="bol-deliveryMethod" required onchange="handleOutboundVehicleDeliveryMethodChange()"><option value="">请选择</option><option>FBA自提</option><option>客户自提</option><option>卡派打托</option><option>卡派地板</option><option>快递</option><option>外配</option></select></div>
<div class="form-group"><label>提货方式 *</label><select id="bol-pickupMethod" required><option value="">请选择</option><option>一提一卸</option><option>一提多卸</option></select></div>
<div class="form-group"><label>装车类型 *</label><select id="bol-loadType" required onchange="handleOutboundVehicleLoadTypeChange()"><option>托盘</option><option>地板</option><option>混装</option></select></div>
<div class="form-group"><label>车队/司机名</label><input id="bol-driverName"/></div>
<div class="form-group"><label>车队联系电话</label><input id="bol-driverPhone"/></div>
<div class="form-group"><label>车辆类型</label><select id="bol-vehicleType"><option value="">请选择</option><option>厢式货车</option><option>平板车</option><option>集装箱卡车</option></select></div>
<div class="form-group"><label>车辆大小</label><select id="bol-vehicleSize"><option value="">请选择</option><option>大车(30托/15托)</option><option>小车(14托/12托)</option></select></div>
<div class="form-group"><label>预约账号</label><select id="bol-bookAccount"><option value="">请选择</option><option>4</option><option>8</option></select></div>
<div class="form-group"><label>预约号</label><input id="bol-appointmentNo"/></div>
</div>
<div class="outbound-vehicle-section-title">费用信息</div>
<div class="outbound-vehicle-form-grid">
<div class="form-group"><label>派送费用(USD) *</label><input id="bol-dispatchFee" type="number" min="0" step="0.01" placeholder="请输入派送费用" required/></div>
<div class="form-group" id="bol-loading-fee-field" hidden><label>装车费(USD) *</label><input id="bol-loadingFee" type="number" min="0" step="0.01"/></div>
<div class="form-group" id="bol-courier-company-field" hidden><label>快递公司 *</label><select id="bol-courierCompanyId"><option value="">请选择快递公司</option></select></div>
</div>
<div class="outbound-vehicle-section-title">订单明细 <span id="bol-detail-count"></span></div>
<div class="outbound-vehicle-table-wrap"><table><thead><tr><th>出库单号</th><th>重量(kg)</th><th>托盘数量</th><th>目的地FBA</th><th>体积(m³)</th><th>货柜号</th></tr></thead><tbody id="bol-detail-body"></tbody></table></div>
<!-- legacy fields retained for compatible scripts -->
<div hidden><input id="bol-containerNo"/><input id="bol-cabinetNo"/></div>
<template id="legacy-bol-fields">
<!-- 第一行 -->
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:0 16px;margin-bottom:14px">
<div><label style="font-size:12px;font-weight:600;color:#333;display:block;margin-bottom:4px">日期类型</label><select id="bol-dateType" style="width:100%;padding:9px 12px;border:1px solid #d9d9d9;border-radius:6px;font-size:13px;box-sizing:border-box;color:#999"><option value="">请选择</option><option>预约日期</option><option>实际日期</option></select></div>
@@ -28,24 +53,22 @@
<!-- 第四行 -->
<div style="display:grid;grid-template-columns:1fr 1fr auto;gap:0 16px;margin-bottom:14px;align-items:end">
<div><label style="font-size:12px;font-weight:600;color:#333;display:block;margin-bottom:4px">预派送日期 <span style="color:#f5222d">*</span></label><input id="bol-deliveryDate" type="date" placeholder="请选择预派送日期" style="width:100%;padding:9px 12px;border:1px solid #d9d9d9;border-radius:6px;font-size:13px;box-sizing:border-box"/></div>
<div><label style="font-size:12px;font-weight:600;color:#333;display:block;margin-bottom:4px">派送方式 <span style="color:#f5222d">*</span></label><select id="bol-deliveryMethod" style="width:100%;padding:9px 12px;border:1px solid #d9d9d9;border-radius:6px;font-size:13px;box-sizing:border-box;color:#999"><option value="">请选择</option><option>自提</option><option>配送</option><option>第三方物流</option></select></div>
<div><label style="font-size:12px;font-weight:600;color:#333;display:block;margin-bottom:4px">派送方式 <span style="color:#f5222d">*</span></label><select id="bol-deliveryMethod" style="width:100%;padding:9px 12px;border:1px solid #d9d9d9;border-radius:6px;font-size:13px;box-sizing:border-box;color:#999"><option value="">请选择</option><option>FBA自提</option><option>客户自提</option><option>卡派打托</option><option>卡派地板</option><option>快递</option><option>外配</option></select></div>
<div><button class="btn btn-primary" style="padding:9px 24px;border-radius:6px;font-size:13px" onclick="bolSearch()">🔍 搜索</button></div>
</div>
<!-- 分隔线 -->
<div style="border-top:1px solid #e8e8e8;margin:18px 0"></div>
<!-- 第五行 -->
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:0 16px;margin-bottom:14px">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:0 16px;margin-bottom:14px">
<div><label style="font-size:12px;font-weight:600;color:#333;display:block;margin-bottom:4px">车队/司机名</label><input id="bol-driverName" placeholder="请输入车队/司机名" style="width:100%;padding:9px 12px;border:1px solid #d9d9d9;border-radius:6px;font-size:13px;box-sizing:border-box"/></div>
<div><label style="font-size:12px;font-weight:600;color:#333;display:block;margin-bottom:4px">车队联系电话</label><input id="bol-driverPhone" placeholder="请输入车队联系电话" style="width:100%;padding:9px 12px;border:1px solid #d9d9d9;border-radius:6px;font-size:13px;box-sizing:border-box"/></div>
<div><label style="font-size:12px;font-weight:600;color:#333;display:block;margin-bottom:4px">车队邮箱</label><input id="bol-driverEmail" placeholder="请输入车队邮箱" style="width:100%;padding:9px 12px;border:1px solid #d9d9d9;border-radius:6px;font-size:13px;box-sizing:border-box"/></div>
<div><label style="font-size:12px;font-weight:600;color:#333;display:block;margin-bottom:4px">车辆类型</label><select id="bol-vehicleType" style="width:100%;padding:9px 12px;border:1px solid #d9d9d9;border-radius:6px;font-size:13px;box-sizing:border-box;color:#999"><option value="">请选择车辆类型</option><option>厢式货车</option><option>平板车</option><option>冷藏车</option><option>集装箱卡车</option></select></div>
</div>
<!-- 第六行 -->
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:0 16px;margin-bottom:14px">
<div><label style="font-size:12px;font-weight:600;color:#333;display:block;margin-bottom:4px">预约账号</label><input id="bol-bookAccount" placeholder="请输入预约账号" style="width:100%;padding:9px 12px;border:1px solid #d9d9d9;border-radius:6px;font-size:13px;box-sizing:border-box"/></div>
<div><label style="font-size:12px;font-weight:600;color:#333;display:block;margin-bottom:4px">ISA号码</label><input id="bol-isaNo" placeholder="请输入ISA号码" style="width:100%;padding:9px 12px;border:1px solid #d9d9d9;border-radius:6px;font-size:13px;box-sizing:border-box"/></div>
<div><label style="font-size:12px;font-weight:600;color:#333;display:block;margin-bottom:4px">车辆大小</label><input id="bol-vehicleSize" placeholder="请输入车辆大小" style="width:100%;padding:9px 12px;border:1px solid #d9d9d9;border-radius:6px;font-size:13px;box-sizing:border-box"/></div>
<div><label style="font-size:12px;font-weight:600;color:#333;display:block;margin-bottom:4px">是否发送派送单</label><select id="bol-sendDispatch" style="width:100%;padding:9px 12px;border:1px solid #d9d9d9;border-radius:6px;font-size:13px;box-sizing:border-box;color:#999"><option value="">请输入是否发送派送单</option><option>是</option><option>否</option></select></div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:0 16px;margin-bottom:14px">
<div><label style="font-size:12px;font-weight:600;color:#333;display:block;margin-bottom:4px">车辆大小</label><select id="bol-vehicleSize" style="width:100%;padding:9px 12px;border:1px solid #d9d9d9;border-radius:6px;font-size:13px;box-sizing:border-box;color:#999"><option value="">请选择</option><option>大车(30托/15托)</option><option>小车(14托/12托)</option></select></div>
<div><label style="font-size:12px;font-weight:600;color:#333;display:block;margin-bottom:4px">预约账号</label><select id="bol-bookAccount" style="width:100%;padding:9px 12px;border:1px solid #d9d9d9;border-radius:6px;font-size:13px;box-sizing:border-box;color:#999"><option value="">请选择</option><option>4</option><option>8</option></select></div>
<div><label style="font-size:12px;font-weight:600;color:#333;display:block;margin-bottom:4px">预约号</label><input id="bol-appointmentNo" placeholder="请输入预约号" style="width:100%;padding:9px 12px;border:1px solid #d9d9d9;border-radius:6px;font-size:13px;box-sizing:border-box"/></div>
</div>
<!-- 订单明细 -->
<div style="border-top:1px solid #e8e8e8;margin:18px 0 12px;padding-top:6px">
@@ -62,8 +85,7 @@
</tr></thead>
<tbody id="bol-detail-body"></tbody>
</table>
</div>
</div>
</div></div></template>
</div>
<div class="modal-footer"><button class="btn btn-default" onclick="hideModal('modal-bol')">取消</button><button class="btn btn-primary" onclick="saveBol()">确认提交</button></div></div></div>
@@ -0,0 +1,29 @@
#!/usr/bin/env node
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
const html = fs.readFileSync(path.join(root, 'prototype-src/modules/03-overseas-warehouse.html'), 'utf8');
const overlays = fs.readFileSync(path.join(root, 'prototype-src/overlays.html'), 'utf8');
const app = fs.readFileSync(path.join(root, 'prototype-src/app.js'), 'utf8');
const failures = [];
const expect = (condition, message) => { if (!condition) failures.push(message); };
expect(/预约送仓时间/.test(app), '智能排车列表缺少预约送仓时间');
expect(/预计装车时间/.test(app), '智能排车列表缺少预计装车时间');
expect(/\.ds-order-table\{[^}]*min-width:980px/.test(overlays), '智能排车列表未为新增时间列保留横向空间');
expect(/id="fbio-appointment-loading-time"/.test(html), '预约弹框缺少预计装车时间输入');
expect(/id="modal-fbio-edit"/.test(html), '缺少入库单编辑弹框');
expect(/onclick="openFbioEditModal\(/.test(app), '缺少入库单编辑入口');
expect(/function\s+saveFbioEdit\s*\(/.test(app), '缺少入库单编辑保存函数');
expect(/estimatedLoadingTime/.test(app), '脚本缺少预计装车时间字段');
expect(/appointmentDate/.test(app), '脚本缺少预约送仓时间字段');
if (failures.length) {
console.error('FBA_INBOUND_0813_CHECK_FAIL');
failures.forEach(message => console.error(`- ${message}`));
process.exitCode = 1;
} else {
console.log('FBA_INBOUND_0813_CHECK_PASS');
}
@@ -0,0 +1,21 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
const moduleSource = fs.readFileSync(path.join(root, 'prototype-src/modules/06-containers.html'), 'utf8');
const appSource = fs.readFileSync(path.join(root, 'prototype-src/app.js'), 'utf8');
assert.match(moduleSource, /<label>船名\(Vessel\)<\/label><input placeholder="船名"\/>/, '新增订舱页船名仍标记为必填');
assert.match(moduleSource, /<label>航次\(Voyage\)<\/label><input placeholder="航次"\/>/, '新增订舱页航次仍标记为必填');
assert.match(moduleSource, /id="release-vessel"/, '放舱弹框缺少船名字段');
assert.match(moduleSource, /id="release-vessel"[^>]*placeholder="请输入船名/, '放舱弹框船名缺少输入提示');
assert.match(appSource, /getElementById\('release-vessel'\)\.value='';/, '打开放舱弹框时未重置船名');
assert.match(appSource, /const vessel=document\.getElementById\('release-vessel'\)\.value\.trim\(\);/, '确认放舱时未读取船名');
assert.match(appSource, /if\(!vessel\|\|!v\|\|!bl\)\{showToast\('请填写船名、航次和提单号','error'\);return;\}/, '放舱必填校验未覆盖船名');
assert.match(appSource, /船名:'\+vessel\+'\\n航次:'\+v\+'\\n提单号:'\+bl/, '确认提示未展示船名');
assert.match(appSource, /船名:'\+vessel\+'<br>航次:'\+v\+'<br>提单号:'\+bl/, '放舱结果未展示船名');
assert.match(appSource, /td\.dataset\.vessel=vessel; td\.dataset\.v=v; td\.dataset\.bl=bl;/, '放舱结果未保存船名');
assert.match(appSource, /document\.getElementById\('release-vessel'\)\.value=td\.dataset\.vessel\|\|'';/, '编辑放舱信息时未回填船名');
console.log('订舱放舱船名字段验证通过。');
@@ -4,7 +4,9 @@ const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const html = fs.readFileSync(path.resolve(__dirname, '..', '货代SaaS平台原型.html'), 'utf8');
const root = path.resolve(__dirname, '..');
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'prototype-src', 'manifest.json'), 'utf8'));
const html = fs.readFileSync(path.join(root, manifest.source), 'utf8');
const failures = [];
function expect(condition, message) {
@@ -71,7 +73,7 @@ expect(/renderContainerStatusTag/.test(ctRenderer), '货柜列表应复用公共
expect(/findContainerByNo/.test(lastMileRenderer) && /renderContainerStatusTag/.test(lastMileRenderer), '尾程列表应实时查找并渲染货柜状态');
expect(/getContainerAbnormalState/.test(lastMileRenderer), '尾程列表应按集装箱号读取货柜异常状态');
expect(/renderAbnormalTag/.test(lastMileRenderer), '尾程列表应渲染异常状态标签');
expect(/colspan="31"/.test(lastMileRenderer), '尾程列表空状态应覆盖新增后的 31 列');
expect(/colspan="33"/.test(lastMileRenderer), '尾程列表空状态应覆盖新增后的 33 列');
expect(/货柜异常/.test(detailRenderer) && /异常原因/.test(detailRenderer), '尾程详情应显示货柜异常状态和原因');
expect(/containerNo:'FFAU6685194'/.test(html), '应提供一条与货柜列表可见关联的尾程样例');
@@ -0,0 +1,23 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
const overlays = fs.readFileSync(path.join(root, 'prototype-src/overlays.html'), 'utf8');
const moduleHtml = fs.readFileSync(path.join(root, 'prototype-src/modules/06-containers.html'), 'utf8');
const appJs = fs.readFileSync(path.join(root, 'prototype-src/app.js'), 'utf8');
assert.ok(overlays.includes('id="cost-supplier-field"'), '新增费用缺少供应商控件容器');
assert.ok(overlays.includes('id="cost-supplier"'), '新增费用缺少供应商选择框');
assert.ok(overlays.includes('id="cost-supplier-field" style="margin-bottom:14px"'), '供应商控件不应默认隐藏');
assert.ok(!overlays.includes('onchange="syncCtCostSupplierField()"'), '费用类型不应再控制供应商显示');
assert.ok(moduleHtml.includes('onclick="openCtCostAdd()"'), '新增费用按钮未接入初始化函数');
assert.ok(appJs.includes('function openCtCostAdd'), '缺少新增费用初始化函数');
assert.ok(appJs.includes("(supplierData||[]).map(function(s)"), '供应商选项未使用全部供应商数据');
assert.ok(appJs.includes("if(!supplier){showToast('请选择供应商','error');return;}"), '保存未统一校验供应商');
assert.ok(!appJs.includes('function isCtCostSupplierRequired'), '不应保留仅拖车费判断函数');
assert.ok(!appJs.includes('function syncCtCostSupplierField'), '不应保留费用类型条件联动函数');
assert.ok(appJs.includes("(document.getElementById('cost-supplier')||{}).value||''"), '保存函数未读取供应商');
assert.ok(appJs.includes('supplier:supplier'), '费用记录未保存供应商');
console.log('货柜费用全项目供应商回归校验通过');
@@ -0,0 +1,58 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const root = path.resolve(__dirname, '..');
const moduleHtml = fs.readFileSync(path.join(root, 'prototype-src/modules/06-containers.html'), 'utf8');
const overlays = fs.readFileSync(path.join(root, 'prototype-src/overlays.html'), 'utf8');
const appJs = fs.readFileSync(path.join(root, 'prototype-src/app.js'), 'utf8');
assert.ok(moduleHtml.includes('table style="min-width:2800px"'), '货柜列表最小宽度应为 2800px');
assert.ok(moduleHtml.includes('<th style="width:300px;white-space:nowrap;text-align:center">操作</th><th>报空预警</th><th>集装箱号</th>'), '报空预警应紧跟操作列');
assert.ok(appJs.includes("['_cb','_act','_emptyWarning','no'"), '报空预警渲染键应紧跟操作列');
['预计提柜时间', '约车时间', '报空时间', '还柜时间', '报空预警'].forEach(label => {
assert.ok(moduleHtml.includes(label), `货柜列表缺少表头:${label}`);
});
assert.ok(!moduleHtml.includes('<th>预计报空时间</th>'), '货柜列表不应显示预计报空时间');
assert.ok(
moduleHtml.includes('<th>预计提柜时间</th><th>约车时间</th><th>报空时间</th>'),
'约车时间应位于预计提柜时间和报空时间之间',
);
['ct-add-pickup-expected', 'ct-add-appointment-at', 'ct-add-emptied-at', 'ct-add-returned-at'].forEach(id => {
assert.ok(overlays.includes(`id="${id}"`), `货柜表单缺少字段:${id}`);
});
assert.ok(!overlays.includes('id="ct-add-empty-expected"'), '货柜表单不应显示预计报空时间字段');
assert.ok(
/预计提柜时间:[\s\S]{0,220}ct-add-pickup-expected[\s\S]{0,220}约车时间:[\s\S]{0,220}ct-add-appointment-at/.test(overlays),
'约车时间应紧跟预计提柜时间',
);
['pickupExpectedAt', 'appointmentAt', 'emptyExpectedAt', 'emptiedAt', 'returnedAt'].forEach(key => {
assert.ok(appJs.includes(key), `货柜数据缺少字段:${key}`);
});
['ct-add-pickup-expected', 'ct-add-appointment-at', 'ct-add-emptied-at', 'ct-add-returned-at'].forEach(id => {
const uses = appJs.match(new RegExp(id, 'g')) || [];
assert.ok(uses.length >= 3, `货柜表单字段未完整接通重置、编辑和保存:${id}`);
});
assert.ok(!appJs.includes("document.getElementById('ct-add-empty-expected')"), '预计报空时间不应继续接入货柜表单');
assert.ok(
appJs.includes("'pickupExpectedAt','appointmentAt','emptiedAt','returnedAt'"),
'货柜列表时间列映射应包含约车时间并移除预计报空时间',
);
const start = appJs.indexOf('function getCtEmptyWarningState');
const end = appJs.indexOf('\nfunction renderCtEmptyWarning', start);
assert.ok(start >= 0 && end > start, '缺少可测试的报空预警函数');
const sandbox = {};
vm.runInNewContext(`${appJs.slice(start, end)}\nresult=getCtEmptyWarningState;`, sandbox);
const getState = sandbox.result;
const now = new Date('2026-08-14T00:00:00');
const normalize = value => JSON.parse(JSON.stringify(value));
assert.deepEqual(normalize(getState({}, now)), {tone:'none', text:'—'});
assert.deepEqual(normalize(getState({emptyExpectedAt:'2026-08-13T12:00', emptiedAt:'2026-08-13T18:00'}, now)), {tone:'done', text:'已报空'});
assert.deepEqual(normalize(getState({emptyExpectedAt:'2026-08-15T00:00', emptiedAt:''}, now)), {tone:'normal', text:'未超时'});
assert.deepEqual(normalize(getState({emptyExpectedAt:'2026-08-13T12:00', emptiedAt:''}, now)), {tone:'overdue', text:'超时 1 天'});
console.log('货柜报空时间与预警回归校验通过');
@@ -0,0 +1,30 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
const appJs = fs.readFileSync(path.join(root, 'prototype-src', 'app.js'), 'utf8');
function extractBetween(startMarker, endMarker, label) {
const start = appJs.indexOf(startMarker);
assert.notEqual(start, -1, `${label}起始标记不存在`);
const end = appJs.indexOf(endMarker, start + startMarker.length);
assert.notEqual(end, -1, `${label}结束标记不存在`);
return appJs.slice(start, end);
}
const renderCtListSource = extractBetween(
'function renderCtList()',
'function ctUploadDoc(',
'货柜列表渲染片段',
);
assert.ok(/class="ct-fleet-control"/.test(renderCtListSource), '码头车队控件容器缺失');
assert.ok(/class="ct-fleet-sel"/.test(renderCtListSource), '码头车队选择框缺失');
assert.ok(!/class="ct-fleet-region-sel"/.test(renderCtListSource), '码头车队不应保留国内/海外选择框');
assert.ok(!/const regionOpts=/.test(renderCtListSource), '码头车队不应保留国内/海外选项');
assert.ok(!/ctSetFleetRegion/.test(renderCtListSource), '码头车队渲染不应绑定区域变更事件');
assert.ok(!appJs.includes('function ctSetFleetRegion('), '码头车队区域 setter 应移除');
console.log('货柜列表码头车队单选择框验证通过');
@@ -0,0 +1,315 @@
#!/usr/bin/env node
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const {spawnSync} = require('node:child_process');
const vm = require('node:vm');
const root = path.resolve(__dirname, '..');
const appJs = fs.readFileSync(path.join(root, 'prototype-src', 'app.js'), 'utf8');
const moduleHtml = fs.readFileSync(path.join(root, 'prototype-src', 'modules', '06-containers.html'), 'utf8');
const overlaysHtml = fs.readFileSync(path.join(root, 'prototype-src', 'overlays.html'), 'utf8');
const shellHtml = fs.readFileSync(path.join(root, 'prototype-src', 'shell.html'), 'utf8');
const pureFunctionsStart = '// ===== 货柜托盘标纯函数 =====';
const captureMarker = '// ===== 货柜托盘标交互 =====';
function loadCtPalletLabelApi(source) {
if (!source.includes(captureMarker)) return null;
const sentinel = {};
const context = {
console,
setTimeout,
clearTimeout,
__ctPalletLabelSentinel: sentinel,
};
const capture = [
captureMarker,
';globalThis.__ctPalletLabelApi = {',
" normalizeCtPalletCabinet: typeof normalizeCtPalletCabinet === 'function' ? normalizeCtPalletCabinet : null,",
" buildCtPalletLabels: typeof buildCtPalletLabels === 'function' ? buildCtPalletLabels : null,",
'};',
'throw globalThis.__ctPalletLabelSentinel;',
].join('\n');
const instrumented = source.split(captureMarker).join(capture);
vm.createContext(context);
try {
vm.runInContext(instrumented, context, {timeout: 2000});
} catch (error) {
if (error === sentinel) return context.__ctPalletLabelApi || null;
if (error && error.name === 'ReferenceError') return null;
throw error;
}
return null;
}
const loaderFixture = [
pureFunctionsStart,
'function normalizeCtPalletCabinet(value) { return value; }',
'function buildCtPalletLabels() { return {labels: []}; }',
captureMarker,
].join('\n');
const executableApi = loadCtPalletLabelApi(loaderFixture);
assert.equal(typeof executableApi.normalizeCtPalletCabinet, 'function', 'loader 应捕获真实可执行函数');
assert.equal(typeof executableApi.buildCtPalletLabels, 'function', 'loader 应捕获真实可执行函数');
assert.equal(
loadCtPalletLabelApi(['/*', loaderFixture, '*/'].join('\n')),
null,
'loader 不应捕获块注释中的伪函数',
);
assert.equal(
loadCtPalletLabelApi(['const ignored = `', loaderFixture, '`;'].join('\n')),
null,
'loader 不应捕获多行模板字符串中的伪函数',
);
const sandbox = loadCtPalletLabelApi(appJs);
assert.ok(
sandbox && typeof sandbox.normalizeCtPalletCabinet === 'function',
'缺少 normalizeCtPalletCabinet 函数',
);
assert.ok(
typeof sandbox.buildCtPalletLabels === 'function',
'缺少 buildCtPalletLabels 函数',
);
assert.equal(sandbox.normalizeCtPalletCabinet(' D2253(AJC) '), 'D2253');
assert.equal(sandbox.normalizeCtPalletCabinet(' D2254(TC) '), 'D2254');
const floatingBoundaryResult = sandbox.buildCtPalletLabels(
[{no: 'CONT-FLOAT', ctNo: 'D2001(A)', manifest: 'MF-FLOAT'}],
[{no: 'MF-FLOAT'}],
{'MF-FLOAT': Array.from({length: 20}, () => ({destWh: 'ONT8', businessTypeCode: 'DF', vol: 0.1}))},
);
assert.equal(floatingBoundaryResult.labels.length, 1, '20 个 0.1 方归一后只应生成 1 张标签');
assert.equal(floatingBoundaryResult.labels[0].volume, 2);
const fractionalRemainderResult = sandbox.buildCtPalletLabels(
[{no: 'CONT-FRACTION', ctNo: 'D2002(A)', manifest: 'MF-FRACTION'}],
[{no: 'MF-FRACTION'}],
{'MF-FRACTION': [{destWh: 'ONT8', businessTypeCode: 'OTHER', vol: 2.0000004}]},
);
assert.equal(fractionalRemainderResult.labels.length, 2, '超过 2 方的微小余量仍应生成第 2 张标签');
assert.equal(fractionalRemainderResult.labels[0].volume, 2.0000004);
assert.equal(fractionalRemainderResult.labels[1].volume, 2.0000004);
const containers = [
{no: 'CONT-1', ctNo: 'D2253(AJC)', manifest: 'MF-1'},
{no: 'CONT-2', ctNo: '', manifest: 'MF-2'},
];
const manifests = [
{no: 'MF-1'},
{no: 'MF-2'},
];
const detailMap = {
'MF-1': [
{destWh: 'ONT8', businessTypeCode: 'DF', vol: 2.1},
{destWh: 'ONT8', businessTypeCode: 'DF', vol: 2.2},
{destWh: 'LAX9', businessTypeCode: 'DF', vol: 2},
{destWh: 'LAX9', businessTypeCode: 'TC', vol: 0.1},
{destWh: '', businessTypeCode: 'DF', vol: 1},
{destWh: 'ONT8', businessTypeCode: '', vol: 1},
{destWh: 'ONT8', businessTypeCode: 'DF', vol: 0},
],
'MF-2': [
{destWh: 'ONT8', businessTypeCode: 'DF', vol: 2},
],
};
const result = JSON.parse(JSON.stringify(
sandbox.buildCtPalletLabels(containers, manifests, detailMap),
));
assert.deepEqual(result.labels, [
{code: 'D2253-DF-1', cabinetNo: 'D2253', destWh: 'LAX9', businessTypeCode: 'DF', volume: 2},
{code: 'D2253-DF-2', cabinetNo: 'D2253', destWh: 'ONT8', businessTypeCode: 'DF', volume: 4.3},
{code: 'D2253-DF-3', cabinetNo: 'D2253', destWh: 'ONT8', businessTypeCode: 'DF', volume: 4.3},
{code: 'D2253-DF-4', cabinetNo: 'D2253', destWh: 'ONT8', businessTypeCode: 'DF', volume: 4.3},
{code: 'D2253-TC-1', cabinetNo: 'D2253', destWh: 'LAX9', businessTypeCode: 'TC', volume: 0.1},
]);
assert.equal(result.skippedContainers, 1);
assert.equal(result.skippedDetails, 3);
const perContainerResult = JSON.parse(JSON.stringify(sandbox.buildCtPalletLabels(
[
{no: 'CONT-A', ctNo: 'D3001(AJC)', manifest: 'MF-A'},
{no: 'CONT-B', ctNo: 'D3002(TC)', manifest: 'MF-B'},
],
[{no: 'MF-A'}, {no: 'MF-B'}],
{
'MF-A': [{destWh: 'ONT8', businessTypeCode: 'DF', vol: 2.1}],
'MF-B': [{destWh: 'ONT8', businessTypeCode: 'DF', vol: 2.1}],
},
)));
assert.deepEqual(perContainerResult.labels, [
{code: 'D3001-DF-1', cabinetNo: 'D3001', destWh: 'ONT8', businessTypeCode: 'DF', volume: 2.1},
{code: 'D3001-DF-2', cabinetNo: 'D3001', destWh: 'ONT8', businessTypeCode: 'DF', volume: 2.1},
{code: 'D3002-DF-1', cabinetNo: 'D3002', destWh: 'ONT8', businessTypeCode: 'DF', volume: 2.1},
{code: 'D3002-DF-2', cabinetNo: 'D3002', destWh: 'ONT8', businessTypeCode: 'DF', volume: 2.1},
]);
const duplicateCabinetResult = sandbox.buildCtPalletLabels(
[
{no: 'CONT-DUP-A', ctNo: 'D1(A)', manifest: 'MF-DUP-A'},
{no: 'CONT-DUP-B', ctNo: 'D1(B)', manifest: 'MF-DUP-B'},
],
[{no: 'MF-DUP-A'}, {no: 'MF-DUP-B'}],
{
'MF-DUP-A': [{destWh: 'LAX9', businessTypeCode: 'DF', vol: 1}],
'MF-DUP-B': [{destWh: 'ONT8', businessTypeCode: 'DF', vol: 1}],
},
);
assert.deepEqual(
Array.from(duplicateCabinetResult.labels, item => item.code),
['D1-DF-1', 'D1-DF-2'],
);
const stableDetails = [
{destWh: 'ONT8', businessTypeCode: 'TC', vol: 1},
{destWh: 'SBD1', businessTypeCode: 'DF', vol: 1},
{destWh: 'LAX9', businessTypeCode: 'DF', vol: 1},
];
function buildStableResult(details) {
return JSON.parse(JSON.stringify(sandbox.buildCtPalletLabels(
[{no: 'CONT-STABLE', ctNo: 'D6001(A)', manifest: 'MF-STABLE'}],
[{no: 'MF-STABLE'}],
{'MF-STABLE': details},
)));
}
assert.deepEqual(
buildStableResult(stableDetails),
buildStableResult(stableDetails.slice().reverse()),
'分组输出不应受明细输入顺序影响',
);
const validDetail = [{destWh: 'ONT8', businessTypeCode: 'DF', vol: 2}];
const missingManifestResult = sandbox.buildCtPalletLabels(
[{no: 'CONT-MISSING-MF', ctNo: 'D4001', manifest: 'MF-MISSING'}],
[],
{},
);
assert.equal(missingManifestResult.skippedContainers, 1);
assert.equal(missingManifestResult.labels.length, 0);
const missingDetailsResult = sandbox.buildCtPalletLabels(
[{no: 'CONT-MISSING-DETAILS', ctNo: 'D4002', manifest: 'MF-NO-DETAILS'}],
[{no: 'MF-NO-DETAILS'}],
{},
);
assert.equal(missingDetailsResult.skippedContainers, 1);
assert.equal(missingDetailsResult.labels.length, 0);
const emptyCabinetResult = sandbox.buildCtPalletLabels(
[{no: 'CONT-EMPTY-CABINET', ctNo: ' ', manifest: 'MF-VALID'}],
[{no: 'MF-VALID'}],
{'MF-VALID': validDetail},
);
assert.equal(emptyCabinetResult.skippedContainers, 1);
assert.equal(emptyCabinetResult.labels.length, 0);
const invalidVolumeResult = sandbox.buildCtPalletLabels(
[{no: 'CONT-INVALID-VOL', ctNo: 'D5001(AJC)', manifest: 'MF-INVALID-VOL'}],
[{no: 'MF-INVALID-VOL'}],
{
'MF-INVALID-VOL': [
{destWh: 'ONT8', businessTypeCode: 'DF', vol: -1},
{destWh: 'ONT8', businessTypeCode: 'DF', vol: 'not-a-number'},
],
},
);
assert.equal(invalidVolumeResult.skippedContainers, 0);
assert.equal(invalidVolumeResult.skippedDetails, 2);
assert.equal(invalidVolumeResult.labels.length, 0);
function buildOverflowResult(details) {
const script = [
'const MAX_CT_PALLET_LABELS_PER_GROUP=500;',
'const CT_PALLET_VOLUME_SCALE=1000000000;',
sandbox.normalizeCtPalletCabinet.toString(),
sandbox.buildCtPalletLabels.toString(),
`process.stdout.write(JSON.stringify(buildCtPalletLabels(
[{no:'CONT-OVERFLOW',ctNo:'D7001(A)',manifest:'MF-OVERFLOW'}],
[{no:'MF-OVERFLOW'}],
{'MF-OVERFLOW':${JSON.stringify(details)}}
)));`,
].join('\n');
const child = spawnSync(process.execPath, ['-e', script], {encoding: 'utf8', timeout: 1000});
assert.equal(child.error, undefined, '溢出分组必须在 1 秒内返回');
assert.equal(child.status, 0, child.stderr || '溢出分组子进程异常退出');
return JSON.parse(child.stdout);
}
const maxValueResult = buildOverflowResult([
{destWh: 'ONT8', businessTypeCode: 'DF', vol: Number.MAX_VALUE},
]);
assert.equal(maxValueResult.labels.length, 0);
assert.equal(maxValueResult.skippedDetails, 1);
const summedInfinityResult = buildOverflowResult([
{destWh: 'ONT8', businessTypeCode: 'DF', vol: Number.MAX_VALUE},
{destWh: 'ONT8', businessTypeCode: 'DF', vol: Number.MAX_VALUE},
]);
assert.equal(summedInfinityResult.labels.length, 0);
assert.equal(summedInfinityResult.skippedDetails, 2);
assert.match(moduleHtml, /生成托盘标/, '货柜列表缺少生成托盘标按钮');
assert.match(moduleHtml, /openCtPalletLabelPreview\(\)/, '货柜列表按钮未绑定托盘标预览');
[
'modal-ct-pallet-label-preview',
'ct-pallet-label-list',
'ct-pallet-preview-count',
'托盘标签字号',
'目的仓字号',
'printCtPalletLabels()',
].forEach(token => assert.ok(overlaysHtml.includes(token), `托盘标预览缺少 ${token}`));
assert.match(overlaysHtml, /id="ct-pallet-preview-close"/, '托盘标预览关闭按钮缺少稳定 ID');
assert.match(
overlaysHtml,
/class="[^"]*\bct-pallet-print-action\b[^"]*"[^>]*onclick="printCtPalletLabels\(\)"/,
'托盘标打印按钮缺少专用主操作 class',
);
assert.match(shellHtml, /\.ct-pallet-label-sheet\b/, '缺少托盘标纸张样式');
assert.match(shellHtml, /page-break-after\s*:\s*always/, '托盘标未配置逐页打印');
assert.match(shellHtml, /body\.ct-pallet-printing/, '托盘标打印样式缺少 body 作用域');
assert.match(
shellHtml,
/\.ct-pallet-preview-modal\{[^}]*--ct-pallet-code-size\s*:\s*120px[^}]*--ct-pallet-destination-size\s*:\s*180px/,
'字号变量默认值必须与脚本更新保持在预览 modal 同一作用域',
);
assert.match(
shellHtml,
/\.ct-pallet-print-action\{[^}]*background\s*:\s*#0958d9\s*!important[^}]*border\s*:\s*1px solid #0958d9\s*!important/,
'托盘标打印按钮缺少专用高对比主操作样式',
);
assert.match(
shellHtml,
/@media\(max-width:760px\)\{[\s\S]*?\.ct-pallet-preview-controls \.btn,[^}]*?\.ct-pallet-preview-controls \.booking-print-close,[^}]*?\.ct-pallet-font-control button\{[^}]*min-height\s*:\s*40px/,
'窄屏操作控件高度未达到 40px',
);
assert.match(
shellHtml,
/@media\(max-width:760px\)\{[\s\S]*?\.ct-pallet-font-stepper button\{[^}]*width\s*:\s*40px[^}]*height\s*:\s*40px/,
'窄屏字号步进器未达到 40px 触控尺寸',
);
[
'openCtPalletLabelPreview',
'renderCtPalletLabelPreview',
'adjustCtPalletLabelFont',
'closeCtPalletLabelPreview',
'printCtPalletLabels',
].forEach(name => assert.match(appJs, new RegExp(`function\\s+${name}\\s*\\(`), `缺少 ${name} 函数`));
assert.match(appJs, /var\s+ctPalletLabelReturnFocus\s*=\s*null/, '缺少托盘标预览返回焦点状态');
assert.match(appJs, /ctPalletLabelReturnFocus\s*=\s*document\.activeElement/, '打开预览前未保存当前焦点');
assert.match(appJs, /requestAnimationFrame\s*\([\s\S]*?ct-pallet-preview-close[\s\S]*?\.focus\s*\(/, '打开预览后未聚焦关闭按钮');
assert.match(appJs, /ctPalletLabelReturnFocus\.isConnected[\s\S]*?ctPalletLabelReturnFocus\.focus\s*\(/, '关闭预览后未恢复原焦点');
assert.match(appJs, /event\.key\s*(?:===|!==)\s*'Tab'/, '托盘标预览未处理 Tab 键');
assert.match(appJs, /button:not\(\[disabled\]\)[\s\S]*?\[tabindex\]:not\(\[tabindex="-1"\]\)/, '托盘标预览缺少可聚焦控件查询');
assert.match(appJs, /event\.shiftKey[\s\S]*?event\.preventDefault\s*\(/, '托盘标预览缺少反向 Tab 循环');
console.log('CONTAINER_PALLET_LABEL_CHECK_PASS');
@@ -0,0 +1,20 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
const moduleHtml = fs.readFileSync(path.join(root, 'prototype-src/modules/06-containers.html'), 'utf8');
const appJs = fs.readFileSync(path.join(root, 'prototype-src/app.js'), 'utf8');
assert.match(moduleHtml, /truck-region-tabs/);
assert.match(moduleHtml, /国内/);
assert.match(moduleHtml, /海外/);
assert.match(appJs, /overseasTruckList/);
assert.match(appJs, /truckRegion/);
assert.match(appJs, /USD/);
assert.match(appJs, /RMB/);
assert.match(appJs, /function switchTruckRegion\(/);
assert.match(appJs, /function getActiveTruckList\(/);
assert.match(appJs, /function getTruckCurrency\(/);
console.log('拖车国内/海外页签回归校验通过');
@@ -0,0 +1,71 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const root = path.resolve(__dirname, '..');
const moduleHtml = fs.readFileSync(path.join(root, 'prototype-src/modules/08-finance.html'), 'utf8');
const overlays = fs.readFileSync(path.join(root, 'prototype-src/overlays.html'), 'utf8');
const appJs = fs.readFileSync(path.join(root, 'prototype-src/app.js'), 'utf8');
assert.ok(appJs.includes("showCustomerDeduction(\\'"), '客户账户列表缺少扣款入口');
assert.ok(moduleHtml.includes('id="ca-detail-deduction-btn"'), '账户详情缺少手动扣款按钮');
[
'modal-customer-deduction',
'ca-deduction-id',
'ca-deduction-customer',
'ca-deduction-current',
'ca-deduction-available',
'ca-deduction-amount',
'ca-deduction-type',
'ca-deduction-date',
'ca-deduction-order',
'ca-deduction-remark'
].forEach(id => assert.ok(overlays.includes(`id="${id}"`), `扣款弹窗缺少字段 ${id}`));
assert.ok(overlays.includes('onclick="saveCustomerDeduction()"'), '扣款弹窗确认按钮未接通');
function loadFunction(name, nextName) {
const start = appJs.indexOf(`function ${name}`);
const end = appJs.indexOf(`\nfunction ${nextName}`, start);
assert.ok(start >= 0 && end > start, `缺少可测试函数 ${name}`);
const sandbox = {};
vm.runInNewContext(`${appJs.slice(start, end)}\nresult=${name};`, sandbox);
return sandbox.result;
}
const validate = loadFunction('validateCustomerDeduction', 'applyCustomerDeduction');
const apply = loadFunction('applyCustomerDeduction', 'showCustomerDeduction');
const normalize = value => JSON.parse(JSON.stringify(value));
assert.equal(validate({status:'正常', balance:100, frozen:20}, 80), '');
assert.equal(validate({status:'正常', balance:100, frozen:20}, 80.01), '扣款金额不能超过可用余额');
assert.equal(validate({status:'冻结', balance:100, frozen:0}, 10), '当前账户状态不允许扣款');
assert.equal(validate({status:'停用', balance:100, frozen:0}, 10), '当前账户状态不允许扣款');
assert.equal(validate({status:'正常', balance:100, frozen:0}, 0), '请输入大于0的扣款金额');
const account = {status:'正常', balance:100, frozen:20, lastChange:'2026-08-01 08:00'};
const transactions = [];
const transaction = apply(account, transactions, {
amount:35.5,
type:'仓储费用',
time:'2026-08-15 10:20',
no:'AC2026081512345678',
order:'OUT20260815001',
remark:''
});
assert.equal(account.balance, 64.5);
assert.equal(account.frozen, 20);
assert.equal(account.lastChange, '2026-08-15 10:20');
assert.deepEqual(normalize(transaction), {
no:'AC2026081512345678', time:'2026-08-15 10:20', type:'仓储费用',
order:'OUT20260815001', income:0, expense:35.5, balance:64.5,
operator:'当前用户', remark:'手动扣款'
});
assert.equal(transactions[0], transaction);
const saveStart = appJs.indexOf('function saveCustomerDeduction');
const saveEnd = appJs.indexOf('\nfunction ', saveStart + 10);
assert.ok(saveStart >= 0 && saveEnd > saveStart, '缺少扣款保存函数');
assert.ok(appJs.slice(saveStart, saveEnd).includes('validateCustomerDeduction'), '保存函数缺少二次业务校验');
console.log('客户账户手动扣款回归校验通过');
@@ -0,0 +1,58 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const root = path.resolve(__dirname, '..');
const moduleHtml = fs.readFileSync(path.join(root, 'prototype-src/modules/08-finance.html'), 'utf8');
const overlays = fs.readFileSync(path.join(root, 'prototype-src/overlays.html'), 'utf8');
const appJs = fs.readFileSync(path.join(root, 'prototype-src/app.js'), 'utf8');
const pageStart = moduleHtml.indexOf('id="page-customer-arap"');
const pageEnd = moduleHtml.indexOf('id="page-finance-ar-detail-report"', pageStart);
const pageHtml = moduleHtml.slice(pageStart, pageEnd);
const feeHeaderIndex = pageHtml.indexOf('>费用类型</th>');
const amountHeaderIndex = pageHtml.indexOf('>金额</th>');
assert.ok(feeHeaderIndex >= 0, '客户应收应付列表缺少费用类型表头');
assert.ok(amountHeaderIndex > feeHeaderIndex, '客户应收应付列表应在费用类型后显示金额');
assert.ok(overlays.includes('id="arap-form-amount"'), '新增/编辑弹框缺少金额字段');
assert.ok(overlays.includes('id="arap-audit-amount"'), '审核摘要缺少金额字段');
const formStart = overlays.indexOf('id="modal-arap-form"');
const formEnd = overlays.indexOf('id="modal-arap-audit"', formStart);
const formHtml = overlays.slice(formStart, formEnd);
assert.ok(formHtml.indexOf('>金额 *</label>') > formHtml.indexOf('>费用类型 *</label>'), '新增/编辑弹框金额应位于费用类型之后');
const auditStart = overlays.indexOf('id="modal-arap-audit"');
const auditEnd = overlays.indexOf('id="modal-settlement-ap-voucher"', auditStart);
const auditHtml = overlays.slice(auditStart, auditEnd);
assert.ok(auditHtml.indexOf('>金额</span>') > auditHtml.indexOf('>费用类型</span>'), '审核摘要金额应位于费用类型之后');
function loadFunction(name, nextName) {
const start = appJs.indexOf(`function ${name}`);
const end = appJs.indexOf(`\nfunction ${nextName}`, start);
assert.ok(start >= 0 && end > start, `缺少可测试函数 ${name}`);
const sandbox = {};
vm.runInNewContext(`${appJs.slice(start, end)}\nresult=${name};`, sandbox);
return sandbox.result;
}
const normalizeAmount = loadFunction('normalizeArapAmount', 'appendArapLog');
assert.equal(normalizeAmount('1200'), '1200.00');
assert.equal(normalizeAmount('1200.5'), '1200.50');
assert.equal(normalizeAmount('0.01'), '0.01');
['', '-1', 'abc', '1.234'].forEach(value => assert.equal(normalizeAmount(value), ''));
assert.ok(appJs.includes("['arap-form-amount',record.amount]"), '编辑弹框未回填金额');
assert.ok(appJs.includes("getArapFormValue('arap-form-amount')"), '保存函数未读取金额');
assert.ok(appJs.includes("document.getElementById('arap-audit-amount').textContent=record.amount||'—'"), '审核摘要未将金额写入可见文本');
assert.ok(appJs.includes("['费用类型',d.feeType],['金额',d.amount]"), '详情页金额未紧跟费用类型');
assert.ok(appJs.includes("existing.auditStatus=getArapStatusAfterSave('edit')"), '编辑保存后未重新进入审核');
const renderStart = appJs.indexOf('function renderArap()');
const renderEnd = appJs.indexOf('// ============ 应收款统计表', renderStart);
const renderSource = appJs.slice(renderStart, renderEnd);
assert.ok(renderSource.includes("+d.feeType+'</td>'\n +'<td"), '列表金额未紧跟费用类型渲染');
assert.ok(!renderSource.includes('>删除</a>'), '客户应收应付列表仍包含删除操作');
assert.ok(renderSource.includes('colspan="14"'), '列表空态列数未更新为 14');
console.log('客户应收应付金额与审核回归校验通过');
@@ -0,0 +1,52 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const root = path.resolve(__dirname, '..');
const moduleHtml = fs.readFileSync(path.join(root, 'prototype-src/modules/08-finance.html'), 'utf8');
const overlays = fs.readFileSync(path.join(root, 'prototype-src/overlays.html'), 'utf8');
const appJs = fs.readFileSync(path.join(root, 'prototype-src/app.js'), 'utf8');
assert.ok(moduleHtml.includes('onclick="openArapForm()"'), '新增按钮未接通');
assert.ok(moduleHtml.includes('id="arap-detail-edit-btn"'), '详情页缺少编辑按钮 ID');
['待审核', '审核通过', '审核驳回'].forEach(status => {
assert.ok(moduleHtml.includes(`<option>${status}</option>`), `筛选项缺少 ${status}`);
});
assert.ok(overlays.includes('id="modal-arap-form"'), '缺少新增编辑弹框');
assert.ok(overlays.includes('id="modal-arap-audit"'), '缺少审核弹框');
[
'arap-form-customer', 'arap-form-join-settle', 'arap-form-type',
'arap-form-wh-code', 'arap-form-order-status', 'arap-form-dispatch-no',
'arap-form-fee-type', 'arap-form-container-no', 'arap-form-cabinet-no'
].forEach(id => assert.ok(overlays.includes(`id="${id}"`), `表单缺少字段 ${id}`));
function loadFunction(name, nextName) {
const start = appJs.indexOf(`function ${name}`);
const end = appJs.indexOf(`\nfunction ${nextName}`, start);
assert.ok(start >= 0 && end > start, `缺少可测试函数 ${name}`);
const sandbox = {};
vm.runInNewContext(`${appJs.slice(start, end)}\nresult=${name};`, sandbox);
return sandbox.result;
}
const getActionState = loadFunction('getArapActionState', 'getArapStatusAfterSave');
const getStatusAfterSave = loadFunction('getArapStatusAfterSave', 'getArapAuditStatus');
const getAuditStatus = loadFunction('getArapAuditStatus', 'appendArapLog');
const normalize = value => JSON.parse(JSON.stringify(value));
assert.deepEqual(normalize(getActionState('待审核')), {canEdit:true, canAudit:true});
assert.deepEqual(normalize(getActionState('审核通过')), {canEdit:false, canAudit:false});
assert.deepEqual(normalize(getActionState('审核驳回')), {canEdit:true, canAudit:false});
assert.equal(getStatusAfterSave('new'), '待审核');
assert.equal(getStatusAfterSave('edit'), '待审核');
assert.equal(getAuditStatus('pass'), '审核通过');
assert.equal(getAuditStatus('reject'), '审核驳回');
assert.ok(appJs.includes("if(!state.canEdit)"), '编辑函数缺少通过状态的函数层锁定');
assert.ok(appJs.includes("if(!state.canAudit)"), '审核函数缺少状态校验');
assert.ok(appJs.includes("驳回原因不能为空"), '缺少驳回原因必填校验');
assert.ok(appJs.includes("style.display=actionState.canEdit?'inline-flex':'none'"), '详情页未同步编辑锁定状态');
assert.ok(appJs.includes('var logs=Array.isArray(d.logs)?d.logs:[]'), '详情页未读取真实操作日志');
console.log('客户应收应付新增、编辑与审核回归校验通过');
@@ -0,0 +1,83 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
const moduleSource = fs.readFileSync(path.join(root, 'prototype-src/modules/08-finance.html'), 'utf8');
const overlaysSource = fs.readFileSync(path.join(root, 'prototype-src/overlays.html'), 'utf8');
const appSource = fs.readFileSync(path.join(root, 'prototype-src/app.js'), 'utf8');
assert.match(moduleSource, /充值申请记录/);
assert.match(moduleSource, /showCustomerRechargeApplication\(/);
assert.match(overlaysSource, /id="modal-customer-recharge-application"/);
assert.match(overlaysSource, /id="modal-customer-recharge-review"/);
assert.match(overlaysSource, /customer-recharge-qr/);
assert.match(overlaysSource, /id="customer-recharge-vouchers"[^>]*multiple[^>]*accept="\.pdf,\.jpg,\.jpeg,\.png"/);
assert.match(appSource, /customerRechargeApplications/);
assert.match(appSource, /function submitCustomerRechargeApplication\(/);
assert.match(appSource, /function reviewCustomerRechargeApplication\(/);
assert.match(appSource, /function approveCustomerRechargeApplication\(/);
assert.doesNotMatch(moduleSource, /id="modal-customer-recharge"/);
assert.doesNotMatch(appSource, /function showCustomerRecharge\(/);
assert.doesNotMatch(appSource, /function saveCustomerRecharge\(/);
const rechargeDataStart = appSource.indexOf('const customerRechargeApplications=');
const rechargeDataEnd = appSource.indexOf('const integrationData', rechargeDataStart);
const rechargeSource = appSource.slice(rechargeDataStart, rechargeDataEnd);
assert.match(rechargeSource, /referenceNo:/);
assert.match(rechargeSource, /app\.referenceNo/);
assert.doesNotMatch(rechargeSource, /\breference:/);
assert.doesNotMatch(rechargeSource, /app\.reference\b/);
const closeSource = extract('closeCustomerRechargeApplication');
for (const id of ['customer-recharge-application-id', 'customer-recharge-customer', 'customer-recharge-current', 'customer-recharge-amount', 'customer-recharge-date', 'customer-recharge-ref', 'customer-recharge-remark', 'customer-recharge-vouchers']) {
assert.match(closeSource, new RegExp(id), `close must clear ${id}`);
}
const reviewSource = extract('reviewCustomerRechargeApplication');
assert.match(reviewSource, /未找到充值申请/);
const handleSource = extract('handleCustomerRechargeReview');
assert.match(handleSource, /未找到充值申请/);
assert.match(handleSource, /未找到客户账户/);
assert.ok(handleSource.indexOf("customerAccountData.find") < handleSource.indexOf("customerAccountTransactions[app.accountId]"), 'account must be validated before transaction storage access');
function extract(name) {
const match = appSource.match(new RegExp(`function ${name}\\([^]*?\\n\\}`));
assert.ok(match, `missing ${name}`);
return match[0];
}
const normalizeSource = extract('normalizeCustomerRechargeAmount');
const normalize = new Function(`${normalizeSource}; return normalizeCustomerRechargeAmount;`)();
assert.equal(normalize('12'), 12);
assert.equal(normalize('12.3'), 12.3);
assert.equal(normalize('12.345'), null);
assert.equal(normalize('-1'), null);
assert.equal(normalize(''), null);
const approveSource = extract('approveCustomerRechargeApplication');
const approve = new Function(`${approveSource}; return approveCustomerRechargeApplication;`)();
const account = { id: 'CA-X', balance: 100, frozen: 0, status: '正常', lastChange: '' };
const application = { id: 'a1', accountId: 'CA-X', amount: 25.5, arrivalDate: '2026-08-20', status: '待审核', vouchers: [] };
const transactions = [];
const first = approve(application, account, transactions, { decision: '通过', now: '2026/8/20 10:00', operator: '财务' });
assert.equal(first.credited, true);
assert.equal(first.processed, true);
assert.match(first.transaction.no, /^AC20260820\d{8}$/);
assert.equal(account.balance, 125.5);
assert.equal(transactions.length, 1);
const second = approve(application, account, transactions, { decision: '通过', now: '2026-08-20 10:01', operator: '财务' });
assert.equal(second.credited, false);
assert.equal(second.processed, false);
assert.equal(account.balance, 125.5);
assert.equal(transactions.length, 1);
const rejected = { id: 'a2', accountId: 'CA-X', amount: 10, status: '待审核', vouchers: [] };
const firstReject = approve(rejected, account, transactions, { decision: '驳回', remark: '金额不符', now: '2026-08-20 10:02', operator: '财务' });
assert.equal(firstReject.processed, true);
assert.equal(firstReject.credited, false);
const repeatedReject = approve(rejected, account, transactions, { decision: '驳回', remark: '再次驳回', now: '2026-08-20 10:03', operator: '财务' });
assert.equal(repeatedReject.processed, false);
assert.equal(transactions.length, 1);
console.log('客户充值申请与审核回归校验通过');
@@ -0,0 +1,66 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const root = path.resolve(__dirname, '..');
const moduleHtml = fs.readFileSync(path.join(root, 'prototype-src/modules/08-finance.html'), 'utf8');
const shellHtml = fs.readFileSync(path.join(root, 'prototype-src/shell.html'), 'utf8');
const appJs = fs.readFileSync(path.join(root, 'prototype-src/app.js'), 'utf8');
assert.ok(moduleHtml.includes('<th>客户是否确认</th>'), '客户对账单列表缺少客户是否确认列');
assert.ok(shellHtml.includes('.recon-action-link'), '缺少对账单紧凑操作样式');
assert.ok(appJs.includes('pushReconStmtToCustomer'), '缺少推送给客户动作');
assert.ok(appJs.includes('createSettlementFromReconStmt'), '缺少对账单生成结算单动作');
assert.ok(appJs.includes('客户确认后才可结算'), '缺少结算门槛提示');
assert.ok(appJs.includes("customerConfirmStatus:'待确认'"), '缺少待确认示例对账单');
assert.ok(appJs.includes("customerConfirmStatus:'已确认'"), '缺少已确认示例对账单');
const start = appJs.indexOf('function getReconStmtActionState');
const end = appJs.indexOf('\nfunction reconStmtNowText', start);
assert.ok(start >= 0 && end > start, '缺少可测试的对账单状态函数');
const sandbox = {};
vm.runInNewContext(
appJs.slice(start, end) +
'\nresult={getReconStmtActionState,markReconStmtPushed,buildSettlementFromReconStmt};',
sandbox
);
const api = sandbox.result;
const normalize = value => JSON.parse(JSON.stringify(value));
assert.deepEqual(
normalize(api.getReconStmtActionState({status:'待审核',customerConfirmStatus:'未推送',settlementId:''})),
{canPush:false,canSettle:false,hasSettlement:false}
);
assert.deepEqual(
normalize(api.getReconStmtActionState({status:'审核通过',customerConfirmStatus:'未推送',settlementId:''})),
{canPush:true,canSettle:false,hasSettlement:false}
);
assert.deepEqual(
normalize(api.getReconStmtActionState({status:'审核通过',customerConfirmStatus:'已确认',settlementId:''})),
{canPush:false,canSettle:true,hasSettlement:false}
);
assert.deepEqual(
normalize(api.getReconStmtActionState({status:'审核通过',customerConfirmStatus:'已确认',settlementId:'SAR-202608-009'})),
{canPush:false,canSettle:false,hasSettlement:true}
);
const pushed = {status:'审核通过',customerConfirmStatus:'未推送',pushTime:''};
assert.equal(api.markReconStmtPushed(pushed, '2026-08-14 18:30:00'), true);
assert.equal(pushed.customerConfirmStatus, '待确认');
assert.equal(pushed.pushTime, '2026-08-14 18:30:00');
assert.equal(api.markReconStmtPushed({status:'待审核'}, '2026-08-14 18:30:00'), false);
const source = {
id:'Y2607084',status:'审核通过',customerConfirmStatus:'已确认',
customer:'浙江柏悦贸易有限公司',currency:'CNY',totalAr:98000,paidAr:0,
startDate:'2026-07-01',endDate:'2026-07-31',period:30,creator:'叶珊珊',settlementId:''
};
const settlement = api.buildSettlementFromReconStmt(source, 'SAR-202608-009', '2026-08-14');
assert.equal(settlement.id, 'SAR-202608-009');
assert.equal(settlement.billNo, 'Y2607084');
assert.equal(settlement.balance, 98000);
assert.equal(settlement.status, '草稿');
assert.equal(api.buildSettlementFromReconStmt({...source,customerConfirmStatus:'待确认'}, 'SAR-X', '2026-08-14'), null);
console.log('客户对账单推送、确认状态与结算门槛校验通过');
@@ -0,0 +1,52 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
const read = relativePath => fs.readFileSync(path.join(root, relativePath), 'utf8');
const page = read('prototype-src/modules/04a-customer-service.html');
const app = read('prototype-src/app.js');
const overlays = read('prototype-src/overlays.html');
const shell = read('prototype-src/shell.html');
const includes = (source, expected, message) => assert.ok(source.includes(expected), message);
const functionSource = name => {
const start = app.search(new RegExp(`function\\s+${name}\\s*\\(`));
assert.notEqual(start, -1, `app.js 缺少 ${name}()`);
const next = app.indexOf('\nfunction ', start + 1);
return app.slice(start, next === -1 ? app.length : next);
};
const staticDispatchRecords = app.match(/const staticDispatchRecords\s*=\s*\[([\s\S]*?)\n\];/);
assert.ok(staticDispatchRecords, 'app.js 缺少静态派送订单数据');
assert.match(page, /<th\b[^>]*>\s*异常订单号\s*<\/th>/, '异常订单页缺少异常订单号表头');
assert.match(page, /<th\b[^>]*>\s*关联订单号\s*<\/th>/, '异常订单页缺少关联订单号表头');
assert.doesNotMatch(page, /<th\b[^>]*>\s*派送单号\s*<\/th>/, '异常订单页不应保留独立派送单号表头');
includes(page, 'placeholder="异常订单号 / 关联订单号 / 客户 / 原因"', '异常订单页搜索框占位符不符合编号契约');
assert.match(page, /min-width\s*:\s*1520px\b/, '异常订单页表格最小宽度应为 1520px');
assert.match(app, /function ensureDispatchExceptionNo\(/, 'app.js 缺少 ensureDispatchExceptionNo()');
assert.match(app, /function getDispatchExceptionRelatedOrders\(/, 'app.js 缺少 getDispatchExceptionRelatedOrders()');
assert.match(app, /function renderDispatchExceptionRelations\(/, 'app.js 缺少 renderDispatchExceptionRelations()');
assert.match(staticDispatchRecords[1], /dispatchNo\s*:\s*['"]YWBHK200615004['"][^}]*exceptionNo\s*:\s*['"]EXC20260812001['"]/, '异常订单编号 EXC20260812001 未关联派送单 YWBHK200615004');
assert.match(staticDispatchRecords[1], /dispatchNo\s*:\s*['"]YWBHK200615005['"][^}]*exceptionNo\s*:\s*['"]EXC20260812002['"]/, '异常订单编号 EXC20260812002 未关联派送单 YWBHK200615005');
const searchDispatchExceptionsSource = functionSource('searchDispatchExceptions');
const renderDispatchExceptionsSource = functionSource('renderDispatchExceptions');
const renderDispatchExceptionRelationsSource = functionSource('renderDispatchExceptionRelations');
const openDispatchExceptionProcessSource = functionSource('openDispatchExceptionProcess');
assert.match(searchDispatchExceptionsSource, /\[\s*record\.exceptionNo\s*,\s*related\.lastMileNo\s*,\s*related\.dispatchNo\s*,\s*related\.inboundNo\s*,\s*related\.outboundNo/, '异常订单搜索未覆盖编号与关联订单字段');
assert.match(renderDispatchExceptionsSource, /colspan\s*=\s*["']13["']/, '异常订单空状态 colspan 应为 13');
assert.match(renderDispatchExceptionRelationsSource, /\[\s*\[\s*['"]尾程['"]\s*,\s*related\.lastMileNo\s*\]\s*,\s*\[\s*['"]派送['"]\s*,\s*related\.dispatchNo\s*\]\s*,\s*\[\s*['"]入库['"]\s*,\s*related\.inboundNo\s*\]\s*,\s*\[\s*['"]出库['"]\s*,\s*related\.outboundNo\s*\]\s*\]/, '异常订单关联订单映射不完整');
assert.match(openDispatchExceptionProcessSource, /document\.getElementById\(\s*['"]dispatch-exception-process-exception-no['"]\s*\)\.textContent\s*=\s*record\.exceptionNo/, '处理弹窗未写入异常订单号');
assert.match(openDispatchExceptionProcessSource, /document\.getElementById\(\s*['"]dispatch-exception-process-relations['"]\s*\)\.innerHTML\s*=\s*renderDispatchExceptionRelations\(\s*record\s*\)/, '处理弹窗未渲染关联订单号');
assert.match(openDispatchExceptionProcessSource, /querySelector\(\s*['"]#modal-dispatch-exception-process \.modal['"]\s*\)[\s\S]*scrollTop\s*=\s*0/, '处理弹窗重复打开时未重置滚动位置');
includes(overlays, 'id="dispatch-exception-process-exception-no"', '处理弹窗缺少异常订单号容器');
includes(overlays, 'id="dispatch-exception-process-relations"', '处理弹窗缺少关联订单号容器');
assert.match(shell, /\.dispatch-exception-no\s*\{[^}]*font-weight\s*:\s*600\b/, '全局样式缺少带 font-weight:600 的 .dispatch-exception-no');
assert.match(shell, /\.dispatch-exception-relations\s*\{[^}]*display\s*:\s*flex\b/, '全局样式缺少带 display:flex 的 .dispatch-exception-relations');
assert.match(shell, /\.dispatch-exception-relation-label\s*\{[^}]*font-size\s*:\s*11px\b/, '全局样式缺少带 font-size:11px 的 .dispatch-exception-relation-label');
console.log('CUSTOMER_SERVICE_EXCEPTION_ORDER_NUMBERS_CHECK_PASS');
@@ -0,0 +1,138 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
const sourceRoot = path.join(root, 'prototype-src');
const shell = fs.readFileSync(path.join(sourceRoot, 'shell.html'), 'utf8');
const manifest = JSON.parse(fs.readFileSync(path.join(sourceRoot, 'manifest.json'), 'utf8'));
const overseasDelivery = fs.readFileSync(
path.join(sourceRoot, 'modules/04-overseas-delivery.html'),
'utf8',
);
const customerServicePath = path.join(sourceRoot, 'modules/04a-customer-service.html');
const customerService = fs.existsSync(customerServicePath)
? fs.readFileSync(customerServicePath, 'utf8')
: '';
const app = fs.readFileSync(path.join(sourceRoot, 'app.js'), 'utf8');
function sidebarItemStart(source, marker) {
const markerIndex = source.indexOf(marker);
return markerIndex < 0 ? -1 : source.lastIndexOf('<div class="sidebar-item"', markerIndex);
}
function sidebarItemAt(source, start) {
if (start < 0) return '';
const nextStart = source.indexOf('<div class="sidebar-item"', start + 1);
return source.slice(start, nextStart < 0 ? source.length : nextStart);
}
function sidebarItemStartByLabel(source, label) {
const starts = [...source.matchAll(/<div class="sidebar-item"(?:\s[^>]*)?>/g)].map(
match => match.index,
);
return starts.find((start, index) => {
const item = source.slice(start, starts[index + 1] ?? source.length);
const labelEnd = item.indexOf('</div>');
return labelEnd >= 0 && item.slice(0, labelEnd).includes(label);
}) ?? -1;
}
function countMatches(source, pattern) {
return [...source.matchAll(pattern)].length;
}
function pageSource(source, pageId) {
const pagePattern = new RegExp(`<div\\b[^>]*\\bid=(["'])page-${pageId}\\1[^>]*>`);
const match = pagePattern.exec(source);
if (!match) return '';
const nextPagePattern = /<div\b[^>]*\bid=["']page-/g;
nextPagePattern.lastIndex = match.index + match[0].length;
const nextPage = nextPagePattern.exec(source);
return source.slice(match.index, nextPage ? nextPage.index : source.length);
}
const outboundNavStart = sidebarItemStart(shell, 'data-menu="outbound"');
const supplierNavStart = sidebarItemStart(shell, 'data-menu="supplier"');
const customerServiceNavStart = sidebarItemStartByLabel(shell, '客户服务');
assert.ok(customerServiceNavStart >= 0, '侧栏缺少客户服务导航');
assert.ok(outboundNavStart >= 0, '侧栏缺少海外派送导航');
assert.ok(supplierNavStart >= 0, '侧栏缺少供应商中心导航');
assert.ok(
outboundNavStart < customerServiceNavStart && customerServiceNavStart < supplierNavStart,
'客户服务导航必须位于海外派送之后、供应商中心之前',
);
const outboundNav = sidebarItemAt(shell, outboundNavStart);
const customerServiceNav = sidebarItemAt(shell, customerServiceNavStart);
assert.doesNotMatch(
outboundNav,
/outbound-exception|异常订单/,
'海外派送导航不应再包含异常订单',
);
assert.match(
customerServiceNav,
/switchPage\(\s*['"]outbound-exception['"]\s*,\s*this\s*\)/,
'客户服务导航缺少 outbound-exception 入口',
);
assert.match(customerServiceNav, />\s*异常订单\s*</, '客户服务导航缺少异常订单文案');
const overseasDeliveryIndex = manifest.modules.findIndex(
item => item.file === 'modules/04-overseas-delivery.html',
);
const customerServiceIndex = manifest.modules.findIndex(
item => item.file === 'modules/04a-customer-service.html',
);
const customerServiceModule = manifest.modules[customerServiceIndex];
assert.ok(overseasDeliveryIndex >= 0, 'manifest 缺少海外派送模块');
assert.ok(customerServiceIndex >= 0, 'manifest 缺少客户服务模块');
assert.equal(customerServiceModule.title, '客户服务', '客户服务模块标题不正确');
assert.equal(
customerServiceIndex,
overseasDeliveryIndex + 1,
'客户服务模块必须紧邻海外派送之后',
);
assert.deepEqual(
customerServiceModule.pages,
['outbound-exception'],
'客户服务模块必须且只能归属 outbound-exception 页面',
);
assert.ok(
!manifest.modules[overseasDeliveryIndex].pages.includes('outbound-exception'),
'海外派送模块不应再归属 outbound-exception 页面',
);
assert.equal(
countMatches(overseasDelivery, /id=["']page-outbound-exception["']/g),
0,
'海外派送模块仍包含 page-outbound-exception',
);
assert.equal(
countMatches(customerService, /id=["']page-outbound-exception["']/g),
1,
'客户服务模块必须且只能包含一个 page-outbound-exception',
);
const exceptionPage = pageSource(customerService, 'outbound-exception');
assert.match(
exceptionPage,
/class=["']breadcrumb["'][^>]*>[\s\S]*?客户服务[\s\S]*?<strong>\s*异常订单\s*<\/strong>/,
'异常订单页面面包屑必须归属客户服务',
);
assert.match(
exceptionPage,
/onclick=["']switchPage\(\s*['"]outbound-dispatch['"]\s*,\s*this\s*\)["'][^>]*>\s*返回派送单\s*<\/button>/,
'异常订单页面必须保留原有返回派送单链接',
);
assert.match(
app,
/if\s*\(\s*page\s*===\s*['"]outbound-exception['"]\s*\)\s*renderDispatchExceptions\s*\(\s*\)\s*;/,
'app.js 必须继续通过 renderDispatchExceptions() 渲染 outbound-exception',
);
console.log('CUSTOMER_SERVICE_EXCEPTION_ORDERS_CHECK_PASS');
@@ -0,0 +1,84 @@
#!/usr/bin/env node
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const root = path.resolve(__dirname, '..');
const suppliers = fs.readFileSync(path.join(root, 'prototype-src/modules/05-suppliers.html'), 'utf8');
const general = fs.readFileSync(path.join(root, 'prototype-src/modules/10-general.html'), 'utf8');
const shell = fs.readFileSync(path.join(root, 'prototype-src/shell.html'), 'utf8');
const app = fs.readFileSync(path.join(root, 'prototype-src/app.js'), 'utf8');
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'prototype-src/manifest.json'), 'utf8'));
const supplierModule = manifest.modules.find(module => module.file === 'modules/05-suppliers.html');
assert.ok(supplierModule, '缺少供应商模块');
const generalModule = manifest.modules.find(module => module.file === 'modules/10-general.html');
assert.ok(generalModule, '缺少通用资料模块');
assert.ok(!supplierModule.pages.includes('delivery-fleet-quote'), '供应商模块不应继续登记派送车队报价表');
assert.ok(generalModule.pages.includes('delivery-fleet-quote'), '通用资料模块缺少派送车队报价表');
assert.equal(generalModule.pages.indexOf('delivery-fleet-quote'), generalModule.pages.indexOf('vehicle') + 1, '通用模块中派送车队报价表应紧跟车辆列表');
assert.equal(manifest.pageOrder.indexOf('delivery-fleet-quote'), manifest.pageOrder.indexOf('vehicle') + 1, '顶层页面顺序中派送车队报价表应紧跟车辆列表');
assert.equal((shell.match(/switchPage\('delivery-fleet-quote',this\)/g) || []).length, 1, '侧栏只能保留一个派送车队报价表入口');
assert.match(shell, /switchPage\('vehicle',this\)[^>]*>车辆列表<\/div>\s*<div class="sub-menu-item" onclick="switchPage\('delivery-fleet-quote',this\)">派送车队报价<\/div>\s*<div class="sub-menu-item" onclick="switchPage\('gen-download',this\)">下载中心<\/div>/, '通用菜单中派送车队报价后应紧跟下载中心');
assert.match(general, /<strong>派送车队报价<\/strong>/, '页面面包屑应使用派送车队报价');
assert.match(general, /<div class="table-toolbar"><span class="title">派送车队报价<\/span>/, '页面标题应使用派送车队报价');
assert.doesNotMatch(suppliers, /id="page-delivery-fleet-quote"/, '供应商模块不应继续包含派送车队报价页面');
assert.match(general, /id="page-delivery-fleet-quote"/);
assert.match(general, /#page-delivery-fleet-quote \.dfq-table th\{[^}]*text-align:left/);
assert.match(general, /#page-delivery-fleet-quote \.dfq-table td\{[^}]*text-align:left/);
assert.match(general, /#page-delivery-fleet-quote \.dfq-table \.dfq-price-cell\{[^}]*text-align:left/);
assert.match(general, /#modal-delivery-fleet-quote \.dfq-modal-grid\{display:grid/);
assert.match(general, /id="dfq-search-keyword"/);
assert.match(general, /onclick="openDeliveryFleetQuoteImport\(\)"[^>]*>批量导入</);
assert.match(general, /id="modal-delivery-fleet-quote-import"/);
assert.match(general, /id="dfq-import-file"[^>]*accept="\.csv"/);
['发货地(城市)', '目的仓(FBA仓点)', '迈数(预估距离)', '车队渠道', '整车自提', '整车派送', '散板派送'].forEach(label => assert.ok(general.includes(label), `缺少字段:${label}`));
['renderDeliveryFleetQuotes', 'resetDeliveryFleetQuoteSearch', 'openDeliveryFleetQuoteModal', 'saveDeliveryFleetQuote', 'toggleDeliveryFleetQuoteStatus', 'deleteDeliveryFleetQuote', 'openDeliveryFleetQuoteImport', 'downloadDeliveryFleetQuoteImportTemplate', 'handleDeliveryFleetQuoteImportFile', 'confirmDeliveryFleetQuoteImport'].forEach(name => assert.match(app, new RegExp(`function\\s+${name}\\s*\\(`), `缺少函数:${name}`));
assert.match(app, /deliveryFleetQuoteData\s*=\s*\[/);
const helperNames = [
'deliveryFleetQuoteHasPrice',
'escapeDeliveryFleetQuoteHtml',
'filterDeliveryFleetQuoteRows',
'validateDeliveryFleetQuoteDraft',
'deliveryFleetQuoteKey',
'hasDuplicateDeliveryFleetQuote',
'splitDeliveryFleetQuoteCsvLine',
'parseDeliveryFleetQuoteCsv'
];
const helperStart = app.indexOf('function deliveryFleetQuoteHasPrice(');
const helperEnd = app.indexOf('function formatDeliveryFleetQuotePrice(', helperStart);
assert.ok(helperStart >= 0 && helperEnd > helperStart, '报价表纯函数边界缺失');
const context = {};
const helperSource = `${app.slice(helperStart, helperEnd)}\nthis.api={${helperNames.join(',')}};`;
vm.runInNewContext(helperSource, context);
const rows = [
{id:'A',origin:'洛杉矶, CA',warehouse:'LAX9',miles:42,fleet:'SMART',selfPickup:0,fullDelivery:null,looseDelivery:115,status:'启用'},
{id:'B',origin:'芝加哥, IL',warehouse:'ORD2',miles:28,fleet:'RIGLL',selfPickup:null,fullDelivery:230,looseDelivery:null,status:'停用'}
];
assert.equal(context.api.filterDeliveryFleetQuoteRows(rows, {keyword:'smart'}).length, 1, '关键字应跨字段匹配车队渠道');
assert.equal(context.api.filterDeliveryFleetQuoteRows(rows, {keyword:'ord2'}).length, 1, '关键字应跨字段匹配 FBA 仓点');
assert.equal(context.api.filterDeliveryFleetQuoteRows(rows, {method:'selfPickup'}).length, 1, '零报价仍应视为已配置报价');
assert.equal(context.api.escapeDeliveryFleetQuoteHtml('<img src=x onerror=alert(1)>'), '&lt;img src=x onerror=alert(1)&gt;', '用户输入必须转义后渲染');
assert.equal(context.api.validateDeliveryFleetQuoteDraft(rows[0]), '', '零报价应通过校验');
assert.match(context.api.validateDeliveryFleetQuoteDraft({...rows[0], selfPickup:-1}), /非负数字/, '负报价必须拒绝');
assert.match(context.api.validateDeliveryFleetQuoteDraft({...rows[0], selfPickup:null, looseDelivery:null}), /至少填写一种/, '三种空报价必须拒绝');
assert.equal(context.api.hasDuplicateDeliveryFleetQuote(rows, {...rows[0], id:'C'}, ''), true, '相同维度组合必须识别为重复');
assert.equal(context.api.hasDuplicateDeliveryFleetQuote(rows, {...rows[0], id:'A'}, 'A'), false, '编辑当前记录不应误判为重复');
const csv = [
'发货城市,目的FBA仓点,迈数,车队渠道,整车自提,整车派送,散板派送,状态,备注',
'西雅图,SEA8,32,NORTH,0,180,120,启用,正常报价',
'洛杉矶, LAX9,42,SMART,155,175,115,启用,重复报价',
'奥克兰,OAK4,20,WEST,-1,,,启用,负数报价'
].join('\n');
const imported = context.api.parseDeliveryFleetQuoteCsv(csv, rows);
assert.equal(imported.length, 3, '应解析全部 CSV 数据行');
assert.equal(imported[0].valid, true, '合法报价行应通过');
assert.equal(imported[0].data.selfPickup, 0, '导入应保留零报价');
assert.match(imported[1].error, /重复/, '重复维度组合应拒绝');
assert.match(imported[2].error, /非负数字/, '负数报价应拒绝');
console.log('DELIVERY_FLEET_QUOTE_CHECK_PASS');
@@ -3,7 +3,7 @@
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.resolve(__dirname, '..', '货代SaaS平台原型.html'), 'utf8');
const html = fs.readFileSync(path.resolve(__dirname, '..', 'index.html'), 'utf8');
const failures = [];
function requireMatch(pattern, label, source = html) {
@@ -0,0 +1,31 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
const moduleHtml = fs.readFileSync(path.join(root, 'prototype-src/modules/04-overseas-delivery.html'), 'utf8');
const overlays = fs.readFileSync(path.join(root, 'prototype-src/overlays.html'), 'utf8');
const app = fs.readFileSync(path.join(root, 'prototype-src/app.js'), 'utf8');
assert.match(moduleHtml, /<option>拒签<\/option>/, '状态筛选缺少拒签');
assert.match(moduleHtml, /data-dispatch-no="YWBHK200615001"/, '静态派送单缺少稳定行标识');
assert.match(overlays, /id="modal-dispatch-reject"/, '缺少拒收货物弹窗');
assert.match(overlays, /id="dispatch-reject-reason"/, '缺少拒收原因字段');
assert.match(overlays, /id="dispatch-reject-notify"[^>]*checked/, '通知客户默认未开启');
assert.match(overlays, /同时发送系统消息和短信/, '缺少双渠道绑定说明');
assert.match(app, /function\s+getDispatchRecord\s*\(/, '缺少统一派送记录查找');
assert.match(app, /function\s+canRejectDispatch\s*\(/, '缺少拒收状态判断');
assert.match(app, /return\s+status==='待派送'\|\|status==='派送中'/, '可拒收状态范围不正确');
assert.match(app, /dispatch-reject-action/, '缺少拒收操作渲染');
assert.match(app, /function\s+openDispatchReject\s*\(/, '缺少打开拒收弹窗函数');
assert.match(app, /function\s+updateDispatchRejectNotice\s*\(/, '缺少通知说明更新函数');
assert.match(app, /function\s+confirmDispatchReject\s*\(/, '缺少拒收提交函数');
assert.match(app, /rejectionReason/, '缺少拒收原因记录');
assert.match(app, /rejectionNotify/, '缺少通知选择记录');
assert.match(app, /notificationChannels/, '缺少通知渠道结果');
assert.match(app, /rejectedBy/, '缺少操作人记录');
assert.match(app, /rejectedAt/, '缺少操作时间记录');
assert.match(app, /\.status=['"]拒签['"]|status:\s*['"]拒签['"]/, '缺少拒签状态写回');
assert.match(app, /\['系统消息','短信'\]/, '通知渠道未绑定为系统消息和短信');
console.log('DISPATCH_CARGO_REJECTION_CHECK_PASS');
@@ -0,0 +1,121 @@
#!/usr/bin/env node
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const root = path.resolve(__dirname, '..');
const app = fs.readFileSync(path.join(root, 'prototype-src/app.js'), 'utf8');
const overlays = fs.readFileSync(path.join(root, 'prototype-src/overlays.html'), 'utf8');
const shell = fs.readFileSync(path.join(root, 'prototype-src/shell.html'), 'utf8');
function extractFunction(source, name) {
const start = source.indexOf(`function ${name}(`);
assert.notEqual(start, -1, `缺少 ${name} 函数`);
const bodyStart = source.indexOf('{', start);
let depth = 0;
let quote = '';
let escaped = false;
for (let index = bodyStart; index < source.length; index += 1) {
const char = source[index];
if (quote) {
if (escaped) escaped = false;
else if (char === '\\') escaped = true;
else if (char === quote) quote = '';
continue;
}
if (char === "'" || char === '"' || char === '`') {
quote = char;
continue;
}
if (char === '{') depth += 1;
if (char === '}') {
depth -= 1;
if (depth === 0) return source.slice(start, index + 1);
}
}
throw new Error(`${name} 函数未正确闭合`);
}
assert.match(app, /key:'loadingPhotos',label:'装车照片',accept:'\.jpg,\.jpeg,\.png,\.webp'/, '缺少装车照片资料类型');
assert.match(app, /key:'courierReceiptScreenshots',label:'快递签收截图',accept:'\.jpg,\.jpeg,\.png,\.webp'/, '缺少快递签收截图资料类型');
assert.match(overlays, /id="dispatch-docs-summary">0\/7 已上传<\/strong>/, '资料弹窗初始汇总未更新为 7 类');
assert.match(app, /dispatchDocTypes\.length/, '资料摘要未使用资料类型总数动态计算');
const alertStateSource = extractFunction(app, 'getCourierAlertState');
assert.match(alertStateSource, /48\s*\*\s*60\s*\*\s*60\s*\*\s*1000/, '异常阈值不是完整 48 小时');
const context = { Date, Number, String, Boolean, Array };
vm.createContext(context);
vm.runInContext(`${alertStateSource};this.getCourierAlertState=getCourierAlertState;`, context);
const now = new Date('2026-08-13T12:00:00+08:00');
const baseRecord = {
status: '派送中',
expressNo: 'UPS-ALERT-001',
outboundAt: '2026-08-11 12:00:00',
courierNodes: []
};
assert.equal(context.getCourierAlertState(baseRecord, now).anomalous, true, '满 48 小时无节点应判定异常');
assert.equal(context.getCourierAlertState({...baseRecord, outboundAt: '2026-08-11 12:00:01'}, now).anomalous, false, '不足 48 小时不应判定异常');
assert.equal(context.getCourierAlertState({...baseRecord, courierNodes: [{time: '2026-08-12 08:00'}]}, now).anomalous, false, '已有任意快递节点不应判定异常');
assert.equal(context.getCourierAlertState({...baseRecord, status: '已签收'}, now).anomalous, false, '已签收不应保持异常');
assert.equal(context.getCourierAlertState({...baseRecord, expressNo: ''}, now).anomalous, false, '无快递单号不参与异常判定');
assert.equal(context.getCourierAlertState({...baseRecord, outboundAt: ''}, now).anomalous, false, '无明确出库时间不参与异常判定');
const createMessageSource = extractFunction(app, 'createCourierAlertMessage');
const formatTimeSource = extractFunction(app, 'formatCourierAlertTime');
const syncSource = extractFunction(app, 'syncCourierAlert');
const waybillSource = extractFunction(app, 'renderDispatchWaybillCell');
assert.match(createMessageSource, /msgManageData\.unshift/, '首次异常未写入消息管理');
assert.match(createMessageSource, /msgMyData\.unshift/, '首次异常未写入我的消息');
assert.match(createMessageSource, /type:'异常告警'/, '消息管理类型不是异常告警');
assert.match(createMessageSource, /target:'操作员'/, '消息管理接收对象不是操作员');
assert.match(createMessageSource, /status:'未读'/, '我的消息未标记为未读');
assert.match(createMessageSource, /暂无快递节点/, '内部提醒未说明暂无快递节点');
assert.doesNotMatch(createMessageSource, /短信|SMS/i, '快递异常提醒不应发送短信');
assert.match(syncSource, /courierAlertedAt/, '缺少异常提醒时间去重字段');
assert.match(syncSource, /courierAlertStatus=['"]异常['"]/, '缺少异常状态写入');
assert.match(syncSource, /courierAlertStatus=['"]已恢复['"]/, '缺少已恢复状态写入');
assert.match(syncSource, /courierRecoveredAt/, '缺少异常恢复时间');
assert.match(waybillSource, /row\.cells\[4\]/, '异常未应用到现有运单号单元格');
assert.match(waybillSource, /出库已满48小时,暂未查询到快递节点/, '缺少确认的悬停提示');
assert.match(waybillSource, /dispatch-waybill-alert/, '缺少异常运单号样式类');
assert.match(app, /renderDispatchWaybillCell\(record,row\)/, '静态和动态派送行未统一调用异常渲染');
assert.match(app, /expressNo:order\.expressNo\|\|''/, '动态派送未仅复制明确快递单号');
assert.match(app, /outboundAt:order\.outboundAt\|\|''/, '动态派送未仅复制明确出库时间');
assert.match(app, /status:'派送中'[^\n]+outboundAt:'2026-08-12 08:00:00',courierNodes:\[\]/, '静态样例缺少未满 48 小时且无节点的派送单');
assert.match(shell, /\.dispatch-waybill-alert\{[^}]*color:#dc2626!important/, '缺少异常运单号红色样式');
const alertRuntime = {
Date,
Number,
String,
Boolean,
Array,
msgManageData: [],
msgMyData: [],
lmoNow: () => '2026-08-13 12:00'
};
vm.createContext(alertRuntime);
vm.runInContext(
`${alertStateSource};${createMessageSource};${formatTimeSource};${syncSource};` +
'this.syncCourierAlert=syncCourierAlert;',
alertRuntime
);
const runtimeRecord = {...baseRecord, dispatchNo: 'DSP-ALERT-001'};
alertRuntime.syncCourierAlert(runtimeRecord, now);
alertRuntime.syncCourierAlert(runtimeRecord, now);
assert.equal(alertRuntime.msgManageData.length, 1, '持续异常重复渲染不应重复写入消息管理');
assert.equal(alertRuntime.msgMyData.length, 1, '持续异常重复渲染不应重复写入我的消息');
assert.equal(runtimeRecord.courierAlertStatus, '异常', '首次异常未记录异常状态');
assert.equal(runtimeRecord.courierAlertedAt, '2026-08-13 12:00', '首次异常未记录提醒时间');
runtimeRecord.courierNodes.push({time: '2026-08-13 12:10', status: '已揽收'});
alertRuntime.syncCourierAlert(runtimeRecord, new Date('2026-08-13T12:15:00+08:00'));
assert.equal(runtimeRecord.courierAlertStatus, '已恢复', '出现快递节点后未记录已恢复');
assert.equal(runtimeRecord.courierRecoveredAt, '2026-08-13 12:15', '出现快递节点后未记录恢复时间');
assert.equal(alertRuntime.msgManageData.length, 1, '异常恢复不应重复写入消息管理');
assert.equal(alertRuntime.msgMyData.length, 1, '异常恢复不应重复写入我的消息');
console.log('DISPATCH_DOCS_COURIER_ALERT_CHECK_PASS');
@@ -0,0 +1,111 @@
#!/usr/bin/env node
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const root = path.resolve(__dirname, '..');
const overlays = fs.readFileSync(path.join(root, 'prototype-src/overlays.html'), 'utf8');
const general = fs.readFileSync(path.join(root, 'prototype-src/modules/10-general.html'), 'utf8');
const app = fs.readFileSync(path.join(root, 'prototype-src/app.js'), 'utf8');
const shell = fs.readFileSync(path.join(root, 'prototype-src/shell.html'), 'utf8');
function extractFunction(source, name) {
const start = source.indexOf(`function ${name}(`);
assert.notEqual(start, -1, `缺少 ${name} 函数`);
const bodyStart = source.indexOf('{', start);
let depth = 0;
let quote = '';
let escaped = false;
for (let index = bodyStart; index < source.length; index += 1) {
const char = source[index];
if (quote) {
if (escaped) escaped = false;
else if (char === '\\') escaped = true;
else if (char === quote) quote = '';
continue;
}
if (char === "'" || char === '"' || char === '`') {
quote = char;
continue;
}
if (char === '{') depth += 1;
if (char === '}') {
depth -= 1;
if (depth === 0) return source.slice(start, index + 1);
}
}
throw new Error(`${name} 函数未正确闭合`);
}
assert.match(overlays, /id="ef-current-vehicle"/, '编辑车队弹窗缺少当前车辆摘要');
assert.match(overlays, /id="ef-vehicle-id"[^>]*onchange="updateEditFleetReasonState\(\)"/, '编辑车队弹窗缺少外派车辆下拉框');
assert.match(overlays, /id="ef-change-reason"[^>]*maxlength="200"/, '编辑车队弹窗缺少更换原因字段或长度限制');
assert.match(overlays, /id="ef-change-reason-hint"/, '编辑车队弹窗缺少原因规则提示');
assert.doesNotMatch(overlays, /id="ef-fleet-name"|id="ef-fleet-phone"|id="ef-fleet-email"/, '编辑车队弹窗仍保留自由输入字段');
const dispatchPanelStart = general.indexOf('id="panel-vehicle-dispatch"');
const dispatchPanelEnd = general.indexOf('<div class="pagination">', dispatchPanelStart);
const dispatchPanel = general.slice(dispatchPanelStart, dispatchPanelEnd);
assert.match(dispatchPanel, /<th[^>]*>当前派送单<\/th>/, '外派车辆表格缺少当前派送单列');
assert.match(dispatchPanel, /<th[^>]*>最近更换原因<\/th>/, '外派车辆表格缺少最近更换原因列');
assert.match(general, /id="page-vehicle-detail"/, '车辆详情缺少独立页面');
assert.match(general, /id="vehicle-detail-history-body"/, '车辆详情缺少派送更换记录表体');
const ensureSource = extractFunction(app, 'ensureFleetSyncState');
const labelSource = extractFunction(app, 'getFleetVehicleLabel');
const applySource = extractFunction(app, 'applyDispatchFleetChange');
const runtime = {Array, Object};
vm.createContext(runtime);
vm.runInContext(
`${ensureSource};${labelSource};${applySource};` +
'this.applyDispatchFleetChange=applyDispatchFleetChange;',
runtime
);
const dispatch = {dispatchNo: 'DSP001', bolNo: 'BOL001'};
const vehicleA = {id: 'VD1', plate: '沪A10001', driver: '顺通物流', phone: '021-10001'};
const vehicleB = {id: 'VD2', plate: '浙B20002', driver: '安捷运输', phone: '0571-20002'};
const first = runtime.applyDispatchFleetChange(dispatch, null, vehicleA, '', '2026-08-14 10:00', '张经理');
assert.equal(first.type, 'initial', '首次分配未返回 initial');
assert.equal(dispatch.fleetVehicleId, 'VD1', '首次分配未写入派送单当前车辆');
assert.equal(dispatch.fleetVehiclePlate, '沪A10001', '首次分配未写入车牌快照');
assert.equal(vehicleA.currentDispatchNo, 'DSP001', '首次分配未同步车辆当前派送单');
assert.equal(vehicleA.lastFleetChangeReason, '首次分配', '首次分配未写入车辆摘要原因');
assert.equal(dispatch.fleetChangeHistory.length, 1, '首次分配未写入派送历史');
assert.equal(vehicleA.fleetChangeHistory[0].action, '首次分配', '首次分配未写入车辆历史');
const changed = runtime.applyDispatchFleetChange(dispatch, vehicleA, vehicleB, '原车辆临时维修', '2026-08-14 11:00', '张经理');
assert.equal(changed.type, 'changed', '车辆更换未返回 changed');
assert.equal(dispatch.fleetVehicleId, 'VD2', '车辆更换未更新派送单当前车辆');
assert.equal(dispatch.fleetChangeReason, '原车辆临时维修', '车辆更换未写入最近原因');
assert.equal(vehicleA.currentDispatchNo, '', '车辆更换未释放原车辆');
assert.equal(vehicleB.currentDispatchNo, 'DSP001', '车辆更换未关联新车辆');
assert.equal(dispatch.fleetChangeHistory.length, 2, '车辆更换未追加派送历史');
assert.equal(vehicleA.fleetChangeHistory[0].action, '更换离开', '原车辆历史动作错误');
assert.equal(vehicleB.fleetChangeHistory[0].action, '更换接入', '新车辆历史动作错误');
assert.equal(vehicleB.fleetChangeHistory[0].reason, '原车辆临时维修', '新车辆历史缺少更换原因');
const unchanged = runtime.applyDispatchFleetChange(dispatch, vehicleB, vehicleB, '不会写入', '2026-08-14 12:00', '张经理');
assert.equal(unchanged.type, 'unchanged', '重复保存同一车辆未返回 unchanged');
assert.equal(dispatch.fleetChangeHistory.length, 2, '重复保存同一车辆产生了重复派送历史');
assert.equal(vehicleB.fleetChangeHistory.length, 1, '重复保存同一车辆产生了重复车辆历史');
assert.match(app, /function\s+getDispatchRecordByBolNo\s*\(/, '缺少通过 BOL 查找派送单的函数');
assert.match(app, /function\s+updateEditFleetReasonState\s*\(/, '缺少更换原因状态更新函数');
assert.match(app, /function\s+renderDispatchFleetCells\s*\(/, '缺少派送列表车辆回显函数');
assert.match(app, /row\.cells\[11\]/, '派送列表未更新车牌单元格');
assert.match(app, /row\.cells\[12\]/, '派送列表未更新司机或外派单位单元格');
assert.match(app, /请填写更换原因/, '车辆更换缺少原因必填校验');
assert.match(app, /车队信息已保存并同步到外派车辆/, '缺少同步成功提示');
assert.match(app, /currentDispatchNo/, '外派车辆未保存当前派送单');
assert.match(app, /lastFleetChangeReason/, '外派车辆未保存最近更换原因');
assert.match(app, /function\s+renderVehicleFleetHistory\s*\(/, '缺少车辆更换历史渲染函数');
assert.match(app, /暂无派送更换记录/, '车辆详情缺少历史空态');
assert.match(app, /vehicle-fleet-reason/, '外派车辆列表未渲染原因省略样式');
assert.match(shell, /\.edit-fleet-current/, '缺少当前车辆摘要样式');
assert.match(shell, /\.vehicle-detail-table/, '缺少车辆详情表格样式');
console.log('DISPATCH_FLEET_CHANGE_SYNC_CHECK_PASS');
@@ -1,7 +1,9 @@
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.resolve(__dirname, '..', '货代SaaS平台原型.html'), 'utf8');
const root = path.resolve(__dirname, '..');
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'prototype-src', 'manifest.json'), 'utf8'));
const html = fs.readFileSync(path.join(root, manifest.source), 'utf8');
const failures = [];
function getPageSource(pageId) {
@@ -0,0 +1,55 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const root = path.resolve(__dirname, '..');
const moduleHtml = fs.readFileSync(path.join(root, 'prototype-src/modules/04-overseas-delivery.html'), 'utf8');
const overlays = fs.readFileSync(path.join(root, 'prototype-src/overlays.html'), 'utf8');
const app = fs.readFileSync(path.join(root, 'prototype-src/app.js'), 'utf8');
for (const status of ['待派送', '已取消', '拒签']) {
assert.match(moduleHtml, new RegExp(`<option>${status}</option>`), `状态筛选缺少${status}`);
}
assert.match(overlays, /id="modal-dispatch-cancel"/, '缺少取消派送弹窗');
assert.match(overlays, /id="dispatch-cancel-reason"/, '缺少取消原因字段');
assert.match(overlays, /id="dispatch-cancel-picking"/, '取消弹窗未展示拣货状态');
assert.match(app, /function\s+normalizeDispatchStatus\s*\(/, '缺少派送状态兼容函数');
assert.match(app, /function\s+hasDispatchPicking\s*\(/, '缺少拣货判断函数');
assert.match(app, /function\s+getDispatchCancelError\s*\(/, '缺少取消条件校验函数');
assert.match(app, /function\s+openDispatchCancel\s*\(/, '缺少打开取消弹窗函数');
assert.match(app, /function\s+confirmDispatchCancel\s*\(/, '缺少取消提交函数');
assert.match(app, /dispatch-cancel-action/, '派送操作列缺少取消操作');
assert.match(app, /cancellationReason/, '取消结果未记录取消原因');
assert.match(app, /canceledAt/, '取消结果未记录取消时间');
assert.match(app, /canceledBy/, '取消结果未记录操作人');
function extractFunction(name) {
const start = app.indexOf(`function ${name}(`);
assert.notEqual(start, -1, `未找到函数 ${name}`);
const bodyStart = app.indexOf('{', start);
let depth = 0;
for (let index = bodyStart; index < app.length; index += 1) {
if (app[index] === '{') depth += 1;
if (app[index] === '}') depth -= 1;
if (depth === 0) return app.slice(start, index + 1);
}
throw new Error(`函数 ${name} 未闭合`);
}
const context = {};
vm.runInNewContext([
extractFunction('normalizeDispatchStatus'),
extractFunction('hasDispatchPicking'),
extractFunction('getDispatchCancelError'),
'result = { normalizeDispatchStatus, hasDispatchPicking, getDispatchCancelError };',
].join('\n'), context);
assert.equal(context.result.normalizeDispatchStatus('已拒收'), '拒签', '历史拒收状态未兼容为拒签');
assert.equal(context.result.hasDispatchPicking({pickingStatus: '未拣货'}), false, '未拣货不应阻止取消');
assert.equal(context.result.hasDispatchPicking({pickingStatus: '已拣货'}), true, '已拣货必须阻止取消');
assert.equal(context.result.getDispatchCancelError({status: '待派送', pickingStatus: '未拣货'}), '', '未拣货待派送订单应允许取消');
assert.match(context.result.getDispatchCancelError({status: '待派送', pickingStatus: '拣货中'}), /已开始拣货/, '拣货中订单未被阻止');
assert.match(context.result.getDispatchCancelError({status: '派送中', pickingStatus: '未拣货'}), /仅待派送/, '非待派送订单未被阻止');
console.log('派送状态与取消规则验证通过');
@@ -0,0 +1,97 @@
#!/usr/bin/env node
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
const moduleHtml = fs.readFileSync(path.join(root, 'prototype-src', 'modules', '02-domestic-warehouse.html'), 'utf8');
const overlays = fs.readFileSync(path.join(root, 'prototype-src', 'overlays.html'), 'utf8');
const app = fs.readFileSync(path.join(root, 'prototype-src', 'app.js'), 'utf8');
const generatedIndex = fs.readFileSync(path.join(root, 'index.html'), 'utf8');
const failures = [];
function expect(condition, message) {
if (!condition) failures.push(message);
}
function functionSource(name, source = app) {
const match = new RegExp(`function\\s+${name}\\s*\\([^)]*\\)\\s*\\{`).exec(source);
if (!match) return '';
const start = source.indexOf('{', match.index);
let depth = 0;
let quote = '';
let escaped = false;
for (let index = start; index < source.length; index += 1) {
const char = source[index];
if (quote) {
if (escaped) escaped = false;
else if (char === '\\') escaped = true;
else if (char === quote) quote = '';
continue;
}
if (char === "'" || char === '"' || char === '`') {
quote = char;
continue;
}
if (char === '{') depth += 1;
if (char === '}' && --depth === 0) return source.slice(match.index, index + 1);
}
return '';
}
expect(/<input\b(?=[^>]*\bname="boxChargeFlag")(?=[^>]*\bvalue="1")[^>]*>/i.test(moduleHtml), '详情“其他”缺少箱子计费“是”选项');
expect(/<input\b(?=[^>]*\bname="boxChargeFlag")(?=[^>]*\bvalue="0")(?=[^>]*\bchecked\b)[^>]*>/i.test(moduleHtml), '详情“其他”缺少默认选中的箱子计费“否”选项');
expect(/onclick="openDomesticInboundFeeModal\('pickup'\)"/.test(moduleHtml), '详情费用信息缺少取货费新增入口');
expect(/id="domestic-inbound-fee-body"/.test(moduleHtml), '详情费用信息缺少费用明细容器');
expect(/快递订单/.test(moduleHtml), '国内仓订单列表缺少快递订单');
expect(/填写快递费/.test(moduleHtml), '快递订单缺少填写快递费入口');
expect(/id="modal-domestic-inbound-fee"/.test(overlays), '缺少国内仓入库费用弹窗');
expect(/id="domestic-fee-voucher"/.test(overlays), '国内仓入库费用弹窗缺少付款凭证字段');
expect(/function\s+buildDomesticInboundFeeRecord\s*\(/.test(app), '缺少 buildDomesticInboundFeeRecord 函数');
expect(/function\s+renderDomesticInboundFees\s*\(/.test(app), '缺少 renderDomesticInboundFees 函数');
expect(/function\s+saveDomesticInboundFee\s*\(/.test(app), '缺少 saveDomesticInboundFee 函数');
expect(/function\s+syncDomesticInboundDetailOrderFromListClick\s*\(/.test(app), '普通订单详情入口缺少订单上下文同步');
expect(/addEventListener\('click',syncDomesticInboundDetailOrderFromListClick,true\)/.test(app), '订单上下文同步未在跳转前执行');
expect(/箱子是否计费/.test(app), '脚本缺少箱子是否计费处理');
expect(/取货费必须上传付款凭证/.test(app), '脚本缺少取货费付款凭证必填校验');
const feePanel = /<div id="detail-fee"[\s\S]*?<\/table>/.exec(moduleHtml)?.[0] || '';
expect((feePanel.match(/<th(?:\s|>)/g) || []).length === 28, '费用明细表头不是 28 列');
expect(/colspan="28"/.test(functionSource('renderDomesticInboundFees')), '费用明细空态列数与表头不一致');
expect(/if\(input\)input\.value='';/.test(functionSource('removeDomesticFeeVoucher')), '付款凭证移除时未重置文件输入框');
expect(generatedIndex.includes('function syncDomesticInboundDetailOrderFromListClick'), 'index.html 未包含最新订单上下文同步逻辑');
expect(generatedIndex.includes('id="modal-domestic-inbound-fee"'), 'index.html 未包含费用弹窗');
const buildFeeSource = functionSource('buildDomesticInboundFeeRecord');
if (buildFeeSource) {
try {
const buildDomesticInboundFeeRecord = new Function(`return (${buildFeeSource});`)();
assert.deepEqual(buildDomesticInboundFeeRecord(
{ type: '取货费', amount: '88.50', currency: 'CNY', voucherName: 'pickup.pdf', remark: '园区提货' },
{ orderCode: 'PCKK2606151065' },
'2026-08-14 16:30:00'
), {
orderCode: 'PCKK2606151065',
type: '取货费',
amount: 88.5,
currency: 'CNY',
voucherName: 'pickup.pdf',
remark: '园区提货',
auditStatus: '待审核',
businessDate: '2026-08-14',
});
} catch (error) {
failures.push(`费用记录构建行为失败:${error.message}`);
}
}
if (failures.length) {
console.error('DOMESTIC_ORDER_FEES_CHECK_FAIL');
failures.forEach(message => console.error(`- ${message}`));
process.exitCode = 1;
} else {
console.log('DOMESTIC_ORDER_FEES_CHECK_PASS');
}
@@ -0,0 +1,173 @@
#!/usr/bin/env node
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
const moduleHtml = fs.readFileSync(path.join(root, 'prototype-src', 'modules', '02-domestic-warehouse.html'), 'utf8');
const appJs = fs.readFileSync(path.join(root, 'prototype-src', 'app.js'), 'utf8');
const agentsMd = fs.readFileSync(path.join(root, 'AGENTS.md'), 'utf8');
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'prototype-src', 'manifest.json'), 'utf8'));
const failures = [];
function escapeRegExp(value) {
return value.replace(/[.*+?^\${}()|[\]\\]/g, '\\$&');
}
function requireMatch(pattern, label, source) {
if (!pattern.test(source)) failures.push('missing ' + label);
}
function findClosingBrace(source, openingIndex) {
let depth = 0;
let quote = '';
let lineComment = false;
let blockComment = false;
for (let index = openingIndex; index < source.length; index += 1) {
const character = source[index];
const next = source[index + 1];
if (lineComment) {
if (character === '\n' || character === '\r') lineComment = false;
continue;
}
if (blockComment) {
if (character === '*' && next === '/') { blockComment = false; index += 1; }
continue;
}
if (quote) {
if (character === '\\') index += 1;
else if (character === quote) quote = '';
continue;
}
if (character === '/' && next === '/') { lineComment = true; index += 1; continue; }
if (character === '/' && next === '*') { blockComment = true; index += 1; continue; }
if (character === "'" || character === '"' || character === '\x60') { quote = character; continue; }
if (character === '{') depth += 1;
if (character === '}') {
depth -= 1;
if (depth === 0) return index;
}
}
return -1;
}
function getFunctionSource(functionName) {
const pattern = new RegExp('function\\s+' + escapeRegExp(functionName) + '\\s*\\([^)]*\\)\\s*\\{');
const declaration = pattern.exec(appJs);
if (!declaration) return '';
const openingBrace = appJs.indexOf('{', declaration.index);
const closingBrace = findClosingBrace(appJs, openingBrace);
return closingBrace < 0 ? '' : appJs.slice(declaration.index, closingBrace + 1);
}
function getPageSource(pageId) {
const marker = 'id="page-' + pageId + '"';
const start = moduleHtml.indexOf(marker);
if (start < 0) return '';
const opening = moduleHtml.lastIndexOf('<div class="page"', start);
const next = moduleHtml.indexOf('<div class="page"', start + marker.length);
return moduleHtml.slice(opening, next < 0 ? moduleHtml.length : next);
}
const listPage = getPageSource('pallet-manage');
const detailPage = getPageSource('pallet-detail');
requireMatch(/<th[^>]*>\s*采购价格\s*<\/th>/, '列表采购价格列', listPage);
requireMatch(/<input\b(?=[^>]*\bid=["']pallet-purchase-price-in["'])(?=[^>]*\btype=["']number["'])(?=[^>]*\bmin=["']0["'])(?=[^>]*\bstep=["']0\.01["'])[^>]*>/i, '采购价格非负两位小数输入框', listPage);
requireMatch(/采购价格(元)\s*\*/, '采购价格必填标签', listPage);
requireMatch(/id=["']page-pallet-detail["']/, '国内仓托盘详情页', detailPage);
requireMatch(/id=["']pallet-detail-no["']/, '详情托盘编码节点', detailPage);
requireMatch(/id=["']pallet-detail-status["']/, '详情状态节点', detailPage);
requireMatch(/id=["']pallet-detail-content["']/, '详情内容节点', detailPage);
requireMatch(/backToPalletManage\(\)/, '详情返回入口', detailPage);
const domesticModule = manifest.modules.find((item) => item.file === 'modules/02-domestic-warehouse.html');
if (!manifest.pageOrder.includes('pallet-detail')) failures.push('missing manifest.pageOrder 中的 pallet-detail');
if (!domesticModule || !domesticModule.pages.includes('pallet-detail')) failures.push('missing 国内仓模块 pages 中的 pallet-detail');
requireMatch(/国内仓[^\n]*托盘及详情/, 'AGENTS.md 国内仓托盘详情索引', agentsMd);
const palletDeclaration = /const overseasPalletData=\[[\s\S]*?\];/.exec(appJs);
const stateDeclaration = /let editingPalletIdx=-1;/.exec(appJs);
const functionNames = ['renderOverseasPallet', 'openPalletModal', 'savePallet', 'openPalletDetail', 'backToPalletManage'];
const functionSources = functionNames.map((name) => {
const source = getFunctionSource(name);
if (!source) failures.push('missing ' + name + ' 函数');
return source;
}).filter(Boolean).join('\n');
if (!palletDeclaration) failures.push('missing overseasPalletData 数据');
if (!stateDeclaration) failures.push('missing editingPalletIdx 状态');
requireMatch(/purchasePrice\s*:/, '托盘 purchasePrice 示例数据', appJs);
requireMatch(/Number\(d\.purchasePrice\)\.toFixed\(2\)/, '列表采购价格两位小数格式', getFunctionSource('renderOverseasPallet'));
requireMatch(/openPalletDetail\(/, '列表详情入口', getFunctionSource('renderOverseasPallet'));
requireMatch(/pallet-purchase-price-in/, '编辑采购价格回填', getFunctionSource('openPalletModal'));
requireMatch(/请填写有效的采购价格/, '采购价格保存校验提示', getFunctionSource('savePallet'));
if (!failures.length) {
try {
const ids = ['tbody-pallet-manage','pallet-total','pallet-modal-title','pallet-code-in','pallet-type-in','pallet-vol-in','pallet-weight-in','pallet-purchase-price-in','pallet-status-in','pallet-remark-in','pallet-detail-no','pallet-detail-status','pallet-detail-content'];
const elements = Object.fromEntries(ids.map((id) => {
let value = '';
return [id, {
get value() { return value; },
set value(nextValue) { value = String(nextValue); },
textContent: '',
innerHTML: '',
className: '',
}];
}));
const modalCalls = [];
const hiddenModals = [];
const toasts = [];
const pageCalls = [];
const runtimeSource = palletDeclaration[0] + '\n' + stateDeclaration[0] + '\n' + functionSources + '\nreturn {data:overseasPalletData,renderOverseasPallet,openPalletModal,savePallet,openPalletDetail,backToPalletManage};';
const runtime = new Function('document','showModal','hideModal','showToast','switchPage',runtimeSource)(
{getElementById:(id)=>elements[id]||null},
(id)=>modalCalls.push(id),
(id)=>hiddenModals.push(id),
(message,type)=>toasts.push([message,type]),
(pageId)=>pageCalls.push(pageId),
);
runtime.data[0].purchasePrice = 120;
runtime.renderOverseasPallet([runtime.data[0]]);
assert.match(elements['tbody-pallet-manage'].innerHTML, /¥120\.00/);
assert.match(elements['tbody-pallet-manage'].innerHTML, /openPalletDetail\(['"]TP-001['"]\)/);
runtime.openPalletModal(0);
assert.equal(String(elements['pallet-purchase-price-in'].value), '120');
elements['pallet-purchase-price-in'].value = '-0.01';
runtime.savePallet();
assert.deepEqual(toasts.at(-1), ['请填写有效的采购价格','warning']);
assert.equal(hiddenModals.length, 0);
elements['pallet-purchase-price-in'].value = '88.6';
runtime.savePallet();
assert.equal(runtime.data[0].purchasePrice, 88.6);
assert.deepEqual(hiddenModals, ['modal-pallet']);
runtime.openPalletDetail('TP-001');
assert.equal(elements['pallet-detail-no'].textContent, 'TP-001');
assert.match(elements['pallet-detail-content'].innerHTML, /采购价格/);
assert.match(elements['pallet-detail-content'].innerHTML, /¥88\.60/);
assert.equal(pageCalls.at(-1), 'pallet-detail');
runtime.openPalletDetail('NOT-FOUND');
assert.deepEqual(toasts.at(-1), ['未找到托盘记录','error']);
assert.equal(pageCalls.at(-1), 'pallet-detail');
runtime.backToPalletManage();
assert.equal(pageCalls.at(-1), 'pallet-manage');
runtime.openPalletModal(-1);
assert.equal(elements['pallet-purchase-price-in'].value, '');
} catch (error) {
failures.push('采购价格行为错误:' + error.message);
}
}
if (failures.length) {
console.error('DOMESTIC_PALLET_PURCHASE_PRICE_CHECK_FAIL');
failures.forEach((failure) => console.error('- ' + failure));
process.exitCode = 1;
} else {
console.log('DOMESTIC_PALLET_PURCHASE_PRICE_CHECK_PASS');
}
@@ -0,0 +1,27 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
const modulePath = path.join(root, 'prototype-src/modules/04-overseas-delivery.html');
const appPath = path.join(root, 'prototype-src/app.js');
const overlaysPath = path.join(root, 'prototype-src/overlays.html');
const shellPath = path.join(root, 'prototype-src/shell.html');
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'prototype-src/manifest.json'), 'utf8'));
const moduleEntry = manifest.modules.find(item => item.file === 'modules/04-overseas-delivery.html');
const html = fs.readFileSync(modulePath, 'utf8');
const app = fs.readFileSync(appPath, 'utf8');
const overlays = fs.readFileSync(overlaysPath, 'utf8');
const shell = fs.readFileSync(shellPath, 'utf8');
assert.ok(moduleEntry.pages.includes('outbound-exception'), 'manifest 未登记异常订单页面');
assert.match(html, /id="page-outbound-exception"/, '海外派送模块缺少异常订单页面');
assert.match(shell, /switchPage\('outbound-exception',this\)/, '侧栏缺少异常订单入口');
assert.match(overlays, /id="modal-dispatch-return"/, '缺少退货处理弹窗');
assert.match(app, /function openDispatchReturn\(/, '缺少打开退货弹窗函数');
assert.match(app, /function confirmDispatchReturn\(/, '缺少保存退货处理函数');
assert.match(app, /function renderDispatchExceptions\(/, '缺少异常订单渲染函数');
assert.match(app, /abnormalType|returnStatus|rejectionReason/, '派送数据缺少异常字段');
assert.match(html, /退货|拒收/, '异常订单页缺少退货或拒收处理文案');
console.log('异常订单契约验证通过');
@@ -0,0 +1,307 @@
#!/usr/bin/env node
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
const moduleHtml = fs.readFileSync(path.join(root, 'prototype-src/modules/03-overseas-warehouse.html'), 'utf8');
const overlaysHtml = fs.readFileSync(path.join(root, 'prototype-src/overlays.html'), 'utf8');
const appJs = fs.readFileSync(path.join(root, 'prototype-src/app.js'), 'utf8');
const failures = [];
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// Find the matching brace while treating strings and comments as opaque text.
function findClosingBrace(source, openingIndex) {
let depth = 0;
let quote = '';
let lineComment = false;
let blockComment = false;
for (let index = openingIndex; index < source.length; index += 1) {
const character = source[index];
const next = source[index + 1];
if (lineComment) {
if (character === '\n' || character === '\r') lineComment = false;
continue;
}
if (blockComment) {
if (character === '*' && next === '/') {
blockComment = false;
index += 1;
}
continue;
}
if (quote) {
if (character === '\\') index += 1;
else if (character === quote) quote = '';
continue;
}
if (character === '/' && next === '/') {
lineComment = true;
index += 1;
continue;
}
if (character === '/' && next === '*') {
blockComment = true;
index += 1;
continue;
}
if (character === "'" || character === '"' || character === '`') {
quote = character;
continue;
}
if (character === '{') depth += 1;
if (character === '}') {
depth -= 1;
if (depth === 0) return index;
}
}
return -1;
}
function findStatementEnd(source, startIndex) {
let quote = '';
let lineComment = false;
let blockComment = false;
let parenDepth = 0;
let bracketDepth = 0;
let braceDepth = 0;
for (let index = startIndex; index < source.length; index += 1) {
const character = source[index];
const next = source[index + 1];
if (lineComment) {
if (character === '\n' || character === '\r') lineComment = false;
continue;
}
if (blockComment) {
if (character === '*' && next === '/') {
blockComment = false;
index += 1;
}
continue;
}
if (quote) {
if (character === '\\') index += 1;
else if (character === quote) quote = '';
continue;
}
if (character === '/' && next === '/') {
lineComment = true;
index += 1;
continue;
}
if (character === '/' && next === '*') {
blockComment = true;
index += 1;
continue;
}
if (character === "'" || character === '"' || character === '`') {
quote = character;
continue;
}
if (character === '(') parenDepth += 1;
else if (character === ')') parenDepth -= 1;
else if (character === '[') bracketDepth += 1;
else if (character === ']') bracketDepth -= 1;
else if (character === '{') braceDepth += 1;
else if (character === '}') braceDepth -= 1;
else if (character === ';' && parenDepth === 0 && bracketDepth === 0 && braceDepth === 0) return index;
}
return -1;
}
function getFunctionSource(name, source = appJs) {
const declaration = new RegExp(`(?:async\\s+)?function\\s+${escapeRegExp(name)}\\s*\\([^)]*\\)\\s*\\{`).exec(source);
if (!declaration) return '';
const openingBrace = source.indexOf('{', declaration.index);
const closingBrace = findClosingBrace(source, openingBrace);
return closingBrace < 0 ? '' : source.slice(declaration.index, closingBrace + 1);
}
function getDeclarationSource(name, source = appJs) {
const declaration = new RegExp(`(?:const|let|var)\\s+${escapeRegExp(name)}\\s*=`).exec(source);
if (!declaration) return '';
const semicolon = findStatementEnd(source, declaration.index);
return semicolon < 0 ? '' : source.slice(declaration.index, semicolon + 1);
}
function requireMatch(pattern, label, source) {
try {
assert.match(source, pattern, label);
} catch (error) {
failures.push(error.message);
}
}
requireMatch(/onclick="openFbaCartonForm\(\)"/, '箱号新增按钮应打开表单', moduleHtml);
requireMatch(/onclick="exportFbaCartons\(\)"/, '箱号导出按钮应调用导出方法', moduleHtml);
requireMatch(/id="fbc-wh"[^>]*>[\s\S]*?<option>FBA IND9<\/option>/, '箱号仓库筛选应覆盖 FBA IND9', moduleHtml);
const modalStart = overlaysHtml.indexOf('<div class="modal-overlay" id="modal-fba-carton-form"');
const nextModalStart = modalStart < 0 ? -1 : overlaysHtml.indexOf('<div class="modal-overlay"', modalStart + 1);
const cartonModalHtml = modalStart < 0 ? '' : overlaysHtml.slice(modalStart, nextModalStart < 0 ? overlaysHtml.length : nextModalStart);
requireMatch(/id="modal-fba-carton-form"/, '应提供箱号新增编辑弹框', cartonModalHtml);
[
'fbc-form-title', 'fbc-form-carton', 'fbc-form-wh', 'fbc-form-sku',
'fbc-form-cn-name', 'fbc-form-qty', 'fbc-form-weight', 'fbc-form-pallet',
'fbc-form-inbound', 'fbc-form-outbound', 'fbc-form-status', 'fbc-form-remark',
].forEach((id) => requireMatch(new RegExp(`id="${id}"`), `表单缺少 ${id}`, cartonModalHtml));
requireMatch(/saveFbaCartonForm\(\)/, '应提供箱号保存入口', cartonModalHtml);
const functionNames = [
'getFbaCartonStatus', 'getFilteredFbaCartons', 'openFbaCartonForm',
'saveFbaCartonForm', 'exportFbaCartons',
];
const functionSources = {};
functionNames.forEach((name) => {
functionSources[name] = getFunctionSource(name);
if (!functionSources[name]) failures.push(`missing ${name} 函数`);
});
// Keep the edit-state declaration in the same lexical scope as the extracted functions.
const stateNames = ['fbaCartonEditingNo', 'fbaCartonEditingCode', 'fbaCartonEditingId'];
const stateSources = stateNames.map((name) => getDeclarationSource(name)).filter(Boolean);
const declaredStateNames = new Set(stateSources.map((source) => {
const match = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)/.exec(source);
return match && match[1];
}).filter(Boolean));
stateNames.forEach((name) => {
if (!declaredStateNames.has(name)) stateSources.push(`let ${name} = '';`);
});
// Exercise the parser against braces in strings and comments before using app.js.
const parserFixture = "function fixture(){const text='}'; /* } */ return {ok:true}; // }\n}";
assert.match(getFunctionSource('fixture', parserFixture), /return \{ok:true\};/);
assert.equal(getDeclarationSource('fixtureValue', "const fixtureValue = [';'];"), "const fixtureValue = [';'];");
if (!failures.length) {
try {
const elementIds = [
'fbc-form-title', 'fbc-form-carton', 'fbc-form-wh', 'fbc-form-sku',
'fbc-form-cn-name', 'fbc-form-qty', 'fbc-form-weight', 'fbc-form-pallet',
'fbc-form-inbound', 'fbc-form-outbound', 'fbc-form-status', 'fbc-form-remark',
'fbc-carton', 'fbc-wh', 'fbc-status',
];
const elements = Object.fromEntries(elementIds.map((id) => [id, {value: '', textContent: '', readOnly: false}]));
const cartons = [
{cartonNo: 'CTN-IN-001', fbaWh: 'FBA ONT8', sku: 'SKU-A1001', cnName: '在库商品', qty: 24, weight: 12, relatedOutboundNo: '—', createdAt: '2026-07-20 09:20', updatedAt: '2026-08-01 13:50', remark: '保留备注'},
{cartonNo: 'CTN-OUT-002', fbaWh: 'FBA CLT2', sku: 'SKU-B2002', cnName: '已出库商品', qty: 50, weight: 25, relatedOutboundNo: 'FBO20260722001', createdAt: '2026-07-21 09:20', updatedAt: '2026-08-02 13:50', remark: '—'},
{cartonNo: 'CTN-IN-003', fbaWh: 'FBA MEM1', sku: 'SKU-C3003', cnName: '另一在库商品', qty: 40, weight: 30, relatedOutboundNo: '', createdAt: '2026-07-22 09:20', updatedAt: '2026-08-03 13:50', remark: '—'},
];
const modalCalls = [];
const hideCalls = [];
const toasts = [];
const renderCalls = [];
const downloads = [];
const documentStub = {getElementById: (id) => elements[id] || null};
const runtime = new Function(
'document', 'showModal', 'hideModal', 'showToast', 'renderFbaCarton',
'downloadCsvFile', 'csvEscape', 'fbaCartonData',
`${stateSources.join('\n')}\n${Object.values(functionSources).join('\n')}\nreturn {${functionNames.join(',')}};`,
)(
documentStub,
(id) => modalCalls.push(id),
(id) => hideCalls.push(id),
(message, type) => toasts.push([message, type]),
(list) => renderCalls.push((list || []).slice()),
(filename, csv) => downloads.push({filename, csv}),
(value) => `"${String(value == null ? '' : value).replace(/"/g, '""')}"`,
cartons,
);
assert.equal(runtime.getFbaCartonStatus({relatedOutboundNo: '—'}), '在库');
assert.equal(runtime.getFbaCartonStatus({relatedOutboundNo: ''}), '在库');
assert.equal(runtime.getFbaCartonStatus({relatedOutboundNo: 'FBO-001'}), '已出库');
runtime.openFbaCartonForm('CTN-OUT-002');
assert.equal(toasts.at(-1)[1], 'warning');
assert.match(toasts.at(-1)[0], /出库|编辑/);
assert.deepEqual(modalCalls, []);
runtime.openFbaCartonForm('CTN-IN-001');
assert.equal(elements['fbc-form-title'].textContent, '编辑箱号');
assert.equal(elements['fbc-form-carton'].value, 'CTN-IN-001');
assert.equal(elements['fbc-form-carton'].readOnly, true);
assert.deepEqual(modalCalls, ['modal-fba-carton-form']);
const original = cartons[0];
const originalCreatedAt = original.createdAt;
const previousUpdatedAt = original.updatedAt;
const renderCountBeforeSave = renderCalls.length;
elements['fbc-form-cn-name'].value = '更新后的商品';
elements['fbc-form-qty'].value = '88';
elements['fbc-form-weight'].value = '44.5';
runtime.saveFbaCartonForm();
assert.equal(original.cnName, '更新后的商品');
assert.equal(original.qty, 88);
assert.equal(original.weight, 44.5);
assert.equal(original.createdAt, originalCreatedAt);
assert.match(original.updatedAt, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/);
assert.notEqual(original.updatedAt, previousUpdatedAt);
assert.deepEqual(hideCalls, ['modal-fba-carton-form']);
assert.ok(renderCalls.length > renderCountBeforeSave, '保存后应重绘箱号列表');
runtime.openFbaCartonForm();
elements['fbc-form-carton'].value = 'CTN-IN-001';
elements['fbc-form-wh'].value = 'FBA ONT8';
elements['fbc-form-sku'].value = 'SKU-A1001';
elements['fbc-form-cn-name'].value = '重复箱号';
elements['fbc-form-qty'].value = '1';
elements['fbc-form-weight'].value = '1';
const cartonCountBeforeDuplicate = cartons.length;
const renderCountBeforeDuplicate = renderCalls.length;
runtime.saveFbaCartonForm();
assert.equal(toasts.at(-1)[1], 'warning');
assert.match(toasts.at(-1)[0], /存在|重复/);
assert.equal(hideCalls.length, 1);
assert.equal(cartons.length, cartonCountBeforeDuplicate);
assert.equal(renderCalls.length, renderCountBeforeDuplicate);
elements['fbc-carton'].value = 'OUT-002';
elements['fbc-wh'].value = '';
elements['fbc-status'].value = '';
assert.deepEqual(runtime.getFilteredFbaCartons().map((item) => item.cartonNo), ['CTN-OUT-002']);
elements['fbc-carton'].value = '';
elements['fbc-wh'].value = 'FBA MEM1';
assert.deepEqual(runtime.getFilteredFbaCartons().map((item) => item.cartonNo), ['CTN-IN-003']);
elements['fbc-wh'].value = '';
elements['fbc-status'].value = '已出库';
assert.deepEqual(runtime.getFilteredFbaCartons().map((item) => item.cartonNo), ['CTN-OUT-002']);
elements['fbc-status'].value = '在库';
assert.deepEqual(runtime.getFilteredFbaCartons().map((item) => item.cartonNo), ['CTN-IN-001', 'CTN-IN-003']);
elements['fbc-carton'].value = 'IN';
elements['fbc-wh'].value = 'FBA ONT8';
elements['fbc-status'].value = '在库';
runtime.exportFbaCartons();
assert.equal(downloads.length, 1);
assert.match(downloads[0].filename, /^箱号管理-\d{8}\.csv$/);
assert.equal(downloads[0].csv.charCodeAt(0), 0xfeff);
assert.match(downloads[0].csv, /CTN-IN-001/);
assert.doesNotMatch(downloads[0].csv, /CTN-IN-003/);
assert.doesNotMatch(downloads[0].csv, /CTN-OUT-002/);
elements['fbc-carton'].value = 'NOT-FOUND';
elements['fbc-wh'].value = '';
elements['fbc-status'].value = '';
runtime.exportFbaCartons();
assert.deepEqual(toasts.at(-1), ['暂无可导出数据', 'warning']);
assert.equal(downloads.length, 1);
} catch (error) {
failures.push(`箱号管理运行时行为错误:${error.message}`);
}
}
if (failures.length) {
console.error('FBA_CARTON_MANAGEMENT_CHECK_FAIL');
failures.forEach((failure) => console.error(`- ${failure}`));
process.exitCode = 1;
} else {
console.log('FBA_CARTON_MANAGEMENT_CHECK_PASS');
}
@@ -0,0 +1,31 @@
#!/usr/bin/env node
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
const moduleHtml = fs.readFileSync(path.join(root, 'prototype-src/modules/03-overseas-warehouse.html'), 'utf8');
const app = fs.readFileSync(path.join(root, 'prototype-src/app.js'), 'utf8');
const failures = [];
const expect = (condition, message) => { if (!condition) failures.push(message); };
expect(/id="fbd-inbound-fees"/.test(moduleHtml), '缺少入库单费用明细容器');
expect(/费用项目/.test(moduleHtml), '缺少费用项目表头');
expect(/币种/.test(moduleHtml), '缺少币种表头');
expect(/金额/.test(moduleHtml), '缺少金额表头');
expect(/费用合计(USD)/.test(moduleHtml), '缺少费用合计');
expect(/class="fbd-detail-table fbd-inbound-fee-table"/.test(moduleHtml), '费用表格缺少专用居中样式类');
expect(/\.fbd-inbound-fee-table th,\.fbd-inbound-fee-table td\{text-align:center!important\}/.test(moduleHtml), '费用表格内容未全部居中');
expect(/打托费/.test(app), '缺少打托费示例数据');
expect(/拆柜费/.test(app), '缺少拆柜费示例数据');
expect(/提柜费/.test(app), '缺少提柜费示例数据');
expect(/function\s+renderFbaInboundFees\s*\(/.test(app), '缺少费用明细渲染函数');
expect(/renderFbaInboundFees\(d\.fees\)/.test(app), '入库单详情未调用费用渲染');
if (failures.length) {
console.error('FBA_INBOUND_FEE_DETAIL_CHECK_FAIL');
failures.forEach(message => console.error(`- ${message}`));
process.exitCode = 1;
} else {
console.log('FBA_INBOUND_FEE_DETAIL_CHECK_PASS');
}
@@ -0,0 +1,157 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const root = path.resolve(__dirname, '..');
const appJs = fs.readFileSync(path.join(root, 'prototype-src', 'app.js'), 'utf8');
const overlays = fs.readFileSync(path.join(root, 'prototype-src', 'overlays.html'), 'utf8');
const dispatchOrdersMatch = appJs.match(/const\s+dispatchOrders\s*=\s*(\[[\s\S]*?\n\]);/);
assert.ok(dispatchOrdersMatch, 'app.js 缺少可提取的 dispatchOrders 数组');
const dispatchOrders = vm.runInNewContext(
`(${dispatchOrdersMatch[1]})`,
Object.create(null),
{timeout: 1000}
);
assert.ok(Array.isArray(dispatchOrders) && dispatchOrders.length > 0, 'dispatchOrders 必须至少包含一个订单');
const allowedOrderTypes = ['一件代发', '头程', '仓储转运'];
const actualOrderTypes = new Set(dispatchOrders.map(order => order.otype));
dispatchOrders.forEach(order => {
assert.ok(
typeof order.otype === 'string' && order.otype.trim(),
`订单 ${order.orderNo || '(未知订单)'} 缺少非空 otype`
);
assert.ok(
allowedOrderTypes.includes(order.otype),
`订单 ${order.orderNo || '(未知订单)'} 使用了无效订单类型 ${order.otype}`
);
assert.ok(
Array.isArray(order.palletDetails),
`订单 ${order.orderNo || '(未知订单)'} 缺少 palletDetails 数组`
);
order.palletDetails.forEach((detail, index) => {
assert.ok(
typeof detail.palletNo === 'string' && detail.palletNo.trim(),
`订单 ${order.orderNo || '(未知订单)'} 的第 ${index + 1} 条托盘明细缺少 palletNo`
);
assert.ok(
typeof detail.boxes === 'number' && Number.isFinite(detail.boxes),
`订单 ${order.orderNo || '(未知订单)'} 的托盘 ${detail.palletNo || index + 1} 缺少有限数字 boxes`
);
});
});
assert.deepEqual(
[...actualOrderTypes].sort(),
[...allowedOrderTypes].sort(),
'智能排车示例数据应覆盖全部三个订单类型'
);
assert.ok(
dispatchOrders.some(order => order.palletDetails.length > 0),
'至少一条智能排车订单应包含托盘明细'
);
assert.ok(
dispatchOrders.some(order => order.palletDetails.length === 0),
'至少一条智能排车订单应覆盖无托盘明细空态'
);
assert.ok(appJs.includes("let dsSelectedOrderNo='';"), 'app.js 缺少智能排车订单选择状态');
const selectOrderFunction = appJs.match(/function\s+selectDispatchOrder\s*\(\s*ono\s*\)\s*\{([\s\S]*?)\n\}/);
assert.ok(selectOrderFunction, 'app.js 缺少 selectDispatchOrder(ono)');
assert.match(selectOrderFunction[1], /dsSelectedOrderNo\s*=\s*ono\s*;/, 'selectDispatchOrder() 未保存当前订单号');
assert.match(selectOrderFunction[1], /renderDispatchOrders\s*\(\s*\)/, 'selectDispatchOrder() 未刷新订单列表与明细');
const renderOrdersFunction = appJs.match(/function\s+renderDispatchOrders\s*\(\s*\)\s*\{([\s\S]*?)\n\}/);
assert.ok(renderOrdersFunction, 'app.js 缺少 renderDispatchOrders()');
assert.match(renderOrdersFunction[1], /<th(?:\s[^>]*)?>订单类型<\/th>/, '智能排车订单表缺少“订单类型”表头');
assert.ok(
/onclick=["']selectDispatchOrder\('\$\{o\.orderNo\}'\)["']/.test(renderOrdersFunction[1]),
'智能排车订单行缺少 selectDispatchOrder 点击调用'
);
assert.match(renderOrdersFunction[1], /dsSelectedOrderNo===o\.orderNo\?' selected'/, '订单行未根据当前订单号渲染选中状态');
assert.match(renderOrdersFunction[1], /renderDispatchPalletDetails\s*\(\s*\)/, 'renderDispatchOrders() 未同步刷新托盘明细');
assert.doesNotMatch(renderOrdersFunction[1], /draggable=/, '订单行只负责选择,不应继续作为拖拽源');
assert.doesNotMatch(renderOrdersFunction[1], /dsDragStart/, '订单行不应继续调用订单级拖拽函数');
assert.match(appJs, /let\s+dsTruckPallets\s*=\s*\{\}\s*;/, '智能排车缺少托盘级分配映射 dsTruckPallets');
const palletKeyFunction = appJs.match(/function\s+getDispatchPalletKey\s*\([^)]*\)\s*\{[^\n]*\}/);
const palletVolumeFunction = appJs.match(/function\s+getDispatchPalletVolume\s*\([^)]*\)\s*\{[^\n]*\}/);
assert.ok(palletKeyFunction, 'app.js 缺少 getDispatchPalletKey()');
assert.ok(palletVolumeFunction, 'app.js 缺少 getDispatchPalletVolume()');
const getDispatchPalletKey = vm.runInNewContext(`(${palletKeyFunction[0]})`, Object.create(null));
const getDispatchPalletVolume = vm.runInNewContext(`(${palletVolumeFunction[0]})`, Object.create(null));
assert.equal(getDispatchPalletKey('ORDER-1', 'PAL-1'), 'ORDER-1::PAL-1', '托盘唯一键应组合订单号和托盘号');
assert.equal(getDispatchPalletVolume({vol:18, pallets:20}), 0.9, '单托盘立方应按订单总体积除以总托盘数');
assert.match(appJs, /function\s+getDispatchPalletByKey\s*\(/, 'app.js 缺少 getDispatchPalletByKey()');
assert.match(appJs, /function\s+getDispatchPalletAssignment\s*\(/, 'app.js 缺少 getDispatchPalletAssignment()');
const renderTrucksFunction = appJs.match(/function\s+renderDispatchTrucks\s*\(\s*\)\s*\{([\s\S]*?)\n\}/);
assert.ok(renderTrucksFunction, 'app.js 缺少 renderDispatchTrucks()');
assert.match(renderTrucksFunction[1], /ondrop="dsDropTruck\(event,'\$\{t\.id\}'\)"/, '装托盘容器缺少拖放处理');
assert.match(renderTrucksFunction[1], /const\s+p\s*=\s*arr\.length/, '装托盘托数应按托盘条目数量汇总');
assert.match(renderTrucksFunction[1], /item\.volume/, '装托盘立方应按托盘分摊体积汇总');
assert.match(renderTrucksFunction[1], /item\.palletNo/, '装托盘条目应展示托盘号');
assert.match(renderTrucksFunction[1], /dsPalletDragStart/, '已分配托盘应支持拖到其他装托盘类型');
assert.match(renderTrucksFunction[1], /托数 <b/, '装托盘容器缺少托数汇总');
assert.match(renderTrucksFunction[1], /立方 <b>/, '装托盘容器缺少立方汇总');
const palletRenderFunction = appJs.match(/function\s+renderDispatchPalletDetails\s*\(\s*\)\s*\{([\s\S]*?)\n\}/);
assert.ok(palletRenderFunction, 'app.js 缺少 renderDispatchPalletDetails()');
assert.match(palletRenderFunction[1], /orderNo===dsSelectedOrderNo/, '托盘明细未按当前订单号查找订单');
assert.match(palletRenderFunction[1], /palletDetails/, '托盘明细渲染未读取 palletDetails');
assert.match(palletRenderFunction[1], /ds-pallet-row/, '托盘明细缺少可拖拽行样式');
assert.match(palletRenderFunction[1], /draggable="true"/, '未分配托盘应可拖拽');
assert.match(palletRenderFunction[1], /dsPalletDragStart/, '托盘行缺少拖拽起点处理');
assert.match(palletRenderFunction[1], /getDispatchPalletAssignment/, '托盘明细未检查已分配状态');
assert.match(palletRenderFunction[1], /已分配/, '托盘明细缺少已分配状态');
assert.match(palletRenderFunction[1], /请选择订单查看托盘明细/, '无选中订单时缺少托盘明细引导空态');
assert.match(palletRenderFunction[1], /该订单暂无托盘明细/, '托盘明细渲染缺少空态');
const openFunction = appJs.match(/function\s+openDispatchSchedule\s*\(\s*\)\s*\{([\s\S]*?)\n\}/);
assert.ok(openFunction, 'app.js 缺少 openDispatchSchedule()');
assert.match(openFunction[1], /dsSelectedOrderNo\s*=\s*''\s*;/, '打开智能排车弹窗时未清空订单选择');
assert.match(openFunction[1], /renderDispatchOrders\s*\(\s*\)/, '打开智能排车弹窗时未刷新订单列表与托盘明细');
const scheduleModalStart = overlays.indexOf('id="modal-dispatch-schedule"');
const scheduleModalEnd = overlays.indexOf('<!-- ==================== MODALS ==================== -->', scheduleModalStart);
assert.ok(scheduleModalStart >= 0 && scheduleModalEnd > scheduleModalStart, '无法提取智能排车弹窗');
const scheduleModal = overlays.slice(scheduleModalStart, scheduleModalEnd);
allowedOrderTypes.forEach(type => {
assert.ok(scheduleModal.includes(`<option value="${type}">${type}</option>`), `订单类型下拉框缺少 ${type}`);
assert.ok(scheduleModal.includes(`data-tf="${type}"`), `快捷筛选缺少 ${type}`);
assert.ok(overlays.includes(`.otype-${type}{`), `订单类型标签缺少 ${type} 样式`);
});
['合德正班', '合德海卡', '洛杉矶普通'].forEach(type => {
assert.ok(!scheduleModal.includes(type), `智能排车弹框不应保留旧类型 ${type}`);
assert.ok(!dispatchOrders.some(order => order.otype === type), `智能排车数据不应保留旧类型 ${type}`);
});
const palletPanelStart = scheduleModal.indexOf('id="ds-pallet-detail"');
const palletPanelEnd = scheduleModal.indexOf('<!-- 右侧装托盘类型 -->', palletPanelStart);
const ordersPanelStart = scheduleModal.indexOf('class="ds-orders"');
const palletWrapStart = scheduleModal.indexOf('class="ds-pallets-wrap"');
const trucksPanelStart = scheduleModal.indexOf('class="ds-trucks-wrap"');
assert.ok(ordersPanelStart >= 0 && palletWrapStart > ordersPanelStart && trucksPanelStart > palletWrapStart, '智能排车应按出库单、托盘明细、装托盘类型排列为三栏');
assert.ok(palletPanelStart >= 0 && palletPanelEnd > palletPanelStart, '智能排车中间栏缺少托盘明细区域');
const palletPanel = scheduleModal.slice(palletPanelStart, palletPanelEnd);
['ds-pallet-detail-order', 'ds-pallet-detail-body'].forEach(id => {
assert.ok(palletPanel.includes(`id="${id}"`), `托盘明细区域缺少 ${id}`);
});
assert.match(palletPanel, /<th(?:\s[^>]*)?>\s*托盘号\s*<\/th>/, '托盘明细缺少“托盘号”表头');
assert.match(palletPanel, /<th(?:\s[^>]*)?>\s*箱数\s*<\/th>/, '托盘明细缺少“箱数”表头');
assert.match(
overlays,
/\.ds-order-table\s+tbody\s+tr\.selected\s*\{/,
'智能排车订单表缺少选中行样式 .ds-order-table tbody tr.selected'
);
assert.match(overlays, /\.ds-orders\{flex:44 1 0;/, '出库单列表应按剩余空间的 44 份分配宽度');
assert.match(overlays, /\.ds-pallets-wrap\{flex:22 1 0;/, '托盘明细中间栏应按剩余空间的 22 份分配宽度');
assert.match(overlays, /\.ds-trucks-wrap\{flex:34 1 0;/, '装托盘类型应按剩余空间的 34 份分配宽度');
assert.doesNotMatch(overlays, /\.ds-(?:orders|pallets-wrap|trucks-wrap)\{flex:0 0 \d+%;max-width:\d+%/, '三栏百分比基宽叠加 gap 会造成横向溢出');
assert.match(overlays, /@media\(max-width:900px\)[^{]*\{[\s\S]*\.ds-orders,\.ds-pallets-wrap,\.ds-trucks-wrap/, '窄屏应将三栏切换为纵向布局');
assert.match(overlays, /@media\(min-width:901px\)\{[\s\S]*\.ds-pallet-detail\{margin-top:57px\}[\s\S]*\.ds-pallet-detail-table-wrap\{padding-top:0\}[\s\S]*\.ds-trucks\{padding:24px 2px 0\}/, '桌面端托盘明细应下移并与出库单列表对齐');
assert.match(scheduleModal, /将中间托盘明细拖入右侧装托盘类型/, '智能排车底部提示未更新为托盘级拖拽说明');
console.log('FBA inbound smart schedule pallet detail verification passed.');
@@ -0,0 +1,139 @@
#!/usr/bin/env node
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const root = path.resolve(__dirname, '..');
const moduleHtml = fs.readFileSync(path.join(root, 'prototype-src', 'modules', '03-overseas-warehouse.html'), 'utf8');
const overlaysHtml = fs.readFileSync(path.join(root, 'prototype-src', 'overlays.html'), 'utf8');
const appJs = fs.readFileSync(path.join(root, 'prototype-src', 'app.js'), 'utf8');
const failures = [];
function expectMatch(source, pattern, label) {
if (!pattern.test(source)) failures.push(`missing ${label}`);
}
function functionSource(name, source = appJs) {
const match = new RegExp(`function\\s+${name}\\s*\\([^)]*\\)\\s*\\{`).exec(source);
if (!match) return '';
const start = source.indexOf('{', match.index);
let depth = 0;
let quote = '';
let escaped = false;
for (let index = start; index < source.length; index += 1) {
const char = source[index];
if (quote) {
if (escaped) escaped = false;
else if (char === '\\') escaped = true;
else if (char === quote) quote = '';
continue;
}
if (char === "'" || char === '"' || char === '`') {
quote = char;
continue;
}
if (char === '{') depth += 1;
if (char === '}' && --depth === 0) return source.slice(match.index, index + 1);
}
return '';
}
const statusSelect = moduleHtml.match(/<select\b(?=[^>]*\bid="fboo-status")[^>]*>([\s\S]*?)<\/select>/);
if (!statusSelect) failures.push('missing 出库单状态筛选');
else expectMatch(statusSelect[1], /<option>已取消<\/option>/, '已取消筛选选项');
expectMatch(overlaysHtml, /id="modal-fboo-cancel"/, '取消出库单弹窗');
expectMatch(overlaysHtml, /id="fboo-cancel-summary"/, '取消出库单摘要');
expectMatch(overlaysHtml, /id="fboo-cancel-no"/, '取消出库单号');
expectMatch(overlaysHtml, /id="fboo-cancel-reason"[^>]*maxlength="200"/, '取消原因字段');
expectMatch(overlaysHtml, /onclick="confirmFbaOutboundCancellation\(\)"/, '确认取消按钮');
for (const name of [
'getFbaOutboundStockRows',
'prepareFbaOutboundCancellation',
'openFbaOutboundCancellation',
'confirmFbaOutboundCancellation',
'getFilteredFbaOutboundOrders',
'fbooApplyCurrentSearch',
]) {
if (!functionSource(name)) failures.push(`missing ${name} function`);
}
expectMatch(appJs, /data-fboo-cancel-order/, '列表取消数据入口');
expectMatch(appJs, /openFbaOutboundCancellation\(cancel\.dataset\.fbooCancelOrder\)/, '取消入口事件委托');
expectMatch(appJs, /['"]已取消['"]\s*:\s*['"]tag-gray['"]/, '已取消灰色标签');
expectMatch(appJs, /仅待发货出库单可取消/, '仅待发货取消校验');
expectMatch(appJs, /取消原因/, '详情取消原因');
expectMatch(appJs, /取消时间/, '详情取消时间');
const stockRowsSource = functionSource('getFbaOutboundStockRows');
const prepareSource = functionSource('prepareFbaOutboundCancellation');
if (stockRowsSource && prepareSource) {
try {
const prepareFbaOutboundCancellation = new Function(
`${stockRowsSource}\n${prepareSource}\nreturn prepareFbaOutboundCancellation;`,
)();
const stocks = [
{ sku: 'SKU-A', wh: '洛杉矶仓', availQty: 10 },
{ sku: 'SKU-B', wh: '纽约仓', availQty: 20 },
];
const order = {
outboundNo: 'FBOO-TEST',
status: '待发货',
items: [
{ stockIndex: 0, sku: 'SKU-A', warehouse: '洛杉矶仓', quantity: 3 },
{ stockIndex: 0, sku: 'SKU-A', warehouse: '洛杉矶仓', quantity: 2 },
{ stockIndex: 1, sku: 'SKU-B', warehouse: '纽约仓', quantity: 4 },
],
};
const originalStocks = structuredClone(stocks);
const originalOrder = structuredClone(order);
const plan = prepareFbaOutboundCancellation(order, stocks, ' 客户取消 ', '2026-08-14 20:00');
assert.equal(plan.ok, true);
assert.deepEqual(plan.stockUpdates, { 0: 15, 1: 24 });
assert.deepEqual(plan.orderChanges, {
status: '已取消',
cancelReason: '客户取消',
cancelledAt: '2026-08-14 20:00',
updatedAt: '2026-08-14 20:00',
});
assert.deepEqual(stocks, originalStocks);
assert.deepEqual(order, originalOrder);
const shipped = prepareFbaOutboundCancellation({ ...order, status: '已发货' }, stocks, '客户取消', '2026-08-14 20:00');
assert.deepEqual(shipped, { ok: false, message: '仅待发货出库单可取消' });
const emptyReason = prepareFbaOutboundCancellation(order, stocks, ' ', '2026-08-14 20:00');
assert.deepEqual(emptyReason, { ok: false, message: '请填写取消原因' });
const missingStockOrder = {
outboundNo: 'FBOO-MISSING',
status: '待发货',
items: [{ stockIndex: 9, sku: 'SKU-MISSING', warehouse: '未知仓', quantity: 2 }],
};
const missingOriginal = structuredClone(stocks);
const missing = prepareFbaOutboundCancellation(missingStockOrder, stocks, '客户取消', '2026-08-14 20:00');
assert.deepEqual(missing, { ok: false, message: '库存返还校验失败,请刷新后重试' });
assert.deepEqual(stocks, missingOriginal);
assert.equal(missingStockOrder.status, '待发货');
} catch (error) {
failures.push(`取消计划行为失败:${error.message}`);
}
}
const confirmSource = functionSource('confirmFbaOutboundCancellation');
if (confirmSource) {
expectMatch(confirmSource, /prepareFbaOutboundCancellation/, '提交调用取消计划');
expectMatch(confirmSource, /warehouseStockData\[index\]\.availQty\s*=\s*plan\.stockUpdates\[index\]/, '提交一次性写回库存');
expectMatch(confirmSource, /Object\.assign\(order,plan\.orderChanges\)/, '提交写回取消状态');
expectMatch(confirmSource, /fbooApplyCurrentSearch\(\)/, '取消后保留筛选结果');
}
if (failures.length) {
console.error('FBA_OUTBOUND_CANCEL_CHECK_FAIL');
failures.forEach((failure) => console.error(`- ${failure}`));
process.exitCode = 1;
} else {
console.log('FBA_OUTBOUND_CANCEL_CHECK_PASS');
}
@@ -4,7 +4,11 @@ const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const html = fs.readFileSync(path.resolve(__dirname, '..', '货代SaaS平台原型.html'), 'utf8');
const root = path.resolve(__dirname, '..');
const html = [
fs.readFileSync(path.join(root, 'prototype-src', 'modules', '03-overseas-warehouse.html'), 'utf8'),
fs.readFileSync(path.join(root, 'prototype-src', 'app.js'), 'utf8'),
].join('\n');
const failures = [];
function requireMatch(pattern, label, source = html) {
@@ -31,8 +35,8 @@ const page = pageStart >= 0 && pageEnd > pageStart ? html.slice(pageStart, pageE
requireMatch(/onclick="openFbaOutboundCreate\(\)"[^>]*>\+ 一件代发出库<\/button>/, '一件代发出库入口按钮', page);
requireMatch(/>出库类型<\/th>/, '出库类型列表列', page);
requireMatch(/>出库数量<\/th>/, '出库数量列表列', page);
requireMatch(/>目的仓<\/th>[\s\S]{0,200}>转仓<\/th>[\s\S]{0,200}>商品SKU<\/th>/, '目的仓后的转仓列表列', page);
requireMatch(/>SKU种数<\/th>[\s\S]{0,200}>总托数<\/th>/, 'SKU 汇总列表列', page);
requireMatch(/>总箱数<\/th>[\s\S]{0,200}>总重量\(kg\)<\/th>[\s\S]{0,200}>总体积\(m3\)<\/th>/, '箱重体积汇总列表列', page);
requireMatch(/id="modal-fboo-create"/, '新增出库单弹窗');
requireMatch(/id="modal-fboo-create"[\s\S]*?<div class="modal-header"><span>一件代发出库<\/span>/, '一件代发出库弹窗标题', page);
const createTypeMatch = page.match(/<select\b(?=[^>]*\bid="fboo-create-type")[^>]*>([\s\S]*?)<\/select>/);
@@ -58,8 +62,8 @@ requireMatch(/quantity\s*>\s*available/, '超可用库存拦截');
requireMatch(/warehouseStockData[\s\S]{0,500}availQty\s*-=\s*item\.quantity/, '保存后扣减可用库存');
requireMatch(/fbaOutboundOrderData\.unshift\(/, '新增记录写入出库单数据');
requireMatch(/d\.outboundType/, '列表渲染出库类型');
requireMatch(/d\.totalQty/, '列表渲染出库数量');
requireMatch(/d\.transferWarehouse\s*\|\|\s*['"]—['"]/, '列表渲染转仓目的仓并提供空值占位');
requireMatch(/d\.skuCount/, '列表渲染 SKU 种数');
requireMatch(/d\.palletTotal/, '列表渲染总托数');
try {
const elements = {
@@ -73,7 +77,7 @@ try {
'document',
'renderFbaOutboundInventory',
'showModal',
`${getFunctionSource('updateFbaOutboundDestinationHint')}\n${getFunctionSource('openFbaOutboundCreate')}\nreturn { openFbaOutboundCreate, updateFbaOutboundDestinationHint };`,
`${getFunctionSource('fbooDestinationConfig')}\n${getFunctionSource('updateFbaOutboundDestinationHint')}\n${getFunctionSource('openFbaOutboundCreate')}\nreturn { openFbaOutboundCreate, updateFbaOutboundDestinationHint };`,
)(
{ getElementById: (id) => elements[id] },
() => {},
@@ -0,0 +1,41 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
const moduleHtml = fs.readFileSync(path.join(root, 'prototype-src', 'modules', '03-overseas-warehouse.html'), 'utf8');
const appJs = fs.readFileSync(path.join(root, 'prototype-src', 'app.js'), 'utf8');
const pageStart = moduleHtml.indexOf('<div class="page" id="page-fba-outbound-order-detail">');
const pageEnd = moduleHtml.indexOf('<!-- ==================== OVERSEAS WAREHOUSE - 换标单详情', pageStart);
assert.ok(pageStart >= 0 && pageEnd > pageStart, '无法提取海外仓出库单详情页面');
const detailPage = moduleHtml.slice(pageStart, pageEnd);
const sectionStart = detailPage.indexOf('aria-labelledby="fbd-outbound-sku-title"');
const sectionEnd = detailPage.indexOf('</section>', sectionStart);
assert.ok(sectionStart >= 0 && sectionEnd > sectionStart, '无法提取出库单详情信息区块');
const detailSection = detailPage.slice(sectionStart, sectionEnd);
assert.ok(detailSection.includes('id="fbd-outbound-sku-title">托盘信息</div>'), '详情区块标题应为“托盘信息”');
const headers = [...detailSection.matchAll(/<th(?:\s[^>]*)?>([^<]+)<\/th>/g)].map(match => match[1].trim());
assert.deepEqual(headers, ['托盘号', '入仓码', '箱数', '重量', '体积'], '托盘信息应只保留五个逐托盘字段');
assert.ok(!detailSection.includes('<th>SKU</th>'), '托盘信息不应展示 SKU 列');
assert.ok(!detailSection.includes('<th>中文品名</th>'), '托盘信息不应展示中文品名列');
assert.ok(!detailSection.includes('<th>总托数</th>'), '托盘信息不应展示总托数列');
assert.ok(!detailSection.includes('<th>总托数</th>'), '托盘信息不应展示总托数列');
assert.ok(detailSection.includes('colspan="5"'), '托盘信息空态应跨五列');
assert.ok(detailSection.includes('暂无托盘信息'), '托盘信息应提供对应空态文案');
const renderFunction = appJs.match(/function\s+renderFbaOutboundSkuInfo\s*\(\s*items\s*\)\s*\{([\s\S]*?)\n\}/);
assert.ok(renderFunction, 'app.js 缺少 renderFbaOutboundSkuInfo(items)');
const renderBody = renderFunction[1];
['item.palletNo', 'item.inboundCode', 'item.boxes', 'item.weight', 'item.volume'].forEach(field => {
assert.ok(renderBody.includes(field), `托盘信息渲染缺少 ${field}`);
});
['item.sku', 'item.cnName'].forEach(field => {
assert.ok(!renderBody.includes(field), `托盘信息渲染不应包含 ${field}`);
});
assert.ok(renderBody.includes('colspan="5"'), '托盘信息渲染空态应跨五列');
assert.ok(renderBody.includes('暂无托盘信息'), '托盘信息渲染应使用对应空态文案');
console.log('FBA outbound detail pallet info verification passed.');
@@ -0,0 +1,28 @@
const fs = require('node:fs');
const assert = require('node:assert/strict');
const moduleHtml = fs.readFileSync('prototype-src/modules/03-overseas-warehouse.html', 'utf8');
const appJs = fs.readFileSync('prototype-src/app.js', 'utf8');
assert.match(moduleHtml, /id="modal-fboo-edit"/);
assert.match(moduleHtml, /id="fboo-edit-no"/);
assert.match(moduleHtml, /id="fboo-edit-destination"/);
assert.match(moduleHtml, /id="tbody-fboo-edit-inventory"/);
assert.match(moduleHtml, /id="fboo-edit-save"/);
assert.match(moduleHtml, /@media\(max-width:600px\).*#modal-fboo-edit \.form-row/s);
assert.match(appJs, /function openFbaOutboundEdit\(/);
assert.match(appJs, /function updateFbaOutboundEditSummary\(/);
assert.match(appJs, /function saveFbaOutboundEdit\(/);
assert.match(appJs, /仅待发货出库单可编辑/);
assert.match(appJs, /fbooEditInventoryLimit/);
assert.match(appJs, /原占用量/);
assert.match(appJs, /currentFbaOutboundEditState/);
assert.match(appJs, /stockIndex:item\.index/);
assert.match(appJs, /openFbaOutboundEdit\('\$\{d\.outboundNo\}'\)/);
assert.match(appJs, /d\.status === '待发货'/);
assert.match(appJs, /warehouseStockData\[.*\]\.availQty/);
assert.match(appJs, /renderFbaOutboundOrder\(\)/);
assert.match(appJs, /editModal\.scrollTop=0/);
console.log('fba outbound edit checks passed');
@@ -0,0 +1,79 @@
#!/usr/bin/env node
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const root = path.resolve(__dirname, '..');
const moduleHtml = fs.readFileSync(path.join(root, 'prototype-src', 'modules', '03-overseas-warehouse.html'), 'utf8');
const appJs = fs.readFileSync(path.join(root, 'prototype-src', 'app.js'), 'utf8');
const failures = [];
function getPage(pageId) {
const start = moduleHtml.indexOf(`<div class="page" id="page-${pageId}">`);
const end = start >= 0 ? moduleHtml.indexOf('<div class="page"', start + 20) : -1;
return start >= 0 ? moduleHtml.slice(start, end >= 0 ? end : moduleHtml.length) : '';
}
function getFunction(name) {
const match = new RegExp(`function\\s+${name}\\s*\\(`).exec(appJs);
if (!match) return '';
const next = /\nfunction\s+[A-Za-z_$][\w$]*\s*\(/.exec(appJs.slice(match.index + 1));
return appJs.slice(match.index, next ? match.index + 1 + next.index : appJs.length);
}
function requireMatch(source, pattern, label) {
if (!pattern.test(source)) failures.push(`missing ${label}`);
}
const listPage = getPage('fba-outbound-order');
const detailPage = getPage('fba-outbound-order-detail');
const listRenderer = getFunction('renderFbaOutboundOrder');
const detailOpener = getFunction('openFbaOutboundOrderDetail');
['SKU种数', '总托数', '总箱数', '总重量(kg)', '总体积(m3)'].forEach((field) => {
requireMatch(listPage, new RegExp(`>${field.replace(/[()]/g, '\\$&')}<\\/th>`), `列表列 ${field}`);
});
['货主', '目的仓', '转仓', '商品SKU', '中文品名'].forEach((field) => {
if (new RegExp(`>${field}<\\/th>`).test(listPage)) failures.push(`list retains removed field ${field}`);
});
['d.skuCount', 'd.palletTotal', 'd.boxes', 'd.netWt', 'd.volume'].forEach((field) => {
requireMatch(listRenderer, new RegExp(field.replace('.', '\\.'), 'g'), `列表渲染 ${field}`);
});
requireMatch(detailPage, /id="fbd-outbound-sku-title">托盘信息<\/div>/, '托盘信息模块标题');
requireMatch(detailPage, /id="fbd-outbound-sku-items"/, 'SKU 信息表体');
['SKU', '中文品名', '托盘号', '入仓码', '箱数', '重量', '体积'].forEach((field) => {
requireMatch(detailPage, new RegExp(`>${field.replace(/[()]/g, '\\$&')}<\\/th>`), `SKU 信息列 ${field}`);
});
requireMatch(detailOpener, /renderFbaOutboundSkuInfo\(d\.items\)/, '详情渲染 SKU 信息');
requireMatch(appJs, /function renderFbaOutboundSkuInfo\(/, 'SKU 信息渲染函数');
requireMatch(appJs, /function getFbaOutboundPalletRows\(/, '按托盘展开 SKU 函数');
requireMatch(appJs, /getFbaOutboundPalletRows\(item\)/, 'SKU 信息按托盘渲染');
requireMatch(appJs, /owner:/, '出库单货主字段');
requireMatch(appJs, /palletTotal:/, '出库单总托数字段');
requireMatch(appJs, /volume:/, '出库单总体积字段');
requireMatch(appJs, /pallets:/, 'SKU 明细托数字段');
requireMatch(appJs, /volume:/, 'SKU 明细体积字段');
requireMatch(getFunction('renderFbaOutboundSkuInfo'), /fbaDetailDisplay\(item\.sku\)/, '托盘渲染 SKU');
requireMatch(getFunction('renderFbaOutboundSkuInfo'), /fbaDetailDisplay\(item\.cnName\)/, '托盘渲染中文品名');
try {
const getPalletRows = new Function(`${getFunction('getFbaOutboundPalletRows')}\nreturn getFbaOutboundPalletRows;`)();
const rows = getPalletRows({sku:'SKU-A1001',cnName:'蓝牙耳机套件',palletNo:'PAL-A1001-01',inboundCode:'IC20260722001',pallets:2,boxes:5,weight:12,volume:1.5});
assert.equal(rows.length, 2);
assert.deepEqual(rows.map((row) => row.palletNo), ['PAL-A1001-01', 'PAL-A1001-02']);
assert.equal(rows.reduce((sum, row) => sum + row.boxes, 0), 5);
assert.equal(rows.reduce((sum, row) => sum + row.weight, 0), 12);
assert.equal(rows.reduce((sum, row) => sum + row.volume, 0), 1.5);
} catch (error) {
failures.push(`missing 按托盘拆分汇总行为: ${error.message}`);
}
if (failures.length) {
console.error('FBA_OUTBOUND_LIST_SKU_INFO_CHECK_FAIL');
failures.forEach((failure) => console.error(`- ${failure}`));
process.exitCode = 1;
} else {
console.log('FBA_OUTBOUND_LIST_SKU_INFO_CHECK_PASS');
}
@@ -0,0 +1,37 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
const moduleHtml = fs.readFileSync(path.join(root, 'prototype-src/modules/03-overseas-warehouse.html'), 'utf8');
const appJs = fs.readFileSync(path.join(root, 'prototype-src/app.js'), 'utf8');
assert.match(moduleHtml, /onclick="openFbaPalletForm\(\)"/, '新增托盘入口应打开表单');
assert.match(moduleHtml, /id="modal-fba-pallet-form"/, '应提供托盘新增编辑弹框');
['fbp-form-code', 'fbp-form-type', 'fbp-form-volume', 'fbp-form-price', 'fbp-form-warehouse', 'fbp-form-status', 'fbp-form-remark'].forEach((id) => assert.match(moduleHtml, new RegExp(`id="${id}"`), `表单缺少 ${id}`));
assert.match(appJs, /function openFbaPalletForm\(/, '应提供打开托盘表单的方法');
assert.match(appJs, /function saveFbaPalletForm\(/, '应提供保存托盘表单的方法');
assert.match(appJs, /托盘编码已存在/, '保存时应校验重复托盘编码');
assert.match(appJs, /openFbaPalletForm\('\$\{d\.code\}'\)/, '列表编辑操作应传入托盘编码');
assert.match(appJs, /relatedInboundNo:'—',cartonCount:0/, '新增记录应初始化关联信息');
assert.match(moduleHtml, /<th[^>]*>仓库<\/th>/, '托盘列表应展示仓库列');
assert.match(moduleHtml, /id="fbp-form-overseas-warehouse"/, '托盘表单应提供海外仓字段');
assert.match(appJs, /warehouse:'洛杉矶长滩海外仓'/, '托盘示例数据应包含仓库字段');
assert.match(appJs, /d\.warehouse/, '托盘列表应渲染仓库字段');
assert.match(appJs, /fbp-form-overseas-warehouse/, '托盘新增编辑应读写仓库字段');
assert.match(appJs, /id === 'fbp-wh'|fbp-wh/, '托盘列表应支持按仓库筛选');
assert.match(moduleHtml, /<th[^>]*>容积\(m³\)<\/th>\s*<th[^>]*>托盘重量\(kg\)<\/th>/, '托盘重量应在容积后展示');
assert.match(moduleHtml, /<label>托盘重量\(kg\) \*<\/label><input id="fbp-form-weight" type="number" min="0\.01" step="0\.01" required aria-required="true"/, '新增编辑表单应提供必填托盘重量');
const palletDataBlock = appJs.match(/const fbaPalletData = \[([\s\S]*?)\];/);
assert.ok(palletDataBlock, '应定义托盘示例数据');
assert.equal((palletDataBlock[1].match(/weight:\s*\d+(?:\.\d+)?/g) || []).length, 5, '每条托盘示例数据都应包含重量');
assert.match(appJs, /\$\{d\.weight\}/, '托盘列表应渲染重量');
assert.match(appJs, /set\('fbp-form-weight',d\?d\.weight:''\)/, '编辑托盘时应回填重量');
assert.match(appJs, /const weightInput=get\('fbp-form-weight'\)/, '保存时应读取重量');
assert.match(appJs, /!weightInput\|\|!Number\.isFinite\(weight\)\|\|weight<=0/, '重量为空或不大于0时应阻止保存');
assert.match(appJs, /请输入大于0的托盘重量/, '重量校验应提供明确提示');
assert.match(appJs, /const next=\{code,type,warehouse,vol:volume,weight,purchasePrice,fbaWh,status,remark\}/, '新增编辑保存结果应包含重量');
console.log('FBA pallet create/edit checks passed');
@@ -0,0 +1,15 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
const moduleHtml = fs.readFileSync(path.join(root, 'prototype-src/modules/03-overseas-warehouse.html'), 'utf8');
const appJs = fs.readFileSync(path.join(root, 'prototype-src/app.js'), 'utf8');
assert.match(moduleHtml, /<th[^>]*>采购价格<\/th>/, '托盘列表应包含采购价格表头');
assert.match(appJs, /purchasePrice:/, '托盘数据应包含 purchasePrice');
assert.match(appJs, /function formatFbaPalletPurchasePrice\(/, '应提供采购价格格式化函数');
assert.match(appJs, /formatFbaPalletPurchasePrice\(d\.purchasePrice\)/, '列表和详情应使用统一采购价格格式化函数');
assert.match(appJs, /\['采购价格',formatFbaPalletPurchasePrice\(d\.purchasePrice\)\]/, '托盘详情应显示采购价格');
console.log('FBA pallet purchase-price checks passed');
@@ -0,0 +1,81 @@
#!/usr/bin/env node
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
const moduleHtml = fs.readFileSync(path.join(root, 'prototype-src/modules/03-overseas-warehouse.html'), 'utf8');
const appJs = fs.readFileSync(path.join(root, 'prototype-src/app.js'), 'utf8');
const failures = [];
const expect = (condition, message) => { if (!condition) failures.push(message); };
function getPage(pageId, nextMarker) {
const start = moduleHtml.indexOf(`<div class="page" id="page-${pageId}">`);
const end = moduleHtml.indexOf(nextMarker, start);
return start >= 0 && end > start ? moduleHtml.slice(start, end) : '';
}
const inboundDetail = getPage(
'fba-inbound-order-detail',
'<!-- ==================== OVERSEAS WAREHOUSE - 出库单详情',
);
const outboundDetail = getPage(
'fba-outbound-order-detail',
'<!-- ==================== OVERSEAS WAREHOUSE - 换标单详情',
);
expect(/id="fbd-inbound-fee-title">应收费用明细<\/div>/.test(inboundDetail), '入库单费用区块未改为应收费用明细');
expect(/暂无应收费用明细/.test(inboundDetail), '入库单缺少应收费用空态');
expect(/应收费用合计(USD)/.test(inboundDetail), '入库单缺少应收费用合计');
expect(/id="fbd-outbound-fee-title">应收费用明细<\/div>/.test(outboundDetail), '出库单缺少应收费用明细区块');
expect(/id="fbd-outbound-fees"/.test(outboundDetail), '出库单缺少应收费用表体');
expect(/id="fbd-outbound-fee-total"/.test(outboundDetail), '出库单缺少应收费用合计节点');
expect(/暂无应收费用明细/.test(outboundDetail), '出库单缺少应收费用空态');
expect(/应收费用合计(USD)/.test(outboundDetail), '出库单缺少应收费用合计');
const expectedFees = [
['仓储费', 85],
['出库费', 45],
['托盘自提打托费', 30],
['托盘自提出库费', 25],
['快递交车费', 18],
['快递出库费', 22],
['扣货费', 50],
['弃货费', 65],
];
const feeTemplateStart = appJs.indexOf('const fbaOutboundReceivableFeeTemplate');
const feeTemplateEnd = appJs.indexOf('];', feeTemplateStart);
const feeTemplate = feeTemplateStart >= 0 && feeTemplateEnd > feeTemplateStart
? appJs.slice(feeTemplateStart, feeTemplateEnd + 2)
: '';
for (const [feeName, amount] of expectedFees) {
expect(
feeTemplate.includes(`{name:'${feeName}',currency:'USD',amount:${amount},remark:'-'}`),
`出库应收费用缺少 ${feeName} USD ${amount}`,
);
}
expect(expectedFees.reduce((sum, [, amount]) => sum + amount, 0) === 340, '出库应收费用示例金额合计不是 340');
expect(/function\s+createFbaOutboundReceivableFees\s*\(\)/.test(appJs), '缺少出库应收费用数组创建函数');
expect(/fbaOutboundOrderData\.forEach\(order=>\{order\.fees=createFbaOutboundReceivableFees\(\);\}\);/.test(appJs), '现有出库单未接入统一应收费用模板');
expect((appJs.match(/fees:createFbaOutboundReceivableFees\(\)/g) || []).length >= 2, '动态新增出库单未接入统一应收费用模板');
expect(/function\s+formatFbaReceivableFee\s*\(/.test(appJs), '缺少统一应收费用金额格式化函数');
expect(/amount===null\|\|amount===undefined\|\|String\(amount\)\.trim\(\)===''/.test(appJs), '空金额未显示为 -');
expect(/function\s+renderFbaReceivableFees\s*\(/.test(appJs), '缺少统一应收费用渲染函数');
expect(/item\.name\|\|'-'/.test(appJs), '空费用项目未显示为 -');
expect(/item\.currency\|\|'-'/.test(appJs), '空币种未显示为 -');
expect(/item\.remark\|\|'-'/.test(appJs), '空备注未显示为 -');
expect(/暂无应收费用明细/.test(appJs), '统一渲染函数缺少应收费用空态');
expect(/function\s+renderFbaInboundFees\s*\(fees\)[\s\S]*?renderFbaReceivableFees\('fbd-inbound-fees','fbd-inbound-fee-total',fees\)/.test(appJs), '入库单未复用统一应收费用渲染');
expect(/function\s+renderFbaOutboundFees\s*\(fees\)[\s\S]*?renderFbaReceivableFees\('fbd-outbound-fees','fbd-outbound-fee-total',fees\)/.test(appJs), '出库单未复用统一应收费用渲染');
expect(/renderFbaInboundFees\(d\.fees\)/.test(appJs), '入库单详情未渲染应收费用');
expect(/renderFbaOutboundFees\(d\.fees\)/.test(appJs), '出库单详情未渲染应收费用');
if (failures.length) {
console.error('FBA_RECEIVABLE_FEE_DETAIL_CHECK_FAIL');
failures.forEach(message => console.error(`- ${message}`));
process.exitCode = 1;
} else {
console.log('FBA_RECEIVABLE_FEE_DETAIL_CHECK_PASS');
}
@@ -4,8 +4,11 @@ const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const htmlPath = path.resolve(__dirname, '..', '货代SaaS平台原型.html');
const html = fs.readFileSync(htmlPath, 'utf8');
const root = path.resolve(__dirname, '..');
const html = [
fs.readFileSync(path.join(root, 'prototype-src', 'modules', '03-overseas-warehouse.html'), 'utf8'),
fs.readFileSync(path.join(root, 'prototype-src', 'app.js'), 'utf8'),
].join('\n');
const failures = [];
function expectMatch(pattern, label, source = html) {
@@ -48,19 +51,27 @@ for (const id of [
'fbro-form-label-req',
'fbro-form-file',
'fbro-form-status',
'fbro-form-budget-total',
'fbro-form-cost-row',
'fbro-form-relabel-cost',
'fbro-form-remark',
]) {
expectMatch(new RegExp(`id="${id}"`), `${id} 表单字段`);
}
expectMatch(/data-fbro-edit-order/, '列表编辑数据入口');
expectMatch(/data-fbro-audit-order/, '列表审核数据入口');
expectMatch(/data-fbro-view-order/, '列表查看数据入口');
expectMatch(/预算总价/, '预算总价字段');
expectMatch(/预算报价/, '审核预算报价字段');
expectMatch(/id="fbro-form-relabel-cost"[^>]*type="number"/, '审核预算报价输入框');
const sources = Object.fromEntries([
'nextFbaRelabelOrderNo',
'formatFbaRelabelFee',
'calculateFbaRelabelFeeTotal',
'buildFbaRelabelRecord',
'openFbaRelabelCreate',
'openFbaRelabelEdit',
'readFbaRelabelForm',
'saveFbaRelabelOrder',
'openFbaRelabelOrderDetail',
].map((name) => [name, functionSource(name)]));
@@ -87,20 +98,32 @@ if (sources.formatFbaRelabelFee) {
const formatFbaRelabelFee = new Function(`return (${sources.formatFbaRelabelFee});`)();
assert.equal(formatFbaRelabelFee(null), '-');
assert.equal(formatFbaRelabelFee(''), '-');
assert.equal(formatFbaRelabelFee('abc'), '-');
assert.equal(formatFbaRelabelFee(0), '¥0.00');
assert.equal(formatFbaRelabelFee(12.5), '¥12.50');
assert.equal(formatFbaRelabelFee('abc'), 'abc');
assert.equal(formatFbaRelabelFee(0), '$0.00');
assert.equal(formatFbaRelabelFee(12.5), '$12.50');
} catch (error) {
failures.push(`换标费用格式化行为失败:${error.message}`);
}
}
if (sources.calculateFbaRelabelFeeTotal) {
try {
const calculateFbaRelabelFeeTotal = new Function(`return (${sources.calculateFbaRelabelFeeTotal});`)();
assert.equal(calculateFbaRelabelFeeTotal({ openBoxFee: 10, pickingFee: '2.5', relabelFee: 3, cartonMarkFee: 4, relabelCost: 99 }), 19.5);
assert.equal(calculateFbaRelabelFeeTotal({ openBoxFee: null, pickingFee: 'invalid', relabelFee: undefined, cartonMarkFee: 0 }), 0);
} catch (error) {
failures.push(`换标费总计行为失败:${error.message}`);
}
} else {
failures.push('missing calculateFbaRelabelFeeTotal function');
}
if (sources.buildFbaRelabelRecord) {
try {
const buildFbaRelabelRecord = new Function(`${sources.nextFbaRelabelOrderNo}\nreturn (${sources.buildFbaRelabelRecord});`)();
const form = {
relatedOrderNo: 'RMA-NEW', sku: 'SKU-NEW', cnName: '新品', boxes: 3,
labelReq: '加贴新标', file: 'new.pdf', status: '已完成', remark: '新建备注',
labelReq: '加贴新标', file: 'new.pdf', status: '已完成', remark: '新建备注', relabelCost: 21.75, budgetTotal: 18.5,
};
const orders = [{ orderNo: 'FBRO20260812001' }];
const created = buildFbaRelabelRecord(form, null, '2026-08-12 19:00', orders);
@@ -108,17 +131,20 @@ if (sources.buildFbaRelabelRecord) {
assert.equal(created.status, '待处理');
assert.equal(created.createdAt, '2026-08-12 19:00');
assert.equal(created.updatedAt, '2026-08-12 19:00');
assert.equal(created.budgetTotal, 18.5);
assert.deepEqual(created.fees, {});
const current = {
orderNo: 'FBRO20260720001', lastMileNo: 'LMO-KEEP', createdAt: '2026-07-20 10:15',
updatedAt: '2026-07-21 15:40', fees: { openBoxFee: 12 }, sku: 'OLD', status: '处理中',
};
const edited = buildFbaRelabelRecord({ ...form, status: '已完成' }, current, '2026-08-12 19:10', orders);
const edited = buildFbaRelabelRecord({ ...form, status: '已完成', relabelCost: 22.5, budgetTotal: 19.25 }, current, '2026-08-12 19:10', orders);
assert.equal(edited.orderNo, current.orderNo);
assert.equal(edited.lastMileNo, current.lastMileNo);
assert.equal(edited.createdAt, current.createdAt);
assert.deepEqual(edited.fees, current.fees);
assert.equal(edited.fees.openBoxFee, current.fees.openBoxFee);
assert.equal(edited.fees.relabelCost, 22.5);
assert.equal(edited.budgetTotal, 19.25);
assert.equal(edited.sku, 'SKU-NEW');
assert.equal(edited.status, '已完成');
assert.equal(edited.updatedAt, '2026-08-12 19:10');
@@ -128,15 +154,28 @@ if (sources.buildFbaRelabelRecord) {
}
if (sources.openFbaRelabelOrderDetail) {
for (const label of ['费用明细', '开箱费', '挑货费', '换标费', '换箱唛标费']) {
for (const label of ['商品信息', '预算总价', '费用明细', '开箱费', '挑货费', '换标费', '换箱唛标费', '换标费总计']) {
expectMatch(new RegExp(label), `详情${label}`, sources.openFbaRelabelOrderDetail);
}
if (/换标成本/.test(sources.openFbaRelabelOrderDetail)) failures.push('详情仍展示换标成本');
expectMatch(/formatFbaRelabelFee/, '详情使用统一费用格式化', sources.openFbaRelabelOrderDetail);
expectMatch(/calculateFbaRelabelFeeTotal/, '详情使用换标费总计函数', sources.openFbaRelabelOrderDetail);
}
if (sources.openFbaRelabelEdit) {
expectMatch(/审核换标单/, '审核弹窗标题', sources.openFbaRelabelEdit);
expectMatch(/fbro-form-cost-row/, '审核显示换标成本区域', sources.openFbaRelabelEdit);
expectMatch(/fbro-form-relabel-cost/, '审核回填预算报价', sources.openFbaRelabelEdit);
}
if (sources.readFbaRelabelForm) {
expectMatch(/fbro-form-relabel-cost/, '表单读取换标成本', sources.readFbaRelabelForm);
}
if (sources.saveFbaRelabelOrder) {
expectMatch(/buildFbaRelabelRecord/, '保存调用记录构建函数', sources.saveFbaRelabelOrder);
expectMatch(/Number\.isInteger/, '箱数正整数校验', sources.saveFbaRelabelOrder);
expectMatch(/relabelCost/, '保存换标报价选择', sources.saveFbaRelabelOrder);
expectMatch(/fbroApplyCurrentSearch/, '保存后保留当前筛选结果', sources.saveFbaRelabelOrder);
}
@@ -0,0 +1,46 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
const file = path.join(root, '流程图', '费用流转流程图.html');
assert.ok(fs.existsSync(file), '缺少费用流转流程图 HTML');
const html = fs.readFileSync(file, 'utf8');
assert.match(html, /data-flow-direction="vertical"/, '流程方向必须为由上向下');
assert.match(html, /data-department-layout="columns"/, '部门必须横向排列为四列');
const departmentColumns = [...html.matchAll(/<div class="lane" data-department="([^"]+)">/g)].map(match => match[1]);
assert.deepEqual(departmentColumns, ['业务订单', '系统', '财务', '客户与供应商'], '部门列顺序不正确');
const canvasSize = html.match(/data-canvas-width="(\d+)" data-canvas-height="(\d+)"/);
assert.ok(canvasSize, '缺少竖向画布尺寸标记');
assert.ok(Number(canvasSize[2]) > Number(canvasSize[1]), '竖向画布高度必须大于宽度');
for (const label of ['业务订单', '系统', '财务', '客户与供应商']) {
assert.match(html, new RegExp(label), `缺少泳道:${label}`);
}
for (const label of ['应收', '应付', '待审核', '审核通过', '审核驳回', '待确认', '已确认', '待审批', '生效', '部分收款', '部分付款', '已结清', '禁止结算', '原型未定义']) {
assert.match(html, new RegExp(label), `缺少流程节点或图例:${label}`);
}
for (const marker of ['data-node-id=', 'contenteditable', 'localStorage', 'download', 'Blob', '打印', '恢复默认']) {
assert.match(html, new RegExp(marker), `缺少可编辑能力:${marker}`);
}
assert.doesNotMatch(html, /<script[^>]+src=|<link[^>]+href=/i, '流程图不应依赖外部脚本或样式');
const expectedNodeIds = [
'order-fee-source', 'fee-generated', 'fee-pending-audit', 'fee-audit-decision',
'fee-rejected', 'fee-approved', 'join-settlement-decision', 'fee-archived',
'ar-bill', 'ar-bill-audit', 'ar-push', 'customer-confirm', 'ar-confirm-wait',
'ar-settlement', 'ar-approve', 'ar-payment', 'ar-paid', 'ar-undefined',
'ap-bill', 'ap-settlement', 'ap-approval', 'ap-rejected', 'ap-effective',
'ap-voucher', 'ap-payment', 'ap-paid', 'supplier-undefined'
];
const nodeIds = [...html.matchAll(/data-node-id="([^"]+)"/g)].map(match => match[1]);
assert.equal(nodeIds.length, expectedNodeIds.length, '费用节点数量不正确');
assert.equal(new Set(nodeIds).size, nodeIds.length, '费用节点 ID 必须唯一');
assert.deepEqual(new Set(nodeIds), new Set(expectedNodeIds), '费用节点 ID 清单不完整');
assert.match(html, /function readNodeText\(textEl\)/, '缺少保留换行的纯文本读取函数');
assert.match(html, /const safeStorage\s*=\s*\{/, '缺少 localStorage 安全访问封装');
assert.match(html, /addEventListener\('paste'/, '缺少纯文本粘贴处理');
assert.match(html, /height:74px[^}]*overflow:hidden/, '节点必须保持固定高度,避免覆盖相邻节点和连线');
assert.match(html, /textEl\.textContent\s*=\s*exportedTexts\[/, '下载文件必须使用清洗后的纯文本节点内容');
console.log('验证通过:费用流转流程图 HTML 结构完整且为离线自包含文件。');
@@ -0,0 +1,181 @@
#!/usr/bin/env node
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const root = path.resolve(__dirname, '..');
const sourceRoot = path.join(root, 'prototype-src');
const manifest = JSON.parse(fs.readFileSync(path.join(sourceRoot, 'manifest.json'), 'utf8'));
const financeHtml = fs.readFileSync(path.join(sourceRoot, 'modules', '08-finance.html'), 'utf8');
const shellHtml = fs.readFileSync(path.join(sourceRoot, 'shell.html'), 'utf8');
const overlaysHtml = fs.readFileSync(path.join(sourceRoot, 'overlays.html'), 'utf8');
const appJs = fs.readFileSync(path.join(sourceRoot, 'app.js'), 'utf8');
function countMatches(source, pattern) {
return [...source.matchAll(pattern)].length;
}
function extractPage(source, pageId) {
const escaped = pageId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const opening = new RegExp(`<div\\b[^>]*\\bid=["']page-${escaped}["'][^>]*>`, 'i').exec(source);
if (!opening) return '';
const rest = source.slice(opening.index + opening[0].length);
const nextPage = /<div\b[^>]*\bclass=["'][^"']*\bpage\b[^"']*["'][^>]*\bid=["']page-/i.exec(rest);
return source.slice(opening.index, nextPage
? opening.index + opening[0].length + nextPage.index
: source.length);
}
const pageId = 'finance-ar-detail-report';
const financeModule = manifest.modules.find(item => item.file === 'modules/08-finance.html');
assert.ok(financeModule, '缺少财务模块 manifest 登记');
assert.equal(financeModule.pages.filter(page => page === pageId).length, 1, '财务模块必须唯一登记报表页面');
assert.equal(manifest.pageOrder.filter(page => page === pageId).length, 1, '根 pageOrder 必须唯一登记报表页面');
assert.equal(countMatches(financeHtml, /id=["']page-finance-ar-detail-report["']/g), 1, '财务模块必须唯一包含报表页面');
const navEntry = `switchPage('${pageId}',this)`;
const navIndex = shellHtml.indexOf(navEntry);
const arapIndex = shellHtml.indexOf("switchPage('customer-arap',this)");
const reconIndex = shellHtml.indexOf("switchPage('customer-recon-stmt',this)");
assert.ok(navIndex >= 0, '财务导航缺少应收款统计表入口');
assert.ok(arapIndex >= 0 && navIndex > arapIndex, '应收款统计表入口应位于客户应收应付之后');
assert.ok(reconIndex >= 0 && navIndex < reconIndex, '应收款统计表入口应位于客户对账单之前');
assert.match(
shellHtml,
/<div\b[^>]*\bonclick=["']switchPage\('finance-ar-detail-report',this\)["'][^>]*>\s*应收款统计表\s*<\/div>/,
'财务导航入口必须使用“应收款统计表”文案',
);
const reportPage = extractPage(financeHtml, pageId);
assert.ok(reportPage, '财务模块缺少应收款统计表页面');
assert.ok(reportPage.includes('应收款统计表'), '页面标题和面包屑必须使用“应收款统计表”');
assert.match(reportPage, /<label\b[^>]*for=["']ardr-month["'][^>]*>统计月份<\/label>/, '筛选项必须改为统计月份');
assert.match(reportPage, /<input\b[^>]*\bid=["']ardr-month["'][^>]*\btype=["']month["'][^>]*\bvalue=["']2026-07["']/, '统计月份必须使用 month 控件并默认 2026-07');
assert.doesNotMatch(reportPage, /\bid=["']ardr-year["']/, '页面不应继续保留统计年度控件');
[
'ardr-entity',
'ardr-currency',
'ardr-total-receivable',
'ardr-total-received',
'ardr-total-deduction',
'ardr-total-balance',
'thead-receivable-detail-report',
'tbody-receivable-detail-report',
].forEach(id => assert.match(reportPage, new RegExp(`\\bid=["']${id}["']`), `页面缺少字段 ${id}`));
['应收总金额', '已收合计', '零头减免', '余额未收', '客户', '备注']
.forEach(label => assert.ok(reportPage.includes(label), `页面缺少文案 ${label}`));
assert.match(reportPage, /<span\b[^>]*\bid=["']ardr-table-title["'][^>]*>应收款明细<\/span>/, '列表标题不应显示统计月份');
assert.doesNotMatch(reportPage, /position\s*:\s*sticky/i, '标准列表不应使用页面专属冻结列');
assert.doesNotMatch(reportPage, /ardr-month-group/, '标准列表不应使用月份分组表头');
assert.match(overlaysHtml, /<div\b[^>]*\bid=["']modal-receivable-report-edit["']/, '缺少应收款明细编辑弹窗');
assert.match(overlaysHtml, /<input\b[^>]*\bid=["']ardr-edit-entity["'][^>]*\breadonly\b/, '应收单位必须只读');
assert.match(overlaysHtml, /<input\b[^>]*\bid=["']ardr-edit-customer["'][^>]*\brequired\b[^>]*\bmaxlength=["']50["']/, '客户必须必填且最多 50 字');
assert.match(overlaysHtml, /<textarea\b[^>]*\bid=["']ardr-edit-remark["'][^>]*\bmaxlength=["']200["']/, '备注必须最多 200 字');
assert.match(overlaysHtml, /onclick=["']saveReceivableReportEdit\(\)["']/, '编辑弹窗保存按钮未接入保存函数');
const calculateStart = appJs.indexOf('function calculateReceivableReportRow');
const renderStart = appJs.indexOf('function renderReceivableDetailReport');
assert.ok(calculateStart >= 0 && renderStart > calculateStart, '缺少应收款统计表连续函数区间');
const reportFunctions = appJs.slice(calculateStart, renderStart);
['calculateReceivableReportRow', 'summarizeReceivableReportByCurrency', 'buildReceivableReportHeader', 'updateReceivableReportEditableFields']
.forEach(name => assert.match(reportFunctions, new RegExp(`function\\s+${name}\\s*\\(`), `函数区间缺少 ${name}`));
const sandbox = {};
vm.runInNewContext(
`${reportFunctions}\nthis.api={calculateReceivableReportRow,summarizeReceivableReportByCurrency,buildReceivableReportHeader,updateReceivableReportEditableFields};`,
sandbox,
{filename: 'receivable-report-functions.js', timeout: 1000},
);
const api = sandbox.api;
const julyRow = api.calculateReceivableReportRow({
year: 2026,
customer: '客户甲',
receivableEntity: '主体A',
currency: 'CNY',
monthlyAmounts: {
6: {direct: 50, special: 10, received: 40, deduction: 0},
7: {direct: 100, special: 20, received: 90, deduction: 5},
},
}, '2026-07');
assert.equal(julyRow.monthKey, '2026-07', '行数据必须记录所选月份');
assert.deepEqual(
JSON.parse(JSON.stringify(julyRow.month)),
{direct: 100, special: 20},
'行数据只能读取所选月份的 FBA 金额',
);
assert.equal(julyRow.receivableAmount, 120, '应收总金额必须为所选月份两项业务金额之和');
assert.equal(julyRow.receivedAmount, 90, '已收合计必须读取所选月份金额');
assert.equal(julyRow.deductionAmount, 5, '零头减免必须读取所选月份金额');
assert.equal(julyRow.balanceAmount, 25, '余额未收必须按所选月份计算');
const missingRow = api.calculateReceivableReportRow({
year: 2026,
currency: 'CNY',
monthlyAmounts: {7: {direct: 100, special: 20, received: 90, deduction: 5}},
}, '2026-08');
assert.deepEqual(
JSON.parse(JSON.stringify(missingRow.month)),
{direct: null, special: null},
'缺失月份必须返回空业务金额',
);
assert.equal(missingRow.receivableAmount, 0, '缺失月份应收金额必须为 0');
assert.equal(missingRow.receivedAmount, 0, '缺失月份已收金额必须为 0');
assert.equal(missingRow.balanceAmount, 0, '缺失月份余额必须为 0');
const usdRow = api.calculateReceivableReportRow({
year: 2026,
currency: 'USD',
monthlyAmounts: {7: {direct: 10, special: 5, received: 3, deduction: 2}},
}, '2026-07');
const summary = api.summarizeReceivableReportByCurrency([julyRow, usdRow]);
assert.deepEqual(
JSON.parse(JSON.stringify(summary)),
{
CNY: {receivable: 120, received: 90, deduction: 5, balance: 25},
USD: {receivable: 15, received: 3, deduction: 2, balance: 10},
},
'应按币种汇总所选月份金额',
);
const editableRecord = {customer: '原客户', remark: '原备注'};
assert.equal(api.updateReceivableReportEditableFields(editableRecord, ' 新客户 ', ' 新备注 '), true, '有效编辑应返回 true');
assert.deepEqual(editableRecord, {customer: '新客户', remark: '新备注'}, '客户和备注应去除首尾空格后更新');
const invalidRecord = {customer: '保留客户', remark: '保留备注'};
assert.equal(api.updateReceivableReportEditableFields(invalidRecord, ' ', '不应写入'), false, '空客户应返回 false');
assert.deepEqual(invalidRecord, {customer: '保留客户', remark: '保留备注'}, '校验失败时不应修改原数据');
const header = api.buildReceivableReportHeader('2026-07');
assert.equal(typeof header, 'string', '单月表头必须生成 HTML 字符串');
assert.equal(countMatches(header, /<tr>/g), 1, '标准列表必须使用单层表头');
assert.equal(countMatches(header, /<th\b/g), 12, '标准列表必须固定 12 列');
assert.match(header, /<th>操作<\/th><\/tr>$/, '操作必须是最后一列');
assert.equal(countMatches(header, /ardr-month-group/g), 0, '表头不应生成月份分组');
assert.equal(countMatches(header, /FBA直发/g), 1, '表头只能生成一个 FBA直发');
assert.equal(countMatches(header, /FBA专线/g), 1, '表头只能生成一个 FBA专线');
assert.ok(!header.includes('2026.07'), '表头不应显示所选月份');
assert.match(appJs, /if\(page==='finance-ar-detail-report'\)renderReceivableDetailReport\(\)/, 'switchPage 未初始化应收款统计表');
assert.ok(appJs.includes("getElementById('ardr-month')"), '渲染逻辑必须读取统计月份');
assert.ok(appJs.includes("month.value='2026-07'"), '重置必须恢复默认月份 2026-07');
assert.ok(appJs.includes('暂无匹配的应收款明细'), '缺少单月统计空态文案');
assert.match(appJs, /function\s+openReceivableReportEdit\s*\(/, '缺少打开单条编辑弹窗函数');
assert.match(appJs, /function\s+saveReceivableReportEdit\s*\(/, '缺少保存单条编辑函数');
assert.ok(appJs.includes('请输入客户'), '缺少客户必填校验提示');
assert.ok(appJs.includes('应收款明细已更新'), '缺少编辑成功提示');
assert.ok(appJs.includes('应收款统计表已导出'), '缺少新报表名称的导出反馈');
assert.ok(appJs.includes('正在打开打印预览'), '缺少打印反馈');
assert.match(
appJs,
/<button\b[^>]*\btype=["']button["'][^>]*\bclass=["']btn btn-sm btn-primary["'][^>]*\bonclick="openReceivableReportEdit\([^\"]*\)"[^>]*>编辑<\/button>/,
'应收款明细行编辑按钮必须使用 btn btn-sm btn-primary 样式并保留编辑行为',
);
assert.doesNotMatch(
appJs,
/<button\b[^>]*\bclass=["'][^"']*\bbtn-link\b[^"']*["'][^>]*\bonclick=["']openReceivableReportEdit\(/,
'应收款明细行编辑按钮不应使用未定义的 btn-link 样式',
);
console.log('应收款统计表校验通过');
@@ -0,0 +1,313 @@
#!/usr/bin/env node
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const root = path.resolve(__dirname, '..');
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
const general = read('prototype-src/modules/10-general.html');
const overlays = read('prototype-src/overlays.html');
const shell = read('prototype-src/shell.html');
const app = read('prototype-src/app.js');
const agents = read('AGENTS.md');
const manifest = JSON.parse(read('prototype-src/manifest.json'));
function count(source, pattern) {
return (source.match(pattern) || []).length;
}
function pageFragment(source, pageId) {
const marker = `<div class="page" id="page-${pageId}">`;
const start = source.indexOf(marker);
assert.ok(start >= 0, `缺少页面 page-${pageId}`);
const next = source.indexOf('<div class="page" id="page-', start + marker.length);
return source.slice(start, next < 0 ? source.length : next);
}
function headers(page) {
return Array.from(page.matchAll(/<th>([^<]+)<\/th>/g), match => match[1]);
}
function tabMarkup(page, targetPage) {
const match = new RegExp(`<button\\b[^>]*switchWarehousePage\\('${targetPage}',\\s*this\\)[^>]*>`, 'g').exec(page);
assert.ok(match, `缺少跳转至 ${targetPage} 的仓库页签`);
return match[0];
}
function extractBalanced(source, start, open, close) {
let depth = 0;
let quote = '';
for (let index = start; index < source.length; index += 1) {
const char = source[index];
if (quote) {
if (char === '\\') index += 1;
else if (char === quote) quote = '';
continue;
}
if (char === '\'' || char === '"' || char === '`') {
quote = char;
continue;
}
if (char === open) depth += 1;
if (char === close) {
depth -= 1;
if (depth === 0) return index;
}
}
throw new Error(`无法闭合 ${open}${close} 片段`);
}
function extractArrayDeclaration(source, name) {
const match = new RegExp(`(?:const|let|var)\\s+${name}\\s*=`).exec(source);
assert.ok(match, `app.js 缺少 ${name} 数据数组`);
const open = source.indexOf('[', match.index + match[0].length);
const end = extractBalanced(source, open, '[', ']');
return source.slice(match.index, source.indexOf(';', end) + 1);
}
function extractVariableDeclaration(source, name) {
const match = new RegExp(`(?:const|let|var)\\s+${name}\\s*=`).exec(source);
assert.ok(match, `app.js 缺少 ${name} 声明`);
const open = source.indexOf('{', match.index + match[0].length);
const end = extractBalanced(source, open, '{', '}');
return source.slice(match.index, source.indexOf(';', end) + 1);
}
function extractFunction(source, name) {
const match = new RegExp(`function\\s+${name}\\s*\\(`).exec(source);
assert.ok(match, `app.js 缺少函数 ${name}`);
const open = source.indexOf('{', match.index + match[0].length);
const end = extractBalanced(source, open, '{', '}');
return source.slice(match.index, end + 1);
}
function makeTbody(eventLog, name) {
let html = '';
const element = {};
Object.defineProperty(element, 'innerHTML', {
get: () => html,
set: value => {
html = String(value);
eventLog.push(['render', name, html]);
}
});
return element;
}
const ownedPage = pageFragment(general, 'gen-owned-warehouse');
const fbaPage = pageFragment(general, 'gen-fba');
const partnerPage = pageFragment(general, 'gen-partner-warehouse');
const pageCases = [
['自有仓', ownedPage, 'gen-owned-warehouse'],
['FBA仓库', fbaPage, 'gen-fba'],
['合作仓', partnerPage, 'gen-partner-warehouse']
];
for (const [name, page, activePage] of pageCases) {
for (const target of ['gen-owned-warehouse', 'gen-fba', 'gen-partner-warehouse']) {
assert.match(page, new RegExp(`switchWarehousePage\\('${target}',\\s*this\\)`), `${name}缺少 ${target} 页签`);
const tab = tabMarkup(page, target);
assert.match(tab, new RegExp(`aria-selected="${target === activePage ? 'true' : 'false'}"`), `${name}页签 aria-selected 错误`);
if (target === activePage) assert.match(tab, /class="[^"]*active[^"]*"/, `${name}当前页签未高亮`);
else assert.doesNotMatch(tab, /class="[^"]*active[^"]*"/, `${name}非当前页签错误高亮`);
}
}
assert.deepEqual(headers(ownedPage), ['自有仓名称', '仓库地址', '城市', '州/省', '邮编', '国家', '状态', '操作']);
assert.doesNotMatch(ownedPage, /距离(MILES)|data-owned-warehouse-select/, '自有仓页面不得维护距离');
assert.match(ownedPage, /openWarehouseAdd\('owned'\)/, '自有仓新增入口未指定 owned 目标');
const targetHeaders = ['仓库代码', '仓库地址', '城市', '州/省', '邮编', '国家', '距离(MILES)', '状态', '操作'];
assert.deepEqual(headers(fbaPage), targetHeaders);
assert.deepEqual(headers(partnerPage), targetHeaders);
assert.match(fbaPage, /id="gen-fba-owned-warehouse"[^>]*data-owned-warehouse-select/);
assert.match(partnerPage, /id="gen-partner-warehouse-owned-warehouse"[^>]*data-owned-warehouse-select/);
const sidebarStart = shell.indexOf('<aside');
const sidebarEnd = shell.indexOf('</aside>', sidebarStart);
const sidebar = shell.slice(sidebarStart, sidebarEnd);
assert.equal(count(sidebar, /switchPage\('gen-owned-warehouse',this\)">仓库管理<\/div>/g), 1, '侧栏仓库管理必须默认进入自有仓');
assert.doesNotMatch(sidebar, /sub-menu-item[^>]+gen-(?:fba|partner-warehouse)/, '侧栏不得为 FBA 或合作仓增加独立入口');
assert.match(shell, /#page-gen-owned-warehouse \.warehouse-table-wrap[^{}]*,[^{}]*#page-gen-fba \.warehouse-table-wrap[^{}]*,[^{}]*#page-gen-partner-warehouse \.warehouse-table-wrap/);
const generalModule = manifest.modules.find(module => module.file === 'modules/10-general.html');
assert.ok(generalModule, 'manifest 缺少通用资料模块');
for (const list of [manifest.pageOrder, generalModule.pages]) {
assert.equal(list.filter(id => id === 'gen-owned-warehouse').length, 1, 'gen-owned-warehouse 必须只登记一次');
const ownedIndex = list.indexOf('gen-owned-warehouse');
assert.equal(list[ownedIndex + 1], 'gen-fba', '自有仓后必须紧接 FBA仓库');
assert.equal(list[ownedIndex + 2], 'gen-partner-warehouse', 'FBA仓库后必须紧接合作仓');
}
assert.match(agents, /仓库(`gen-owned-warehouse`、`gen-fba`、`gen-partner-warehouse`)/);
const modalStart = overlays.indexOf('<div class="modal-overlay" id="modal-fba-add">');
assert.ok(modalStart >= 0, '共享浮层中缺少仓库表单弹窗');
const nextModalStart = overlays.indexOf('<div class="modal-overlay"', modalStart + 1);
const warehouseModal = overlays.slice(modalStart, nextModalStart < 0 ? overlays.length : nextModalStart);
assert.match(warehouseModal, /id="warehouse-form-title"/);
assert.match(warehouseModal, /id="warehouse-owned-name-row"/);
assert.match(warehouseModal, /id="warehouse-code-row"/);
assert.match(warehouseModal, /id="warehouse-distance-row"[^>]*style="display:none"/);
assert.match(warehouseModal, /id="warehouse-distance-origin"/);
assert.match(warehouseModal, /id="warehouse-distance-miles"[^>]*type="number"[^>]*min="0"/);
assert.match(warehouseModal, /onclick="saveWarehouseForm\(\)"/);
const eventLog = [];
const showModalCalls = [];
const hideModalCalls = [];
const toastCalls = [];
const ownedTbody = makeTbody(eventLog, 'owned');
const fbaTbody = makeTbody(eventLog, 'fba');
const partnerTbody = makeTbody(eventLog, 'partner');
const fbaOwnedSelect = {value: '洛杉矶仓', innerHTML: ''};
const partnerOwnedSelect = {value: '洛杉矶仓', innerHTML: ''};
const elements = {
'gen-owned-warehouse-keyword': {value: ''},
'gen-owned-warehouse-country': {value: ''},
'tbody-gen-owned-warehouse': ownedTbody,
'gen-fba-owned-warehouse': fbaOwnedSelect,
'gen-fba-keyword': {value: ''},
'gen-fba-country': {value: ''},
'tbody-gen-fba': fbaTbody,
'gen-partner-warehouse-owned-warehouse': partnerOwnedSelect,
'gen-partner-warehouse-keyword': {value: ''},
'gen-partner-warehouse-country': {value: ''},
'tbody-gen-partner-warehouse': partnerTbody,
'warehouse-form-title': {textContent: ''},
'warehouse-owned-name-row': {style: {display: 'none'}},
'warehouse-code-row': {style: {display: ''}},
'warehouse-distance-row': {style: {display: 'none'}},
'warehouse-distance-origin': {textContent: ''},
'warehouse-owned-name': {value: ''},
'fba-add-country': {value: ''},
'fba-add-code': {value: ''},
'fba-add-city': {value: ''},
'fba-add-state': {value: ''},
'fba-add-addr': {value: ''},
'fba-add-zip': {value: ''},
'fba-add-tax-rate': {value: ''},
'fba-add-no-tax-rate': {value: ''},
'fba-add-inner-code': {value: ''},
'warehouse-distance-miles': {value: ''}
};
const context = {
document: {
getElementById: id => elements[id] || null,
querySelectorAll: selector => selector === '[data-owned-warehouse-select]' ? [fbaOwnedSelect, partnerOwnedSelect] : []
},
showModal: id => { showModalCalls.push(id); eventLog.push(['show', id]); },
hideModal: id => { hideModalCalls.push(id); eventLog.push(['hide', id]); },
showToast: (message, type) => { toastCalls.push([message, type]); eventLog.push(['toast', message, type]); }
};
const functionNames = [
'normalizeWarehouseTarget', 'getWarehousePageConfig', 'getNextWarehouseId', 'getNextOwnedWarehouseId',
'resetWarehouseForm', 'syncOwnedWarehouseOptions', 'setWarehouseFormFieldVisibility',
'filterWarehouseRows', 'formatGenFbaDistance', 'renderWarehousePage', 'renderOwnedWarehouse',
'renderGenFba', 'renderPartnerWarehouse', 'openWarehouseAdd', 'openWarehouseEdit', 'saveWarehouseForm'
];
const vmSource = [
extractArrayDeclaration(app, 'genOwnedWarehouseData'),
extractArrayDeclaration(app, 'genFbaData'),
extractArrayDeclaration(app, 'genPartnerWarehouseData'),
extractVariableDeclaration(app, 'warehouseFormState'),
...functionNames.map(name => extractFunction(app, name)),
'this.api={genOwnedWarehouseData,genFbaData,genPartnerWarehouseData,warehouseFormState,renderOwnedWarehouse,renderGenFba,renderPartnerWarehouse,openWarehouseAdd,openWarehouseEdit,saveWarehouseForm,syncOwnedWarehouseOptions};'
].join('\n');
vm.runInNewContext(vmSource, context);
assert.deepEqual(Array.from(context.api.genOwnedWarehouseData, row => row.name), ['洛杉矶仓', '纽约仓']);
for (const row of context.api.genOwnedWarehouseData) {
assert.equal(Object.hasOwn(row, 'distanceMiles'), false, '自有仓记录不得包含距离数据');
}
context.api.syncOwnedWarehouseOptions();
assert.match(fbaOwnedSelect.innerHTML, /洛杉矶仓/);
assert.match(fbaOwnedSelect.innerHTML, /纽约仓/);
context.api.renderOwnedWarehouse();
assert.match(ownedTbody.innerHTML, /洛杉矶仓/);
assert.doesNotMatch(ownedTbody.innerHTML, /MILES|distanceMiles/);
context.api.openWarehouseEdit('owned', 'OWN001');
assert.equal(elements['warehouse-owned-name-row'].style.display, '');
assert.equal(elements['warehouse-code-row'].style.display, 'none');
assert.equal(elements['warehouse-distance-row'].style.display, 'none');
assert.equal(elements['warehouse-owned-name'].value, '洛杉矶仓');
fbaOwnedSelect.value = '洛杉矶仓';
context.api.openWarehouseEdit('fba', 'FBA001');
assert.equal(elements['warehouse-distance-row'].style.display, '');
assert.equal(elements['warehouse-distance-origin'].textContent, '洛杉矶仓');
assert.equal(elements['warehouse-distance-miles'].value, '22');
elements['warehouse-distance-miles'].value = '31.5';
context.api.saveWarehouseForm();
assert.equal(context.api.genFbaData[0].distanceMiles['洛杉矶仓'], 31.5);
assert.equal(context.api.genFbaData[0].distanceMiles['纽约仓'], 2800);
assert.equal(context.api.genFbaData[1].distanceMiles['洛杉矶仓'], 73);
partnerOwnedSelect.value = '纽约仓';
context.api.openWarehouseEdit('partner', 'FBA008');
assert.equal(elements['warehouse-distance-origin'].textContent, '纽约仓');
assert.equal(elements['warehouse-distance-miles'].value, '2770');
elements['warehouse-distance-miles'].value = '';
context.api.saveWarehouseForm();
assert.equal(Object.hasOwn(context.api.genPartnerWarehouseData[0].distanceMiles, '纽约仓'), false);
assert.equal(context.api.genPartnerWarehouseData[0].distanceMiles['洛杉矶仓'], 48);
partnerOwnedSelect.value = '洛杉矶仓';
context.api.openWarehouseEdit('partner', 'FBA008');
const hideCountBeforeInvalid = hideModalCalls.length;
elements['warehouse-distance-miles'].value = '-1';
context.api.saveWarehouseForm();
assert.equal(context.api.genPartnerWarehouseData[0].distanceMiles['洛杉矶仓'], 48);
assert.equal(hideModalCalls.length, hideCountBeforeInvalid, '非法距离不得关闭弹窗');
assert.deepEqual(toastCalls.at(-1), ['距离必须为非负数', 'warning']);
fbaOwnedSelect.value = '洛杉矶仓';
context.api.openWarehouseAdd('fba');
assert.equal(elements['warehouse-distance-row'].style.display, '', 'FBA 新增必须显示距离字段');
assert.equal(elements['warehouse-distance-origin'].textContent, '洛杉矶仓');
elements['fba-add-country'].value = '美国';
elements['fba-add-code'].value = 'SEA8';
elements['fba-add-city'].value = 'Seattle';
elements['fba-add-state'].value = 'WA';
elements['fba-add-addr'].value = '100 Demo St';
elements['fba-add-zip'].value = '98101';
elements['warehouse-distance-miles'].value = '1135.5';
context.api.saveWarehouseForm();
const addedFba = context.api.genFbaData.at(-1);
assert.equal(addedFba.code, 'SEA8');
assert.deepEqual(Object.keys(addedFba.distanceMiles), ['洛杉矶仓']);
assert.equal(addedFba.distanceMiles['洛杉矶仓'], 1135.5);
partnerOwnedSelect.value = '纽约仓';
context.api.openWarehouseAdd('partner');
assert.equal(elements['warehouse-distance-row'].style.display, '', '合作仓新增必须显示距离字段');
assert.equal(elements['warehouse-distance-origin'].textContent, '纽约仓');
elements['fba-add-country'].value = '美国';
elements['fba-add-code'].value = 'EWR-DEMO';
elements['fba-add-city'].value = 'Newark';
elements['fba-add-state'].value = 'NJ';
elements['fba-add-addr'].value = '200 Demo Ave';
elements['fba-add-zip'].value = '07101';
elements['warehouse-distance-miles'].value = '18';
context.api.saveWarehouseForm();
const addedPartner = context.api.genPartnerWarehouseData.at(-1);
assert.equal(addedPartner.code, 'EWR-DEMO');
assert.deepEqual(Object.keys(addedPartner.distanceMiles), ['纽约仓']);
assert.equal(addedPartner.distanceMiles['纽约仓'], 18);
context.api.openWarehouseAdd('owned');
elements['warehouse-owned-name'].value = '芝加哥仓';
elements['fba-add-country'].value = '美国';
elements['fba-add-city'].value = 'Chicago';
elements['fba-add-state'].value = 'IL';
elements['fba-add-addr'].value = '100 Demo Warehouse Rd';
elements['fba-add-zip'].value = '60601';
context.api.saveWarehouseForm();
assert.equal(context.api.genOwnedWarehouseData.length, 3);
assert.equal(context.api.genOwnedWarehouseData.at(-1).name, '芝加哥仓');
assert.equal(Object.hasOwn(context.api.genOwnedWarehouseData.at(-1), 'distanceMiles'), false);
assert.match(fbaOwnedSelect.innerHTML, /芝加哥仓/);
assert.ok(showModalCalls.length >= 4, '新增编辑流程必须打开共享弹窗');
console.log('GEN_FBA_OWNED_DISTANCE_CHECK_PASS');
@@ -0,0 +1,30 @@
const fs = require('fs');
const path = require('path');
const root = path.resolve(__dirname, '..');
const moduleHtml = fs.readFileSync(path.join(root, 'prototype-src/modules/03-overseas-warehouse.html'), 'utf8');
const appJs = fs.readFileSync(path.join(root, 'prototype-src/app.js'), 'utf8');
function fail(message) {
console.error('FAIL:', message);
process.exit(1);
}
function expect(condition, message) {
if (!condition) fail(message);
}
const overview = moduleHtml.slice(moduleHtml.indexOf('<div class="page" id="page-inventory-overview">'), moduleHtml.indexOf('<!-- ==================== WAREHOUSE STOCK'));
const transactions = moduleHtml.slice(moduleHtml.indexOf('<div class="page" id="page-inventory-transactions">'), moduleHtml.indexOf('<!-- ==================== OVERSEAS WAREHOUSE - 采购库存详情'));
expect(!overview.includes('>批次数</th>'), 'inventory overview still exposes 批次数');
expect(!overview.includes('>库位数</th>'), 'inventory overview still exposes 库位数');
expect(!transactions.includes('<th>批次号</th>'), 'inventory transactions still expose 批次号');
expect(!transactions.includes('<th>库位</th>'), 'inventory transactions still expose 库位');
expect(/colspan="13"[^>]*>暂无符合条件的库存信息/.test(appJs), 'inventory overview empty row is not 13 visible columns');
expect(/colspan="14"[^>]*>暂无符合条件的进销存记录/.test(appJs), 'inventory transactions empty row is not 14 visible columns');
expect(/const batch=value\('wms-inventory-batch'\)/.test(appJs), 'inventory batch filter was removed');
expect(/const location=value\('wms-inventory-location'\)/.test(appJs), 'inventory location filter was removed');
expect(/batchNo:row\.batchNo\|\|'-'/.test(appJs), 'transaction batch data was removed');
expect(/location:row\.location\|\|'-'/.test(appJs), 'transaction location data was removed');
console.log('Inventory batch/location display checks passed.');
@@ -3,7 +3,7 @@
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.resolve(__dirname, '..', '货代SaaS平台原型.html'), 'utf8');
const html = fs.readFileSync(path.resolve(__dirname, '..', 'index.html'), 'utf8');
const failures = [];
function expect(condition, label) {
@@ -45,8 +45,8 @@ expect(Boolean(overview), 'missing WMS inventory overview section');
expect(!/wms-summary-pending|>待上架<|pendingPutawayStock/.test(overview), 'WMS overview still contains pending-putaway UI');
expect(!/待上架|pendingPutawayStock|wms-summary-pending/.test(wmsScript), 'unified WMS script still contains pending-putaway logic');
expect(!/待上架|pendingPutawayStock/.test(detail), 'WMS detail still contains pending-putaway summary');
expect(/min-width:2140px/.test(overview) && !/min-width:2280px/.test(overview), 'WMS table width is not 2140px');
expect(/colspan="15"[^>]*>暂无符合条件的库存信息/.test(wmsScript), 'WMS empty row is not 15 visible columns');
expect(/min-width:1860px/.test(overview), 'WMS table width is not 1860px');
expect(/colspan="13"[^>]*>暂无符合条件的库存信息/.test(wmsScript), 'WMS empty row is not 13 visible columns');
expect(/id="page-wh-inbound"[\s\S]*?>待上架</.test(html), 'inbound pending-putaway flow was removed');
expect(/id="page-wh-shelve"[\s\S]*?>待上架</.test(html), 'shelving pending-putaway flow was removed');
@@ -189,7 +189,7 @@ if (behaviorSources.every(Boolean)) {
runtime.renderUnifiedInventory([special]);
const inventoryMarkup = nodes.get('tbody-inventory-overview').innerHTML;
expect((inventoryMarkup.match(/<td\b/g)||[]).length === 15, 'rendered inventory row does not contain 15 visible cells');
expect((inventoryMarkup.match(/<td\b/g)||[]).length === 13, 'rendered inventory row does not contain 13 visible cells');
expect(!/onclick=|decodeURIComponent|待上架/.test(inventoryMarkup), 'inventory detail link still embeds executable data or pending-putaway text');
expect(/data-wms-action="stock-detail"/.test(inventoryMarkup), 'inventory detail link lacks delegated action data');
expect(inventoryMarkup.includes('O&#39;Reilly &amp; Sons'), 'special-character owner is not safely stored in inventory link data');
@@ -4,7 +4,9 @@ const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const html = fs.readFileSync(path.resolve(__dirname, '..', '货代SaaS平台原型.html'), 'utf8');
const root = path.resolve(__dirname, '..');
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'prototype-src', 'manifest.json'), 'utf8'));
const html = fs.readFileSync(path.join(root, manifest.source), 'utf8');
const failures = [];
function expect(condition, message) { if (!condition) failures.push(message); }
function section(startMarker, endMarker) {
@@ -0,0 +1,245 @@
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const moduleSource = fs.readFileSync(path.join(__dirname, '..', 'prototype-src', 'modules', '03-overseas-warehouse.html'), 'utf8');
const appSource = fs.readFileSync(path.join(__dirname, '..', 'prototype-src', 'app.js'), 'utf8');
const failures = [];
function expect(condition, message) {
if (!condition) failures.push(message);
}
function section(source, startMarker, endMarker) {
const start = source.indexOf(startMarker);
if (start < 0) return '';
const end = source.indexOf(endMarker, start + startMarker.length);
return end < 0 ? source.slice(start) : source.slice(start, end);
}
function skipQuoted(source, index, quote) {
let cursor = index + 1;
while (cursor < source.length) {
if (source[cursor] === '\\') cursor += 2;
else if (source[cursor] === quote) return cursor + 1;
else cursor += 1;
}
return source.length;
}
function previousToken(source, index) {
let cursor = index - 1;
while (cursor >= 0 && /\s/.test(source[cursor])) cursor -= 1;
const end = cursor + 1;
while (cursor >= 0 && /[\w$]/.test(source[cursor])) cursor -= 1;
return source.slice(cursor + 1, end);
}
function isRegexStart(source, index) {
const previous = previousToken(source, index);
return !previous || /^(?:return|case|throw|else|do|typeof|void|delete|new|in|of|yield|await)$/.test(previous) || /[([{:;,=!?&|+\-*~%^<>]/.test(previous.slice(-1));
}
function skipRegex(source, index) {
let cursor = index + 1;
let inCharacterClass = false;
while (cursor < source.length) {
if (source[cursor] === '\\') cursor += 2;
else if (source[cursor] === '[') { inCharacterClass = true; cursor += 1; }
else if (source[cursor] === ']' && inCharacterClass) { inCharacterClass = false; cursor += 1; }
else if (source[cursor] === '/' && !inCharacterClass) {
cursor += 1;
while (/[a-z]/i.test(source[cursor] || '')) cursor += 1;
return cursor;
} else cursor += 1;
}
return source.length;
}
function skipTemplateExpression(source, index) {
let depth = 1;
let cursor = index;
while (cursor < source.length && depth > 0) {
const char = source[cursor];
if (char === "'" || char === '"') cursor = skipQuoted(source, cursor, char);
else if (char === '`') cursor = skipTemplate(source, cursor);
else if (char === '/' && source[cursor + 1] === '/') {
const newline = source.indexOf('\n', cursor + 2);
cursor = newline < 0 ? source.length : newline + 1;
} else if (char === '/' && source[cursor + 1] === '*') {
const close = source.indexOf('*/', cursor + 2);
cursor = close < 0 ? source.length : close + 2;
} else if (char === '/' && isRegexStart(source, cursor)) cursor = skipRegex(source, cursor);
else if (char === '{') { depth += 1; cursor += 1; }
else if (char === '}') { depth -= 1; cursor += 1; }
else cursor += 1;
}
return cursor;
}
function skipTemplate(source, index) {
let cursor = index + 1;
while (cursor < source.length) {
if (source[cursor] === '\\') cursor += 2;
else if (source[cursor] === '`') return cursor + 1;
else if (source[cursor] === '$' && source[cursor + 1] === '{') cursor = skipTemplateExpression(source, cursor + 2);
else cursor += 1;
}
return source.length;
}
function functionSource(name) {
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const declaration = new RegExp('(^|\\n)[\\t ]*function[\\t ]+' + escapedName + '[\\t ]*\\(', 'm').exec(appSource);
if (!declaration) return '';
const start = declaration.index + declaration[0].indexOf('function');
const openBrace = appSource.indexOf('{', start + declaration[0].length);
if (openBrace < 0) return '';
let depth = 1;
let cursor = openBrace + 1;
while (cursor < appSource.length && depth > 0) {
const char = appSource[cursor];
if (char === "'" || char === '"') cursor = skipQuoted(appSource, cursor, char);
else if (char === '`') cursor = skipTemplate(appSource, cursor);
else if (char === '/' && appSource[cursor + 1] === '/') {
const newline = appSource.indexOf('\n', cursor + 2);
cursor = newline < 0 ? appSource.length : newline + 1;
} else if (char === '/' && appSource[cursor + 1] === '*') {
const close = appSource.indexOf('*/', cursor + 2);
cursor = close < 0 ? appSource.length : close + 2;
} else if (char === '/' && isRegexStart(appSource, cursor)) cursor = skipRegex(appSource, cursor);
else if (char === '{') { depth += 1; cursor += 1; }
else if (char === '}') { depth -= 1; cursor += 1; }
else cursor += 1;
}
return depth === 0 ? appSource.slice(start, cursor) : '';
}
function expectExtractedFunction(name) {
const source = functionSource(name);
expect(Boolean(source), name + ' is defined');
if (source) {
try { new Function('return (' + source + ');'); }
catch (error) { expect(false, name + ' extraction is syntactically valid: ' + error.message); }
}
return source;
}
const lastMilePage = section(moduleSource, 'id="page-last-mile-orders"', 'id="page-last-mile-order-detail"');
const listTable = section(lastMilePage, '<table', '</table>');
const listHeader = section(listTable, '<thead', '</thead>');
const headerCount = (listHeader.match(/<th\b/g) || []).length;
expect(lastMilePage.length > 0, 'tail-order list page section is present');
expect(/尾程单号<\/th>\s*<th[^>]*>预警<\/th>\s*<th[^>]*>订单类型<\/th>\s*<th[^>]*>拒收时间<\/th>\s*<th[^>]*>入仓码<\/th>/.test(listHeader), 'tail-order header sequence is exactly 尾程单号, 预警, 订单类型, 拒收时间, 入仓码');
expect(headerCount === 33, 'tail-order list has 33 table headers');
const orderTypeSelect = section(moduleSource, 'id="lmo-order-type"', '</select>');
expect(orderTypeSelect.includes('拒收订单') && orderTypeSelect.includes('快递订单'), 'lmo-order-type contains 拒收订单 and 快递订单');
expect(/#page-last-mile-orders\s+\.lmo-warning-list\b/.test(moduleSource), 'module CSS scopes .lmo-warning-list to the tail-order page');
const fieldConfigSource = section(appSource, 'const lastMileFieldConfig = [', '\n];');
const completeFieldConfigSource = fieldConfigSource ? fieldConfigSource + '\n];' : '';
expect(/\{\s*key:'rejectionTime'\s*,\s*label:'拒收时间'[^}]*\}/.test(completeFieldConfigSource), 'lastMileFieldConfig contains rejectionTime / 拒收时间');
expect(/\{\s*key:'actualInboundTime'\s*,\s*label:'实际入库时间'[^}]*\}/.test(completeFieldConfigSource), 'lastMileFieldConfig contains actualInboundTime / 实际入库时间');
const createSource = expectExtractedFunction('createLastMileOrderFromFirstLeg');
expect(/rejectionTime\s*:\s*firstLegOrder\.rejectionTime\s*\|\|\s*''/.test(createSource), 'createLastMileOrderFromFirstLeg copies rejectionTime from first-leg order');
expect(/actualInboundTime\s*:\s*firstLegOrder\.actualInboundTime\s*\|\|\s*''/.test(createSource), 'createLastMileOrderFromFirstLeg copies actualInboundTime from first-leg order');
const containerSamplesSource = section(appSource, 'const ctListData = [', '\n];');
const firstLegSamplesSource = section(appSource, 'const lastMileFirstLegSamples = [', '\n];');
const firstLegSample = no => section(firstLegSamplesSource, `{firstLegNo:'${no}'`, '\n');
expect(/no:'FFAU6685194'[^\n]*isAbnormal:true[^\n]*abnormalReason:'箱体右侧破损,待处理'/.test(containerSamplesSource), 'container samples retain the abnormal FFAU6685194 scenario');
expect(/serviceType:'拒收订单'[^\n]*containerNo:'FFAU6685194'[^\n]*rejectionTime:'2026-08-12 14:30'/.test(firstLegSample('HC20260801001')), 'first tail-order sample retains combined rejection and abnormal-container scenario');
expect(/serviceType:'快递订单'[^\n]*actualInboundTime:'2026-08-10 08:00'/.test(firstLegSample('HC20260801006')) && !firstLegSample('HC20260801006').includes('outboundNo:'), 'courier sample retains overdue in-warehouse scenario');
expect(/serviceType:'快递订单'[^\n]*actualInboundTime:'2026-08-07 08:00'[^\n]*outboundNo:'FBO20260807001'/.test(firstLegSample('HC20260801004')), 'shipped courier sample retains timeout-cancellation scenario');
expect(firstLegSample('HC20260801003').includes("containerNo:'CMAU26080103'") && !firstLegSample('HC20260801003').includes('rejectionTime:') && !firstLegSample('HC20260801003').includes('actualInboundTime:'), 'first-leg samples retain a normal no-warning scenario');
const listSource = expectExtractedFunction('renderLastMileOrders');
expect(listSource.includes('renderLastMileWarningTags(order)'), 'renderLastMileOrders calls renderLastMileWarningTags(order)');
expect(/order\.rejectionTime/.test(listSource), 'renderLastMileOrders renders order.rejectionTime');
const emptyColspan = /colspan="(\d+)"/.exec(listSource);
expect(Boolean(emptyColspan) && Number(emptyColspan[1]) === headerCount, 'renderLastMileOrders empty-state colspan matches the structural header count');
const detailSource = expectExtractedFunction('renderLastMileOrderDetail');
expect(/lmoInfo\('拒收时间'\s*,\s*order\.rejectionTime\)/.test(detailSource), 'detail binds 拒收时间 to order.rejectionTime');
expect(/lmoInfo\('实际入库时间'\s*,\s*order\.actualInboundTime\)/.test(detailSource), 'detail binds 实际入库时间 to order.actualInboundTime');
const csvFunctionNames = ['getLastMileFieldDefaults', 'getLastMileField', 'getSelectedImportFieldKeys', 'getLastMileFieldValue', 'csvEscape', 'buildLastMileCsv', 'splitLastMileCsvLine', 'parseLastMileCsv'];
const csvSources = Object.fromEntries(csvFunctionNames.map(name => [name, expectExtractedFunction(name)]));
if (completeFieldConfigSource && csvFunctionNames.every(name => csvSources[name])) {
try {
const csvContext = { console };
vm.createContext(csvContext);
vm.runInContext([
completeFieldConfigSource,
'const orderedLastMileFieldConfig=lastMileFieldConfig.slice().sort((a,b)=>a.order-b.order);',
csvSources.getLastMileFieldDefaults,
'const lastMileFieldSelectionStore={import:getLastMileFieldDefaults(\'import\'),export:getLastMileFieldDefaults(\'export\')};',
'const lastMileOrderData=[];',
csvSources.getLastMileField,
csvSources.getSelectedImportFieldKeys,
csvSources.getLastMileFieldValue,
csvSources.csvEscape,
csvSources.buildLastMileCsv,
csvSources.splitLastMileCsvLine,
csvSources.parseLastMileCsv,
'this.__lastMileCsv={parseLastMileCsv,buildLastMileCsv};'
].join('\n'), csvContext, { filename: 'last-mile-csv-functions.js' });
const selectedKeys = ['orderType', 'firstLegNo', 'owner', 'warehouse', 'fbaWarehouse', 'rejectionTime', 'actualInboundTime'];
const csv = ['订单类型,头程单号,货主,当前仓,FBA仓库,拒收时间,实际入库时间', '拒收订单,HC-REJECT,客户甲,洛杉矶长滩海外仓,FBA ONT8,2026-08-01 08:00,2026-08-01 09:00', '快递订单,HC-COURIER,客户乙,纽约海外仓,FBA CLT2,,2026-08-01 10:00'].join('\n');
const parsed = csvContext.__lastMileCsv.parseLastMileCsv(csv, selectedKeys);
expect(parsed.length === 2 && parsed.every(row => row.valid), 'parseLastMileCsv returns valid 拒收订单 and 快递订单 rows');
expect(parsed[0] && parsed[0].data.rejectionTime === '2026-08-01 08:00' && parsed[0].data.actualInboundTime === '2026-08-01 09:00', 'parseLastMileCsv maps rejectionTime and actualInboundTime into data');
const exported = csvContext.__lastMileCsv.buildLastMileCsv([parsed[0] && parsed[0].data || {}], selectedKeys);
expect(exported.includes('"拒收时间"') && exported.includes('"实际入库时间"') && exported.includes('"2026-08-01 08:00"') && exported.includes('"2026-08-01 09:00"'), 'buildLastMileCsv exports selected warning fields and values');
} catch (error) {
expect(false, 'CSV VM execution succeeds: ' + error.message);
}
}
const warningFunctionNames = ['parseLastMileTime', 'getLastMileWarnings', 'renderLastMileWarningTags'];
const warningSources = Object.fromEntries(warningFunctionNames.map(name => [name, expectExtractedFunction(name)]));
const lmoEscSource = expectExtractedFunction('lmoEsc');
if (lmoEscSource && warningFunctionNames.every(name => warningSources[name])) {
const abnormalContainers = new Set(['ABNORMAL-1', 'ABNORMAL-XSS']);
const context = {
getContainerAbnormalState(containerNo) {
if (!abnormalContainers.has(containerNo)) return { isAbnormal: false, reason: '' };
return { isAbnormal: true, reason: containerNo === 'ABNORMAL-XSS' ? '柜门 <异常>' : '柜门损坏' };
}
};
try {
vm.createContext(context);
vm.runInContext([lmoEscSource, ...warningFunctionNames.map(name => warningSources[name])].join('\n'), context, { filename: 'last-mile-warning-functions.js' });
const now = '2026-08-03T00:00:00Z';
const courier = overrides => ({ serviceType: '快递订单', actualInboundTime: '2026-08-01T00:01:00Z', outboundNo: '', rejectionTime: '', containerNo: '', ...overrides });
const warningTypes = order => context.getLastMileWarnings(order, now).map(warning => warning.type);
expect(!warningTypes(courier()).includes('warehouse-timeout'), 'courier at 47h59m has no warehouse-timeout warning');
const atThreshold = context.getLastMileWarnings(courier({ actualInboundTime: '2026-08-01T00:00:00Z' }), now);
expect(atThreshold.some(warning => warning.type === 'warehouse-timeout' && warning.label === '在库超时 48h'), 'courier at exactly 48h has warehouse-timeout label 在库超时 48h');
expect(!warningTypes(courier({ outboundNo: 'OUT-001', actualInboundTime: '2026-08-01T00:00:00Z' })).includes('warehouse-timeout'), 'courier with outboundNo has no warehouse-timeout warning');
expect(!warningTypes(courier({ actualInboundTime: '' })).includes('warehouse-timeout'), 'courier missing actualInboundTime has no warehouse-timeout warning');
expect(!warningTypes(courier({ actualInboundTime: 'not-a-date' })).includes('warehouse-timeout'), 'courier with unparseable actualInboundTime has no warehouse-timeout warning');
expect(!warningTypes(courier({ actualInboundTime: '2026-02-30 00:00' })).includes('warehouse-timeout'), 'courier with calendar-invalid February date has no warehouse-timeout warning');
expect(!warningTypes(courier({ actualInboundTime: '2026-04-31 00:00' })).includes('warehouse-timeout'), 'courier with Date.parse-normalized April date has no warehouse-timeout warning');
const combined = courier({ serviceType: '拒收订单', containerNo: 'ABNORMAL-1', rejectionTime: '2026-08-01T00:00:00Z' });
expect(warningTypes(combined).slice(0, 2).join(',') === 'container,rejection', 'abnormal-container rejection warnings are ordered container,rejection');
const combinedHtml = context.renderLastMileWarningTags(combined, now);
expect(combinedHtml.includes('货柜异常') && combinedHtml.includes('拒收') && (combinedHtml.match(/<span\b[^>]*>/g) || []).length === 2, 'warning renderer emits two separate tags for the combined warning');
const escapedTitle = context.renderLastMileWarningTags(courier({ serviceType: '头程', actualInboundTime: '', containerNo: 'ABNORMAL-XSS' }), now);
expect(escapedTitle.includes('title=') && escapedTitle.includes('&lt;异常&gt;') && !escapedTitle.includes('<异常>'), 'warning renderer escapes dynamic warning titles');
expect(context.renderLastMileWarningTags(courier({ serviceType: '头程', actualInboundTime: '', containerNo: '' }), now) === '—', 'normal order warning renderer returns exact em dash placeholder');
} catch (error) {
expect(false, 'warning function VM execution succeeds: ' + error.message);
}
}
if (failures.length) {
console.error('LAST_MILE_ORDER_WARNINGS_CHECK_FAIL');
failures.forEach(message => console.error('- ' + message));
process.exitCode = 1;
} else {
console.log('LAST_MILE_ORDER_WARNINGS_CHECK_PASS');
}
@@ -4,7 +4,9 @@ const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const html = fs.readFileSync(path.resolve(__dirname, '..', '货代SaaS平台原型.html'), 'utf8');
const root = path.resolve(__dirname, '..');
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'prototype-src', 'manifest.json'), 'utf8'));
const html = fs.readFileSync(path.join(root, manifest.source), 'utf8');
const failures = [];
function requireMatch(pattern, label, source = html) {
@@ -75,8 +77,8 @@ requireMatch(/id="page-last-mile-orders"/, '尾程订单列表页面');
requireMatch(/id="page-last-mile-order-detail"/, '尾程订单详情页面');
requireMatch(/id="lmo-detail-order-details"/, '尾程订单明细模块');
requireMatch(/id="tbody-last-mile-orders"/, '尾程订单列表表体');
requireMatch(/openBolModal\([^)]*\)[^>]*>转派送<\/button>/, '排车列表转派送按钮');
if (/openBolModal\([^)]*\)[^>]*>转BOL<\/button>/.test(html)) failures.push('排车列表仍显示转BOL按钮');
requireMatch(/createAction\(['"]转派送['"],\s*\(\)\s*=>\s*openBolModal\(v\.vehicleNo\),\s*true\)/, '排车列表转派送按钮');
if (/createAction\(['"]转BOL['"],\s*\(\)\s*=>\s*openBolModal\(/.test(html)) failures.push('排车列表仍显示转BOL按钮');
const lastMilePageStart = html.indexOf('<div class="page" id="page-last-mile-orders">');
const lastMilePageEnd = html.indexOf('<!-- ==================== OVERSEAS WAREHOUSE - 尾程订单详情', lastMilePageStart);
const lastMilePage = lastMilePageStart >= 0 && lastMilePageEnd > lastMilePageStart
@@ -106,12 +108,12 @@ requireMatch(/id="lmo-status"/, '尾程状态查询条件');
const consolidatedFilterMatch = lastMilePage.match(/<select(?=[^>]*\bid=["']lmo-consolidated-filter["'])[^>]*>[\s\S]*?<\/select>/);
const consolidatedFilterSource = consolidatedFilterMatch ? consolidatedFilterMatch[0] : '';
requireMatch(/<select(?=[^>]*\bid=["']lmo-consolidated-filter["'])[^>]*>/, '是否合托查询条件', lastMilePage);
requireMatch(/<option[^>]*value=["']["'][^>]*>\s*全部\s*<\/option>/, '是否合托查询全部选项', consolidatedFilterSource);
requireMatch(/<option[^>]*value=["']["'][^>]*>\s*是否合托\s*<\/option>/, '是否合托查询默认选项', consolidatedFilterSource);
requireMatch(/<option[^>]*value=["']yes["'][^>]*>\s*是\s*<\/option>/, '是否合托查询是选项', consolidatedFilterSource);
requireMatch(/<option[^>]*value=["']no["'][^>]*>\s*否\s*<\/option>/, '是否合托查询否选项', consolidatedFilterSource);
requireMatch(/<label>预计到港时间<\/label>[\s\S]{0,240}id="lmo-port-eta-start"[^>]*type="date"[\s\S]{0,160}id="lmo-port-eta-end"[^>]*type="date"/, '预计到港时间范围查询条件', lastMilePage);
requireMatch(/class="lmo-search-field lmo-search-date-field"/, '预计到港时间窄屏跨列标记', lastMilePage);
requireMatch(/@media\(max-width:900px\)[^{]*\{[\s\S]{0,500}\.lmo-search-date-field\s*\{\s*grid-column:1\/-1/, '预计到港时间窄屏跨列样式');
requireMatch(/class="lmo-search-date-range"[\s\S]{0,160}id="lmo-port-eta-start"[^>]*type="date"[^>]*aria-label="预计到港开始时间"[\s\S]{0,160}id="lmo-port-eta-end"[^>]*type="date"[^>]*aria-label="预计到港结束时间"/, '预计到港时间范围查询条件', lastMilePage);
requireMatch(/#page-last-mile-orders \.lmo-search-date-range\{display:flex;align-items:center;gap:6px/, '预计到港时间范围布局样式');
requireMatch(/#page-last-mile-orders \.lmo-search-date-range input\{flex:0 0 150px;width:150px\}/, '预计到港时间输入框稳定宽度');
if (/流程进度|id="lmo-detail-progress"/.test(lastMileDetailPage)) failures.push('尾程订单详情仍显示流程进度');
for (const label of ['全部', '待分配']) {
if (!lastMilePage.includes(`data-lmo-tab="${label}"`)) failures.push(`missing 尾程订单页签 ${label}`);
@@ -4,7 +4,9 @@ const fs=require('node:fs');
const path=require('node:path');
const vm=require('node:vm');
const html=fs.readFileSync(path.resolve(__dirname,'..','货代SaaS平台原型.html'),'utf8');
const root=path.resolve(__dirname,'..');
const manifest=JSON.parse(fs.readFileSync(path.join(root,'prototype-src','manifest.json'),'utf8'));
const html=fs.readFileSync(path.join(root,manifest.source),'utf8');
const failures=[];
function expect(condition,message){if(!condition)failures.push(message);}
function section(startMarker,endMarker){
@@ -46,7 +48,7 @@ expect(/openFbaRelabelOrderDetail/.test(renderList),'列表换标单号应进入
expect(/换标状态/.test(renderDetail),'尾程详情计划信息应显示换标状态');
expect(/openFbaRelabelOrderDetail/.test(renderDetail),'尾程详情关联换标单应进入换标单详情');
expect(/lastMileNo\s*:\s*'LMO20260810001'/.test(html),'应提供尾程订单换标关联样例');
expect(/colspan="31"/.test(renderList),'列表空状态应覆盖新增后的 31 列');
expect(/colspan="33"/.test(renderList),'列表空状态应覆盖新增后的 33 列');
if(getRelabel&&statusClass){
try{
@@ -0,0 +1,329 @@
#!/usr/bin/env node
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
const sourceRoot = path.join(root, 'prototype-src');
const readSource = (relativePath) => fs.readFileSync(path.join(sourceRoot, relativePath), 'utf8');
const manifest = JSON.parse(readSource('manifest.json'));
const shell = readSource('shell.html');
const app = readSource('app.js');
const moduleSources = new Map(
manifest.modules.map((module) => [module.file, readSource(module.file)]),
);
const domesticHtml = moduleSources.get('modules/02-domestic-warehouse.html');
const overseasHtml = moduleSources.get('modules/03-overseas-warehouse.html');
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function getAttribute(tag, attribute) {
const match = new RegExp(`\\b${escapeRegExp(attribute)}\\s*=\\s*(["'])(.*?)\\1`, 'i').exec(tag);
return match ? match[2] : null;
}
function hasClass(tag, className) {
return (getAttribute(tag, 'class') || '').split(/\s+/).includes(className);
}
function countIdOccurrences(source, id) {
const idPattern = /\bid\s*=\s*(["'])(.*?)\1/gi;
return [...source.matchAll(idPattern)].filter((match) => match[2] === id).length;
}
function countExact(source, text) {
let count = 0;
let cursor = 0;
while ((cursor = source.indexOf(text, cursor)) !== -1) {
count += 1;
cursor += text.length;
}
return count;
}
function getPageSource(source, pageId, moduleLabel) {
const openingDiv = /<div\b[^>]*>/gi;
let match;
let start = -1;
while ((match = openingDiv.exec(source))) {
if (hasClass(match[0], 'page') && getAttribute(match[0], 'id') === `page-${pageId}`) {
start = match.index;
break;
}
}
assert.notEqual(start, -1, `${moduleLabel}找不到页面起点 page-${pageId}`);
while ((match = openingDiv.exec(source))) {
if (hasClass(match[0], 'page') && (getAttribute(match[0], 'id') || '').startsWith('page-')) {
return source.slice(start, match.index);
}
}
return source.slice(start);
}
function getDivBlock(source, start, label) {
const tags = /<div\b[^>]*>|<\/div\s*>/gi;
tags.lastIndex = start;
let depth = 0;
let match;
while ((match = tags.exec(source))) {
if (match.index === start || depth > 0) {
depth += /^<\/div/i.test(match[0]) ? -1 : 1;
if (depth === 0) return source.slice(start, tags.lastIndex);
}
}
assert.fail(`${label}的 div 结构未闭合`);
}
function getSidebarBlock(source, label) {
const openingDiv = /<div\b[^>]*>/gi;
const blocks = [];
let match;
while ((match = openingDiv.exec(source))) {
if (!hasClass(match[0], 'sidebar-item')) continue;
const block = getDivBlock(source, match.index, `${label}侧栏`);
if (block.includes(`>${label}<span class="arrow">`)) blocks.push(block);
}
assert.equal(blocks.length, 1, `shell 中应恰好有一个${label}侧栏块`);
return blocks[0];
}
function getHeaders(pageSource, pageId) {
const head = pageSource.match(/<thead\b[^>]*>([\s\S]*?)<\/thead>/i);
assert.ok(head, `page-${pageId} 缺少表头`);
return [...head[1].matchAll(/<th\b[^>]*>([\s\S]*?)<\/th>/gi)].map((match) =>
match[1].replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(),
);
}
function getBracedBody(source, openBrace, label) {
let depth = 0;
let quote = '';
let lineComment = false;
let blockComment = false;
for (let index = openBrace; index < source.length; index += 1) {
const char = source[index];
const next = source[index + 1];
if (lineComment) {
if (char === '\n') lineComment = false;
continue;
}
if (blockComment) {
if (char === '*' && next === '/') {
blockComment = false;
index += 1;
}
continue;
}
if (quote) {
if (char === '\\') {
index += 1;
} else if (char === quote) {
quote = '';
}
continue;
}
if (char === '/' && next === '/') {
lineComment = true;
index += 1;
continue;
}
if (char === '/' && next === '*') {
blockComment = true;
index += 1;
continue;
}
if (char === '"' || char === "'" || char === '`') {
quote = char;
continue;
}
if (char === '{') depth += 1;
if (char === '}') {
depth -= 1;
if (depth === 0) return source.slice(openBrace + 1, index);
}
}
assert.fail(`${label}的代码块未闭合`);
}
function getFunctionBody(source, functionName) {
const signature = new RegExp(`function\\s+${escapeRegExp(functionName)}\\s*\\([^)]*\\)\\s*\\{`).exec(source);
assert.ok(signature, `app.js 缺少 ${functionName} 函数`);
const openBrace = signature.index + signature[0].lastIndexOf('{');
return getBracedBody(source, openBrace, functionName);
}
function getConstObjectBody(source, constName) {
const declaration = new RegExp(`const\\s+${escapeRegExp(constName)}\\s*=\\s*\\{`).exec(source);
assert.ok(declaration, `app.js 缺少 ${constName} 配置对象`);
const openBrace = declaration.index + declaration[0].lastIndexOf('{');
return getBracedBody(source, openBrace, constName);
}
const domesticModule = manifest.modules.find((module) => module.file === 'modules/02-domestic-warehouse.html');
const overseasModule = manifest.modules.find((module) => module.file === 'modules/03-overseas-warehouse.html');
assert.ok(domesticModule, 'manifest 缺少国内仓模块');
assert.ok(overseasModule, 'manifest 缺少海外仓模块');
assert.ok(domesticModule.pages.includes('domestic-location'), '国内仓模块缺少 domestic-location');
assert.ok(!domesticModule.pages.includes('overseas-location'), '国内仓模块不应包含 overseas-location');
assert.ok(overseasModule.pages.includes('overseas-location'), '海外仓模块缺少 overseas-location');
for (const [pageId, expectedOwner] of [
['domestic-location', 'modules/02-domestic-warehouse.html'],
['overseas-location', 'modules/03-overseas-warehouse.html'],
]) {
const owners = manifest.modules.filter((module) => module.pages.includes(pageId));
assert.equal(owners.length, 1, `${pageId} 在 manifest 中必须恰好归属一个模块`);
assert.equal(owners[0].file, expectedOwner, `${pageId} 的 manifest 模块归属错误`);
const sourceOccurrences = [...moduleSources.values()].reduce(
(total, source) => total + countIdOccurrences(source, `page-${pageId}`),
0,
);
assert.equal(sourceOccurrences, 1, `page-${pageId} 在全部模块源码中必须恰好出现一次`);
}
assert.equal(countIdOccurrences(domesticHtml, 'page-domestic-location'), 1, '国内仓模块必须有且仅有一个 page-domestic-location');
assert.equal(countIdOccurrences(domesticHtml, 'page-overseas-location'), 0, '国内仓模块不应包含 page-overseas-location');
assert.equal(countIdOccurrences(overseasHtml, 'page-overseas-location'), 1, '海外仓模块必须有且仅有一个 page-overseas-location');
const domesticCall = "switchPage('domestic-location',this)";
const overseasCall = "switchPage('overseas-location',this)";
assert.equal(countExact(shell, domesticCall), 1, `shell 中 ${domesticCall} 应恰好出现一次`);
assert.equal(countExact(shell, overseasCall), 1, `shell 中 ${overseasCall} 应恰好出现一次`);
const domesticSidebar = getSidebarBlock(shell, '国内仓');
const overseasSidebar = getSidebarBlock(shell, '海外仓');
assert.equal(countExact(domesticSidebar, domesticCall), 1, 'domestic-location 导航必须位于国内仓侧栏块');
assert.equal(countExact(overseasSidebar, overseasCall), 1, 'overseas-location 导航必须位于海外仓侧栏块');
function getSidebarPageOrder(sidebar) {
return [...sidebar.matchAll(/class=["']sub-menu-item[^"']*["'][^>]*onclick=["']switchPage\(["']([^"']+)["']/g)]
.map((match) => match[1]);
}
const domesticSidebarOrder = getSidebarPageOrder(domesticSidebar);
const overseasSidebarOrder = getSidebarPageOrder(overseasSidebar);
assert.equal(
domesticSidebarOrder.indexOf('domestic-location'),
domesticSidebarOrder.indexOf('pallet-manage') + 1,
'国内仓库位管理必须紧跟托盘管理',
);
assert.equal(
overseasSidebarOrder.indexOf('overseas-location'),
overseasSidebarOrder.indexOf('fba-pallet') + 1,
'海外仓库位管理必须紧跟托盘管理',
);
const domesticPage = getPageSource(domesticHtml, 'domestic-location', '国内仓模块');
const overseasPage = getPageSource(overseasHtml, 'overseas-location', '海外仓模块');
const expectedHeaders = ['操作', '库位编码', '仓库', '库区', '库位类型', '最大承重(kg)', '状态', '备注'];
assert.deepEqual(getHeaders(domesticPage, 'domestic-location'), expectedHeaders, '国内仓库位管理表头不符合契约');
assert.deepEqual(getHeaders(overseasPage, 'overseas-location'), expectedHeaders, '海外仓库位管理表头不符合契约');
const locationControlSuffixes = [
'wh',
'zone',
'code',
'status',
'code-in',
'wh-in',
'zone-in',
'type-in',
'weight-in',
'status-in',
'remark-in',
];
function assertLocationControlIds(pageSource, prefix, pageLabel) {
for (const suffix of locationControlSuffixes) {
const id = `${prefix}-${suffix}`;
assert.equal(countIdOccurrences(pageSource, id), 1, `${pageLabel}必须有且仅有一个控件 ID ${id}`);
}
}
assertLocationControlIds(domesticPage, 'dom-loc', '国内仓库位管理页面');
assertLocationControlIds(overseasPage, 'ovs-loc', '海外仓库位管理页面');
for (const contract of [
{
page: domesticPage,
module: domesticHtml,
label: '国内仓',
ids: ['modal-domestic-location', 'dom-loc-modal-title', 'tbody-domestic-location', 'dom-loc-total'],
},
{
page: overseasPage,
module: overseasHtml,
label: '海外仓',
ids: ['modal-overseas-location', 'ovs-loc-modal-title', 'tbody-overseas-location', 'ovs-loc-total'],
},
]) {
for (const id of contract.ids) {
assert.equal(countIdOccurrences(contract.page, id), 1, `${contract.label}库位页面必须有且仅有一个 ID ${id}`);
assert.equal(countIdOccurrences(contract.module, id), 1, `${contract.label}模块必须有且仅有一个 ID ${id}`);
}
}
function assertIndependentArrayInitializer(name) {
const declarations = new RegExp(`\\bconst\\s+${escapeRegExp(name)}\\s*=`, 'g');
assert.equal(countOccurrences(app, declarations), 1, `app.js 必须有且仅有一个 ${name} 声明`);
const initializer = new RegExp(
`\\bconst\\s+${escapeRegExp(name)}\\s*=\\s*(?:\\[|\\(\\s*function\\s*\\()`,
);
assert.match(app, initializer, `${name} 必须使用独立数组初始化器,不能引用另一数据集`);
}
function countOccurrences(source, pattern) {
return [...source.matchAll(pattern)].length;
}
assertIndependentArrayInitializer('domesticLocationData');
assertIndependentArrayInitializer('overseasLocationData');
const configBody = getConstObjectBody(app, 'locationManagementConfigs');
function getScopeConfig(scope) {
const match = new RegExp(
`(?:^|,)\\s*(?:${escapeRegExp(scope)}|["']${escapeRegExp(scope)}["'])\\s*:\\s*\\{([\\s\\S]*?)\\}(?=\\s*(?:,|$))`,
).exec(configBody);
assert.ok(match, `locationManagementConfigs 缺少 ${scope} 配置`);
return match[1];
}
assert.match(getScopeConfig('domestic'), /\bdata\s*:\s*domesticLocationData\b/, 'domestic 配置必须绑定 domesticLocationData');
assert.match(getScopeConfig('overseas'), /\bdata\s*:\s*overseasLocationData\b/, 'overseas 配置必须绑定 overseasLocationData');
assert.ok(app.includes('库位编码已存在'), 'app.js 缺少库位编码重复校验提示');
assert.ok(app.includes('最大承重不能小于 0'), 'app.js 缺少最大承重非负校验提示');
assert.match(app, /function\s+renderDomesticLocation\s*\(/, 'app.js 缺少 renderDomesticLocation 函数');
assert.match(app, /function\s+renderOverseasLocation\s*\(/, 'app.js 缺少 renderOverseasLocation 函数');
assert.ok(
shell.includes('#modal-domestic-location .form-row,#modal-overseas-location .form-row{grid-template-columns:minmax(0,1fr)'),
'国内仓和海外仓库位弹窗在窄屏下必须切换为单列表单',
);
const switchPageBody = getFunctionBody(app, 'switchPage');
assert.match(
switchPageBody,
/if\s*\(\s*page\s*===\s*["']domestic-location["']\s*\)\s*renderDomesticLocation\s*\(/,
'switchPage 缺少 domestic-location 初始化钩子',
);
assert.match(
switchPageBody,
/if\s*\(\s*page\s*===\s*["']overseas-location["']\s*\)\s*renderOverseasLocation\s*\(/,
'switchPage 缺少 overseas-location 初始化钩子',
);
console.log('库位管理结构与交互契约验证通过。');
@@ -0,0 +1,444 @@
#!/usr/bin/env node
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const root = path.resolve(__dirname, '..');
const sourceRoot = path.join(root, 'prototype-src');
const generalHtml = fs.readFileSync(path.join(sourceRoot, 'modules', '10-general.html'), 'utf8');
const shellHtml = fs.readFileSync(path.join(sourceRoot, 'shell.html'), 'utf8');
const overlaysHtml = fs.readFileSync(path.join(sourceRoot, 'overlays.html'), 'utf8');
const appJs = fs.readFileSync(path.join(sourceRoot, 'app.js'), 'utf8');
const manifest = JSON.parse(fs.readFileSync(path.join(sourceRoot, 'manifest.json'), 'utf8'));
function maskNonCode(source) {
let output = '';
let state = 'code';
let escaped = false;
const regexCanStartAfter = /[([{,:;=!?&|+\-*%^~<>]/;
const controlKeywords = new Set(['if', 'while', 'for', 'with', 'switch', 'catch']);
const parenthesisStack = [];
let previousSignificant = '';
let previousWord = '';
let regexAllowedAfterControlParen = false;
for (let index = 0; index < source.length; index += 1) {
const char = source[index];
const next = source[index + 1];
if (state === 'line-comment') {
output += char === '\n' || char === '\r' ? char : ' ';
if (char === '\n' || char === '\r') state = 'code';
continue;
}
if (state === 'block-comment') {
output += char === '\n' || char === '\r' ? char : ' ';
if (char === '*' && next === '/') {
output += ' ';
index += 1;
state = 'code';
}
continue;
}
if (state === 'regex' || state === 'regex-class') {
output += char === '\n' || char === '\r' ? char : ' ';
if (escaped) {
escaped = false;
} else if (char === '\\') {
escaped = true;
} else if (state === 'regex' && char === '[') {
state = 'regex-class';
} else if (state === 'regex-class' && char === ']') {
state = 'regex';
} else if (state === 'regex' && char === '/') {
state = 'code';
previousSignificant = '/';
previousWord = '';
regexAllowedAfterControlParen = false;
}
continue;
}
if (state !== 'code') {
output += char === '\n' || char === '\r' ? char : ' ';
if (escaped) {
escaped = false;
} else if (char === '\\') {
escaped = true;
} else if (
(state === 'single-quote' && char === "'")
|| (state === 'double-quote' && char === '"')
|| (state === 'template' && char === '`')
) {
state = 'code';
}
continue;
}
if (char === '/' && next === '/') {
output += ' ';
index += 1;
state = 'line-comment';
} else if (char === '/' && next === '*') {
output += ' ';
index += 1;
state = 'block-comment';
} else if (char === "'") {
output += ' ';
state = 'single-quote';
} else if (char === '"') {
output += ' ';
state = 'double-quote';
} else if (char === '`') {
output += ' ';
state = 'template';
} else if (char === '(') {
output += char;
parenthesisStack.push(controlKeywords.has(previousWord));
previousSignificant = char;
previousWord = '';
regexAllowedAfterControlParen = false;
} else if (char === ')') {
output += char;
regexAllowedAfterControlParen = parenthesisStack.pop() === true;
previousSignificant = char;
previousWord = '';
} else if (char === '/') {
const startsRegex = !previousSignificant
|| regexCanStartAfter.test(previousSignificant)
|| /^(return|case|throw|typeof|void|delete|yield|await)$/.test(previousWord)
|| regexAllowedAfterControlParen;
output += startsRegex ? ' ' : char;
if (startsRegex) state = 'regex';
else {
previousSignificant = char;
previousWord = '';
}
regexAllowedAfterControlParen = false;
} else {
output += char;
if (!/\s/.test(char)) {
previousSignificant = char;
previousWord = /[A-Za-z0-9_$]/.test(char) ? `${previousWord}${char}` : '';
regexAllowedAfterControlParen = false;
}
}
}
return output;
}
function extractFunction(name, source) {
const code = maskNonCode(source);
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const declaration = new RegExp(`function\\s+${escapedName}\\s*\\(`).exec(code);
if (!declaration) return '';
const bodyStart = code.indexOf('{', declaration.index + declaration[0].length);
if (bodyStart < 0) return '';
let depth = 0;
for (let index = bodyStart; index < code.length; index += 1) {
if (code[index] === '{') depth += 1;
if (code[index] === '}') {
depth -= 1;
if (depth === 0) return source.slice(declaration.index, index + 1);
}
}
return '';
}
function extractPage(source, pageId) {
const opening = new RegExp(`<div\\b[^>]*\\bid=["']page-${pageId}["'][^>]*>`, 'i').exec(source);
if (!opening) return '';
const rest = source.slice(opening.index + opening[0].length);
const nextPage = /<div\b[^>]*\bclass=["'][^"']*\bpage\b[^"']*["'][^>]*\bid=["']page-/i.exec(rest);
return source.slice(opening.index, nextPage ? opening.index + opening[0].length + nextPage.index : source.length);
}
function extractDivById(source, id) {
const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const opening = new RegExp(`<div\\b[^>]*\\bid=["']${escapedId}["'][^>]*>`, 'i').exec(source);
if (!opening) return '';
const divTags = /<\/?div\b[^>]*>/gi;
divTags.lastIndex = opening.index;
let depth = 0;
let tag;
while ((tag = divTags.exec(source))) {
depth += /^<\/div\b/i.test(tag[0]) ? -1 : 1;
if (depth === 0) return source.slice(opening.index, divTags.lastIndex);
}
return '';
}
function openingTagById(source, id) {
const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return (new RegExp(`<[^>]+\\bid=["']${escapedId}["'][^>]*>`, 'i').exec(source) || [''])[0];
}
function textContent(fragment) {
return fragment.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
}
const extractorProbe = [
'// function ignoredLine() {}',
'/* function ignoredBlock() {} */',
'const ignoredString = "function ignoredString() {}";',
'const ignoredTemplate = `function ignoredTemplate() {}`;',
'if (ready) /function ignoredRegex() {}/.test(value);',
'function liveProbe() {',
' const nested = {outer: {inner: true}};',
' const markup = `<div data-value="${JSON.stringify({closing: "}"})}">{text}</div>`;',
' return {nested, markup};',
'}',
'function afterProbe() { return true; }',
].join('\n');
assert.equal(extractFunction('ignoredLine', extractorProbe), '', '函数提取器误识别行注释');
assert.equal(extractFunction('ignoredBlock', extractorProbe), '', '函数提取器误识别块注释');
assert.equal(extractFunction('ignoredString', extractorProbe), '', '函数提取器误识别字符串内容');
assert.equal(extractFunction('ignoredTemplate', extractorProbe), '', '函数提取器误识别模板字符串内容');
assert.equal(extractFunction('ignoredRegex', extractorProbe), '', '函数提取器误识别正则内容');
assert.match(extractFunction('liveProbe', extractorProbe), /return \{nested, markup\};/, '函数提取器被对象或模板字符串截断');
assert.match(extractFunction('afterProbe', extractorProbe), /return true;/, '函数提取器未能继续定位后续函数');
const generalModule = manifest.modules.find((item) => item.file === 'modules/10-general.html');
assert.ok(generalModule && generalModule.pages.includes('gen-exchange-rate'), '通用模块未登记汇率管理页');
assert.equal(
manifest.pageOrder.filter((page) => page === 'gen-exchange-rate').length,
1,
'pageOrder 应唯一登记 gen-exchange-rate',
);
assert.match(
shellHtml,
/<div\b[^>]*\bclass=["'][^"']*\bsub-menu-item\b[^"']*["'][^>]*\bonclick=["']switchPage\('gen-exchange-rate',this\)["'][^>]*>\s*汇率管理\s*<\/div>/,
'通用导航缺少“汇率管理”入口',
);
const exchangeRatePage = extractPage(generalHtml, 'gen-exchange-rate');
assert.ok(exchangeRatePage, '通用模块缺少 page-gen-exchange-rate');
const monthFilterTag = openingTagById(exchangeRatePage, 'exchange-rate-month-filter');
assert.ok(monthFilterTag, '汇率管理页缺少月份筛选 exchange-rate-month-filter');
assert.match(monthFilterTag, /\btype=["']month["']/, '汇率查询必须使用原生 month 控件');
assert.match(monthFilterTag, /\baria-label=["']查询月份["']/, '月份筛选缺少明确可访问名称');
assert.doesNotMatch(exchangeRatePage, /\bid=["']exchange-rate-year["']/, '不应保留旧的年份筛选控件');
assert.match(exchangeRatePage, /\bid=["']tbody-exchange-rate["']/, '汇率管理页缺少表体 tbody-exchange-rate');
assert.match(exchangeRatePage, /class=["'][^"']*exchange-rate-summary/, '汇率概览缺少独立响应式网格类');
assert.doesNotMatch(exchangeRatePage, /class=["'][^"']*exchange-rate-summary[^>]*style=["'][^"']*grid-template-columns/, '汇率概览不应以内联列数覆盖响应式规则');
assert.match(shellHtml, /#page-gen-exchange-rate \.exchange-rate-summary\{grid-template-columns:repeat\(4,minmax\(0,1fr\)\)\}/, '桌面端汇率概览应使用四列');
assert.match(shellHtml, /max-width:1200px[^}]*#page-gen-exchange-rate \.exchange-rate-summary\{grid-template-columns:repeat\(2,minmax\(0,1fr\)\)\}/, '中等视口汇率概览应降为两列');
assert.match(shellHtml, /max-width:640px[^}]*#page-gen-exchange-rate \.exchange-rate-summary\{grid-template-columns:1fr\}/, '移动端汇率概览应降为单列');
assert.match(shellHtml, /#page-gen-exchange-rate \.exchange-rate-summary \.value\{[^}]*font-size:20px[^}]*overflow-wrap:anywhere/, '汇率概览长文本应使用紧凑字号并允许换行');
const exchangeRateTable = (/<table\b[^>]*>[\s\S]*?<\/table>/i.exec(exchangeRatePage) || [''])[0];
assert.ok(exchangeRateTable, '汇率管理页缺少汇率表格');
const headers = [...exchangeRateTable.matchAll(/<th\b[^>]*>([\s\S]*?)<\/th>/gi)]
.map((match) => textContent(match[1]));
assert.deepEqual(
headers,
['汇率月份', '平均汇率', '换算说明', '状态', '更新人', '更新时间', '备注', '操作'],
'汇率管理表头顺序不符合契约',
);
const editModal = extractDivById(overlaysHtml, 'modal-exchange-rate-edit');
assert.ok(editModal, '缺少 modal-exchange-rate-edit');
const monthInput = openingTagById(editModal, 'exchange-rate-month');
assert.ok(monthInput, '汇率编辑弹窗缺少 exchange-rate-month');
assert.match(monthInput, /\btype=["']month["']/, 'exchange-rate-month 必须使用 month 输入类型');
const valueInput = openingTagById(editModal, 'exchange-rate-value');
assert.ok(valueInput, '汇率编辑弹窗缺少 exchange-rate-value');
assert.match(valueInput, /\bstep=["']0\.0001["']/, 'exchange-rate-value 必须支持四位小数');
assert.match(editModal, /1\s*USD\s*=\s*X\s*CNY/, '汇率编辑弹窗缺少换算说明“1 USD = X CNY”');
const getPreviousMonthKeySource = extractFunction('getPreviousMonthKey', appJs);
assert.ok(getPreviousMonthKeySource, 'app.js 缺少 getPreviousMonthKey 函数');
const getPreviousMonthAverageRateSource = extractFunction('getPreviousMonthAverageRate', appJs);
assert.ok(getPreviousMonthAverageRateSource, 'app.js 缺少 getPreviousMonthAverageRate 函数');
const rateLookupContext = {
exchangeRateData: [
{month: '2026-07', averageRate: 7.1846, status: '生效'},
],
};
vm.runInNewContext(
`${getPreviousMonthKeySource}\n${getPreviousMonthAverageRateSource}\n`
+ 'this.api={getPreviousMonthKey,getPreviousMonthAverageRate};',
rateLookupContext,
{filename: 'monthly-exchange-rate-helpers.js', timeout: 1000},
);
assert.equal(rateLookupContext.api.getPreviousMonthKey('2026-08-13'), '2026-07');
assert.equal(rateLookupContext.api.getPreviousMonthKey('2026-01-15'), '2025-12');
assert.equal(rateLookupContext.api.getPreviousMonthKey(''), '');
assert.equal(rateLookupContext.api.getPreviousMonthAverageRate('2026-08-13'), 7.1846);
assert.equal(rateLookupContext.api.getPreviousMonthAverageRate('2026-10-01'), null);
const filterExchangeRatesByMonthSource = extractFunction('filterExchangeRatesByMonth', appJs);
assert.ok(filterExchangeRatesByMonthSource, 'app.js 缺少 filterExchangeRatesByMonth 函数');
const monthFilterContext = {};
vm.runInNewContext(
`${filterExchangeRatesByMonthSource}\nthis.filterExchangeRatesByMonth=filterExchangeRatesByMonth;`,
monthFilterContext,
{filename: 'filter-exchange-rates-by-month.js', timeout: 1000},
);
const filterRows = [
{month: '2026-07', averageRate: 7.1846},
{month: '2026-06', averageRate: 7.1768},
{month: '2025-07', averageRate: 7.1522},
];
assert.equal(monthFilterContext.filterExchangeRatesByMonth(filterRows, '').length, 3, '空月份应展示全部记录');
assert.deepEqual(
JSON.parse(JSON.stringify(monthFilterContext.filterExchangeRatesByMonth(filterRows, '2026-07'))),
[{month: '2026-07', averageRate: 7.1846}],
'月份筛选必须按 YYYY-MM 精确匹配',
);
assert.equal(monthFilterContext.filterExchangeRatesByMonth(filterRows, '2026-08').length, 0, '不存在月份应返回空结果');
const renderExchangeRatesSource = extractFunction('renderExchangeRates', appJs);
assert.match(renderExchangeRatesSource, /exchange-rate-month-filter/, '渲染函数未读取月份筛选控件');
assert.match(renderExchangeRatesSource, /filterExchangeRatesByMonth/, '渲染函数未使用月份精确筛选函数');
const resetExchangeRateFiltersSource = extractFunction('resetExchangeRateFilters', appJs);
assert.match(
resetExchangeRateFiltersSource,
/exchange-rate-month-filter[^]*value\s*=\s*["']["']/,
'重置函数未清空月份筛选',
);
const saveExchangeRateSource = extractFunction('saveExchangeRate', appJs);
assert.ok(saveExchangeRateSource, 'app.js 缺少 saveExchangeRate 函数');
const formElements = {
'exchange-rate-month': {value: '2026-07'},
'exchange-rate-value': {value: '7.2'},
'exchange-rate-remark': {value: '月末更新'},
'exchange-rate-status': {value: '生效'},
'exchange-rate-edit-id': {value: ''},
};
const saveContext = {
exchangeRateData: [
{
month: '2026-07',
averageRate: 7.1846,
status: '生效',
updatedBy: '张经理',
updatedAt: '2026-08-01 09:00',
remark: '初始值',
},
],
exchangeRateEditingMonth: '2026-07',
exchangeRateEditingId: '2026-07',
editingExchangeRateMonth: '2026-07',
currentExchangeRateMonth: '2026-07',
document: {
getElementById(id) {
return formElements[id] || {value: '', textContent: '', innerHTML: ''};
},
querySelector() {
return null;
},
},
renderExchangeRates() {},
hideModal() {},
showToast() {},
alert() {},
confirm() {
return true;
},
window: {
confirm() {
return true;
},
},
};
vm.runInNewContext(
`${saveExchangeRateSource}\nthis.saveExchangeRate=saveExchangeRate;`,
saveContext,
{filename: 'save-monthly-exchange-rate.js', timeout: 1000},
);
saveContext.saveExchangeRate();
assert.equal(saveContext.exchangeRateData.length, 1, '保存已有月份时不得新增重复记录');
assert.equal(saveContext.exchangeRateData[0].averageRate, 7.2, '保存已有月份时应覆盖平均汇率');
let validationToast = '';
formElements['exchange-rate-value'].value = '0';
saveContext.showToast = (message) => { validationToast = message; };
saveContext.saveExchangeRate();
assert.equal(saveContext.exchangeRateData[0].averageRate, 7.2, '非正数汇率不得覆盖已有记录');
assert.match(validationToast, /大于\s*0/, '非正数汇率应显示明确校验提示');
const applyPreviousMonthRateSource = extractFunction('applyPreviousMonthRate', appJs);
assert.ok(applyPreviousMonthRateSource, 'app.js 缺少 applyPreviousMonthRate 函数');
const rateInput = {value: '6.9000'};
const rateSource = {textContent: '', style: {color: ''}};
const applyContext = {
document: {
getElementById(id) {
if (id === 'cost-rate') return rateInput;
if (id === 'cost-rate-source') return rateSource;
return null;
},
},
getPreviousMonthKey: rateLookupContext.api.getPreviousMonthKey,
getPreviousMonthAverageRate: rateLookupContext.api.getPreviousMonthAverageRate,
};
vm.runInNewContext(
`${applyPreviousMonthRateSource}\nthis.applyPreviousMonthRate=applyPreviousMonthRate;`,
applyContext,
{filename: 'apply-previous-month-rate.js', timeout: 1000},
);
applyContext.applyPreviousMonthRate('2026-08-13');
assert.equal(rateInput.value, '7.1846', '找到上月记录时应带入四位小数汇率');
assert.match(rateSource.textContent, /2026-07/, '汇率来源提示应显示上一个自然月');
rateInput.value = '7.3333';
applyContext.applyPreviousMonthRate('2026-10-01');
assert.equal(rateInput.value, '7.3333', '上月汇率缺失时不得覆盖当前成本汇率');
assert.match(rateSource.textContent, /未维护\s*2026-09/, '上月汇率缺失时应显示缺失月份');
const switchPageSource = extractFunction('switchPage', appJs);
assert.ok(switchPageSource, 'app.js 缺少 switchPage 函数');
assert.match(
switchPageSource,
/page\s*===\s*["']gen-exchange-rate["'][\s\S]{0,160}renderExchangeRates\s*\(\s*\)/,
'进入 gen-exchange-rate 页面时未调用 renderExchangeRates()',
);
const switchVoyageTabSource = extractFunction('switchVoyageTab', appJs);
assert.ok(switchVoyageTabSource, 'app.js 缺少 switchVoyageTab 函数');
let appliedPreviousMonthRate = 0;
const voyageContext = {
document: {
querySelectorAll() {
return [];
},
getElementById(id) {
return id === 'cost-voyage-date' ? {value: '2026-08-13'} : null;
},
},
renderGenVoyage() {},
renderCostCalcRows() {},
applyPreviousMonthRate() {
appliedPreviousMonthRate += 1;
},
};
vm.runInNewContext(
`${switchVoyageTabSource}\nthis.switchVoyageTab=switchVoyageTab;`,
voyageContext,
{filename: 'switch-voyage-tab.js', timeout: 1000},
);
voyageContext.switchVoyageTab('cost');
assert.equal(appliedPreviousMonthRate, 1, '切换到成本测算时应调用 applyPreviousMonthRate');
const voyagePage = extractPage(generalHtml, 'gen-voyage');
assert.ok(voyagePage, '通用模块缺少 page-gen-voyage');
const voyageDateInput = openingTagById(voyagePage, 'cost-voyage-date');
assert.match(
voyageDateInput,
/\bonchange=["']applyPreviousMonthRate\(this\.value\)["']/,
'船期日期变化时应调用 applyPreviousMonthRate(this.value)',
);
assert.match(voyagePage, /\bid=["']cost-rate-source["']/, '成本测算缺少汇率来源 cost-rate-source');
console.log('MONTHLY_EXCHANGE_RATE_CHECK_PASS');
@@ -0,0 +1,179 @@
#!/usr/bin/env node
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const root = path.resolve(__dirname, '..');
const tail = fs.readFileSync(path.join(root, 'prototype-src', 'tail.html'), 'utf8');
const moduleHtml = fs.readFileSync(
path.join(root, 'prototype-src', 'modules', '04-overseas-delivery.html'),
'utf8',
);
const app = fs.readFileSync(path.join(root, 'prototype-src', 'app.js'), 'utf8');
function extractFunction(name, source) {
const declaration = new RegExp(`function\\s+${name}\\s*\\(`).exec(source);
if (!declaration) return '';
const bodyStart = source.indexOf('{', declaration.index + declaration[0].length);
let depth = 0;
let state = 'code';
let escaped = false;
for (let index = bodyStart; index < source.length; index += 1) {
const char = source[index];
const next = source[index + 1];
if (state === 'line-comment') {
if (char === '\n') state = 'code';
continue;
}
if (state === 'block-comment') {
if (char === '*' && next === '/') {
state = 'code';
index += 1;
}
continue;
}
if (state !== 'code') {
if (escaped) escaped = false;
else if (char === '\\') escaped = true;
else if (
(state === 'single' && char === "'")
|| (state === 'double' && char === '"')
|| (state === 'template' && char === '`')
) state = 'code';
continue;
}
if (char === '/' && next === '/') {
state = 'line-comment';
index += 1;
} else if (char === '/' && next === '*') {
state = 'block-comment';
index += 1;
} else if (char === "'") state = 'single';
else if (char === '"') state = 'double';
else if (char === '`') state = 'template';
else if (char === '{') depth += 1;
else if (char === '}') {
depth -= 1;
if (depth === 0) return source.slice(declaration.index, index + 1);
}
}
return '';
}
function loadPureFunction(name) {
const source = extractFunction(name, app);
assert.ok(source, `缺少 ${name} 函数`);
return new Function(`${source}\nreturn ${name};`)();
}
assert.match(
tail,
/id="bol-deliveryMethod"[^>]*onchange="handleOutboundVehicleDeliveryMethodChange\(\)"/,
'派送方式变化时应更新快递字段',
);
assert.match(tail, /id="bol-courier-company-field"[^>]*hidden/, '缺少默认隐藏的快递公司字段');
assert.match(tail, /id="bol-courierCompanyId"/, '缺少快递公司选择框');
assert.match(
tail,
/<label>派送费用(USD) \*<\/label><input id="bol-dispatchFee"[^>]*type="number"[^>]*min="0"[^>]*step="0\.01"[^>]*required/,
'转派送弹框应只保留必填的派送费用',
);
assert.doesNotMatch(tail, /id="bol-estimatedDispatchFee"/, '不应保留预估派送费用输入');
assert.doesNotMatch(tail, /id="bol-courierFee"/, '不应保留独立快递费用输入');
assert.doesNotMatch(tail, /id="bol-courier-fee-field"/, '不应保留独立快递费用字段容器');
assert.doesNotMatch(
tail,
/<\/template>\s*<\/div>\s*<\/div>\s*<div class="modal-footer"/,
'转派送按钮栏必须位于弹框主体内,避免移动端 flex 挤压',
);
const feePanel = /<div id="dispatch-fee-panel"[\s\S]*?<\/table>[\s\S]*?<\/div>/.exec(moduleHtml)?.[0] || '';
assert.match(feePanel, /<tbody id="tbody-dispatch-fee">/, '费用明细表缺少稳定表体 ID');
assert.match(moduleHtml, /id="dispatch-fee-total"/, '费用明细缺少动态总数 ID');
const validate = loadPureFunction('validateOutboundVehicleDeliveryDraft');
const companies = [
{id: 'E001', name: 'DHL', status: '合作中'},
{id: 'E002', name: '停用快递', status: '已终止'},
];
const base = {
deliveryDate: '2026-08-14',
pickupMethod: '一提一卸',
loadType: '托盘',
dispatchFee: 200,
loadingFee: 0,
};
assert.equal(validate({...base, deliveryMethod: '客户自提'}, companies), '');
assert.match(
validate({...base, deliveryMethod: '客户自提', dispatchFee: ''}, companies),
/派送费用/,
);
assert.match(
validate({...base, deliveryMethod: '客户自提', dispatchFee: '-1'}, companies),
/派送费用/,
);
assert.match(
validate({...base, deliveryMethod: '客户自提', dispatchFee: 'not-a-number'}, companies),
/派送费用/,
);
assert.match(
validate({...base, deliveryMethod: '快递', courierCompanyId: ''}, companies),
/快递公司/,
);
assert.match(
validate({...base, deliveryMethod: '快递', courierCompanyId: 'E002'}, companies),
/快递公司/,
);
assert.equal(
validate({...base, deliveryMethod: '快递', courierCompanyId: 'E001'}, companies),
'',
);
const getDetails = loadPureFunction('getOutboundVehicleCourierFeeDetails');
assert.deepEqual(
getDetails([
{
dispatchNo: 'PSD001',
source: 'outboundVehicle',
deliveryMethod: '快递',
courierCompanyName: 'DHL',
dispatchFee: 12.5,
appointmentDate: '2026-08-14',
},
{dispatchNo: 'PSD002', source: 'outboundVehicle', deliveryMethod: '客户自提', dispatchFee: 99},
{dispatchNo: 'PSD003', source: 'outboundVehicle', deliveryMethod: '快递', dispatchFee: -1},
]),
[{dispatchNo: 'PSD001', company: 'DHL', businessDate: '2026-08-14', currency: 'USD', amount: 12.5}],
);
const deliveryMethodChange = extractFunction('handleOutboundVehicleDeliveryMethodChange', app);
assert.match(deliveryMethodChange, /status\s*===\s*'合作中'/, '快递公司下拉应只显示合作中公司');
assert.doesNotMatch(deliveryMethodChange, /bol-courierFee|bol-courier-fee-field/, '派送方式联动不应再控制独立快递费用');
const saveBol = extractFunction('saveBol', app);
assert.match(saveBol, /validateOutboundVehicleDeliveryDraft\(draft\s*,\s*genExpressData\)/, '保存时应使用快递公司主数据校验');
assert.match(saveBol, /courierCompanyId/, '派送单应保存快递公司 ID 快照');
assert.match(saveBol, /courierCompanyName/, '派送单应保存快递公司名称快照');
assert.doesNotMatch(saveBol, /value\('bol-estimatedDispatchFee'\)/, '保存时不应读取预估派送费用输入');
assert.doesNotMatch(saveBol, /value\('bol-courierFee'\)/, '保存时不应读取独立快递费用输入');
assert.match(saveBol, /estimatedDispatchFee\s*:\s*deliveryFee/, '现有预估费用展示应复用唯一派送费用');
const renderFeeRows = extractFunction('renderOutboundVehicleCourierFeeRows', app);
assert.match(renderFeeRows, /data-generated-courier-fee/, '动态费用行应支持幂等清理');
assert.match(renderFeeRows, /待审核/, '自动生成快递费用应为待审核');
assert.match(renderFeeRows, /应付/, '自动生成快递费用应为应付');
assert.match(renderFeeRows, /快递费/, '自动生成费用类型应为快递费');
assert.match(renderFeeRows, /排车转派送自动生成/, '自动生成快递费用应记录来源备注');
assert.match(
extractFunction('renderLastMileDispatchRows', app),
/renderOutboundVehicleCourierFeeRows\(\)/,
'派送单列表刷新时应同步刷新快递费用明细',
);
assert.match(
extractFunction('switchDispatchTab', app),
/tab\s*===\s*'fee'[^;]*renderOutboundVehicleCourierFeeRows\(\)/,
'切换到费用明细标签时应刷新快递费用明细',
);
console.log('OUTBOUND_VEHICLE_COURIER_FEE_CHECK_PASS');
@@ -3,7 +3,7 @@
const fs = require('node:fs');
const path = require('node:path');
const prototypePath = path.resolve(__dirname, '..', '货代SaaS平台原型.html');
const prototypePath = path.resolve(__dirname, '..', 'index.html');
const html = fs.readFileSync(prototypePath, 'utf8');
const failures = [];
@@ -0,0 +1,432 @@
#!/usr/bin/env node
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const root = path.resolve(__dirname, '..');
const moduleHtml = fs.readFileSync(
path.join(root, 'prototype-src', 'modules', '04-overseas-delivery.html'),
'utf8',
);
const overlays = fs.readFileSync(path.join(root, 'prototype-src', 'overlays.html'), 'utf8');
const tail = fs.readFileSync(path.join(root, 'prototype-src', 'tail.html'), 'utf8');
const app = fs.readFileSync(path.join(root, 'prototype-src', 'app.js'), 'utf8');
const manifest = JSON.parse(
fs.readFileSync(path.join(root, 'prototype-src', 'manifest.json'), 'utf8'),
);
const failures = [];
function expect(condition, message) {
if (!condition) failures.push(message);
}
function codeOnly(source) {
let output = '';
let state = 'code';
let escaped = false;
const regexCanStartAfter = /[([{,:;=!?&|+\-*%^~<>]/;
const controlKeywords = new Set(['if', 'while', 'for', 'with', 'switch', 'catch']);
let previousSignificant = '';
let previousWord = '';
let regexAllowedAfterControlParen = false;
const parenthesisStack = [];
for (let index = 0; index < source.length; index += 1) {
const char = source[index];
const next = source[index + 1];
if (state === 'line-comment') {
output += char === '\n' || char === '\r' ? char : ' ';
if (char === '\n' || char === '\r') state = 'code';
continue;
}
if (state === 'block-comment') {
output += ' ';
if (char === '*' && next === '/') {
output += ' ';
index += 1;
state = 'code';
}
continue;
}
if (state === 'regex' || state === 'regex-class') {
output += char === '\n' || char === '\r' ? char : ' ';
if (escaped) {
escaped = false;
} else if (char === '\\') {
escaped = true;
} else if (state === 'regex' && char === '[') {
state = 'regex-class';
} else if (state === 'regex-class' && char === ']') {
state = 'regex';
} else if (state === 'regex' && char === '/') {
state = 'code';
previousSignificant = '/';
previousWord = '';
regexAllowedAfterControlParen = false;
}
continue;
}
if (state !== 'code') {
output += char === '\n' || char === '\r' ? char : ' ';
if (escaped) {
escaped = false;
} else if (char === '\\') {
escaped = true;
} else if (
(state === 'single-quote' && char === "'")
|| (state === 'double-quote' && char === '"')
|| (state === 'template' && char === '`')
) {
state = 'code';
}
continue;
}
if (char === '/' && next === '/') {
output += ' ';
index += 1;
state = 'line-comment';
} else if (char === '/' && next === '*') {
output += ' ';
index += 1;
state = 'block-comment';
} else if (char === "'") {
output += ' ';
state = 'single-quote';
} else if (char === '"') {
output += ' ';
state = 'double-quote';
} else if (char === '`') {
output += ' ';
state = 'template';
} else if (char === '(') {
output += char;
parenthesisStack.push(controlKeywords.has(previousWord));
previousSignificant = char;
previousWord = '';
regexAllowedAfterControlParen = false;
} else if (char === ')') {
output += char;
regexAllowedAfterControlParen = parenthesisStack.pop() === true;
previousSignificant = char;
previousWord = '';
} else if (char === '/') {
const startsRegex = !previousSignificant
|| regexCanStartAfter.test(previousSignificant)
|| /^(return|case|throw|typeof|void|delete|yield|await)$/.test(previousWord)
|| regexAllowedAfterControlParen;
output += startsRegex ? ' ' : char;
if (startsRegex) state = 'regex';
else {
previousSignificant = char;
previousWord = '';
}
regexAllowedAfterControlParen = false;
} else {
output += char;
if (!/\s/.test(char)) {
previousSignificant = char;
previousWord = /[A-Za-z0-9_$]/.test(char) ? `${previousWord}${char}` : '';
regexAllowedAfterControlParen = false;
}
}
}
return output;
}
function extractFunction(name, source, code = codeOnly(source)) {
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const declaration = new RegExp(`function\\s+${escapedName}\\s*\\(`).exec(code);
if (!declaration) return '';
const bodyStart = code.indexOf('{', declaration.index + declaration[0].length);
if (bodyStart < 0) return '';
let depth = 0;
for (let index = bodyStart; index < code.length; index += 1) {
const char = code[index];
if (char === '{') {
depth += 1;
} else if (char === '}') {
depth -= 1;
if (depth === 0) return source.slice(declaration.index, index + 1);
}
}
return '';
}
const extractorProbe = [
'// function ignoredLine() {}',
'/* function ignoredBlock() {} */',
'const ignoredString = "function ignoredString() {}";',
'const ignoredTemplate = `function ignoredTemplate() {}`;',
'const quotePattern = /["\']/;',
'if (ready) /function ignoredControlRegex() {}/.test(value);',
'function liveProbe() { return {ok: true}; }',
].join('\n');
expect(!extractFunction('ignoredLine', extractorProbe), '函数提取器误识别行注释声明');
expect(!extractFunction('ignoredBlock', extractorProbe), '函数提取器误识别块注释声明');
expect(!extractFunction('ignoredString', extractorProbe), '函数提取器误识别字符串声明');
expect(!extractFunction('ignoredTemplate', extractorProbe), '函数提取器误识别模板文本声明');
expect(!extractFunction('ignoredControlRegex', extractorProbe), '函数提取器误识别控制语句后的正则文本');
expect(Boolean(extractFunction('liveProbe', extractorProbe)), '函数提取器无法识别真实声明');
function extractModal(source, modalId) {
const escapedId = modalId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const opening = new RegExp(`<div\\b[^>]*\\bid=["']${escapedId}["'][^>]*>`, 'i').exec(source);
if (!opening) return '';
const tagPattern = /<\/?div\b[^>]*>/gi;
tagPattern.lastIndex = opening.index;
let depth = 0;
let tag;
while ((tag = tagPattern.exec(source))) {
if (/^<\/div\b/i.test(tag[0])) depth -= 1;
else depth += 1;
if (depth === 0) return source.slice(opening.index, tagPattern.lastIndex);
}
return '';
}
function getSelectOptionValues(source, selectId) {
const escapedId = selectId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const select = new RegExp(
`<select\\b(?=[^>]*\\bid=["']${escapedId}["'])[^>]*>([\\s\\S]*?)<\\/select>`,
'i',
).exec(source);
if (!select) return null;
const values = [];
const optionPattern = /<option\b([^>]*)>([\s\S]*?)<\/option>/gi;
let option;
while ((option = optionPattern.exec(select[1]))) {
const value = /\bvalue=["']([^"']*)["']/i.exec(option[1]);
const text = option[2].replace(/<[^>]*>/g, '').trim();
const optionValue = value ? value[1] : text;
if (optionValue) values.push(optionValue);
}
return values;
}
function expectExactOptionSet(source, selectId, expectedValues, label) {
const actualValues = getSelectOptionValues(source, selectId);
expect(Boolean(actualValues), `缺少 ${label}选择框`);
if (!actualValues) return;
const actual = [...actualValues].sort();
const expected = [...expectedValues].sort();
expect(
actual.length === expected.length && actual.every((value, index) => value === expected[index]),
`${label}选项应为“${expectedValues.join('、')}”`,
);
}
expect(!moduleHtml.includes('id="page-outbound-vehicle-edit"'), '独立编辑页应移除');
expect(!manifest.pageOrder.includes('outbound-vehicle-edit'), 'pageOrder 应移除独立编辑页');
const deliveryModule = manifest.modules.find(
(item) => item.file === 'modules/04-overseas-delivery.html',
);
expect(Boolean(deliveryModule), 'manifest 缺少海外派送模块');
if (deliveryModule) {
expect(
!deliveryModule.pages.includes('outbound-vehicle-edit'),
'海外派送模块 pages 应移除独立编辑页',
);
}
expect(overlays.includes('id="modal-outbound-vehicle-edit"'), '缺少独立编辑排车弹框');
expect(
overlays.includes('id="modal-outbound-vehicle-order-select"'),
'缺少关联订单选择弹框',
);
const bolModal = extractModal(tail, 'modal-bol');
expect(Boolean(bolModal), '缺少独立转派送弹框');
expectExactOptionSet(
bolModal,
'bol-deliveryMethod',
['FBA自提', '客户自提', '卡派打托', '卡派地板', '快递', '外配'],
'派送方式',
);
expectExactOptionSet(bolModal, 'bol-pickupMethod', ['一提一卸', '一提多卸'], '提货方式');
expectExactOptionSet(bolModal, 'bol-loadType', ['托盘', '地板', '混装'], '装车类型');
expectExactOptionSet(bolModal, 'bol-bookAccount', ['4', '8'], '预约账号');
expectExactOptionSet(
bolModal,
'bol-vehicleSize',
['大车(30托/15托)', '小车(14托/12托)'],
'车辆大小',
);
expect(!bolModal.includes('卡派地版'), '转派送弹框不应保留错误文案“卡派地版”');
expect(bolModal.includes('<label>预约号</label>'), 'ISA号码应改为预约号');
expect(bolModal.includes('id="bol-appointmentNo"'), '预约号应使用独立字段');
expect(bolModal.includes('id="bol-courierCompanyId"'), '快递方式应保留快递公司选择');
expect(!bolModal.includes('车队邮箱'), '转派送弹框应移除车队邮箱');
expect(!bolModal.includes('ISA号码'), '转派送弹框不应保留 ISA号码 文案');
expect(!bolModal.includes('是否发送派送单'), '转派送弹框应移除是否发送派送单');
expect(!bolModal.includes('预估派送单费用'), '费用区应移除预估派送单费用');
expect(!bolModal.includes('快递费用'), '费用区应移除快递费用');
expect(!bolModal.includes('id="bol-driverEmail"'), '转派送弹框不应保留车队邮箱字段');
expect(!bolModal.includes('id="bol-sendDispatch"'), '转派送弹框不应保留发送派送单字段');
expect(!bolModal.includes('id="bol-estimatedDispatchFee"'), '转派送弹框不应保留预估派送费用字段');
expect(!bolModal.includes('id="bol-courierFee"'), '转派送弹框不应保留快递费字段');
expect(
/id="bol-dispatchFee"[^>]*placeholder="请输入派送费用"/.test(bolModal),
'派送费用输入框应提示“请输入派送费用”',
);
const vehicleTypeIndex = bolModal.indexOf('id="bol-vehicleType"');
const vehicleSizeIndex = bolModal.indexOf('id="bol-vehicleSize"');
const appointmentAccountIndex = bolModal.indexOf('id="bol-bookAccount"');
expect(
vehicleTypeIndex >= 0 && vehicleSizeIndex > vehicleTypeIndex && appointmentAccountIndex > vehicleSizeIndex,
'车辆大小应排在车辆类型后、预约账号前',
);
const functionNames = [
'editVehicle',
'switchOutboundVehicleEditTab',
'renderOutboundVehicleEditOrders',
'removeOutboundVehicleEditOrder',
'getAvailableOutboundVehicleOrders',
'openOutboundVehicleOrderSelect',
'renderOutboundVehicleOrderOptions',
'confirmOutboundVehicleOrderSelection',
'saveVehicleEdit',
'openBolModal',
'handleOutboundVehicleLoadTypeChange',
'validateOutboundVehicleDeliveryDraft',
'nextOutboundVehicleDeliveryNo',
'saveBol',
'openOutboundVehicleDelivery',
'getOutboundVehicleCourierFeeDetails',
];
const scriptSource = `${app}\n${tail}`;
const scriptCode = codeOnly(scriptSource);
const functionSources = Object.fromEntries(
functionNames.map((name) => [name, extractFunction(name, scriptSource, scriptCode)]),
);
functionNames.forEach((name) => {
expect(Boolean(functionSources[name]), `缺少 ${name} 函数`);
});
const saveBolSource = functionSources.saveBol || '';
expect(saveBolSource.includes("appointmentAccount:value('bol-bookAccount')"), '保存时应读取预约账号');
expect(saveBolSource.includes("appointmentNo:value('bol-appointmentNo')"), '保存时应读取预约号');
expect(saveBolSource.includes("vehicleSize:value('bol-vehicleSize')"), '保存时应读取车辆大小');
expect(saveBolSource.includes('appointmentAccount:draft.appointmentAccount'), '派送记录应保存预约账号');
expect(saveBolSource.includes('appointmentNo:draft.appointmentNo'), '派送记录应保存预约号');
expect(saveBolSource.includes('vehicleSize:draft.vehicleSize'), '派送记录应保存车辆大小');
function runPureFunctionCheck(name, callback) {
if (!functionSources[name]) return;
try {
const pureFunction = new Function(`${functionSources[name]}\nreturn ${name};`)();
callback(pureFunction);
} catch (error) {
failures.push(`${name} 运行时验证失败:${error.message}`);
}
}
runPureFunctionCheck('getAvailableOutboundVehicleOrders', (getAvailableOutboundVehicleOrders) => {
const orders = [
{orderNo: 'ORDER-A', status: '待派送', destFba: 'ONT8'},
{orderNo: 'ORDER-B', status: '待派送', destFba: 'LAX9'},
{orderNo: 'ORDER-C', status: '派送中', destFba: 'ONT8'},
];
const vehicles = [
{vehicleNo: 'CAR-1', orders: [{orderNo: 'ORDER-A'}]},
{vehicleNo: 'CAR-2', orders: [{orderNo: 'ORDER-B'}]},
];
assert.deepEqual(
getAvailableOutboundVehicleOrders('CAR-1', [], orders, vehicles).map((item) => item.orderNo),
['ORDER-A'],
);
});
runPureFunctionCheck('validateOutboundVehicleDeliveryDraft', (validateOutboundVehicleDeliveryDraft) => {
assert.equal(
validateOutboundVehicleDeliveryDraft({
deliveryDate: '2026-08-13',
deliveryMethod: '卡派地板',
pickupMethod: '一提一卸',
loadType: '地板',
dispatchFee: 200,
loadingFee: 150,
}),
'',
);
assert.match(
validateOutboundVehicleDeliveryDraft({
deliveryDate: '2026-08-13',
deliveryMethod: '卡派地板',
pickupMethod: '一提一卸',
loadType: '地板',
dispatchFee: -1,
loadingFee: 150,
}),
/派送费用/,
);
assert.match(
validateOutboundVehicleDeliveryDraft({
deliveryDate: '2026-08-13',
deliveryMethod: '快递',
pickupMethod: '一提一卸',
loadType: '托盘',
dispatchFee: 200,
courierCompanyId: '',
}, [{id: 'EXP-1', status: '合作中'}]),
/快递公司/,
);
assert.equal(
validateOutboundVehicleDeliveryDraft({
deliveryDate: '2026-08-13',
deliveryMethod: '快递',
pickupMethod: '一提一卸',
loadType: '托盘',
dispatchFee: 200,
courierCompanyId: 'EXP-1',
}, [{id: 'EXP-1', status: '合作中'}]),
'',
);
});
runPureFunctionCheck('getOutboundVehicleCourierFeeDetails', (getOutboundVehicleCourierFeeDetails) => {
assert.deepEqual(
getOutboundVehicleCourierFeeDetails([
{
source: 'outboundVehicle',
deliveryMethod: '快递',
dispatchNo: 'PSD20260818001',
courierCompanyName: 'UPS',
appointmentDate: '2026-08-18',
dispatchFee: 88,
},
]),
[{
dispatchNo: 'PSD20260818001',
company: 'UPS',
businessDate: '2026-08-18',
currency: 'USD',
amount: 88,
}],
);
});
runPureFunctionCheck('nextOutboundVehicleDeliveryNo', (nextOutboundVehicleDeliveryNo) => {
assert.equal(
nextOutboundVehicleDeliveryNo('2026-08-13', [
{dispatchNo: 'PSD20260813001'},
{dispatchNo: 'PSD20260813003'},
]),
'PSD20260813004',
);
});
if (failures.length) {
console.error('OUTBOUND_VEHICLE_EDIT_DELIVERY_CHECK_FAIL');
failures.forEach((failure) => console.error(`- ${failure}`));
process.exitCode = 1;
} else {
console.log('OUTBOUND_VEHICLE_EDIT_DELIVERY_CHECK_PASS');
}
@@ -0,0 +1,122 @@
#!/usr/bin/env node
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const root = path.resolve(__dirname, '..');
const shell = fs.readFileSync(path.join(root, 'prototype-src', 'shell.html'), 'utf8');
const tail = fs.readFileSync(path.join(root, 'prototype-src', 'tail.html'), 'utf8');
const bolStart = tail.indexOf('<div class="modal-overlay" id="modal-bol">');
const bolEnd = tail.indexOf('<!-- ==================== 装柜清单', bolStart);
const bolModal = tail.slice(bolStart, bolEnd);
assert.ok(bolStart >= 0 && bolEnd > bolStart, '缺少完整转派送弹框');
function matchingDivEnd(source, start) {
const tagPattern = /<div\b[^>]*>|<\/div>/g;
tagPattern.lastIndex = start;
let depth = 0;
let match;
while ((match = tagPattern.exec(source))) {
depth += match[0] === '</div>' ? -1 : 1;
if (depth === 0) return tagPattern.lastIndex;
}
return -1;
}
function matchingBraceEnd(source, openingBrace) {
let depth = 0;
for (let index = openingBrace; index < source.length; index += 1) {
if (source[index] === '{') depth += 1;
else if (source[index] === '}') {
depth -= 1;
if (depth === 0) return index;
}
}
return -1;
}
function ruleBlock(source, selector) {
const selectorPattern = selector
.split('>')
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('\\s*>\\s*');
const match = new RegExp(`${selectorPattern}\\s*\\{([^}]*)\\}`).exec(source);
return match?.[1] || '';
}
function hasDeclaration(block, property, value) {
const normalized = block.replace(/\s+/g, ' ').trim();
return new RegExp(`${property}\\s*:\\s*${value}\\s*(?:;|$)`).test(normalized);
}
const overlayEnd = matchingDivEnd(bolModal, bolStart - bolStart);
const modalStart = bolModal.indexOf('<div class="modal outbound-vehicle-modal outbound-vehicle-delivery-modal"');
const modalEnd = matchingDivEnd(bolModal, modalStart);
const bodyStart = bolModal.indexOf('<div class="modal-body outbound-vehicle-modal-body">', modalStart);
const bodyEnd = matchingDivEnd(bolModal, bodyStart);
const footerStart = bolModal.indexOf('<div class="modal-footer">', bodyEnd);
const footerEnd = matchingDivEnd(bolModal, footerStart);
assert.ok(modalStart >= 0 && modalEnd > modalStart, '转派送弹框缺少主体容器');
assert.ok(bodyStart >= 0 && bodyEnd > bodyStart, '转派送弹框缺少内容区');
assert.ok(footerStart >= bodyEnd && footerEnd > footerStart, '转派送底部操作栏必须位于内容区之后');
assert.equal(
bolModal.slice(bodyEnd, footerStart).trim(),
'',
'转派送底部操作栏必须在内容区关闭后直接作为弹框主体子节点出现',
);
assert.equal(
bolModal.slice(footerEnd, modalEnd - '</div>'.length).trim(),
'',
'转派送底部操作栏必须是弹框主体的最后一个子节点',
);
assert.equal(bolModal.slice(overlayEnd).trim(), '', '转派送弹框主体必须位于遮罩层内');
const modalRule = ruleBlock(shell, '.outbound-vehicle-modal');
const bodyRule = ruleBlock(shell, '.outbound-vehicle-modal-body');
const footerRule = ruleBlock(shell, '.outbound-vehicle-modal>.modal-footer');
const mediaPattern = /@media\s*\(\s*max-width\s*:\s*760px\s*\)/g;
let mobileRule = '';
let mediaMatch;
while ((mediaMatch = mediaPattern.exec(shell))) {
const mobileOpeningBrace = shell.indexOf('{', mediaMatch.index);
const mobileEnd = matchingBraceEnd(shell, mobileOpeningBrace);
if (mobileOpeningBrace < 0 || mobileEnd <= mobileOpeningBrace) continue;
const candidateRule = ruleBlock(
shell.slice(mobileOpeningBrace + 1, mobileEnd),
'.outbound-vehicle-modal',
);
if (candidateRule) {
mobileRule = candidateRule;
break;
}
mediaPattern.lastIndex = mobileEnd + 1;
}
assert.ok(
hasDeclaration(modalRule, 'display', 'flex')
&& hasDeclaration(modalRule, 'flex-direction', 'column')
&& hasDeclaration(modalRule, 'overflow', 'hidden'),
'转派送弹框应使用纵向 flex 布局',
);
assert.ok(
hasDeclaration(bodyRule, 'flex', '1(?:\\s+1\\s+auto)?')
&& hasDeclaration(bodyRule, 'min-height', '0')
&& hasDeclaration(bodyRule, 'overflow-y', 'auto'),
'转派送内容区应独立纵向滚动',
);
assert.ok(
hasDeclaration(footerRule, 'flex', '0\\s+0\\s+auto')
&& hasDeclaration(footerRule, 'border-top', '1px\\s+solid\\s+#e5e7eb'),
'转派送底栏应固定在弹框内并使用中性分隔线',
);
assert.ok(
hasDeclaration(mobileRule, 'width', 'calc\\(100vw\\s+-\\s+16px\\)'),
'移动端弹框应保持窄视口宽度约束',
);
console.log('OUTBOUND_VEHICLE_MODAL_LAYOUT_CHECK_PASS');
@@ -0,0 +1,101 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const root = path.resolve(__dirname, '..');
const htmlPath = path.join(root, 'PDA(海外)', 'index.html');
assert.ok(fs.existsSync(htmlPath), 'PDA(海外)/index.html must exist');
const html = fs.readFileSync(htmlPath, 'utf8');
assert.match(html, /const I18N\s*=\s*\{/, 'three-language dictionary must exist');
assert.match(html, /zh\s*:\s*\{/, 'Chinese dictionary must exist');
assert.match(html, /en\s*:\s*\{/, 'English dictionary must exist');
assert.match(html, /es\s*:\s*\{/, 'Spanish dictionary must exist');
for (const metric of ['待卸货集装箱', '今日已入库', '今日待出库']) {
assert.ok(html.includes(metric), `workbench metric missing: ${metric}`);
}
assert.ok(!html.includes('入库上架'), 'overseas PDA must not expose inbound shelving');
assert.ok(!html.includes('批次号'), 'overseas PDA must not expose batch fields');
for (const photoKey of ['licensePlate', 'containerSeal', 'doorOpen', 'unloading', 'unloadComplete']) {
assert.ok(html.includes(photoKey), `unload photo step missing: ${photoKey}`);
}
assert.match(html, /capture=["']environment["']/, 'unload photos must support the rear camera');
assert.match(html, /mergeFlag|isMerged/, 'pallet lines must expose a merge marker');
assert.ok(html.includes('托数'), 'inbound plans must use pallet count rather than carton count');
assert.match(html, /startPlanPallet|开始打托/, 'inbound plan must start palletizing');
assert.match(html, /startConsolidation|合托/, 'consolidation must have a separate entry');
assert.match(html, /targetPallets/, 'palletizing must carry the inbound plan pallet target');
assert.match(html, /palletMode\s*===\s*['"]consolidate['"]/, 'palletizing and consolidation modes must be distinct');
assert.match(html, /expectedPallets/, 'palletizing progress must use planned pallet count');
assert.match(html, /palletTargetReached/, 'submitting more pallets than the plan target must be blocked');
assert.match(html, /receiveListView/, 'unload receiving must open a pending-container list first');
assert.match(html, /page==='receive-list'/, 'pending-container list must be a separate page');
assert.ok(html.includes("container:'D2222'"), 'container codes must demonstrate the D2222 format');
assert.match(html, /-TC-/, 'pallet codes must follow the container-TC-sequence format');
assert.ok(!html.includes('打托 / 合托'), 'palletize and consolidation must not use a combined visible label');
assert.match(html, /inboundPallet|入库打托/, 'inbound palletizing must be the single inbound pallet entry');
assert.doesNotMatch(html, /<h3>\$\{t\('plan'\)\}<\/h3>[\s\S]*?<h3>\$\{t\('pallet'\)\}/, 'workbench must not expose duplicate plan and pallet entries');
assert.match(html, /palletCount|托盘数量/, 'outbound orders must expose pallet quantity');
assert.match(html, /orderInfo|关联订单/, 'outbound orders must expose linked order information');
for (const outboundField of ['inboundCode','plateNo','driver','outboundTime','totalPallets']) {
assert.ok(html.includes(outboundField), `outbound picking field missing: ${outboundField}`);
}
assert.match(html, /totalPalletBoxes|总托数\s*\/\s*总箱数/, 'outbound picking must combine total pallets and total cartons');
assert.match(html, /injectInboundPalletUnits[\s\S]*entries\?\.\[1\]/, 'unload receiving must remain immediately after inbound palletizing');
assert.match(html, /injectInboundPalletUnits[\s\S]*insertBefore/, 'workbench inbound entries must order unload before palletizing');
for (const unloadField of ['tractorDriver','tractorPlate']) {
assert.ok(html.includes(unloadField), `unload list field missing: ${unloadField}`);
}
assert.match(html, /receiveListView[\s\S]*inboundCode/, 'unload list must label ticket as inbound code');
assert.match(html, /destinationWarehouse/, 'unload list must use destination warehouse label');
assert.match(html, /destinationWarehouses|slice\(0,3\)/, 'unload list must support up to three destination warehouse codes');
assert.match(html, /navigate\('receive-list'\)/, 'workbench must expose a separate unload receiving entry');
assert.match(html, /page\s*=\s*['"]pallet-list['"]|palletListView/, 'inbound palletizing must open a pallet list before pallet detail');
assert.match(html, /selectPallet|openPalletDetail|pallet-list/, 'pallet list must provide a pallet detail selection entry');
assert.match(html, /viewPalletDetails|选择查看明细/, 'inbound order list must use the view details label');
assert.match(html, /startPalletWork|t\('startPallet'\)/, 'inbound order list must provide a palletizing action');
assert.match(html, /btn ghost[\s\S]*viewPalletDetails|viewPalletDetails[\s\S]*btn ghost/, 'view details must be the secondary left action');
assert.match(html, /directEnter|直接进入/, 'pallet list must directly enter pallet detail');
assert.match(html, /palletLabelSelector|selectPalletLabel|选择托盘标/, 'pallet detail must provide a pallet label selector');
assert.match(html, /palletLabelOption[\s\S]*idle|occupied/, 'pallet label options must show pallet number and status');
assert.match(html, /t\('boxMark'\)|箱唛/, 'pallet detail must show box mark');
assert.match(html, /t\('inboundCode'\)|入仓码/, 'pallet detail must show inbound code');
assert.match(html, /t\('productName'\)|产品名称/, 'pallet detail must show product name');
assert.match(html, /removePalletItem/, 'pallet detail must keep a delete action');
const orderListView = html.match(/function plansPalletView\(\)\{[^\n]+\}/)?.[0] || '';
assert.equal((orderListView.match(/t\('container'\)/g) || []).length, 1, 'inbound order card must show container number only once');
assert.match(orderListView, /esc\(p\.container\)/, 'inbound order cards must render each plan container number');
assert.match(html, /cartonCount|箱数/, 'pallet list must show carton count');
assert.match(html, /idle|occupied|闲置|占用/, 'pallet list must show idle or occupied status');
const consolidationFn = html.match(/function startConsolidation\(\)\{[^\n]+\}/)?.[0] || '';
assert.match(consolidationFn, /page\s*=\s*['"]pallet['"]/, 'consolidation must open detail directly');
assert.doesNotMatch(consolidationFn, /pallet-list/, 'consolidation must not open pallet list first');
assert.match(html, /injectReservedEntries[\s\S]*more\.style\.display='none'/, 'more operations must be hidden');
assert.doesNotMatch(html, /injectReservedEntries[\s\S]*t\('stocktake'\)/, 'stocktake must be hidden');
assert.doesNotMatch(html, /injectReservedEntries[\s\S]*t\('transfer'\)/, 'transfer must be hidden');
for (const handoverPhoto of ['licensePlatePhoto','vehicleCabPhoto','driverLicense','loadingSealPhoto','lockSealPhoto','bolDriverSigned','amazonAppointment']) {
assert.ok(html.includes(handoverPhoto), `handover photo step missing: ${handoverPhoto}`);
}
assert.match(html, /HANDOVER_PHOTO_STEPS/, 'handover must require a seven-photo checklist');
assert.match(html, /completed===7|completed<7|7 \/ 7/, 'handover completion must require seven photos');
assert.match(html, /function relabelView|page==='relabel'/, 'relabel operation must be implemented');
assert.match(html, /function exceptionView|page==='exception'/, 'exception operation must be implemented');
const fnMatch = html.match(/function canMergePallet\(pallet,\s*cargo\)\s*\{[\s\S]*?\n\s*\}/);
assert.ok(fnMatch, 'canMergePallet(pallet, cargo) must exist');
const canMergePallet = vm.runInNewContext(`(${fnMatch[0].replace('function canMergePallet', 'function')})`);
const emptyPallet = { destinationCode: '', destinationAddress: '' };
const laxA = { customer: 'A', planNo: 'IN-A', destinationCode: 'ONT8', destinationAddress: '24300 Nandina Ave, Moreno Valley, CA' };
const laxB = { customer: 'B', planNo: 'IN-B', destinationCode: 'ONT8', destinationAddress: '24300 Nandina Ave, Moreno Valley, CA' };
const conflict = { customer: 'B', planNo: 'IN-B', destinationCode: 'LGB8', destinationAddress: '1568 N Linden Ave, Rialto, CA' };
assert.equal(canMergePallet(emptyPallet, laxA), true, 'first cargo must lock an empty pallet');
assert.equal(canMergePallet({ ...laxA }, laxB), true, 'same destination must merge across customers and plans');
assert.equal(canMergePallet({ ...laxA }, conflict), false, 'different destination must be blocked');
console.log('verify-overseas-pda: all assertions passed');
@@ -3,12 +3,15 @@
const fs = require('node:fs');
const path = require('node:path');
const prototypePath = path.resolve(__dirname, '..', '货代SaaS平台原型.html');
const root = path.resolve(__dirname, '..');
const failures = [];
let html = '';
try {
html = fs.readFileSync(prototypePath, 'utf8');
html = [
fs.readFileSync(path.join(root, 'prototype-src', 'modules', '03-overseas-warehouse.html'), 'utf8'),
fs.readFileSync(path.join(root, 'prototype-src', 'app.js'), 'utf8'),
].join('\n');
} catch (error) {
failures.push(`无法读取原型文件:${error.message}`);
}
@@ -42,7 +45,7 @@ const detailPages = [
renderer: 'renderFbaRelabelOrder',
entryField: 'orderNo',
title: '换标单详情',
fields: ['换标单号', '贴标要求', '费用明细', '开箱费', '挑货费', '换标费', '换箱唛标费'],
fields: ['换标单号', '商品信息', '预算总价', '贴标要求', '费用明细', '开箱费', '挑货费', '换标费', '换箱唛标费', '换标费总计'],
},
{
pageId: 'fba-pallet-detail',
@@ -141,6 +144,9 @@ detailPages.forEach((detail) => {
if (detail.pageId === 'fba-relabel-order-detail') {
requireMatch(/data-fbro-view-order/, `${detail.renderer} 列表查看数据入口`, rendererSource);
requireMatch(/openFbaRelabelOrderDetail\s*\(\s*view\.dataset\.fbroViewOrder\s*\)/, `${detail.renderer} 委托查看入口传入 ${detail.entryField}`);
} else if (detail.pageId === 'fba-carton-detail') {
requireMatch(/cartonArg\s*=\s*lmoEsc\s*\(\s*JSON\.stringify\s*\(\s*item\.cartonNo\s*\)\s*\)/, `${detail.renderer} 安全序列化 ${detail.entryField}`, rendererSource);
requireMatch(/openFbaCartonDetail\s*\(\$\{cartonArg\}\)/, `${detail.renderer} 查看入口传入 ${detail.entryField}`, rendererSource);
} else {
const entryPattern = new RegExp(
`${escapeRegExp(detail.openFunction)}\\s*\\([^)]*\\b(?:d|item|record)\\.${escapeRegExp(detail.entryField)}\\b[^)]*\\)`,
@@ -0,0 +1,139 @@
#!/usr/bin/env node
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
const sourceRoot = path.join(root, 'prototype-src');
const general = fs.readFileSync(path.join(sourceRoot, 'modules/10-general.html'), 'utf8');
const shell = fs.readFileSync(path.join(sourceRoot, 'shell.html'), 'utf8');
const app = fs.readFileSync(path.join(sourceRoot, 'app.js'), 'utf8');
const manifest = JSON.parse(fs.readFileSync(path.join(sourceRoot, 'manifest.json'), 'utf8'));
const generalModule = manifest.modules.find(module => module.file === 'modules/10-general.html');
assert.ok(generalModule, 'manifest 中缺少通用资料模块');
const genPartnerModuleIndex = generalModule.pages.indexOf('gen-partner-warehouse');
assert.ok(genPartnerModuleIndex >= 0, '通用资料模块缺少 gen-partner-warehouse 页面');
assert.equal(
generalModule.pages[genPartnerModuleIndex + 1],
'gen-overseas-quote',
'gen-overseas-quote 应紧跟完整仓库页签组登记在通用资料模块中'
);
const genPartnerOrderIndex = manifest.pageOrder.indexOf('gen-partner-warehouse');
assert.ok(genPartnerOrderIndex >= 0, '顶层 pageOrder 缺少 gen-partner-warehouse');
assert.equal(
manifest.pageOrder[genPartnerOrderIndex + 1],
'gen-overseas-quote',
'gen-overseas-quote 应紧跟完整仓库页签组登记在顶层 pageOrder 中'
);
assert.match(
shell,
/switchPage\('gen-owned-warehouse',this\)"[^>]*>仓库管理<\/div>\s*<div class="sub-menu-item" onclick="switchPage\('gen-overseas-quote',this\)">海外仓报价<\/div>/,
'通用导航中海外仓报价入口必须紧跟仓库管理'
);
assert.equal(
(general.match(/id="page-gen-overseas-quote"/g) || []).length,
1,
'10-general.html 中 page-gen-overseas-quote 必须恰好出现一次'
);
assert.match(
general,
/#page-gen-overseas-quote \.goq-table\{min-width:1480px;table-layout:fixed;font-size:12px\}/,
'海外仓报价列表应为加宽后的操作列保留表格横向空间'
);
assert.match(
general,
/#page-gen-overseas-quote \.goq-table th:first-child,#page-gen-overseas-quote \.goq-table td:first-child\{width:250px\}/,
'海外仓报价列表操作列宽度应容纳详情、编辑、停用和删除按钮'
);
const quotePageStart = general.indexOf('<div class="page" id="page-gen-overseas-quote">');
const quotePageEnd = general.indexOf('<div class="page"', quotePageStart + 1);
const quotePage = general.slice(quotePageStart, quotePageEnd < 0 ? general.length : quotePageEnd);
for (const field of [
'报价名称',
'海外仓',
'服务商',
'币种',
'生效日期',
'失效日期',
'报价版本',
'仓储作业费用',
'中转派送价格',
'目的 FBA 仓',
'车型/提货方式',
'预估迈数',
'最低收费'
]) {
assert.ok(quotePage.includes(field), `海外仓报价页面缺少字段:${field}`);
}
assert.match(
general,
/id="modal-gen-overseas-quote-detail"/,
'10-general.html 缺少海外仓报价独立详情弹窗'
);
for (const functionName of [
'renderOverseasWarehouseQuotes',
'resetOverseasWarehouseQuoteFilters',
'openOverseasWarehouseQuoteDetail',
'renderOverseasWarehouseQuoteDetail',
'openOverseasWarehouseQuoteModal',
'addOverseasWarehouseFeeRow',
'addOverseasTransitPriceRow',
'saveOverseasWarehouseQuote',
'toggleOverseasWarehouseQuoteStatus',
'deleteOverseasWarehouseQuote'
]) {
assert.match(app, new RegExp(`function\\s+${functionName}\\s*\\(`), `app.js 缺少函数:${functionName}`);
}
function getFunctionSource(functionName) {
const start = app.search(new RegExp(`function\\s+${functionName}\\s*\\(`));
assert.ok(start >= 0, `app.js 缺少函数:${functionName}`);
const sourceFromFunction = app.slice(start);
const nextFunctionOffset = sourceFromFunction.slice(1).search(/\nfunction\s+[A-Za-z_$][\w$]*\s*\(/);
return nextFunctionOffset < 0
? sourceFromFunction
: sourceFromFunction.slice(0, nextFunctionOffset + 1);
}
const quoteListSource = getFunctionSource('renderOverseasWarehouseQuotes');
const quoteDetailButtonMatch = quoteListSource.match(
/<button\b[^>]*\bonclick="openOverseasWarehouseQuoteDetail\([^\"]*\)"[^>]*>\s*详情\s*<\/button>/
);
const quoteEditButtonMatch = quoteListSource.match(
/<button\b[^>]*\bonclick="openOverseasWarehouseQuoteModal\([^\"]*\)"[^>]*>\s*编辑\s*<\/button>/
);
assert.ok(
quoteDetailButtonMatch &&
quoteEditButtonMatch &&
quoteDetailButtonMatch.index < quoteEditButtonMatch.index,
'海外仓报价列表首位缺少“详情”入口'
);
const quoteDetailSource = [
getFunctionSource('openOverseasWarehouseQuoteDetail'),
getFunctionSource('renderOverseasWarehouseQuoteDetail')
].join('\n');
assert.match(
quoteDetailSource,
/openOverseasWarehouseQuoteModal\s*\(/,
'海外仓报价详情缺少进入现有编辑弹窗的路径'
);
const quoteDataStart = app.search(/overseasWarehouseQuoteData\s*=\s*\[/);
assert.ok(quoteDataStart >= 0, 'app.js 缺少 overseasWarehouseQuoteData 数组');
const quoteDataEnd = app.indexOf('];', quoteDataStart);
assert.ok(quoteDataEnd > quoteDataStart, 'overseasWarehouseQuoteData 数组缺少结束标记');
const quoteDataSource = app.slice(quoteDataStart, quoteDataEnd + 2);
assert.match(quoteDataSource, /warehouseFees\s*:/, '报价数据缺少 warehouseFees 字段');
assert.match(quoteDataSource, /transitPrices\s*:/, '报价数据缺少 transitPrices 字段');
console.log('海外仓报价源码契约验证通过。');
@@ -0,0 +1,175 @@
#!/usr/bin/env node
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
const financeHtml = fs.readFileSync(path.join(root, 'prototype-src', 'modules', '08-finance.html'), 'utf8');
const appJs = fs.readFileSync(path.join(root, 'prototype-src', 'app.js'), 'utf8');
function assertPattern(source, pattern, message) {
assert.ok(pattern.test(source), message);
}
function elementSourceById(source, id) {
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const opening = new RegExp(`<div\\b[^>]*\\bid=["']${escaped}["'][^>]*>`, 'i').exec(source);
if (!opening) return '';
const tagPattern = /<div\b[^>]*>|<\/div\s*>/gi;
tagPattern.lastIndex = opening.index;
let depth = 0;
let match;
while ((match = tagPattern.exec(source))) {
if (/^<div\b/i.test(match[0])) depth += 1;
else if (--depth === 0) return source.slice(opening.index, tagPattern.lastIndex);
}
return '';
}
function constObjectSource(source, name) {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const declaration = new RegExp(`const\\s+${escaped}\\s*=\\s*\\{`).exec(source);
if (!declaration) return '';
const braceStart = source.indexOf('{', declaration.index);
let depth = 0;
let quote = '';
let escapedChar = false;
for (let index = braceStart; index < source.length; index += 1) {
const char = source[index];
if (quote) {
if (escapedChar) escapedChar = false;
else if (char === '\\') escapedChar = true;
else if (char === quote) quote = '';
continue;
}
if (char === "'" || char === '"' || char === '`') {
quote = char;
continue;
}
if (char === '{') depth += 1;
if (char === '}' && --depth === 0) return source.slice(declaration.index, index + 1);
}
return '';
}
function functionSource(source, name) {
const start = source.indexOf(`function ${name}(`);
if (start < 0) return '';
const braceStart = source.indexOf('{', start);
if (braceStart < 0) return '';
let depth = 0;
let quote = '';
let escaped = false;
for (let index = braceStart; index < source.length; index += 1) {
const char = source[index];
if (quote) {
if (escaped) escaped = false;
else if (char === '\\') escaped = true;
else if (char === quote) quote = '';
continue;
}
if (char === "'" || char === '"' || char === '`') {
quote = char;
continue;
}
if (char === '{') depth += 1;
if (char === '}' && --depth === 0) return source.slice(start, index + 1);
}
return '';
}
const businessTabs = {
firstMile: '头程',
lastMile: '尾程',
};
const businessTabsSource = elementSourceById(financeHtml, 'profit-business-tabs');
assert.ok(businessTabsSource, '利润表缺少业务类型页签容器');
Object.entries(businessTabs).forEach(([key, label]) => {
assertPattern(
businessTabsSource,
new RegExp(`data-profit-business=["']${key}["'][^>]*>\\s*${label}`),
`利润表缺少“${label}”业务页签`,
);
});
assert.ok(
!/data-profit-business=["'](warehouseRent|relabel|warehouseTransfer)["']/.test(businessTabsSource),
'利润表不应继续展示仓租、贴标或仓储转运业务页签',
);
const orderTypeMatch = /<select\b[^>]*\bid=["']profit-order-type["'][^>]*>[\s\S]*?<\/select>/i.exec(financeHtml);
const orderTypeSource = orderTypeMatch ? orderTypeMatch[0] : '';
assert.ok(orderTypeSource, '利润表缺少订单类型查询下拉');
['全部订单类型', '暂扣', '退货', '仓储转运', '换标'].forEach((label) => {
assertPattern(orderTypeSource, new RegExp('>\\s*' + label + '\\s*</option>'), `订单类型下拉缺少“${label}”`);
});
const profitBusinessDataSource = constObjectSource(appJs, 'profitBusinessData');
assert.ok(profitBusinessDataSource, '缺少利润表业务数据对象');
assertPattern(appJs, /let\s+profitBusiness\s*=\s*["']firstMile["']/, '利润表默认业务类型应为头程');
assertPattern(appJs, /function\s+switchProfitBusiness\s*\(/, '缺少利润表业务类型切换函数');
assertPattern(appJs, /function\s+getActiveProfitTickets\s*\(/, '缺少当前业务利润单票读取函数');
Object.keys(businessTabs).forEach((key) => {
assertPattern(profitBusinessDataSource, new RegExp(`(?:["']${key}["']|\\b${key})\\s*:\\s*\\[`), `利润表业务数据缺少 ${key} 数组`);
});
assertPattern(appJs, /profit-order-type/, '利润表脚本未读取订单类型筛选');
assertPattern(appJs, /orderType/, '利润表数据或过滤逻辑缺少订单类型字段');
const profitBusinessLabelsSource = constObjectSource(appJs, 'profitBusinessLabels');
assert.ok(profitBusinessLabelsSource, '缺少利润表业务名称对象');
Object.entries(businessTabs).forEach(([key, label]) => {
assertPattern(
profitBusinessLabelsSource,
new RegExp(`(?:["']${key}["']|\\b${key})\\s*:\\s*["']${label}["']`),
`利润表业务名称 ${key} 应映射为“${label}”`,
);
});
const profitFeeTemplatesSource = constObjectSource(appJs, 'profitFeeTemplates');
assert.ok(profitFeeTemplatesSource, '缺少利润表费用模板对象');
Object.keys(businessTabs).forEach((key) => {
assertPattern(
profitFeeTemplatesSource,
new RegExp(`(?:["']${key}["']|\\b${key})\\s*:`),
`利润表费用模板缺少 ${key} 配置`,
);
});
for (const feeLabel of ['头程运费收入', '尾程服务收入']) {
assertPattern(
profitFeeTemplatesSource,
new RegExp("[\"']" + feeLabel + "[\"']"),
'利润表费用模板缺少“' + feeLabel + '”',
);
}
const profitDetailSource = functionSource(appJs, 'openProfitDetail');
assert.ok(profitDetailSource, '缺少利润明细函数');
assertPattern(
profitDetailSource,
/profitBusinessLabels\s*\[\s*profitBusiness\s*\]/,
'利润单票明细应展示当前业务类型',
);
const profitResetSource = functionSource(appJs, 'profitReset');
assert.ok(profitResetSource, '缺少利润表重置函数');
assertPattern(
profitResetSource,
/profitBusiness\s*=\s*["']firstMile["']/,
'利润表重置时应恢复头程业务类型',
);
assertPattern(
profitResetSource,
/profit-order-type[\s\S]*value\s*=\s*["']?["']?/,
'利润表重置时应清空订单类型',
);
console.log('利润表业务类型页签回归校验通过');
@@ -6,6 +6,17 @@ const root = path.resolve(__dirname, '..');
const sourceRoot = path.join(root, 'prototype-src');
const manifest = JSON.parse(fs.readFileSync(path.join(sourceRoot, 'manifest.json'), 'utf8'));
const complete = fs.readFileSync(path.join(root, manifest.source), 'utf8');
const overseasWarehouseModule = fs.readFileSync(path.join(sourceRoot, 'modules/03-overseas-warehouse.html'), 'utf8');
const appSource = fs.readFileSync(path.join(sourceRoot, 'app.js'), 'utf8');
assert.ok(!overseasWarehouseModule.includes('id="lmo-departure-fee"'), '尾程订单详情仍显示离港后费用明细区块');
assert.match(overseasWarehouseModule, /<th>单据类型<\/th><th>单据编号<\/th><th>生成时间<\/th><th>报价<\/th>/, '关联单据列表缺少报价字段');
const planIndex = overseasWarehouseModule.indexOf('id="lmo-detail-plan-info"');
const docsIndex = overseasWarehouseModule.indexOf('id="tbody-lmo-detail-documents"');
assert.ok(planIndex >= 0 && docsIndex > planIndex, '关联单据应位于计划信息之后');
assert.ok(!appSource.includes('renderLastMileDepartureFees(order);'), '尾程订单详情仍调用离港后费用渲染');
assert.match(appSource, /order\.quote\|\|order\.quoteNo\|\|['"]—['"]/, '关联单据报价未兼容 quote、quoteNo 和空值');
assert.match(appSource, /colspan="4" class="lmo-empty">暂无关联单据/, '关联单据空状态未覆盖四列');
const modulePages = [];
for (const module of manifest.modules) {
@@ -0,0 +1,29 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
const moduleSource = fs.readFileSync(path.join(root, 'prototype-src/modules/07-overseas-business.html'), 'utf8');
const appSource = fs.readFileSync(path.join(root, 'prototype-src/app.js'), 'utf8');
for (const pageId of ['page-domestic-purchase-detail', 'page-overseas-purchase-detail']) {
const start = moduleSource.indexOf(`id="${pageId}"`);
assert.ok(start >= 0, `${pageId} is missing`);
const end = moduleSource.indexOf('<div class="page"', start + 1);
const page = moduleSource.slice(start, end < 0 ? moduleSource.length : end);
assert.ok(page.includes('<h3>操作记录</h3>'), `${pageId} is missing 操作记录`);
assert.ok(!page.includes('进销存明细'), `${pageId} still exposes 进销存明细`);
assert.ok(!page.includes('-detail-ledger'), `${pageId} still exposes the ledger DOM id`);
}
assert.ok(!appSource.includes('inventoryTransactions:'), 'purchase data still defines inventoryTransactions');
assert.ok(!appSource.includes('domestic-detail-ledger'), 'domestic purchase renderer still targets the ledger DOM');
assert.ok(!appSource.includes('overseas-detail-ledger'), 'overseas purchase renderer still targets the ledger DOM');
assert.match(appSource, /operationRecords:/, 'purchase data does not define operationRecords');
assert.match(appSource, /r\.operationRecords/, 'domestic or overseas detail does not read operationRecords');
assert.match(appSource, /domestic-detail-operation-records/, 'domestic operation-record renderer is missing');
assert.match(appSource, /overseas-detail-operation-records/, 'overseas operation-record renderer is missing');
const expectedHeaderRow = '<th>操作时间</th><th>操作内容</th><th>操作人</th><th>操作结果</th><th>备注</th>';
assert.ok(appSource.split(expectedHeaderRow).length - 1 >= 2, 'both detail renderers must output the five operation-record headers');
console.log('PURCHASE_ORDER_OPERATION_RECORDS_CHECK_PASS');
@@ -0,0 +1,65 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
const salesModule = fs.readFileSync(path.join(root, 'prototype-src/modules/09-sales.html'), 'utf8');
const appJs = fs.readFileSync(path.join(root, 'prototype-src/app.js'), 'utf8');
const salesDataBlock = appJs.slice(appJs.indexOf('const salesCommissionData='), appJs.indexOf('const sysTenantData'));
function assertInOrder(source, labels, message) {
let previous = -1;
labels.forEach((label) => {
const position = source.indexOf(label, previous + 1);
assert.ok(position > previous, `${message}: 缺少或顺序错误 “${label}”`);
previous = position;
});
}
assertInOrder(
salesModule,
['综合提成率', '固定提成(元)', '溢价提成(元)', '垫款利息(元)', '提成金额(元)'],
'销售提成列表表头'
);
['fixedCommission', 'premiumCommission', 'advanceInterest'].forEach((field) => {
assert.ok(new RegExp(`${field}:-?\\d`).test(salesDataBlock), `销售人员示例数据缺少 ${field}`);
});
const advanceInterestValues = [...salesDataBlock.matchAll(/advanceInterest:(-?\d+)/g)].map((match) => Number(match[1]));
assert.equal(advanceInterestValues.length, 5, '销售人员垫款利息示例数据数量不正确');
assert.ok(advanceInterestValues.every((value) => value < 0), '垫款利息必须全部为负值');
assert.ok(
appJs.includes("return (value<0?'-':'')+'¥'+Math.abs(value).toLocaleString"),
'负数金额必须显示为 -¥金额'
);
const salesOrderCount = (salesDataBlock.match(/{no:'SO/g) || []).length;
assert.equal((salesDataBlock.match(/floorPrice:\d/g) || []).length, salesOrderCount, '并非每条订单都有 floorPrice');
assert.equal((salesDataBlock.match(/settlementStatus:/g) || []).length, salesOrderCount, '并非每条订单都有独立结算状态');
assert.ok(/settlementStatus:'未结算'/.test(salesDataBlock), '订单示例数据缺少未结算状态');
assert.ok(/settlementStatus:'已结算'/.test(salesDataBlock), '订单示例数据缺少已结算状态');
assert.ok(/comm:fixedCommission\+premiumCommission\+advanceInterest/.test(appJs), '提成金额没有按三类提成求和');
assert.ok(/calculatedComm:total\*rate\/100/.test(appJs), '原订单比例提成结果没有保留给明细使用');
assert.ok(appJs.includes('scMoney(c.calculatedComm)'), '明细汇总没有继续使用原订单比例提成口径');
['c.fixedCommission', 'c.premiumCommission', 'c.advanceInterest'].forEach((field) => {
assert.ok(appJs.includes(field), `列表没有渲染 ${field}`);
});
assert.ok(/colspan="12"[^>]*>暂无匹配数据/.test(appJs), '列表空态列数不是 12');
assertInOrder(
appJs,
['收款时间', '状态', '客户名称'],
'订单提成明细状态表头'
);
assertInOrder(
appJs,
['单价(元/kg)', '销售底价(元/kg)', '件数'],
'订单提成明细销售底价表头'
);
assert.ok(/o\.floorPrice\.toFixed\(2\)/.test(appJs), '订单提成明细没有渲染销售底价');
assert.ok(/o\.settlementStatus/.test(appJs), '订单提成明细没有渲染独立结算状态');
assert.ok(appJs.includes("o.settlementStatus==='已结算'?'tag-green':'tag-orange'"), '结算状态没有使用约定标签颜色');
assert.ok(/colspan="10"[^>]*>合计/.test(appJs), '明细合计行列数不是 10');
assert.ok(/overflow-x:auto/.test(appJs), '13 列明细表缺少横向滚动容器');
console.log('销售提成字段验证通过');
@@ -0,0 +1,43 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const root = path.resolve(__dirname, '..');
const moduleHtml = fs.readFileSync(path.join(root, 'prototype-src/modules/08-finance.html'), 'utf8');
const overlays = fs.readFileSync(path.join(root, 'prototype-src/overlays.html'), 'utf8');
const appJs = fs.readFileSync(path.join(root, 'prototype-src/app.js'), 'utf8');
assert.ok(moduleHtml.includes('付款状态:'), '查询区缺少付款状态标签');
assert.ok(appJs.includes('openSettlementApVoucher'), '列表缺少上传付款凭证入口');
assert.ok(appJs.includes('openSettlementApApproval'), '列表缺少审批入口');
assert.ok(overlays.includes('id="modal-settlement-ap-voucher"'), '缺少付款凭证弹框');
assert.ok(overlays.includes('id="settlement-ap-voucher-files"'), '付款凭证文件框缺少 ID');
assert.ok(overlays.includes('multiple'), '付款凭证不支持多文件');
assert.ok(overlays.includes('id="modal-settlement-ap-approval"'), '缺少审批弹框');
assert.ok(overlays.includes('审批通过'), '审批弹框缺少通过操作');
assert.ok(overlays.includes('审批驳回'), '审批弹框缺少驳回操作');
function loadFunction(name, nextName) {
const start = appJs.indexOf(`function ${name}`);
const end = appJs.indexOf(`\nfunction ${nextName}`, start);
assert.ok(start >= 0 && end > start, `缺少可测试函数 ${name}`);
const sandbox = {};
vm.runInNewContext(`${appJs.slice(start, end)}\nresult=${name};`, sandbox);
return sandbox.result;
}
const actionState = loadFunction('getSettlementApApprovalActionState', 'getSettlementApApprovalResult');
const approvalResult = loadFunction('getSettlementApApprovalResult', 'formatSettlementApVoucherSize');
const normalize = value => JSON.parse(JSON.stringify(value));
assert.deepEqual(normalize(actionState('已审批')), {canSubmit:false,readonly:true});
assert.deepEqual(normalize(actionState('审批中')), {canSubmit:true,readonly:false});
assert.deepEqual(normalize(actionState('已驳回')), {canSubmit:true,readonly:false});
assert.deepEqual(normalize(approvalResult('pass')), {approveStatus:'已审批',status:'生效'});
assert.deepEqual(normalize(approvalResult('reject')), {approveStatus:'已驳回',status:'待审批'});
assert.ok(appJs.includes('驳回原因不能为空'), '缺少驳回原因必填校验');
assert.ok(appJs.includes('paymentVouchers'), '数据未保存付款凭证数组');
assert.ok(appJs.includes('10 * 1024 * 1024'), '缺少 10MB 文件大小校验');
console.log('结算单应付付款凭证与审批回归校验通过');
@@ -0,0 +1,52 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const root = path.resolve(__dirname, '..');
const overlays = fs.readFileSync(path.join(root, 'prototype-src/overlays.html'), 'utf8');
const appJs = fs.readFileSync(path.join(root, 'prototype-src/app.js'), 'utf8');
assert.ok(appJs.includes('上传付款凭证'), '应收列表操作列缺少上传付款凭证入口');
assert.ok(appJs.includes('openSettlementArVoucher'), '上传入口未接通打开函数');
[
'modal-settlement-ap-voucher',
'settlement-ap-voucher-no',
'settlement-voucher-party-label',
'settlement-ap-voucher-supplier',
'settlement-ap-voucher-files',
'settlement-ap-voucher-list',
'settlement-voucher-count'
].forEach(id => assert.ok(overlays.includes(`id="${id}"`), `付款凭证弹窗缺少 ${id}`));
assert.ok(overlays.includes('multiple'), '付款凭证文件选择器未开启多选');
assert.ok(overlays.includes('.pdf,.jpg,.jpeg,.png'), '付款凭证文件类型限制不完整');
function loadFunction(name, nextName) {
const start = appJs.indexOf(`function ${name}`);
const end = appJs.indexOf(`\nfunction ${nextName}`, start);
assert.ok(start >= 0 && end > start, `缺少可测试函数 ${name}`);
const sandbox = {};
vm.runInNewContext(`${appJs.slice(start, end)}\nresult=${name};`, sandbox);
return sandbox.result;
}
const validate = loadFunction('validateSettlementApVoucherFile', 'createSettlementApVoucherEntry');
const createEntry = loadFunction('createSettlementApVoucherEntry', 'getSettlementVoucherPartyLabel');
const mb = 1024 * 1024;
assert.equal(validate({name:'water-slip.pdf', size:2 * mb}, []), '');
assert.match(validate({name:'water-slip.exe', size:2 * mb}, []), /仅支持/);
assert.match(validate({name:'water-slip.pdf', size:11 * mb}, []), /10MB/);
assert.match(validate({name:'WATER-SLIP.PDF', size:2 * mb}, [{name:'water-slip.pdf'}]), /已存在/);
assert.deepEqual(JSON.parse(JSON.stringify(createEntry({name:'water-slip.pdf', size:2048, type:'application/pdf'}, '2026-08-14 10:00:00'))), {
name:'water-slip.pdf', size:2048, type:'application/pdf', uploadedAt:'2026-08-14 10:00:00', uploader:'王建国'
});
const saveStart = appJs.indexOf('function saveSettlementApVouchers');
const saveEnd = appJs.indexOf('\nfunction ', saveStart + 10);
assert.ok(saveStart >= 0 && saveEnd > saveStart, '缺少付款凭证保存函数');
const saveSource = appJs.slice(saveStart, saveEnd);
['received', 'balance', 'payStatus', 'paymentRecords'].forEach(field => {
assert.ok(!saveSource.includes(field), `保存付款凭证不应修改 ${field}`);
});
console.log('结算单应收付款凭证回归校验通过');
@@ -1,7 +1,9 @@
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.resolve(__dirname, '..', '货代SaaS平台原型.html'), 'utf8');
const root = path.resolve(__dirname, '..');
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'prototype-src', 'manifest.json'), 'utf8'));
const html = fs.readFileSync(path.join(root, manifest.source), 'utf8');
const failures = [];
function expect(pattern, label, source = html) {
@@ -1,7 +1,7 @@
const fs = require('fs');
const path = require('path');
const htmlPath = path.resolve(__dirname, '..', '货代SaaS平台原型.html');
const htmlPath = path.resolve(__dirname, '..', 'index.html');
const html = fs.readFileSync(htmlPath, 'utf8');
function expect(pattern, message) {
@@ -0,0 +1,174 @@
#!/usr/bin/env node
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const root = path.resolve(__dirname, '..');
const sourceRoot = path.join(root, 'prototype-src');
const manifest = JSON.parse(fs.readFileSync(path.join(sourceRoot, 'manifest.json'), 'utf8'));
const general = fs.readFileSync(path.join(sourceRoot, 'modules/10-general.html'), 'utf8');
const app = fs.readFileSync(path.join(sourceRoot, 'app.js'), 'utf8');
const shell = fs.readFileSync(path.join(sourceRoot, 'shell.html'), 'utf8');
function extractFunction(source, name) {
const start = source.indexOf(`function ${name}(`);
assert.notEqual(start, -1, `缺少 ${name} 函数`);
const bodyStart = source.indexOf('{', start);
let depth = 0;
let quote = '';
let escaped = false;
for (let index = bodyStart; index < source.length; index += 1) {
const char = source[index];
if (quote) {
if (escaped) escaped = false;
else if (char === '\\') escaped = true;
else if (char === quote) quote = '';
continue;
}
if (char === "'" || char === '"' || char === '`') {
quote = char;
continue;
}
if (char === '{') depth += 1;
if (char === '}') {
depth -= 1;
if (depth === 0) return source.slice(start, index + 1);
}
}
throw new Error(`${name} 函数未正确闭合`);
}
const generalModule = manifest.modules.find(item => item.file === 'modules/10-general.html');
assert.ok(generalModule, 'manifest 缺少通用资料模块');
assert.equal(
manifest.pageOrder.filter(id => id === 'vehicle-detail').length,
1,
'pageOrder 应唯一登记 vehicle-detail',
);
assert.equal(
generalModule.pages.filter(id => id === 'vehicle-detail').length,
1,
'通用资料模块应唯一登记 vehicle-detail',
);
const vehicleListStart = general.indexOf('<div class="page" id="page-vehicle">');
const vehicleDetailStart = general.indexOf('<div class="page" id="page-vehicle-detail">');
assert.notEqual(vehicleListStart, -1, '缺少车辆列表页面');
assert.notEqual(vehicleDetailStart, -1, '缺少独立车辆详情页面');
const vehicleListPage = general.slice(vehicleListStart, vehicleDetailStart);
const vehicleDetailEnd = general.indexOf('<div class="page"', vehicleDetailStart + 1);
const vehicleDetailPage = general.slice(
vehicleDetailStart,
vehicleDetailEnd === -1 ? general.length : vehicleDetailEnd,
);
assert.match(
vehicleListPage,
/class="vehicle-region-tab active"[^>]*data-region="domestic"/,
'缺少默认选中的国内车辆一级标签',
);
assert.match(
vehicleListPage,
/class="vehicle-region-tab"[^>]*data-region="overseas"/,
'缺少海外车辆一级标签',
);
assert.match(
vehicleListPage,
/class="vehicle-owner-tab active"[^>]*data-owner="own"/,
'缺少默认选中的自有车辆二级标签',
);
assert.match(
vehicleListPage,
/class="vehicle-owner-tab"[^>]*data-owner="dispatch"/,
'缺少外派车辆二级标签',
);
assert.doesNotMatch(vehicleListPage, /vregion-pill/, '车辆列表仍保留旧区域胶囊筛选');
assert.doesNotMatch(vehicleListPage, />国外</, '车辆列表仍使用“国外”文案');
assert.match(
vehicleListPage,
/<div class="breadcrumb">首页 <span class="sep">&gt;<\/span> 通用/,
'车辆列表面包屑应归属通用模块',
);
assert.equal(
(vehicleListPage.match(/class="vehicle-overseas-only"[^>]*>邮箱<\/th>/g) || []).length,
2,
'自有和外派车辆列表都应提供海外专属邮箱列',
);
assert.match(
vehicleListPage,
/class="vehicle-overseas-only" style="display:none;/,
'国内车辆默认视图应隐藏邮箱列',
);
assert.match(vehicleListPage, /id="vehicle-m-email"/, '车辆新增/编辑表单缺少邮箱字段');
assert.match(
vehicleListPage,
/id="vehicle-m-region"[^>]*onchange="syncVehicleEmailFieldVisibility\(\)"/,
'切换车辆区域时应同步邮箱字段可见性',
);
assert.match(vehicleDetailPage, /id="vehicle-detail-plate"/, '详情页缺少车牌标题节点');
assert.match(vehicleDetailPage, /id="vehicle-detail-status"/, '详情页缺少状态节点');
assert.match(vehicleDetailPage, /id="vehicle-detail-info"/, '详情页缺少车辆信息区域');
assert.match(app, /\['邮箱',vehicle\.email\|\|'—'\]/, '车辆详情缺少邮箱字段');
assert.match(vehicleDetailPage, /id="vehicle-detail-orders-body"/, '详情页缺少派送订单明细表体');
assert.match(vehicleDetailPage, /id="vehicle-detail-history-body"/, '详情页缺少派送更换记录表体');
assert.match(vehicleDetailPage, /onclick="backToVehicleList\(\)"/, '详情页缺少返回列表入口');
assert.doesNotMatch(general, /id="modal-vehicle-view"/, '车辆查看弹窗尚未移除');
assert.match(app, /function\s+switchVehicleRegion\s*\(/, '缺少区域标签切换函数');
assert.match(app, /function\s+openVehicleDetail\s*\(/, '缺少进入车辆详情函数');
assert.match(app, /function\s+renderVehicleDetail\s*\(/, '缺少车辆详情渲染函数');
assert.match(app, /function\s+backToVehicleList\s*\(/, '缺少返回车辆列表函数');
assert.doesNotMatch(app, /showModal\(['"]modal-vehicle-view['"]\)/, '查看车辆仍会打开弹窗');
assert.match(shell, /\.vehicle-detail-grid/, '缺少车辆详情信息栅格样式');
assert.match(shell, /@media[^}]*max-width:\s*900px/s, '缺少车辆详情窄屏适配');
const aggregateSource = extractFunction(app, 'getVehicleDispatchOrders');
const emailVisibilitySource = extractFunction(app, 'syncVehicleEmailFieldVisibility');
const detailRenderSource = extractFunction(app, 'renderVehicleDetail');
const openDetailSource = extractFunction(app, 'openVehicleDetail');
const backToListSource = extractFunction(app, 'backToVehicleList');
assert.doesNotMatch(detailRenderSource, /\bd\./, '详情渲染仍引用旧弹窗局部变量 d');
assert.match(emailVisibilitySource, /region==='overseas'/, '邮箱表单应仅对海外车辆显示');
assert.match(app, /const email=region==='overseas'/, '保存车辆时应仅为海外车辆读取邮箱');
assert.match(
openDetailSource,
/switchPage\('vehicle-detail',vehicleNav\)/,
'进入详情页后应保留车辆列表导航高亮',
);
assert.match(
backToListSource,
/switchPage\('vehicle',vehicleNav\)/,
'返回列表后应恢复车辆列表导航高亮',
);
const runtime = {Array, Map, Set, String};
vm.createContext(runtime);
vm.runInContext(`${aggregateSource};this.getVehicleDispatchOrders=getVehicleDispatchOrders;`, runtime);
const result = runtime.getVehicleDispatchOrders(
{
id: 'VD1',
fleetChangeHistory: [
{dispatchNo: 'DSP-OLD'},
{dispatchNo: 'DSP-CURRENT'},
],
},
[
{dispatchNo: 'DSP-OLD', fleetVehicleId: 'VD2'},
{dispatchNo: 'DSP-CURRENT', fleetVehicleId: 'VD1'},
{dispatchNo: 'DSP-CURRENT', fleetVehicleId: 'VD1'},
{dispatchNo: 'DSP-OTHER', fleetVehicleId: 'VD3'},
],
);
assert.deepEqual(
JSON.parse(JSON.stringify(result.map(item => [item.record.dispatchNo, item.relation]))),
[
['DSP-OLD', '历史关联'],
['DSP-CURRENT', '当前关联'],
],
'车辆派送订单应合并当前与历史关联、去重并优先标记当前关联',
);
console.log('VEHICLE_REGION_TABS_DETAIL_CHECK_PASS');
@@ -0,0 +1,55 @@
#!/usr/bin/env node
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
const generalHtml = fs.readFileSync(path.join(root, 'prototype-src/modules/10-general.html'), 'utf8');
const appJs = fs.readFileSync(path.join(root, 'prototype-src/app.js'), 'utf8');
function extractPage(source, pageId) {
const marker = `<div class="page" id="page-${pageId}">`;
const start = source.indexOf(marker);
if (start < 0) return '';
const next = source.indexOf('<div class="page" id="page-', start + marker.length);
return source.slice(start, next < 0 ? source.length : next);
}
function extractFunction(source, name, nextName) {
const start = source.indexOf(`function ${name}(`);
if (start < 0) return '';
const end = source.indexOf(`function ${nextName}(`, start);
return source.slice(start, end < 0 ? source.length : end);
}
const voyagePage = extractPage(generalHtml, 'gen-voyage');
assert.ok(voyagePage, '通用资料模块缺少 page-gen-voyage');
assert.match(
voyagePage,
/<th id="cost-unit-header">成本单价\(元\/kg\)<\/th><th>销售底价<\/th><th>建议报价<\/th><\/tr><\/thead><tbody id="tbody-voyage-cost">/,
'成本测算表尾列顺序必须为“成本单价、销售底价、建议报价”',
);
assert.match(
voyagePage,
/销售底价\s*=\s*建议报价\s*-\s*0\.6/,
'成本测算公式说明缺少“销售底价 = 建议报价 - 0.6”',
);
const calculateCostRowSource = extractFunction(appJs, 'calculateCostRow', 'renderCostCalcRows');
assert.ok(calculateCostRowSource, 'app.js 缺少 calculateCostRow 函数');
assert.match(
calculateCostRowSource,
/salesFloor\s*:\s*quote\s*-\s*0\.6/,
'calculateCostRow 必须返回固定派生值 salesFloor: quote - 0.6',
);
const renderCostCalcRowsSource = extractFunction(appJs, 'renderCostCalcRows', 'calculateVoyageCosts');
assert.ok(renderCostCalcRowsSource, 'app.js 缺少 renderCostCalcRows 函数');
assert.match(
renderCostCalcRowsSource,
/<td class="cost-sales-floor">¥'\+c\.salesFloor\.toFixed\(2\)\+'<\/td><td class="cost-quote">¥'\+c\.quote\.toFixed\(2\)/,
'renderCostCalcRows 必须在建议报价前渲染 cost-sales-floor 与 salesFloor.toFixed(2)',
);
console.log('VOYAGE_SALES_FLOOR_CHECK_PASS');
@@ -1,7 +1,7 @@
const fs = require('fs');
const path = require('path');
const html = fs.readFileSync(path.resolve(__dirname, '..', '货代SaaS平台原型.html'), 'utf8');
const html = fs.readFileSync(path.resolve(__dirname, '..', 'index.html'), 'utf8');
const failures = [];
function expect(pattern, label, source = html) {
@@ -17,6 +17,11 @@ const managementEnd = html.indexOf('<!-- ==================== INVENTORY OVERVIEW
const management = managementStart >= 0 && managementEnd > managementStart
? html.slice(managementStart, managementEnd)
: '';
const overviewStart = html.indexOf('<div class="page" id="page-inventory-overview">');
const overviewEnd = html.indexOf('<!-- ==================== WAREHOUSE STOCK', overviewStart);
const overview = overviewStart >= 0 && overviewEnd > overviewStart
? html.slice(overviewStart, overviewEnd)
: '';
expect(/id="page-fba-stock-management"/, '库存管理页面');
expectAbsent(/class="inventory-management-tabs"/, '海外仓库存业务页签容器', management);
@@ -36,9 +41,10 @@ expectAbsent(/data-stock-tab="(?:warehouse-stock|fba-return-stock|overseas-stock
'tbody-inventory-overview'
].forEach(id => expect(new RegExp(`id="${id}"`), id));
['在库总量', '可用库存', '暂扣库存', '美本土散版', '在途库存', '采购库存', '批次数', '库位数', '更新时间'].forEach(label => {
['在库总量', '可用库存', '暂扣库存', '美本土散版', '在途库存', '采购库存', '更新时间'].forEach(label => {
expect(new RegExp(label), `WMS 字段 ${label}`);
});
expectAbsent(/>批次数<|>库位数</, '库存管理批次/库位统计列', overview);
expectAbsent(/不良品/, '旧不良品库存文案');
expect(/存在美本土散版/, '美本土散版库存状态');
expect(/const\s+looseboardStockData\s*=\s*\[/, '美本土散版库存数据源');
@@ -0,0 +1,296 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>费用流转流程图</title>
<style>
:root{--ink:#1f2937;--muted:#64748b;--line:#d8e1eb;--blue:#2563eb;--blue-soft:#eff6ff;--orange:#d97706;--orange-soft:#fff7ed;--green:#15803d;--green-soft:#f0fdf4;--red:#dc2626;--red-soft:#fef2f2;--gray:#64748b;--gray-soft:#f8fafc;--lane:#f7f9fc;--lane-alt:#fbfdff}
*{box-sizing:border-box}
html,body{margin:0;min-height:100%;font-family:"Microsoft YaHei","PingFang SC",Arial,sans-serif;color:var(--ink);background:#eef2f6}
body{padding:24px}
.page{max-width:1800px;margin:0 auto}
.toolbar{display:flex;align-items:flex-start;justify-content:space-between;gap:18px;margin-bottom:16px;padding:20px 22px;background:#fff;border:1px solid #dbe3ed;border-radius:10px;box-shadow:0 6px 18px rgba(30,41,59,.06)}
.title-wrap h1{margin:0;font-size:24px;letter-spacing:0;font-weight:760;color:#172033}
.title-wrap p{margin:8px 0 0;color:var(--muted);font-size:13px;line-height:1.55}
.toolbar-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px}
button{border:1px solid #cbd5e1;border-radius:6px;background:#fff;color:#334155;padding:9px 14px;font:inherit;font-size:13px;cursor:pointer;transition:background .15s,border-color .15s,color .15s}
button:hover{border-color:#94a3b8;background:#f8fafc}
button.primary{border-color:#2563eb;background:#2563eb;color:#fff}
button.primary:hover{background:#1d4ed8;border-color:#1d4ed8}
button.danger{color:#b91c1c;border-color:#fecaca;background:#fffafa}
.legend{display:flex;align-items:center;flex-wrap:wrap;gap:10px 18px;margin-bottom:14px;padding:12px 16px;background:#fff;border:1px solid #dbe3ed;border-radius:8px;color:#475569;font-size:12px}
.legend-title{font-weight:700;color:#334155;margin-right:2px}
.legend-item{display:inline-flex;align-items:center;gap:7px;white-space:nowrap}
.legend-dot{width:12px;height:12px;border-radius:4px;border:1px solid currentColor;display:inline-block}
.legend-dot.blue{background:var(--blue-soft);color:var(--blue)}
.legend-dot.orange{background:var(--orange-soft);color:var(--orange)}
.legend-dot.green{background:var(--green-soft);color:var(--green)}
.legend-dot.red{background:var(--red-soft);color:var(--red)}
.legend-dot.gray{background:var(--gray-soft);color:var(--gray);border-style:dashed}
.legend-line{width:30px;border-top:2px solid #64748b;display:inline-block}
.legend-line.red{border-top-color:var(--red);border-top-style:dashed}
.legend-line.gray{border-top-color:#94a3b8;border-top-style:dashed}
.scroll-frame{max-width:1602px;margin:0 auto;overflow:auto;border:1px solid #cfd9e5;border-radius:10px;background:#fff;box-shadow:0 8px 22px rgba(30,41,59,.06)}
.canvas{position:relative;width:1600px;height:3300px;background:#fff}
.lane{position:absolute;top:0;bottom:0;width:400px;border-left:1px solid #dbe4ee;border-right:1px solid #dbe4ee}
.lane:nth-of-type(1){left:0;background:var(--lane-alt)}
.lane:nth-of-type(2){left:400px;background:var(--lane)}
.lane:nth-of-type(3){left:800px;background:var(--lane-alt)}
.lane:nth-of-type(4){left:1200px;background:var(--lane)}
.lane-label{position:absolute;left:0;right:0;top:0;height:76px;display:flex;align-items:center;justify-content:center;border-bottom:1px solid #d6e0eb;background:rgba(241,245,249,.88);font-size:16px;font-weight:760;color:#334155;writing-mode:horizontal-tb;letter-spacing:1px}
.lane-note{position:absolute;left:24px;right:24px;top:48px;text-align:center;font-size:10px;line-height:1.4;color:#94a3b8;white-space:nowrap;letter-spacing:0}
.node{position:absolute;width:220px;height:74px;overflow:hidden;padding:12px 13px;display:flex;align-items:center;justify-content:center;text-align:center;border:1px solid #cbd5e1;border-radius:8px;background:#fff;box-shadow:0 3px 8px rgba(15,23,42,.07);font-size:13px;line-height:1.45;z-index:3}
.node .node-text{display:block;width:100%;max-height:50px;overflow:auto;outline:0;white-space:pre-line;overflow-wrap:anywhere;scrollbar-width:thin}
.node.system{border-color:#93c5fd;background:var(--blue-soft);color:#1e40af}
.node.pending{border-color:#f6c778;background:var(--orange-soft);color:#92400e}
.node.success{border-color:#86d39d;background:var(--green-soft);color:#166534}
.node.reject{border-color:#f3a4a4;background:var(--red-soft);color:#991b1b}
.node.decision{border-style:dashed;border-color:#f59e0b;background:#fffdf5;color:#854d0e;border-radius:8px}
.node.note{border-style:dashed;border-color:#b8c3d1;background:var(--gray-soft);color:#64748b;font-size:12px}
.node.editing{box-shadow:0 0 0 3px rgba(37,99,235,.16),0 3px 8px rgba(15,23,42,.07)}
.node.editing .node-text{cursor:text;background:rgba(255,255,255,.6);border-radius:4px;padding:2px 3px}
.branch-tag{position:absolute;z-index:4;padding:6px 12px;border-radius:4px;font-size:11px;font-weight:700;letter-spacing:.2px;background:#fff}
.branch-tag.ar{left:24px;top:1000px;color:#1d4ed8;border-left:3px solid #2563eb}
.branch-tag.ap{left:24px;top:2130px;color:#7c3aed;border-left:3px solid #7c3aed}
.canvas-caption{display:flex;justify-content:space-between;gap:12px;padding:11px 16px;border-top:1px solid #dbe4ee;background:#fff;color:#64748b;font-size:12px}
.canvas-caption strong{color:#334155}
.status{min-height:18px;color:#64748b;font-size:12px}
svg.connections{position:absolute;inset:0;width:100%;height:100%;z-index:2;pointer-events:none}
.connections path{fill:none;stroke:#64748b;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round}
.connections path.red{stroke:var(--red);stroke-dasharray:7 6}
.connections path.gray{stroke:#94a3b8;stroke-dasharray:5 6}
.connections text{font-family:"Microsoft YaHei","PingFang SC",Arial,sans-serif;font-size:12px;fill:#64748b}
.connections text.red{fill:var(--red)}
.connections text.gray{fill:#64748b}
@media (max-width:900px){body{padding:12px}.toolbar{display:block}.toolbar-actions{justify-content:flex-start;margin-top:14px}.title-wrap h1{font-size:20px}}
@media print{body{padding:0;background:#fff}.toolbar,.legend,.canvas-caption{display:none}.page{max-width:none}.scroll-frame{border:0;overflow:visible;box-shadow:none}.canvas{transform-origin:top left;transform:scale(.48);margin-bottom:-1716px}}
</style>
</head>
<body data-storage-key="fee-flow-node-texts-v1">
<main class="page">
<header class="toolbar">
<div class="title-wrap">
<h1>费用流转流程图</h1>
<p>依据后台订单与财务原型整理:费用生成 → 财务审核 → 应收 / 应付结算 → 收付款结清。</p>
</div>
<div class="toolbar-actions" aria-label="流程图操作">
<button type="button" class="primary" id="edit-toggle">编辑文字</button>
<button type="button" id="reset-flow">恢复默认</button>
<button type="button" id="download-flow">下载 HTML</button>
<button type="button" id="print-flow">打印</button>
</div>
</header>
<section class="legend" aria-label="图例">
<span class="legend-title">图例</span>
<span class="legend-item"><i class="legend-dot blue"></i>系统动作 / 单据</span>
<span class="legend-item"><i class="legend-dot orange"></i>待处理 / 待确认</span>
<span class="legend-item"><i class="legend-dot green"></i>完成 / 结清</span>
<span class="legend-item"><i class="legend-dot red"></i>驳回 / 异常</span>
<span class="legend-item"><i class="legend-dot gray"></i>原型未定义 / 留档</span>
<span class="legend-item"><i class="legend-line"></i>正常流</span>
<span class="legend-item"><i class="legend-line red"></i>驳回回路</span>
<span class="legend-item"><i class="legend-line gray"></i>旁路</span>
</section>
<div class="scroll-frame">
<section class="canvas" aria-label="费用流转竖向泳道图" data-flow-direction="vertical" data-department-layout="columns" data-canvas-width="1600" data-canvas-height="3300">
<div class="lane" data-department="业务订单"><div class="lane-label">业务订单</div><div class="lane-note">国内仓 / 海外仓 / 派送 / 货柜等业务单据</div></div>
<div class="lane" data-department="系统"><div class="lane-label">系统</div><div class="lane-note">记录费用、账单、结算单和余额状态</div></div>
<div class="lane" data-department="财务"><div class="lane-label">财务</div><div class="lane-note">审核、对账、审批、收付款登记</div></div>
<div class="lane" data-department="客户与供应商"><div class="lane-label">客户与供应商</div><div class="lane-note">客户确认已实现;供应商确认未在原型定义</div></div>
<span class="branch-tag ar">应收分支 · 客户结算</span>
<span class="branch-tag ap">应付分支 · 供应商结算</span>
<svg class="connections" viewBox="0 0 1600 3300" role="img" aria-label="费用流转连线">
<defs>
<marker id="arrow" markerWidth="9" markerHeight="9" refX="7" refY="4.5" orient="auto"><path d="M0,0 L9,4.5 L0,9 Z" fill="#64748b"></path></marker>
<marker id="arrow-red" markerWidth="9" markerHeight="9" refX="7" refY="4.5" orient="auto"><path d="M0,0 L9,4.5 L0,9 Z" fill="#dc2626"></path></marker>
<marker id="arrow-gray" markerWidth="9" markerHeight="9" refX="7" refY="4.5" orient="auto"><path d="M0,0 L9,4.5 L0,9 Z" fill="#94a3b8"></path></marker>
</defs>
<path d="M200 194 V224 H600 V250" marker-end="url(#arrow)"></path>
<path d="M600 324 V352 H1000 V380" marker-end="url(#arrow)"></path>
<path d="M1000 454 V510" marker-end="url(#arrow)"></path>
<path d="M890 547 H600 V650" marker-end="url(#arrow)"></path>
<text x="730" y="535">通过</text>
<path d="M1000 584 V610 H200 V650" class="red" marker-end="url(#arrow-red)"></path>
<text x="925" y="603" class="red">驳回</text>
<path d="M90 687 H40 V287 H490" class="red" marker-end="url(#arrow-red)"></path>
<text x="48" y="472" class="red">编辑后重新提交</text>
<path d="M600 724 V790" marker-end="url(#arrow)"></path>
<path d="M710 827 H1000 V900" class="gray" marker-end="url(#arrow-gray)"></path>
<text x="822" y="815" class="gray">否:留档</text>
<path d="M600 864 V1040" marker-end="url(#arrow)"></path>
<text x="612" y="963">是:应收</text>
<path d="M490 827 H430 V2217 H490" marker-end="url(#arrow)"></path>
<text x="440" y="2106">是:应付</text>
<path d="M600 1114 V1142 H1000 V1170" marker-end="url(#arrow)"></path>
<path d="M1000 1244 V1300" marker-end="url(#arrow)"></path>
<path d="M1000 1374 V1402 H1400 V1430" marker-end="url(#arrow)"></path>
<path d="M1400 1504 V1560" class="red" marker-end="url(#arrow-red)"></path>
<text x="1412" y="1540" class="red">否</text>
<path d="M1290 1467 H1180 V1518 H600 V1560" marker-end="url(#arrow)"></path>
<text x="1200" y="1454">是:已确认</text>
<path d="M1510 1597 H1560 V1337 H1110" class="red" marker-end="url(#arrow-red)"></path>
<text x="1470" y="1392" class="red">重新推送</text>
<path d="M600 1634 V1662 H1000 V1690" marker-end="url(#arrow)"></path>
<path d="M1000 1764 V1820" marker-end="url(#arrow)"></path>
<path d="M1000 1894 V1950" marker-end="url(#arrow)"></path>
<path d="M600 1634 V1820" class="gray" marker-end="url(#arrow-gray)"></path>
<text x="612" y="1754" class="gray">原型未定义</text>
<path d="M600 2254 V2310" marker-end="url(#arrow)"></path>
<path d="M600 2384 V2412 H1000 V2440" marker-end="url(#arrow)"></path>
<path d="M1000 2514 V2570" class="red" marker-end="url(#arrow-red)"></path>
<text x="1012" y="2552" class="red">驳回</text>
<path d="M1110 2607 H1150 V2477 H1110" class="red" marker-end="url(#arrow-red)"></path>
<text x="1160" y="2548" class="red">重新审批</text>
<path d="M890 2477 H600 V2700" marker-end="url(#arrow)"></path>
<text x="730" y="2465">通过</text>
<path d="M710 2737 H1290" class="gray" marker-end="url(#arrow-gray)"></path>
<text x="1008" y="2724" class="gray">供应商确认未定义</text>
<path d="M600 2774 V2802 H1000 V2830" marker-end="url(#arrow)"></path>
<path d="M1000 2904 V2960" marker-end="url(#arrow)"></path>
<path d="M1000 3034 V3090" marker-end="url(#arrow)"></path>
</svg>
<div class="node" style="left:90px;top:120px" data-node-id="order-fee-source" data-default-text="业务订单产生费用"><span class="node-text" contenteditable="false">业务订单产生费用</span></div>
<div class="node system" style="left:490px;top:250px" data-node-id="fee-generated" data-default-text="系统生成应收/应付费用记录"><span class="node-text" contenteditable="false">系统生成应收/应付费用记录</span></div>
<div class="node pending" style="left:890px;top:380px" data-node-id="fee-pending-audit" data-default-text="费用待审核"><span class="node-text" contenteditable="false">费用待审核</span></div>
<div class="node decision" style="left:890px;top:510px" data-node-id="fee-audit-decision" data-default-text="财务审核通过?"><span class="node-text" contenteditable="false">财务审核通过?</span></div>
<div class="node reject" style="left:90px;top:650px" data-node-id="fee-rejected" data-default-text="审核驳回:费用可编辑"><span class="node-text" contenteditable="false">审核驳回:费用可编辑</span></div>
<div class="node success" style="left:490px;top:650px" data-node-id="fee-approved" data-default-text="审核通过:记录锁定"><span class="node-text" contenteditable="false">审核通过:记录锁定</span></div>
<div class="node decision" style="left:490px;top:790px" data-node-id="join-settlement-decision" data-default-text="参与结算?"><span class="node-text" contenteditable="false">参与结算?</span></div>
<div class="node note" style="left:890px;top:900px" data-node-id="fee-archived" data-default-text="不参与结算 / 禁止结算\n留档,不生成账单"><span class="node-text" contenteditable="false">不参与结算 / 禁止结算<br>留档,不生成账单</span></div>
<div class="node system" style="left:490px;top:1040px" data-node-id="ar-bill" data-default-text="客户对账单\n按费用明细汇总"><span class="node-text" contenteditable="false">客户对账单<br>按费用明细汇总</span></div>
<div class="node pending" style="left:890px;top:1170px" data-node-id="ar-bill-audit" data-default-text="对账单审核通过"><span class="node-text" contenteditable="false">对账单审核通过</span></div>
<div class="node" style="left:890px;top:1300px" data-node-id="ar-push" data-default-text="推送客户"><span class="node-text" contenteditable="false">推送客户</span></div>
<div class="node decision" style="left:1290px;top:1430px" data-node-id="customer-confirm" data-default-text="客户确认?"><span class="node-text" contenteditable="false">客户确认?</span></div>
<div class="node pending" style="left:1290px;top:1560px" data-node-id="ar-confirm-wait" data-default-text="待确认 / 可重新推送"><span class="node-text" contenteditable="false">待确认 / 可重新推送</span></div>
<div class="node system" style="left:490px;top:1560px" data-node-id="ar-settlement" data-default-text="生成应收结算单"><span class="node-text" contenteditable="false">生成应收结算单</span></div>
<div class="node pending" style="left:890px;top:1690px" data-node-id="ar-approve" data-default-text="应收结算单\n草稿 → 待审批 → 生效"><span class="node-text" contenteditable="false">应收结算单<br>草稿 → 待审批 → 生效</span></div>
<div class="node pending" style="left:890px;top:1820px" data-node-id="ar-payment" data-default-text="收款登记\n部分收款可重复登记"><span class="node-text" contenteditable="false">收款登记<br>部分收款可重复登记</span></div>
<div class="node success" style="left:890px;top:1950px" data-node-id="ar-paid" data-default-text="已结清"><span class="node-text" contenteditable="false">已结清</span></div>
<div class="node note" style="left:490px;top:1820px" data-node-id="ar-undefined" data-default-text="自动开票 / 自动核销:原型未定义"><span class="node-text" contenteditable="false">自动开票 / 自动核销:原型未定义</span></div>
<div class="node system" style="left:490px;top:2180px" data-node-id="ap-bill" data-default-text="供应商应付对账\n按费用明细汇总"><span class="node-text" contenteditable="false">供应商应付对账<br>按费用明细汇总</span></div>
<div class="node system" style="left:490px;top:2310px" data-node-id="ap-settlement" data-default-text="生成应付结算单"><span class="node-text" contenteditable="false">生成应付结算单</span></div>
<div class="node pending" style="left:890px;top:2440px" data-node-id="ap-approval" data-default-text="应付结算审批\n草稿 → 待审批"><span class="node-text" contenteditable="false">应付结算审批<br>草稿 → 待审批</span></div>
<div class="node reject" style="left:890px;top:2570px" data-node-id="ap-rejected" data-default-text="审批驳回\n可重新审批"><span class="node-text" contenteditable="false">审批驳回<br>可重新审批</span></div>
<div class="node success" style="left:490px;top:2700px" data-node-id="ap-effective" data-default-text="审批通过:生效"><span class="node-text" contenteditable="false">审批通过:生效</span></div>
<div class="node note" style="left:1290px;top:2700px" data-node-id="supplier-undefined" data-default-text="供应商确认:原型未定义"><span class="node-text" contenteditable="false">供应商确认:原型未定义</span></div>
<div class="node" style="left:890px;top:2830px" data-node-id="ap-voucher" data-default-text="上传付款凭证"><span class="node-text" contenteditable="false">上传付款凭证</span></div>
<div class="node pending" style="left:890px;top:2960px" data-node-id="ap-payment" data-default-text="付款登记\n部分付款可重复登记"><span class="node-text" contenteditable="false">付款登记<br>部分付款可重复登记</span></div>
<div class="node success" style="left:890px;top:3090px" data-node-id="ap-paid" data-default-text="已结清"><span class="node-text" contenteditable="false">已结清</span></div>
</section>
<div class="canvas-caption"><span><strong>原型已确认:</strong>费用审核、客户确认门槛、结算审批、部分收付款与结清。</span><span><strong>原型未定义:</strong>供应商确认、自动开票、自动核销。</span></div>
</div>
<p class="status" id="status" role="status" aria-live="polite">当前为只读模式。点击“编辑文字”后可修改节点内容。</p>
</main>
<script>
(() => {
const body = document.body;
const storageKey = body.dataset.storageKey || 'fee-flow-node-texts-v1';
const nodes = [...document.querySelectorAll('[data-node-id]')];
const textEls = nodes.map(node => node.querySelector('.node-text')).filter(Boolean);
const defaults = Object.fromEntries(nodes.map(node => [node.dataset.nodeId, node.dataset.defaultText.replace(/\\n/g, '\n')]));
const editToggle = document.getElementById('edit-toggle');
const resetButton = document.getElementById('reset-flow');
const downloadButton = document.getElementById('download-flow');
const printButton = document.getElementById('print-flow');
const status = document.getElementById('status');
const safeStorage = {
get() { try { return localStorage.getItem(storageKey); } catch { return null; } },
set(value) { try { localStorage.setItem(storageKey, value); return true; } catch { return false; } },
remove() { try { localStorage.removeItem(storageKey); return true; } catch { return false; } }
};
function setStatus(message) { status.textContent = message; }
function readNodeText(textEl) {
return String(textEl.innerText || textEl.textContent || '').replace(/\r\n/g, '\n').replace(/\u00a0/g, ' ');
}
function applyTexts(values) {
nodes.forEach(node => {
const value = values[node.dataset.nodeId];
if (value !== undefined) node.querySelector('.node-text').textContent = String(value).replace(/\\n/g, '\n');
});
}
function currentTexts() {
return Object.fromEntries(nodes.map(node => [node.dataset.nodeId, readNodeText(node.querySelector('.node-text'))]));
}
function persist() {
if (!safeStorage.set(JSON.stringify(currentTexts()))) setStatus('浏览器未允许本地保存,仍可下载当前 HTML。');
}
function setEditing(enabled) {
body.classList.toggle('editing', enabled);
textEls.forEach(textEl => {
textEl.contentEditable = enabled ? 'true' : 'false';
textEl.parentElement.classList.toggle('editing', enabled);
textEl.spellcheck = false;
});
editToggle.textContent = enabled ? '完成编辑' : '编辑文字';
editToggle.classList.toggle('primary', !enabled);
setStatus(enabled ? '编辑模式:点击任意节点文字进行修改,修改会自动保存。' : '当前为只读模式。点击“编辑文字”后可修改节点内容。');
if (enabled && textEls[0]) textEls[0].focus();
}
function loadSaved() {
try {
const saved = JSON.parse(safeStorage.get() || '{}');
applyTexts(saved && typeof saved === 'object' ? saved : {});
} catch { safeStorage.remove(); }
}
function resetFlow() {
if (!window.confirm('确定恢复流程图默认文字吗?')) return;
safeStorage.remove();
applyTexts(defaults);
setEditing(false);
setStatus('已恢复默认文字。');
}
function downloadHtml() {
const exportedTexts = currentTexts();
const clone = document.documentElement.cloneNode(true);
const cloneBody = clone.querySelector('body');
cloneBody.classList.remove('editing');
cloneBody.dataset.storageKey = 'fee-flow-node-texts-export-' + Date.now();
clone.querySelectorAll('[data-node-id]').forEach(node => {
const textEl = node.querySelector('.node-text');
node.dataset.defaultText = exportedTexts[node.dataset.nodeId];
textEl.textContent = exportedTexts[node.dataset.nodeId];
textEl.setAttribute('contenteditable', 'false');
textEl.removeAttribute('spellcheck');
node.classList.remove('editing');
});
const blob = new Blob(['<!doctype html>\n' + clone.outerHTML], { type: 'text/html;charset=utf-8' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = '费用流转流程图-已编辑.html';
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(link.href), 0);
setStatus('已下载当前编辑版本 HTML。');
}
loadSaved();
setEditing(false);
editToggle.addEventListener('click', () => setEditing(!body.classList.contains('editing')));
resetButton.addEventListener('click', resetFlow);
downloadButton.addEventListener('click', downloadHtml);
printButton.addEventListener('click', () => window.print());
textEls.forEach(textEl => {
textEl.addEventListener('input', persist);
textEl.addEventListener('paste', event => {
event.preventDefault();
document.execCommand('insertText', false, event.clipboardData.getData('text/plain'));
});
});
})();
</script>
</body>
</html>