This commit is contained in:
2026-08-21 09:21:53 +08:00
30 changed files with 2901 additions and 486 deletions
+4
View File
@@ -1,2 +1,6 @@
.DS_Store
**/.DS_Store
.playwright-cli
.superpowers
output
docs
File diff suppressed because one or more lines are too long
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

@@ -641,6 +641,7 @@
<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>
@@ -651,7 +652,7 @@
<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>采购价格(USD) *</label><input id="fbp-form-price" type="number" min="0" step="0.01" placeholder="请输入采购价格"></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>
<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">
@@ -969,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"/>
@@ -987,7 +987,7 @@
<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: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>
<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>
@@ -1013,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>
@@ -1023,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>
@@ -1037,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>
@@ -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><button class="btn btn-default" id="ca-detail-deduction-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">
@@ -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>
@@ -972,7 +972,6 @@
<th>操作</th>
<th>结算单号</th>
<th>客户公司名</th>
<th>费用类型</th>
<th>币种</th>
<th>应收金额</th>
<th>已收</th>
@@ -1032,7 +1031,6 @@
<th>操作</th>
<th>结算单号</th>
<th>供应商</th>
<th>费用类型</th>
<th>币种</th>
<th>应付金额</th>
<th>已付</th>
@@ -1112,13 +1110,12 @@
<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="warehouseRent" onclick="switchProfitBusiness('warehouseRent',this)">仓租</button>
<button type="button" class="profit-business-tab" data-profit-business="relabel" onclick="switchProfitBusiness('relabel',this)">贴标</button>
<button type="button" class="profit-business-tab" data-profit-business="warehouseTransfer" onclick="switchProfitBusiness('warehouseTransfer',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="开始月份"/>
@@ -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>
@@ -290,8 +290,8 @@ FORM: 通用模块中的管理清单加分区编辑弹窗,作为既有后台
<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>
@@ -324,6 +324,7 @@ FORM: 通用模块中的管理清单加分区编辑弹窗,作为既有后台
<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>
@@ -339,6 +340,7 @@ FORM: 通用模块中的管理清单加分区编辑弹窗,作为既有后台
<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>
@@ -369,7 +371,11 @@ FORM: 通用模块中的管理清单加分区编辑弹窗,作为既有后台
</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>
@@ -562,7 +562,7 @@
<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-empty-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>
@@ -1595,8 +1595,8 @@
<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-container-no" maxlength="32" placeholder="选填"/></div></div>
<div class="form-row single"><div class="form-group"><label>柜号</label><input id="arap-form-cabinet-no" maxlength="80" 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>
@@ -1609,7 +1609,7 @@
<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-status">—</strong></div>
<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>
@@ -1678,6 +1678,25 @@
<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">
@@ -1754,3 +1773,39 @@
<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>
@@ -147,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}
@@ -1119,6 +1119,64 @@ body.dashboard-fullscreen #page-dashboard table:has(>thead)>tbody>tr>td{backgrou
#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>
@@ -1189,8 +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('overseas-location',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>
@@ -8,25 +8,21 @@
<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></select></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><input id="bol-driverEmail" type="email"/></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><input id="bol-bookAccount"/></div>
<div class="form-group"><label>ISA号码</label><input id="bol-isaNo"/></div>
<div class="form-group"><label>车辆大小</label><input id="bol-vehicleSize"/></div>
<div class="form-group"><label>是否发送派送单</label><select id="bol-sendDispatch"><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" required/></div>
<div class="form-group"><label>预估派送单费用(USD) *</label><input id="bol-estimatedDispatchFee" type="number" min="0" step="0.01" required/></div>
<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 class="form-group" id="bol-courier-fee-field" hidden><label>快递费用(USD) *</label><input id="bol-courierFee" type="number" min="0" step="0.01"/></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>
@@ -57,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">
@@ -12,19 +12,34 @@ assert.ok(moduleHtml.includes('table style="min-width: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 => {
['预计提柜时间', '约车时间', '报空时间', '还柜时间', '报空预警'].forEach(label => {
assert.ok(moduleHtml.includes(label), `货柜列表缺少表头:${label}`);
});
['ct-add-pickup-expected', 'ct-add-empty-expected', 'ct-add-emptied-at', 'ct-add-returned-at'].forEach(id => {
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}`);
});
['pickupExpectedAt', 'emptyExpectedAt', 'emptiedAt', 'returnedAt'].forEach(key => {
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-empty-expected', 'ct-add-emptied-at', 'ct-add-returned-at'].forEach(id => {
['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);
@@ -19,22 +19,12 @@ const renderCtListSource = extractBetween(
'货柜列表渲染片段',
);
assert.ok(/class="ct-fleet-region-sel"/.test(renderCtListSource), '码头车队区域选择框缺失');
assert.ok(/const regionOpts=\['国内','海外'\]/.test(renderCtListSource), '国内和海外区域选项缺失');
assert.ok(/regionOpts\.map\(region=>/.test(renderCtListSource), '区域选项渲染逻辑缺失');
assert.ok(/d\.fleetRegion===region/.test(renderCtListSource), '区域选中态绑定缺失');
assert.ok(
/onchange="ctSetFleetRegion\('\$\{d\.no\}',this\.value\)"/.test(renderCtListSource),
'区域选择变更事件缺失',
);
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), '码头车队渲染不应绑定区域变更事件');
const ctSetFleetRegionSource = extractBetween(
'function ctSetFleetRegion(no,val)',
'function ctSetFleet(no,val)',
'码头车队区域 setter 片段',
);
assert.ok(!appJs.includes('function ctSetFleetRegion('), '码头车队区域 setter 应移除');
assert.ok(/d\.fleetRegion=val/.test(ctSetFleetRegionSource), '区域 setter 赋值缺失');
console.log('货柜列表码头车队区域选择框验证通过');
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,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,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('客户充值申请与审核回归校验通过');
@@ -22,4 +22,16 @@ 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,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 结构完整且为离线自包含文件。');
@@ -74,12 +74,14 @@ assert.match(
);
assert.match(tail, /id="bol-courier-company-field"[^>]*hidden/, '缺少默认隐藏的快递公司字段');
assert.match(tail, /id="bol-courierCompanyId"/, '缺少快递公司选择框');
assert.match(tail, /id="bol-courier-fee-field"[^>]*hidden/, '缺少默认隐藏的快递费用字段');
assert.match(
tail,
/id="bol-courierFee"[^>]*type="number"[^>]*min="0"[^>]*step="0\.01"/,
'快递费用应支持非负 USD 小数',
/<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"/,
@@ -99,32 +101,31 @@ const base = {
pickupMethod: '一提一卸',
loadType: '托盘',
dispatchFee: 200,
estimatedDispatchFee: 180,
loadingFee: 0,
};
assert.equal(validate({...base, deliveryMethod: '客户自提'}, companies), '');
assert.match(
validate({...base, deliveryMethod: '快递', courierCompanyId: '', courierFee: '10'}, companies),
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', courierFee: '10'}, companies),
validate({...base, deliveryMethod: '快递', courierCompanyId: 'E002'}, companies),
/快递公司/,
);
assert.match(
validate({...base, deliveryMethod: '快递', courierCompanyId: 'E001', courierFee: ''}, companies),
/快递费用/,
);
assert.match(
validate({...base, deliveryMethod: '快递', courierCompanyId: 'E001', courierFee: '-1'}, companies),
/快递费用/,
);
assert.match(
validate({...base, deliveryMethod: '快递', courierCompanyId: 'E001', courierFee: 'not-a-number'}, companies),
/快递费用/,
);
assert.equal(
validate({...base, deliveryMethod: '快递', courierCompanyId: 'E001', courierFee: '0'}, companies),
validate({...base, deliveryMethod: '快递', courierCompanyId: 'E001'}, companies),
'',
);
@@ -136,24 +137,26 @@ assert.deepEqual(
source: 'outboundVehicle',
deliveryMethod: '快递',
courierCompanyName: 'DHL',
courierFee: 12.5,
dispatchFee: 12.5,
appointmentDate: '2026-08-14',
},
{dispatchNo: 'PSD002', source: 'outboundVehicle', deliveryMethod: '客户自提', courierFee: 99},
{dispatchNo: 'PSD003', source: 'outboundVehicle', deliveryMethod: '快递', courierFee: -1},
{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.match(deliveryMethodChange, /feeInput\.value\s*=\s*'0'/, '切换非快递方式时应重置快递费用');
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.match(saveBol, /courierFee/, '派送单和车辆派送快照应保存快递费用');
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/, '动态费用行应支持幂等清理');
@@ -245,12 +245,42 @@ expect(Boolean(bolModal), '缺少独立转派送弹框');
expectExactOptionSet(
bolModal,
'bol-deliveryMethod',
['FBA自提', '客户自提', '卡派打托', '卡派地板', '快递'],
['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',
@@ -268,6 +298,7 @@ const functionNames = [
'nextOutboundVehicleDeliveryNo',
'saveBol',
'openOutboundVehicleDelivery',
'getOutboundVehicleCourierFeeDetails',
];
const scriptSource = `${app}\n${tail}`;
const scriptCode = codeOnly(scriptSource);
@@ -278,6 +309,14 @@ 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 {
@@ -313,7 +352,6 @@ runPureFunctionCheck('validateOutboundVehicleDeliveryDraft', (validateOutboundVe
pickupMethod: '一提一卸',
loadType: '地板',
dispatchFee: 200,
estimatedDispatchFee: 180,
loadingFee: 150,
}),
'',
@@ -325,10 +363,53 @@ runPureFunctionCheck('validateOutboundVehicleDeliveryDraft', (validateOutboundVe
pickupMethod: '一提一卸',
loadType: '地板',
dispatchFee: -1,
estimatedDispatchFee: 180,
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,
}],
);
});
@@ -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');
@@ -85,9 +85,7 @@ function functionSource(source, name) {
const businessTabs = {
firstMile: '头程',
warehouseRent: '仓租',
relabel: '贴标',
warehouseTransfer: '仓储转运',
lastMile: '尾程',
};
const businessTabsSource = elementSourceById(financeHtml, 'profit-business-tabs');
@@ -100,6 +98,18 @@ Object.entries(businessTabs).forEach(([key, 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["']/, '利润表默认业务类型应为头程');
@@ -110,6 +120,9 @@ 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]) => {
@@ -130,7 +143,7 @@ Object.keys(businessTabs).forEach((key) => {
);
});
for (const feeLabel of ['头程运费收入', '仓租收入', '贴标服务收入', '仓储转运收入']) {
for (const feeLabel of ['头程运费收入', '尾程服务收入']) {
assertPattern(
profitFeeTemplatesSource,
new RegExp("[\"']" + feeLabel + "[\"']"),
@@ -153,5 +166,10 @@ assertPattern(
/profitBusiness\s*=\s*["']firstMile["']/,
'利润表重置时应恢复头程业务类型',
);
assertPattern(
profitResetSource,
/profit-order-type[\s\S]*value\s*=\s*["']?["']?/,
'利润表重置时应清空订单类型',
);
console.log('利润表业务类型页签回归校验通过');
@@ -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('销售提成字段验证通过');
@@ -91,10 +91,27 @@ assert.match(
/<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\(\)"/, '详情页缺少返回列表入口');
@@ -109,10 +126,13 @@ 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\)/,
@@ -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');
@@ -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>