2026-09-29-pl-multi-contract-product-split-demo.html 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011
  1. <!doctype html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8" />
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  6. <title>合同多单价行 × 运输计划多合同拆行 × 归档 Demo</title>
  7. <link rel="preconnect" href="https://fonts.googleapis.com" />
  8. <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
  9. <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
  10. <style>
  11. :root {
  12. --primary: #10b981;
  13. --primary-dark: #059669;
  14. --primary-bg: #ecfdf5;
  15. --text: #1e293b;
  16. --text-muted: #64748b;
  17. --border: #d9e8e2;
  18. --surface: #ffffff;
  19. --bg-page: #f1f5f9;
  20. --shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
  21. --shadow-lg: 0 12px 40px rgba(15, 23, 42, 0.14);
  22. --radius: 10px;
  23. --warn: #d97706;
  24. --warn-bg: #fffbeb;
  25. --warn-border: #fde68a;
  26. --danger: #dc2626;
  27. --danger-bg: #fef2f2;
  28. --info: #0284c7;
  29. --info-bg: #f0f9ff;
  30. --info-border: #bae6fd;
  31. --violet: #7c3aed;
  32. --violet-bg: #f5f3ff;
  33. }
  34. * { box-sizing: border-box; }
  35. body {
  36. margin: 0;
  37. min-height: 100vh;
  38. font-family: Inter, -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
  39. font-size: 14px;
  40. line-height: 1.5;
  41. color: var(--text);
  42. background: var(--bg-page);
  43. }
  44. .page { max-width: 1280px; margin: 0 auto; padding: 24px 20px 96px; }
  45. .hero {
  46. background: linear-gradient(135deg, #ecfdf5 0%, #fff 48%, #f0f9ff 100%);
  47. border: 1px solid var(--border);
  48. border-radius: var(--radius);
  49. padding: 20px 22px;
  50. margin-bottom: 16px;
  51. box-shadow: var(--shadow);
  52. }
  53. .hero h1 { margin: 0 0 8px; font-size: 22px; font-weight: 700; letter-spacing: -0.02em; }
  54. .hero h1 em { font-style: normal; color: var(--primary-dark); }
  55. .hero p { margin: 0; color: var(--text-muted); max-width: 980px; }
  56. .pills { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
  57. .pill {
  58. display: inline-flex; align-items: center; gap: 6px;
  59. padding: 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 500;
  60. background: var(--primary-bg); color: var(--primary-dark); border: 1px solid #a7f3d0;
  61. }
  62. .pill.warn { background: var(--warn-bg); color: var(--warn); border-color: var(--warn-border); }
  63. .pill.info { background: var(--info-bg); color: var(--info); border-color: var(--info-border); }
  64. .pill.muted { background: #f1f5f9; color: var(--text-muted); border-color: #e2e8f0; }
  65. .pill.violet { background: var(--violet-bg); color: var(--violet); border-color: #ddd6fe; }
  66. .steps {
  67. display: flex; flex-wrap: wrap; gap: 8px;
  68. margin-bottom: 14px;
  69. }
  70. .step-btn {
  71. border: 1px solid var(--border);
  72. background: #fff;
  73. border-radius: 8px;
  74. padding: 8px 14px;
  75. font: inherit;
  76. font-weight: 600;
  77. font-size: 13px;
  78. color: var(--text-muted);
  79. cursor: pointer;
  80. }
  81. .step-btn:hover { border-color: #a7f3d0; color: var(--primary-dark); }
  82. .step-btn.active {
  83. background: var(--primary-bg);
  84. border-color: var(--primary);
  85. color: var(--primary-dark);
  86. }
  87. .step-btn .n {
  88. display: inline-flex; width: 18px; height: 18px; align-items: center; justify-content: center;
  89. border-radius: 999px; background: #e2e8f0; color: #475569; font-size: 11px; margin-right: 6px;
  90. }
  91. .step-btn.active .n { background: var(--primary); color: #fff; }
  92. .panel {
  93. display: none;
  94. background: var(--surface);
  95. border: 1px solid var(--border);
  96. border-radius: var(--radius);
  97. box-shadow: var(--shadow);
  98. overflow: hidden;
  99. }
  100. .panel.active { display: block; }
  101. .panel-head {
  102. display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 12px;
  103. padding: 14px 18px;
  104. border-bottom: 1px solid var(--border);
  105. background: #fafafa;
  106. }
  107. .panel-head h2 {
  108. margin: 0;
  109. font-size: 16px;
  110. font-weight: 700;
  111. display: flex; align-items: center; gap: 8px;
  112. }
  113. .panel-head h2::before {
  114. content: "";
  115. width: 3px; height: 16px;
  116. background: var(--primary);
  117. border-radius: 2px;
  118. }
  119. .panel-head .hint { margin: 4px 0 0; font-size: 12px; color: var(--text-muted); max-width: 720px; }
  120. .panel-body { padding: 16px 18px 20px; }
  121. .callout {
  122. margin-bottom: 14px;
  123. padding: 10px 12px;
  124. border-radius: 8px;
  125. font-size: 13px;
  126. border: 1px solid var(--warn-border);
  127. background: var(--warn-bg);
  128. color: #92400e;
  129. }
  130. .callout strong { color: #78350f; }
  131. .callout.info {
  132. border-color: var(--info-border);
  133. background: var(--info-bg);
  134. color: #0369a1;
  135. }
  136. .callout.info strong { color: #0c4a6e; }
  137. .callout.ok {
  138. border-color: #a7f3d0;
  139. background: var(--primary-bg);
  140. color: #047857;
  141. }
  142. .callout.ok strong { color: #065f46; }
  143. .special-grid {
  144. display: grid;
  145. grid-template-columns: repeat(3, 1fr);
  146. gap: 10px;
  147. margin-bottom: 16px;
  148. }
  149. @media (max-width: 960px) { .special-grid { grid-template-columns: 1fr; } }
  150. .special-card {
  151. border: 1px dashed #fbbf24;
  152. background: #fffbeb;
  153. border-radius: 8px;
  154. padding: 10px 12px;
  155. }
  156. .special-card .t { font-size: 12px; font-weight: 700; color: var(--warn); margin-bottom: 4px; }
  157. .special-card .d { font-size: 12px; color: #78350f; }
  158. table.mini {
  159. width: 100%;
  160. border-collapse: collapse;
  161. font-size: 13px;
  162. }
  163. table.mini th, table.mini td {
  164. border-bottom: 1px solid #eef2f0;
  165. padding: 8px 10px;
  166. text-align: left;
  167. vertical-align: middle;
  168. }
  169. table.mini th {
  170. background: #f8fafc;
  171. color: var(--text-muted);
  172. font-weight: 600;
  173. font-size: 12px;
  174. }
  175. table.mini tr:nth-child(even) td { background: #fafafa; }
  176. table.mini tr.hl td {
  177. background: #ecfdf5 !important;
  178. box-shadow: inset 3px 0 0 var(--primary);
  179. }
  180. table.mini tr.hl2 td {
  181. background: #f0f9ff !important;
  182. box-shadow: inset 3px 0 0 var(--info);
  183. }
  184. .tag {
  185. display: inline-flex; align-items: center;
  186. padding: 1px 8px; border-radius: 4px;
  187. font-size: 11px; font-weight: 600;
  188. background: #059669; color: #fff;
  189. }
  190. .tag.warn { background: #d97706; }
  191. .tag.info { background: transparent; color: #1e293b; border: 1px solid #cbd5e1; }
  192. .tag.violet { background: #7c3aed; }
  193. .mono { font-variant-numeric: tabular-nums; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
  194. .muted { color: var(--text-muted); }
  195. .price-diff { color: var(--warn); font-weight: 700; }
  196. .btn-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
  197. .btn {
  198. display: inline-flex; align-items: center; gap: 6px;
  199. height: 34px; padding: 0 14px;
  200. border-radius: 8px; border: 1px solid var(--border);
  201. background: #fff; color: var(--text);
  202. font: inherit; font-weight: 600; font-size: 13px;
  203. cursor: pointer;
  204. }
  205. .btn:hover { border-color: #a7f3d0; }
  206. .btn.primary {
  207. background: var(--primary);
  208. border-color: var(--primary);
  209. color: #fff;
  210. }
  211. .btn.primary:hover { background: var(--primary-dark); }
  212. .btn.ghost { background: var(--primary-bg); border-color: #a7f3d0; color: var(--primary-dark); }
  213. .btn:disabled { opacity: 0.5; cursor: not-allowed; }
  214. .split-cols {
  215. display: grid;
  216. grid-template-columns: 1fr 1fr;
  217. gap: 14px;
  218. }
  219. @media (max-width: 960px) { .split-cols { grid-template-columns: 1fr; } }
  220. .box {
  221. border: 1px solid var(--border);
  222. border-radius: 8px;
  223. overflow: hidden;
  224. }
  225. .box-h {
  226. display: flex; align-items: center; justify-content: space-between; gap: 8px;
  227. padding: 10px 12px;
  228. background: #f8fafc;
  229. border-bottom: 1px solid var(--border);
  230. font-weight: 700;
  231. font-size: 13px;
  232. }
  233. .box-h.load { border-left: 3px solid var(--primary); }
  234. .box-h.delivery { border-left: 3px solid var(--info); }
  235. .box-b { padding: 10px 12px; }
  236. .chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
  237. .chip {
  238. display: inline-flex; align-items: center; gap: 6px;
  239. padding: 4px 10px; border-radius: 6px;
  240. background: var(--primary-bg); border: 1px solid #a7f3d0;
  241. font-size: 12px; font-weight: 600; color: var(--primary-dark);
  242. }
  243. .chip .x {
  244. width: 16px; height: 16px; border-radius: 4px;
  245. border: none; background: #fff; color: var(--text-muted); cursor: pointer; font-size: 12px; line-height: 1;
  246. }
  247. .chip.info { background: var(--info-bg); border-color: var(--info-border); color: #0369a1; }
  248. /* Modal */
  249. .mask {
  250. display: none;
  251. position: fixed; inset: 0;
  252. background: rgba(15, 23, 42, 0.45);
  253. z-index: 50;
  254. align-items: center; justify-content: center;
  255. padding: 20px;
  256. backdrop-filter: none;
  257. }
  258. .mask.show { display: flex; }
  259. .dialog {
  260. width: min(720px, 100%);
  261. background: #fff;
  262. border-radius: var(--radius);
  263. box-shadow: var(--shadow-lg);
  264. overflow: hidden;
  265. max-height: calc(100vh - 40px);
  266. display: flex; flex-direction: column;
  267. }
  268. .dialog-h {
  269. padding: 14px 18px;
  270. border-bottom: 1px solid var(--border);
  271. display: flex; align-items: center; justify-content: space-between;
  272. }
  273. .dialog-h h3 { margin: 0; font-size: 16px; }
  274. .dialog-b { padding: 16px 18px; overflow: auto; }
  275. .dialog-f {
  276. padding: 12px 18px;
  277. border-top: 1px solid var(--border);
  278. display: flex; justify-content: flex-end; gap: 8px;
  279. background: #fafafa;
  280. }
  281. .field-grid {
  282. display: grid;
  283. grid-template-columns: 1fr 1fr;
  284. gap: 10px;
  285. margin-bottom: 12px;
  286. }
  287. .field label { display: block; font-size: 11px; color: var(--text-muted); margin-bottom: 4px; font-weight: 600; }
  288. .field .val {
  289. height: 36px; border: 1px solid var(--border); border-radius: 8px;
  290. padding: 0 10px; display: flex; align-items: center; background: #f8fafc;
  291. }
  292. .field input[type="number"] {
  293. width: 100%; height: 36px; border: 1px solid var(--border); border-radius: 8px;
  294. padding: 0 10px; font: inherit;
  295. }
  296. .alloc-sum {
  297. margin-top: 8px; font-size: 12px; color: var(--text-muted);
  298. }
  299. .alloc-sum.bad { color: var(--danger); font-weight: 600; }
  300. .alloc-sum.ok { color: var(--primary-dark); font-weight: 600; }
  301. .toast {
  302. position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(20px);
  303. background: #0f172a; color: #fff; padding: 10px 16px; border-radius: 8px;
  304. font-size: 13px; opacity: 0; pointer-events: none; transition: .25s; z-index: 60;
  305. max-width: 90vw;
  306. }
  307. .toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
  308. .legend {
  309. display: flex; flex-wrap: wrap; gap: 12px;
  310. margin-top: 10px; font-size: 12px; color: var(--text-muted);
  311. }
  312. .legend i {
  313. display: inline-block; width: 12px; height: 12px; border-radius: 2px;
  314. vertical-align: -2px; margin-right: 4px;
  315. }
  316. .flow {
  317. display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
  318. margin: 12px 0 4px; font-size: 12px; color: var(--text-muted);
  319. }
  320. .flow .node {
  321. padding: 4px 10px; border-radius: 6px; background: #f1f5f9; border: 1px solid #e2e8f0;
  322. font-weight: 600; color: var(--text);
  323. }
  324. .flow .arrow { color: #94a3b8; }
  325. </style>
  326. </head>
  327. <body>
  328. <div class="page">
  329. <header class="hero">
  330. <h1>合同 / 运输计划 · <em>多合同 · 同品同包装多单价</em> · 拆行 Demo</h1>
  331. <p>
  332. 按已确认方案:合同允许同产品同包装多行(不同单价);段次可多选合同;选品先选产品/包装,多命中则弹拆分量;
  333. 装/送独立维护,提供「一键从装货同步到送货」;归档按行录实际量(安排阶段已拆好,不做事后划分)。
  334. </p>
  335. <div class="pills">
  336. <span class="pill warn">特殊① 同品同包装多单价行</span>
  337. <span class="pill warn">特殊② 段次多合同</span>
  338. <span class="pill warn">特殊③ 选品命中多明细 → 拆行</span>
  339. <span class="pill info">特殊④ 装/送独立 + 一键同步</span>
  340. <span class="pill violet">特殊⑤ 归档各行各自实际量</span>
  341. <span class="pill muted">方案 1 · contract_product_id 绑定</span>
  342. </div>
  343. </header>
  344. <nav class="steps">
  345. <button class="step-btn active" data-step="1"><span class="n">1</span>合同产品明细</button>
  346. <button class="step-btn" data-step="2"><span class="n">2</span>运输计划 · 多合同选品拆行</button>
  347. <button class="step-btn" data-step="3"><span class="n">3</span>归档 · 按行录实际量</button>
  348. <button class="step-btn" data-step="4"><span class="n">4</span>特殊情况对照</button>
  349. </nav>
  350. <!-- ========== Step 1: Contract ========== -->
  351. <section class="panel active" id="panel-1">
  352. <div class="panel-head">
  353. <div>
  354. <h2>合同管理 · 产品明细</h2>
  355. <p class="hint">特殊情况:同一合同内,同一产品 + 同一包装规格可以有多行,单价不同(如批次/议价差异)。</p>
  356. </div>
  357. <button class="btn ghost" type="button" id="btn-add-dup">+ 再加一行同品同包装(演示)</button>
  358. </div>
  359. <div class="panel-body">
  360. <div class="callout">
  361. <strong>对比现状:</strong>现网前后端会拦截「同一产品的包装规格不能重复」。本 Demo 展示放开后的效果。
  362. </div>
  363. <div class="special-grid">
  364. <div class="special-card">
  365. <div class="t">特殊①</div>
  366. <div class="d">合同 HT-A:烧碱 32% / 25kg袋,<b>两行</b>,单价 2100 与 2350。</div>
  367. </div>
  368. <div class="special-card">
  369. <div class="t">特殊①(另一合同)</div>
  370. <div class="d">合同 HT-B:同品同包装再有一行,单价 2200(供多合同段次演示)。</div>
  371. </div>
  372. <div class="special-card">
  373. <div class="t">计量键</div>
  374. <div class="d">后续安排/归档按 <b>合同明细行 id</b>(cp-xxx)记账,不再按「产品+包装」糊成一池。</div>
  375. </div>
  376. </div>
  377. <h3 style="margin:0 0 8px;font-size:13px;color:var(--text-muted);">合同 HT-A · 安徽某某贸易(示意)</h3>
  378. <table class="mini" id="tbl-contract-a">
  379. <thead>
  380. <tr>
  381. <th>明细行 id</th>
  382. <th>产品</th>
  383. <th>包装规格</th>
  384. <th>合同数量</th>
  385. <th>单价</th>
  386. <th>已安排</th>
  387. <th>剩余</th>
  388. <th>说明</th>
  389. </tr>
  390. </thead>
  391. <tbody></tbody>
  392. </table>
  393. <h3 style="margin:18px 0 8px;font-size:13px;color:var(--text-muted);">合同 HT-B · 合肥某某化工(示意)</h3>
  394. <table class="mini" id="tbl-contract-b">
  395. <thead>
  396. <tr>
  397. <th>明细行 id</th>
  398. <th>产品</th>
  399. <th>包装规格</th>
  400. <th>合同数量</th>
  401. <th>单价</th>
  402. <th>已安排</th>
  403. <th>剩余</th>
  404. <th>说明</th>
  405. </tr>
  406. </thead>
  407. <tbody></tbody>
  408. </table>
  409. <div class="legend">
  410. <span><i style="background:#ecfdf5;border:1px solid #a7f3d0"></i>同品同包装 · 多单价行</span>
  411. <span><i style="background:#f0f9ff;border:1px solid #bae6fd"></i>另一合同同品同包装</span>
  412. </div>
  413. <div class="btn-row">
  414. <button class="btn primary" type="button" data-goto="2">下一步:运输计划选品拆行 →</button>
  415. </div>
  416. </div>
  417. </section>
  418. <!-- ========== Step 2: Plan ========== -->
  419. <section class="panel" id="panel-2">
  420. <div class="panel-head">
  421. <div>
  422. <h2>运输计划 · 段次多合同 + 选品拆行</h2>
  423. <p class="hint">特殊②③④:段次可选多份合同;先选产品/包装,命中多条明细则弹拆分量;装/送独立,可一键同步。</p>
  424. </div>
  425. </div>
  426. <div class="panel-body">
  427. <div class="flow">
  428. <span class="node">选多份合同</span><span class="arrow">→</span>
  429. <span class="node">选产品 / 包装</span><span class="arrow">→</span>
  430. <span class="node">命中 ≥2 条明细</span><span class="arrow">→</span>
  431. <span class="node">拆分量弹窗</span><span class="arrow">→</span>
  432. <span class="node">生成多行(绑 contract_product_id)</span>
  433. </div>
  434. <div class="split-cols" style="margin-top:14px">
  435. <!-- Load -->
  436. <div class="box">
  437. <div class="box-h load">
  438. <span>装货段</span>
  439. <span class="muted" style="font-weight:500;font-size:12px">非内置客户 · 须选合同</span>
  440. </div>
  441. <div class="box-b">
  442. <div class="callout info" style="margin-bottom:10px">
  443. <strong>特殊②:</strong>本段已选 <b>2 份</b>合同(现状上限为 1)。
  444. </div>
  445. <div class="chips" id="load-contracts"></div>
  446. <div class="btn-row" style="margin-top:0;margin-bottom:10px">
  447. <button class="btn ghost" type="button" id="btn-pick-product-load">+ 添加产品(先选品)</button>
  448. </div>
  449. <table class="mini" id="tbl-load">
  450. <thead>
  451. <tr>
  452. <th>绑定明细</th>
  453. <th>合同</th>
  454. <th>产品 / 包装</th>
  455. <th>单价</th>
  456. <th>计划量</th>
  457. </tr>
  458. </thead>
  459. <tbody></tbody>
  460. </table>
  461. <p class="muted" style="margin:8px 0 0;font-size:12px" id="load-empty-hint">尚未添加产品。点「添加产品」演示:选「烧碱 32% / 25kg袋」将命中 3 条明细。</p>
  462. </div>
  463. </div>
  464. <!-- Delivery -->
  465. <div class="box">
  466. <div class="box-h delivery">
  467. <span>送货段</span>
  468. <span class="muted" style="font-weight:500;font-size:12px">独立选品 · 不强制对齐装货</span>
  469. </div>
  470. <div class="box-b">
  471. <div class="callout info" style="margin-bottom:10px">
  472. <strong>特殊④:</strong>默认不同步;可用下方按钮从装货覆盖同步。
  473. </div>
  474. <div class="chips" id="delivery-contracts"></div>
  475. <div class="btn-row" style="margin-top:0;margin-bottom:10px">
  476. <button class="btn ghost" type="button" id="btn-pick-product-delivery">+ 添加产品(先选品)</button>
  477. <button class="btn" type="button" id="btn-sync">从装货同步到送货</button>
  478. </div>
  479. <table class="mini" id="tbl-delivery">
  480. <thead>
  481. <tr>
  482. <th>绑定明细</th>
  483. <th>合同</th>
  484. <th>产品 / 包装</th>
  485. <th>单价</th>
  486. <th>计划量</th>
  487. </tr>
  488. </thead>
  489. <tbody></tbody>
  490. </table>
  491. <p class="muted" style="margin:8px 0 0;font-size:12px" id="delivery-empty-hint">送货可另选合同明细;本 Demo 默认可与装货选同一批合同。</p>
  492. </div>
  493. </div>
  494. </div>
  495. <div class="btn-row">
  496. <button class="btn" type="button" data-goto="1">← 合同明细</button>
  497. <button class="btn primary" type="button" data-goto="3" id="btn-to-archive">下一步:归档录实际量 →</button>
  498. </div>
  499. </div>
  500. </section>
  501. <!-- ========== Step 3: Archive ========== -->
  502. <section class="panel" id="panel-3">
  503. <div class="panel-head">
  504. <div>
  505. <h2>运输计划归档 · 按行录实际量</h2>
  506. <p class="hint">特殊⑤:不做「事后按比例划分」。安排阶段已拆成多行,归档时每行各自填写实际量;合同已归档量按 contract_product_id 累加。</p>
  507. </div>
  508. </div>
  509. <div class="panel-body">
  510. <div class="callout ok">
  511. <strong>与需求 3 的对应:</strong>多合同同品同包装、或同合同多单价行 —— 在计划层已是多行;归档只录本行实际量,即完成「划分」。
  512. </div>
  513. <h3 style="margin:0 0 8px;font-size:13px;color:var(--text-muted);">装货段 · 入库实际量</h3>
  514. <table class="mini" id="tbl-archive-load">
  515. <thead>
  516. <tr>
  517. <th>绑定明细</th>
  518. <th>合同</th>
  519. <th>产品 / 包装</th>
  520. <th>单价</th>
  521. <th>计划量</th>
  522. <th>实际入库量</th>
  523. </tr>
  524. </thead>
  525. <tbody></tbody>
  526. </table>
  527. <h3 style="margin:18px 0 8px;font-size:13px;color:var(--text-muted);">送货段 · 出库实际量</h3>
  528. <table class="mini" id="tbl-archive-delivery">
  529. <thead>
  530. <tr>
  531. <th>绑定明细</th>
  532. <th>合同</th>
  533. <th>产品 / 包装</th>
  534. <th>单价</th>
  535. <th>计划量</th>
  536. <th>实际出库量</th>
  537. </tr>
  538. </thead>
  539. <tbody></tbody>
  540. </table>
  541. <div class="btn-row">
  542. <button class="btn primary" type="button" id="btn-confirm-archive">确认归档(演示回写合同已安排)</button>
  543. <button class="btn" type="button" data-goto="2">← 返回计划</button>
  544. <button class="btn ghost" type="button" data-goto="4">特殊情况对照 →</button>
  545. </div>
  546. <div id="archive-result" style="display:none;margin-top:14px">
  547. <div class="callout ok">
  548. <strong>归档后合同明细已安排量(读时按行聚合):</strong>
  549. <span id="archive-result-text"></span>
  550. </div>
  551. </div>
  552. </div>
  553. </section>
  554. <!-- ========== Step 4: Matrix ========== -->
  555. <section class="panel" id="panel-4">
  556. <div class="panel-head">
  557. <div>
  558. <h2>特殊情况对照表</h2>
  559. <p class="hint">把客户三点需求与 Demo 操作、数据键对齐,便于评审。</p>
  560. </div>
  561. </div>
  562. <div class="panel-body">
  563. <table class="mini">
  564. <thead>
  565. <tr>
  566. <th style="width:18%">特殊情况</th>
  567. <th style="width:28%">客户诉求</th>
  568. <th style="width:28%">Demo 怎么体现</th>
  569. <th>实现要点(方案 1)</th>
  570. </tr>
  571. </thead>
  572. <tbody>
  573. <tr class="hl">
  574. <td><span class="tag warn">①</span> 合同同品同包装多行</td>
  575. <td>同产品同包装可多条,因单价可能不同</td>
  576. <td>步骤 1:HT-A 两行烧碱/25kg,单价 2100 / 2350</td>
  577. <td>去掉包装唯一校验;行主键即计量键</td>
  578. </tr>
  579. <tr class="hl2">
  580. <td><span class="tag warn">②</span> 段次多合同</td>
  581. <td>一次运输可送两个合同的同品同包装货</td>
  582. <td>步骤 2:装货 chips 显示 HT-A + HT-B</td>
  583. <td>取消 assert_at_most_one_contract</td>
  584. </tr>
  585. <tr class="hl">
  586. <td><span class="tag warn">③</span> 选品拆行</td>
  587. <td>安排阶段拆好,归档不再事后划分</td>
  588. <td>步骤 2:选烧碱/25kg → 弹窗对 3 条明细填计划量 → 生成 3 行</td>
  589. <td>段产品行绑定 contract_product_id</td>
  590. </tr>
  591. <tr>
  592. <td><span class="tag info">④</span> 装/送独立</td>
  593. <td>送货不强制与装货一一对应</td>
  594. <td>步骤 2:送货可单独加品;「从装货同步」覆盖</td>
  595. <td>取消默认自动同步;保留手动按钮</td>
  596. </tr>
  597. <tr>
  598. <td><span class="tag violet">⑤</span> 归档按行实际量</td>
  599. <td>多合同 / 同合同多单价行的已执行按实际划分</td>
  600. <td>步骤 3:每行各自填入库/出库实际量;确认后回写各 cp 行</td>
  601. <td>聚合键改为 contract_product_id;无事后分摊算法</td>
  602. </tr>
  603. </tbody>
  604. </table>
  605. <div class="btn-row">
  606. <button class="btn primary" type="button" data-goto="1">从头再看一遍</button>
  607. <button class="btn" type="button" data-goto="2">再试拆行</button>
  608. </div>
  609. </div>
  610. </section>
  611. </div>
  612. <!-- Product pick (simplified) -->
  613. <div class="mask" id="mask-pick">
  614. <div class="dialog" style="width:min(480px,100%)">
  615. <div class="dialog-h">
  616. <h3 id="pick-title">选择产品 / 包装</h3>
  617. <button class="btn" type="button" data-close="mask-pick">关闭</button>
  618. </div>
  619. <div class="dialog-b">
  620. <div class="callout info">
  621. <strong>交互 B:</strong>仍按产品选。本 Demo 只有一个演示品,点击后若命中多条合同明细则进入拆分量。
  622. </div>
  623. <button class="btn primary" type="button" id="btn-choose-caustic" style="width:100%;justify-content:center;height:44px">
  624. 烧碱 32% · 包装 25kg袋
  625. </button>
  626. <p class="muted" style="margin:10px 0 0;font-size:12px">将在当前段已选合同中匹配「产品+包装 ∈ 明细」的所有行(含同合同多单价 + 跨合同)。</p>
  627. </div>
  628. </div>
  629. </div>
  630. <!-- Split alloc dialog -->
  631. <div class="mask" id="mask-split">
  632. <div class="dialog">
  633. <div class="dialog-h">
  634. <h3>拆分量 · 烧碱 32% / 25kg袋</h3>
  635. <button class="btn" type="button" data-close="mask-split">关闭</button>
  636. </div>
  637. <div class="dialog-b">
  638. <div class="callout">
  639. <strong>特殊③命中:</strong>当前段所选合同中,同产品同包装共 <b id="split-hit-count">3</b> 条明细(同合同多单价 + 跨合同)。请为需要安排的行填写计划量;填 0 或不填则不生成该行。
  640. </div>
  641. <div class="field-grid">
  642. <div class="field">
  643. <label>产品</label>
  644. <div class="val">烧碱 32%</div>
  645. </div>
  646. <div class="field">
  647. <label>包装规格</label>
  648. <div class="val">25kg袋</div>
  649. </div>
  650. </div>
  651. <table class="mini" id="tbl-split">
  652. <thead>
  653. <tr>
  654. <th>明细行</th>
  655. <th>合同</th>
  656. <th>单价</th>
  657. <th>剩余可安排</th>
  658. <th>本次计划量</th>
  659. </tr>
  660. </thead>
  661. <tbody></tbody>
  662. </table>
  663. <div class="alloc-sum" id="split-sum">合计计划量:0</div>
  664. </div>
  665. <div class="dialog-f">
  666. <button class="btn" type="button" data-close="mask-split">取消</button>
  667. <button class="btn primary" type="button" id="btn-confirm-split">生成多行</button>
  668. </div>
  669. </div>
  670. </div>
  671. <div class="toast" id="toast"></div>
  672. <script>
  673. /** Demo state */
  674. const PRODUCT = { id: 'p-caustic', name: '烧碱 32%', pkg: '25kg袋' }
  675. const contractsMeta = {
  676. 'HT-A': { name: '安徽某某贸易' },
  677. 'HT-B': { name: '合肥某某化工' }
  678. }
  679. /** Contract product lines — special case ① */
  680. let lines = [
  681. { id: 'cp-a1', contractId: 'HT-A', productId: PRODUCT.id, name: PRODUCT.name, pkg: PRODUCT.pkg, qty: 100, price: 2100, arranged: 0, note: '同品同包装 · 低价行' },
  682. { id: 'cp-a2', contractId: 'HT-A', productId: PRODUCT.id, name: PRODUCT.name, pkg: PRODUCT.pkg, qty: 40, price: 2350, arranged: 0, note: '同品同包装 · 高价行' },
  683. { id: 'cp-a3', contractId: 'HT-A', productId: 'p-hcl', name: '盐酸 31%', pkg: '槽罐车', qty: 200, price: 180, arranged: 0, note: '其他产品(对照)' },
  684. { id: 'cp-b1', contractId: 'HT-B', productId: PRODUCT.id, name: PRODUCT.name, pkg: PRODUCT.pkg, qty: 80, price: 2200, arranged: 0, note: '另一合同 · 同品同包装' }
  685. ]
  686. const loadContracts = ['HT-A', 'HT-B']
  687. const deliveryContracts = ['HT-A', 'HT-B']
  688. let loadRows = []
  689. let deliveryRows = []
  690. let pickSide = 'load' // load | delivery
  691. function toast(msg) {
  692. const el = document.getElementById('toast')
  693. el.textContent = msg
  694. el.classList.add('show')
  695. clearTimeout(toast._t)
  696. toast._t = setTimeout(() => el.classList.remove('show'), 2600)
  697. }
  698. function remaining(line) {
  699. return Math.round((line.qty - line.arranged) * 1000) / 1000
  700. }
  701. function renderContractTables() {
  702. const render = (cid, tbody) => {
  703. tbody.innerHTML = ''
  704. lines.filter(l => l.contractId === cid).forEach(l => {
  705. const tr = document.createElement('tr')
  706. const sameKey = l.productId === PRODUCT.id && l.pkg === PRODUCT.pkg
  707. if (sameKey && cid === 'HT-A') tr.className = 'hl'
  708. if (sameKey && cid === 'HT-B') tr.className = 'hl2'
  709. tr.innerHTML = `
  710. <td class="mono">${l.id}</td>
  711. <td>${l.name}</td>
  712. <td>${l.pkg}</td>
  713. <td class="mono">${l.qty}</td>
  714. <td class="mono ${sameKey ? 'price-diff' : ''}">¥ ${l.price.toLocaleString()}</td>
  715. <td class="mono">${l.arranged}</td>
  716. <td class="mono">${remaining(l)}</td>
  717. <td>${sameKey ? '<span class="tag warn">同品同包装</span> ' : ''}<span class="muted">${l.note}</span></td>
  718. `
  719. tbody.appendChild(tr)
  720. })
  721. }
  722. render('HT-A', document.querySelector('#tbl-contract-a tbody'))
  723. render('HT-B', document.querySelector('#tbl-contract-b tbody'))
  724. }
  725. function renderChips(elId, ids, chipClass) {
  726. const el = document.getElementById(elId)
  727. el.innerHTML = ids.map(id =>
  728. `<span class="chip ${chipClass || ''}">${id}<span class="muted" style="font-weight:500">· ${contractsMeta[id].name}</span></span>`
  729. ).join('')
  730. }
  731. function renderSideTable(tbodySel, rows, emptyHintId) {
  732. const tbody = document.querySelector(tbodySel)
  733. tbody.innerHTML = ''
  734. const hint = document.getElementById(emptyHintId)
  735. if (!rows.length) {
  736. if (hint) hint.style.display = 'block'
  737. return
  738. }
  739. if (hint) hint.style.display = 'none'
  740. rows.forEach(r => {
  741. const line = lines.find(l => l.id === r.contractProductId)
  742. const tr = document.createElement('tr')
  743. tr.className = 'hl'
  744. tr.innerHTML = `
  745. <td class="mono">${r.contractProductId}</td>
  746. <td>${r.contractId}</td>
  747. <td>${PRODUCT.name} / ${PRODUCT.pkg}</td>
  748. <td class="mono price-diff">¥ ${(line ? line.price : r.price).toLocaleString()}</td>
  749. <td class="mono">${r.planQty}</td>
  750. `
  751. tbody.appendChild(tr)
  752. })
  753. }
  754. function renderPlanTables() {
  755. renderChips('load-contracts', loadContracts)
  756. renderChips('delivery-contracts', deliveryContracts, 'info')
  757. renderSideTable('#tbl-load tbody', loadRows, 'load-empty-hint')
  758. renderSideTable('#tbl-delivery tbody', deliveryRows, 'delivery-empty-hint')
  759. }
  760. function matchingLines(contractIds) {
  761. return lines.filter(l =>
  762. contractIds.includes(l.contractId) &&
  763. l.productId === PRODUCT.id &&
  764. l.pkg === PRODUCT.pkg
  765. )
  766. }
  767. function openMask(id) { document.getElementById(id).classList.add('show') }
  768. function closeMask(id) { document.getElementById(id).classList.remove('show') }
  769. function openPick(side) {
  770. pickSide = side
  771. document.getElementById('pick-title').textContent =
  772. side === 'load' ? '装货 · 选择产品 / 包装' : '送货 · 选择产品 / 包装'
  773. openMask('mask-pick')
  774. }
  775. function openSplit() {
  776. const ids = pickSide === 'load' ? loadContracts : deliveryContracts
  777. const hits = matchingLines(ids)
  778. document.getElementById('split-hit-count').textContent = String(hits.length)
  779. const tbody = document.querySelector('#tbl-split tbody')
  780. tbody.innerHTML = ''
  781. hits.forEach(l => {
  782. const tr = document.createElement('tr')
  783. tr.className = l.contractId === 'HT-A' ? 'hl' : 'hl2'
  784. tr.innerHTML = `
  785. <td class="mono">${l.id}</td>
  786. <td>${l.contractId}</td>
  787. <td class="mono price-diff">¥ ${l.price.toLocaleString()}</td>
  788. <td class="mono">${remaining(l)}</td>
  789. <td><input type="number" min="0" step="0.001" max="${remaining(l)}" data-cp="${l.id}" placeholder="0" /></td>
  790. `
  791. tbody.appendChild(tr)
  792. })
  793. const updateSum = () => {
  794. let sum = 0
  795. tbody.querySelectorAll('input').forEach(inp => { sum += Number(inp.value) || 0 })
  796. const el = document.getElementById('split-sum')
  797. el.textContent = `合计计划量:${Math.round(sum * 1000) / 1000}`
  798. el.className = 'alloc-sum' + (sum > 0 ? ' ok' : '')
  799. }
  800. tbody.querySelectorAll('input').forEach(inp => inp.addEventListener('input', updateSum))
  801. updateSum()
  802. closeMask('mask-pick')
  803. openMask('mask-split')
  804. }
  805. function confirmSplit() {
  806. const inputs = [...document.querySelectorAll('#tbl-split input')]
  807. const created = []
  808. for (const inp of inputs) {
  809. const q = Number(inp.value) || 0
  810. if (q <= 0) continue
  811. const line = lines.find(l => l.id === inp.dataset.cp)
  812. if (!line) continue
  813. if (q > remaining(line) + 1e-9) {
  814. toast(`超出剩余可安排:${line.id}`)
  815. return
  816. }
  817. created.push({
  818. contractProductId: line.id,
  819. contractId: line.contractId,
  820. price: line.price,
  821. planQty: Math.round(q * 1000) / 1000,
  822. actualQty: null
  823. })
  824. }
  825. if (!created.length) {
  826. toast('请至少填写一行计划量')
  827. return
  828. }
  829. if (pickSide === 'load') {
  830. // replace same product rows that collide on same cp ids, else append
  831. const ids = new Set(created.map(c => c.contractProductId))
  832. loadRows = loadRows.filter(r => !ids.has(r.contractProductId)).concat(created)
  833. } else {
  834. const ids = new Set(created.map(c => c.contractProductId))
  835. deliveryRows = deliveryRows.filter(r => !ids.has(r.contractProductId)).concat(created)
  836. }
  837. closeMask('mask-split')
  838. renderPlanTables()
  839. toast(`已生成 ${created.length} 行(绑合同明细 id)`)
  840. }
  841. function syncDeliveryFromLoad() {
  842. if (!loadRows.length) {
  843. toast('装货尚无产品,无法同步')
  844. return
  845. }
  846. if (deliveryRows.length && !confirm('将覆盖送货已有拆行,是否继续?')) return
  847. deliveryRows = loadRows.map(r => ({ ...r, actualQty: null }))
  848. renderPlanTables()
  849. toast('已从装货同步到送货(覆盖)')
  850. }
  851. function renderArchiveTables() {
  852. const fill = (tbodySel, rows, side) => {
  853. const tbody = document.querySelector(tbodySel)
  854. tbody.innerHTML = ''
  855. if (!rows.length) {
  856. const tr = document.createElement('tr')
  857. tr.innerHTML = `<td colspan="6" class="muted">暂无行。请先在步骤 2 拆行${side === 'delivery' ? '或同步' : ''}。</td>`
  858. tbody.appendChild(tr)
  859. return
  860. }
  861. rows.forEach((r, idx) => {
  862. const line = lines.find(l => l.id === r.contractProductId)
  863. const tr = document.createElement('tr')
  864. tr.className = r.contractId === 'HT-A' ? 'hl' : 'hl2'
  865. const defaultActual = r.actualQty != null ? r.actualQty : r.planQty
  866. tr.innerHTML = `
  867. <td class="mono">${r.contractProductId}</td>
  868. <td>${r.contractId}</td>
  869. <td>${PRODUCT.name} / ${PRODUCT.pkg}</td>
  870. <td class="mono price-diff">¥ ${(line ? line.price : r.price).toLocaleString()}</td>
  871. <td class="mono">${r.planQty}</td>
  872. <td><input type="number" min="0" step="0.001" value="${defaultActual}" data-side="${side}" data-idx="${idx}" /></td>
  873. `
  874. tbody.appendChild(tr)
  875. })
  876. }
  877. fill('#tbl-archive-load tbody', loadRows, 'load')
  878. fill('#tbl-archive-delivery tbody', deliveryRows, 'delivery')
  879. }
  880. function confirmArchive() {
  881. if (!loadRows.length && !deliveryRows.length) {
  882. toast('请先在步骤 2 生成拆行')
  883. return
  884. }
  885. // read actuals — demo uses load side for contract arranged (装货口径示意)
  886. document.querySelectorAll('#tbl-archive-load input').forEach(inp => {
  887. const idx = Number(inp.dataset.idx)
  888. loadRows[idx].actualQty = Number(inp.value) || 0
  889. })
  890. document.querySelectorAll('#tbl-archive-delivery input').forEach(inp => {
  891. const idx = Number(inp.dataset.idx)
  892. deliveryRows[idx].actualQty = Number(inp.value) || 0
  893. })
  894. // reset arranged then apply load actuals as archived qty demo
  895. lines.forEach(l => { l.arranged = 0 })
  896. loadRows.forEach(r => {
  897. const line = lines.find(l => l.id === r.contractProductId)
  898. if (line) line.arranged = Math.round((line.arranged + (r.actualQty || 0)) * 1000) / 1000
  899. })
  900. const parts = loadRows.map(r => {
  901. const line = lines.find(l => l.id === r.contractProductId)
  902. return `${r.contractProductId}(${r.contractId}·¥${line.price})+${r.actualQty}`
  903. })
  904. document.getElementById('archive-result').style.display = 'block'
  905. document.getElementById('archive-result-text').textContent =
  906. parts.length ? parts.join(';') + '。各行独立累加,无事后分摊。' : '无装货行。'
  907. renderContractTables()
  908. toast('归档完成:已按 contract_product_id 回写演示量')
  909. }
  910. function gotoStep(n) {
  911. document.querySelectorAll('.step-btn').forEach(b => b.classList.toggle('active', b.dataset.step === String(n)))
  912. document.querySelectorAll('.panel').forEach(p => p.classList.toggle('active', p.id === 'panel-' + n))
  913. if (n === 2) renderPlanTables()
  914. if (n === 3) renderArchiveTables()
  915. window.scrollTo({ top: 0, behavior: 'smooth' })
  916. }
  917. // Events
  918. document.querySelectorAll('.step-btn').forEach(b => b.addEventListener('click', () => gotoStep(b.dataset.step)))
  919. document.querySelectorAll('[data-goto]').forEach(b => b.addEventListener('click', () => gotoStep(b.dataset.goto)))
  920. document.querySelectorAll('[data-close]').forEach(b => b.addEventListener('click', () => closeMask(b.dataset.close)))
  921. document.getElementById('btn-pick-product-load').addEventListener('click', () => openPick('load'))
  922. document.getElementById('btn-pick-product-delivery').addEventListener('click', () => openPick('delivery'))
  923. document.getElementById('btn-choose-caustic').addEventListener('click', openSplit)
  924. document.getElementById('btn-confirm-split').addEventListener('click', confirmSplit)
  925. document.getElementById('btn-sync').addEventListener('click', syncDeliveryFromLoad)
  926. document.getElementById('btn-confirm-archive').addEventListener('click', confirmArchive)
  927. document.getElementById('btn-to-archive').addEventListener('click', () => {
  928. if (!loadRows.length && !deliveryRows.length) {
  929. toast('建议先点「添加产品」走一遍拆行(也可直接进归档看空态)')
  930. }
  931. })
  932. document.getElementById('btn-add-dup').addEventListener('click', () => {
  933. const n = lines.filter(l => l.contractId === 'HT-A' && l.productId === PRODUCT.id).length + 1
  934. lines.push({
  935. id: 'cp-a' + (10 + n),
  936. contractId: 'HT-A',
  937. productId: PRODUCT.id,
  938. name: PRODUCT.name,
  939. pkg: PRODUCT.pkg,
  940. qty: 20,
  941. price: 2400 + n * 50,
  942. arranged: 0,
  943. note: '演示追加 · 又一同品同包装单价'
  944. })
  945. renderContractTables()
  946. toast('已追加同品同包装行(现网会被校验拦住)')
  947. })
  948. // click mask backdrop to close
  949. ;['mask-pick', 'mask-split'].forEach(id => {
  950. document.getElementById(id).addEventListener('click', e => {
  951. if (e.target.id === id) closeMask(id)
  952. })
  953. })
  954. renderContractTables()
  955. renderPlanTables()
  956. </script>
  957. </body>
  958. </html>