Files
shanxia2.0/docs/superpowers/demos/2026-10-05-contract-plan-line-qty-demo.html
T
2026-10-06 17:55:20 +08:00

925 lines
42 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>合同行绑定 · 计划目标量 / 归档实际量 · Fresh Mint Demo</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<style>
:root {
--primary: #10b981;
--primary-dark: #059669;
--primary-bg: #ecfdf5;
--text: #1e293b;
--text-muted: #64748b;
--border: #d9e8e2;
--surface: #ffffff;
--bg-page: #f1f5f9;
--table-head: #f8fafc;
--row-alt: #fafafa;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
--shadow-lg: 0 12px 40px rgba(15, 23, 42, 0.14);
--radius: 10px;
--warn: #d97706;
--warn-bg: #fffbeb;
--danger: #dc2626;
--danger-bg: #fef2f2;
--info: #0284c7;
--info-bg: #f0f9ff;
--info-border: #bae6fd;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: Inter, -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--text);
background: var(--bg-page);
}
.page { max-width: 1320px; margin: 0 auto; padding: 24px 20px 80px; }
.hero {
background: linear-gradient(135deg, #ecfdf5 0%, #fff 50%, #f0f9ff 100%);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px 22px;
margin-bottom: 14px;
box-shadow: var(--shadow);
}
.hero h1 { margin: 0 0 8px; font-size: 22px; font-weight: 700; }
.hero h1 em { font-style: normal; color: var(--primary-dark); }
.hero p { margin: 0; color: var(--text-muted); max-width: 1040px; }
.pills { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.pill {
padding: 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 500;
background: var(--primary-bg); color: var(--primary-dark); border: 1px solid #a7f3d0;
}
.pill.warn { background: var(--warn-bg); color: var(--warn); border-color: #fde68a; }
.pill.info { background: var(--info-bg); color: var(--info); border-color: var(--info-border); }
.pill.muted { background: #f1f5f9; color: var(--text-muted); border-color: #e2e8f0; }
.steps { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
.step-btn {
border: 1px solid var(--border); background: #fff; border-radius: 8px;
padding: 8px 14px; font: inherit; font-weight: 600; font-size: 13px;
color: var(--text-muted); cursor: pointer;
}
.step-btn:hover { border-color: #a7f3d0; color: var(--primary-dark); }
.step-btn.active {
background: var(--primary-bg); border-color: var(--primary); color: var(--primary-dark);
}
.step-btn .n {
display: inline-flex; width: 18px; height: 18px; align-items: center; justify-content: center;
border-radius: 999px; background: #e2e8f0; color: #475569; font-size: 11px; margin-right: 6px;
}
.step-btn.active .n { background: var(--primary); color: #fff; }
.panel { display: none; }
.panel.active { display: block; }
.callout {
margin-bottom: 12px; padding: 10px 12px; border-radius: 8px; font-size: 13px;
border: 1px solid #a7f3d0; background: var(--primary-bg); color: #047857;
}
.callout.warn { border-color: #fde68a; background: var(--warn-bg); color: #92400e; }
.callout.info { border-color: var(--info-border); background: var(--info-bg); color: #0369a1; }
.callout.danger { border-color: #fecaca; background: var(--danger-bg); color: #991b1b; }
.dialog-sim {
background: #fff; border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: var(--shadow-lg); overflow: hidden; max-height: calc(100vh - 120px);
display: flex; flex-direction: column;
}
.dialog-sim__hd {
padding: 14px 18px; border-bottom: 1px solid var(--border); font-weight: 700;
background: linear-gradient(180deg, #ecfdf5, #fff);
display: flex; align-items: center; justify-content: space-between; gap: 12px;
}
.dialog-sim__hd .sub { font-weight: 500; font-size: 12px; color: var(--text-muted); }
.dialog-sim__bd { padding: 16px 18px; overflow: auto; }
.dialog-sim__ft {
padding: 12px 18px; border-top: 1px solid var(--border); background: var(--table-head);
display: flex; justify-content: flex-end; gap: 8px;
}
.sec-title {
margin: 0 0 10px; font-size: 14px; font-weight: 700; display: flex; align-items: center; gap: 8px;
}
.sec-title::before {
content: ""; width: 3px; height: 14px; background: var(--primary); border-radius: 2px;
}
.form-grid {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 12px; margin-bottom: 14px;
}
@media (max-width: 900px) { .form-grid { grid-template-columns: 1fr 1fr; } }
.field label {
display: block; font-size: 11px; color: var(--text-muted); font-weight: 600; margin-bottom: 4px;
}
.field .ctl, .field input, .field select {
width: 100%; height: 36px; border: 1px solid var(--border); border-radius: 8px;
padding: 0 10px; font: inherit; background: #fff;
}
.field .ctl.ro { background: #f8fafc; display: flex; align-items: center; color: var(--text-muted); }
.field input:focus, .field select:focus {
outline: none; border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2);
}
table.mini { width: 100%; border-collapse: collapse; font-size: 13px; }
table.mini th, table.mini td {
border-bottom: 1px solid #eef2f0; padding: 8px 10px; text-align: left; vertical-align: middle;
}
table.mini th {
background: var(--table-head); color: var(--text-muted); font-weight: 600; font-size: 12px;
}
table.mini tr:nth-child(even) td { background: var(--row-alt); }
table.mini tr.dup td { background: #ecfdf5 !important; box-shadow: inset 3px 0 0 var(--primary); }
table.mini tr.over td { background: #fef2f2 !important; box-shadow: inset 3px 0 0 var(--danger); }
table.mini input[type="number"] {
width: 88px; height: 32px; border: 1px solid var(--border); border-radius: 8px;
padding: 0 8px; font: inherit;
}
table.mini tr.over input { border-color: #fca5a5; background: #fff; }
.tag {
display: inline-flex; align-items: center; padding: 1px 8px; border-radius: 4px;
font-size: 11px; font-weight: 600; background: #059669; color: #fff;
}
.tag.warn { background: #d97706; }
.tag.danger { background: #dc2626; }
.tag.info { background: transparent; color: #1e293b; border: 1px solid #cbd5e1; }
.mono { font-variant-numeric: tabular-nums; }
.muted { color: var(--text-muted); }
.price { color: var(--warn); font-weight: 700; }
.btn {
display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 14px;
border-radius: 8px; border: 1px solid var(--border); background: #fff; color: var(--text);
font: inherit; font-weight: 600; font-size: 13px; cursor: pointer;
}
.btn:hover { border-color: #a7f3d0; }
.btn.primary { background: var(--primary); border-color: var(--primary); color: #fff; }
.btn.primary:hover { background: var(--primary-dark); }
.btn.ghost { background: var(--primary-bg); border-color: #a7f3d0; color: var(--primary-dark); }
.btn.danger { color: #fff; background: var(--danger); border-color: var(--danger); }
.btn.link { height: auto; padding: 0; border: none; background: none; color: var(--primary-dark); }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 8px 0 10px; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip {
display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 6px;
background: var(--primary-bg); border: 1px solid #a7f3d0; font-size: 12px; font-weight: 600;
color: var(--primary-dark);
}
.chip .x {
width: 16px; height: 16px; border: none; border-radius: 4px; background: #fff;
color: var(--text-muted); cursor: pointer; line-height: 1;
}
.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
@media (max-width: 980px) { .cols { grid-template-columns: 1fr; } }
.box { border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
.box-h {
padding: 10px 12px; background: #f8fafc; border-bottom: 1px solid var(--border);
font-weight: 700; font-size: 13px; display: flex; justify-content: space-between; gap: 8px;
}
.box-h.load { border-left: 3px solid var(--primary); }
.box-h.delivery { border-left: 3px solid var(--info); }
.box-b { padding: 10px 12px; }
.mask {
display: none; position: fixed; inset: 0; background: rgba(15, 23, 42, 0.35);
z-index: 40; align-items: center; justify-content: center; padding: 20px;
backdrop-filter: none;
}
.mask.show { display: flex; }
.modal {
width: min(640px, 100%); background: #fff; border-radius: var(--radius);
box-shadow: var(--shadow-lg); max-height: calc(100vh - 40px);
display: flex; flex-direction: column; overflow: hidden;
}
.modal-h { padding: 14px 18px; border-bottom: 1px solid var(--border); font-weight: 700; }
.modal-b { padding: 16px 18px; overflow: auto; }
.modal-f {
padding: 12px 18px; border-top: 1px solid var(--border); background: var(--table-head);
display: flex; justify-content: flex-end; gap: 8px;
}
.driver {
max-width: 390px; margin: 0 auto; background: #fff; border-radius: 18px;
border: 1px solid var(--border); overflow: hidden; box-shadow: var(--shadow);
}
.driver-hd {
background: linear-gradient(180deg, #0a9f6d, #087f57); color: #fff; padding: 18px 16px 22px;
border-radius: 0 0 20px 20px;
}
.driver-hd h3 { margin: 0; font-size: 16px; }
.driver-hd p { margin: 4px 0 0; font-size: 12px; opacity: .9; }
.driver-bd { padding: 14px; }
.driver-card {
border: 1px solid #d1fae5; background: #e9f9f2; border-radius: 12px; padding: 12px; margin-bottom: 10px;
}
.driver-card .k { font-size: 12px; color: #087f57; }
.driver-card .v { font-size: 18px; font-weight: 700; }
.toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(16px);
background: #0f172a; color: #fff; padding: 10px 16px; border-radius: 8px;
font-size: 13px; opacity: 0; pointer-events: none; transition: .2s; z-index: 70; max-width: 90vw;
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.remain.neg { color: var(--danger); font-weight: 700; }
.sum-bar {
margin-top: 8px; font-size: 12px; color: var(--text-muted);
display: flex; flex-wrap: wrap; gap: 12px;
}
.sum-bar.bad { color: var(--danger); font-weight: 600; }
.sum-bar.ok { color: var(--primary-dark); font-weight: 600; }
</style>
</head>
<body>
<div class="page">
<header class="hero">
<h1>合同明细多单价行 × 运输计划绑合同行 × <em>归档确认实际量</em></h1>
<p>
按 2026-10-05 已定口径:计划员保存时按「合同行 + 目标量」拆行;归档填实际量;超剩余红字高亮并二次确认;
司机/现场只看产品与数量。本页是界面示意,可点操作,不是现网页面。
</p>
<div class="pills">
<span class="pill warn">同品同包装可多行</span>
<span class="pill warn">装/送货多选合同</span>
<span class="pill">选合同行,不先选产品再拆</span>
<span class="pill info">计划可超剩余 · 安排才硬拦</span>
<span class="pill info">归档超交 · 红字 + 二次确认</span>
<span class="pill muted">司机现场不展示合同</span>
</div>
</header>
<nav class="steps">
<button class="step-btn active" data-step="1"><span class="n">1</span>合同 · 新增/编辑弹窗</button>
<button class="step-btn" data-step="2"><span class="n">2</span>运输计划 · 新增/编辑</button>
<button class="step-btn" data-step="3"><span class="n">3</span>安排运输 · 硬拦</button>
<button class="step-btn" data-step="4"><span class="n">4</span>归档 · 实际量与超交</button>
<button class="step-btn" data-step="5"><span class="n">5</span>司机 / 现场</button>
<button class="step-btn" data-step="6"><span class="n">6</span>合同详情挂量</button>
</nav>
<!-- 1 Contract form -->
<section class="panel active" id="panel-1">
<div class="callout warn">
<strong>相对现状:</strong>现网会提示「同一产品的包装规格不能重复」。下面弹窗允许两行都是「次氯酸钠 / 槽罐车」,单价不同。
</div>
<article class="dialog-sim">
<div class="dialog-sim__hd">
<div>编辑合同 <span class="sub">HT-A · 客户:安徽某某水务</span></div>
</div>
<div class="dialog-sim__bd">
<div class="form-grid">
<div class="field"><label>合同编号</label><div class="ctl ro">HT-A-2026-088</div></div>
<div class="field"><label>客户</label><div class="ctl ro">安徽某某水务</div></div>
<div class="field"><label>合同类型</label><div class="ctl ro">销售合同</div></div>
<div class="field"><label>状态</label><div class="ctl ro"><span class="tag">执行中</span></div></div>
</div>
<h3 class="sec-title">产品明细</h3>
<div class="toolbar">
<button class="btn primary" type="button" id="btn-add-line">+ 添加产品行</button>
<span class="muted" style="font-size:12px">同品同包装允许重复;请用单价 / 数量区分行。</span>
</div>
<table class="mini" id="tbl-contract">
<thead>
<tr>
<th>#</th>
<th>产品</th>
<th>包装规格</th>
<th>合同数量</th>
<th>含税单价</th>
<th>说明</th>
<th></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="dialog-sim__ft">
<button class="btn" type="button">取消</button>
<button class="btn primary" type="button" id="btn-save-contract">确定保存</button>
</div>
</article>
</section>
<!-- 2 Plan form -->
<section class="panel" id="panel-2">
<div class="callout">
<strong>计划员视角:</strong>知道本车对应哪些合同行、各多少目标吨。装货/送货可多选合同;添加产品时直接选「合同行」,不要只选产品名。
</div>
<article class="dialog-sim">
<div class="dialog-sim__hd">
<div>新增运输计划 <span class="sub">PO20261005001 · 直销单</span></div>
</div>
<div class="dialog-sim__bd">
<div class="form-grid">
<div class="field"><label>计划日期</label><div class="ctl ro">2026-10-05</div></div>
<div class="field"><label>单据类型</label><div class="ctl ro">直销单</div></div>
<div class="field"><label>车牌</label><div class="ctl ro">皖 H12xxx</div></div>
<div class="field"><label>本车目标合计</label><div class="ctl ro mono" id="plan-total">0 吨</div></div>
</div>
<h3 class="sec-title">装送货信息</h3>
<div class="cols">
<div class="box">
<div class="box-h load"><span>装货信息</span><span class="muted" style="font-weight:500">客户:安徽某某水务</span></div>
<div class="box-b">
<label class="muted" style="font-size:11px;font-weight:600">合同(可多选)</label>
<div class="chips" id="load-chips" style="margin:6px 0 10px"></div>
<div class="toolbar" style="margin-top:0">
<button class="btn ghost" type="button" id="btn-add-load-row">+ 添加装货产品(选合同行)</button>
</div>
<table class="mini" id="tbl-load"><thead>
<tr><th>合同行</th><th>产品 / 包装</th><th>单价</th><th>剩余</th><th>目标量</th><th></th></tr>
</thead><tbody></tbody></table>
</div>
</div>
<div class="box">
<div class="box-h delivery"><span>送货信息 #1</span><span class="muted" style="font-weight:500">同一客户</span></div>
<div class="box-b">
<label class="muted" style="font-size:11px;font-weight:600">合同(可多选)</label>
<div class="chips" id="delivery-chips" style="margin:6px 0 10px"></div>
<div class="toolbar" style="margin-top:0">
<button class="btn ghost" type="button" id="btn-add-delivery-row">+ 添加送货产品(选合同行)</button>
<button class="btn" type="button" id="btn-sync">从装货同步目标量</button>
</div>
<table class="mini" id="tbl-delivery"><thead>
<tr><th>合同行</th><th>产品 / 包装</th><th>单价</th><th>剩余</th><th>目标量</th><th></th></tr>
</thead><tbody></tbody></table>
</div>
</div>
</div>
<p class="muted" style="margin:10px 0 0;font-size:12px">草稿保存不拦超剩余。下方「保存草稿」可超;「安排运输」会硬拦(见步骤 3)。</p>
</div>
<div class="dialog-sim__ft">
<button class="btn" type="button">取消</button>
<button class="btn" type="button" id="btn-save-draft">保存草稿</button>
<button class="btn primary" type="button" id="btn-go-arrange">安排运输…</button>
</div>
</article>
</section>
<!-- 3 Arrange -->
<section class="panel" id="panel-3">
<div class="callout warn">
<strong>安排运输硬拦:</strong>任一合同行目标量 &gt; 该行剩余时,禁止安排。请先改目标量或换行。不会记到别的同品行。
</div>
<article class="dialog-sim" style="max-width:720px;margin:0 auto">
<div class="dialog-sim__hd">安排运输</div>
<div class="dialog-sim__bd">
<p class="muted" style="margin:0 0 10px">将按当前送货目标量检查合同行剩余。</p>
<table class="mini" id="tbl-arrange-check">
<thead><tr><th>合同行</th><th>目标量</th><th>剩余</th><th>结果</th></tr></thead>
<tbody></tbody>
</table>
</div>
<div class="dialog-sim__ft">
<button class="btn" type="button" data-step="2">返回改目标量</button>
<button class="btn primary" type="button" id="btn-confirm-arrange">确认安排</button>
</div>
</article>
</section>
<!-- 4 Archive -->
<section class="panel" id="panel-4">
<div class="callout info">
<strong>归档:</strong>行结构与计划相同。实际量默认=目标量,可改。地磅实发须等于各行实际合计。某行超过该行剩余时红字,必须二次确认才归档。
</div>
<article class="dialog-sim">
<div class="dialog-sim__hd">
<div>运输计划归档 <span class="sub">PO20261005001 · 已完成运输</span></div>
</div>
<div class="dialog-sim__bd">
<h3 class="sec-title">装卸货归档 · 送货段</h3>
<div class="form-grid" style="grid-template-columns:repeat(3,1fr)">
<div class="field"><label>地磅 / 本车实发</label><input id="weigh-total" type="number" step="0.001" value="30" /></div>
<div class="field"><label>各行实际合计</label><div class="ctl ro mono" id="actual-sum">—</div></div>
<div class="field"><label>对齐结果</label><div class="ctl ro" id="align-result">—</div></div>
</div>
<table class="mini" id="tbl-archive">
<thead>
<tr>
<th>合同行</th>
<th>产品 / 包装</th>
<th>单价</th>
<th>目标量</th>
<th>该行剩余(归档前)</th>
<th>实际量</th>
</tr>
</thead>
<tbody></tbody>
</table>
<div class="sum-bar" id="archive-hint"></div>
<div class="toolbar">
<button class="btn" type="button" id="btn-demo-over">演示超交:行1 实际量改 55,地磅改 75</button>
<span class="muted" style="font-size:12px">用于弹出红字二次确认。行2、行3 仍为 10,合计 75。</span>
</div>
</div>
<div class="dialog-sim__ft">
<button class="btn" type="button">取消</button>
<button class="btn primary" type="button" id="btn-archive">确认归档</button>
</div>
</article>
</section>
<!-- 5 Driver -->
<section class="panel" id="panel-5">
<div class="callout">
<strong>司机 / 现场:</strong>不展示合同号、单价、合同行。只看到产品和大约数量。内部仍按合同行记账。
</div>
<div class="driver">
<div class="driver-hd">
<h3>送货任务</h3>
<p>皖 H12xxx · 安徽某某水务</p>
</div>
<div class="driver-bd">
<div class="driver-card">
<div class="k">产品</div>
<div class="v">次氯酸钠 · 槽罐车</div>
</div>
<div class="driver-card">
<div class="k">本车数量(仅供现场)</div>
<div class="v" id="driver-qty">30 吨</div>
</div>
<p class="muted" style="font-size:12px;margin:0">不会出现 HT-A / HT-B、800 元 / 850 元等合同信息。</p>
</div>
</div>
</section>
<!-- 6 Contract detail -->
<section class="panel" id="panel-6">
<div class="callout warn">
<strong>合同详情:</strong>同品同包装并排两行,各自已归档 / 剩余。超交显示负数或「超交」,绝不合并成一行。
</div>
<article class="dialog-sim">
<div class="dialog-sim__hd">查看合同 HT-A-2026-088</div>
<div class="dialog-sim__bd">
<h3 class="sec-title">产品明细 · 履约数量</h3>
<table class="mini" id="tbl-detail">
<thead>
<tr>
<th>产品</th><th>包装</th><th>单价</th><th>合同量</th>
<th>已归档</th><th>未归档已安排</th><th>剩余</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</article>
</section>
</div>
<!-- pick contract line -->
<div class="mask" id="mask-pick">
<div class="modal">
<div class="modal-h" id="pick-title">选择合同产品行</div>
<div class="modal-b">
<p class="muted" style="margin:0 0 10px;font-size:13px">仅列出本段已选合同中的明细。同品同包装会因单价不同出现多行,请点目标那一行。</p>
<table class="mini" id="tbl-pick">
<thead><tr><th>合同</th><th>产品 / 包装</th><th>单价</th><th>剩余</th><th></th></tr></thead>
<tbody></tbody>
</table>
</div>
<div class="modal-f">
<button class="btn" type="button" data-close="mask-pick">取消</button>
</div>
</div>
</div>
<!-- overage confirm -->
<div class="mask" id="mask-over">
<div class="modal" style="width:min(560px,100%)">
<div class="modal-h" style="color:#991b1b">实际量超过合同行剩余</div>
<div class="modal-b">
<div class="callout danger" style="margin-bottom:12px">
不会记到其它同品同包装行。确认后超交部分记在下列指定行,剩余将为负(超交)。
</div>
<table class="mini" id="tbl-over">
<thead><tr><th>合同行</th><th>剩余</th><th>实际量</th><th>超出</th></tr></thead>
<tbody></tbody>
</table>
</div>
<div class="modal-f">
<button class="btn" type="button" data-close="mask-over">返回修改</button>
<button class="btn danger" type="button" id="btn-over-ok">我已确认,仍然归档</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
const PRODUCT = { id: 'p-nao', name: '次氯酸钠', pkg: '槽罐车' }
let contractLines = [
{ id: '行1', contractId: 'HT-A', contractNo: 'HT-A-2026-088', productId: PRODUCT.id, name: PRODUCT.name, pkg: PRODUCT.pkg, qty: 50, price: 800, arrangedUnarchived: 0, archived: 0, note: '低价行' },
{ id: '行2', contractId: 'HT-A', contractNo: 'HT-A-2026-088', productId: PRODUCT.id, name: PRODUCT.name, pkg: PRODUCT.pkg, qty: 40, price: 850, arrangedUnarchived: 0, archived: 0, note: '高价行' },
{ id: '行3', contractId: 'HT-B', contractNo: 'HT-B-2026-012', productId: PRODUCT.id, name: PRODUCT.name, pkg: PRODUCT.pkg, qty: 80, price: 820, arrangedUnarchived: 0, archived: 0, note: '另一合同' },
{ id: '行4', contractId: 'HT-A', contractNo: 'HT-A-2026-088', productId: 'p-hcl', name: '工业盐酸', pkg: '槽罐车', qty: 100, price: 180, arrangedUnarchived: 0, archived: 0, note: '其它产品' }
]
const loadContracts = ['HT-A', 'HT-B']
const deliveryContracts = ['HT-A', 'HT-B']
let loadRows = []
let deliveryRows = []
let pickSide = 'load'
let arranged = false
let archivedOk = false
function remaining(line) {
return Math.round((line.qty - line.archived - line.arrangedUnarchived) * 1000) / 1000
}
function toast(msg) {
const el = document.getElementById('toast')
el.textContent = msg
el.classList.add('show')
clearTimeout(toast._t)
toast._t = setTimeout(() => el.classList.remove('show'), 2800)
}
function openMask(id) { document.getElementById(id).classList.add('show') }
function closeMask(id) { document.getElementById(id).classList.remove('show') }
function lineById(id) { return contractLines.find((l) => l.id === id) }
function renderContractForm() {
const tb = document.querySelector('#tbl-contract tbody')
tb.innerHTML = ''
contractLines.filter((l) => l.contractId === 'HT-A').forEach((l, i) => {
const dup = l.productId === PRODUCT.id
const tr = document.createElement('tr')
if (dup) tr.className = 'dup'
tr.innerHTML = `
<td>${i + 1}</td>
<td>${l.name}</td>
<td>${l.pkg}</td>
<td class="mono">${l.qty}</td>
<td class="mono ${dup ? 'price' : ''}">¥ ${l.price}</td>
<td>${dup ? '<span class="tag warn">同品同包装</span> ' : ''}<span class="muted">${l.note}</span></td>
<td>${dup ? '' : ''}</td>`
tb.appendChild(tr)
})
}
function renderChips() {
const html = (ids) => ids.map((id) => {
const no = id === 'HT-A' ? 'HT-A-2026-088' : 'HT-B-2026-012'
return `<span class="chip">${no}<span class="muted" style="font-weight:500">${id === 'HT-A' ? '安徽某某水务' : '安徽某某水务(框架)'}</span></span>`
}).join('')
document.getElementById('load-chips').innerHTML = html(loadContracts)
document.getElementById('delivery-chips').innerHTML = html(deliveryContracts)
}
function renderStopTable(sel, rows) {
const tb = document.querySelector(sel)
tb.innerHTML = ''
if (!rows.length) {
tb.innerHTML = `<tr><td colspan="6" class="muted">尚未添加。点「选合同行」添加目标量。</td></tr>`
return
}
rows.forEach((r, idx) => {
const line = lineById(r.cpId)
const tr = document.createElement('tr')
tr.className = 'dup'
const rem = remaining(line)
const over = r.planQty > rem + 1e-9
tr.innerHTML = `
<td class="mono">${line.contractNo}<div class="muted" style="font-size:11px">${line.id} · ${line.note}</div></td>
<td>${line.name} / ${line.pkg}</td>
<td class="mono price">¥ ${line.price}</td>
<td class="mono">${rem}</td>
<td><input type="number" min="0" step="0.001" value="${r.planQty}" data-side="${sel.includes('load') ? 'load' : 'delivery'}" data-idx="${idx}" /></td>
<td><button class="btn link" type="button" data-del="${sel.includes('load') ? 'load' : 'delivery'}" data-idx="${idx}">删除</button></td>`
if (over) tr.classList.add('over')
tb.appendChild(tr)
})
tb.querySelectorAll('input').forEach((inp) => {
inp.addEventListener('input', () => {
const side = inp.dataset.side
const idx = Number(inp.dataset.idx)
const v = Number(inp.value) || 0
if (side === 'load') loadRows[idx].planQty = v
else deliveryRows[idx].planQty = v
const line = lineById((side === 'load' ? loadRows : deliveryRows)[idx].cpId)
inp.closest('tr').classList.toggle('over', v > remaining(line) + 1e-9)
updatePlanTotal()
})
})
tb.querySelectorAll('[data-del]').forEach((btn) => {
btn.addEventListener('click', () => {
const idx = Number(btn.dataset.idx)
if (btn.dataset.del === 'load') loadRows.splice(idx, 1)
else deliveryRows.splice(idx, 1)
renderPlan()
})
})
}
function updatePlanTotal() {
const sum = deliveryRows.reduce((s, r) => s + (Number(r.planQty) || 0), 0)
document.getElementById('plan-total').textContent = (Math.round(sum * 1000) / 1000) + ' 吨'
document.getElementById('driver-qty').textContent = (Math.round(sum * 1000) / 1000) + ' 吨'
}
function renderPlan() {
renderChips()
renderStopTable('#tbl-load tbody', loadRows)
renderStopTable('#tbl-delivery tbody', deliveryRows)
updatePlanTotal()
}
function openPick(side) {
pickSide = side
document.getElementById('pick-title').textContent = side === 'load' ? '装货 · 选择合同产品行' : '送货 · 选择合同产品行'
const ids = side === 'load' ? loadContracts : deliveryContracts
const used = new Set((side === 'load' ? loadRows : deliveryRows).map((r) => r.cpId))
const tb = document.querySelector('#tbl-pick tbody')
tb.innerHTML = ''
contractLines.filter((l) => ids.includes(l.contractId)).forEach((l) => {
const tr = document.createElement('tr')
if (l.productId === PRODUCT.id) tr.className = 'dup'
const disabled = used.has(l.id)
tr.innerHTML = `
<td>${l.contractNo}</td>
<td>${l.name} / ${l.pkg}</td>
<td class="mono price">¥ ${l.price}</td>
<td class="mono">${remaining(l)}</td>
<td>${disabled ? '<span class="muted">已添加</span>' : `<button class="btn ghost" type="button" data-pick="${l.id}">选用此行</button>`}</td>`
tb.appendChild(tr)
})
tb.querySelectorAll('[data-pick]').forEach((btn) => {
btn.addEventListener('click', () => {
const line = lineById(btn.dataset.pick)
const row = { cpId: line.id, planQty: 10, actualQty: null }
if (pickSide === 'load') loadRows.push(row)
else deliveryRows.push(row)
closeMask('mask-pick')
renderPlan()
toast('已绑定合同行 ' + line.id + ',请改目标量')
})
})
openMask('mask-pick')
}
function seedDemoRows() {
if (loadRows.length) return
loadRows = [
{ cpId: '行1', planQty: 10, actualQty: null },
{ cpId: '行2', planQty: 10, actualQty: null },
{ cpId: '行3', planQty: 10, actualQty: null }
]
deliveryRows = loadRows.map((r) => ({ ...r }))
}
function renderArrangeCheck() {
const tb = document.querySelector('#tbl-arrange-check tbody')
tb.innerHTML = ''
if (!deliveryRows.length) {
tb.innerHTML = `<tr><td colspan="4" class="muted">请先在步骤 2 添加合同行。</td></tr>`
return
}
deliveryRows.forEach((r) => {
const line = lineById(r.cpId)
const rem = remaining(line)
const ok = r.planQty <= rem + 1e-9
const tr = document.createElement('tr')
if (!ok) tr.className = 'over'
tr.innerHTML = `
<td>${line.contractNo} · ${line.id} · ¥${line.price}</td>
<td class="mono">${r.planQty}</td>
<td class="mono">${rem}</td>
<td>${ok ? '<span class="tag">通过</span>' : '<span class="tag danger">超过剩余,禁止安排</span>'}</td>`
tb.appendChild(tr)
})
}
function arrangeOk() {
return deliveryRows.every((r) => r.planQty <= remaining(lineById(r.cpId)) + 1e-9)
}
function occupyUnarchived() {
contractLines.forEach((l) => { l.arrangedUnarchived = 0 })
deliveryRows.forEach((r) => {
const line = lineById(r.cpId)
line.arrangedUnarchived += r.planQty
})
}
function renderArchive() {
const tb = document.querySelector('#tbl-archive tbody')
tb.innerHTML = ''
if (!deliveryRows.length) {
tb.innerHTML = `<tr><td colspan="6" class="muted">请先完成步骤 2 的拆行。</td></tr>`
return
}
deliveryRows.forEach((r, idx) => {
const line = lineById(r.cpId)
if (r.actualQty == null) r.actualQty = r.planQty
const rem = remaining(line) + (arranged ? r.planQty : 0)
const over = r.actualQty > rem + 1e-9
const tr = document.createElement('tr')
if (over) tr.className = 'over'
tr.innerHTML = `
<td class="mono">${line.contractNo}<div class="muted" style="font-size:11px">${line.id} · ${line.note}</div></td>
<td>${line.name} / ${line.pkg}</td>
<td class="mono price">¥ ${line.price}</td>
<td class="mono">${r.planQty}</td>
<td class="mono">${Math.round(rem * 1000) / 1000}</td>
<td><input type="number" min="0" step="0.001" value="${r.actualQty}" data-idx="${idx}" /></td>`
tb.appendChild(tr)
})
tb.querySelectorAll('input').forEach((inp) => {
inp.addEventListener('input', () => {
const idx = Number(inp.dataset.idx)
deliveryRows[idx].actualQty = Number(inp.value) || 0
const line = lineById(deliveryRows[idx].cpId)
const rem = remaining(line) + (arranged ? deliveryRows[idx].planQty : 0)
inp.closest('tr').classList.toggle('over', deliveryRows[idx].actualQty > rem + 1e-9)
updateArchiveSum()
})
})
updateArchiveSum()
}
function updateArchiveSum() {
const sum = deliveryRows.reduce((s, r) => s + (Number(r.actualQty ?? r.planQty) || 0), 0)
const weigh = Number(document.getElementById('weigh-total').value) || 0
const aligned = Math.abs(sum - weigh) < 0.0005
document.getElementById('actual-sum').textContent = (Math.round(sum * 1000) / 1000) + ' 吨'
const res = document.getElementById('align-result')
res.innerHTML = aligned ? '<span class="tag">已对齐</span>' : '<span class="tag danger">与地磅不一致</span>'
const hint = document.getElementById('archive-hint')
const overs = overageRows()
hint.className = 'sum-bar' + (aligned && !overs.length ? ' ok' : ' bad')
hint.textContent = aligned
? (overs.length ? `地磅已对齐,但有 ${overs.length} 行超过合同剩余。` : '地磅已对齐,各行未超剩余。')
: `各行实际合计 ${sum} ≠ 地磅 ${weigh},不能归档。`
}
function overageRows() {
return deliveryRows.filter((r) => {
const line = lineById(r.cpId)
const rem = remaining(line) + (arranged ? r.planQty : 0)
return (r.actualQty ?? 0) > rem + 1e-9
}).map((r) => {
const line = lineById(r.cpId)
const rem = remaining(line) + (arranged ? r.planQty : 0)
const actual = r.actualQty ?? 0
return { line, rem, actual, over: Math.round((actual - rem) * 1000) / 1000 }
})
}
function tryArchive() {
const weigh = Number(document.getElementById('weigh-total').value) || 0
const sum = deliveryRows.reduce((s, r) => s + (Number(r.actualQty ?? r.planQty) || 0), 0)
if (Math.abs(sum - weigh) >= 0.0005) {
toast('各行实际合计必须等于地磅实发')
return
}
const overs = overageRows()
if (overs.length) {
const tb = document.querySelector('#tbl-over tbody')
tb.innerHTML = overs.map((o) => `
<tr class="over">
<td>${o.line.contractNo} · ${o.line.id} · ¥${o.line.price}</td>
<td class="mono">${o.rem}</td>
<td class="mono">${o.actual}</td>
<td class="mono remain neg">+${o.over}</td>
</tr>`).join('')
openMask('mask-over')
return
}
commitArchive()
}
function commitArchive() {
closeMask('mask-over')
contractLines.forEach((l) => { l.arrangedUnarchived = 0 })
deliveryRows.forEach((r) => {
const line = lineById(r.cpId)
line.archived += Number(r.actualQty) || 0
})
archivedOk = true
arranged = false
renderDetail()
toast('已归档:实际量记在指定合同行')
gotoStep(6)
}
function renderDetail() {
const tb = document.querySelector('#tbl-detail tbody')
tb.innerHTML = ''
contractLines.filter((l) => l.contractId === 'HT-A').forEach((l) => {
const rem = remaining(l)
const tr = document.createElement('tr')
if (l.productId === PRODUCT.id) tr.className = rem < 0 ? 'over' : 'dup'
tr.innerHTML = `
<td>${l.name}</td>
<td>${l.pkg}</td>
<td class="mono price">¥ ${l.price}</td>
<td class="mono">${l.qty}</td>
<td class="mono">${Math.round(l.archived * 1000) / 1000}</td>
<td class="mono">${Math.round(l.arrangedUnarchived * 1000) / 1000}</td>
<td class="mono ${rem < 0 ? 'remain neg' : ''}">${rem < 0 ? '超交 ' + Math.abs(rem) : rem}</td>`
tb.appendChild(tr)
})
}
function gotoStep(n) {
document.querySelectorAll('.step-btn').forEach((b) => b.classList.toggle('active', b.dataset.step === String(n)))
document.querySelectorAll('.panel').forEach((p) => p.classList.toggle('active', p.id === 'panel-' + n))
if (n === '2' || n === 2) {
seedDemoRows()
renderPlan()
}
if (n === '3' || n === 3) {
seedDemoRows()
renderPlan()
renderArrangeCheck()
}
if (n === '4' || n === 4) {
seedDemoRows()
if (!arranged) {
/* 归档前视为已安排,占用按目标量 */
occupyUnarchived()
arranged = true
}
renderArchive()
}
if (n === '6' || n === 6) renderDetail()
window.scrollTo({ top: 0, behavior: 'smooth' })
}
document.querySelectorAll('.step-btn, [data-step]').forEach((b) => {
if (b._stepBound) return
b._stepBound = true
b.addEventListener('click', () => gotoStep(b.dataset.step))
})
document.querySelectorAll('[data-close]').forEach((b) => b.addEventListener('click', () => closeMask(b.dataset.close)))
document.getElementById('btn-add-line').addEventListener('click', () => {
contractLines.push({
id: '行' + (contractLines.length + 1),
contractId: 'HT-A',
contractNo: 'HT-A-2026-088',
productId: PRODUCT.id,
name: PRODUCT.name,
pkg: PRODUCT.pkg,
qty: 15,
price: 860,
arrangedUnarchived: 0,
archived: 0,
note: '演示再加一行同品同包装'
})
renderContractForm()
toast('已追加同品同包装行(现网会被拦住)')
})
document.getElementById('btn-save-contract').addEventListener('click', () => {
toast('合同已保存:同品同包装多行有效')
gotoStep(2)
})
document.getElementById('btn-add-load-row').addEventListener('click', () => openPick('load'))
document.getElementById('btn-add-delivery-row').addEventListener('click', () => openPick('delivery'))
document.getElementById('btn-sync').addEventListener('click', () => {
deliveryRows = loadRows.map((r) => ({ ...r, actualQty: null }))
renderPlan()
toast('已从装货同步目标量到送货')
})
document.getElementById('btn-save-draft').addEventListener('click', () => {
toast('草稿已保存(允许目标量暂时超过剩余)')
})
document.getElementById('btn-go-arrange').addEventListener('click', () => gotoStep(3))
document.getElementById('btn-confirm-arrange').addEventListener('click', () => {
if (!deliveryRows.length) { toast('请先添加送货合同行'); return }
if (!arrangeOk()) { toast('存在超过剩余的合同行,不能安排'); renderArrangeCheck(); return }
occupyUnarchived()
arranged = true
toast('安排成功:各行按目标量占用额度')
gotoStep(4)
})
document.getElementById('weigh-total').addEventListener('input', updateArchiveSum)
document.getElementById('btn-demo-over').addEventListener('click', () => {
const row = deliveryRows.find((r) => r.cpId === '行1')
if (row) row.actualQty = 55
document.getElementById('weigh-total').value = '75'
renderArchive()
toast('行1 已标红。点「确认归档」会弹出二次确认')
})
document.getElementById('btn-archive').addEventListener('click', tryArchive)
document.getElementById('btn-over-ok').addEventListener('click', commitArchive)
document.getElementById('mask-pick').addEventListener('click', (e) => { if (e.target.id === 'mask-pick') closeMask('mask-pick') })
document.getElementById('mask-over').addEventListener('click', (e) => { if (e.target.id === 'mask-over') closeMask('mask-over') })
renderContractForm()
renderDetail()
</script>
</body>
</html>