| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011 |
- <!doctype html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8" />
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
- <title>合同多单价行 × 运输计划多合同拆行 × 归档 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;
- --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;
- --warn-border: #fde68a;
- --danger: #dc2626;
- --danger-bg: #fef2f2;
- --info: #0284c7;
- --info-bg: #f0f9ff;
- --info-border: #bae6fd;
- --violet: #7c3aed;
- --violet-bg: #f5f3ff;
- }
- * { 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: 1280px; margin: 0 auto; padding: 24px 20px 96px; }
- .hero {
- background: linear-gradient(135deg, #ecfdf5 0%, #fff 48%, #f0f9ff 100%);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 20px 22px;
- margin-bottom: 16px;
- box-shadow: var(--shadow);
- }
- .hero h1 { margin: 0 0 8px; font-size: 22px; font-weight: 700; letter-spacing: -0.02em; }
- .hero h1 em { font-style: normal; color: var(--primary-dark); }
- .hero p { margin: 0; color: var(--text-muted); max-width: 980px; }
- .pills { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
- .pill {
- display: inline-flex; align-items: center; gap: 6px;
- 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: var(--warn-border); }
- .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; }
- .pill.violet { background: var(--violet-bg); color: var(--violet); border-color: #ddd6fe; }
- .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;
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- box-shadow: var(--shadow);
- overflow: hidden;
- }
- .panel.active { display: block; }
- .panel-head {
- display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 12px;
- padding: 14px 18px;
- border-bottom: 1px solid var(--border);
- background: #fafafa;
- }
- .panel-head h2 {
- margin: 0;
- font-size: 16px;
- font-weight: 700;
- display: flex; align-items: center; gap: 8px;
- }
- .panel-head h2::before {
- content: "";
- width: 3px; height: 16px;
- background: var(--primary);
- border-radius: 2px;
- }
- .panel-head .hint { margin: 4px 0 0; font-size: 12px; color: var(--text-muted); max-width: 720px; }
- .panel-body { padding: 16px 18px 20px; }
- .callout {
- margin-bottom: 14px;
- padding: 10px 12px;
- border-radius: 8px;
- font-size: 13px;
- border: 1px solid var(--warn-border);
- background: var(--warn-bg);
- color: #92400e;
- }
- .callout strong { color: #78350f; }
- .callout.info {
- border-color: var(--info-border);
- background: var(--info-bg);
- color: #0369a1;
- }
- .callout.info strong { color: #0c4a6e; }
- .callout.ok {
- border-color: #a7f3d0;
- background: var(--primary-bg);
- color: #047857;
- }
- .callout.ok strong { color: #065f46; }
- .special-grid {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- gap: 10px;
- margin-bottom: 16px;
- }
- @media (max-width: 960px) { .special-grid { grid-template-columns: 1fr; } }
- .special-card {
- border: 1px dashed #fbbf24;
- background: #fffbeb;
- border-radius: 8px;
- padding: 10px 12px;
- }
- .special-card .t { font-size: 12px; font-weight: 700; color: var(--warn); margin-bottom: 4px; }
- .special-card .d { font-size: 12px; color: #78350f; }
- 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: #f8fafc;
- color: var(--text-muted);
- font-weight: 600;
- font-size: 12px;
- }
- table.mini tr:nth-child(even) td { background: #fafafa; }
- table.mini tr.hl td {
- background: #ecfdf5 !important;
- box-shadow: inset 3px 0 0 var(--primary);
- }
- table.mini tr.hl2 td {
- background: #f0f9ff !important;
- box-shadow: inset 3px 0 0 var(--info);
- }
- .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.info { background: transparent; color: #1e293b; border: 1px solid #cbd5e1; }
- .tag.violet { background: #7c3aed; }
- .mono { font-variant-numeric: tabular-nums; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
- .muted { color: var(--text-muted); }
- .price-diff { color: var(--warn); font-weight: 700; }
- .btn-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
- .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:disabled { opacity: 0.5; cursor: not-allowed; }
- .split-cols {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 14px;
- }
- @media (max-width: 960px) { .split-cols { grid-template-columns: 1fr; } }
- .box {
- border: 1px solid var(--border);
- border-radius: 8px;
- overflow: hidden;
- }
- .box-h {
- display: flex; align-items: center; justify-content: space-between; gap: 8px;
- padding: 10px 12px;
- background: #f8fafc;
- border-bottom: 1px solid var(--border);
- font-weight: 700;
- font-size: 13px;
- }
- .box-h.load { border-left: 3px solid var(--primary); }
- .box-h.delivery { border-left: 3px solid var(--info); }
- .box-b { padding: 10px 12px; }
- .chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
- .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-radius: 4px;
- border: none; background: #fff; color: var(--text-muted); cursor: pointer; font-size: 12px; line-height: 1;
- }
- .chip.info { background: var(--info-bg); border-color: var(--info-border); color: #0369a1; }
- /* Modal */
- .mask {
- display: none;
- position: fixed; inset: 0;
- background: rgba(15, 23, 42, 0.45);
- z-index: 50;
- align-items: center; justify-content: center;
- padding: 20px;
- backdrop-filter: none;
- }
- .mask.show { display: flex; }
- .dialog {
- width: min(720px, 100%);
- background: #fff;
- border-radius: var(--radius);
- box-shadow: var(--shadow-lg);
- overflow: hidden;
- max-height: calc(100vh - 40px);
- display: flex; flex-direction: column;
- }
- .dialog-h {
- padding: 14px 18px;
- border-bottom: 1px solid var(--border);
- display: flex; align-items: center; justify-content: space-between;
- }
- .dialog-h h3 { margin: 0; font-size: 16px; }
- .dialog-b { padding: 16px 18px; overflow: auto; }
- .dialog-f {
- padding: 12px 18px;
- border-top: 1px solid var(--border);
- display: flex; justify-content: flex-end; gap: 8px;
- background: #fafafa;
- }
- .field-grid {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 10px;
- margin-bottom: 12px;
- }
- .field label { display: block; font-size: 11px; color: var(--text-muted); margin-bottom: 4px; font-weight: 600; }
- .field .val {
- height: 36px; border: 1px solid var(--border); border-radius: 8px;
- padding: 0 10px; display: flex; align-items: center; background: #f8fafc;
- }
- .field input[type="number"] {
- width: 100%; height: 36px; border: 1px solid var(--border); border-radius: 8px;
- padding: 0 10px; font: inherit;
- }
- .alloc-sum {
- margin-top: 8px; font-size: 12px; color: var(--text-muted);
- }
- .alloc-sum.bad { color: var(--danger); font-weight: 600; }
- .alloc-sum.ok { color: var(--primary-dark); font-weight: 600; }
- .toast {
- position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(20px);
- background: #0f172a; color: #fff; padding: 10px 16px; border-radius: 8px;
- font-size: 13px; opacity: 0; pointer-events: none; transition: .25s; z-index: 60;
- max-width: 90vw;
- }
- .toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
- .legend {
- display: flex; flex-wrap: wrap; gap: 12px;
- margin-top: 10px; font-size: 12px; color: var(--text-muted);
- }
- .legend i {
- display: inline-block; width: 12px; height: 12px; border-radius: 2px;
- vertical-align: -2px; margin-right: 4px;
- }
- .flow {
- display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
- margin: 12px 0 4px; font-size: 12px; color: var(--text-muted);
- }
- .flow .node {
- padding: 4px 10px; border-radius: 6px; background: #f1f5f9; border: 1px solid #e2e8f0;
- font-weight: 600; color: var(--text);
- }
- .flow .arrow { color: #94a3b8; }
- </style>
- </head>
- <body>
- <div class="page">
- <header class="hero">
- <h1>合同 / 运输计划 · <em>多合同 · 同品同包装多单价</em> · 拆行 Demo</h1>
- <p>
- 按已确认方案:合同允许同产品同包装多行(不同单价);段次可多选合同;选品先选产品/包装,多命中则弹拆分量;
- 装/送独立维护,提供「一键从装货同步到送货」;归档按行录实际量(安排阶段已拆好,不做事后划分)。
- </p>
- <div class="pills">
- <span class="pill warn">特殊① 同品同包装多单价行</span>
- <span class="pill warn">特殊② 段次多合同</span>
- <span class="pill warn">特殊③ 选品命中多明细 → 拆行</span>
- <span class="pill info">特殊④ 装/送独立 + 一键同步</span>
- <span class="pill violet">特殊⑤ 归档各行各自实际量</span>
- <span class="pill muted">方案 1 · contract_product_id 绑定</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>
- </nav>
- <!-- ========== Step 1: Contract ========== -->
- <section class="panel active" id="panel-1">
- <div class="panel-head">
- <div>
- <h2>合同管理 · 产品明细</h2>
- <p class="hint">特殊情况:同一合同内,同一产品 + 同一包装规格可以有多行,单价不同(如批次/议价差异)。</p>
- </div>
- <button class="btn ghost" type="button" id="btn-add-dup">+ 再加一行同品同包装(演示)</button>
- </div>
- <div class="panel-body">
- <div class="callout">
- <strong>对比现状:</strong>现网前后端会拦截「同一产品的包装规格不能重复」。本 Demo 展示放开后的效果。
- </div>
- <div class="special-grid">
- <div class="special-card">
- <div class="t">特殊①</div>
- <div class="d">合同 HT-A:烧碱 32% / 25kg袋,<b>两行</b>,单价 2100 与 2350。</div>
- </div>
- <div class="special-card">
- <div class="t">特殊①(另一合同)</div>
- <div class="d">合同 HT-B:同品同包装再有一行,单价 2200(供多合同段次演示)。</div>
- </div>
- <div class="special-card">
- <div class="t">计量键</div>
- <div class="d">后续安排/归档按 <b>合同明细行 id</b>(cp-xxx)记账,不再按「产品+包装」糊成一池。</div>
- </div>
- </div>
- <h3 style="margin:0 0 8px;font-size:13px;color:var(--text-muted);">合同 HT-A · 安徽某某贸易(示意)</h3>
- <table class="mini" id="tbl-contract-a">
- <thead>
- <tr>
- <th>明细行 id</th>
- <th>产品</th>
- <th>包装规格</th>
- <th>合同数量</th>
- <th>单价</th>
- <th>已安排</th>
- <th>剩余</th>
- <th>说明</th>
- </tr>
- </thead>
- <tbody></tbody>
- </table>
- <h3 style="margin:18px 0 8px;font-size:13px;color:var(--text-muted);">合同 HT-B · 合肥某某化工(示意)</h3>
- <table class="mini" id="tbl-contract-b">
- <thead>
- <tr>
- <th>明细行 id</th>
- <th>产品</th>
- <th>包装规格</th>
- <th>合同数量</th>
- <th>单价</th>
- <th>已安排</th>
- <th>剩余</th>
- <th>说明</th>
- </tr>
- </thead>
- <tbody></tbody>
- </table>
- <div class="legend">
- <span><i style="background:#ecfdf5;border:1px solid #a7f3d0"></i>同品同包装 · 多单价行</span>
- <span><i style="background:#f0f9ff;border:1px solid #bae6fd"></i>另一合同同品同包装</span>
- </div>
- <div class="btn-row">
- <button class="btn primary" type="button" data-goto="2">下一步:运输计划选品拆行 →</button>
- </div>
- </div>
- </section>
- <!-- ========== Step 2: Plan ========== -->
- <section class="panel" id="panel-2">
- <div class="panel-head">
- <div>
- <h2>运输计划 · 段次多合同 + 选品拆行</h2>
- <p class="hint">特殊②③④:段次可选多份合同;先选产品/包装,命中多条明细则弹拆分量;装/送独立,可一键同步。</p>
- </div>
- </div>
- <div class="panel-body">
- <div class="flow">
- <span class="node">选多份合同</span><span class="arrow">→</span>
- <span class="node">选产品 / 包装</span><span class="arrow">→</span>
- <span class="node">命中 ≥2 条明细</span><span class="arrow">→</span>
- <span class="node">拆分量弹窗</span><span class="arrow">→</span>
- <span class="node">生成多行(绑 contract_product_id)</span>
- </div>
- <div class="split-cols" style="margin-top:14px">
- <!-- Load -->
- <div class="box">
- <div class="box-h load">
- <span>装货段</span>
- <span class="muted" style="font-weight:500;font-size:12px">非内置客户 · 须选合同</span>
- </div>
- <div class="box-b">
- <div class="callout info" style="margin-bottom:10px">
- <strong>特殊②:</strong>本段已选 <b>2 份</b>合同(现状上限为 1)。
- </div>
- <div class="chips" id="load-contracts"></div>
- <div class="btn-row" style="margin-top:0;margin-bottom:10px">
- <button class="btn ghost" type="button" id="btn-pick-product-load">+ 添加产品(先选品)</button>
- </div>
- <table class="mini" id="tbl-load">
- <thead>
- <tr>
- <th>绑定明细</th>
- <th>合同</th>
- <th>产品 / 包装</th>
- <th>单价</th>
- <th>计划量</th>
- </tr>
- </thead>
- <tbody></tbody>
- </table>
- <p class="muted" style="margin:8px 0 0;font-size:12px" id="load-empty-hint">尚未添加产品。点「添加产品」演示:选「烧碱 32% / 25kg袋」将命中 3 条明细。</p>
- </div>
- </div>
- <!-- Delivery -->
- <div class="box">
- <div class="box-h delivery">
- <span>送货段</span>
- <span class="muted" style="font-weight:500;font-size:12px">独立选品 · 不强制对齐装货</span>
- </div>
- <div class="box-b">
- <div class="callout info" style="margin-bottom:10px">
- <strong>特殊④:</strong>默认不同步;可用下方按钮从装货覆盖同步。
- </div>
- <div class="chips" id="delivery-contracts"></div>
- <div class="btn-row" style="margin-top:0;margin-bottom:10px">
- <button class="btn ghost" type="button" id="btn-pick-product-delivery">+ 添加产品(先选品)</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>
- </tr>
- </thead>
- <tbody></tbody>
- </table>
- <p class="muted" style="margin:8px 0 0;font-size:12px" id="delivery-empty-hint">送货可另选合同明细;本 Demo 默认可与装货选同一批合同。</p>
- </div>
- </div>
- </div>
- <div class="btn-row">
- <button class="btn" type="button" data-goto="1">← 合同明细</button>
- <button class="btn primary" type="button" data-goto="3" id="btn-to-archive">下一步:归档录实际量 →</button>
- </div>
- </div>
- </section>
- <!-- ========== Step 3: Archive ========== -->
- <section class="panel" id="panel-3">
- <div class="panel-head">
- <div>
- <h2>运输计划归档 · 按行录实际量</h2>
- <p class="hint">特殊⑤:不做「事后按比例划分」。安排阶段已拆成多行,归档时每行各自填写实际量;合同已归档量按 contract_product_id 累加。</p>
- </div>
- </div>
- <div class="panel-body">
- <div class="callout ok">
- <strong>与需求 3 的对应:</strong>多合同同品同包装、或同合同多单价行 —— 在计划层已是多行;归档只录本行实际量,即完成「划分」。
- </div>
- <h3 style="margin:0 0 8px;font-size:13px;color:var(--text-muted);">装货段 · 入库实际量</h3>
- <table class="mini" id="tbl-archive-load">
- <thead>
- <tr>
- <th>绑定明细</th>
- <th>合同</th>
- <th>产品 / 包装</th>
- <th>单价</th>
- <th>计划量</th>
- <th>实际入库量</th>
- </tr>
- </thead>
- <tbody></tbody>
- </table>
- <h3 style="margin:18px 0 8px;font-size:13px;color:var(--text-muted);">送货段 · 出库实际量</h3>
- <table class="mini" id="tbl-archive-delivery">
- <thead>
- <tr>
- <th>绑定明细</th>
- <th>合同</th>
- <th>产品 / 包装</th>
- <th>单价</th>
- <th>计划量</th>
- <th>实际出库量</th>
- </tr>
- </thead>
- <tbody></tbody>
- </table>
- <div class="btn-row">
- <button class="btn primary" type="button" id="btn-confirm-archive">确认归档(演示回写合同已安排)</button>
- <button class="btn" type="button" data-goto="2">← 返回计划</button>
- <button class="btn ghost" type="button" data-goto="4">特殊情况对照 →</button>
- </div>
- <div id="archive-result" style="display:none;margin-top:14px">
- <div class="callout ok">
- <strong>归档后合同明细已安排量(读时按行聚合):</strong>
- <span id="archive-result-text"></span>
- </div>
- </div>
- </div>
- </section>
- <!-- ========== Step 4: Matrix ========== -->
- <section class="panel" id="panel-4">
- <div class="panel-head">
- <div>
- <h2>特殊情况对照表</h2>
- <p class="hint">把客户三点需求与 Demo 操作、数据键对齐,便于评审。</p>
- </div>
- </div>
- <div class="panel-body">
- <table class="mini">
- <thead>
- <tr>
- <th style="width:18%">特殊情况</th>
- <th style="width:28%">客户诉求</th>
- <th style="width:28%">Demo 怎么体现</th>
- <th>实现要点(方案 1)</th>
- </tr>
- </thead>
- <tbody>
- <tr class="hl">
- <td><span class="tag warn">①</span> 合同同品同包装多行</td>
- <td>同产品同包装可多条,因单价可能不同</td>
- <td>步骤 1:HT-A 两行烧碱/25kg,单价 2100 / 2350</td>
- <td>去掉包装唯一校验;行主键即计量键</td>
- </tr>
- <tr class="hl2">
- <td><span class="tag warn">②</span> 段次多合同</td>
- <td>一次运输可送两个合同的同品同包装货</td>
- <td>步骤 2:装货 chips 显示 HT-A + HT-B</td>
- <td>取消 assert_at_most_one_contract</td>
- </tr>
- <tr class="hl">
- <td><span class="tag warn">③</span> 选品拆行</td>
- <td>安排阶段拆好,归档不再事后划分</td>
- <td>步骤 2:选烧碱/25kg → 弹窗对 3 条明细填计划量 → 生成 3 行</td>
- <td>段产品行绑定 contract_product_id</td>
- </tr>
- <tr>
- <td><span class="tag info">④</span> 装/送独立</td>
- <td>送货不强制与装货一一对应</td>
- <td>步骤 2:送货可单独加品;「从装货同步」覆盖</td>
- <td>取消默认自动同步;保留手动按钮</td>
- </tr>
- <tr>
- <td><span class="tag violet">⑤</span> 归档按行实际量</td>
- <td>多合同 / 同合同多单价行的已执行按实际划分</td>
- <td>步骤 3:每行各自填入库/出库实际量;确认后回写各 cp 行</td>
- <td>聚合键改为 contract_product_id;无事后分摊算法</td>
- </tr>
- </tbody>
- </table>
- <div class="btn-row">
- <button class="btn primary" type="button" data-goto="1">从头再看一遍</button>
- <button class="btn" type="button" data-goto="2">再试拆行</button>
- </div>
- </div>
- </section>
- </div>
- <!-- Product pick (simplified) -->
- <div class="mask" id="mask-pick">
- <div class="dialog" style="width:min(480px,100%)">
- <div class="dialog-h">
- <h3 id="pick-title">选择产品 / 包装</h3>
- <button class="btn" type="button" data-close="mask-pick">关闭</button>
- </div>
- <div class="dialog-b">
- <div class="callout info">
- <strong>交互 B:</strong>仍按产品选。本 Demo 只有一个演示品,点击后若命中多条合同明细则进入拆分量。
- </div>
- <button class="btn primary" type="button" id="btn-choose-caustic" style="width:100%;justify-content:center;height:44px">
- 烧碱 32% · 包装 25kg袋
- </button>
- <p class="muted" style="margin:10px 0 0;font-size:12px">将在当前段已选合同中匹配「产品+包装 ∈ 明细」的所有行(含同合同多单价 + 跨合同)。</p>
- </div>
- </div>
- </div>
- <!-- Split alloc dialog -->
- <div class="mask" id="mask-split">
- <div class="dialog">
- <div class="dialog-h">
- <h3>拆分量 · 烧碱 32% / 25kg袋</h3>
- <button class="btn" type="button" data-close="mask-split">关闭</button>
- </div>
- <div class="dialog-b">
- <div class="callout">
- <strong>特殊③命中:</strong>当前段所选合同中,同产品同包装共 <b id="split-hit-count">3</b> 条明细(同合同多单价 + 跨合同)。请为需要安排的行填写计划量;填 0 或不填则不生成该行。
- </div>
- <div class="field-grid">
- <div class="field">
- <label>产品</label>
- <div class="val">烧碱 32%</div>
- </div>
- <div class="field">
- <label>包装规格</label>
- <div class="val">25kg袋</div>
- </div>
- </div>
- <table class="mini" id="tbl-split">
- <thead>
- <tr>
- <th>明细行</th>
- <th>合同</th>
- <th>单价</th>
- <th>剩余可安排</th>
- <th>本次计划量</th>
- </tr>
- </thead>
- <tbody></tbody>
- </table>
- <div class="alloc-sum" id="split-sum">合计计划量:0</div>
- </div>
- <div class="dialog-f">
- <button class="btn" type="button" data-close="mask-split">取消</button>
- <button class="btn primary" type="button" id="btn-confirm-split">生成多行</button>
- </div>
- </div>
- </div>
- <div class="toast" id="toast"></div>
- <script>
- /** Demo state */
- const PRODUCT = { id: 'p-caustic', name: '烧碱 32%', pkg: '25kg袋' }
- const contractsMeta = {
- 'HT-A': { name: '安徽某某贸易' },
- 'HT-B': { name: '合肥某某化工' }
- }
- /** Contract product lines — special case ① */
- let lines = [
- { id: 'cp-a1', contractId: 'HT-A', productId: PRODUCT.id, name: PRODUCT.name, pkg: PRODUCT.pkg, qty: 100, price: 2100, arranged: 0, note: '同品同包装 · 低价行' },
- { id: 'cp-a2', contractId: 'HT-A', productId: PRODUCT.id, name: PRODUCT.name, pkg: PRODUCT.pkg, qty: 40, price: 2350, arranged: 0, note: '同品同包装 · 高价行' },
- { id: 'cp-a3', contractId: 'HT-A', productId: 'p-hcl', name: '盐酸 31%', pkg: '槽罐车', qty: 200, price: 180, arranged: 0, note: '其他产品(对照)' },
- { id: 'cp-b1', contractId: 'HT-B', productId: PRODUCT.id, name: PRODUCT.name, pkg: PRODUCT.pkg, qty: 80, price: 2200, arranged: 0, note: '另一合同 · 同品同包装' }
- ]
- const loadContracts = ['HT-A', 'HT-B']
- const deliveryContracts = ['HT-A', 'HT-B']
- let loadRows = []
- let deliveryRows = []
- let pickSide = 'load' // load | delivery
- 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'), 2600)
- }
- function remaining(line) {
- return Math.round((line.qty - line.arranged) * 1000) / 1000
- }
- function renderContractTables() {
- const render = (cid, tbody) => {
- tbody.innerHTML = ''
- lines.filter(l => l.contractId === cid).forEach(l => {
- const tr = document.createElement('tr')
- const sameKey = l.productId === PRODUCT.id && l.pkg === PRODUCT.pkg
- if (sameKey && cid === 'HT-A') tr.className = 'hl'
- if (sameKey && cid === 'HT-B') tr.className = 'hl2'
- tr.innerHTML = `
- <td class="mono">${l.id}</td>
- <td>${l.name}</td>
- <td>${l.pkg}</td>
- <td class="mono">${l.qty}</td>
- <td class="mono ${sameKey ? 'price-diff' : ''}">¥ ${l.price.toLocaleString()}</td>
- <td class="mono">${l.arranged}</td>
- <td class="mono">${remaining(l)}</td>
- <td>${sameKey ? '<span class="tag warn">同品同包装</span> ' : ''}<span class="muted">${l.note}</span></td>
- `
- tbody.appendChild(tr)
- })
- }
- render('HT-A', document.querySelector('#tbl-contract-a tbody'))
- render('HT-B', document.querySelector('#tbl-contract-b tbody'))
- }
- function renderChips(elId, ids, chipClass) {
- const el = document.getElementById(elId)
- el.innerHTML = ids.map(id =>
- `<span class="chip ${chipClass || ''}">${id}<span class="muted" style="font-weight:500">· ${contractsMeta[id].name}</span></span>`
- ).join('')
- }
- function renderSideTable(tbodySel, rows, emptyHintId) {
- const tbody = document.querySelector(tbodySel)
- tbody.innerHTML = ''
- const hint = document.getElementById(emptyHintId)
- if (!rows.length) {
- if (hint) hint.style.display = 'block'
- return
- }
- if (hint) hint.style.display = 'none'
- rows.forEach(r => {
- const line = lines.find(l => l.id === r.contractProductId)
- const tr = document.createElement('tr')
- tr.className = 'hl'
- tr.innerHTML = `
- <td class="mono">${r.contractProductId}</td>
- <td>${r.contractId}</td>
- <td>${PRODUCT.name} / ${PRODUCT.pkg}</td>
- <td class="mono price-diff">¥ ${(line ? line.price : r.price).toLocaleString()}</td>
- <td class="mono">${r.planQty}</td>
- `
- tbody.appendChild(tr)
- })
- }
- function renderPlanTables() {
- renderChips('load-contracts', loadContracts)
- renderChips('delivery-contracts', deliveryContracts, 'info')
- renderSideTable('#tbl-load tbody', loadRows, 'load-empty-hint')
- renderSideTable('#tbl-delivery tbody', deliveryRows, 'delivery-empty-hint')
- }
- function matchingLines(contractIds) {
- return lines.filter(l =>
- contractIds.includes(l.contractId) &&
- l.productId === PRODUCT.id &&
- l.pkg === PRODUCT.pkg
- )
- }
- function openMask(id) { document.getElementById(id).classList.add('show') }
- function closeMask(id) { document.getElementById(id).classList.remove('show') }
- function openPick(side) {
- pickSide = side
- document.getElementById('pick-title').textContent =
- side === 'load' ? '装货 · 选择产品 / 包装' : '送货 · 选择产品 / 包装'
- openMask('mask-pick')
- }
- function openSplit() {
- const ids = pickSide === 'load' ? loadContracts : deliveryContracts
- const hits = matchingLines(ids)
- document.getElementById('split-hit-count').textContent = String(hits.length)
- const tbody = document.querySelector('#tbl-split tbody')
- tbody.innerHTML = ''
- hits.forEach(l => {
- const tr = document.createElement('tr')
- tr.className = l.contractId === 'HT-A' ? 'hl' : 'hl2'
- tr.innerHTML = `
- <td class="mono">${l.id}</td>
- <td>${l.contractId}</td>
- <td class="mono price-diff">¥ ${l.price.toLocaleString()}</td>
- <td class="mono">${remaining(l)}</td>
- <td><input type="number" min="0" step="0.001" max="${remaining(l)}" data-cp="${l.id}" placeholder="0" /></td>
- `
- tbody.appendChild(tr)
- })
- const updateSum = () => {
- let sum = 0
- tbody.querySelectorAll('input').forEach(inp => { sum += Number(inp.value) || 0 })
- const el = document.getElementById('split-sum')
- el.textContent = `合计计划量:${Math.round(sum * 1000) / 1000}`
- el.className = 'alloc-sum' + (sum > 0 ? ' ok' : '')
- }
- tbody.querySelectorAll('input').forEach(inp => inp.addEventListener('input', updateSum))
- updateSum()
- closeMask('mask-pick')
- openMask('mask-split')
- }
- function confirmSplit() {
- const inputs = [...document.querySelectorAll('#tbl-split input')]
- const created = []
- for (const inp of inputs) {
- const q = Number(inp.value) || 0
- if (q <= 0) continue
- const line = lines.find(l => l.id === inp.dataset.cp)
- if (!line) continue
- if (q > remaining(line) + 1e-9) {
- toast(`超出剩余可安排:${line.id}`)
- return
- }
- created.push({
- contractProductId: line.id,
- contractId: line.contractId,
- price: line.price,
- planQty: Math.round(q * 1000) / 1000,
- actualQty: null
- })
- }
- if (!created.length) {
- toast('请至少填写一行计划量')
- return
- }
- if (pickSide === 'load') {
- // replace same product rows that collide on same cp ids, else append
- const ids = new Set(created.map(c => c.contractProductId))
- loadRows = loadRows.filter(r => !ids.has(r.contractProductId)).concat(created)
- } else {
- const ids = new Set(created.map(c => c.contractProductId))
- deliveryRows = deliveryRows.filter(r => !ids.has(r.contractProductId)).concat(created)
- }
- closeMask('mask-split')
- renderPlanTables()
- toast(`已生成 ${created.length} 行(绑合同明细 id)`)
- }
- function syncDeliveryFromLoad() {
- if (!loadRows.length) {
- toast('装货尚无产品,无法同步')
- return
- }
- if (deliveryRows.length && !confirm('将覆盖送货已有拆行,是否继续?')) return
- deliveryRows = loadRows.map(r => ({ ...r, actualQty: null }))
- renderPlanTables()
- toast('已从装货同步到送货(覆盖)')
- }
- function renderArchiveTables() {
- const fill = (tbodySel, rows, side) => {
- const tbody = document.querySelector(tbodySel)
- tbody.innerHTML = ''
- if (!rows.length) {
- const tr = document.createElement('tr')
- tr.innerHTML = `<td colspan="6" class="muted">暂无行。请先在步骤 2 拆行${side === 'delivery' ? '或同步' : ''}。</td>`
- tbody.appendChild(tr)
- return
- }
- rows.forEach((r, idx) => {
- const line = lines.find(l => l.id === r.contractProductId)
- const tr = document.createElement('tr')
- tr.className = r.contractId === 'HT-A' ? 'hl' : 'hl2'
- const defaultActual = r.actualQty != null ? r.actualQty : r.planQty
- tr.innerHTML = `
- <td class="mono">${r.contractProductId}</td>
- <td>${r.contractId}</td>
- <td>${PRODUCT.name} / ${PRODUCT.pkg}</td>
- <td class="mono price-diff">¥ ${(line ? line.price : r.price).toLocaleString()}</td>
- <td class="mono">${r.planQty}</td>
- <td><input type="number" min="0" step="0.001" value="${defaultActual}" data-side="${side}" data-idx="${idx}" /></td>
- `
- tbody.appendChild(tr)
- })
- }
- fill('#tbl-archive-load tbody', loadRows, 'load')
- fill('#tbl-archive-delivery tbody', deliveryRows, 'delivery')
- }
- function confirmArchive() {
- if (!loadRows.length && !deliveryRows.length) {
- toast('请先在步骤 2 生成拆行')
- return
- }
- // read actuals — demo uses load side for contract arranged (装货口径示意)
- document.querySelectorAll('#tbl-archive-load input').forEach(inp => {
- const idx = Number(inp.dataset.idx)
- loadRows[idx].actualQty = Number(inp.value) || 0
- })
- document.querySelectorAll('#tbl-archive-delivery input').forEach(inp => {
- const idx = Number(inp.dataset.idx)
- deliveryRows[idx].actualQty = Number(inp.value) || 0
- })
- // reset arranged then apply load actuals as archived qty demo
- lines.forEach(l => { l.arranged = 0 })
- loadRows.forEach(r => {
- const line = lines.find(l => l.id === r.contractProductId)
- if (line) line.arranged = Math.round((line.arranged + (r.actualQty || 0)) * 1000) / 1000
- })
- const parts = loadRows.map(r => {
- const line = lines.find(l => l.id === r.contractProductId)
- return `${r.contractProductId}(${r.contractId}·¥${line.price})+${r.actualQty}`
- })
- document.getElementById('archive-result').style.display = 'block'
- document.getElementById('archive-result-text').textContent =
- parts.length ? parts.join(';') + '。各行独立累加,无事后分摊。' : '无装货行。'
- renderContractTables()
- toast('归档完成:已按 contract_product_id 回写演示量')
- }
- 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) renderPlanTables()
- if (n === 3) renderArchiveTables()
- window.scrollTo({ top: 0, behavior: 'smooth' })
- }
- // Events
- document.querySelectorAll('.step-btn').forEach(b => b.addEventListener('click', () => gotoStep(b.dataset.step)))
- document.querySelectorAll('[data-goto]').forEach(b => b.addEventListener('click', () => gotoStep(b.dataset.goto)))
- document.querySelectorAll('[data-close]').forEach(b => b.addEventListener('click', () => closeMask(b.dataset.close)))
- document.getElementById('btn-pick-product-load').addEventListener('click', () => openPick('load'))
- document.getElementById('btn-pick-product-delivery').addEventListener('click', () => openPick('delivery'))
- document.getElementById('btn-choose-caustic').addEventListener('click', openSplit)
- document.getElementById('btn-confirm-split').addEventListener('click', confirmSplit)
- document.getElementById('btn-sync').addEventListener('click', syncDeliveryFromLoad)
- document.getElementById('btn-confirm-archive').addEventListener('click', confirmArchive)
- document.getElementById('btn-to-archive').addEventListener('click', () => {
- if (!loadRows.length && !deliveryRows.length) {
- toast('建议先点「添加产品」走一遍拆行(也可直接进归档看空态)')
- }
- })
- document.getElementById('btn-add-dup').addEventListener('click', () => {
- const n = lines.filter(l => l.contractId === 'HT-A' && l.productId === PRODUCT.id).length + 1
- lines.push({
- id: 'cp-a' + (10 + n),
- contractId: 'HT-A',
- productId: PRODUCT.id,
- name: PRODUCT.name,
- pkg: PRODUCT.pkg,
- qty: 20,
- price: 2400 + n * 50,
- arranged: 0,
- note: '演示追加 · 又一同品同包装单价'
- })
- renderContractTables()
- toast('已追加同品同包装行(现网会被校验拦住)')
- })
- // click mask backdrop to close
- ;['mask-pick', 'mask-split'].forEach(id => {
- document.getElementById(id).addEventListener('click', e => {
- if (e.target.id === id) closeMask(id)
- })
- })
- renderContractTables()
- renderPlanTables()
- </script>
- </body>
- </html>
|