Files
shanxia2.0/index(2).html
T
2026-10-15 08:50:25 +08:00

11018 lines
630 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>山峡代工一体化平台</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=Noto+Sans+SC:wght@400;500;600;700&display=swap" rel="stylesheet" />
<style>
:root {
--green: #167c4a;
--green-dark: #0f5e38;
--green-soft: #e8f6ee;
--green-line: #b7dfc8;
--blue: #2a6bb3;
--blue-soft: #e8f1fb;
--blue-line: #b7cce8;
--ink: #1c2430;
--muted: #5b6675;
--line: #e6ebe7;
--paper: #f4f7f5;
--white: #ffffff;
--warn: #d94841;
--warn-soft: #fdecea;
--amber: #c47d11;
--amber-soft: #fff6e5;
--sidebar: 232px;
--header: 56px;
--radius: 12px;
--shadow: 0 1px 2px rgba(28, 36, 48, 0.04), 0 8px 24px rgba(28, 36, 48, 0.04);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
font-family: "PingFang SC", "Hiragino Sans GB", "Noto Sans SC", "Microsoft YaHei", sans-serif;
color: var(--ink); background: var(--paper); font-size: 13px; line-height: 1.5;
}
button, input, select, textarea { font-family: inherit; font-size: 13px; }
button { cursor: pointer; border: 0; background: none; }
.app {
display: grid;
grid-template-columns: var(--sidebar) 1fr;
min-height: 100vh;
}
.sidebar {
background: var(--white);
border-right: 1px solid var(--line);
display: flex;
flex-direction: column;
position: sticky;
top: 0;
height: 100vh;
}
.brand {
height: var(--header);
display: flex;
align-items: center;
gap: 10px;
padding: 0 18px;
border-bottom: 1px solid var(--line);
min-width: 0;
}
.logo {
width: 28px; height: 28px; border-radius: 8px; flex: none;
background: linear-gradient(160deg, #22a05d, var(--green-dark));
display: grid; place-items: center; color: #fff; font-weight: 700; font-size: 13px;
}
.brand strong { font-size: 13px; letter-spacing: 0.02em; font-weight: 700; }
.brand .sub { display: block; font-size: 11px; color: var(--muted); font-weight: 400; margin-top: 1px; }
.nav { padding: 12px 10px; overflow: auto; flex: 1; }
.nav-label {
font-size: 11px; color: #8a94a0; letter-spacing: 0.08em;
padding: 10px 12px 6px; text-transform: uppercase;
}
.side-item {
display: flex; align-items: center; gap: 10px;
padding: 9px 12px; margin-bottom: 2px;
border-radius: 8px; color: #3d4754; width: 100%; text-align: left; font-size: 13.5px;
}
.side-item svg { width: 16px; height: 16px; flex: none; stroke: currentColor; fill: none; stroke-width: 1.8; }
.side-item:hover { background: #f3f6f4; }
.side-item.active { background: var(--green-soft); color: var(--green-dark); font-weight: 600; }
.side-dot { display: none; }
.badge-n {
margin-left: auto; min-width: 18px; height: 18px; padding: 0 5px;
border-radius: 9px; background: var(--warn); color: #fff;
font-size: 10px; display: grid; place-items: center; font-weight: 600;
}
.tag {
margin-left: auto; font-size: 10px; color: var(--green); background: var(--green-soft);
border-radius: 4px; padding: 1px 5px; font-weight: 600;
}
.nav-group { margin-bottom: 2px; }
.nav-group > .side-item .chev {
margin-left: auto; width: 14px; height: 14px; opacity: .45;
transition: transform .15s; flex: none;
}
.nav-group.open > .side-item .chev { transform: rotate(90deg); }
.nav-sub { display: none; padding: 0 0 6px; }
.nav-group.open > .nav-sub { display: block; }
.side-sub { padding: 8px 12px 8px 38px; font-size: 13px; margin-bottom: 1px; }
.coming {
background: var(--white); border: 1px solid var(--line); border-radius: var(--radius);
box-shadow: var(--shadow); padding: 56px 24px; text-align: center;
}
.coming h2 { font-size: 16px; margin-bottom: 8px; }
.coming p { color: var(--muted); font-size: 13px; }
.main { min-width: 0; overflow: visible; }
.topbar {
height: var(--header);
background: var(--white);
border-bottom: 1px solid var(--line);
display: flex; align-items: center; gap: 16px;
padding: 0 28px; position: sticky; top: 0; z-index: 5;
}
.crumb { font-size: 13px; color: var(--muted); }
.crumb b { color: var(--ink); font-weight: 600; }
.search {
margin-left: auto; display: flex; align-items: center; gap: 8px;
background: var(--paper); border: 1px solid var(--line);
border-radius: 8px; padding: 6px 10px; width: 260px;
}
.search input, .top-search {
border: 0; outline: 0; background: transparent; width: 100%; font-size: 13px; color: var(--ink);
}
.search input::placeholder, .top-search::placeholder { color: #8a94a0; }
.user {
display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--muted); flex-shrink: 0;
}
.avatar {
width: 28px; height: 28px; border-radius: 50%;
background: #d7eee0; color: var(--green-dark);
display: grid; place-items: center; font-weight: 700; font-size: 12px;
}
.page { padding: 24px 28px 40px; overflow: visible; }
.subnav { display: flex; gap: 4px; background: var(--white); border: 1px solid var(--line); border-radius: 10px; padding: 4px; margin-bottom: 18px; box-shadow: var(--shadow); flex-wrap: wrap; }
#mod-fleet > .subnav { display: none; }
.subnav button { height: 32px; padding: 0 12px; border-radius: 8px; color: #4b5563; font-weight: 500; white-space: nowrap; }
.subnav button.on { background: var(--green-soft); color: var(--green-dark); font-weight: 650; }
.page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 18px; flex-wrap: wrap; }
.page-head h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.02em; }
.page-head p { color: var(--muted); margin-top: 6px; font-size: 13px; }
.actions { display: flex; gap: 8px; flex-wrap: wrap; }
.btn { height: 32px; padding: 0 12px; border-radius: 8px; font-weight: 500; border: 1px solid var(--line); background: var(--white); }
.btn:hover { border-color: var(--green-line); }
.btn-primary { background: var(--green); color: #fff; border-color: var(--green); }
.btn-primary:hover { background: var(--green-dark); }
.btn-sm { height: 26px; padding: 0 8px; font-size: 12px; }
.alerts { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
.alert { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 8px; background: var(--amber-soft); color: var(--amber); border: 1px solid #f3ddb0; font-size: 12px; }
.alert.red { background: var(--warn-soft); color: var(--warn); border-color: #f5c5c2; }
.alert.ok { background: var(--green-soft); color: var(--green-dark); border-color: var(--green-line); }
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 22px; }
.stats.five { grid-template-columns: repeat(5, 1fr); }
.stats.three { grid-template-columns: repeat(3, 1fr); }
.stat { background: var(--white); border: 1px solid var(--line); border-radius: var(--radius); padding: 14px 16px; box-shadow: var(--shadow); }
.stat .k { color: var(--muted); font-size: 12px; }
.stat .v { font-size: 26px; font-weight: 700; margin: 4px 0 2px; letter-spacing: -.03em; }
#taskStats .stat .k {
display: flex;
align-items: baseline;
gap: 10px;
white-space: nowrap;
}
#taskStats .stat .k .stat-name {
font-weight: 650;
font-size: 13px;
}
#taskStats .stat .k .stat-name.wait { color: var(--amber); }
#taskStats .stat .k .stat-name.run { color: var(--blue); }
#taskStats .stat .k .stat-name.done { color: var(--green); }
#taskStats .stat .k .stat-name.all { color: var(--ink); }
#taskStats .stat .k .kinds { font-weight: 400; color: var(--muted); }
#taskStats .stat .v { font-size: 22px; letter-spacing: 0; white-space: nowrap; margin-bottom: 0; }
#taskStats .stat .v i,
#taskStats .stat .k .kinds i {
font-style: normal;
font-weight: 400;
color: #b4bcc6;
margin: 0 .42em;
vertical-align: .12em;
}
#taskStats .stat .v i { font-size: 12px; }
#taskStats .stat .k .kinds i { font-size: 10px; }
.stat .s { font-size: 12px; color: var(--muted); margin-top: 0; }
.plan-n-link {
display: inline-flex; align-items: center; height: 22px; padding: 0 8px;
border-radius: 6px; border: 1px solid var(--green-line); background: var(--green-soft);
color: var(--green-dark); font-weight: 650; font-size: 12px; cursor: pointer;
}
.plan-n-link:hover { background: #d7eee0; }
.plan-n-link.off { background: #f3f4f6; border-color: #e5e7eb; color: #6b7280; }
#vehPlanBody tfoot td {
font-weight: 700; background: #f7faf8; border-top: 1px solid var(--line);
}
.grid { display: grid; grid-template-columns: 1.35fr .9fr; gap: 12px; align-items: start; }
.stack { display: flex; flex-direction: column; gap: 12px; }
.card { background: var(--white); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }
.card-h { display: flex; align-items: center; justify-content: space-between; padding: 12px 14px; border-bottom: 1px solid var(--line); }
.card-h h2 { font-size: 14px; font-weight: 650; }
.card-b { padding: 12px 14px; }
.quick { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.qbtn {
text-align: left; border: 1px solid var(--line); border-radius: var(--radius);
padding: 14px; background: var(--white); min-height: 88px;
box-shadow: var(--shadow); transition: border-color .15s, transform .15s;
}
.qbtn:hover { border-color: var(--green-line); transform: translateY(-1px); background: var(--white); }
.qbtn .ico {
width: 40px; height: 40px; border-radius: 10px; display: grid; place-items: center;
margin-bottom: 8px; font-weight: 700; font-size: 12px;
background: var(--green-soft); color: var(--green-dark);
}
.qbtn h3 { font-size: 13.5px; font-weight: 650; }
.qbtn p { color: var(--muted); font-size: 12px; margin-top: 4px; line-height: 1.45; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 8px 6px; border-bottom: 1px solid #eef1f4; font-size: 12px; }
th { color: var(--muted); font-weight: 500; }
tr:hover td { background: #f8faf9; }
.st { display: inline-flex; align-items: center; height: 20px; padding: 0 7px; border-radius: 99px; font-size: 11px; font-weight: 600; }
.st-run { background: var(--green-soft); color: var(--green-dark); }
.st-fix { background: #fff7ed; color: var(--amber); }
.st-stop { background: #f3f4f6; color: #4b5563; }
.st-due { background: #fef2f2; color: #b91c1c; }
.timeline { display: flex; flex-direction: column; gap: 0; }
.tl { display: grid; grid-template-columns: 72px 1fr; gap: 8px; padding: 8px 0; border-bottom: 1px solid #eef1f4; }
.tl:last-child { border: 0; }
.tl .t { color: var(--muted); font-size: 11px; }
.tl b { font-weight: 600; }
.tl .d { color: var(--muted); }
.bars { display: flex; flex-direction: column; gap: 8px; }
.bar-row { display: grid; grid-template-columns: 88px 1fr 64px; gap: 8px; align-items: center; }
.bar-track { height: 8px; background: #eef1f4; border-radius: 99px; overflow: visible; position: relative; }
.bar-fill { height: 100%; background: linear-gradient(90deg, #167c4a, #22a05d); border-radius: 99px; }
.bar-fill.warn { background: linear-gradient(90deg, #e24b4a, #f97316); }
.bar-mark { position: absolute; top: -3px; height: 14px; width: 2px; background: var(--warn); border-radius: 1px; }
.bar-row .n { color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; }
.rate-over { color: #b91c1c; font-weight: 650; }
.filters { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
.filters input, .filters select, .form input, .form select, .form textarea {
height: 32px; border: 1px solid var(--line); border-radius: 8px; padding: 0 10px; background: #fff; outline: none;
}
.form textarea { height: 64px; padding: 8px 10px; resize: vertical; }
.form { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.form label { display: flex; flex-direction: column; gap: 4px; color: var(--muted); font-size: 12px; }
.form .full { grid-column: 1 / -1; }
.form .span2 { grid-column: span 2; }
.fuel-plate-grid {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 16px;
}
.fuel-plate-card {
background: var(--white); border: 1px solid var(--line); border-radius: var(--radius);
padding: 14px 16px; box-shadow: var(--shadow); min-width: 0; cursor: pointer;
}
.fuel-plate-card:hover { border-color: var(--blue); }
.fuel-plate-card .plate { font-size: 15px; font-weight: 700; color: var(--ink); letter-spacing: .01em; white-space: nowrap; }
.fuel-plate-card .meta { color: var(--muted); font-size: 12px; margin: 6px 0 10px; }
.fuel-plate-card .nums { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
.fuel-plate-card .nums.four { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; }
.fuel-plate-card .nums.four b { font-size: 16px; }
.fuel-plate-card .nums b { display: block; font-size: 18px; font-weight: 700; letter-spacing: -.03em; }
.fuel-plate-card .nums span { color: var(--muted); font-size: 11px; }
.fuel-plate-card .mom { display: block; font-size: 11px; font-style: normal; font-weight: 650; margin-top: 2px; }
.fuel-plate-card .mom.up { color: #c2410c; }
.fuel-plate-card .mom.down { color: var(--green-dark); }
.fuel-plate-card .mom.flat { color: var(--muted); font-weight: 500; }
@media (max-width: 1200px) { .fuel-plate-grid { grid-template-columns: repeat(2, 1fr); } }
.file-box {
border: 1px dashed var(--green-line); border-radius: 8px; background: #f7fbf8;
padding: 8px 10px; min-height: 64px; display: flex; flex-direction: column; gap: 6px;
}
.file-box input[type=file] { height: auto; padding: 0; border: 0; background: transparent; }
.file-hint { color: var(--amber); font-size: 11px; }
.task-photo-row { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 10px; }
.task-photo-up { width: 132px; height: 96px; flex-shrink: 0; }
.task-photo-thumbs { display: flex; flex-wrap: wrap; gap: 8px; }
.task-photo-thumbs button { padding: 0; border: 0; background: transparent; cursor: pointer; }
.task-photo-thumbs img {
width: 88px; height: 88px; object-fit: cover; border-radius: 8px;
border: 1px solid var(--line); display: block;
}
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip {
display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: 99px;
background: var(--green-soft); color: var(--green-dark); font-size: 11px; font-weight: 600; border: 0;
}
.chip.ghost { background: #f3f4f6; color: #4b5563; cursor: default; }
.chip:disabled { opacity: .65; cursor: default; }
dialog {
border: 1px solid var(--line); border-radius: 12px; padding: 0; box-shadow: var(--shadow);
max-width: min(720px, 92vw); width: 720px;
}
dialog.apply-dlg { max-width: min(760px, 94vw); width: 760px; }
dialog.apply-dlg.wide-dlg { max-width: min(920px, 96vw); width: 920px; }
.task-line-wrap { overflow: auto; border: 1px solid var(--line); border-radius: 8px; }
.task-line-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.task-line-table th, .task-line-table td { border-bottom: 1px solid #eef1f4; padding: 7px 8px; text-align: left; }
.task-line-table thead th { background: #f7faf8; color: #5b6675; font-weight: 600; }
.task-sheet {
background: #fff; border: 1px solid #222; padding: 16px 18px 12px; font-size: 13px; color: #111;
}
.task-sheet h3 { text-align: center; font-size: 20px; letter-spacing: .18em; margin: 0 0 6px; font-weight: 700; }
.task-sheet .ts-no { text-align: right; font-size: 12px; margin: 0 2px 6px; }
.task-sheet table { width: 100%; border-collapse: collapse; table-layout: fixed; }
.task-sheet td, .task-sheet th { border: 1px solid #333; padding: 7px 8px; vertical-align: middle; }
.task-sheet th { text-align: center; font-weight: 650; background: #fff; }
.task-sheet td.idx, .task-sheet td.center { text-align: center; }
.task-sheet .ts-foot { text-align: center; letter-spacing: .08em; }
.task-sheet .ts-checks { display: inline-flex; align-items: center; gap: 14px; margin-left: 16px; }
.task-sheet .ts-box {
display: inline-block; width: 12px; height: 12px; border: 1px solid #333;
margin-right: 4px; vertical-align: -1px; background: #fff;
}
.task-sheet .ts-box.on { background: #111; }
dialog::backdrop { background: rgba(28, 36, 48, .35); }
.dlg-h { display: flex; align-items: center; justify-content: space-between; padding: 12px 14px; border-bottom: 1px solid var(--line); }
.dlg-h.green { background: var(--green-soft); }
.dlg-h h3 { font-size: 14px; }
.dlg-x { width: 28px; height: 28px; border-radius: 6px; color: #5b6572; font-size: 18px; line-height: 1; }
.dlg-x:hover { background: #e4eee7; }
.dlg-b { padding: 14px; max-height: 70vh; overflow: auto; }
.dlg-b img { max-width: 100%; border-radius: 8px; display: block; }
.dlg-f { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--line); }
.apply { display: grid; grid-template-columns: 1fr 1fr; gap: 16px 28px; }
.apply .row { display: grid; grid-template-columns: 88px 1fr; gap: 10px; align-items: start; }
.apply .full { grid-column: 1 / -1; }
.apply .lab { color: var(--ink); padding-top: 7px; text-align: right; }
.apply input, .apply select, .apply textarea {
width: 100%; height: 32px; border: 1px solid var(--line); border-radius: 8px; padding: 0 10px; background: #fff; outline: none;
}
.apply textarea { height: 88px; padding: 8px 10px; resize: vertical; }
.star { color: #e24b4a; margin-right: 2px; }
.stepper { display: flex; height: 32px; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; background: #fff; }
.stepper button { width: 36px; background: #f7f8fa; color: #6a7380; }
.stepper button:hover { background: #eef1f4; }
.stepper input { flex: 1; border: 0; border-radius: 0; border-left: 1px solid var(--line); border-right: 1px solid var(--line); text-align: center; height: 32px; width: auto; }
.upload {
width: 132px; height: 108px; border: 1px dashed var(--green-line); border-radius: 10px;
background: #f4faf6; display: flex; flex-direction: column; align-items: center; justify-content: center;
color: var(--green); gap: 4px; cursor: pointer; font-weight: 500;
}
.ocr-box .upload { width: 100%; height: 72px; }
.upload:hover { background: #ebf7ef; }
.upload input { display: none; }
.upload .plus { font-size: 22px; line-height: 1; font-weight: 400; }
.hint { color: var(--muted); font-size: 11px; margin-top: 8px; }
.hidden { display: none !important; }
.toast {
position: fixed; right: 20px; bottom: 20px; background: #1c2430; color: #fff;
padding: 10px 14px; border-radius: 8px; display: none; z-index: 50;
}
.toast.show { display: block; }
.pie-wrap { display: flex; align-items: center; gap: 16px; }
.legend { display: flex; flex-direction: column; gap: 6px; }
.legend span { display: flex; align-items: center; gap: 6px; }
.sw { width: 8px; height: 8px; border-radius: 2px; }
.cell-no { color: var(--green-dark); font-weight: 600; }
.muted { color: var(--muted); }
.row-link { cursor: pointer; }
.need-tag { display: inline-flex; height: 18px; padding: 0 6px; border-radius: 4px; background: #fef2f2; color: #b91c1c; font-size: 10px; font-weight: 700; margin-left: 4px; }
.need-tag.bao { background: #fff7ed; color: var(--amber); }
.remain { font-variant-numeric: tabular-nums; font-weight: 650; }
.remain.warn { color: var(--amber); }
.remain.bad { color: #b91c1c; }
.ocr-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 12px; }
.ocr-box {
border: 1px dashed var(--green-line); border-radius: 12px; background: #f7fbf8; padding: 12px;
min-height: 148px; display: flex; flex-direction: column; gap: 8px;
}
.ocr-box.buy { border-color: var(--green-line); background: #f4faf6; }
.ocr-box.trans { border-color: #b7cce8; background: #f3f7fc; }
.ocr-box.drag { outline: 2px dashed var(--green); outline-offset: 2px; }
.ocr-box.trans.drag { outline-color: var(--blue); }
.ocr-box.buy h4 { color: var(--green-dark); }
.ocr-box.trans h4 { color: var(--blue); }
.ocr-box.trans .upload { border-color: #b7cce8; background: #eef4fb; color: var(--blue); }
.ocr-box.trans .upload:hover { background: #e4eef8; }
.split-h .col-buy h4 { color: var(--green-dark); border-bottom-color: var(--green-line); }
.split-h .col-trans h4 { color: var(--blue); border-bottom-color: #c5d6ee; }
dialog:not([open]) { display: none; }
.ocr-box h4 { font-size: 13px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.ocr-preview { width: 100%; max-height: 120px; object-fit: contain; border-radius: 8px; background: #fff; border: 1px solid var(--line); }
.ocr-status { font-size: 12px; color: var(--amber); min-height: 18px; }
.ocr-status.ok { color: var(--green-dark); }
.split-h { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 4px; }
.split-h h4 { font-size: 13px; margin-bottom: 8px; padding-bottom: 6px; border-bottom: 1px solid var(--line); }
.kv { display: grid; grid-template-columns: 108px 1fr; gap: 6px 10px; font-size: 12px; }
.kv .lab { color: var(--muted); text-align: right; }
.kv .val { font-weight: 500; }
.sheet { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.sheet .full-card { grid-column: 1 / -1; }
.cert-duo {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 3fr);
gap: 16px;
align-items: stretch;
width: 100%;
margin-top: 24px;
}
.cert-pane.buy .kv { grid-template-columns: 72px 1fr; }
.cert-thumb {
display: block; width: 100%; margin-top: 12px; padding: 6px;
border: 1px solid var(--line); border-radius: 8px; background: #fff;
cursor: zoom-in; text-align: center;
}
.cert-thumb:hover { border-color: var(--green-line); }
.cert-pane.trans .cert-thumb:hover { border-color: var(--blue-line); }
.cert-thumb img {
display: block; width: 100%; max-height: 140px; object-fit: contain; margin: 0 auto;
}
dialog.cert-img-dlg {
max-width: min(1100px, 96vw); width: auto; background: transparent;
border: 0; box-shadow: none; padding: 0;
}
dialog.cert-img-dlg img {
display: block; max-width: 94vw; max-height: 88vh; object-fit: contain;
border-radius: 10px; background: #111; box-shadow: 0 16px 48px rgba(0,0,0,.35);
}
dialog.cert-img-dlg::backdrop { background: rgba(28, 36, 48, .72); }
.cert-pane {
border-radius: var(--radius);
padding: 10px 10px 12px;
min-width: 0;
display: flex;
flex-direction: column;
}
.cert-pane > .card { flex: 1; }
.cert-pane.buy {
background: linear-gradient(180deg, #e8f6ee 0%, #f4faf6 48px, #f7fbf8 100%);
border: 1px solid var(--green-line);
}
.cert-pane.trans {
background: linear-gradient(180deg, var(--blue-soft) 0%, #f3f7fc 48px, #f6f9fd 100%);
border: 1px solid var(--blue-line);
}
.trans-card-row {
display: flex;
gap: 12px;
align-items: stretch;
min-width: 0;
}
.trans-card-row > .card {
flex: 1 1 0;
min-width: 0;
margin: 0;
}
.trans-card-row .kv { grid-template-columns: 76px 1fr; }
.cert-pane-h {
display: flex; align-items: center; gap: 8px;
padding: 4px 6px 10px;
}
.cert-pane-h h2 { font-size: 14px; font-weight: 650; }
.cert-pane-h .muted { margin-left: auto; font-size: 12px; }
.cert-tag {
display: inline-flex; align-items: center; height: 22px; padding: 0 8px;
border-radius: 6px; font-size: 11px; font-weight: 700; letter-spacing: 0.04em;
}
.cert-tag.buy { background: var(--green); color: #fff; }
.cert-tag.trans { background: var(--blue); color: #fff; }
.cert-pane.buy .card { border-color: var(--green-line); }
.cert-pane.trans .card { border-color: var(--blue-line); }
.cert-pane .card-h { background: #fff; }
.cert-pane.buy .card-h { border-bottom-color: var(--green-line); }
.cert-pane.trans .card-h { border-bottom-color: var(--blue-line); }
.stat.tone-buy { border-color: var(--green-line); box-shadow: none; }
.stat.tone-buy .k { color: var(--green-dark); }
.stat.tone-trans { border-color: var(--blue-line); box-shadow: none; }
.stat.tone-trans .k { color: var(--blue); }
.qty-bar { height: 8px; background: #eef1f4; border-radius: 99px; overflow: hidden; margin-top: 8px; }
.qty-fill { height: 100%; background: linear-gradient(90deg, #167c4a, #22a05d); }
.qty-fill.warn { background: linear-gradient(90deg, #e24b4a, #f97316); }
.warn-qty-table { width: 100%; border-collapse: collapse; }
.warn-qty-table th, .warn-qty-table td {
padding: 8px 10px; font-size: 12px; text-align: left; border-bottom: 1px solid #eef1f4; vertical-align: middle;
}
.warn-qty-table thead th { color: #5b6675; font-weight: 600; background: #f7faf8; }
.warn-qty-edit { display: inline-flex; align-items: center; gap: 6px; }
.warn-qty-edit input {
width: 88px; height: 28px; border: 1px solid var(--line); border-radius: 6px; padding: 0 8px;
}
.erp-page { margin-top: -6px; }
.rel-flow {
display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center;
font-size: 12.5px; color: var(--muted); margin: 0 0 12px; padding: 8px 12px;
background: #fff; border: 1px dashed var(--green-line); border-radius: 10px;
}
.rel-flow b { color: var(--ink); font-weight: 650; }
.rel-flow em { font-style: normal; color: var(--green); font-weight: 650; }
.cert-steps {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 12px;
}
.cert-step {
background: #fff; border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px;
display: flex; gap: 10px; align-items: flex-start;
}
.cert-step .n {
width: 22px; height: 22px; border-radius: 99px; flex: none;
background: var(--green-soft); color: var(--green-dark);
display: grid; place-items: center; font-weight: 750; font-size: 12px;
}
.cert-step { cursor: pointer; }
.cert-step:hover { border-color: var(--green-line); }
.cert-step.on { border-color: var(--green); background: #f4faf6; }
.cert-step.warn { border-color: #fdba74; background: #fff7ed; }
.cert-step.warn .n { background: #fff7ed; color: #9a3412; }
.cert-step b { display: block; font-size: 13px; }
.cert-step p { color: var(--muted); font-size: 12px; margin-top: 2px; line-height: 1.45; }
.cert-step .s { margin-top: 4px; font-size: 12px; color: var(--ink); font-weight: 650; }
@media (max-width: 980px) { .cert-steps { grid-template-columns: 1fr; } }
.extra-trans {
margin-top: 12px; padding: 12px; border: 1px dashed #b7cce8; border-radius: 10px; background: #f6f9fd;
}
.extra-trans.drag { outline: 2px dashed var(--blue); outline-offset: 2px; }
.extra-trans h4 { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.veh-over td { background: #fff5f5; }
.warn-dot { color: #b91c1c; font-weight: 700; }
.veh-board { overflow: auto; }
.link-preview {
margin: 0 0 12px; padding: 10px 12px; border-radius: 10px;
background: #f4faf6; border: 1px solid var(--green-line); font-size: 13px; line-height: 1.55;
}
.link-preview.warn { background: #fff7ed; border-color: #fdba74; }
.link-preview.over { background: #fef2f2; border-color: #fecaca; color: #9f1239; }
.stat.stat-click { cursor: pointer; }
.stat.stat-click:hover { border-color: var(--green-line); }
.wait-link-box { margin-bottom: 12px; }
.wait-link-box .card-h { align-items: flex-start; flex-wrap: wrap; }
.wait-link-box .card-h h2 { flex: none; white-space: nowrap; }
.wait-link-box .card-h .muted { flex: 1; min-width: 180px; }
.match-rec { display: inline-flex; align-items: center; height: 18px; padding: 0 6px; border-radius: 4px; background: var(--green-soft); color: var(--green-dark); font-size: 10px; font-weight: 700; white-space: nowrap; }
.match-rec.veh { background: #eef4fb; color: var(--blue); }
.match-rec.warn { background: #fff7ed; color: var(--amber); }
.match-rec.in { background: #eef4fb; color: var(--blue); }
.match-rec.out { background: var(--green-soft); color: var(--green-dark); }
.match-rec.direct { background: #f5f0ff; color: #6d28d9; }
#certWaitCard { overflow: visible; }
.wait-scene-tabs { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
.wait-table-wrap { width: 100%; overflow-x: auto; overflow-y: visible; max-height: none; border: 1px solid var(--line); border-radius: 8px; position: relative; z-index: 2; box-sizing: border-box; }
.wait-table, table.erp-table.wait-table { width: 100%; min-width: 100%; border-collapse: collapse; }
.wait-table th, .wait-table td {
padding: 8px 10px; font-size: 12px; vertical-align: middle;
white-space: nowrap; border-bottom: 1px solid #eef1f4; background: #fff;
}
.wait-table thead th {
position: sticky; top: 0; z-index: 1; background: #f7faf8;
color: #5b6675; font-weight: 600;
}
.wait-table tbody tr:hover td { background: #f8faf9; }
.wait-table tr.warn-row td { background: #fffdf8; }
.wait-table tr.over-row td { background: #fff8f8; }
.wait-table .cell-name, .wait-table td.wrap {
white-space: normal; max-width: 140px; line-height: 1.4;
}
.wait-table .cell-remain, .wait-table td.remain-cell {
white-space: normal; min-width: 140px; max-width: 200px; line-height: 1.4;
}
.wait-table select {
display: block; height: 28px; min-width: 148px; max-width: 210px;
border: 1px solid var(--line); border-radius: 6px; padding: 0 6px; background: #fff;
}
.wait-table .hint { margin-top: 4px; }
.wait-table .op, .wait-table td.ops { white-space: nowrap; }
.erp-table.wait-table .idx,
.erp-table.wait-table .col-no {
position: static; left: auto; box-shadow: none;
}
.erp-table.wait-table .col-op-buy,
.erp-table.wait-table .col-op-trans {
position: sticky; right: auto; min-width: 88px; z-index: 5; text-align: center;
}
.erp-table.wait-table .col-op-buy { left: 0; }
.erp-table.wait-table .col-op-trans { left: 88px; box-shadow: 6px 0 8px -6px rgba(28,36,48,.12); }
.erp-table.wait-table thead .col-op-buy,
.erp-table.wait-table thead .col-op-trans { z-index: 6; background: #f7faf8; }
.erp-table.wait-table tbody .col-op-buy,
.erp-table.wait-table tbody .col-op-trans { background: #fff; }
.wait-table .col-op-buy .btn,
.wait-table .col-op-trans .btn { margin: 0; width: 100%; white-space: nowrap; }
.wait-table .clip { max-width: none; overflow: visible; text-overflow: unset; }
.wait-table th.col-ship,
.wait-table td.col-ship {
white-space: normal; max-width: none; min-width: 196px; width: 18%; line-height: 1.4; overflow: visible;
}
.wait-op-no { font-size: 11px; color: var(--muted); line-height: 1.3; word-break: break-all; }
.btn.wait-btn-buy {
background: #e8f6ee; border-color: var(--green-line); color: var(--green-dark);
}
.btn.wait-btn-buy:hover { background: #d7eee0; }
.btn.wait-btn-trans {
background: #eef4fb; border-color: var(--blue-line); color: var(--blue);
}
.btn.wait-btn-trans:hover { background: #e0ebf7; }
.btn.wait-btn-buy.wait-on,
.btn.wait-btn-trans.wait-on {
background: #f3f4f6; border-color: #e5e7eb; color: #6b7280; font-weight: 500;
}
.btn.wait-btn-buy.wait-on:hover,
.btn.wait-btn-trans.wait-on:hover { background: #eaecef; border-color: #d1d5db; color: #4b5563; }
dialog.wait-link-dlg { max-width: min(1180px, 96vw); width: 1180px; }
.wait-link-dlg .dlg-b { overflow: auto; }
.wait-link-dlg .cert-pane {
margin-bottom: 0; overflow: visible; min-width: 0; height: auto;
box-sizing: border-box;
}
.wait-link-dlg .cert-pane > .card,
.wait-link-dlg .wait-trans-sheet {
margin: 0; box-shadow: none; min-width: 0; flex: none;
overflow: visible; border-radius: 10px; box-sizing: border-box;
}
.wait-link-dlg .wait-trans-sheet + .wait-trans-sheet { margin-top: 12px; }
.wait-link-dlg .wait-trans-sheet.hit { border-color: var(--blue-line); }
.wait-link-dlg .qty-bar { margin-bottom: 2px; }
.wait-link-dlg .wait-cert-pick {
display: flex; align-items: center; gap: 8px; cursor: pointer; min-width: 0;
}
.wait-link-dlg .wait-cert-pick input { margin: 0; }
.wait-link-dlg .card-h .btn { margin-left: auto; }
.wait-link-dlg .kv .val { overflow-wrap: anywhere; word-break: break-word; }
.wait-plan-bar {
display: flex; flex-wrap: wrap; gap: 6px 16px; align-items: center;
padding: 10px 12px; margin-bottom: 12px;
background: #f7faf8; border: 1px solid var(--green-line); border-radius: 10px; font-size: 12px;
}
.wait-plan-bar b { font-weight: 650; }
.wait-dlg-split { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; align-items: stretch; }
.wait-dlg-split > * { min-width: 0; }
@media (max-width: 900px) { .wait-dlg-split { grid-template-columns: 1fr; } }
.wait-info-card {
border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; background: #fff; margin-bottom: 10px;
}
.wait-info-card.buy { border-color: var(--green-line); background: #f7fbf8; }
.wait-info-card.trans { border-color: #c5d6ee; background: #f6f9fd; }
.wait-info-card.hit { box-shadow: inset 0 0 0 1px var(--green); }
.wait-info-card h4 {
display: flex; align-items: center; gap: 8px; margin-bottom: 8px; font-size: 13px;
}
.wait-info-card .kv { grid-template-columns: 78px 1fr 78px 1fr; gap: 4px 8px; }
.wait-info-stack { max-height: 56vh; overflow: auto; padding: 8px 6px; min-width: 0; }
.wait-scene-tabs button {
height: 26px; padding: 0 10px; border-radius: 6px; border: 1px solid var(--line); background: #fff; color: #4b5563;
}
.wait-scene-tabs button.on { background: var(--green-soft); color: var(--green-dark); border-color: var(--green-line); font-weight: 650; }
.qty-formula {
display: flex; flex-wrap: wrap; gap: 8px 18px; margin-bottom: 12px;
padding: 10px 12px; background: #f7faf8; border: 1px dashed var(--green-line); border-radius: 10px; font-size: 13px;
}
.qty-formula b { font-variant-numeric: tabular-nums; }
.qty-formula .bad { color: var(--warn); }
.qty-formula .warn { color: var(--amber); }
.link-sec h4 { font-size: 13px; margin: 4px 0 8px; }
.link-sec + .link-sec { margin-top: 14px; }
.ct-mini { font-size: 11px; color: var(--muted); line-height: 1.45; }
.erp-tabs-row {
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
border-bottom: 1px solid var(--line); margin-bottom: 8px;
}
.plan-tabs { display: flex; gap: 2px; flex-wrap: wrap; }
.plan-tabs button {
height: 36px; padding: 0 14px; border: 0; background: transparent;
color: #4b5563; font-weight: 500; border-bottom: 2px solid transparent; margin-bottom: -1px;
}
.plan-tabs button.on { color: var(--green); font-weight: 650; border-bottom-color: var(--green); }
.plan-table tr.row-wait td { background: #fff8f8; }
.plan-foot {
display: flex; justify-content: space-between; align-items: center;
padding: 8px 14px; border-top: 1px solid var(--green-line); background: #eaf6ef;
}
.erp-toolbar {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding: 4px 0 8px;
}
.erp-toolbar .btn { display: inline-flex; align-items: center; gap: 4px; }
.erp-toolbar .split { width: 1px; height: 16px; background: var(--line); margin: 0 2px; }
.erp-toolbar .right { margin-left: auto; display: flex; align-items: center; gap: 6px; }
.erp-toolbar input[type=search], .erp-toolbar input[type=text] {
height: 32px; border: 1px solid var(--line); border-radius: 8px; padding: 0 10px; width: 168px;
}
.erp-toolbar .btn.on { background: var(--green-soft); border-color: var(--green-line); color: var(--green-dark); }
.btn-danger { background: var(--warn); color: #fff; border-color: var(--warn); }
.btn-danger:hover { background: #c13b35; border-color: #c13b35; }
.plan-action-row { align-items: center; border-bottom: 0; margin-bottom: 0; gap: 10px; }
.plan-action-row .plan-tabs button { height: 32px; padding: 0 10px; }
.quote-search.plan-search { width: 280px; margin-left: 0; }
.plan-toolbar { padding: 6px 0 8px; }
#planKpis { margin-bottom: 12px; grid-template-columns: repeat(4, 1fr); }
#planKpis .stat {
width: 100%; text-align: left;
display: flex; flex-direction: column; align-items: flex-start; justify-content: center; gap: 10px;
min-height: 96px; padding: 18px 18px;
transition: border-color .15s, background .15s;
}
#planKpis .stat .k { color: var(--ink); font-size: 13px; font-weight: 650; white-space: nowrap; }
#planKpis .stat .v {
margin: 0; padding: 0; border: 0; background: none;
font-size: 28px; line-height: 1; font-weight: 700; letter-spacing: -.03em;
cursor: pointer; color: var(--amber);
}
#planKpis .stat .v:hover { opacity: .85; }
#planKpis .today-bits {
display: flex; flex-wrap: nowrap; align-items: baseline; gap: 14px;
white-space: nowrap;
}
#planKpis .today-bits button {
margin: 0; padding: 0; border: 0; background: none; cursor: pointer;
font-size: 13px; font-weight: 500; color: var(--muted); line-height: 1;
}
#planKpis .today-bits button b { font-size: 22px; font-weight: 700; color: var(--green); margin: 0 3px; }
#planKpis .today-bits button.open b { color: var(--amber); }
#planKpis .today-bits button:hover { color: var(--ink); }
#planKpis .today-bits button.on { color: var(--ink); }
#planKpis .stat.tone-ctrl .v { color: var(--warn); }
#planKpis .stat.tone-arch .v { color: var(--blue); }
#planKpis .stat.on.tone-wait { border-color: #e8c56b; background: var(--amber-soft); }
#planKpis .stat.on.tone-today { border-color: var(--green-line); background: var(--green-soft); }
#planKpis .stat.on.tone-ctrl { border-color: #f5c5c2; background: var(--warn-soft); }
#planKpis .stat.on.tone-arch { border-color: var(--blue-line); background: var(--blue-soft); }
.erp-hint { font-size: 12px; color: var(--muted); margin-left: auto; }
.erp-hint b { color: var(--ink); font-weight: 600; }
.erp-card { overflow: hidden; }
.erp-table-wrap { overflow: auto; max-height: calc(100vh - 228px); }
#quoteStats, #ctStats { display: block; margin-bottom: 12px; }
.wait-banner {
width: 100%; display: flex; align-items: center; gap: 10px;
padding: 12px 16px; text-align: left; cursor: pointer;
background: #fff; border: 1px solid #f3ddb0; border-radius: var(--radius);
box-shadow: var(--shadow);
transition: border-color .15s, background .15s, box-shadow .15s;
}
.wait-banner:hover { border-color: #e8c56b; background: var(--amber-soft); }
.wait-banner.on { border-color: #e8c56b; background: var(--amber-soft); }
.wait-banner.empty { border-color: var(--green-line); }
.wait-banner.empty:hover, .wait-banner.empty.on { background: var(--green-soft); border-color: var(--green); }
.wb-ico {
width: 36px; height: 36px; border-radius: 10px; flex: none;
display: grid; place-items: center; background: var(--amber-soft); color: var(--amber);
}
.wait-banner.empty .wb-ico { background: var(--green-soft); color: var(--green-dark); }
.wb-ico svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
.wb-n { font-size: 28px; font-weight: 700; letter-spacing: -.04em; color: var(--amber); line-height: 1; }
.wait-banner.empty .wb-n { color: var(--green-dark); }
.wb-k { font-size: 14px; font-weight: 700; color: var(--ink); line-height: 1.2; }
.wb-go {
margin-left: auto; flex: none; height: 28px; padding: 0 12px; border-radius: 999px;
font-size: 12px; font-weight: 650; display: grid; place-items: center;
background: var(--amber); color: #fff;
}
.wait-banner.empty .wb-go { background: var(--green); }
#mod-quote .erp-table-wrap, #mod-contract .erp-table-wrap { max-height: calc(100vh - 280px); }
table.erp-table { min-width: 1680px; border-collapse: separate; border-spacing: 0; }
.erp-table th, .erp-table td {
padding: 7px 8px; font-size: 12px; white-space: nowrap; vertical-align: middle;
border-bottom: 1px solid #eef1f4; background: #fff;
}
.erp-table thead th {
position: sticky; top: 0; z-index: 2; background: #f7faf8;
color: #5b6675; font-weight: 600; border-bottom: 1px solid var(--line);
}
.task-tag {
display: inline-flex; align-items: center; height: 20px; padding: 0 7px;
border-radius: 4px; font-size: 11px; font-weight: 650;
}
.task-tag.dilute { background: #e8f1fb; color: var(--blue); }
.task-tag.refine { background: #f3eefb; color: #7c5cbf; }
.task-tag.pack { background: #fff1e0; color: #c47d11; }
.erp-table .num { font-variant-numeric: tabular-nums; }
.erp-table .qty { color: var(--green); font-weight: 650; }
.erp-table .out-qty { color: var(--blue); }
.erp-table .chk, .erp-table .idx { width: 36px; text-align: center; color: var(--muted); padding-left: 8px; padding-right: 4px; }
.erp-table .chk { position: sticky; left: 0; z-index: 3; }
.erp-table .idx { position: sticky; left: 36px; z-index: 3; }
.erp-table .col-no { position: sticky; left: 68px; z-index: 3; min-width: 128px; box-shadow: 6px 0 8px -6px rgba(28,36,48,.12); }
.erp-table thead .chk, .erp-table thead .idx, .erp-table thead .col-no { z-index: 4; background: #f7faf8; }
.erp-table tbody .chk, .erp-table tbody .idx, .erp-table tbody .col-no { background: inherit; }
.erp-table .col-op {
position: sticky; right: 0; z-index: 3; min-width: 88px;
box-shadow: -6px 0 8px -6px rgba(28,36,48,.12);
white-space: nowrap;
}
.erp-table.task-ops .col-op { min-width: 168px; }
.erp-table thead .col-op { z-index: 4; background: #f7faf8; }
.erp-table tbody .col-op { background: inherit; }
.erp-table .clip { max-width: 148px; overflow: hidden; text-overflow: ellipsis; }
.erp-table.wait-table .idx,
.erp-table.wait-table .col-no {
position: static !important; left: auto !important; box-shadow: none;
}
.erp-table.wait-table .col-op-buy,
.erp-table.wait-table .col-op-trans {
position: sticky; right: auto; min-width: 88px; z-index: 8; text-align: center;
pointer-events: auto;
}
.erp-table.wait-table .col-op-buy { left: 0; }
.erp-table.wait-table .col-op-trans { left: 88px; box-shadow: 6px 0 8px -6px rgba(28,36,48,.12); }
.erp-table.wait-table thead .col-op-buy,
.erp-table.wait-table thead .col-op-trans { z-index: 9; background: #f7faf8; }
.erp-table.wait-table tbody .col-op-buy,
.erp-table.wait-table tbody .col-op-trans { background: #fff; }
.wait-table .col-op-buy .btn,
.wait-table .col-op-trans .btn {
position: relative; z-index: 9; pointer-events: auto; cursor: pointer;
}
.kind-tag {
display: inline-flex; align-items: center; height: 22px; padding: 0 8px;
border-radius: 4px; font-size: 12px; font-weight: 650;
}
.kind-tag.dilute { background: #eaf2fb; color: var(--blue); }
.kind-tag.refine { background: var(--green-soft); color: var(--green-dark); }
.kind-tag.pack { background: var(--amber-soft); color: var(--amber); }
.st-pill {
display: inline-flex; align-items: center; height: 22px; padding: 0 8px;
border-radius: 4px; font-size: 12px; font-weight: 600; color: #fff;
}
.st-pill.wait { background: #f59e0b; }
.st-pill.ok { background: #22a05d; }
.st-pill.done { background: #22a05d; }
.st-pill.fin { background: #2a6bb3; }
.st-pill.arch { background: #64748b; }
.wh-act { font-weight: 650; }
.wh-act.in { color: var(--green); }
.wh-act.out { color: var(--blue); }
.wh-act.none { color: var(--muted); font-weight: 500; }
.st-pill.void { background: #ef4444; }
.prod-tag {
display: inline-flex; align-items: center; height: 18px; padding: 0 6px; margin-left: 4px;
border-radius: 3px; font-size: 11px; font-weight: 650;
}
.prod-tag.orange { background: #fff1e0; color: #c47d11; }
.prod-tag.green { background: #e8f6ee; color: var(--green-dark); }
.prod-tag.red { background: #fdecea; color: #d94841; }
.match-tag { font-weight: 650; }
.match-tag.yes { color: #c47d11; }
.match-tag.ok { color: var(--green); }
.match-tag.no { color: #9aa3ad; }
.src-cell { display: flex; flex-direction: column; gap: 2px; font-size: 11px; color: #5b6675; line-height: 1.35; }
.src-cell .ico { display: inline-flex; align-items: center; gap: 4px; }
.src-dot { width: 14px; height: 14px; border-radius: 3px; background: #e8f6ee; color: var(--green-dark); font-size: 9px; font-weight: 800; display: grid; place-items: center; }
.op-link { color: var(--green); font-weight: 600; background: none; border: 0; padding: 0 8px 0 0; }
.op-link:hover { text-decoration: underline; }
.op-link.muted { color: #9aa3ad; cursor: default; text-decoration: none; }
.page-size { height: 26px; border: 1px solid var(--line); border-radius: 6px; padding: 0 6px; background: #fff; }
.query-row { display: none; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
.query-row.show { display: flex; }
.query-row select, .query-row input {
height: 32px; border: 1px solid var(--line); border-radius: 8px; padding: 0 10px; background: #fff;
}
.plan-foot .sum { color: var(--green); font-weight: 650; }
.amt-red { color: #e24b4a; font-weight: 700; font-variant-numeric: tabular-nums; }
.sale-tag {
display: inline-flex; align-items: center; height: 22px; padding: 0 8px;
border-radius: 4px; font-size: 12px; font-weight: 650;
background: var(--green-soft); color: var(--green-dark);
}
.quote-table { min-width: 1280px !important; }
.quote-table .quote-expand,
.quote-table .chk,
.quote-table .col-no {
position: sticky;
z-index: 3;
background: #fff;
}
.quote-table .quote-expand {
left: 0;
width: 32px; min-width: 32px; max-width: 32px;
padding-left: 0; padding-right: 0;
}
.quote-table .chk {
left: 32px;
width: 40px; min-width: 40px; max-width: 40px;
padding-left: 0; padding-right: 0;
}
.quote-table .col-no {
left: 72px;
width: 176px; min-width: 176px; max-width: 176px;
}
.quote-table thead .quote-expand,
.quote-table thead .chk,
.quote-table thead .col-no {
z-index: 5;
background: #f7faf8;
}
.quote-table tbody .quote-expand,
.quote-table tbody .chk,
.quote-table tbody .col-no { background: #fff; }
.quote-table tr.row-alert .quote-expand,
.quote-table tr.row-alert .chk,
.quote-table tr.row-alert .col-no { background: #fdecea; }
.quote-table .col-title {
min-width: 168px;
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
}
.quote-table .col-title .quote-link {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: bottom;
}
.erp-table tr.row-alert td { background: #fdecea; }
.erp-table tr.row-alert .chk, .erp-table tr.row-alert .idx, .erp-table tr.row-alert .col-no,
.erp-table tr.row-alert .quote-expand { background: #fdecea; }
.quote-expand { color: #9aa3ad; text-align: center; cursor: pointer; }
.quote-expand.on { color: var(--green-dark); }
.quote-sub td { background: #f8faf9 !important; font-size: 12px; color: var(--muted); white-space: normal; }
.quote-link { color: var(--green); font-weight: 650; background: none; border: 0; padding: 0; text-align: left; }
.quote-link:hover { text-decoration: underline; }
.th-sort { display: inline-flex; align-items: center; gap: 4px; }
.th-sort::after { content: ""; width: 0; height: 0; border-left: 4px solid transparent; border-right: 4px solid transparent; border-top: 5px solid #c5ccd4; }
.quote-search.wide { width: 280px; }
.erp-drop { position: relative; }
.erp-drop-menu {
display: none; position: absolute; top: calc(100% + 4px); left: 0; z-index: 8;
min-width: 132px; background: #fff; border: 1px solid var(--line); border-radius: 8px;
box-shadow: var(--shadow); padding: 4px;
}
.erp-drop.open .erp-drop-menu { display: block; }
.erp-drop-menu button {
display: block; width: 100%; text-align: left; height: 32px; padding: 0 10px;
border-radius: 6px; color: var(--ink);
}
.erp-drop-menu button:hover { background: var(--green-soft); color: var(--green-dark); }
.ct-table { min-width: 1780px !important; }
.ct-table .quote-expand,
.ct-table .chk,
.ct-table .col-no {
position: sticky; z-index: 3; background: #fff;
}
.ct-table .quote-expand { left: 0; width: 32px; min-width: 32px; max-width: 32px; padding: 7px 0; }
.ct-table .chk { left: 32px; width: 40px; min-width: 40px; max-width: 40px; padding: 7px 0; }
.ct-table .col-no { left: 72px; width: 168px; min-width: 168px; max-width: 168px; }
.ct-table thead .quote-expand,
.ct-table thead .chk,
.ct-table thead .col-no { z-index: 5; background: #f7faf8; }
.ct-table tbody .quote-expand,
.ct-table tbody .chk,
.ct-table tbody .col-no { background: #fff; }
.ct-table tr.row-alert .quote-expand,
.ct-table tr.row-alert .chk,
.ct-table tr.row-alert .col-no { background: #fdecea; }
.ct-name { white-space: normal !important; min-width: 148px; max-width: 186px; line-height: 1.35; }
.ct-name .quote-link { display: block; }
.ct-name .sub { display: block; color: var(--green); font-size: 11px; font-weight: 600; }
.ct-cust { white-space: normal !important; min-width: 132px; max-width: 168px; line-height: 1.35; }
.ct-prod { white-space: normal !important; min-width: 96px; max-width: 118px; line-height: 1.35; }
.ct-att { display: flex; align-items: center; gap: 8px; min-width: 92px; }
.pdf-chip { display: flex; flex-direction: column; align-items: center; gap: 2px; color: #d94841; min-width: 48px; }
.pdf-box {
width: 28px; height: 32px; border-radius: 3px; background: #e24b4a; color: #fff;
font-size: 8px; font-weight: 800; letter-spacing: .2px; display: grid; place-items: center;
box-shadow: 0 1px 2px rgba(226,75,74,.25);
}
.pdf-chip em { font-style: normal; font-size: 10px; font-weight: 650; color: #5b6675; white-space: nowrap; }
.up-chip {
width: 28px; height: 28px; border-radius: 6px; background: var(--green-soft); color: var(--green);
display: grid; place-items: center; flex: none;
}
.up-chip:hover { background: var(--green); color: #fff; }
.up-chip svg { width: 14px; height: 14px; stroke: currentColor; fill: none; stroke-width: 2; }
.ct-st { color: #3d4754; }
.quote-search {
margin-left: auto; display: flex; align-items: center; gap: 6px;
height: 32px; border: 1px solid var(--line); border-radius: 8px; padding: 0 10px; background: #fff; width: 240px;
}
.quote-search svg { width: 14px; height: 14px; stroke: #8a94a0; fill: none; stroke-width: 2; flex: none; }
.quote-search input { border: 0; outline: 0; flex: 1; min-width: 0; background: transparent; }
.btn-ico { width: 14px; height: 14px; stroke: currentColor; fill: none; stroke-width: 1.8; }
.btn .btn-ico { margin-right: 4px; vertical-align: -2px; }
.quote-pager { display: flex; align-items: center; gap: 6px; }
.quote-pager button {
min-width: 28px; height: 28px; padding: 0 8px; border: 1px solid transparent;
border-radius: 6px; color: #4b5563;
}
.quote-pager button.on { background: var(--green); color: #fff; font-weight: 650; }
#mod-product .erp-toolbar { padding-top: 0; }
#mod-product .prod-search-row {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 8px;
width: 100%;
padding: 0 0 8px;
}
#mod-product .prod-search-row .quote-search {
width: 280px;
flex: 0 0 280px;
margin: 0;
}
#mod-product .prod-search-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 6px;
flex: none;
}
#mod-product .erp-table-wrap { max-height: calc(100vh - 260px); }
#mod-staff .erp-toolbar { padding-top: 0; }
#mod-staff .erp-table-wrap { max-height: calc(100vh - 360px); }
.staff-table { min-width: 1280px !important; }
.staff-name { display: inline-flex; align-items: center; gap: 8px; }
.staff-av {
width: 26px; height: 26px; border-radius: 99px; flex: none;
background: var(--green-soft); color: var(--green-dark);
display: grid; place-items: center; font-size: 11px; font-weight: 700;
}
.staff-due { color: #b91c1c; font-weight: 650; }
.staff-dept-sel {
height: 32px; border: 1px solid var(--line); border-radius: 8px; padding: 0 8px; background: #fff; min-width: 120px;
}
.quote-search.prod-search { width: 280px; margin-left: 0; }
.prod-table { min-width: 1180px !important; }
.prod-table .chk { width: 36px; }
.prod-table .col-idx { width: 56px; }
.prod-hz {
display: inline-flex; align-items: center; justify-content: center;
min-width: 16px; height: 16px; padding: 0 4px; margin-left: 6px;
border-radius: 3px; background: #e24b4a; color: #fff; font-size: 10px; font-weight: 650; vertical-align: 1px;
}
.prod-st {
display: inline-flex; align-items: center; height: 22px; padding: 0 10px; border-radius: 11px;
background: #22a05d; color: #fff; font-size: 12px; font-weight: 650;
}
.prod-st.off { background: #94a3b8; }
.prod-price { color: #e24b4a; }
.th-filt {
display: inline-block; width: 11px; height: 11px; margin-left: 4px; opacity: .38; vertical-align: -1px;
background: currentColor;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M4 5h16l-6 7v6l-4 2v-8z'/%3E%3C/svg%3E") center / contain no-repeat;
}
.prod-cat-list { display: flex; flex-direction: column; gap: 4px; }
.prod-cat-list button {
display: flex; justify-content: space-between; width: 100%; text-align: left;
padding: 8px 10px; border-radius: 8px; border: 1px solid var(--line); background: #fff;
}
.prod-cat-list button.on, .prod-cat-list button:hover { border-color: var(--green-line); background: var(--green-soft); color: var(--green-dark); }
.quote-pager button:hover:not(.on) { background: #e8f6ee; }
.quote-goto { height: 28px; width: 48px; border: 1px solid var(--line); border-radius: 6px; padding: 0 6px; text-align: center; }
.erp-ico-btn {
width: 32px; height: 32px; border: 1px solid var(--line); border-radius: 8px; background: #fff;
display: grid; place-items: center; color: #5b6675;
}
.erp-ico-btn:hover { border-color: var(--green-line); color: var(--green-dark); }
.empty { color: var(--muted); padding: 28px 0; text-align: center; }
dialog.wide-dlg { max-width: min(1080px, 96vw); width: 1080px; }
dialog.exp-dlg {
max-width: min(960px, 96vw); width: 960px; border: 0; border-radius: 12px;
box-shadow: 0 18px 50px rgba(28, 36, 48, .18); overflow: hidden; background: #fff;
margin: auto;
}
dialog.exp-dlg .dlg-h {
padding: 14px 22px; border-bottom: 0; background: #e8f6ee;
}
dialog.exp-dlg .dlg-h h3 { font-size: 15px; font-weight: 650; color: var(--ink); }
dialog.exp-dlg .dlg-x { font-size: 22px; color: #6a7380; width: 32px; height: 32px; }
dialog.exp-dlg .dlg-b { max-height: 72vh; padding: 18px 24px 4px; }
dialog.exp-dlg .dlg-f { padding: 12px 24px 18px; border-top: 0; gap: 10px; }
dialog.exp-dlg .dlg-f .btn { display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 14px; }
.exp-new-top {
display: grid; grid-template-columns: 1fr 1fr 1.25fr; gap: 16px 40px; margin-bottom: 6px;
}
.exp-new-top label { display: flex; flex-direction: column; gap: 8px; font-size: 13px; color: #3d4754; font-weight: 500; }
.exp-new-top input, .exp-new-top select, .exp-field-date {
height: 38px; border: 1px solid #d8dee6; border-radius: 8px; padding: 0 12px; background: #fff; outline: none; color: var(--ink); font-size: 13px;
}
.exp-field-date {
display: flex; align-items: center; gap: 8px; padding: 0 10px; position: relative;
}
.exp-field-date svg { width: 16px; height: 16px; stroke: #8a94a0; fill: none; stroke-width: 1.8; flex: none; }
.exp-field-date input {
flex: 1; height: 36px; border: 0 !important; padding: 0 !important; background: transparent; min-width: 0;
}
.exp-field-date input::-webkit-calendar-picker-indicator {
opacity: 0; position: absolute; inset: 0; width: 100%; height: 100%; cursor: pointer;
}
.exp-new-top input:focus, .exp-new-top select:focus,
.exp-line-table input:focus, .exp-line-table select:focus, .exp-note:focus {
border-color: var(--green-line);
}
.exp-block { margin-top: 8px; padding-top: 18px; border-top: 1px dashed #e4e9ed; }
.exp-block.tight { border-top: 0; padding-top: 18px; margin-top: 4px; }
.exp-block > h4 { font-size: 13px; font-weight: 650; color: var(--ink); margin-bottom: 10px; }
.exp-block > h4 .opt { color: var(--muted); font-weight: 400; }
.exp-line-wrap { overflow: auto; border-radius: 8px; }
.exp-line-table { width: 100%; min-width: 760px; border-collapse: separate; border-spacing: 0; }
.exp-line-table th {
background: #f4f6f8; font-size: 12px; color: #5b6675; font-weight: 600;
padding: 11px 12px; border-bottom: 1px solid #eef1f4; text-align: left;
}
.exp-line-table th:first-child { border-radius: 8px 0 0 0; }
.exp-line-table th:last-child { text-align: center; border-radius: 0 8px 0 0; }
.exp-line-table td { padding: 10px 12px; vertical-align: middle; font-size: 13px; color: var(--muted); background: #fff; }
.exp-line-table td:last-child { text-align: center; }
.exp-line-table input, .exp-line-table select {
width: 100%; height: 36px; border: 1px solid #d8dee6; border-radius: 8px; padding: 0 10px; background: #fff; outline: none; color: var(--ink);
}
.exp-line-table select:invalid, .exp-line-table select.placeholder { color: #9aa3ad; }
.exp-line-table input::placeholder { color: #9aa3ad; }
.exp-datewrap { position: relative; display: flex; align-items: center; }
.exp-datewrap svg {
position: absolute; left: 10px; width: 16px; height: 16px;
stroke: #8a94a0; fill: none; stroke-width: 1.8; pointer-events: none; z-index: 1;
}
.exp-datewrap input[type="date"] { color: var(--ink); padding-left: 32px; }
.exp-datewrap:not(.has) input[type="date"] { color: transparent; }
.exp-datewrap:not(.has) input[type="date"]::-webkit-datetime-edit { color: transparent; }
.exp-datewrap input[type="date"]::-webkit-calendar-picker-indicator {
opacity: 0; position: absolute; inset: 0; width: 100%; height: 100%; cursor: pointer;
}
.exp-date-ph {
position: absolute; left: 32px; top: 50%; transform: translateY(-50%);
color: #9aa3ad; pointer-events: none; font-size: 13px;
}
.exp-datewrap.has .exp-date-ph { display: none; }
.exp-line-foot { display: flex; justify-content: space-between; align-items: center; margin-top: 14px; gap: 12px; flex-wrap: wrap; }
.exp-line-foot .btn { height: 34px; padding: 0 12px; }
.exp-total { font-size: 13px; color: var(--ink); }
.exp-total b { color: #e24b4a; font-size: 16px; font-weight: 750; margin-left: 4px; }
.exp-inv-meta { font-size: 13px; color: var(--ink); margin-bottom: 12px; }
.exp-inv-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 12px; min-height: 0; }
.exp-up {
width: 132px; height: 110px; border: 1px dashed #8fd0aa; border-radius: 12px;
background: #e8f6ee; display: flex; flex-direction: column; align-items: center; justify-content: center;
color: var(--green); gap: 2px; cursor: pointer; font-weight: 500; font-size: 13px;
}
.exp-up:hover { background: #def1e5; }
.exp-up input { display: none; }
.exp-up .plus { font-size: 30px; line-height: 1; color: #22a05d; font-weight: 400; }
.exp-note { width: 100%; min-height: 40px; height: 40px; border: 1px solid #d8dee6; border-radius: 8px; padding: 8px 12px; resize: vertical; outline: none; }
.btn-ico { width: 14px; height: 14px; stroke: currentColor; fill: none; stroke-width: 1.8; flex: none; }
.inv-pick-list { display: flex; flex-direction: column; gap: 8px; }
.inv-pick-item {
display: flex; align-items: center; gap: 10px; padding: 10px 12px;
border: 1px solid var(--line); border-radius: 8px; background: #fff; width: 100%; text-align: left;
}
.inv-pick-item.on { border-color: var(--green); background: var(--green-soft); }
.inv-pick-item .amt { margin-left: auto; font-weight: 650; }
@media (max-width: 1100px) {
.exp-new-top { grid-template-columns: 1fr; }
}
.plate-cell { font-size: 12px; line-height: 1.45; white-space: normal; vertical-align: top; min-width: 360px; }
.plate-mini {
width: auto; min-width: 0; border-collapse: collapse; background: transparent;
}
.plate-mini th, .plate-mini td {
padding: 2px 18px 2px 0; border: 0; background: transparent !important;
white-space: nowrap; font-size: 12px; vertical-align: baseline;
}
.plate-mini th {
color: var(--muted); font-weight: 600; font-size: 11px;
}
.plate-mini td:first-child { font-weight: 650; color: var(--ink); padding-right: 22px; }
.plate-mini th:nth-child(n+2), .plate-mini td:nth-child(n+2) {
text-align: right; padding-left: 16px; padding-right: 16px;
}
.plate-mini th:last-child, .plate-mini td:last-child { padding-right: 0; }
.attach-mode #buyOcrBox, .attach-mode #buyFormCol { display: none !important; }
.attach-mode .ocr-pair, .attach-mode .split-h { grid-template-columns: 1fr; }
.trans-slot {
margin-top: 12px; padding-top: 12px; border-top: 1px dashed var(--line);
}
.trans-slot-h { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.trans-slot-h h4 { font-size: 13px; }
.exp-board { margin-bottom: 14px; }
.exp-map {
background: #fff; border: 1px solid var(--line); border-radius: 12px; overflow: hidden;
}
.exp-map-grid {
display: grid;
grid-template-columns: 88px repeat(5, minmax(0, 1fr));
}
.exp-map-head, .exp-map-row {
display: grid; grid-template-columns: subgrid; grid-column: 1 / -1; align-items: stretch;
}
.exp-map-head { background: #f7f9f8; border-bottom: 1px solid #eef1f4; }
.exp-colh {
padding: 8px 6px; font-size: 11px; color: var(--muted); text-align: center;
}
.exp-colh b { color: var(--ink); font-weight: 650; margin-right: 3px; }
.exp-map-row.bx { background: linear-gradient(90deg, #eef8f2 0, #fff 88px); border-bottom: 1px dashed #e6eaee; }
.exp-map-row.sq { background: linear-gradient(90deg, #fff6eb 0, #fff 88px); }
.exp-map-lab {
display: flex; flex-direction: column; justify-content: center;
padding: 10px 12px;
}
.exp-map-lab b { font-size: 13px; font-weight: 750; color: var(--green); line-height: 1.2; }
.exp-map-row.sq .exp-map-lab b { color: #c2410c; }
.exp-map-lab span { font-size: 11px; color: var(--muted); margin-top: 2px; }
.exp-cell {
position: relative; display: flex; align-items: center; gap: 8px;
margin: 8px 8px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px;
background: #fff; text-align: left; cursor: pointer; min-width: 0;
}
.exp-cell:hover { border-color: var(--green-line); }
.exp-map-row.sq .exp-cell:hover { border-color: #f6c38b; }
.exp-cell.on { border-color: var(--green); background: var(--green-soft); }
.exp-map-row.sq .exp-cell.on { border-color: #ea580c; background: #fff7ed; }
.exp-cell.ghost {
border-style: dashed; background: #f4f7f5; cursor: default; color: var(--muted);
}
.exp-cell.ghost:hover { border-color: var(--line); }
.exp-cell .t { min-width: 0; flex: 1; }
.exp-cell .t b { display: block; font-size: 12.5px; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.exp-cell .t em { display: block; font-style: normal; font-size: 10px; color: var(--muted); }
.exp-cell .c { flex: none; font-size: 16px; font-weight: 750; letter-spacing: -.03em; }
.exp-cell:not(.last)::after {
content: ""; position: absolute; right: -9px; top: 50%; z-index: 1; width: 10px; height: 10px;
margin-top: -5px; pointer-events: none;
background: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'><path d='M4 2l4 4-4 4' fill='none' stroke='%23c9d0d6' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/></svg>") center / 10px no-repeat;
}
@media (max-width: 1100px) {
.exp-map-grid { display: block; }
.exp-map-head { display: none; }
.exp-map-row { display: grid; grid-template-columns: 1fr 1fr; }
.exp-map-lab { grid-column: 1 / -1; padding-bottom: 0; }
.exp-cell:not(.last)::after { display: none; }
}
.fin-sec { margin-bottom: 18px; }
.fin-sec:last-child { margin-bottom: 0; }
.fin-sec-h {
display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 700; margin: 0 0 8px;
}
.kind-tag {
display: inline-flex; align-items: center; height: 18px; padding: 0 6px; border-radius: 4px;
font-size: 10px; font-weight: 700; margin-right: 4px; vertical-align: middle;
}
.kind-tag.bx { background: var(--green-soft); color: var(--green); }
.kind-tag.sq { background: #fff7ed; color: #c2410c; }
.exp-log { display: flex; flex-direction: column; gap: 0; }
.exp-log .row {
display: grid; grid-template-columns: 118px 88px 1fr; gap: 8px;
padding: 8px 0; border-bottom: 1px solid #eef1f4; font-size: 12px;
}
.exp-log .row:last-child { border: 0; }
.duty-bar {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px;
}
.duty-brand {
display: inline-flex; align-items: center; gap: 8px; padding-left: 10px;
border-left: 3px solid var(--green); font-size: 15px; font-weight: 750; color: var(--green-dark);
}
.duty-brand svg { width: 18px; height: 18px; stroke: var(--green); fill: none; stroke-width: 1.8; }
.duty-nav { display: inline-flex; align-items: center; gap: 6px; }
.duty-nav .m { font-weight: 650; min-width: 92px; text-align: center; }
.duty-ico {
width: 28px; height: 28px; border-radius: 8px; border: 1px solid var(--line); background: #fff;
display: grid; place-items: center; cursor: pointer;
}
.duty-ico svg { width: 14px; height: 14px; stroke: #4b5563; fill: none; stroke-width: 1.8; }
.duty-legend {
margin-left: auto; display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
font-size: 12px; color: var(--muted);
}
.duty-legend i {
display: inline-block; width: 8px; height: 8px; border-radius: 99px; margin-right: 4px; vertical-align: 1px;
}
.duty-legend .am { background: #e67e22; }
.duty-legend .pm { background: #c45c26; }
.duty-legend .watch { background: #d94841; }
.duty-legend .other { background: #8a94a0; }
.duty-link { color: var(--green); font-weight: 500; background: none; border: 0; cursor: pointer; padding: 0; }
.duty-stats {
display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 12px;
}
.duty-pills { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.duty-pill {
display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border-radius: 8px;
background: #fff; border: 1px solid var(--line); font-size: 12.5px;
}
.duty-pill i { width: 8px; height: 8px; border-radius: 99px; display: inline-block; }
.duty-pill i.am { background: #e67e22; }
.duty-pill i.pm { background: #c45c26; }
.duty-pill i.watch { background: #d94841; }
.duty-pill b { font-size: 14px; font-weight: 750; }
.duty-sum { font-size: 12.5px; color: var(--ink); }
.duty-sum b { color: var(--green); font-size: 14px; font-weight: 750; }
.duty-people { display: flex; flex-wrap: wrap; gap: 10px 16px; font-size: 12.5px; color: var(--muted); align-items: center; }
.duty-people em { font-style: normal; color: var(--ink); }
.duty-people b { color: var(--green); font-weight: 750; }
.duty-cal { background: #fff; border: 1px solid var(--line); border-radius: 12px; overflow: hidden; }
.duty-week {
display: grid; grid-template-columns: repeat(7, 1fr); background: #f7f9f8; border-bottom: 1px solid var(--line);
}
.duty-week span { padding: 8px; text-align: center; font-size: 12px; color: var(--muted); font-weight: 500; }
.duty-grid { display: grid; grid-template-columns: repeat(7, 1fr); }
.duty-day {
min-height: 124px; padding: 8px; text-align: left; border: 0; border-right: 1px solid #eef1f4; border-bottom: 1px solid #eef1f4;
background: #fff; vertical-align: top; cursor: pointer; font: inherit; color: inherit; display: block; width: 100%;
}
.duty-day:nth-child(7n) { border-right: 0; }
.duty-day.out { background: #fafbfb; color: #c5cbd1; }
.duty-day:hover { background: #f8faf9; }
.duty-day .num { font-weight: 650; margin-bottom: 6px; font-size: 13px; }
.duty-day.today .num {
width: 22px; height: 22px; border-radius: 99px; background: var(--green); color: #fff;
display: grid; place-items: center; font-size: 12px;
}
.duty-chip {
display: block; width: 100%; border-radius: 4px; padding: 3px 6px; margin-bottom: 4px;
font-size: 11px; color: #fff; text-align: left; line-height: 1.35; font-weight: 500;
}
.duty-chip.am { background: #e67e22; }
.duty-chip.pm { background: #c45c26; }
.duty-chip.watch { background: #d94841; }
.duty-chip.other { background: #6b7280; }
.duty-empty { color: #9aa3ad; font-size: 12px; }
.chip.pick { background: #fff; color: var(--ink); border: 1px solid var(--line); cursor: pointer; height: 28px; }
.chip.pick.on { background: var(--green-soft); color: var(--green-dark); border-color: var(--green); }
.duty-exist { display: flex; flex-direction: column; gap: 8px; }
.duty-exist .row {
display: flex; align-items: center; gap: 8px; font-size: 12.5px;
}
.wh-tree {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 16px;
}
#view-wh-info, #view-wh-mode, #view-wh-proc, #view-wh-stock {
display: flex;
flex-direction: column;
gap: 16px;
}
.wh-nav-card { overflow: visible; }
.wh-nav {
padding: 10px 14px;
display: flex;
flex-direction: column;
gap: 0;
}
.wh-nav-mains {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.wh-nav-lab {
font-size: 13px; font-weight: 650; color: var(--ink);
margin-right: 4px;
}
.wh-nav-item, .wh-nav-child {
display: inline-flex; align-items: center; gap: 6px;
width: auto; text-align: left; border-radius: 99px;
padding: 6px 12px;
}
.wh-nav-item {
font-weight: 650;
background: #fff;
color: #5b6675;
border: 1.5px solid #c5ccd4;
}
.wh-nav-item.acid { color: #2a6bb3; background: #eef4fb; border-color: #b7cde6; }
.wh-nav-item.alkali { color: #c47d11; background: #fbf4e8; border-color: #e4c792; }
.wh-nav-item.hcl { color: #c45b5b; background: #fbf0f0; border-color: #e3b4b4; }
.wh-nav-item.pack { color: #7c5cbf; background: #f4f0fb; border-color: #c9b8e4; }
.wh-nav-item.solid { color: #5b6675; background: #f2f4f6; border-color: #c5ccd4; }
.wh-nav-item:hover { filter: none; }
.wh-nav-item.acid:hover { background: #e3edf8; }
.wh-nav-item.alkali:hover { background: #f7ebd6; }
.wh-nav-item.hcl:hover { background: #f6e4e4; }
.wh-nav-item.pack:hover { background: #ece4f7; }
.wh-nav-item.solid:hover { background: #e8ebef; }
.wh-nav-item.on {
color: #fff;
box-shadow: 0 2px 8px rgba(28, 36, 48, 0.18);
}
.wh-nav-item.on.acid { background: #2a6bb3; border-color: #1d4f86; }
.wh-nav-item.on.alkali { background: #c47d11; border-color: #8f5a0a; }
.wh-nav-item.on.hcl { background: #c45b5b; border-color: #8f3e3e; }
.wh-nav-item.on.pack { background: #7c5cbf; border-color: #5a3f91; }
.wh-nav-item.on.solid { background: #5b6675; border-color: #3d4754; }
.wh-nav-item.on .wh-dot { background: #fff; }
.wh-nav-child {
font-size: 12.5px;
font-weight: 500;
padding: 5px 10px;
color: #1c2430;
background: #fff;
border: 1px solid #c5ccd4;
}
.wh-nav-child:hover { background: #f5f7f8; }
.wh-nav-child.on {
background: #f3f5f7;
border-color: #8a94a0;
font-weight: 650;
}
.wh-nav-child .wh-dot { background: #8a94a0; }
.wh-nav-subs {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #eef1f4;
}
.wh-nav-subs-lab {
font-size: 11px; color: #8a94a0; font-weight: 600; margin-right: 2px;
}
.wh-nav-child .mini {
font-size: 10px; color: #8a94a0; font-weight: 500;
}
.wh-dot {
width: 8px; height: 8px; border-radius: 50%; flex: none;
background: currentColor;
}
.wh-kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
.wh-kpi {
background: var(--white); border: 1px solid var(--line); border-radius: 10px;
padding: 12px 14px;
}
.wh-kpi .k { color: var(--muted); font-size: 12px; }
.wh-kpi .v { font-size: 22px; font-weight: 700; letter-spacing: -.03em; margin: 4px 0 2px; }
.wh-kpi .s { font-size: 11px; color: var(--muted); }
.wh-cap { height: 6px; background: #eef1f4; border-radius: 99px; overflow: hidden; margin-top: 8px; }
.wh-cap i { display: block; height: 100%; background: linear-gradient(90deg, #167c4a, #22a05d); }
.wh-cap i.warn { background: linear-gradient(90deg, #e24b4a, #f97316); }
.wh-stock-name { font-weight: 650; }
.wh-path { color: var(--muted); font-size: 11px; }
.grade {
display: inline-flex; align-items: center; height: 18px; padding: 0 6px;
border-radius: 4px; font-size: 11px; font-weight: 700;
}
.grade.ind { background: #eaf2fb; color: var(--blue); }
.grade.rg { background: #f3eefb; color: #7c5cbf; }
.grade.raw { background: var(--amber-soft); color: var(--amber); }
.grade.fin { background: var(--green-soft); color: var(--green-dark); }
.grade.ref { background: #e6f4ef; color: #0f7a5a; }
.wh-stock-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.wh-mini {
cursor: pointer;
background: var(--white);
border: 1px solid var(--line);
border-radius: 10px;
padding: 12px 14px;
min-height: 0;
box-shadow: none;
transition: background .15s;
}
.wh-mini:hover { background: #f7faf8; }
.wh-mini-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.wh-mini-name {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
}
.wh-mini-name i {
width: 8px;
height: 8px;
border-radius: 50%;
flex: none;
background: currentColor;
}
.wh-mini-name b {
font-size: 13px;
font-weight: 650;
color: #1c2430;
line-height: 1.3;
}
.wh-mini .qty {
flex: none;
font-size: 18px;
font-weight: 700;
letter-spacing: -.03em;
font-variant-numeric: tabular-nums;
line-height: 1;
}
.wh-mini .qty span {
font-size: 11px;
font-weight: 500;
margin-left: 3px;
opacity: .75;
}
.wh-mini.tone-ind { color: var(--blue); }
.wh-mini.tone-rg { color: #7c5cbf; }
.wh-mini.tone-hcl { color: #c45b5b; }
.wh-mini.tone-ind .qty { color: var(--blue); }
.wh-mini.tone-rg .qty { color: #7c5cbf; }
.wh-mini.tone-hcl .qty { color: #c45b5b; }
.wh-mini .tanks {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 8px;
}
.wh-mini .tanks span {
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 7px;
border-radius: 4px;
background: #f3f5f4;
color: #5b6675;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.wh-mini.tone-ind i,
.wh-mini.tone-rg i,
.wh-mini.tone-hcl i { background: currentColor; }
.proc-table { min-width: 720px; border-collapse: separate; border-spacing: 5px 0; }
.proc-table.wide { min-width: 1480px; border-spacing: 4px 3px; }
.proc-table thead th {
background: #1a3d73;
color: #fff;
font-weight: 600;
white-space: nowrap;
text-align: center;
padding: 9px 8px;
border-bottom: 0;
border-radius: 4px;
}
.proc-table td {
text-align: center;
white-space: nowrap;
font-variant-numeric: tabular-nums;
padding: 9px 8px;
border-bottom: 0;
}
.proc-table tfoot td {
font-weight: 700;
background: #f3f6f4;
border-radius: 4px;
}
.proc-table.wide td {
color: #243040;
font-size: 12.5px;
padding: 10px 8px;
}
.proc-table.wide tbody tr:nth-child(even) td { background: #f4f7fb; }
.proc-table.wide tbody tr:hover td { background: #e8f0fa; }
.proc-table.wide .qty-in { color: var(--green); font-weight: 700; }
.proc-table.wide .qty-out { color: var(--blue); font-weight: 700; }
.proc-table.wide .qty-bal { color: #1a3d73; font-weight: 700; }
.proc-table.wide .amt { font-weight: 650; }
.proc-table.wide .dash { color: #c2c8d0; }
.proc-table th.col-name, .proc-table td.col-name {
text-align: left;
padding-left: 12px;
min-width: 148px;
}
.wh-mode {
border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; background: #fbfefc;
}
.wh-mode h3 { font-size: 13px; margin-bottom: 8px; }
.wh-mode p { color: var(--muted); font-size: 12px; line-height: 1.55; }
.wh-proc {
display: grid; grid-template-columns: 1fr auto 1fr auto 1fr; gap: 8px;
align-items: center; margin-top: 10px;
}
.wh-proc .box {
background: #fff; border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px;
}
.wh-proc .box b { display: block; font-size: 13px; }
.wh-proc .box span { display: block; color: var(--muted); font-size: 11px; margin-top: 4px; }
.wh-proc .arr { color: #4a7fd4; font-weight: 700; font-size: 16px; text-align: center; }
.tank-map {
display: grid; grid-template-columns: repeat(auto-fill, minmax(128px, 1fr));
gap: 8px;
}
.tank-card {
border: 1px solid var(--line); border-radius: 10px; padding: 8px 10px; background: #fff;
min-height: 74px;
}
.tank-card .id { font-weight: 750; font-size: 13px; letter-spacing: 0.02em; }
.tank-card .p { color: var(--muted); font-size: 11px; margin: 3px 0 6px; line-height: 1.35; }
.tank-card .q { font-size: 11px; font-variant-numeric: tabular-nums; font-weight: 650; }
.tank-card.sulfur { border-color: #f3ddb0; background: #fffdf6; }
.tank-group { margin-top: 10px; }
.tank-group .tg-lab { font-size: 11px; color: var(--muted); margin-bottom: 4px; font-weight: 650; }
.tank-group .wh-tank { margin-top: 0; }
.wh-prod-row { display: flex; justify-content: space-between; gap: 8px; padding: 8px 0; border-bottom: 1px solid #eef1f4; font-size: 12px; }
.wh-prod-row:last-child { border: 0; }
.calc-box { background: #f7fbf8; border: 1px dashed var(--green-line); border-radius: 10px; padding: 12px; margin-top: 10px; font-size: 12px; }
.calc-box b { font-weight: 650; }
.tank-use { display: flex; align-items: center; gap: 8px; }
.tank-use .bar { flex: 1; height: 6px; background: #eef1f4; border-radius: 99px; overflow: hidden; min-width: 64px; }
.tank-use .bar i { display: block; height: 100%; background: linear-gradient(90deg, #167c4a, #22a05d); }
.tank-use .bar i.warn { background: linear-gradient(90deg, #e24b4a, #f97316); }
.print-slip { max-width: 720px; margin: 0 auto; padding: 8px 4px; }
.print-slip h1 { font-size: 18px; text-align: center; margin-bottom: 4px; }
.print-slip .sub { text-align: center; color: var(--muted); font-size: 12px; margin-bottom: 16px; }
.print-slip table { width: 100%; }
.print-slip th, .print-slip td { border-bottom: 1px solid var(--line); padding: 8px 6px; }
.print-sign { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; margin-top: 28px; font-size: 12px; color: var(--muted); }
.wh-dir { width: 100%; }
.op-chip {
display: inline-flex; align-items: center; height: 20px; padding: 0 7px; margin: 0 4px 4px 0;
border-radius: 4px; font-size: 11px; font-weight: 650;
background: var(--green-soft); color: var(--green-dark);
}
.op-chip.out { background: #eaf2fb; color: var(--blue); }
.op-chip.mix { background: #fff6e5; color: var(--amber); }
.wh-tree-box {
display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 10px;
}
.wh-branch {
border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; background: #fff;
}
.wh-branch h4 { font-size: 12px; color: var(--muted); margin-bottom: 8px; font-weight: 650; }
.wh-branch button {
display: flex; justify-content: space-between; width: 100%;
padding: 6px 0; border-bottom: 1px solid #eef1f4; text-align: left; font-size: 12.5px;
}
.wh-branch button:last-child { border: 0; }
.wh-branch button:hover { color: var(--green-dark); }
@media (max-width: 1100px) {
.wh-info-grid, .wh-proc, .wh-tree-box, .wh-stock-cards { grid-template-columns: 1fr; }
}
@media (max-width: 1100px) {
.app { grid-template-columns: 1fr; }
.stats, .stats.five, .grid, .quick, .form, .apply, .ocr-pair, .split-h, .sheet, .wh-tree, .wh-kpis { grid-template-columns: 1fr; }
.cert-duo { grid-template-columns: minmax(0, 1fr) minmax(0, 3fr); }
.sidebar, .search { display: none; }
.form .span2 { grid-column: auto; }
}
#detailStats { grid-template-columns: repeat(4, 1fr); }
</style>
</head>
<body>
<div class="app">
<aside class="sidebar">
<div class="brand">
<div class="logo" aria-hidden="true">峡</div>
<div>
<strong>山峡代工</strong>
<span class="sub">一体化平台</span>
</div>
</div>
<nav class="nav">
<div class="nav-label">工作台</div>
<button class="side-item" type="button" data-page="fav">
<svg viewBox="0 0 24 24"><path d="M12 4 4 19h16L12 4z"/><path d="M12 10v4M12 16h.01"/></svg>
收藏
</button>
<button class="side-item" type="button" data-page="todo">
<svg viewBox="0 0 24 24"><rect x="4" y="5" width="16" height="14" rx="2"/><path d="M8 9h8M8 13h5"/></svg>
待办列表
</button>
<div class="nav-group" data-group="crm">
<button class="side-item" type="button" data-toggle="crm">
<svg viewBox="0 0 24 24"><circle cx="9" cy="8" r="3"/><path d="M4 19c.5-3 2.8-5 5-5s4.5 2 5 5"/><circle cx="17" cy="9" r="2.4"/><path d="M16 19c.3-2 1.6-3.4 3-3.8"/></svg>
CRM管理
<svg class="chev" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></svg>
</button>
<div class="nav-sub">
<button class="side-item side-sub" type="button" data-mod="product">产品管理</button>
<button class="side-item side-sub" type="button" data-mod="quote">报价单管理 <span class="badge-n" id="quoteBadge">0</span></button>
<button class="side-item side-sub" type="button" data-mod="contract">合同管理 <span class="badge-n" id="ctBadge">0</span></button>
<button class="side-item side-sub" type="button" data-page="crm-customer">客户管理</button>
</div>
</div>
<div class="nav-group open" data-group="transport">
<button class="side-item" type="button" data-toggle="transport">
<svg viewBox="0 0 24 24"><path d="M3 16h13l5-6H12"/><path d="M5 16V8h7"/><circle cx="7" cy="18" r="2"/><circle cx="16" cy="18" r="2"/></svg>
运输管理
<svg class="chev" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></svg>
</button>
<div class="nav-sub">
<button class="side-item side-sub active" type="button" data-mod="plan">运输计划 <span class="badge-n" id="planBadge">0</span></button>
<button class="side-item side-sub" type="button" data-mod="archive">运输归档 <span class="badge-n" id="archiveBadge">0</span></button>
<button class="side-item side-sub" type="button" data-mod="task">任务单管理 <span class="badge-n" id="taskBadge">0</span></button>
<button class="side-item side-sub" type="button" data-mod="cert">购买证管理 <span class="badge-n" id="certBadge">0</span></button>
<button class="side-item side-sub" type="button" data-page="weigh">地磅管理</button>
<button class="side-item side-sub" type="button" data-page="freight">运费结算</button>
</div>
</div>
<div class="nav-group" data-group="fleet">
<button class="side-item" type="button" data-toggle="fleet">
<svg viewBox="0 0 24 24"><path d="M3 16h13l5-6H12"/><path d="M5 16V8h7"/><circle cx="7" cy="18" r="2"/><circle cx="16" cy="18" r="2"/></svg>
车辆管理
<svg class="chev" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></svg>
</button>
<div class="nav-sub">
<button class="side-item side-sub" type="button" data-mod="fleet" data-fleet-view="etc">ETC充值卡管理</button>
<button class="side-item side-sub" type="button" data-mod="fleet" data-fleet-view="fuel">加油登记管理</button>
<button class="side-item side-sub" type="button" data-mod="fleet" data-fleet-view="oilcard">加油充值卡管理</button>
<button class="side-item side-sub" type="button" data-mod="fleet" data-fleet-view="maint">车辆维保管理</button>
</div>
</div>
<div class="nav-group" data-group="warehouse">
<button class="side-item" type="button" data-toggle="warehouse">
<svg viewBox="0 0 24 24"><path d="M3 10h18v9H3zM5 10V7l3-3h8l3 3v3"/></svg>
仓储管理
<svg class="chev" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></svg>
</button>
<div class="nav-sub">
<button class="side-item side-sub" type="button" data-mod="warehouse" data-wh-view="info">库存信息表</button>
<button class="side-item side-sub" type="button" data-mod="warehouse" data-wh-view="mode">仓库信息表</button>
<button class="side-item side-sub" type="button" data-mod="warehouse" data-wh-view="proc">加工单明细表</button>
<button class="side-item side-sub" type="button" data-mod="warehouse" data-wh-view="stock">库存成本明细表</button>
</div>
</div>
<div class="nav-group" data-group="finance">
<button class="side-item" type="button" data-toggle="finance">
<svg viewBox="0 0 24 24"><rect x="4" y="5" width="16" height="14" rx="2"/><path d="M8 9h8M8 13h5"/></svg>
财务管理
<svg class="chev" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></svg>
</button>
<div class="nav-sub">
<button class="side-item side-sub" type="button" data-page="fin-ar">应收应付</button>
</div>
</div>
<div class="nav-group" data-group="hr">
<button class="side-item" type="button" data-toggle="hr">
<svg viewBox="0 0 24 24"><circle cx="12" cy="8" r="3"/><path d="M5 19c.6-3.2 3.2-5 7-5s6.4 1.8 7 5"/></svg>
人事管理
<svg class="chev" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></svg>
</button>
<div class="nav-sub">
<button class="side-item side-sub" type="button" data-mod="expense">报销管理 <span class="badge-n" id="expBadge">0</span></button>
<button class="side-item side-sub" type="button" data-mod="duty">值班管理</button>
<button class="side-item side-sub" type="button" data-mod="staff">员工档案</button>
</div>
</div>
<div class="nav-group" data-group="system">
<button class="side-item" type="button" data-toggle="system">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a7.8 7.8 0 0 0 .1-6l-2.1.4M6.6 9a7.8 7.8 0 0 0-.1 6l2.1-.4M9 4.6a7.8 7.8 0 0 1 6 0l-.4 2.1M15 19.4a7.8 7.8 0 0 1-6 0l.4-2.1"/></svg>
系统管理
<svg class="chev" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></svg>
</button>
<div class="nav-sub">
<button class="side-item side-sub" type="button" data-page="sys-dict">字典管理</button>
</div>
</div>
</nav>
</aside>
<section class="main">
<header class="topbar">
<div class="crumb">工作台 / <b id="crumbMod">运输管理</b><span id="crumbTail"> / <span id="crumb">运输计划</span></span></div>
<label class="search">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#8a94a0" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m20 20-3-3"/></svg>
<input class="top-search" id="globalSearch" placeholder="搜索车牌、单号、证号" />
</label>
<div class="user">
<div class="avatar">王</div>
调度员
</div>
</header>
<div class="page">
<div id="mod-placeholder" class="hidden">
<div class="page-head">
<div>
<h1 id="phTitle">功能建设中</h1>
<p id="phDesc">该模块尚未接入本演示,菜单结构已按一体化平台一级 / 二级导航铺好。</p>
</div>
</div>
<div class="coming">
<h2 id="phCardTitle">敬请期待</h2>
<p id="phComing">当前演示重点在运输计划与购买证管理。</p>
</div>
</div>
<div id="mod-product" class="hidden">
<section class="erp-page">
<div class="prod-search-row">
<label class="quote-search prod-search">
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m20 20-3-3"/></svg>
<input id="prodQ" type="search" placeholder="产品名称/编号/拼音首字母" oninput="prodPage=1;renderProducts()" />
</label>
<div class="prod-search-actions">
<button class="erp-ico-btn" type="button" title="刷新" onclick="prodPage=1;renderProducts()">↻</button>
<button class="erp-ico-btn" type="button" title="列设置" onclick="toast('演示:列设置')">☰</button>
</div>
</div>
<div class="erp-toolbar">
<button class="btn btn-primary" type="button" onclick="openProdDlg()">+ 新增产品</button>
<button class="btn" type="button" onclick="editSelectedProd()">
<svg class="btn-ico" viewBox="0 0 24 24"><path d="M4 20h4l11-11-4-4L4 16v4z"/><path d="m13 7 4 4"/></svg>
编辑
</button>
<button class="btn" type="button" onclick="deleteProds()">
<svg class="btn-ico" viewBox="0 0 24 24"><path d="M5 7h14M9 7V5h6v2M8 7l1 12h6l1-12"/></svg>
删除
</button>
<button class="btn" type="button" onclick="shelfProds('上架')">
<svg class="btn-ico" viewBox="0 0 24 24"><path d="M12 19V7M8 11l4-4 4 4"/></svg>
批量上架
</button>
<button class="btn" type="button" onclick="shelfProds('下架')">
<svg class="btn-ico" viewBox="0 0 24 24"><path d="M12 5v12M8 13l4 4 4-4"/></svg>
批量下架
</button>
<button class="btn" type="button" onclick="toast('演示:已打开导入模板')">
<svg class="btn-ico" viewBox="0 0 24 24"><path d="M12 16V6M8 10l4-4 4 4"/><path d="M5 18h14"/></svg>
导入
</button>
<div class="erp-drop" id="prodExportDrop">
<button class="btn" type="button" onclick="toggleProdExport(event)">
<svg class="btn-ico" viewBox="0 0 24 24"><path d="M12 4v10M8 10l4 4 4-4M5 18h14"/></svg>
导出
</button>
<div class="erp-drop-menu">
<button type="button" onclick="exportProducts('page')">导出当前页</button>
<button type="button" onclick="exportProducts('all')">导出全部</button>
</div>
</div>
<button class="btn" type="button" onclick="openProdCatDlg()">
<svg class="btn-ico" viewBox="0 0 24 24"><path d="M4 6h7l2 2h7v10H4z"/></svg>
产品分类
</button>
</div>
<article class="card erp-card">
<div class="erp-table-wrap" id="prodTable"></div>
<div class="plan-foot">
<span class="muted" id="prodSelHint">已选 0 项</span>
<span class="quote-pager" id="prodPager"></span>
</div>
</article>
</section>
</div>
<div id="mod-quote" class="hidden">
<section class="erp-page">
<div class="stats" id="quoteStats"></div>
<div class="erp-tabs-row">
<div class="plan-tabs" id="quoteTabs">
<button class="on" type="button" data-tab="全部">全部</button>
<button type="button" data-tab="昨日">昨日</button>
<button type="button" data-tab="今日">今日</button>
<button type="button" data-tab="明日">明日</button>
<button type="button" data-tab="本周">本周</button>
</div>
<label class="quote-search">
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m20 20-3-3"/></svg>
<input id="quoteQ" type="search" placeholder="报价主题/客户名称" oninput="quotePage=1;renderQuotes()" />
</label>
</div>
<div class="erp-toolbar">
<button class="btn btn-primary" type="button" onclick="openQuoteDlg()">+ 新增</button>
<button class="btn" type="button" onclick="copyQuote()">
<svg class="btn-ico" viewBox="0 0 24 24"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M4 16V6a2 2 0 0 1 2-2h10"/></svg>
复制新增
</button>
<button class="btn" type="button" onclick="submitQuotes()">
<svg class="btn-ico" viewBox="0 0 24 24"><path d="M6 4h9l5 5v11H6z"/><path d="M14 4v5h5"/></svg>
提交审批
</button>
<button class="btn" type="button" onclick="genQuotePdf()">
<svg class="btn-ico" viewBox="0 0 24 24"><path d="M8 7V4h8v3M8 17H6a2 2 0 0 1-2-2V9h16v6a2 2 0 0 1-2 2h-2"/><rect x="8" y="14" width="8" height="6" rx="1"/></svg>
生成报价单
</button>
<button class="btn" type="button" onclick="voidQuotes()">
<svg class="btn-ico" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/><path d="M8 12h8"/></svg>
作废
</button>
<button class="btn" type="button" onclick="toast('演示:已导出当前列表')">
<svg class="btn-ico" viewBox="0 0 24 24"><path d="M12 4v10M8 10l4 4 4-4M5 18h14"/></svg>
导出
</button>
<div class="right">
<button class="erp-ico-btn" type="button" title="刷新" onclick="renderQuotes()">↻</button>
<button class="erp-ico-btn" type="button" title="列设置" onclick="toast('演示:列设置')">☰</button>
</div>
</div>
<article class="card erp-card">
<div class="erp-table-wrap" id="quoteTable"></div>
<div class="plan-foot">
<span class="muted" id="quoteSelHint">已选 0 项</span>
<span class="quote-pager" id="quotePager"></span>
</div>
</article>
</section>
</div>
<div id="mod-contract" class="hidden">
<section class="erp-page">
<div class="stats" id="ctStats"></div>
<div class="erp-tabs-row">
<div class="plan-tabs" id="ctTabs">
<button class="on" type="button" data-tab="全部">全部</button>
<button type="button" data-tab="昨日">昨日</button>
<button type="button" data-tab="今日">今日</button>
<button type="button" data-tab="明日">明日</button>
<button type="button" data-tab="本周">本周</button>
</div>
<label class="quote-search wide">
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m20 20-3-3"/></svg>
<input id="ctQ" type="search" placeholder="合同编号/合同名称/客户名称" oninput="ctPage=1;renderContracts()" />
</label>
</div>
<div class="erp-toolbar">
<button class="btn btn-primary" type="button" onclick="openCtDlg()">+ 新增</button>
<button class="btn" type="button" onclick="copyContract()">
<svg class="btn-ico" viewBox="0 0 24 24"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M4 16V6a2 2 0 0 1 2-2h10"/></svg>
合同复制
</button>
<button class="btn" type="button" onclick="submitContracts()">
<svg class="btn-ico" viewBox="0 0 24 24"><path d="M6 4h9l5 5v11H6z"/><path d="M14 4v5h5"/></svg>
提交审批
</button>
<button class="btn" type="button" onclick="openCtScan()">
<svg class="btn-ico" viewBox="0 0 24 24"><path d="M12 16V6M8 10l4-4 4 4"/><rect x="4" y="16" width="16" height="4" rx="1"/></svg>
上传扫描件
</button>
<div class="erp-drop" id="ctExportDrop">
<button class="btn" type="button" onclick="toggleCtExport(event)">
<svg class="btn-ico" viewBox="0 0 24 24"><path d="M12 4v10M8 10l4 4 4-4M5 18h14"/></svg>
导出
</button>
<div class="erp-drop-menu">
<button type="button" onclick="exportContracts('page')">导出当前页</button>
<button type="button" onclick="exportContracts('all')">导出全部</button>
</div>
</div>
<div class="right">
<button class="erp-ico-btn" type="button" title="刷新" onclick="renderContracts()">↻</button>
<button class="erp-ico-btn" type="button" title="列设置" onclick="toast('演示:列设置')">☰</button>
</div>
</div>
<article class="card erp-card">
<div class="erp-table-wrap" id="ctTable"></div>
<div class="plan-foot">
<span class="muted" id="ctSelHint">已选 0 项</span>
<span class="quote-pager" id="ctPager"></span>
</div>
</article>
</section>
</div>
<div id="mod-fleet" class="hidden">
<nav class="subnav" id="subnav">
<button class="on" type="button" data-view="dash">总览</button>
<button type="button" data-view="fleet">车辆台账</button>
<button type="button" data-view="fuel">加油登记管理</button>
<button type="button" data-view="oilcard">加油充值卡管理</button>
<button type="button" data-view="fuelwarn">油耗预警</button>
<button type="button" data-view="etc">ETC 申请登记</button>
<button type="button" data-view="maint">维修保养</button>
<button type="button" data-view="stats">统计</button>
</nav>
<section id="view-dash">
<div class="page-head">
<div>
<h1>危化品车辆作业</h1>
<p>公司自有危货车辆的加油、ETC 申请登记、维修保养,以及油耗与费用统计。</p>
</div>
<div class="actions">
<button class="btn" type="button" onclick="showView('fuel')">登记加油</button>
<button class="btn" type="button" onclick="showView('etc')">ETC 申请</button>
<button class="btn btn-primary" type="button" onclick="showView('maint')">登记维保</button>
</div>
</div>
<div class="alerts" id="dashAlerts"></div>
<div class="stats" id="dashStats"></div>
<div class="grid">
<div class="stack">
<article class="card">
<div class="card-h"><h2>日常登记</h2><span style="color:var(--muted);font-size:12px">同一车辆贯穿三类业务</span></div>
<div class="card-b">
<div class="quick">
<button class="qbtn" type="button" onclick="showView('fuel')"><div class="ico">油</div><h3>加油</h3><p>油品、升数、金额、里程、附件</p></button>
<button class="qbtn" type="button" onclick="showView('fuelwarn')"><div class="ico">警</div><h3>油耗预警</h3><p>超过 0.33 L/km 即预警</p></button>
<button class="qbtn" type="button" onclick="showView('etc')"><div class="ico">E</div><h3>ETC 申请</h3><p>自有车牌、期初公里、附件</p></button>
<button class="qbtn" type="button" onclick="showView('maint')"><div class="ico">保</div><h3>维修保养</h3><p>进厂、项目、费用、下次保养</p></button>
</div>
</div>
</article>
<article class="card">
<div class="card-h"><h2>车辆运行状态</h2><button class="btn" type="button" onclick="showView('fleet')">台账</button></div>
<div class="card-b" style="padding-top:4px"><div id="dashFleet"></div></div>
</article>
</div>
<div class="stack">
<article class="card">
<div class="card-h"><h2>最近登记</h2></div>
<div class="card-b"><div class="timeline" id="dashTl"></div></div>
</article>
<article class="card">
<div class="card-h"><h2>本月单车油耗(升)</h2><button class="btn" type="button" onclick="showView('fuelwarn')">油耗预警</button></div>
<div class="card-b"><div class="bars" id="dashBars"></div></div>
</article>
</div>
</div>
</section>
<section id="view-fleet" class="hidden">
<div class="page-head">
<div><h1>车辆台账</h1><p>危货牵引车 / 罐车基础信息与资质到期。</p></div>
</div>
<div class="filters">
<input id="fleetQ" placeholder="车牌 / 驾驶员" oninput="renderFleet()" />
<select id="fleetSt" onchange="renderFleet()">
<option value="">全部状态</option>
<option>运行</option>
<option>维保中</option>
<option>停运</option>
</select>
</div>
<article class="card"><div class="card-b" style="overflow:auto" id="fleetTable"></div></article>
</section>
<section id="view-fuel" class="hidden">
<div class="page-head">
<div><h1>加油登记管理</h1><p>按自有车辆查看当月加油汇总,点击「加油登记」录入小票、升数、金额与里程。</p></div>
<div class="actions">
<button class="btn btn-primary" type="button" onclick="openFuelDlg()">加油登记</button>
</div>
</div>
<div class="fuel-plate-grid" id="fuelPlateCards"></div>
<article class="card"><div class="card-h"><h2>加油记录</h2></div><div class="card-b" id="fuelTable"></div></article>
</section>
<section id="view-fuelwarn" class="hidden">
<div class="page-head">
<div>
<h1>油耗预警</h1>
<p>按相邻两次加油核对每公里油耗。标准:33 L / 100 km = <b>0.33 L/km</b>,超过即预警。</p>
</div>
</div>
<div class="stats" id="fuelwarnKpis"></div>
<div class="filters">
<select id="fuelwarnSt" onchange="renderFuelWarn()">
<option value="">全部区间</option>
<option value="预警">仅超标预警</option>
<option value="正常">仅正常</option>
<option value="不足">数据不足 / 异常</option>
</select>
<input id="fuelwarnQ" placeholder="车牌" oninput="renderFuelWarn()" />
</div>
<article class="card" style="margin-bottom:12px">
<div class="card-h"><h2>单车油耗对照(L/km)</h2><span style="color:var(--muted);font-size:12px">红线为 0.33</span></div>
<div class="card-b"><div class="bars" id="fuelwarnBars"></div></div>
</article>
<article class="card">
<div class="card-h"><h2>区间明细</h2></div>
<div class="card-b" id="fuelwarnTable"></div>
</article>
</section>
<section id="view-etc" class="hidden">
<div class="page-head">
<div><h1>ETC充值卡管理</h1><p>按自有车辆查看当月 ETC 充值次数、公里与金额,点击卡片或「新增申请」登记。</p></div>
<div class="actions">
<button class="btn btn-primary" type="button" onclick="openEtcDlg()">新增申请</button>
</div>
</div>
<div class="fuel-plate-grid" id="etcPlateCards"></div>
<article class="card"><div class="card-h"><h2>申请记录</h2></div><div class="card-b" id="etcTable"></div></article>
</section>
<section id="view-oilcard" class="hidden">
<div class="page-head">
<div><h1>加油充值卡管理</h1><p>按自有车辆查看当月油卡充值次数、公里与金额,点击卡片或「新增申请」登记。</p></div>
<div class="actions">
<button class="btn btn-primary" type="button" onclick="openOilCardDlg()">新增申请</button>
</div>
</div>
<div class="fuel-plate-grid" id="oilCardPlateCards"></div>
<article class="card"><div class="card-h"><h2>申请记录</h2></div><div class="card-b" id="oilCardTable"></div></article>
</section>
<section id="view-maint" class="hidden">
<div class="page-head">
<div><h1>车辆维保管理</h1><p>按自有车辆查看本年度维修次数、维修金额,以及本月 / 本年度合计。点击「新增申请」弹出表单登记。</p></div>
<div class="actions">
<button class="btn btn-primary" type="button" onclick="openMaintDlg()">新增申请</button>
</div>
</div>
<div class="fuel-plate-grid" id="maintPlateCards"></div>
<article class="card"><div class="card-h"><h2>维保记录</h2></div><div class="card-b" id="maintTable"></div></article>
</section>
<section id="view-stats" class="hidden">
<div class="page-head">
<div><h1>统计</h1><p>本月油耗、维保费用与 ETC 申请笔数,辅助单车成本对照。</p></div>
</div>
<div class="stats" id="statKpis"></div>
<div class="grid">
<article class="card">
<div class="card-h"><h2>单车加油量</h2></div>
<div class="card-b"><div class="bars" id="statBars"></div></div>
</article>
<article class="card">
<div class="card-h"><h2>本月费用构成</h2></div>
<div class="card-b">
<div class="pie-wrap">
<svg width="140" height="140" viewBox="0 0 32 32" id="pie"></svg>
<div class="legend" id="pieLegend"></div>
</div>
</div>
</article>
</div>
<article class="card" style="margin-top:12px">
<div class="card-h"><h2>资质到期一览</h2></div>
<div class="card-b" id="certTable"></div>
</article>
</section>
</div>
<div id="mod-plan" class="hidden">
<section id="view-plan" class="erp-page">
<div class="alerts" id="planAlerts"></div>
<div class="rel-flow">易制毒 / 易制爆计划须先 <em>上传购买证及运输证</em>,再在证件页 <em>人工点击关联对应计划</em>。系统按购买证、运输证数量计算各车运输计划量;计划完成后扣减已完成量;购买证或运输证余量 ≤ 32 吨则预警。</div>
<div class="stats" id="planKpis"></div>
<div class="erp-tabs-row">
<div class="plan-tabs" id="planTabs">
<button type="button" data-tab="待安排">待安排</button>
<button type="button" data-tab="已安排">已安排</button>
<button type="button" data-tab="草稿中">草稿中</button>
<button class="on" type="button" data-tab="全部">全部</button>
<button type="button" data-tab="入库单">入库单</button>
<button type="button" data-tab="出库单">出库单</button>
<button type="button" data-tab="直销单">直销单</button>
</div>
</div>
<div class="erp-tabs-row plan-action-row">
<div class="plan-tabs" id="planDateTabs">
<button class="on" type="button" data-tab="全部">全部</button>
<button type="button" data-tab="昨日">昨日</button>
<button type="button" data-tab="今日">今日</button>
<button type="button" data-tab="明日">明日</button>
<button type="button" data-tab="本周">本周</button>
</div>
<label class="quote-search plan-search">
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m20 20-3-3"/></svg>
<input id="planQ" type="search" placeholder="计划编号/供方/需方/产品名称/车牌号" oninput="renderPlans()" />
</label>
</div>
<div class="erp-toolbar plan-toolbar">
<button class="btn btn-primary" type="button" onclick="openPlanDlg()">+ 新增计划</button>
<button class="btn" type="button" onclick="copyPlan()">
<svg class="btn-ico" viewBox="0 0 24 24"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M4 16V6a2 2 0 0 1 2-2h10"/></svg>
复制
</button>
<button class="btn" type="button" onclick="openPlanTaskDlg()">
<svg class="btn-ico" viewBox="0 0 24 24"><path d="M8 6h12M8 12h12M8 18h8"/><path d="M4 6h.01M4 12h.01M4 18h.01"/></svg>
下发任务单
</button>
<button class="btn" type="button" onclick="printPlanDocs()">
<svg class="btn-ico" viewBox="0 0 24 24"><path d="M8 7V4h8v3M8 17H6a2 2 0 0 1-2-2V9h16v6a2 2 0 0 1-2 2h-2"/><rect x="8" y="14" width="8" height="6" rx="1"/></svg>
打印单据
</button>
<button class="btn" type="button" onclick="voidSelectedPlans()">
<svg class="btn-ico" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/><path d="M8 12h8"/></svg>
作废
</button>
<button class="btn btn-danger" type="button" onclick="createTriParty()">三方人车快速创建</button>
<button class="btn" type="button" onclick="toast('演示:已导出当前列表')">
<svg class="btn-ico" viewBox="0 0 24 24"><path d="M12 4v10M8 10l4 4 4-4M5 18h14"/></svg>
导出
</button>
<button class="btn" type="button" onclick="goLinkBuyTransCert()">去关联购买&amp;运输证</button>
</div>
<article class="card erp-card">
<div class="erp-table-wrap" id="planTable"></div>
<div class="plan-foot">
<span class="muted" id="planCount"></span>
<span class="muted">搜索合计 
<select class="page-size" id="planPageSize" onchange="renderPlans()">
<option>20条/页</option>
<option>50条/页</option>
</select>
</span>
</div>
</article>
</section>
</div>
<div id="mod-archive" class="hidden">
<section id="view-archive" class="erp-page">
<div class="page-head">
<div>
<h1>运输归档</h1>
<p>运输计划完成后进入本页待审。人工审核通过后归档:出库单 / 入库单按品名自动入对应仓库;直销单无中转仓库,不记库存。</p>
</div>
<div class="actions">
<button class="btn" type="button" onclick="showMod('plan')">返回运输计划</button>
<button class="btn btn-primary" type="button" onclick="showMod('warehouse')">查看仓储</button>
</div>
</div>
<div class="stats" id="archiveStats"></div>
<div class="erp-tabs-row">
<div class="plan-tabs" id="archiveTabs">
<button class="on" type="button" data-tab="待审核">待审核</button>
<button type="button" data-tab="已归档">已归档</button>
<button type="button" data-tab="全部">全部</button>
</div>
<div class="erp-hint" id="archiveHintBar"></div>
</div>
<div class="erp-toolbar">
<button class="btn btn-primary" type="button" onclick="batchReviewArchive()">审核归档</button>
<button class="btn" type="button" onclick="toast('演示:打印 / 导出')">打印 / 导出</button>
<input id="archiveQ" type="search" placeholder="单号 / 品名 / 客户 / 车牌" oninput="renderArchive()" />
<select id="archiveType" onchange="renderArchive()">
<option value="">全部类型</option>
<option value="出库单">出库单</option>
<option value="入直单">入库单</option>
<option value="直销单">直销单</option>
</select>
<select id="archiveWh" onchange="renderArchive()">
<option value="">全部仓库</option>
</select>
</div>
<article class="card erp-card">
<div class="erp-table-wrap" id="archiveTable"></div>
<div class="plan-foot">
<span class="muted" id="archiveCount"></span>
<span class="muted">出库 / 入库按品名入仓 直销无中转仓</span>
</div>
</article>
</section>
</div>
<div id="mod-task" class="hidden">
<section id="view-task" class="erp-page">
<div class="page-head">
<div>
<h1>任务单管理</h1>
<p>运输计划下发给现场后进入本页。现场人员接收任务后可回传完成:稀释、精制、分装。</p>
</div>
<div class="actions">
<button class="btn" type="button" onclick="showMod('plan')">返回运输计划</button>
<button class="btn btn-primary" type="button" onclick="showMod('plan');toast('勾选运输计划后点「下发任务单」')">从计划下发</button>
</div>
</div>
<div class="stats" id="taskStats"></div>
<div class="erp-tabs-row">
<div class="plan-tabs" id="taskTabs">
<button class="on" type="button" data-tab="全部">全部</button>
<button type="button" data-tab="稀释">稀释任务单</button>
<button type="button" data-tab="精制">精制任务单</button>
<button type="button" data-tab="分装">分装任务单</button>
<button type="button" data-tab="待执行">待执行</button>
<button type="button" data-tab="已完成">已完成</button>
</div>
<div class="erp-hint" id="taskHint"></div>
</div>
<div class="erp-toolbar">
<button class="btn" type="button" onclick="batchReceiveTask()">接收任务</button>
<button class="btn btn-primary" type="button" onclick="batchReportTask()">回传完成</button>
<span class="split"></span>
<input id="taskQ" type="search" placeholder="单号 / 品名 / 执行人 / 计划号" oninput="renderTasks()" />
<select id="taskSt" onchange="renderTasks()">
<option value="">全部状态</option>
<option>待执行</option>
<option>执行中</option>
<option>已完成</option>
</select>
</div>
<article class="card erp-card">
<div class="erp-table-wrap" id="taskTable"></div>
<div class="plan-foot">
<span class="muted" id="taskCount"></span>
<span class="muted">现场接收后回传完成</span>
</div>
</article>
</section>
</div>
<div id="mod-cert" class="hidden">
<nav class="subnav" id="certSubnav">
<button class="on" type="button" data-view="wait">待关联计划</button>
<button type="button" data-view="list">证件列表</button>
<button type="button" data-view="detail" id="navDetail" disabled>证件信息</button>
</nav>
<section id="view-cert-wait">
<div class="page-head">
<div>
<h1>待关联计划</h1>
<p>已安排、易制毒且已选车号的运输计划,在此关联购买证和运输证。</p>
</div>
</div>
<div class="stats three" id="waitStats"></div>
<article class="card wait-link-box" id="certWaitCard">
<div class="card-h">
<h2>待关联计划</h2>
</div>
<div class="card-b" id="certWaitPlans"></div>
</article>
</section>
<section id="view-cert-list" class="hidden">
<div class="page-head">
<div>
<h1>购买证 / 运输证</h1>
<p>流程固定三步:先上传购买证和运输证,再人工点击关联对应计划;系统按两证数量计算各车运输计划量,计划完成后扣减已完成量;购买证或运输证余量 ≤ 自设预警量时预警(默认核准总量的 10%,可改)。</p>
</div>
<div class="actions">
<button class="btn btn-primary" type="button" onclick="openCertDlg(false)">上传购买证及运输证</button>
</div>
</div>
<div class="cert-steps hidden" id="certSteps"></div>
<div class="alerts hidden" id="listAlerts"></div>
<div class="stats" id="listStats"></div>
<div class="filters">
<input id="certQ" placeholder="证号 / 品名 / 购买单位 / 销售单位 / 车号" oninput="renderCertList()" />
<select id="certSt" onchange="renderCertList()">
<option value="">全部状态</option>
<option>有余量</option>
<option>余量预警</option>
<option>运输证预警</option>
<option>已用尽</option>
<option>即将到期</option>
<option>超证</option>
<option>车辆预警</option>
<option>已核销</option>
</select>
<button class="btn" type="button" onclick="openCertDlg(false)">上传购买证及运输证</button>
</div>
<article class="card">
<div class="card-h"><h2>购买证及运输证</h2><span class="muted" id="listCount"></span></div>
<div class="card-b" style="overflow:auto" id="certListTable"></div>
</article>
</section>
<section id="view-cert-detail" class="hidden">
<div class="page-head">
<div>
<h1 id="detailTitle">证件信息</h1>
<p id="detailSub">由购买证与运输证识别合并生成</p>
</div>
<div class="actions">
<button class="btn" type="button" onclick="showCertView('list')">返回列表</button>
<button class="btn" type="button" onclick="openCertLinkPlanDlg(currentCertId)">关联计划</button>
<button class="btn btn-primary" type="button" onclick="openCertDlg(false, currentCertId)">追加运输证</button>
</div>
</div>
<div class="rel-flow" id="certRelHint"></div>
<div class="stats" id="detailStats"></div>
<article class="card" id="certWarnQtyCard" style="margin-top:12px"></article>
<div class="alerts" id="detailWarns"></div>
<article class="card" style="margin-top:12px">
<div class="card-h">
<h2>关联车辆计划量预警</h2>
</div>
<div class="card-b veh-board">
<div class="qty-formula" id="certQtyFormula"></div>
<div id="certVehicleWarn"></div>
</div>
</article>
<div id="detailSheet"></div>
<article class="card" style="margin-top:12px">
<div class="card-h">
<h2>已关联运输计划</h2>
</div>
<div class="card-b" id="certLinkedPlans"></div>
</article>
<article class="card" style="margin-top:12px">
<div class="card-h">
<h2>按运输证分派车辆</h2>
<div class="actions">
<button class="btn btn-primary" type="button" onclick="openCertDlg(false, currentCertId)">追加运输证</button>
</div>
</div>
<div class="card-b">
<p class="muted" id="dispatchHint" style="margin-bottom:10px"></p>
<div id="detailDispatch"></div>
</div>
</article>
</section>
</div>
<div id="mod-expense" class="hidden">
<section id="view-expense" class="erp-page">
<div class="page-head">
<div>
<h1>报销单据管理</h1>
</div>
<div class="actions">
<button class="btn" type="button" onclick="openPreExpDlg()">备用金申请</button>
<button class="btn btn-primary" type="button" onclick="openExpenseDlg()">填报报销单</button>
</div>
</div>
<div class="exp-board" id="expFlow"></div>
<div class="alerts" id="expAlerts"></div>
<div class="erp-tabs-row">
<div class="plan-tabs" id="expTabs">
<button class="on" type="button" data-tab="全部">全部</button>
<button type="button" data-tab="我的报销">我的报销</button>
<button type="button" data-tab="备用金申请">我的备用金</button>
<button type="button" data-tab="待财务初审">财务初审</button>
<button type="button" data-tab="待终审">领导审核</button>
<button type="button" data-tab="待付款">出纳付款</button>
<button type="button" data-tab="待归档">凭证归档</button>
<button type="button" data-tab="借款">借款 / 冲销</button>
</div>
<div class="erp-hint" id="expHint"></div>
</div>
<div class="erp-toolbar" id="expToolbar"></div>
<article class="card erp-card">
<div class="erp-table-wrap" id="expTable"></div>
<div class="plan-foot">
<span class="muted" id="expCount"></span>
<span class="muted" id="expFootHint">报销:财务核票 → 领导审核 → 出纳付款 备用金:财务核额 → 先支出 → 事后报销冲销</span>
</div>
</article>
</section>
</div>
<div id="mod-duty" class="hidden">
<section id="view-duty" class="erp-page">
<div class="duty-bar">
<div class="duty-brand">
<svg viewBox="0 0 24 24"><rect x="4" y="5" width="16" height="15" rx="2"/><path d="M8 3v4M16 3v4M4 10h16"/></svg>
值班排班
</div>
<div class="duty-nav">
<button class="duty-ico" type="button" onclick="shiftDutyMonth(-1)" aria-label="上一月"><svg viewBox="0 0 24 24"><path d="M14 6 8 12l6 6"/></svg></button>
<div class="m" id="dutyMonthLab">2026年10月</div>
<button class="duty-ico" type="button" onclick="shiftDutyMonth(1)" aria-label="下一月"><svg viewBox="0 0 24 24"><path d="m10 6 6 6-6 6"/></svg></button>
</div>
<button class="btn" type="button" onclick="goDutyToday()">今天</button>
<div class="duty-legend">
<span><i class="am"></i>早班</span>
<span><i class="pm"></i>晚班</span>
<span><i class="watch"></i>值班</span>
<span><i class="other"></i>其他</span>
<span>点击日期配置</span>
<button class="duty-link" type="button" onclick="copyDutyLink()">复制链接</button>
</div>
</div>
<div class="duty-stats">
<div class="duty-pills" id="dutyPills"></div>
<div class="duty-people" id="dutyPeople"></div>
</div>
<div class="duty-cal">
<div class="duty-week">
<span>日</span><span>一</span><span>二</span><span>三</span><span>四</span><span>五</span><span>六</span>
</div>
<div class="duty-grid" id="dutyGrid"></div>
</div>
</section>
</div>
<div id="mod-staff" class="hidden">
<section id="view-staff" class="erp-page">
<div class="stats" id="staffStats"></div>
<div class="erp-tabs-row">
<div class="plan-tabs" id="staffTabs">
<button class="on" type="button" data-tab="全部" onclick="setStaffTab('全部')">全部</button>
<button type="button" data-tab="在职" onclick="setStaffTab('在职')">在职</button>
<button type="button" data-tab="试用" onclick="setStaffTab('试用')">试用</button>
<button type="button" data-tab="离职" onclick="setStaffTab('离职')">离职</button>
</div>
<select class="staff-dept-sel" id="staffDept" onchange="staffPage=1;renderStaff()">
<option value="">全部部门</option>
</select>
<label class="quote-search wide">
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m20 20-3-3"/></svg>
<input id="staffQ" type="search" placeholder="姓名 / 工号 / 岗位 / 手机" oninput="staffPage=1;renderStaff()" />
</label>
</div>
<div class="erp-toolbar">
<button class="btn btn-primary" type="button" onclick="openStaffDlg()">+ 新增员工</button>
<button class="btn" type="button" onclick="editSelectedStaff()">
<svg class="btn-ico" viewBox="0 0 24 24"><path d="M4 20h4l11-11-4-4L4 16v4z"/><path d="m13 7 4 4"/></svg>
编辑
</button>
<button class="btn" type="button" onclick="leaveSelectedStaff()">
<svg class="btn-ico" viewBox="0 0 24 24"><path d="M15 8v8M9 12h12M9 12l3-3M9 12l3 3M5 5v14"/></svg>
办理离职
</button>
<button class="btn" type="button" onclick="toast('演示:已导出员工档案')">
<svg class="btn-ico" viewBox="0 0 24 24"><path d="M12 4v10M8 10l4 4 4-4M5 18h14"/></svg>
导出
</button>
<div class="right">
<button class="erp-ico-btn" type="button" title="刷新" onclick="staffPage=1;renderStaff()">↻</button>
<button class="erp-ico-btn" type="button" title="列设置" onclick="toast('演示:列设置')">☰</button>
</div>
</div>
<article class="card erp-card">
<div class="erp-table-wrap" id="staffTable"></div>
<div class="plan-foot">
<span class="muted" id="staffSelHint">已选 0 项</span>
<span class="quote-pager" id="staffPager"></span>
</div>
</article>
</section>
</div>
<div id="mod-warehouse" class="hidden">
<nav class="subnav" id="whSubnav">
<button class="on" type="button" data-view="info" onclick="showWhView('info')">库存信息表</button>
<button type="button" data-view="mode" onclick="showWhView('mode')">仓库信息表</button>
<button type="button" data-view="proc" onclick="showWhView('proc')">加工单明细表</button>
<button type="button" data-view="stock" onclick="showWhView('stock')">库存成本明细表</button>
</nav>
<div class="page-head">
<div>
<h1 id="whPageTitle">库存信息表</h1>
<p id="whPageDesc">98% 工业硫酸可直销、加水稀释。试剂硫酸单独成线。工业 / 试剂分罐。</p>
</div>
<div class="actions" id="whPageActions"></div>
</div>
<div class="wh-tree">
<article class="card wh-nav-card">
<div class="wh-nav" id="whNav"></div>
</article>
<div class="stack">
<div id="view-wh-info"></div>
<div id="view-wh-mode" class="hidden"></div>
<div id="view-wh-proc" class="hidden"></div>
<div id="view-wh-stock" class="hidden">
<div class="wh-kpis" id="whKpis"></div>
<article class="card" id="whLedgerCard">
<div class="card-h">
<h2 id="whTableTitle">库存成本明细账</h2>
<span class="muted" id="whTableCount"></span>
</div>
<div class="filters" style="padding: 10px 14px 0">
<input id="whQ" placeholder="单号 / 物料 / 往来单位 / 单据类型" oninput="renderWhTable()" />
<select id="whDocFilter" onchange="onWhDocFilter()">
<option value="">全部单据</option>
<option>进货单</option>
<option>产成品入库单</option>
<option>其他入库单</option>
<option>调拨单</option>
<option>组装单</option>
<option>形态转换单</option>
<option>拆卸单</option>
<option>盘点单</option>
<option>销货单</option>
<option>材料出库单</option>
<option>其他出库单</option>
</select>
</div>
<div class="card-b" style="overflow:auto" id="whTable"></div>
</article>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
<div class="toast" id="toast">已保存</div>
<dialog class="apply-dlg" id="prodDialog">
<div class="dlg-h green">
<h3 id="prodDlgTitle">新增产品</h3>
<button class="dlg-x" type="button" aria-label="关闭" onclick="document.getElementById('prodDialog').close()">×</button>
</div>
<form id="prodForm" onsubmit="return false">
<input type="hidden" name="id" />
<div class="dlg-b">
<div class="apply">
<div class="row"><div class="lab"><span class="star">*</span>产品名称</div><input name="name" required placeholder="如 工业硫酸" /></div>
<div class="row"><div class="lab">产品编号</div><input name="no" placeholder="留空则自动生成" /></div>
<div class="row"><div class="lab">产品分类</div>
<select name="cat" id="prodCatSelect"></select>
</div>
<div class="row"><div class="lab">品牌</div><input name="brand" placeholder="如 益泉" /></div>
<div class="row"><div class="lab">别名</div><input name="alias" /></div>
<div class="row"><div class="lab">规格型号</div><input name="spec" placeholder="如 98%" /></div>
<div class="row"><div class="lab">标准价(元)</div><input name="price" type="number" step="0.01" min="0" placeholder="选填" /></div>
<div class="row"><div class="lab">拼音首字母</div><input name="py" placeholder="如 GYSF" /></div>
<div class="row"><div class="lab">危化标识</div>
<select name="hz"><option value="">无</option><option>毒</option><option>爆</option></select>
</div>
<div class="row"><div class="lab">状态</div>
<select name="status"><option>上架</option><option>下架</option></select>
</div>
</div>
</div>
<div class="dlg-f">
<button class="btn" type="button" onclick="document.getElementById('prodDialog').close()">取消</button>
<button class="btn btn-primary" type="button" onclick="saveProd()">保存</button>
</div>
</form>
</dialog>
<dialog class="apply-dlg" id="prodCatDialog" style="width:480px;max-width:92vw">
<div class="dlg-h green">
<h3>产品分类</h3>
<button class="dlg-x" type="button" aria-label="关闭" onclick="document.getElementById('prodCatDialog').close()">×</button>
</div>
<div class="dlg-b">
<div class="prod-cat-list" id="prodCatList"></div>
<div style="display:flex;gap:8px;margin-top:12px">
<input id="prodCatNew" placeholder="新分类名称" style="flex:1;height:32px;border:1px solid var(--line);border-radius:8px;padding:0 10px" />
<button class="btn btn-primary" type="button" onclick="addProdCat()">新增</button>
</div>
</div>
<div class="dlg-f">
<button class="btn" type="button" onclick="document.getElementById('prodCatDialog').close()">关闭</button>
</div>
</dialog>
<dialog class="apply-dlg" id="quoteDialog">
<div class="dlg-h green">
<h3 id="quoteDlgTitle">新增报价单</h3>
<button class="dlg-x" type="button" aria-label="关闭" onclick="document.getElementById('quoteDialog').close()">×</button>
</div>
<form id="quoteForm" onsubmit="return false">
<div class="dlg-b">
<div class="apply">
<div class="row"><div class="lab"><span class="star">*</span>客户名称</div><input name="customer" required placeholder="如 安庆汇辰科技有限公司" /></div>
<div class="row"><div class="lab"><span class="star">*</span>报价主题</div><input name="title" required placeholder="如 山峡-安庆汇辰" /></div>
<div class="row"><div class="lab">报价类型</div>
<select name="kind"><option>销售</option><option>代工</option><option>询价</option></select>
</div>
<div class="row"><div class="lab">报价日期</div><input name="date" type="date" required /></div>
<div class="row"><div class="lab"><span class="star">*</span>产品信息</div><input name="product" required placeholder="如 片碱 2吨" /></div>
<div class="row"><div class="lab"><span class="star">*</span>产品金额</div><input name="prodAmt" type="number" step="0.01" min="0" required /></div>
<div class="row"><div class="lab">运费</div><input name="freight" type="number" step="0.01" min="0" value="0" /></div>
</div>
</div>
<div class="dlg-f">
<button class="btn" type="button" onclick="document.getElementById('quoteDialog').close()">取消</button>
<button class="btn btn-primary" type="button" onclick="saveQuote()">保存草稿</button>
</div>
</form>
</dialog>
<dialog class="apply-dlg" id="ctDialog">
<div class="dlg-h green">
<h3 id="ctDlgTitle">新增合同</h3>
<button class="dlg-x" type="button" aria-label="关闭" onclick="document.getElementById('ctDialog').close()">×</button>
</div>
<form id="ctForm" onsubmit="return false">
<div class="dlg-b">
<div class="apply">
<div class="row"><div class="lab"><span class="star">*</span>客户名称</div><input name="customer" required placeholder="如 安庆汇辰科技有限公司" /></div>
<div class="row"><div class="lab"><span class="star">*</span>合同名称</div><input name="title" required placeholder="如 山峡-安庆汇辰" /></div>
<div class="row"><div class="lab">合同类型</div>
<select name="kind"><option>销售合同</option><option>框架销售合同</option><option>采购合同</option></select>
</div>
<div class="row"><div class="lab">签订日期</div><input name="signDate" type="date" required /></div>
<div class="row"><div class="lab"><span class="star">*</span>产品信息</div><input name="product" required placeholder="如 片碱 2吨" /></div>
<div class="row"><div class="lab"><span class="star">*</span>合同金额</div><input name="amount" type="number" step="0.01" min="0" required /></div>
<div class="row"><div class="lab">运费</div><input name="freight" type="number" step="0.01" min="0" value="0" /></div>
<div class="row"><div class="lab">桶押金</div><input name="deposit" type="number" step="0.01" min="0" value="0" /></div>
<div class="row"><div class="lab">负责人</div><input name="owner" value="万金强" /></div>
</div>
</div>
<div class="dlg-f">
<button class="btn" type="button" onclick="document.getElementById('ctDialog').close()">取消</button>
<button class="btn btn-primary" type="button" onclick="saveContract()">保存草稿</button>
</div>
</form>
</dialog>
<dialog class="apply-dlg" id="ctScanDialog">
<div class="dlg-h green">
<h3>上传扫描件</h3>
<button class="dlg-x" type="button" aria-label="关闭" onclick="document.getElementById('ctScanDialog').close()">×</button>
</div>
<form id="ctScanForm" onsubmit="return false">
<div class="dlg-b">
<p class="muted" id="ctScanHint" style="margin-bottom:12px">为所选合同上传双章 / 单章扫描件。</p>
<div class="apply">
<div class="row"><div class="lab">附件类型</div>
<select name="att"><option>双章</option><option>单章</option></select>
</div>
<div class="row"><div class="lab"><span class="star">*</span>扫描件</div>
<input name="file" type="file" accept="application/pdf,image/*" required />
</div>
</div>
</div>
<div class="dlg-f">
<button class="btn" type="button" onclick="document.getElementById('ctScanDialog').close()">取消</button>
<button class="btn btn-primary" type="button" onclick="saveCtScan()">保存</button>
</div>
</form>
</dialog>
<dialog class="exp-dlg" id="expenseDialog">
<div class="dlg-h green">
<h3 id="expDlgTitle">新增报销申请</h3>
<button class="dlg-x" type="button" aria-label="关闭" onclick="closeExpenseDlg()">×</button>
</div>
<form id="expenseForm" onsubmit="return false">
<div class="dlg-b">
<div class="exp-new-top">
<label>申请人
<select name="user" required onchange="onExpUserChange()"></select>
</label>
<label>报销日期
<div class="exp-field-date">
<svg viewBox="0 0 24 24"><rect x="4" y="6" width="16" height="14" rx="2"/><path d="M8 4v4M16 4v4M4 11h16"/></svg>
<input name="date" type="date" required onchange="syncExpTitle()" />
</div>
</label>
<label>报销标题
<input name="title" required placeholder="自动生成,可修改" />
</label>
</div>
<div class="exp-block tight">
<h4>报销明细</h4>
<div class="exp-line-wrap">
<table class="exp-line-table">
<thead>
<tr>
<th style="width:52px">序号</th>
<th>费用项目 <span class="star">*</span></th>
<th style="width:120px">金额(元) <span class="star">*</span></th>
<th>报销事由 <span class="star">*</span></th>
<th style="width:150px">发生时间</th>
</tr>
</thead>
<tbody id="expLineBody"></tbody>
</table>
</div>
<div class="exp-line-foot">
<button class="btn" type="button" onclick="addExpLine()">+ 新增明细行</button>
<div class="exp-total">合计金额(元):<b id="expLineSum">¥0.00</b></div>
</div>
</div>
<div class="exp-block">
<h4>关联发票 <span class="opt">(可选)</span></h4>
<div class="exp-inv-meta">当前发票余额:<b id="expInvBal">¥0.00</b> ;本次合计:<b id="expInvSum">¥0.00</b></div>
<div class="exp-inv-chips" id="expInvChips"></div>
<button class="btn" type="button" onclick="openInvPick()">
<svg class="btn-ico" viewBox="0 0 24 24"><path d="M4 7h5l2 2h9v10H4z"/></svg>
从发票夹选择
</button>
</div>
<div class="exp-block">
<h4>申请附件 <span class="opt">(可选)</span></h4>
<label class="exp-up">
<input name="attach" type="file" multiple accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.zip,.rar" onchange="previewExpFiles(this)" />
<span class="plus">+</span>
<span>上传附件</span>
</label>
<div class="chips" id="expFileChips" style="margin-top:8px"></div>
<p class="hint">补充行程单、说明截图、合同等;与发票夹无关。支持图片、视频、PDF、Office、压缩包等。</p>
</div>
<div class="exp-block">
<h4>备注说明</h4>
<textarea class="exp-note" name="remark" placeholder="请输入报销说明"></textarea>
</div>
</div>
<div class="dlg-f">
<button class="btn" type="button" onclick="closeExpenseDlg()">× 取消</button>
<button class="btn" type="button" onclick="saveExpense(false)">
<svg class="btn-ico" viewBox="0 0 24 24"><path d="M6 4h9l5 5v11H6z"/><path d="M14 4v5h5M8 13h8M8 17h6"/></svg>
保存草稿
</button>
<button class="btn btn-primary" type="button" onclick="saveExpense(true)">
<svg class="btn-ico" viewBox="0 0 24 24"><path d="M4 12 20 4l-6 16-2-7z"/></svg>
提交审批
</button>
</div>
</form>
</dialog>
<dialog class="apply-dlg" id="invPickDialog">
<div class="dlg-h green">
<h3>从发票夹选择</h3>
<button class="dlg-x" type="button" aria-label="关闭" onclick="document.getElementById('invPickDialog').close()">×</button>
</div>
<div class="dlg-b">
<p class="muted" style="margin-bottom:10px">勾选未核销发票,关联到本张报销单。可选。</p>
<div class="inv-pick-list" id="invPickList"></div>
</div>
<div class="dlg-f">
<button class="btn" type="button" onclick="document.getElementById('invPickDialog').close()">取消</button>
<button class="btn btn-primary" type="button" onclick="confirmInvPick()">确认选择</button>
</div>
</dialog>
<dialog class="apply-dlg" id="preExpDialog">
<div class="dlg-h green">
<h3>备用金申请</h3>
<button class="dlg-x" type="button" aria-label="关闭" onclick="document.getElementById('preExpDialog').close()">×</button>
</div>
<form id="preExpForm" onsubmit="return false">
<div class="dlg-b">
<p class="muted" style="margin-bottom:12px">事前申请,无需发票。财务核额通过后再支出,事后凭票报销冲销。与报销单(事后贴票结算)不是同一条流程。</p>
<div class="apply">
<div class="row"><div class="lab"><span class="star">*</span>申请人</div><input name="user" required value="周建军" /></div>
<div class="row"><div class="lab"><span class="star">*</span>部门</div>
<select name="dept" required>
<option>运输部</option><option>生产部</option><option>行政人事</option><option>销售部</option><option>仓储部</option>
</select>
</div>
<div class="row"><div class="lab"><span class="star">*</span>事项</div>
<select name="type" required>
<option>出差</option><option>采购</option><option>招待</option><option>其他</option>
</select>
</div>
<div class="row"><div class="lab"><span class="star">*</span>预估金额</div><input name="amount" type="number" min="0" step="0.01" required /></div>
<div class="row full"><div class="lab"><span class="star">*</span>申请事由</div><textarea name="remark" required placeholder="为何支出、预计发生时间"></textarea></div>
</div>
</div>
<div class="dlg-f">
<button class="btn" type="button" onclick="document.getElementById('preExpDialog').close()">取消</button>
<button class="btn btn-primary" type="button" onclick="savePreExp()">提交申请</button>
</div>
</form>
</dialog>
<dialog class="wide-dlg" id="expDetailDialog">
<div class="dlg-h green">
<h3 id="expDetailTitle">报销单</h3>
<button class="dlg-x" type="button" aria-label="关闭" onclick="document.getElementById('expDetailDialog').close()">×</button>
</div>
<div class="dlg-b" id="expDetailBody"></div>
<div class="dlg-f" id="expDetailFoot"></div>
</dialog>
<dialog class="apply-dlg" id="expRejectDlg">
<div class="dlg-h">
<h3>驳回 / 退回补资料</h3>
<button class="dlg-x" type="button" aria-label="关闭" onclick="document.getElementById('expRejectDlg').close()">×</button>
</div>
<form id="expRejectForm" onsubmit="return false">
<div class="dlg-b">
<div class="apply">
<div class="row full"><div class="lab"><span class="star">*</span>原因</div>
<textarea name="reason" required placeholder="须写明原因,便于经办人补资料后重新发起"></textarea>
</div>
</div>
</div>
<div class="dlg-f">
<button class="btn" type="button" onclick="document.getElementById('expRejectDlg').close()">取消</button>
<button class="btn btn-primary" type="button" onclick="confirmExpReject()">确认退回</button>
</div>
</form>
</dialog>
<dialog class="apply-dlg" id="dutyDialog" style="width:520px;max-width:94vw">
<div class="dlg-h green">
<h3 id="dutyDlgTitle">值班配置</h3>
<button class="dlg-x" type="button" aria-label="关闭" onclick="document.getElementById('dutyDialog').close()">×</button>
</div>
<form id="dutyForm" onsubmit="return false">
<div class="dlg-b">
<div class="apply">
<div class="row"><div class="lab">班次</div>
<select name="type">
<option value="am">早班</option>
<option value="pm">晚班</option>
<option value="watch">值班</option>
<option value="other">其他</option>
</select>
</div>
<div class="row"><div class="lab">岗位</div>
<input name="post" value="配酸" />
</div>
<div class="row full"><div class="lab">人员</div>
<div id="dutyStaffPick" class="chips" style="gap:8px"></div>
</div>
</div>
<div id="dutyExist" style="margin-top:12px"></div>
</div>
<div class="dlg-f">
<button class="btn" type="button" onclick="document.getElementById('dutyDialog').close()">取消</button>
<button class="btn btn-primary" type="button" onclick="saveDutyShift()">保存排班</button>
</div>
</form>
</dialog>
<dialog class="apply-dlg" id="staffDialog">
<div class="dlg-h green">
<h3 id="staffDlgTitle">新增员工</h3>
<button class="dlg-x" type="button" aria-label="关闭" onclick="document.getElementById('staffDialog').close()">×</button>
</div>
<form id="staffForm" onsubmit="return false">
<input type="hidden" name="id" />
<div class="dlg-b">
<div class="apply">
<div class="row"><div class="lab"><span class="star">*</span>姓名</div><input name="name" required placeholder="如 吴九华" /></div>
<div class="row"><div class="lab">工号</div><input name="no" placeholder="留空则自动生成" /></div>
<div class="row"><div class="lab">性别</div>
<select name="gender"><option>男</option><option>女</option></select>
</div>
<div class="row"><div class="lab"><span class="star">*</span>部门</div>
<select name="dept" id="staffDeptSelect"></select>
</div>
<div class="row"><div class="lab"><span class="star">*</span>岗位</div><input name="post" required placeholder="如 驾驶员" /></div>
<div class="row"><div class="lab">手机</div><input name="phone" placeholder="11 位手机号" /></div>
<div class="row"><div class="lab">入职日期</div><input name="hire" type="date" /></div>
<div class="row"><div class="lab">合同到期</div><input name="contractEnd" type="date" /></div>
<div class="row"><div class="lab">用工类型</div>
<select name="kind"><option>劳动合同</option><option>劳务合同</option><option>实习</option></select>
</div>
<div class="row"><div class="lab">状态</div>
<select name="status"><option>在职</option><option>试用</option><option>离职</option></select>
</div>
<div class="row"><div class="lab">学历</div>
<select name="edu"><option>本科</option><option>大专</option><option>高中</option><option>中专</option><option>初中</option></select>
</div>
<div class="row"><div class="lab">籍贯</div><input name="native" placeholder="如 安徽安庆" /></div>
<div class="row"><div class="lab">紧急联系人</div><input name="emergency" /></div>
<div class="row"><div class="lab">联系人电话</div><input name="emergPhone" /></div>
<div class="row full"><div class="lab">备注</div><textarea name="remark" placeholder="证件、资质、特殊说明等"></textarea></div>
</div>
</div>
<div class="dlg-f">
<button class="btn" type="button" onclick="document.getElementById('staffDialog').close()">取消</button>
<button class="btn btn-primary" type="button" onclick="saveStaff()">保存</button>
</div>
</form>
</dialog>
<dialog class="apply-dlg" id="staffDetailDialog">
<div class="dlg-h green">
<h3 id="staffDetailTitle">员工档案</h3>
<button class="dlg-x" type="button" aria-label="关闭" onclick="document.getElementById('staffDetailDialog').close()">×</button>
</div>
<div class="dlg-b" id="staffDetailBody"></div>
<div class="dlg-f">
<button class="btn" type="button" onclick="document.getElementById('staffDetailDialog').close()">关闭</button>
<button class="btn btn-primary" type="button" onclick="editStaffFromDetail()">编辑</button>
</div>
</dialog>
<dialog class="apply-dlg" id="acidDlg">
<div class="dlg-h green">
<h3>加水 / 加工转换</h3>
<button class="dlg-x" type="button" aria-label="关闭" onclick="closeAcidDlg()">×</button>
</div>
<form id="acidForm" onsubmit="return false">
<div class="dlg-b">
<p class="muted" style="margin-bottom:12px" id="acidDlgHint">98% 工业硫酸可加水稀释。工业 / 试剂分线,不得混兑。</p>
<div class="apply">
<div class="row"><div class="lab"><span class="star">*</span>原料酸</div>
<select name="rawId" required onchange="onAcidRawChange()"></select>
</div>
<div class="row"><div class="lab"><span class="star">*</span>加工去向</div>
<select name="target" required onchange="onAcidTarget()">
<option value="30">加水 · 30%</option>
<option value="50">加水 · 50%</option>
<option value="custom">加水 · 其他定制浓度</option>
</select>
</div>
<div class="row" id="acidCustomRow" style="display:none"><div class="lab"><span class="star">*</span>定制浓度</div>
<input name="customPct" type="number" min="1" max="97" step="0.1" placeholder="如 40、70、80" oninput="updateAcidCalc()" />
</div>
<div class="row"><div class="lab"><span class="star">*</span>成品数量</div>
<input name="outQty" type="number" min="0.01" step="0.01" value="20" required oninput="updateAcidCalc()" />
</div>
</div>
<div class="calc-box" id="acidCalc"></div>
</div>
<div class="dlg-f">
<button class="btn" type="button" onclick="closeAcidDlg()">取消</button>
<button class="btn" type="button" onclick="saveAcidConvert(false)">确认转换</button>
<button class="btn btn-primary" type="button" onclick="saveAcidConvert(true)">确认并打印加工单</button>
</div>
</form>
</dialog>
<dialog id="attachDialog">
<div class="dlg-h">
<h3 id="attachTitle">附件</h3>
<button class="btn" type="button" onclick="closeAttach()">关闭</button>
</div>
<div class="dlg-b" id="attachBody"></div>
</dialog>
<dialog class="apply-dlg wide-dlg" id="fuelDialog">
<div class="dlg-h green">
<h3>加油登记</h3>
<button class="dlg-x" type="button" aria-label="关闭" onclick="closeFuelDlg()">×</button>
</div>
<form class="form" id="fuelForm" onsubmit="return addFuel(event)" style="grid-template-columns:1fr;padding:0;display:flex;flex-direction:column;gap:0">
<div class="dlg-b" style="display:grid;grid-template-columns:1fr 1fr;gap:10px;width:100%">
<label>车辆<select name="plate" required></select></label>
<label>日期<input name="date" type="date" required /></label>
<label>加油站<input name="station" required placeholder="如 中石化猇亭站" /></label>
<label>油品<select name="oil"><option>0# 柴油</option><option>-10# 柴油</option><option>LNG</option></select></label>
<label>升数<input name="liters" type="number" step="0.01" required /></label>
<label>金额(元)<input name="amount" type="number" step="0.01" required /></label>
<label>当前里程(km)<input name="km" type="number" required /></label>
<label>备注<input name="note" placeholder="可选" /></label>
<label class="full" style="grid-column:1 / -1">附件(必传)
<div class="file-box">
<input name="attach" type="file" required multiple accept="image/*,.pdf,application/pdf" onchange="previewFuelFiles(this)" />
<span class="file-hint">须上传加油小票、发票或加油凭证(图片或 PDF,可多选)</span>
<div class="chips" id="fuelFileChips"></div>
</div>
</label>
</div>
<div class="dlg-f" style="grid-column:1 / -1">
<button class="btn" type="button" onclick="closeFuelDlg()">取消</button>
<button class="btn btn-primary" type="submit">保存登记</button>
</div>
</form>
</dialog>
<dialog class="apply-dlg wide-dlg" id="maintDialog">
<div class="dlg-h green">
<h3>新增申请</h3>
<button class="dlg-x" type="button" aria-label="关闭" onclick="closeMaintDlg()">×</button>
</div>
<form id="maintForm" onsubmit="return addMaint(event)">
<div class="dlg-b">
<div class="apply">
<div class="row">
<div class="lab"><span class="star">*</span>申请人</div>
<select name="applicant" required></select>
</div>
<div class="row">
<div class="lab"><span class="star">*</span>车牌号</div>
<select name="plate" required>
<option value="">请选择车牌</option>
</select>
</div>
<div class="row">
<div class="lab"><span class="star">*</span>维保类型</div>
<select name="type" required>
<option value="">请选择或输入维保类型</option>
<option>保养</option>
<option>维修</option>
<option>年检</option>
</select>
</div>
<div class="row">
<div class="lab"><span class="star">*</span>申请日期</div>
<input name="applyDate" type="date" required />
</div>
<div class="row">
<div class="lab"><span class="star">*</span>开始日期</div>
<input name="inDate" type="date" required />
</div>
<div class="row">
<div class="lab">预计取车日期</div>
<input name="outDate" type="date" />
</div>
<div class="row full">
<div class="lab">维保内容</div>
<textarea name="item" placeholder="请输入维保内容"></textarea>
</div>
<div class="row full">
<div class="lab">维修店地址</div>
<input name="shop" placeholder="请输入维修店地址" />
</div>
<div class="row full">
<div class="lab">附件</div>
<div>
<label class="upload">
<input name="attach" type="file" multiple accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,application/pdf" onchange="previewMaintFiles(this)" />
<span class="plus">+</span>
<span>上传附件</span>
</label>
<div class="hint">支持图片、视频、PDF、Word、Excel 等文件;可拖拽或在此区域粘贴上传</div>
<div class="chips" id="maintFileChips" style="margin-top:8px"></div>
</div>
</div>
<div class="row full">
<div class="lab">备注</div>
<textarea name="note" placeholder="备注"></textarea>
</div>
</div>
</div>
<div class="dlg-f">
<button class="btn" type="button" onclick="closeMaintDlg()">× 取消</button>
<button class="btn btn-primary" type="submit">✓ 确定</button>
</div>
</form>
</dialog>
<dialog class="apply-dlg" id="etcDialog">
<div class="dlg-h green">
<h3>新增申请</h3>
<button class="dlg-x" type="button" aria-label="关闭" onclick="closeEtcDlg()">×</button>
</div>
<form id="etcForm" onsubmit="return false">
<div class="dlg-b">
<div class="apply">
<div class="row">
<div class="lab"><span class="star">*</span>申请人</div>
<select name="applicant" required></select>
</div>
<div class="row">
<div class="lab"><span class="star">*</span>车牌号</div>
<select name="plate" required onchange="syncEtcKm()"></select>
</div>
<div class="row">
<div class="lab"><span class="star">*</span>充值类型</div>
<select name="type" required>
<option>ETC</option>
</select>
</div>
<div class="row">
<div class="lab"><span class="star">*</span>申请日期</div>
<input name="date" type="date" required />
</div>
<div class="row">
<div class="lab"><span class="star">*</span>充值金额</div>
<input name="amount" type="number" min="0" step="0.01" required placeholder="元" />
</div>
<div class="row">
<div class="lab">使用金额</div>
<input name="used" type="number" min="0" step="0.01" placeholder="元" />
</div>
<div class="row full">
<div class="lab"><span class="star">*</span>期初公里数</div>
<div class="stepper">
<button type="button" onclick="stepKm(-1)">−</button>
<input name="km" type="number" min="0" required placeholder="请输入期初公里数" />
<button type="button" onclick="stepKm(1)">+</button>
</div>
</div>
<div class="row full">
<div class="lab"><span class="star">*</span>附件</div>
<div>
<label class="upload">
<input name="attach" type="file" required multiple accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,application/pdf" onchange="previewEtcFiles(this)" />
<span class="plus">+</span>
<span>上传附件</span>
</label>
<div class="hint">支持图片、视频、PDF、Word、Excel 等文件;可拖拽或在此区域粘贴上传</div>
<div class="chips" id="etcFileChips" style="margin-top:8px"></div>
</div>
</div>
<div class="row full">
<div class="lab">备注</div>
<textarea name="note" placeholder="备注"></textarea>
</div>
</div>
</div>
<div class="dlg-f">
<button class="btn" type="button" onclick="closeEtcDlg()">× 取消</button>
<button class="btn" type="button" onclick="saveEtc(false)">✓ 保存</button>
<button class="btn btn-primary" type="button" onclick="saveEtc(true)">↑ 保存并提交</button>
</div>
</form>
</dialog>
<dialog class="apply-dlg" id="oilCardDialog">
<div class="dlg-h green">
<h3>新增申请</h3>
<button class="dlg-x" type="button" aria-label="关闭" onclick="closeOilCardDlg()">×</button>
</div>
<form id="oilCardForm" onsubmit="return false">
<div class="dlg-b">
<div class="apply">
<div class="row">
<div class="lab"><span class="star">*</span>申请人</div>
<select name="applicant" required></select>
</div>
<div class="row">
<div class="lab"><span class="star">*</span>车牌号</div>
<select name="plate" required onchange="syncOilKm()"></select>
</div>
<div class="row">
<div class="lab"><span class="star">*</span>充值类型</div>
<select name="type" required>
<option>油卡</option>
</select>
</div>
<div class="row">
<div class="lab"><span class="star">*</span>申请日期</div>
<input name="date" type="date" required />
</div>
<div class="row">
<div class="lab"><span class="star">*</span>充值金额</div>
<input name="amount" type="number" min="0" step="0.01" required placeholder="元" />
</div>
<div class="row">
<div class="lab">使用金额</div>
<input name="used" type="number" min="0" step="0.01" placeholder="元" />
</div>
<div class="row full">
<div class="lab"><span class="star">*</span>期初公里数</div>
<div class="stepper">
<button type="button" onclick="stepOilKm(-1)">−</button>
<input name="km" type="number" min="0" required placeholder="请输入期初公里数" />
<button type="button" onclick="stepOilKm(1)">+</button>
</div>
</div>
<div class="row full">
<div class="lab"><span class="star">*</span>附件</div>
<div>
<label class="upload">
<input name="attach" type="file" required multiple accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,application/pdf" onchange="previewOilCardFiles(this)" />
<span class="plus">+</span>
<span>上传附件</span>
</label>
<div class="hint">支持图片、视频、PDF、Word、Excel 等文件;可拖拽或在此区域粘贴上传</div>
<div class="chips" id="oilCardFileChips" style="margin-top:8px"></div>
</div>
</div>
<div class="row full">
<div class="lab">备注</div>
<textarea name="note" placeholder="备注"></textarea>
</div>
</div>
</div>
<div class="dlg-f">
<button class="btn" type="button" onclick="closeOilCardDlg()">× 取消</button>
<button class="btn" type="button" onclick="saveOilCard(false)">✓ 保存</button>
<button class="btn btn-primary" type="button" onclick="saveOilCard(true)">↑ 保存并提交</button>
</div>
</form>
</dialog>
<dialog class="apply-dlg" id="planDialog">
<div class="dlg-h green">
<h3>新增计划</h3>
<button class="dlg-x" type="button" aria-label="关闭" onclick="closePlanDlg()">×</button>
</div>
<form id="planForm" onsubmit="return false">
<div class="dlg-b">
<div class="apply">
<div class="row"><div class="lab"><span class="star">*</span>单据类型</div>
<select name="type" required><option>出库单</option><option value="入直单">入库单</option><option>直销单</option></select>
</div>
<div class="row"><div class="lab"><span class="star">*</span>单据日期</div><input name="date" type="date" required /></div>
<div class="row"><div class="lab">交货日期</div><input name="deliverDate" type="date" /></div>
<div class="row"><div class="lab"><span class="star">*</span>品名</div><input name="product" required placeholder="如 30%以上烧碱" /></div>
<div class="row"><div class="lab">产品规格</div><input name="spec" placeholder="如 32%" /></div>
<div class="row"><div class="lab">管控</div>
<select name="ctrl"><option value="">普通危化</option><option>易制毒</option><option>易制爆</option></select>
</div>
<div class="row"><div class="lab">运输方式</div>
<select name="shipMode"><option>槽车运输</option><option>车辆运输</option></select>
</div>
<div class="row"><div class="lab"><span class="star">*</span>计划数量</div><input name="planQty" type="number" step="0.01" required /></div>
<div class="row"><div class="lab">单位</div><input name="unit" value="吨" /></div>
<div class="row"><div class="lab">包装规格</div><input name="pack" value="槽罐车" /></div>
<div class="row"><div class="lab">业务员</div><input name="salesman" placeholder="如 杨磊" /></div>
<div class="row"><div class="lab">业务部门</div><input name="dept" value="运输调度组" /></div>
<div class="row"><div class="lab">发货单位</div><input name="loadFrom" /></div>
<div class="row"><div class="lab">结算单位</div><input name="settler" /></div>
<div class="row full"><div class="lab">客户 / 收货</div><input name="customer" /></div>
<div class="row full"><div class="lab">合同</div>
<div>
<div class="chips" id="planCtPick" style="gap:8px"></div>
<div class="muted" style="margin-top:6px">可多选。同一客户多份合同可并入一条运输计划。</div>
</div>
</div>
</div>
</div>
<div class="dlg-f">
<button class="btn" type="button" onclick="closePlanDlg()">取消</button>
<button class="btn btn-primary" type="button" onclick="savePlan()">保存待安排</button>
</div>
</form>
</dialog>
<dialog class="apply-dlg wide-dlg" id="planTaskDialog">
<div class="dlg-h green">
<h3>下发任务单</h3>
<button class="dlg-x" type="button" aria-label="关闭" onclick="closePlanTaskDlg()">×</button>
</div>
<form id="planTaskForm" onsubmit="return false">
<div class="dlg-b">
<p class="muted" id="planTaskHint" style="margin-bottom:12px"></p>
<div class="apply">
<div class="row"><div class="lab">任务单号</div><input name="no" readonly /></div>
<div class="row"><div class="lab">任务类型</div>
<select name="kind" onchange="onPlanTaskKindChange()">
<option value="分装">分装任务单</option>
<option value="稀释">稀释任务单</option>
<option value="精制">精制任务单</option>
</select>
</div>
<div class="row"><div class="lab"><span class="star">*</span>下单日期</div><input name="orderDate" type="date" required /></div>
<div class="row"><div class="lab"><span class="star">*</span>交货日期</div><input name="deliverDate" type="date" required /></div>
<div class="row"><div class="lab"><span class="star">*</span>下单人</div><input name="orderBy" required /></div>
<div class="row"><div class="lab">经手人</div><select name="handler"></select></div>
<div class="row"><div class="lab">制单人</div><input name="maker" /></div>
<div class="row"><div class="lab">审核</div><input name="auditor" placeholder="待审核" /></div>
<div class="row"><div class="lab">生产主管</div><input name="supervisor" /></div>
<div class="row"><div class="lab">桶型说明</div>
<select name="bucket">
<option>桶循环</option>
<option>全新桶</option>
<option>是否回收桶</option>
</select>
</div>
<div class="row full"><div class="lab">产品明细</div>
<div class="task-line-wrap" id="planTaskLines"></div>
</div>
<div class="row full"><div class="lab">备注</div>
<textarea name="remark" placeholder="如客户简称、现场说明"></textarea>
</div>
</div>
</div>
<div class="dlg-f">
<button class="btn" type="button" onclick="closePlanTaskDlg()">取消</button>
<button class="btn btn-primary" type="button" onclick="savePlanTask()">下发任务单</button>
</div>
</form>
</dialog>
<dialog class="apply-dlg wide-dlg" id="taskSheetDialog">
<div class="dlg-h green">
<h3>任务单</h3>
<button class="dlg-x" type="button" aria-label="关闭" onclick="closeTaskSheetDlg()">×</button>
</div>
<div class="dlg-b" id="taskSheetBody"></div>
<div class="dlg-f">
<button class="btn" type="button" onclick="closeTaskSheetDlg()">关闭</button>
<button class="btn btn-primary" type="button" onclick="toast('演示:已打印任务单')">打印</button>
</div>
</dialog>
<dialog class="apply-dlg" id="taskReportDialog">
<div class="dlg-h green">
<h3 id="taskReportTitle">回传完成任务</h3>
<button class="dlg-x" type="button" aria-label="关闭" onclick="closeTaskReportDlg()">×</button>
</div>
<form id="taskReportForm" onsubmit="return false">
<div class="dlg-b">
<p class="muted" id="taskReportHint" style="margin-bottom:12px"></p>
<div class="apply">
<div class="row"><div class="lab">任务单号</div><input name="no" readonly /></div>
<div class="row"><div class="lab">指派人员</div><input name="assignee" readonly /></div>
<div class="row"><div class="lab"><span class="star">*</span>完成时间</div>
<input name="doneTime" type="datetime-local" required />
</div>
<div class="row"><div class="lab"><span class="star">*</span>完成数量</div>
<input name="doneQty" type="number" min="0" step="0.01" required />
</div>
<div class="row full"><div class="lab"><span class="star">*</span>回传说明</div>
<textarea name="report" required placeholder="现场完成情况、异常或交接说明"></textarea>
</div>
<div class="row full">
<div class="lab"><span class="star">*</span>纸质分装单</div>
<div>
<div class="task-photo-row">
<label class="upload task-photo-up" id="taskSheetPhotoBtn">
<input name="sheetPhotos" type="file" accept="image/*" capture="environment" multiple onchange="previewTaskSheetPhotos(this)" />
<span class="plus">📷</span>
<span>拍照 / 上传</span>
</label>
<div class="task-photo-thumbs" id="taskSheetPhotoThumbs"></div>
</div>
<p class="file-hint" id="taskSheetPhotoHint" style="margin-top:8px">须拍摄已签字完成的纸质分装任务单,可多张。</p>
<div class="chips" id="taskSheetPhotoChips"></div>
</div>
</div>
</div>
</div>
<div class="dlg-f">
<button class="btn" type="button" onclick="closeTaskReportDlg()">取消</button>
<button class="btn btn-primary" type="button" id="taskReportSave" onclick="saveTaskReport()">确认回传完成</button>
</div>
</form>
</dialog>
<dialog class="apply-dlg" id="archiveDialog">
<div class="dlg-h green">
<h3>运输归档</h3>
<button class="dlg-x" type="button" aria-label="关闭" onclick="closeArchiveDlg()">×</button>
</div>
<form id="archiveForm" onsubmit="return false">
<div class="dlg-b">
<p class="muted" id="archiveHint" style="margin-bottom:12px"></p>
<div class="apply">
<div class="row"><div class="lab">单号</div><input id="archiveNo" readonly /></div>
<div class="row"><div class="lab">类型 / 品名</div><input id="archiveProd" readonly /></div>
<div class="row"><div class="lab"><span class="star">*</span>实发数量</div>
<input name="realQty" type="number" step="0.01" min="0.01" required />
</div>
<div class="row"><div class="lab">单位</div><input id="archiveUnit" readonly /></div>
<div class="row full" id="archiveWhRow">
<div class="lab">对应仓库</div>
<input id="archiveWhName" readonly />
<input type="hidden" name="warehouseId" />
</div>
</div>
<p class="hint" id="archiveWhHint">出库单 / 入库单按品名自动对应仓库;直销单无中转仓库。</p>
</div>
<div class="dlg-f">
<button class="btn" type="button" onclick="closeArchiveDlg()">取消</button>
<button class="btn btn-primary" type="button" onclick="confirmArchive()">确认归档</button>
</div>
</form>
</dialog>
<dialog class="apply-dlg" id="dispatchDialog">
<div class="dlg-h green">
<h3>分派车辆运输量</h3>
<button class="dlg-x" type="button" aria-label="关闭" onclick="closeDispatchDlg()">×</button>
</div>
<form id="dispatchForm" onsubmit="return false">
<div class="dlg-b">
<p class="muted" id="dispatchDlgHint" style="margin-bottom:12px"></p>
<div class="apply">
<div class="row full"><div class="lab">运输公司</div><input name="company" readonly /></div>
<div class="row"><div class="lab"><span class="star">*</span>车辆</div>
<select name="plate" required onchange="syncDispatchDriver()"></select>
</div>
<div class="row"><div class="lab">驾驶员</div><input name="driver" /></div>
<div class="row full hidden" id="dispatchNewPlateRow">
<div class="lab"><span class="star">*</span>新车牌</div>
<input name="newPlate" placeholder="同公司其他车辆,共用本运输证" />
</div>
<div class="row full"><div class="lab"><span class="star">*</span>分派运量</div>
<input name="qty" type="number" step="0.01" min="0.01" required placeholder="按运输证未分派量填写" />
</div>
</div>
</div>
<div class="dlg-f">
<button class="btn" type="button" onclick="closeDispatchDlg()">取消</button>
<button class="btn btn-primary" type="button" onclick="saveDispatch()">确认分派</button>
</div>
</form>
</dialog>
<dialog class="apply-dlg wide-dlg" id="linkCertDialog">
<div class="dlg-h green">
<h3>从计划关联证件</h3>
<button class="dlg-x" type="button" aria-label="关闭" onclick="closeLinkCertDlg()">×</button>
</div>
<div class="dlg-b">
<p class="muted" id="linkCertHint" style="margin-bottom:10px"></p>
<div class="link-sec">
<h4>选择购买证下的运输证</h4>
<div id="linkCertTable"></div>
</div>
</div>
<div class="dlg-f">
<button class="btn" type="button" onclick="closeLinkCertDlg()">取消</button>
<button class="btn" type="button" onclick="showMod('cert');closeLinkCertDlg()">去上传新证</button>
</div>
</dialog>
<dialog class="apply-dlg wide-dlg wait-link-dlg" id="waitBuyDialog">
<div class="dlg-h green">
<h3>关联购买证</h3>
<button class="dlg-x" type="button" aria-label="关闭" onclick="closeWaitBuyDlg()">×</button>
</div>
<div class="dlg-b">
<p class="muted" id="waitBuyHint" style="margin-bottom:10px"></p>
<div id="waitBuyBody"></div>
</div>
<div class="dlg-f">
<button class="btn" type="button" onclick="closeWaitBuyDlg()">取消</button>
<button class="btn btn-primary" type="button" onclick="saveWaitBuyCert()">确认关联购买证</button>
</div>
</dialog>
<dialog class="apply-dlg wide-dlg wait-link-dlg" id="waitTransDialog">
<div class="dlg-h green">
<h3>关联运输证</h3>
<button class="dlg-x" type="button" aria-label="关闭" onclick="closeWaitTransDlg()">×</button>
</div>
<div class="dlg-b">
<p class="muted" id="waitTransHint" style="margin-bottom:10px"></p>
<div id="waitTransBody"></div>
</div>
<div class="dlg-f">
<button class="btn" type="button" onclick="closeWaitTransDlg()">取消</button>
</div>
</dialog>
<dialog class="apply-dlg wide-dlg" id="vehPlanDialog">
<div class="dlg-h green">
<h3 id="vehPlanTitle">关联运输计划</h3>
<button class="dlg-x" type="button" aria-label="关闭" onclick="closeVehPlanDlg()">×</button>
</div>
<div class="dlg-b" id="vehPlanBody"></div>
<div class="dlg-f">
<button class="btn" type="button" onclick="closeVehPlanDlg()">关闭</button>
</div>
</dialog>
<dialog class="cert-img-dlg" id="certImgDialog" onclick="closeCertImg(event)">
<img id="certImgView" alt="证件原图" />
</dialog>
<dialog class="apply-dlg" id="voidCertDialog">
<div class="dlg-h green">
<h3 id="voidCertTitle">核销证件</h3>
<button class="dlg-x" type="button" aria-label="关闭" onclick="closeVoidCertDlg()">×</button>
</div>
<div class="dlg-b">
<p class="muted" id="voidCertHint"></p>
</div>
<div class="dlg-f">
<button class="btn" type="button" onclick="closeVoidCertDlg()">取消</button>
<button class="btn btn-danger" type="button" onclick="confirmVoidCert()">确认核销</button>
</div>
</dialog>
<dialog class="apply-dlg wide-dlg" id="certLinkPlanDialog">
<div class="dlg-h green">
<h3>人工关联运输计划</h3>
<button class="dlg-x" type="button" aria-label="关闭" onclick="closeCertLinkPlanDlg()">×</button>
</div>
<div class="dlg-b">
<p class="muted" id="certLinkPlanHint" style="margin-bottom:10px"></p>
<div id="certLinkPreview" class="link-preview"></div>
<label class="quote-search plan-search" style="margin:0 0 10px;max-width:360px">
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m20 20-3-3"/></svg>
<input id="certLinkPlanQ" type="search" placeholder="筛选计划编号 / 品名 / 客户 / 车牌" oninput="filterCertLinkPlanRows()" />
</label>
<div class="erp-table-wrap" id="certLinkPlanTable"></div>
</div>
<div class="dlg-f">
<button class="btn" type="button" onclick="closeCertLinkPlanDlg()">取消</button>
<button class="btn btn-primary" type="button" onclick="saveCertLinkPlans()">确认关联所选计划</button>
</div>
</dialog>
<dialog class="wide-dlg" id="certDialog">
<div class="dlg-h green">
<h3 id="certDlgTitle">识别购买证 + 运输证</h3>
<button class="dlg-x" type="button" aria-label="关闭" onclick="closeCertDlg()">×</button>
</div>
<form id="certForm" onsubmit="return false">
<div class="dlg-b">
<p class="hint" id="certDlgHint" style="margin-bottom:12px">一张购买证可同时识别、挂多张运输证;也可保存后再继续追加。</p>
<div class="ocr-pair">
<div class="ocr-box buy" id="buyOcrBox">
<h4><span class="cert-tag buy">购买证</span> 第二类、第三类易制毒化学品购买备案证明</h4>
<p class="hint">上传或拖拽购买证照片或扫描件,读取编号、购买单位、品名与数量。</p>
<label class="upload">
<input id="buyFile" type="file" accept="image/*,.pdf" onchange="recognize('buy', this)" />
<span class="plus">+</span>
<span>上传购买证</span>
</label>
<img class="ocr-preview hidden" id="buyPreview" alt="购买证预览" />
<div class="ocr-status" id="buyStatus">待上传</div>
</div>
<div class="ocr-box trans" id="transOcrBox">
<h4><span class="cert-tag trans">运输证</span> 第三类易制毒化学品运输备案证明</h4>
<p class="hint">上传或拖拽运输证。同一购买证可识别多张运输证。</p>
<label class="upload">
<input id="transFile" type="file" accept="image/*,.pdf" onchange="recognize('trans', this)" />
<span class="plus">+</span>
<span>上传运输证</span>
</label>
<img class="ocr-preview hidden" id="transPreview" alt="运输证预览" />
<div class="ocr-status" id="transStatus">待上传</div>
</div>
</div>
<div class="split-h">
<div id="buyFormCol" class="col-buy">
<h4>购买证信息</h4>
<div class="form" style="grid-template-columns:1fr 1fr">
<label><span><span class="star">*</span>编号</span><input name="buyNo" required /></label>
<label>核发日期<input name="buyDate" type="date" /></label>
<label class="span2"><span><span class="star">*</span>购买单位</span><input name="buyer" required /></label>
<label>法定代表人<input name="buyerLegal" /></label>
<label>电话<input name="buyerTel" /></label>
<label class="span2">住所 / 住址<input name="buyerAddr" /></label>
<label class="span2">销售单位<input name="seller" /></label>
<label>销售方法人<input name="sellerLegal" /></label>
<label>销售方电话<input name="sellerTel" /></label>
<label class="span2">销售方住所<input name="sellerAddr" /></label>
<label><span><span class="star">*</span>购买物品</span><input name="product" required /></label>
<label>用途<input name="usage" /></label>
<label><span><span class="star">*</span>数量</span><input name="buyQty" type="number" step="0.01" required /></label>
<label>单位<input name="unit" value="吨" /></label>
<label>余量预警<input name="warnQty" type="number" step="0.01" min="0" placeholder="核准总量 10%" title="默认按证件核准总量的 10% 预警,可改" /></label>
<label>有效次数<input name="buyTimes" /></label>
<label>有效开始<input name="buyFrom" type="date" /></label>
<label>有效截止<input name="buyDue" type="date" /></label>
<label class="span2">公安机关<input name="buyPolice" /></label>
<label>经办人<input name="buyHandler" /></label>
<label>联系电话<input name="buyPoliceTel" /></label>
</div>
</div>
<div class="col-trans">
<h4>运输证信息(第 1 张)</h4>
<div class="form" id="transForm0" style="grid-template-columns:1fr 1fr">
<label><span><span class="star">*</span>编号</span><input name="transNo" required /></label>
<label>核发日期<input name="transDate" type="date" /></label>
<label class="span2">货主<input name="owner" /></label>
<label>法定代表人<input name="ownerLegal" /></label>
<label>电话<input name="ownerTel" /></label>
<label class="span2">货主住所<input name="ownerAddr" /></label>
<label>包装<input name="pack" /></label>
<label>购买许可/备案证号<input name="permitNo" /></label>
<label class="span2">运输单位<input name="carrier" /></label>
<label>住所/电话<input name="carrierAddr" /></label>
<label>运输方式<input name="mode" /></label>
<label>车号(次)<input name="plates" /></label>
<label class="span2">运输路线<input name="route" /></label>
<label class="span2">收货单位<input name="consignee" /></label>
<label>运输数量<input name="transQty" type="number" step="0.01" /></label>
<label>余量预警<input name="warnQty" type="number" step="0.01" min="0" placeholder="核准总量 10%" title="默认按证件核准总量的 10% 预警,可改" /></label>
<label>有效次数<input name="transTimes" /></label>
<label>有效开始<input name="transFrom" type="date" /></label>
<label>有效截止<input name="transDue" type="date" /></label>
<label class="span2">申请单位<input name="applicantUnit" /></label>
<label class="span2">公安机关<input name="transPolice" /></label>
<label>经办人<input name="transHandler" /></label>
</div>
</div>
</div>
<div id="extraTransSlots"></div>
<div style="margin-top:12px">
<button class="btn" type="button" onclick="addTransSlot()">+ 再识别一张运输证</button>
</div>
</div>
<div class="dlg-f">
<button class="btn" type="button" onclick="closeCertDlg()">取消</button>
<button class="btn btn-primary" type="button" id="certSaveBtn" onclick="saveCert()">生成证件信息</button>
</div>
</form>
</dialog>
<script>
const today = "2026-10-01";
const DUTY_TODAY = "2026-10-07";
const DUTY_STAFF = ["吴自国", "刘茂生", "吴自泰", "黄天宝", "刘丹", "汪璇", "吴良芹"];
const DUTY_TYPE = { am: "早班", pm: "晚班", watch: "值班", other: "其他" };
let dutyYM = { y: 2026, m: 10 };
let dutyPickDate = "";
let dutyPickPeople = [];
let dutySeq = 20;
let dutyShifts = [
{ id: 1, date: "2026-10-01", type: "am", people: ["刘茂生", "吴自国"], post: "配酸" },
{ id: 2, date: "2026-10-01", type: "watch", people: ["黄天宝"], post: "配酸" },
{ id: 3, date: "2026-10-02", type: "am", people: ["吴自泰", "黄天宝"], post: "" },
{ id: 4, date: "2026-10-02", type: "watch", people: ["吴自国"], post: "配酸" },
{ id: 5, date: "2026-10-03", type: "am", people: ["刘茂生", "吴自国"], post: "配酸" },
{ id: 6, date: "2026-10-03", type: "watch", people: ["吴自泰"], post: "配酸" },
{ id: 7, date: "2026-10-04", type: "am", people: ["黄天宝", "吴自泰"], post: "配酸" },
{ id: 8, date: "2026-10-04", type: "watch", people: ["吴自国"], post: "配酸" },
{ id: 9, date: "2026-10-05", type: "am", people: ["吴自泰", "刘茂生"], post: "配酸" },
{ id: 10, date: "2026-10-06", type: "am", people: ["黄天宝", "刘茂生"], post: "配酸" },
{ id: 11, date: "2026-10-06", type: "watch", people: ["吴自国"], post: "配酸" },
{ id: 12, date: "2026-10-07", type: "am", people: ["刘茂生", "吴自泰"], post: "配酸" },
{ id: 13, date: "2026-10-07", type: "watch", people: ["吴自国"], post: "配酸" },
{ id: 14, date: "2026-10-11", type: "watch", people: ["吴良芹"], post: "" },
{ id: 15, date: "2026-10-18", type: "watch", people: ["汪璇"], post: "" },
{ id: 16, date: "2026-10-25", type: "watch", people: ["刘丹"], post: "" }
];
const dayShift = (d, n) => {
const x = new Date(d + "T00:00:00");
x.setDate(x.getDate() + n);
return x.toISOString().slice(0, 10);
};
const quoteMoney = (n) => "¥" + Number(n || 0).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
let quoteTab = "全部";
let quoteStatusFilter = "";
let quotePage = 1;
let quotePageSize = 20;
let quoteSeq = 12;
let selectedQuoteIds = new Set();
let expandedQuoteIds = new Set();
let quotes = [
{ id: 1, no: "SXDD-20260930002", title: "山峡-安庆汇辰 (SXDD-20260930002)", kind: "销售", customer: "安庆汇辰科技有限公司", product: "一水柠檬酸 99.50% 0.125 吨", prodAmt: 1375, freight: 0, date: "2026-09-30", status: "已生效" },
{ id: 2, no: "SXDD-20260930001", title: "山峡-安徽金兽 (SXDD-20260930001)", kind: "销售", customer: "安徽金兽医药科技有限公司", product: "片碱 2 吨", prodAmt: 7400, freight: 0, date: "2026-09-30", status: "已生效" },
{ id: 3, no: "SXDD-20260929004", title: "山峡-厦门圣益 (SXDD-20260929004)", kind: "销售", customer: "厦门圣益石化有限公司", product: "30%液碱 30% 65 吨", prodAmt: 46800, freight: 0, date: "2026-09-29", status: "已生效" },
{ id: 4, no: "SXDD-20260929003", title: "山峡-安徽中禹 (SXDD-20260929003)", kind: "销售", customer: "安徽省中禹商贸有限公司", product: "30% 工业硫酸 30% 21 吨", prodAmt: 25200, freight: 0, date: "2026-09-29", status: "已生效" },
{ id: 5, no: "SXDD-20260929002", title: "山峡-安徽恒星 (SXDD-20260929002)", kind: "销售", customer: "安徽恒星新材料科技有限公司", product: "50%液碱 50% 400 吨", prodAmt: 508000, freight: 0, date: "2026-09-29", status: "待审批", alert: true },
{ id: 6, no: "SXDD-20260929001", title: "山峡-安徽金兽 (SXDD-20260929001)", kind: "销售", customer: "安徽金兽医药科技有限公司", product: "片碱 2 吨", prodAmt: 7400, freight: 0, date: "2026-09-29", status: "已生效" },
{ id: 7, no: "SXDD-20260928001", title: "山峡-安徽紫荆花 (SXDD-20260928001)", kind: "销售", customer: "安徽紫荆花新型材料科技有限公司", product: "聚合氯化铝 5 吨", prodAmt: 10000, freight: 0, date: "2026-09-28", status: "已生效" },
{ id: 8, no: "SXDD-20260924003", title: "山峡-桐城金涛 (SXDD-20260924003)", kind: "销售", customer: "桐城市金涛表面处理有限公司", product: "次氯酸钠 13% 30 吨", prodAmt: 20400, freight: 0, date: "2026-09-24", status: "已生效" },
{ id: 9, no: "SXDD-20260924002", title: "山峡-安庆飞霖 (SXDD-20260924002)", kind: "销售", customer: "安庆飞霖新材料有限公司", product: "32%液碱 32% 30 吨", prodAmt: 25200, freight: 0, date: "2026-09-24", status: "已生效" },
{ id: 10, no: "SXDD-20260924001", title: "山峡-安庆百谊生物 (SXDD-20260924001)", kind: "销售", customer: "安庆百谊生物科技有限公司", product: "工业盐酸 31% 0.025 吨", prodAmt: 50, freight: 0, date: "2026-09-24", status: "已生效" },
{ id: 11, no: "SXDD-20260923001", title: "山峡-三土 (SXDD-20260923001)", kind: "销售", customer: "三土新材料科技(上海)有限公司", product: "氯化钠 国标 0.65 吨", prodAmt: 910, freight: 0, date: "2026-09-23", status: "已生效" },
{ id: 12, no: "SXDD-20261001001", title: "山峡-宜昌安达 (SXDD-20261001001)", kind: "销售", customer: "宜昌安达危运有限公司", product: "98% 工业硫酸 32 吨", prodAmt: 28800, freight: 0, date: "2026-10-01", status: "草稿" },
{ id: 13, no: "SXDD-20261002001", title: "山峡-上饶敏达 (SXDD-20261002001)", kind: "销售", customer: "上饶市敏达精细化工有限公司", product: "硫酸 98% 30 吨", prodAmt: 27000, freight: 0, date: "2026-10-02", status: "草稿" }
];
function quoteWeekRange() {
const d = new Date(today + "T00:00:00");
const day = d.getDay() || 7;
const start = dayShift(today, 1 - day);
const end = dayShift(start, 6);
return [start, end];
}
function filteredQuotes() {
const q = (document.getElementById("quoteQ")?.value || "").trim();
let rows = quotes.filter((x) => x.status !== "已作废" || quoteTab === "全部");
if (quoteTab === "昨日") rows = rows.filter((x) => x.date === dayShift(today, -1));
else if (quoteTab === "今日") rows = rows.filter((x) => x.date === today);
else if (quoteTab === "明日") rows = rows.filter((x) => x.date === dayShift(today, 1));
else if (quoteTab === "本周") {
const [a, b] = quoteWeekRange();
rows = rows.filter((x) => x.date >= a && x.date <= b);
}
if (quoteStatusFilter === "待审核") rows = rows.filter((x) => x.status === "待审批");
else if (quoteStatusFilter) rows = rows.filter((x) => x.status === quoteStatusFilter);
if (q) rows = rows.filter((x) => [x.no, x.title, x.customer, x.product].join(" ").includes(q));
return rows;
}
function toggleQuote(id, on) {
if (on) selectedQuoteIds.add(id); else selectedQuoteIds.delete(id);
renderQuotes();
}
function toggleQuoteAll(on) {
filteredQuotes().forEach((x) => on ? selectedQuoteIds.add(x.id) : selectedQuoteIds.delete(x.id));
renderQuotes();
}
function toggleQuoteExpand(id) {
if (expandedQuoteIds.has(id)) expandedQuoteIds.delete(id); else expandedQuoteIds.add(id);
renderQuotes();
}
function selectedQuotes() {
return quotes.filter((x) => selectedQuoteIds.has(x.id));
}
function setQuoteStatus(st) {
quoteStatusFilter = quoteStatusFilter === st ? "" : st;
quotePage = 1;
renderQuotes();
}
function waitReviewBanner(n, on, onclick, goText) {
const empty = !n;
return `<button class="wait-banner ${on ? "on" : ""} ${empty ? "empty" : ""}" type="button" onclick="${onclick}">
<span class="wb-ico" aria-hidden="true"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/><path d="M12 8v5l3 2"/></svg></span>
<span class="wb-n">${n}</span>
<span class="wb-k">待审核</span>
<span class="wb-go">${empty ? "暂无待审" : goText}</span>
</button>`;
}
function renderQuotes() {
const rows = filteredQuotes();
const pages = Math.max(1, Math.ceil(rows.length / quotePageSize));
if (quotePage > pages) quotePage = pages;
const start = (quotePage - 1) * quotePageSize;
const pageRows = rows.slice(start, start + quotePageSize);
const allOn = pageRows.length && pageRows.every((x) => selectedQuoteIds.has(x.id));
document.getElementById("quoteSelHint").textContent = `已选 ${selectedQuoteIds.size} 项`;
const waitN = quotes.filter((x) => x.status === "待审批").length;
const qStats = document.getElementById("quoteStats");
if (qStats) {
if (quoteStatusFilter === "草稿" || quoteStatusFilter === "已生效") quoteStatusFilter = "";
qStats.innerHTML = waitReviewBanner(waitN, quoteStatusFilter === "待审核", "setQuoteStatus('待审核')", "查看待审报价");
}
const qBadge = document.getElementById("quoteBadge");
if (qBadge) {
const n = quotes.filter((x) => x.status === "待审批" || x.status === "草稿").length;
qBadge.textContent = n;
qBadge.style.display = n ? "" : "none";
}
document.getElementById("quoteTable").innerHTML = pageRows.length ? `<table class="erp-table quote-table">
<thead><tr>
<th class="quote-expand"></th>
<th class="chk"><input type="checkbox" ${allOn ? "checked" : ""} onchange="toggleQuoteAll(this.checked)" /></th>
<th class="col-no"><span class="th-sort">报价单号</span></th>
<th class="col-title"><span class="th-sort">报价主题</span></th>
<th><span class="th-sort">报价类型</span></th>
<th><span class="th-sort">客户名称</span></th>
<th><span class="th-sort">产品信息</span></th>
<th><span class="th-sort">最终金额</span></th>
<th><span class="th-sort">产品总金额</span></th>
<th><span class="th-sort">运费</span></th>
</tr></thead>
<tbody>${pageRows.map((x) => {
const open = expandedQuoteIds.has(x.id);
const finalAmt = x.prodAmt + (x.freight || 0);
const title = String((x.title || "").replace(/\s*\([^)]*\)\s*$/, "") || x.title).replace(/"/g, "&quot;");
return `<tr class="${x.alert || x.status === "待审批" ? "row-alert" : ""}">
<td class="quote-expand ${open ? "on" : ""}" onclick="toggleQuoteExpand(${x.id})">${open ? "▾" : "▸"}</td>
<td class="chk"><input type="checkbox" ${selectedQuoteIds.has(x.id) ? "checked" : ""} onchange="toggleQuote(${x.id}, this.checked)" /></td>
<td class="cell-no col-no"><button class="quote-link" type="button" onclick="toggleQuoteExpand(${x.id})">${x.no}</button></td>
<td class="col-title" title="${title}"><button class="quote-link" type="button" onclick="toggleQuoteExpand(${x.id})">${title}</button></td>
<td><span class="sale-tag">${x.kind}</span></td>
<td>${x.customer}</td>
<td>${x.product}</td>
<td class="amt-red">${quoteMoney(finalAmt)}</td>
<td class="amt-red">${quoteMoney(x.prodAmt)}</td>
<td class="amt-red">${quoteMoney(x.freight)}</td>
</tr>${open ? `<tr class="quote-sub"><td></td><td></td><td colspan="8">日期 ${x.date} 状态 ${x.status} 产品 ${x.product} 金额 ${quoteMoney(x.prodAmt)} 运费 ${quoteMoney(x.freight)}</td></tr>` : ""}`;
}).join("")}</tbody></table>` : `<div class="empty">暂无报价单</div>`;
const nums = [];
const maxBtn = Math.min(pages, 6);
for (let i = 1; i <= maxBtn; i++) nums.push(i);
document.getElementById("quotePager").innerHTML = `
<span class="muted">共 ${rows.length} 条</span>
<select class="page-size" onchange="quotePageSize=+this.value;quotePage=1;renderQuotes()">
<option value="20" ${quotePageSize === 20 ? "selected" : ""}>20条/页</option>
<option value="50" ${quotePageSize === 50 ? "selected" : ""}>50条/页</option>
</select>
${nums.map((n) => `<button type="button" class="${n === quotePage ? "on" : ""}" onclick="quotePage=${n};renderQuotes()">${n}</button>`).join("")}
${pages > 6 ? `<span class="muted">… ${pages}</span>` : ""}
<span class="muted">前往</span>
<input class="quote-goto" type="number" min="1" max="${pages}" value="${quotePage}" onchange="quotePage=Math.min(${pages}, Math.max(1, +this.value||1));renderQuotes()" />
<span class="muted">页</span>`;
}
function openQuoteDlg(copyFrom) {
const f = document.getElementById("quoteForm");
f.reset();
f.date.value = today;
document.getElementById("quoteDlgTitle").textContent = copyFrom ? "复制新增报价单" : "新增报价单";
if (copyFrom) {
f.customer.value = copyFrom.customer;
f.title.value = copyFrom.title.replace(/\(SXDD-[^)]+\)/, "").trim();
f.kind.value = copyFrom.kind;
f.product.value = copyFrom.product;
f.prodAmt.value = copyFrom.prodAmt;
f.freight.value = copyFrom.freight || 0;
}
document.getElementById("quoteDialog").showModal();
}
function saveQuote() {
const f = document.getElementById("quoteForm");
if (!f.reportValidity()) return;
quoteSeq += 1;
const no = "SXDD-" + today.replaceAll("-", "") + String(quoteSeq).padStart(3, "0");
const titleBase = f.title.value.trim();
quotes.unshift({
id: Date.now(), no,
title: `${titleBase} (${no})`,
kind: f.kind.value, customer: f.customer.value.trim(),
product: f.product.value.trim(), prodAmt: +f.prodAmt.value, freight: +f.freight.value || 0,
date: f.date.value, status: "草稿"
});
document.getElementById("quoteDialog").close();
toast("已保存草稿 " + no);
renderQuotes();
}
function copyQuote() {
const rows = selectedQuotes();
if (rows.length !== 1) { toast("请勾选一张报价单后再复制新增"); return; }
openQuoteDlg(rows[0]);
}
function submitQuotes() {
const rows = selectedQuotes().filter((x) => x.status === "草稿");
if (!rows.length) { toast("请勾选草稿状态的报价单"); return; }
rows.forEach((x) => { x.status = "待审批"; x.alert = true; });
toast(`已提交 ${rows.length} 张报价单审批`);
renderQuotes();
}
function genQuotePdf() {
const rows = selectedQuotes();
if (!rows.length) { toast("请先勾选报价单"); return; }
toast("演示:已生成 " + rows.map((x) => x.no).join("、"));
}
function voidQuotes() {
const rows = selectedQuotes();
if (!rows.length) { toast("请先勾选报价单"); return; }
rows.forEach((x) => { x.status = "已作废"; x.alert = false; });
selectedQuoteIds.clear();
toast("已作废所选报价单");
renderQuotes();
}
let ctTab = "全部";
let ctStatusFilter = "";
let ctPage = 1;
let ctPageSize = 20;
let ctSeq = 12;
let selectedCtIds = new Set();
let expandedCtIds = new Set();
let scanCtIds = [];
let contracts = [
{ id: 1, no: "SX/X-JT20260930002", title: "山峡-合肥健宇环保", customer: "合肥健宇环保科技有限公司", kind: "框架销售合同", att: "双章", product: "98%工业硫酸 98% 100吨", amount: 0, freight: 0, deposit: 0, status: "执行中", signDate: "2026-09-30", owner: "万金强", created: "2026-09-30 10:42:57", alert: true },
{ id: 2, no: "SX/X-JT20260930003", title: "山峡-安庆汇辰", customer: "安庆汇辰科技有限公司", kind: "销售合同", att: "", product: "一水柠檬酸 99.50% 0.125吨", amount: 1375, freight: 0, deposit: 0, status: "执行中", signDate: "2026-09-30", owner: "万金强", created: "2026-09-30 10:23:41" },
{ id: 3, no: "SX/X-JT20260930001", title: "山峡-安徽金兽", customer: "安徽金兽医药科技有限公司", kind: "销售合同", att: "双章", product: "片碱 2吨", amount: 7400, freight: 0, deposit: 0, status: "已完成", signDate: "2026-09-30", owner: "万金强", created: "2026-09-30 10:16:21" },
{ id: 4, no: "SG-GX2026-01-0031", title: "安庆曙光-山峡", customer: "安庆市曙光供销运输有限责任公司", kind: "采购合同", att: "单章", product: "32%液碱 32% 800吨<br>50%液碱 50% 600吨", amount: 1108000, freight: 0, deposit: 0, status: "执行中", signDate: "2026-09-30", owner: "万金强", created: "2026-09-30 10:15:44" },
{ id: 5, no: "CG10120260930002", title: "山峡-安徽昊帆", customer: "安徽昊帆生物有限公司", kind: "销售合同", att: "双章", product: "98%工业硫酸 98% 500千克", amount: 1250, freight: 0, deposit: 0, status: "执行中", signDate: "2026-09-30", owner: "万金强", created: "2026-09-30 14:44:56" },
{ id: 6, no: "SX/X-JT20261003001", title: "山峡-上饶敏达", customer: "上饶市敏达精细化工有限公司", kind: "销售合同", att: "双章", product: "硫酸 98% 30吨", amount: 27000, freight: 0, deposit: 0, status: "待审批", signDate: "2026-10-03", owner: "万金强", created: "2026-10-03 09:18:20", alert: true },
{ id: 7, no: "SX/X-JT20261004001", title: "山峡-宜昌安达", customer: "宜昌安达危运有限公司", kind: "销售合同", att: "", product: "98%工业硫酸 32吨", amount: 28800, freight: 0, deposit: 0, status: "草稿", signDate: "2026-10-04", owner: "万金强", created: "2026-10-04 11:06:12" },
{ id: 8, no: "SX/X-JT20260928001", title: "山峡-安徽紫荆花", customer: "安徽紫荆花新型材料科技有限公司", kind: "销售合同", att: "单章", product: "聚合氯化铝 5吨", amount: 10000, freight: 0, deposit: 0, status: "执行中", signDate: "2026-09-28", owner: "万金强", created: "2026-09-28 16:02:08" }
];
function ctDateOf(x) { return (x.signDate || x.created || "").slice(0, 10); }
function filteredContracts() {
const q = (document.getElementById("ctQ")?.value || "").trim();
let rows = contracts.filter((x) => x.status !== "已作废" || ctTab === "全部");
if (ctTab === "昨日") rows = rows.filter((x) => ctDateOf(x) === dayShift(today, -1));
else if (ctTab === "今日") rows = rows.filter((x) => ctDateOf(x) === today);
else if (ctTab === "明日") rows = rows.filter((x) => ctDateOf(x) === dayShift(today, 1));
else if (ctTab === "本周") {
const [a, b] = quoteWeekRange();
rows = rows.filter((x) => { const d = ctDateOf(x); return d >= a && d <= b; });
}
if (ctStatusFilter === "待审核") rows = rows.filter((x) => x.status === "待审批");
if (q) rows = rows.filter((x) => [x.no, x.title, x.customer, x.product].join(" ").includes(q));
return rows;
}
function toggleCt(id, on) {
if (on) selectedCtIds.add(id); else selectedCtIds.delete(id);
renderContracts();
}
function toggleCtAll(on) {
filteredContracts().forEach((x) => on ? selectedCtIds.add(x.id) : selectedCtIds.delete(x.id));
renderContracts();
}
function toggleCtExpand(id) {
if (expandedCtIds.has(id)) expandedCtIds.delete(id); else expandedCtIds.add(id);
renderContracts();
}
function selectedContracts() { return contracts.filter((x) => selectedCtIds.has(x.id)); }
function setCtStatus(st) {
ctStatusFilter = ctStatusFilter === st ? "" : st;
ctPage = 1;
renderContracts();
}
function ctAttHtml(x) {
const pdf = x.att ? `<span class="pdf-chip"><span class="pdf-box">PDF</span><em>${x.att}附件</em></span>` : "";
return `<div class="ct-att">${pdf}<button class="up-chip" type="button" title="上传扫描件" onclick="openCtScan(${x.id})"><svg viewBox="0 0 24 24"><path d="M12 16V6M8 10l4-4 4 4"/><path d="M5 18h14"/></svg></button></div>`;
}
function renderContracts() {
const rows = filteredContracts();
const pages = Math.max(1, Math.ceil(rows.length / ctPageSize));
if (ctPage > pages) ctPage = pages;
const start = (ctPage - 1) * ctPageSize;
const pageRows = rows.slice(start, start + ctPageSize);
const allOn = pageRows.length && pageRows.every((x) => selectedCtIds.has(x.id));
document.getElementById("ctSelHint").textContent = `已选 ${selectedCtIds.size} 项`;
const waitN = contracts.filter((x) => x.status === "待审批").length;
const ctStats = document.getElementById("ctStats");
if (ctStats) {
if (ctStatusFilter === "已生效" || ctStatusFilter === "本月") ctStatusFilter = "";
ctStats.innerHTML = waitReviewBanner(waitN, ctStatusFilter === "待审核", "setCtStatus('待审核')", "查看待审合同");
}
const badge = document.getElementById("ctBadge");
if (badge) {
const n = contracts.filter((x) => x.status === "待审批" || x.status === "草稿").length;
badge.textContent = n;
badge.style.display = n ? "" : "none";
}
document.getElementById("ctTable").innerHTML = pageRows.length ? `<table class="erp-table ct-table">
<thead><tr>
<th class="quote-expand"></th>
<th class="chk"><input type="checkbox" ${allOn ? "checked" : ""} onchange="toggleCtAll(this.checked)" /></th>
<th class="col-no"><span class="th-sort">合同编号</span></th>
<th><span class="th-sort">合同名称</span></th>
<th><span class="th-sort">客户名称</span></th>
<th><span class="th-sort">合同类型</span></th>
<th><span class="th-sort">合同附件</span></th>
<th><span class="th-sort">产品信息</span></th>
<th><span class="th-sort">合同总金额 (元)</span></th>
<th><span class="th-sort">合同金额</span></th>
<th><span class="th-sort">运费</span></th>
<th><span class="th-sort">桶押金</span></th>
<th><span class="th-sort">合同状态</span></th>
<th><span class="th-sort">签订日期</span></th>
<th><span class="th-sort">负责人</span></th>
<th><span class="th-sort">创建时间</span></th>
</tr></thead>
<tbody>${pageRows.map((x) => {
const open = expandedCtIds.has(x.id);
const total = Number(x.amount || 0) + Number(x.freight || 0) + Number(x.deposit || 0);
const alert = x.alert || x.status === "待审批";
return `<tr class="${alert ? "row-alert" : ""}">
<td class="quote-expand ${open ? "on" : ""}" onclick="toggleCtExpand(${x.id})">${open ? "▾" : "▸"}</td>
<td class="chk"><input type="checkbox" ${selectedCtIds.has(x.id) ? "checked" : ""} onchange="toggleCt(${x.id}, this.checked)" /></td>
<td class="cell-no col-no"><button class="quote-link" type="button" onclick="toggleCtExpand(${x.id})">${x.no}</button></td>
<td class="ct-name"><button class="quote-link" type="button" onclick="toggleCtExpand(${x.id})">${x.title}<span class="sub">(${x.no})</span></button></td>
<td class="ct-cust">${x.customer}</td>
<td>${x.kind}</td>
<td>${ctAttHtml(x)}</td>
<td class="ct-prod">${x.product}</td>
<td class="amt-red">${quoteMoney(total)}</td>
<td class="amt-red">${quoteMoney(x.amount)}</td>
<td class="amt-red">${quoteMoney(x.freight)}</td>
<td class="amt-red">${quoteMoney(x.deposit)}</td>
<td class="ct-st">${x.status}</td>
<td>${x.signDate}</td>
<td>${x.owner}</td>
<td>${x.created}</td>
</tr>${open ? `<tr class="quote-sub"><td></td><td></td><td colspan="14">合同 ${x.no} ${x.kind} ${x.status} 签订 ${x.signDate} 负责人 ${x.owner} 产品 ${String(x.product).replace(/<br>/g, " / ")}</td></tr>` : ""}`;
}).join("")}</tbody></table>` : `<div class="empty">暂无合同</div>`;
const nums = [];
const maxBtn = Math.min(pages, 6);
for (let i = 1; i <= maxBtn; i++) nums.push(i);
document.getElementById("ctPager").innerHTML = `
<span class="muted">共 ${rows.length} 条</span>
<select class="page-size" onchange="ctPageSize=+this.value;ctPage=1;renderContracts()">
<option value="20" ${ctPageSize === 20 ? "selected" : ""}>20条/页</option>
<option value="50" ${ctPageSize === 50 ? "selected" : ""}>50条/页</option>
</select>
${nums.map((n) => `<button type="button" class="${n === ctPage ? "on" : ""}" onclick="ctPage=${n};renderContracts()">${n}</button>`).join("")}
${pages > 6 ? `<span class="muted">… ${pages}</span>` : ""}
<span class="muted">前往</span>
<input class="quote-goto" type="number" min="1" max="${pages}" value="${ctPage}" onchange="ctPage=Math.min(${pages}, Math.max(1, +this.value||1));renderContracts()" />
<span class="muted">页</span>`;
}
function openCtDlg(copyFrom) {
const f = document.getElementById("ctForm");
f.reset();
f.signDate.value = today;
f.owner.value = "万金强";
document.getElementById("ctDlgTitle").textContent = copyFrom ? "复制新增合同" : "新增合同";
if (copyFrom) {
f.customer.value = copyFrom.customer;
f.title.value = copyFrom.title;
f.kind.value = copyFrom.kind;
f.product.value = String(copyFrom.product).replace(/<br>/g, " / ");
f.amount.value = copyFrom.amount;
f.freight.value = copyFrom.freight || 0;
f.deposit.value = copyFrom.deposit || 0;
f.owner.value = copyFrom.owner || "万金强";
}
document.getElementById("ctDialog").showModal();
}
function saveContract() {
const f = document.getElementById("ctForm");
if (!f.reportValidity()) return;
ctSeq += 1;
const no = "SX/X-JT" + today.replaceAll("-", "") + String(ctSeq).padStart(3, "0");
const now = today + " " + new Date().toTimeString().slice(0, 8);
contracts.unshift({
id: Date.now(), no, title: f.title.value.trim(), customer: f.customer.value.trim(),
kind: f.kind.value, att: "", product: f.product.value.trim(), amount: +f.amount.value,
freight: +f.freight.value || 0, deposit: +f.deposit.value || 0, status: "草稿",
signDate: f.signDate.value, owner: f.owner.value.trim() || "万金强", created: now
});
document.getElementById("ctDialog").close();
toast("已保存草稿 " + no);
renderContracts();
}
function copyContract() {
const rows = selectedContracts();
if (rows.length !== 1) { toast("请勾选一份合同后再复制"); return; }
openCtDlg(rows[0]);
}
function submitContracts() {
const rows = selectedContracts().filter((x) => x.status === "草稿");
if (!rows.length) { toast("请勾选草稿状态的合同"); return; }
rows.forEach((x) => { x.status = "待审批"; x.alert = true; });
toast(`已提交 ${rows.length} 份合同审批`);
renderContracts();
}
function toggleCtExport(e) {
e.stopPropagation();
document.getElementById("ctExportDrop").classList.toggle("open");
}
function exportContracts(scope) {
document.getElementById("ctExportDrop").classList.remove("open");
const n = scope === "all" ? filteredContracts().length : Math.min(ctPageSize, filteredContracts().length);
toast(`演示:已导出 ${n} 条合同`);
}
function openCtScan(id) {
scanCtIds = id ? [id] : selectedCtIds.size ? [...selectedCtIds] : [];
if (!scanCtIds.length) { toast("请先勾选合同,或直接点行内上传"); return; }
const f = document.getElementById("ctScanForm");
f.reset();
const names = contracts.filter((x) => scanCtIds.includes(x.id)).map((x) => x.no).join("、");
document.getElementById("ctScanHint").textContent = "将为 " + names + " 上传双章 / 单章扫描件。";
document.getElementById("ctScanDialog").showModal();
}
function saveCtScan() {
const f = document.getElementById("ctScanForm");
if (!f.reportValidity()) return;
const att = f.att.value;
scanCtIds.forEach((id) => {
const x = contracts.find((c) => c.id === id);
if (x) x.att = att;
});
document.getElementById("ctScanDialog").close();
toast("已上传" + att + "扫描件");
renderContracts();
}
const vehicles = [
{ plate: "皖H57837", trailer: "皖HE155", type: "柴油罐车", media: "柴油", driver: "周建军", escort: "李强", km: 188900, status: "运行", tankDue: "2026-10-12", insDue: "2026-12-01", nextMaint: 190000 },
{ plate: "皖HA3690", trailer: "皖HA368", type: "甲醇罐车", media: "甲醇", driver: "陈伟", escort: "王磊", km: 145200, status: "维保中", tankDue: "2027-03-08", insDue: "2026-10-20", nextMaint: 146000 },
{ plate: "皖LB1283", trailer: "皖LB129", type: "液氨罐车", media: "液氨", driver: "胡明", escort: "赵鹏", km: 100480, status: "运行", tankDue: "2026-11-02", insDue: "2027-01-15", nextMaint: 104000 },
{ plate: "皖K92881", trailer: "皖K929", type: "柴油罐车", media: "柴油", driver: "吴涛", escort: "孙斌", km: 213100, status: "运行", tankDue: "2027-06-18", insDue: "2026-09-30", nextMaint: 215000 },
{ plate: "皖H61208", trailer: "皖HE208", type: "硫酸罐车", media: "硫酸", driver: "郑浩", escort: "刘洋", km: 66180, status: "停运", tankDue: "2026-10-05", insDue: "2026-11-11", nextMaint: 68000 },
{ plate: "皖K73155", trailer: "皖KE155", type: "盐酸罐车", media: "盐酸", driver: "李强", escort: "周建军", km: 121050, status: "运行", tankDue: "2027-02-14", insDue: "2026-12-18", nextMaint: 124000 },
{ plate: "皖L88201", trailer: "皖LE201", type: "液碱罐车", media: "液碱", driver: "王磊", escort: "陈伟", km: 159400, status: "运行", tankDue: "2027-04-22", insDue: "2027-02-08", nextMaint: 162000 },
{ plate: "皖H90366", trailer: "皖HE366", type: "柴油罐车", media: "柴油", driver: "孙斌", escort: "吴涛", km: 91880, status: "运行", tankDue: "2026-12-30", insDue: "2026-11-20", nextMaint: 94000 }
];
function hazPlate(v) {
if (!v) return "—";
const plate = typeof v === "string" ? v : v.plate;
const row = typeof v === "string" ? vehicles.find((x) => x.plate === v) : v;
const trailer = row && row.trailer ? `${row.trailer}挂` : "";
return `危${plate}${trailer}`;
}
let fuels = [
{ plate: "皖H57837", date: "2026-09-21", station: "中石化猇亭站", oil: "0# 柴油", liters: 300, amount: 2220, km: 185210, note: "", attachments: [{ name: "猇亭站小票_0921.jpg" }] },
{ plate: "皖H57837", date: "2026-09-28", station: "中石化猇亭站", oil: "0# 柴油", liters: 320, amount: 2368, km: 186420, note: "", attachments: [{ name: "猇亭站小票_0928.jpg" }] },
{ plate: "皖HA3690", date: "2026-09-10", station: "公司内部油库", oil: "0# 柴油", liters: 350, amount: 2520, km: 141500, note: "", attachments: [{ name: "内库出库单_0910.jpg" }] },
{ plate: "皖HA3690", date: "2026-09-22", station: "中石化夷陵站", oil: "0# 柴油", liters: 340, amount: 2516, km: 142200, note: "", attachments: [{ name: "夷陵站小票_0922.jpg" }] },
{ plate: "皖LB1283", date: "2026-09-20", station: "中石油伍家岗", oil: "0# 柴油", liters: 260, amount: 1924, km: 96800, note: "", attachments: [{ name: "伍家岗小票_0920.jpg" }] },
{ plate: "皖LB1283", date: "2026-09-27", station: "中石油伍家岗", oil: "0# 柴油", liters: 280, amount: 2072, km: 97850, note: "", attachments: [{ name: "伍家岗发票_0927.pdf" }] },
{ plate: "皖K92881", date: "2026-09-18", station: "中石化夷陵站", oil: "0# 柴油", liters: 380, amount: 2736, km: 208900, note: "", attachments: [{ name: "夷陵站小票_0918.jpg" }] },
{ plate: "皖K92881", date: "2026-09-26", station: "公司内部油库", oil: "0# 柴油", liters: 410, amount: 2952, km: 210110, note: "夜班补油", attachments: [{ name: "内库出库单_0926.jpg" }] },
{ plate: "皖H61208", date: "2026-09-16", station: "中石化猇亭站", oil: "0# 柴油", liters: 220, amount: 1628, km: 64200, note: "", attachments: [{ name: "猇亭站小票_0916.jpg" }] },
{ plate: "皖K73155", date: "2026-09-19", station: "中石油伍家岗", oil: "0# 柴油", liters: 240, amount: 1776, km: 116800, note: "", attachments: [{ name: "伍家岗小票_0919.jpg" }] },
{ plate: "皖L88201", date: "2026-09-24", station: "中石化夷陵站", oil: "0# 柴油", liters: 280, amount: 2072, km: 154100, note: "", attachments: [{ name: "夷陵站小票_0924.jpg" }] },
{ plate: "皖H90366", date: "2026-09-25", station: "公司内部油库", oil: "0# 柴油", liters: 250, amount: 1850, km: 88200, note: "", attachments: [{ name: "内库出库单_0925.jpg" }] },
{ plate: "皖H57837", date: "2026-10-01", station: "中石化猇亭站", oil: "0# 柴油", liters: 310, amount: 2294, km: 186420, note: "", attachments: [{ name: "猇亭站小票_1001.jpg" }] },
{ plate: "皖H57837", date: "2026-10-08", station: "中石化猇亭站", oil: "0# 柴油", liters: 360, amount: 2664, km: 188900, note: "", attachments: [{ name: "猇亭站小票_1008.jpg" }] },
{ plate: "皖HA3690", date: "2026-10-01", station: "中石化夷陵站", oil: "0# 柴油", liters: 280, amount: 2072, km: 142880, note: "", attachments: [{ name: "夷陵站小票_1001.jpg" }] },
{ plate: "皖HA3690", date: "2026-10-07", station: "中石化夷陵站", oil: "0# 柴油", liters: 320, amount: 2368, km: 145200, note: "", attachments: [{ name: "夷陵站小票_1007.jpg" }] },
{ plate: "皖LB1283", date: "2026-10-01", station: "中石油伍家岗", oil: "0# 柴油", liters: 250, amount: 1850, km: 97850, note: "", attachments: [{ name: "伍家岗小票_1001.jpg" }] },
{ plate: "皖LB1283", date: "2026-10-08", station: "中石油伍家岗", oil: "0# 柴油", liters: 290, amount: 2146, km: 100480, note: "", attachments: [{ name: "伍家岗小票_1008.jpg" }] },
{ plate: "皖K92881", date: "2026-10-01", station: "公司内部油库", oil: "0# 柴油", liters: 360, amount: 2664, km: 210330, note: "", attachments: [{ name: "内库出库单_1001.jpg" }] },
{ plate: "皖K92881", date: "2026-10-09", station: "公司内部油库", oil: "0# 柴油", liters: 420, amount: 3108, km: 213100, note: "", attachments: [{ name: "内库出库单_1009.jpg" }] },
{ plate: "皖H61208", date: "2026-10-01", station: "中石化猇亭站", oil: "0# 柴油", liters: 180, amount: 1332, km: 65420, note: "停运前补油", attachments: [{ name: "猇亭站小票_1001b.jpg" }] },
{ plate: "皖H61208", date: "2026-10-03", station: "中石化猇亭站", oil: "0# 柴油", liters: 90, amount: 666, km: 66180, note: "", attachments: [{ name: "猇亭站小票_1003.jpg" }] },
{ plate: "皖K73155", date: "2026-10-01", station: "中石油伍家岗", oil: "0# 柴油", liters: 220, amount: 1628, km: 118600, note: "", attachments: [{ name: "伍家岗小票_1001b.jpg" }] },
{ plate: "皖K73155", date: "2026-10-08", station: "中石油伍家岗", oil: "0# 柴油", liters: 260, amount: 1924, km: 121050, note: "", attachments: [{ name: "伍家岗小票_1008b.jpg" }] },
{ plate: "皖L88201", date: "2026-10-01", station: "中石化夷陵站", oil: "0# 柴油", liters: 300, amount: 2220, km: 156200, note: "", attachments: [{ name: "夷陵站小票_1001b.jpg" }] },
{ plate: "皖L88201", date: "2026-10-08", station: "中石化夷陵站", oil: "0# 柴油", liters: 330, amount: 2442, km: 159400, note: "", attachments: [{ name: "夷陵站小票_1008.jpg" }] },
{ plate: "皖H90366", date: "2026-10-01", station: "公司内部油库", oil: "0# 柴油", liters: 260, amount: 1924, km: 89400, note: "", attachments: [{ name: "内库出库单_1001b.jpg" }] },
{ plate: "皖H90366", date: "2026-10-08", station: "公司内部油库", oil: "0# 柴油", liters: 280, amount: 2072, km: 91880, note: "", attachments: [{ name: "内库出库单_1008.jpg" }] }
];
let etcs = [
{ applicant: "周建军", plate: "皖H57837", type: "ETC", date: "2026-09-12", km: 185210, amount: 600, used: 480, note: "新车上牌后开通", status: "已提交", attachments: [{ name: "ETC开通凭证_0912.jpg" }] },
{ applicant: "吴九华", plate: "皖HA3690", type: "ETC", date: "2026-09-25", km: 142880, amount: 800, used: 650, note: "", status: "已提交", attachments: [{ name: "ETC开通凭证.jpg" }] },
{ applicant: "胡明", plate: "皖LB1283", type: "ETC", date: "2026-09-18", km: 96800, amount: 500, used: 420, note: "", status: "已提交", attachments: [{ name: "ETC充值_0918.jpg" }] },
{ applicant: "吴涛", plate: "皖K92881", type: "ETC", date: "2026-09-08", km: 208900, amount: 400, used: 300, note: "", status: "已提交", attachments: [{ name: "ETC充值_0908.jpg" }] },
{ applicant: "吴涛", plate: "皖K92881", type: "ETC", date: "2026-09-26", km: 210110, amount: 500, used: 380, note: "", status: "已提交", attachments: [{ name: "ETC充值_0926.jpg" }] },
{ applicant: "郑浩", plate: "皖H61208", type: "ETC", date: "2026-09-16", km: 64200, amount: 300, used: 180, note: "", status: "已提交", attachments: [{ name: "ETC充值_0916.jpg" }] },
{ applicant: "李强", plate: "皖K73155", type: "ETC", date: "2026-09-19", km: 116800, amount: 450, used: 360, note: "", status: "已提交", attachments: [{ name: "ETC充值_0919.jpg" }] },
{ applicant: "王磊", plate: "皖L88201", type: "ETC", date: "2026-09-24", km: 154100, amount: 700, used: 520, note: "", status: "已提交", attachments: [{ name: "ETC充值_0924.jpg" }] },
{ applicant: "孙斌", plate: "皖H90366", type: "ETC", date: "2026-09-25", km: 88200, amount: 400, used: 290, note: "", status: "已提交", attachments: [{ name: "ETC充值_0925.jpg" }] },
{ applicant: "周建军", plate: "皖H57837", type: "ETC", date: "2026-10-01", km: 186420, amount: 800, used: 620, note: "", status: "已提交", attachments: [{ name: "ETC充值_1001.jpg" }] },
{ applicant: "周建军", plate: "皖H57837", type: "ETC", date: "2026-10-08", km: 188900, amount: 500, used: 410, note: "", status: "已提交", attachments: [{ name: "ETC充值_1008.jpg" }] },
{ applicant: "陈伟", plate: "皖HA3690", type: "ETC", date: "2026-10-07", km: 145200, amount: 1000, used: 780, note: "", status: "已提交", attachments: [{ name: "ETC充值_1007.jpg" }] },
{ applicant: "胡明", plate: "皖LB1283", type: "ETC", date: "2026-10-01", km: 97850, amount: 600, used: 480, note: "", status: "已提交", attachments: [{ name: "ETC充值_1001b.jpg" }] },
{ applicant: "胡明", plate: "皖LB1283", type: "ETC", date: "2026-10-08", km: 100480, amount: 400, used: 310, note: "", status: "已提交", attachments: [{ name: "ETC充值_1008b.jpg" }] },
{ applicant: "吴涛", plate: "皖K92881", type: "ETC", date: "2026-10-01", km: 210330, amount: 700, used: 540, note: "", status: "已提交", attachments: [{ name: "ETC充值_1001c.jpg" }] },
{ applicant: "吴涛", plate: "皖K92881", type: "ETC", date: "2026-10-09", km: 213100, amount: 650, used: 500, note: "", status: "已提交", attachments: [{ name: "ETC充值_1009.jpg" }] },
{ applicant: "郑浩", plate: "皖H61208", type: "ETC", date: "2026-10-03", km: 66180, amount: 200, used: 90, note: "停运前充值", status: "已提交", attachments: [{ name: "ETC充值_1003.jpg" }] },
{ applicant: "李强", plate: "皖K73155", type: "ETC", date: "2026-10-01", km: 118600, amount: 500, used: 390, note: "", status: "已提交", attachments: [{ name: "ETC充值_1001d.jpg" }] },
{ applicant: "李强", plate: "皖K73155", type: "ETC", date: "2026-10-08", km: 121050, amount: 480, used: 360, note: "", status: "已提交", attachments: [{ name: "ETC充值_1008c.jpg" }] },
{ applicant: "王磊", plate: "皖L88201", type: "ETC", date: "2026-10-01", km: 156200, amount: 750, used: 580, note: "", status: "已提交", attachments: [{ name: "ETC充值_1001e.jpg" }] },
{ applicant: "王磊", plate: "皖L88201", type: "ETC", date: "2026-10-08", km: 159400, amount: 600, used: 450, note: "", status: "已提交", attachments: [{ name: "ETC充值_1008d.jpg" }] },
{ applicant: "孙斌", plate: "皖H90366", type: "ETC", date: "2026-10-08", km: 91880, amount: 550, used: 410, note: "", status: "已提交", attachments: [{ name: "ETC充值_1008e.jpg" }] }
];
let oilcards = [
{ applicant: "周建军", plate: "皖H57837", type: "油卡", date: "2026-09-12", km: 185210, amount: 2000, used: 1680, note: "月初充值", status: "已提交", attachments: [{ name: "油卡充值_0912.jpg" }] },
{ applicant: "吴九华", plate: "皖HA3690", type: "油卡", date: "2026-09-25", km: 142880, amount: 2500, used: 2100, note: "", status: "已提交", attachments: [{ name: "油卡充值_0925.jpg" }] },
{ applicant: "胡明", plate: "皖LB1283", type: "油卡", date: "2026-09-18", km: 96800, amount: 1800, used: 1420, note: "", status: "已提交", attachments: [{ name: "油卡充值_0918.jpg" }] },
{ applicant: "吴涛", plate: "皖K92881", type: "油卡", date: "2026-09-08", km: 208900, amount: 2200, used: 1900, note: "", status: "已提交", attachments: [{ name: "油卡充值_0908.jpg" }] },
{ applicant: "吴涛", plate: "皖K92881", type: "油卡", date: "2026-09-26", km: 210110, amount: 1500, used: 980, note: "", status: "已提交", attachments: [{ name: "油卡充值_0926.jpg" }] },
{ applicant: "郑浩", plate: "皖H61208", type: "油卡", date: "2026-09-16", km: 64200, amount: 800, used: 520, note: "", status: "已提交", attachments: [{ name: "油卡充值_0916.jpg" }] },
{ applicant: "李强", plate: "皖K73155", type: "油卡", date: "2026-09-19", km: 116800, amount: 1600, used: 1280, note: "", status: "已提交", attachments: [{ name: "油卡充值_0919.jpg" }] },
{ applicant: "王磊", plate: "皖L88201", type: "油卡", date: "2026-09-24", km: 154100, amount: 2400, used: 1860, note: "", status: "已提交", attachments: [{ name: "油卡充值_0924.jpg" }] },
{ applicant: "孙斌", plate: "皖H90366", type: "油卡", date: "2026-09-25", km: 88200, amount: 1200, used: 860, note: "", status: "已提交", attachments: [{ name: "油卡充值_0925.jpg" }] },
{ applicant: "周建军", plate: "皖H57837", type: "油卡", date: "2026-10-01", km: 186420, amount: 2200, used: 1740, note: "", status: "已提交", attachments: [{ name: "油卡充值_1001.jpg" }] },
{ applicant: "周建军", plate: "皖H57837", type: "油卡", date: "2026-10-08", km: 188900, amount: 1500, used: 1100, note: "", status: "已提交", attachments: [{ name: "油卡充值_1008.jpg" }] },
{ applicant: "陈伟", plate: "皖HA3690", type: "油卡", date: "2026-10-07", km: 145200, amount: 2800, used: 2300, note: "", status: "已提交", attachments: [{ name: "油卡充值_1007.jpg" }] },
{ applicant: "胡明", plate: "皖LB1283", type: "油卡", date: "2026-10-01", km: 97850, amount: 1600, used: 1280, note: "", status: "已提交", attachments: [{ name: "油卡充值_1001b.jpg" }] },
{ applicant: "胡明", plate: "皖LB1283", type: "油卡", date: "2026-10-08", km: 100480, amount: 1200, used: 860, note: "", status: "已提交", attachments: [{ name: "油卡充值_1008b.jpg" }] },
{ applicant: "吴涛", plate: "皖K92881", type: "油卡", date: "2026-10-01", km: 210330, amount: 2000, used: 1640, note: "", status: "已提交", attachments: [{ name: "油卡充值_1001c.jpg" }] },
{ applicant: "吴涛", plate: "皖K92881", type: "油卡", date: "2026-10-09", km: 213100, amount: 1800, used: 1420, note: "", status: "已提交", attachments: [{ name: "油卡充值_1009.jpg" }] },
{ applicant: "郑浩", plate: "皖H61208", type: "油卡", date: "2026-10-03", km: 66180, amount: 500, used: 220, note: "停运前充值", status: "已提交", attachments: [{ name: "油卡充值_1003.jpg" }] },
{ applicant: "李强", plate: "皖K73155", type: "油卡", date: "2026-10-01", km: 118600, amount: 1700, used: 1310, note: "", status: "已提交", attachments: [{ name: "油卡充值_1001d.jpg" }] },
{ applicant: "李强", plate: "皖K73155", type: "油卡", date: "2026-10-08", km: 121050, amount: 1400, used: 980, note: "", status: "已提交", attachments: [{ name: "油卡充值_1008c.jpg" }] },
{ applicant: "王磊", plate: "皖L88201", type: "油卡", date: "2026-10-01", km: 156200, amount: 2100, used: 1680, note: "", status: "已提交", attachments: [{ name: "油卡充值_1001e.jpg" }] },
{ applicant: "王磊", plate: "皖L88201", type: "油卡", date: "2026-10-08", km: 159400, amount: 1600, used: 1210, note: "", status: "已提交", attachments: [{ name: "油卡充值_1008d.jpg" }] },
{ applicant: "孙斌", plate: "皖H90366", type: "油卡", date: "2026-10-08", km: 91880, amount: 1300, used: 940, note: "", status: "已提交", attachments: [{ name: "油卡充值_1008e.jpg" }] }
];
let maints = [
{ plate: "皖H57837", type: "定期保养", inDate: "2026-03-12", outDate: "2026-03-12", shop: "公司维修班", amount: 580, nextKm: 178000, item: "机油机滤" },
{ plate: "皖H57837", type: "定期保养", inDate: "2026-09-08", outDate: "2026-09-08", shop: "公司维修班", amount: 620, nextKm: 190000, item: "机油机滤、检查罐阀" },
{ plate: "皖H57837", type: "故障维修", inDate: "2026-10-06", outDate: "2026-10-07", shop: "宜昌安达汽修", amount: 980, nextKm: 192000, item: "更换半轴油封" },
{ plate: "皖HA3690", type: "定期保养", inDate: "2026-04-18", outDate: "2026-04-18", shop: "公司维修班", amount: 720, nextKm: 138000, item: "机油机滤、空滤" },
{ plate: "皖HA3690", type: "故障维修", inDate: "2026-09-28", outDate: "", shop: "宜昌安达汽修", amount: 1860, nextKm: 146000, item: "更换刹车片、排查气路" },
{ plate: "皖LB1283", type: "定期保养", inDate: "2026-05-06", outDate: "2026-05-06", shop: "公司维修班", amount: 560, nextKm: 98000, item: "机油机滤" },
{ plate: "皖LB1283", type: "故障维修", inDate: "2026-10-03", outDate: "2026-10-04", shop: "宜昌安达汽修", amount: 1280, nextKm: 104000, item: "更换轮胎、动平衡" },
{ plate: "皖K92881", type: "定期保养", inDate: "2026-06-22", outDate: "2026-06-22", shop: "公司维修班", amount: 890, nextKm: 208000, item: "机油机滤、检查罐阀" },
{ plate: "皖K92881", type: "故障维修", inDate: "2026-10-08", outDate: "2026-10-09", shop: "宜昌安达汽修", amount: 2100, nextKm: 215000, item: "更换刹车片、调整间隙" },
{ plate: "皖H61208", type: "定期保养", inDate: "2026-02-16", outDate: "2026-02-16", shop: "公司维修班", amount: 540, nextKm: 62000, item: "机油机滤" },
{ plate: "皖H61208", type: "罐体检验", inDate: "2026-09-20", outDate: "", shop: "省特检院宜昌分院", amount: 4800, nextKm: 68000, item: "罐体定期检验" },
{ plate: "皖K73155", type: "定期保养", inDate: "2026-04-09", outDate: "2026-04-09", shop: "公司维修班", amount: 640, nextKm: 114000, item: "机油机滤、检查泵阀" },
{ plate: "皖K73155", type: "故障维修", inDate: "2026-10-02", outDate: "2026-10-03", shop: "宜昌安达汽修", amount: 1560, nextKm: 124000, item: "更换水泵、处理渗漏" },
{ plate: "皖L88201", type: "定期保养", inDate: "2026-05-21", outDate: "2026-05-21", shop: "公司维修班", amount: 700, nextKm: 150000, item: "机油机滤" },
{ plate: "皖L88201", type: "故障维修", inDate: "2026-08-14", outDate: "2026-08-15", shop: "宜昌安达汽修", amount: 1100, nextKm: 156000, item: "更换皮带、张紧轮" },
{ plate: "皖H90366", type: "定期保养", inDate: "2026-02-27", outDate: "2026-02-27", shop: "公司维修班", amount: 580, nextKm: 86000, item: "机油机滤" },
{ plate: "皖H90366", type: "故障维修", inDate: "2026-10-05", outDate: "2026-10-05", shop: "公司维修班", amount: 430, nextKm: 94000, item: "更换雨刮、检查灯光" }
];
let expSeq = 12;
let preSeq = 4;
let expTab = "全部";
let expFinKind = "";
let expRejectId = null;
let expFiles = [];
let expLines = [];
let selectedInvIds = [];
const EXP_TYPES = ["差旅费", "交通费", "办公费", "招待费", "通讯费", "采购费", "其他"];
const EXP_STAFF = [
{ name: "吴九华", dept: "行政人事" },
{ name: "周建军", dept: "运输部" },
{ name: "陈伟", dept: "运输部" },
{ name: "胡明", dept: "生产部" },
{ name: "吴涛", dept: "销售部" },
{ name: "郑浩", dept: "仓储部" },
{ name: "刘洋", dept: "仓储部" },
{ name: "孙斌", dept: "运输部" }
];
let invWallet = [
{ id: 1, name: "高铁票_0920.pdf", amount: 580, date: "2026-09-20" },
{ id: 2, name: "住宿发票.pdf", amount: 420, date: "2026-09-20" },
{ id: 3, name: "过路过桥发票.pdf", amount: 186, date: "2026-09-27" },
{ id: 4, name: "办公耗材专票.pdf", amount: 890, date: "2026-09-26" },
{ id: 5, name: "通讯费发票.pdf", amount: 128, date: "2026-09-25" }
];
const EXP_BUDGET = {
"运输部": { total: 20000, used: 15800 },
"生产部": { total: 12000, used: 4100 },
"行政人事": { total: 8000, used: 2200 },
"销售部": { total: 15000, used: 14200 },
"仓储部": { total: 6000, used: 1800 }
};
let expLoans = [
{ no: "JK-20260901-01", user: "周建军", dept: "运输部", amount: 3000, remain: 3000, date: "2026-09-01", status: "未清" },
{ no: "JK-20260812-02", user: "吴涛", dept: "销售部", amount: 2000, remain: 0, date: "2026-08-12", status: "已清" }
];
let preExps = [
{ no: "SQ-20260918-001", user: "周建军", dept: "运输部", type: "出差", amount: 2000, date: "2026-09-18", status: "已批准", remark: "宜昌客户拜访差旅预估" },
{ no: "SQ-20260928-002", user: "吴涛", dept: "销售部", type: "招待", amount: 8000, date: "2026-09-28", status: "待财务初审", remark: "重点客户来访招待" },
{ no: "SQ-20260920-003", user: "郑浩", dept: "仓储部", type: "采购", amount: 1200, date: "2026-09-20", status: "已驳回", remark: "劳保采购,事由不清" }
];
let expenses = [
{
id: 1, no: "BX-20260928-007", user: "周建军", dept: "运输部", type: "差旅费", amount: 1280,
date: "2026-09-20", submit: "2026-09-28", status: "待财务初审",
remark: "宜昌客户拜访往返,关联备用金申请",
invoices: ["高铁票.pdf", "住宿发票.pdf", "行程单.jpg"],
payWay: "冲销借款", loanNo: "JK-20260901-01", bank: "",
preNo: "SQ-20260918-001", overBudget: false,
logs: [
{ t: "2026-09-18 09:10", who: "周建军", act: "备用金申请已批准", note: "出差预估 2,000 元" },
{ t: "2026-09-28 16:20", who: "周建军", act: "提交报销", note: "贴票 3 张,申请冲销借款" }
]
},
{
id: 2, no: "BX-20260927-006", user: "陈伟", dept: "运输部", type: "交通费", amount: 360,
date: "2026-09-27", submit: "2026-09-27", status: "待财务初审",
remark: "过路过桥及停车",
invoices: ["过路过桥发票.pdf", "停车费收据.jpg"],
payWay: "个人银行卡", loanNo: "", bank: "6222****6180",
preNo: "", overBudget: false,
logs: [
{ t: "2026-09-27 11:02", who: "陈伟", act: "提交报销", note: "贴票 2 张" },
{ t: "2026-09-27 15:40", who: "运输部负责人", act: "部门审核通过", note: "业务真实,金额合理" }
]
},
{
id: 3, no: "BX-20260926-005", user: "吴九华", dept: "行政人事", type: "办公费", amount: 890,
date: "2026-09-26", submit: "2026-09-26", status: "待终审",
remark: "打印耗材与档案袋",
invoices: ["增值税普通发票.pdf"],
payWay: "对公转账", loanNo: "", bank: "山峡代工办公耗材户",
preNo: "", overBudget: false, finalRole: "部门经理",
logs: [
{ t: "2026-09-26 10:11", who: "吴九华", act: "提交报销", note: "" },
{ t: "2026-09-26 14:02", who: "行政人事负责人", act: "部门审核通过", note: "部门工作需要" },
{ t: "2026-09-26 17:20", who: "财务会计", act: "财务初审通过", note: "发票真伪核验通过,预算有额度" }
]
},
{
id: 4, no: "BX-20260925-004", user: "胡明", dept: "生产部", type: "通讯费", amount: 128,
date: "2026-09-25", submit: "2026-09-25", status: "待付款",
remark: "9 月通讯补贴",
invoices: ["通讯费发票.pdf"],
payWay: "个人银行卡", loanNo: "", bank: "6228****0902",
preNo: "", overBudget: false, finalRole: "部门经理",
logs: [
{ t: "2026-09-25 09:00", who: "胡明", act: "提交报销", note: "" },
{ t: "2026-09-25 10:20", who: "生产部负责人", act: "部门审核通过", note: "属岗位补贴" },
{ t: "2026-09-25 11:10", who: "财务会计", act: "财务初审通过", note: "票据合规" },
{ t: "2026-09-25 16:00", who: "部门经理", act: "终审同意", note: "小额,部门经理终审" }
]
},
{
id: 5, no: "BX-20260924-003", user: "郑浩", dept: "仓储部", type: "交通费", amount: 210,
date: "2026-09-24", submit: "", status: "草稿",
remark: "送样至检测站",
invoices: [],
payWay: "个人银行卡", loanNo: "", bank: "",
preNo: "", overBudget: false, logs: [{ t: "2026-09-24 18:01", who: "郑浩", act: "保存草稿", note: "待补发票" }]
},
{
id: 6, no: "BX-20260922-002", user: "吴涛", dept: "销售部", type: "招待费", amount: 2460,
date: "2026-09-22", submit: "2026-09-22", status: "已驳回",
remark: "客户来访接待",
invoices: ["餐饮小票.jpg"],
payWay: "个人银行卡", loanNo: "", bank: "6222****0770",
preNo: "", overBudget: false,
logs: [
{ t: "2026-09-22 20:10", who: "吴涛", act: "提交报销", note: "" },
{ t: "2026-09-23 09:30", who: "销售部负责人", act: "部门审核通过", note: "来访属实" },
{ t: "2026-09-23 14:18", who: "财务会计", act: "退回补资料", note: "发票抬头不符、缺少酒水清单,请补增值税发票后重提" }
]
},
{
id: 7, no: "BX-20260918-001", user: "刘洋", dept: "仓储部", type: "办公费", amount: 540,
date: "2026-09-18", submit: "2026-09-18", status: "已归档",
remark: "劳保用品补购",
invoices: ["专票.pdf", "入库单.jpg"],
payWay: "对公转账", loanNo: "", bank: "劳保供应商对公户",
preNo: "", overBudget: false, voucher: "记-202609-118",
logs: [
{ t: "2026-09-18 11:00", who: "刘洋", act: "提交报销", note: "" },
{ t: "2026-09-18 15:00", who: "仓储部负责人", act: "部门审核通过", note: "属库区劳保" },
{ t: "2026-09-19 09:10", who: "财务会计", act: "财务初审通过", note: "可抵扣专票" },
{ t: "2026-09-19 11:00", who: "部门经理", act: "终审同意", note: "" },
{ t: "2026-09-19 16:40", who: "出纳", act: "已付款", note: "对公支付 540 元" },
{ t: "2026-09-20 10:05", who: "会计", act: "制单归档", note: "凭证 记-202609-118,发票装订" }
]
},
{
id: 8, no: "BX-20260930-008", user: "吴涛", dept: "销售部", type: "招待费", amount: 12800,
date: "2026-09-30", submit: "2026-09-30", status: "待终审",
remark: "重点客户商务招待,超部门预算",
invoices: ["餐饮专票.pdf", "签到表.pdf"],
payWay: "对公转账", loanNo: "", bank: "酒店对公户",
preNo: "SQ-20260928-002", overBudget: true, finalRole: "总经理",
logs: [
{ t: "2026-09-30 08:40", who: "吴涛", act: "提交报销", note: "备用金申请尚未批完,同步提报" },
{ t: "2026-09-30 10:12", who: "销售部负责人", act: "部门审核通过", note: "业务真实,金额偏高已说明" },
{ t: "2026-09-30 14:55", who: "财务会计", act: "财务初审通过", note: "票据合规;销售部预算剩余不足,转总经理专项终审" }
]
},
{
id: 9, no: "BX-20260929-009", user: "孙斌", dept: "运输部", type: "差旅费", amount: 4860,
date: "2026-09-12", submit: "2026-09-29", status: "待财务初审",
remark: "安庆办事处驻点一周",
invoices: ["住宿发票.pdf", "餐饮发票.pdf", "高铁票.pdf"],
payWay: "个人银行卡", loanNo: "", bank: "6222****4410",
preNo: "", overBudget: false, finalRole: "财务主管",
logs: [
{ t: "2026-09-29 09:18", who: "孙斌", act: "提交报销", note: "发生日距今已超 15 天" },
{ t: "2026-09-29 11:30", who: "运输部负责人", act: "部门审核通过", note: "驻点任务属实" }
]
},
{
id: 10, no: "BX-20260801-010", user: "赵鹏", dept: "生产部", type: "交通费", amount: 180,
date: "2026-07-10", submit: "2026-09-20", status: "已驳回",
remark: "取样送检打车",
invoices: ["打车票.jpg"],
payWay: "个人银行卡", loanNo: "", bank: "6217****3301",
preNo: "", overBudget: false,
logs: [
{ t: "2026-09-20 08:00", who: "赵鹏", act: "提交报销", note: "" },
{ t: "2026-09-20 09:40", who: "财务会计", act: "逾期退回", note: "业务结束已超过 45 天,按制度不予受理" }
]
},
{
id: 11, no: "BX-20260921-011", user: "李强", dept: "运输部", type: "差旅费", amount: 960,
date: "2026-09-21", submit: "2026-09-21", status: "待归档",
remark: "随车押运补贴及餐费",
invoices: ["餐票.pdf"],
payWay: "冲销借款", loanNo: "JK-20260901-01", bank: "",
preNo: "", overBudget: false, paid: 960,
logs: [
{ t: "2026-09-21 17:00", who: "李强", act: "提交报销", note: "" },
{ t: "2026-09-22 09:00", who: "运输部负责人", act: "部门审核通过", note: "" },
{ t: "2026-09-22 11:20", who: "财务会计", act: "财务初审通过", note: "" },
{ t: "2026-09-22 15:00", who: "部门经理", act: "终审同意", note: "" },
{ t: "2026-09-23 10:10", who: "出纳", act: "已冲借款", note: "冲销 JK-20260901-01 中 960 元" }
]
}
];
let currentMod = "plan";
const PLACE_PAGES = {
fav: { parent: "收藏", title: "收藏", desc: "常用功能与页面可在此固定,便于快速进入。" },
todo: { parent: "待办列表", title: "待办列表", desc: "汇总审批、预警与待处理事项。" },
"crm-quote": { parent: "CRM管理", title: "报价单管理", desc: "客户询价、报价编制与跟进。" },
"crm-contract": { parent: "CRM管理", title: "合同管理", desc: "合同起草、审批、归档与履约跟踪。" },
"crm-price": { parent: "CRM管理", title: "调价函管理", desc: "调价申请、函件下达与生效记录。" },
"crm-customer": { parent: "CRM管理", title: "客户管理", desc: "客户档案、信用与跟进记录。", coming: "使用山峡系统原系统页面" },
"fin-ar": { parent: "财务管理", title: "应收应付", desc: "客户应收、供应商应付对账与核销。" },
"fin-view": { parent: "财务管理", title: "视图中心", desc: "财务看板与关键指标视图。" },
"fin-petty": { parent: "财务管理", title: "备用金管理", desc: "备用金领用、核销与结余。" },
"fin-config": { parent: "财务管理", title: "基础配置", desc: "科目、审批流与财务基础参数。" },
"hr-org": { parent: "人事管理", title: "组织架构", desc: "部门编制与汇报关系。" },
weigh: { parent: "运输管理", title: "地磅管理", desc: "过磅记录、皮重净重与出厂对账。" },
freight: { parent: "运输管理", title: "运费结算", desc: "按计划、车辆与线路核算运费,对账后结算。" },
system: { parent: "系统管理", title: "系统管理", desc: "用户、角色、权限与基础参数。" },
"sys-dict": { parent: "系统管理", title: "字典管理", desc: "维护系统枚举、编码与基础数据字典。" }
};
let prodPage = 1;
let prodPageSize = 20;
let prodSeq = 7463;
let selectedProdIds = new Set();
let prodCatFilter = "";
let prodCats = ["其他", "酸类", "碱类", "溶剂", "助剂", "包装"];
const PROD_SEED = [
{ name: "硼砂", cat: "其他", brand: "益泉", alias: "", spec: "98%", price: "", hz: "", py: "BS" },
{ name: "38%工业硫酸", cat: "", brand: "", alias: "", spec: "38%", price: "", hz: "毒", py: "GYSF" },
{ name: "吨桶", cat: "其他", brand: "", alias: "", spec: "/", price: "", hz: "", py: "DT" },
{ name: "双氧水", cat: "", brand: "", alias: "", spec: "AR", price: "", hz: "爆", py: "SYS" },
{ name: "活性炭", cat: "", brand: "", alias: "", spec: "", price: "", hz: "", py: "HXT" },
{ name: "液氨", cat: "", brand: "", alias: "", spec: "", price: "", hz: "", py: "YA" },
{ name: "乙二醇", cat: "", brand: "", alias: "", spec: "", price: "", hz: "", py: "YEC" },
{ name: "次氯酸钠", cat: "", brand: "", alias: "", spec: "A型I类", price: "", hz: "", py: "CLSN" },
{ name: "亚硫酸氢钠", cat: "", brand: "", alias: "", spec: "AR", price: "", hz: "", py: "YLSQN" },
{ name: "工业硫酸", cat: "", brand: "", alias: "", spec: "≥93%", price: "", hz: "毒", py: "GYSF" },
{ name: "氨水", cat: "", brand: "", alias: "", spec: "25%-28%", price: "", hz: "", py: "AS" },
{ name: "工业硫酸", cat: "", brand: "", alias: "", spec: "53%-56%", price: "", hz: "毒", py: "GYSF" },
{ name: "片碱", cat: "碱类", brand: "", alias: "氢氧化钠", spec: "99%", price: "3700", hz: "", py: "PJ" },
{ name: "32%液碱", cat: "碱类", brand: "", alias: "", spec: "32%", price: "840", hz: "", py: "YJ" },
{ name: "50%液碱", cat: "碱类", brand: "", alias: "", spec: "50%", price: "1270", hz: "", py: "YJ" },
{ name: "工业盐酸", cat: "酸类", brand: "", alias: "", spec: "31%", price: "200", hz: "毒", py: "YS" },
{ name: "一水柠檬酸", cat: "助剂", brand: "", alias: "", spec: "99.50%", price: "11000", hz: "", py: "NMS" },
{ name: "聚合氯化铝", cat: "助剂", brand: "", alias: "PAC", spec: "28%", price: "2000", hz: "", py: "JHLSL" },
{ name: "氯化钠", cat: "助剂", brand: "", alias: "工业盐", spec: "国标", price: "1400", hz: "", py: "LHN" },
{ name: "98%工业硫酸", cat: "酸类", brand: "", alias: "", spec: "98%", price: "900", hz: "毒", py: "GYSF" }
];
const products = [];
(function buildProducts() {
for (let i = 0; i < 143; i++) {
const s = PROD_SEED[i % PROD_SEED.length];
const no = "SXWL" + String(7463 - i).padStart(6, "0");
const extraOff = i > 40 && i % 11 === 0;
products.push({
id: i + 1,
no,
name: s.name,
cat: s.cat,
brand: s.brand,
alias: s.alias,
spec: s.spec,
price: s.price,
hz: s.hz,
py: s.py,
status: extraOff ? "下架" : "上架"
});
}
})();
function dash(v) { return v === undefined || v === null || String(v).trim() === "" ? "-" : v; }
function filteredProducts() {
const q = (document.getElementById("prodQ")?.value || "").trim().toUpperCase();
let rows = products.slice();
if (prodCatFilter) rows = rows.filter((x) => (x.cat || "未分类") === prodCatFilter);
if (q) {
rows = rows.filter((x) => [x.no, x.name, x.brand, x.alias, x.spec, x.py, x.cat].join(" ").toUpperCase().includes(q));
}
return rows;
}
function selectedProds() { return products.filter((x) => selectedProdIds.has(x.id)); }
function toggleProd(id, on) {
if (on) selectedProdIds.add(id); else selectedProdIds.delete(id);
renderProducts();
}
function toggleProdAll(on) {
const rows = filteredProducts();
const start = (prodPage - 1) * prodPageSize;
rows.slice(start, start + prodPageSize).forEach((x) => on ? selectedProdIds.add(x.id) : selectedProdIds.delete(x.id));
renderProducts();
}
function fillProdCatSelect(val) {
const sel = document.getElementById("prodCatSelect");
if (!sel) return;
sel.innerHTML = `<option value="">未分类</option>` + prodCats.map((c) => `<option>${c}</option>`).join("");
if (val != null) sel.value = val;
}
function renderProducts() {
const rows = filteredProducts();
const pages = Math.max(1, Math.ceil(rows.length / prodPageSize));
if (prodPage > pages) prodPage = pages;
const start = (prodPage - 1) * prodPageSize;
const pageRows = rows.slice(start, start + prodPageSize);
const allOn = pageRows.length && pageRows.every((x) => selectedProdIds.has(x.id));
document.getElementById("prodSelHint").textContent = `已选 ${selectedProdIds.size} 项`;
const filt = `<span class="th-filt"></span>`;
document.getElementById("prodTable").innerHTML = pageRows.length ? `<table class="erp-table prod-table">
<thead><tr>
<th class="chk"><input type="checkbox" ${allOn ? "checked" : ""} onchange="toggleProdAll(this.checked)" /></th>
<th class="col-idx">序号</th>
<th>产品编号${filt}</th>
<th>产品名称${filt}</th>
<th>产品分类${filt}</th>
<th>品牌${filt}</th>
<th>别名${filt}</th>
<th>规格型号${filt}</th>
<th>标准价(元)${filt}</th>
<th>状态</th>
</tr></thead>
<tbody>${pageRows.map((x, i) => `<tr>
<td class="chk"><input type="checkbox" ${selectedProdIds.has(x.id) ? "checked" : ""} onchange="toggleProd(${x.id}, this.checked)" /></td>
<td>${start + i + 1}</td>
<td>${x.no}</td>
<td><button class="quote-link" type="button" onclick="openProdDlg(${x.id})">${x.name}</button>${x.hz ? `<span class="prod-hz">${x.hz}</span>` : ""}</td>
<td>${x.cat || ""}</td>
<td>${dash(x.brand)}</td>
<td>${dash(x.alias)}</td>
<td>${dash(x.spec)}</td>
<td class="${x.price ? "" : "prod-price"}">${dash(x.price)}</td>
<td><span class="prod-st${x.status === "下架" ? " off" : ""}">${x.status}</span></td>
</tr>`).join("")}</tbody></table>` : `<div class="empty">暂无产品</div>`;
const nums = [];
const show = Math.min(pages, 6);
for (let n = 1; n <= show; n++) nums.push(n);
document.getElementById("prodPager").innerHTML = `
<span class="muted">共 ${rows.length} 条</span>
<select class="page-size" onchange="prodPageSize=+this.value;prodPage=1;renderProducts()">
<option value="20" ${prodPageSize === 20 ? "selected" : ""}>20条/页</option>
<option value="50" ${prodPageSize === 50 ? "selected" : ""}>50条/页</option>
</select>
<button type="button" onclick="prodPage=Math.max(1, prodPage-1);renderProducts()">‹</button>
${nums.map((n) => `<button type="button" class="${n === prodPage ? "on" : ""}" onclick="prodPage=${n};renderProducts()">${n}</button>`).join("")}
${pages > 6 ? `<span class="muted">…</span><button type="button" class="${prodPage === pages ? "on" : ""}" onclick="prodPage=${pages};renderProducts()">${pages}</button>` : ""}
<button type="button" onclick="prodPage=Math.min(${pages}, prodPage+1);renderProducts()">›</button>
<span class="muted">前往</span>
<input class="quote-goto" type="number" min="1" max="${pages}" value="${prodPage}" onchange="prodPage=Math.min(${pages}, Math.max(1, +this.value||1));renderProducts()" />
<span class="muted">页</span>`;
}
function openProdDlg(id) {
const f = document.getElementById("prodForm");
f.reset();
const x = id ? products.find((p) => p.id === id) : null;
fillProdCatSelect(x ? (x.cat || "") : "");
document.getElementById("prodDlgTitle").textContent = x ? "编辑产品" : "新增产品";
f.id.value = x ? x.id : "";
if (x) {
f.name.value = x.name;
f.no.value = x.no;
f.brand.value = x.brand || "";
f.alias.value = x.alias || "";
f.spec.value = x.spec || "";
f.price.value = x.price || "";
f.py.value = x.py || "";
f.hz.value = x.hz || "";
f.status.value = x.status;
}
document.getElementById("prodDialog").showModal();
}
function saveProd() {
const f = document.getElementById("prodForm");
if (!f.reportValidity()) return;
const payload = {
name: f.name.value.trim(),
no: f.no.value.trim(),
cat: f.cat.value,
brand: f.brand.value.trim(),
alias: f.alias.value.trim(),
spec: f.spec.value.trim(),
price: f.price.value,
py: (f.py.value.trim() || f.name.value.trim()).toUpperCase(),
hz: f.hz.value,
status: f.status.value
};
const id = f.id.value ? +f.id.value : 0;
if (id) {
const x = products.find((p) => p.id === id);
Object.assign(x, payload);
toast("已保存 " + x.no);
} else {
prodSeq += 1;
if (!payload.no) payload.no = "SXWL" + String(prodSeq).padStart(6, "0");
products.unshift({ id: Date.now(), ...payload });
toast("已新增 " + payload.no);
prodPage = 1;
}
document.getElementById("prodDialog").close();
renderProducts();
}
function editSelectedProd() {
const rows = selectedProds();
if (rows.length !== 1) { toast("请勾选一条产品后再编辑"); return; }
openProdDlg(rows[0].id);
}
function deleteProds() {
const rows = selectedProds();
if (!rows.length) { toast("请先勾选产品"); return; }
const ids = new Set(rows.map((x) => x.id));
for (let i = products.length - 1; i >= 0; i--) {
if (ids.has(products[i].id)) products.splice(i, 1);
}
selectedProdIds.clear();
toast(`已删除 ${rows.length} 条产品`);
renderProducts();
}
function shelfProds(st) {
const rows = selectedProds();
if (!rows.length) { toast("请先勾选产品"); return; }
rows.forEach((x) => { x.status = st; });
toast(`已${st} ${rows.length} 条产品`);
renderProducts();
}
function toggleProdExport(e) {
e.stopPropagation();
document.getElementById("prodExportDrop").classList.toggle("open");
}
function exportProducts(scope) {
document.getElementById("prodExportDrop").classList.remove("open");
const n = scope === "page" ? Math.min(prodPageSize, filteredProducts().length) : filteredProducts().length;
toast(`演示:已导出 ${n} 条产品`);
}
function openProdCatDlg() {
renderProdCats();
document.getElementById("prodCatDialog").showModal();
}
function renderProdCats() {
const counts = {};
products.forEach((x) => {
const k = x.cat || "未分类";
counts[k] = (counts[k] || 0) + 1;
});
const keys = ["全部", ...prodCats, "未分类"];
document.getElementById("prodCatList").innerHTML = keys.map((c) => {
const n = c === "全部" ? products.length : (counts[c] || 0);
const on = (c === "全部" && !prodCatFilter) || prodCatFilter === c;
return `<button type="button" class="${on ? "on" : ""}" onclick="filterProdCat('${c}')"><span>${c}</span><span>${n}</span></button>`;
}).join("");
}
function filterProdCat(c) {
prodCatFilter = c === "全部" ? "" : c;
prodPage = 1;
renderProdCats();
renderProducts();
}
function addProdCat() {
const name = (document.getElementById("prodCatNew").value || "").trim();
if (!name) { toast("请输入分类名称"); return; }
if (prodCats.includes(name)) { toast("分类已存在"); return; }
prodCats.push(name);
document.getElementById("prodCatNew").value = "";
renderProdCats();
toast("已新增分类 " + name);
}
let currentCertId = null;
let certSeq = 4;
let transSeq = 5;
let planSeq = 21;
let planTab = "全部";
let planDateTab = "全部";
let archiveTab = "待审核";
let selectedArchiveIds = new Set();
let planCardFilter = "";
let selectedPlanIds = new Set();
let linkingPlanTaskIds = [];
let taskTab = "全部";
let selectedTaskIds = new Set();
let reportingTaskId = null;
const SITE_STAFF = ["刘洋(硫酸库)", "张伟(盐酸库)", "陈刚(分装库)", "王磊(液碱库)", "李强(现场班长)", "赵敏(精制岗)"];
let taskSeq = 3;
const TASKS = [
{
id: 1, no: "SXFZ20261001-001", kind: "稀释", status: "待执行",
date: "2026-10-01", orderDate: "2026-10-01", deliverDate: "2026-10-04",
orderBy: "周建军", handler: "刘洋(硫酸库)", maker: "郑丽娜", auditor: "", supervisor: "",
bucket: "桶循环", remark: "现场按规程佩戴防护",
planTime: "2026-10-04T08:00", planId: 7, planNo: "CK20260928001",
product: "硫酸", spec: "98%", pack: "槽罐车", qty: 30, unit: "吨",
assignee: "刘洋(硫酸库)",
lines: [{ planId: 7, planNo: "CK20260928001", product: "硫酸", model: "98%", pack: "槽罐车", unit: "吨", qty: 30, done: false }]
},
{
id: 2, no: "SXFZ20261001-002", kind: "精制", status: "执行中",
date: "2026-10-01", orderDate: "2026-10-01", deliverDate: "2026-10-03",
orderBy: "胡明", handler: "张伟(盐酸库)", maker: "郑丽娜", auditor: "", supervisor: "",
bucket: "全新桶", remark: "试剂线单独作业,不得混兑",
planTime: "2026-10-03T09:00", planId: 9, planNo: "RZ20260921012",
product: "工业盐酸", spec: "31%", pack: "槽罐车", qty: 28, unit: "吨",
assignee: "张伟(盐酸库)",
receivedAt: "2026-10-03T08:30",
lines: [{ planId: 9, planNo: "RZ20260921012", product: "工业盐酸", model: "31%", pack: "槽罐车", unit: "吨", qty: 28, done: false }]
},
{
id: 3, no: "SXFZ20260930-001", kind: "分装", status: "已完成",
date: "2026-09-30", orderDate: "2026-09-30", deliverDate: "2026-10-07",
orderBy: "调度员", handler: "陈刚(分装库)", maker: "郑丽娜", auditor: "", supervisor: "",
bucket: "桶循环", remark: "江峰",
planTime: "2026-10-02T14:00", planId: 8, planNo: "ZX20260925008",
product: "工业硫酸", spec: "98%", pack: "吨桶", qty: 22, unit: "吨",
assignee: "陈刚(分装库)",
receivedAt: "2026-10-02T13:40",
doneTime: "2026-10-02T17:20", doneQty: 22,
report: "22 吨已分装,桶循环。",
sheetPhotos: [{ name: "分装单_SXFZ20260930-001.jpg", type: "image/svg+xml", url: "data:image/svg+xml;charset=utf-8," + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="640" height="400"><rect width="100%" height="100%" fill="#f4faf6"/><rect x="24" y="24" width="592" height="352" fill="none" stroke="#167c4a" stroke-width="2"/><text x="320" y="90" text-anchor="middle" font-size="22" fill="#167c4a" font-family="sans-serif">山峡分装任务单</text><text x="320" y="140" text-anchor="middle" font-size="16" fill="#1c2430" font-family="sans-serif">SXFZ20260930-001 · 已完成</text><text x="320" y="190" text-anchor="middle" font-size="14" fill="#6a7380" font-family="sans-serif">工业硫酸 98% 吨桶 22吨</text><text x="320" y="240" text-anchor="middle" font-size="14" fill="#6a7380" font-family="sans-serif">经手人 陈刚 · 2026-10-02</text></svg>') }],
lines: [{ planId: 8, planNo: "ZX20260925008", product: "工业硫酸", model: "98%", pack: "吨桶", unit: "吨", qty: 22, done: true }]
}
];
let linkingPlanId = null;
let waitBuyPlanId = null;
let waitTransPlanId = null;
let voidCertTarget = null;
let linkingCertId = null;
let linkingCertFocusIds = [];
let extraTransN = 0;
let archivingPlanId = null;
let currentTransId = null;
let attachBuyId = null;
const certFiles = { buy: null, trans: null, buyUrl: "", transUrl: "" };
const ocrBuySample = {
buyNo: "G36260499487476", buyDate: "2026-09-24",
buyer: "上饶市敏达精细化工有限公司", buyerLegal: "熊伟", buyerTel: "0793-8224115", buyerAddr: "上饶市胜利路96-3号",
seller: "安庆市山峡化工有限公司", sellerLegal: "吴春生", sellerTel: "0556-5348918", sellerAddr: "安徽省安庆市高新区霞虹路5号",
product: "硫酸", usage: "销售", buyQty: 450, unit: "吨", buyTimes: "多次有效",
buyFrom: "2026-09-24", buyDue: "2026-10-23",
buyPolice: "上饶市公安局信州分局禁毒大队", buyHandler: "潘佳", buyPoliceTel: "13755372147"
};
const ocrTransSample = {
transNo: "Y34260211715884", transDate: "2026-09-03",
owner: "铜陵锦帆物资有限责任公司", ownerLegal: "何红燕", ownerTel: "0562-2119658",
ownerAddr: "安徽省铜陵市滨江大道和铜港路交叉口滨江大道停车场",
pack: "槽罐", permitNo: "G34260528735347",
carrier: "安庆宏泰危险货物运输有限公司", carrierAddr: "安徽省安庆市宜秀区中山大道/0556-5593789",
mode: "汽车", plates: "皖HA3690、皖H57837、皖LB1283",
route: "自安徽省铜陵市铜官区经池州市至安徽省安庆市高新",
consignee: "安庆市山峡化工有限公司", transQty: 500,
transTimes: "多次有效", transFrom: "2026-09-03", transDue: "2026-12-02",
applicantUnit: "铜陵锦帆物资有限责任公司",
transPolice: "铜陵市公安局铜官分局环食药(禁毒)大队", transHandler: "桂强"
};
const ocrTransSample2 = {
transNo: "Y34260222800112", transDate: "2026-09-25",
owner: "铜陵锦帆物资有限责任公司", ownerLegal: "何红燕", ownerTel: "0562-2119658",
ownerAddr: "安徽省铜陵市滨江大道和铜港路交叉口滨江大道停车场",
pack: "槽罐", permitNo: "G36260499487476",
carrier: "安庆宏泰危险货物运输有限公司", carrierAddr: "安徽省安庆市宜秀区中山大道/0556-5593789",
mode: "汽车", plates: "皖K92881",
route: "自安徽省铜陵市经安庆至宜昌猇亭",
consignee: "安庆市山峡化工有限公司", transQty: 200,
transTimes: "多次有效", transFrom: "2026-09-25", transDue: "2026-12-24",
applicantUnit: "铜陵锦帆物资有限责任公司",
transPolice: "铜陵市公安局铜官分局环食药(禁毒)大队", transHandler: "桂强"
};
const ocrTransSamples = [ocrTransSample, ocrTransSample2];
let certs = [
{
id: 1, created: today, shipped: 40, ctrl: "易制毒",
...ocrBuySample, product: "硫酸", unit: "吨",
buyFile: "购买备案证明样例.png", buyUrl: "购买备案证明样例.png",
transList: [
{
id: 1, created: today, shipped: 40,
transNo: "Y34260211715884", transDate: "2026-09-03",
owner: "铜陵锦帆物资有限责任公司", ownerLegal: "何红燕", ownerTel: "0562-2119658",
ownerAddr: "安徽省铜陵市滨江大道和铜港路交叉口滨江大道停车场",
pack: "槽罐", permitNo: "G36260499487476",
carrier: "安庆宏泰危险货物运输有限公司", carrierAddr: "安徽省安庆市宜秀区中山大道/0556-5593789",
mode: "汽车", plates: "皖HA3690、皖H57837、皖LB1283",
route: "自安徽省铜陵市铜官区经池州市至安徽省安庆市高新",
consignee: "安庆市山峡化工有限公司", transQty: 250,
transTimes: "多次有效", transFrom: "2026-09-03", transDue: "2026-12-02",
applicantUnit: "铜陵锦帆物资有限责任公司",
transPolice: "铜陵市公安局铜官分局环食药(禁毒)大队", transHandler: "桂强",
transFile: "运输备案证明样例.png", transUrl: "运输备案证明样例.png",
dispatches: [
{ plate: "皖HA3690", driver: "周建军", qty: 90, shipped: 0 },
{ plate: "皖H57837", driver: "陈伟", qty: 80, shipped: 0 },
{ plate: "皖LB1283", driver: "胡明", qty: 80, shipped: 40 }
]
},
{
id: 2, created: today, shipped: 0,
transNo: "Y34260222800112", transDate: "2026-09-25",
owner: "铜陵锦帆物资有限责任公司", ownerLegal: "何红燕", ownerTel: "0562-2119658",
ownerAddr: "安徽省铜陵市滨江大道和铜港路交叉口滨江大道停车场",
pack: "槽罐", permitNo: "G36260499487476",
carrier: "安庆宏泰危险货物运输有限公司", carrierAddr: "安徽省安庆市宜秀区中山大道/0556-5593789",
mode: "汽车", plates: "皖K92881",
route: "自安徽省铜陵市经安庆至宜昌猇亭",
consignee: "安庆市山峡化工有限公司", transQty: 200,
transTimes: "多次有效", transFrom: "2026-09-25", transDue: "2026-12-24",
applicantUnit: "铜陵锦帆物资有限责任公司",
transPolice: "铜陵市公安局铜官分局环食药(禁毒)大队", transHandler: "桂强",
transFile: "运输证_硫酸_2.jpg", transUrl: "",
dispatches: [
{ plate: "皖K92881", driver: "吴涛", qty: 200, shipped: 0 }
],
voided: true, voidedAt: today, voidQty: 200
},
{
id: 5, created: today, shipped: 0,
transNo: "Y36260433900188", transDate: "2026-09-26",
owner: "上饶市敏达精细化工有限公司", ownerLegal: "熊伟", ownerTel: "0793-8224115",
ownerAddr: "上饶市胜利路96-3号",
pack: "槽罐", permitNo: "G36260499487476",
carrier: "安庆宏泰危险货物运输有限公司", carrierAddr: "安徽省安庆市宜秀区中山大道/0556-5593789",
mode: "汽车", plates: "皖HA3690、皖H57837、皖K92881",
route: "自安庆市高新区至上饶市信州区",
consignee: "上饶市敏达精细化工有限公司", transQty: 180,
transTimes: "多次有效", transFrom: "2026-09-26", transDue: "2026-12-25",
applicantUnit: "上饶市敏达精细化工有限公司",
transPolice: "上饶市公安局信州分局禁毒大队", transHandler: "潘佳",
transFile: "运输证_敏达硫酸.jpg", transUrl: "",
dispatches: [
{ plate: "皖HA3690", driver: "周建军", qty: 60, shipped: 0 },
{ plate: "皖H57837", driver: "陈伟", qty: 60, shipped: 0 },
{ plate: "皖K92881", driver: "吴涛", qty: 60, shipped: 0 }
]
}
]
},
{
id: 2, created: today, shipped: 16, ctrl: "易制爆",
buyNo: "G42100311880021", buyDate: "2026-09-20",
buyer: "宜昌爆破器材公司", buyerLegal: "张磊", buyerTel: "0717-8801122", buyerAddr: "宜昌市夷陵区发展大道18号",
seller: "安庆市山峡化工有限公司", sellerLegal: "吴春生", sellerTel: "0556-5348918", sellerAddr: "安徽省安庆市高新区霞虹路5号",
product: "硝酸铵", usage: "生产使用", buyQty: 80, unit: "吨", buyTimes: "多次有效",
buyFrom: "2026-09-20", buyDue: "2026-11-20",
buyPolice: "宜昌市公安局禁毒支队", buyHandler: "李敏", buyPoliceTel: "0717-1234567",
buyFile: "购买证_硝酸铵.jpg", buyUrl: "",
transList: [
{
id: 3, created: today, shipped: 16,
transNo: "Y42100322991008", transDate: "2026-09-20",
owner: "宜昌爆破器材公司", ownerLegal: "张磊", ownerTel: "0717-8801122",
ownerAddr: "宜昌市夷陵区发展大道18号",
pack: "袋装", permitNo: "G42100311880021",
carrier: "宜昌安达危运有限公司", carrierAddr: "宜昌市猇亭区/0717-6677889",
mode: "汽车", plates: "鄂E·危D077",
route: "自安庆市高新区至宜昌市夷陵区",
consignee: "宜昌爆破器材公司", transQty: 80,
transTimes: "多次有效", transFrom: "2026-09-20", transDue: "2026-11-20",
applicantUnit: "宜昌爆破器材公司",
transPolice: "宜昌市公安局禁毒支队", transHandler: "王芳",
transFile: "运输证_硝酸铵.jpg", transUrl: "",
dispatches: [
{ plate: "鄂E·危D077", driver: "吴涛", qty: 80, shipped: 16 }
]
}
]
},
{
id: 3, created: today, shipped: 0, ctrl: "易制毒",
buyNo: "G34080266110031", buyDate: "2026-09-18",
buyer: "安庆市山峡化工有限公司", buyerLegal: "吴春生", buyerTel: "0556-5348918", buyerAddr: "安徽省安庆市高新区霞虹路5号",
seller: "铜陵锦帆物资有限责任公司", sellerLegal: "何红燕", sellerTel: "0562-2119658", sellerAddr: "安徽省铜陵市滨江大道和铜港路交叉口",
product: "硫酸", usage: "生产使用", buyQty: 500, unit: "吨", buyTimes: "多次有效",
buyFrom: "2026-09-18", buyDue: "2026-12-17",
buyPolice: "安庆市公安局高新分局禁毒大队", buyHandler: "刘洋", buyPoliceTel: "0556-5201100",
buyFile: "购买证_山峡采购硫酸.jpg", buyUrl: "",
transList: [
{
id: 4, created: today, shipped: 0,
transNo: "Y34080277220045", transDate: "2026-09-18",
owner: "铜陵锦帆物资有限责任公司", ownerLegal: "何红燕", ownerTel: "0562-2119658",
ownerAddr: "安徽省铜陵市滨江大道和铜港路交叉口滨江大道停车场",
pack: "槽罐", permitNo: "G34080266110031",
carrier: "安庆宏泰危险货物运输有限公司", carrierAddr: "安徽省安庆市宜秀区中山大道/0556-5593789",
mode: "汽车", plates: "皖LB1283、皖K92881",
route: "自安徽省铜陵市经安庆至上饶",
consignee: "安庆市山峡化工有限公司", transQty: 300,
transTimes: "多次有效", transFrom: "2026-09-18", transDue: "2026-12-17",
applicantUnit: "铜陵锦帆物资有限责任公司",
transPolice: "铜陵市公安局铜官分局环食药(禁毒)大队", transHandler: "桂强",
transFile: "运输证_山峡采购硫酸.jpg", transUrl: "",
dispatches: [
{ plate: "皖LB1283", driver: "胡明", qty: 150, shipped: 0 },
{ plate: "皖K92881", driver: "吴涛", qty: 150, shipped: 0 }
]
},
{
id: 7, created: today, shipped: 0,
transNo: "Y34080277220118", transDate: "2026-09-28",
owner: "铜陵锦帆物资有限责任公司", ownerLegal: "何红燕", ownerTel: "0562-2119658",
ownerAddr: "安徽省铜陵市滨江大道和铜港路交叉口滨江大道停车场",
pack: "槽罐", permitNo: "G34080266110031",
carrier: "安庆宏泰危险货物运输有限公司", carrierAddr: "安徽省安庆市宜秀区中山大道/0556-5593789",
mode: "汽车", plates: "皖HA3690、皖H57837",
route: "自安徽省铜陵市至安庆市高新区",
consignee: "安庆市山峡化工有限公司", transQty: 160,
transTimes: "多次有效", transFrom: "2026-09-28", transDue: "2026-12-27",
applicantUnit: "铜陵锦帆物资有限责任公司",
transPolice: "铜陵市公安局铜官分局环食药(禁毒)大队", transHandler: "桂强",
transFile: "运输证_山峡采购硫酸_2.jpg", transUrl: "",
dispatches: [
{ plate: "皖HA3690", driver: "周建军", qty: 80, shipped: 0 },
{ plate: "皖H57837", driver: "陈伟", qty: 80, shipped: 0 }
]
}
]
},
{
id: 4, created: today, shipped: 0, ctrl: "易制毒",
buyNo: "G36260499510088", buyDate: "2026-09-28",
buyer: "上饶市敏达精细化工有限公司", buyerLegal: "熊伟", buyerTel: "0793-8224115", buyerAddr: "上饶市胜利路96-3号",
seller: "安庆市山峡化工有限公司", sellerLegal: "吴春生", sellerTel: "0556-5348918", sellerAddr: "安徽省安庆市高新区霞虹路5号",
product: "硫酸", usage: "生产使用", buyQty: 220, unit: "吨", buyTimes: "多次有效",
buyFrom: "2026-09-28", buyDue: "2026-12-27",
buyPolice: "上饶市公安局信州分局禁毒大队", buyHandler: "潘佳", buyPoliceTel: "13755372147",
buyFile: "购买证_敏达硫酸_2.jpg", buyUrl: "",
transList: [
{
id: 6, created: today, shipped: 0,
transNo: "Y36260455110076", transDate: "2026-09-28",
owner: "上饶市敏达精细化工有限公司", ownerLegal: "熊伟", ownerTel: "0793-8224115",
ownerAddr: "上饶市胜利路96-3号",
pack: "槽罐", permitNo: "G36260499510088",
carrier: "安庆宏泰危险货物运输有限公司", carrierAddr: "安徽省安庆市宜秀区中山大道/0556-5593789",
mode: "汽车", plates: "皖HA3690、皖H57837、皖LB1283、皖K92881",
route: "自安庆市高新区至上饶市信州区",
consignee: "上饶市敏达精细化工有限公司", transQty: 220,
transTimes: "多次有效", transFrom: "2026-09-28", transDue: "2026-12-27",
applicantUnit: "上饶市敏达精细化工有限公司",
transPolice: "上饶市公安局信州分局禁毒大队", transHandler: "潘佳",
transFile: "运输证_敏达硫酸_2.jpg", transUrl: "",
dispatches: [
{ plate: "皖HA3690", driver: "周建军", qty: 55, shipped: 0 },
{ plate: "皖H57837", driver: "陈伟", qty: 55, shipped: 0 },
{ plate: "皖LB1283", driver: "胡明", qty: 55, shipped: 0 },
{ plate: "皖K92881", driver: "吴涛", qty: 55, shipped: 0 }
]
}
]
}
];
let plans = [
{ id: 16, no: "PO202610110001", status: "已安排", date: "2026-10-01", deliverDate: "2026-10-01", type: "出库单", docKind: "开单类", salesman: "万金强", dept: "销售一部", shipMode: "槽车运输", loadFrom: "安庆市山峡化工有限公司", settler: "上饶市敏达精细化工有限公司", customer: "上饶市敏达精细化工有限公司", ctrl: "易制毒", product: "40%工业硫酸", spec: "40%", pack: "槽罐车", unit: "吨", planQty: 32, realQty: 0, tractor: "皖K92881", driver: "吴涛", certId: null, transId: null, warehouseId: "h2so4-custom", source: "手工录入", sourceNo: "PO202610110001" },
{ id: 1, no: "PO202508050191", status: "已安排", date: "2026-10-01", deliverDate: "2026-10-01", type: "出库单", docKind: "选单类", salesman: "杨磊", dept: "运输调度组", shipMode: "槽车运输", loadFrom: "安庆市山峡化工有限公司", settler: "上饶市敏达精细化工有限公司", customer: "上饶市敏达精细化工有限公司", ctrl: "易制毒", product: "硫酸", spec: "98%", pack: "槽罐车", unit: "吨", planQty: 32, realQty: 0, tractor: "皖HA3690", driver: "周建军", certId: null, transId: null, warehouseId: null, source: "ERP", sourceNo: "JT202508050191" },
{ id: 2, no: "PO202508050190", status: "已安排", date: "2026-10-03", deliverDate: "2026-10-03", type: "出库单", docKind: "选单类", salesman: "杨磊 / 杨帆", dept: "北京 / 上海", shipMode: "槽车运输", loadFrom: "安庆市山峡化工有限公司", settler: "安徽东泰化学试剂有限公司", customer: "安徽东泰化学试剂有限公司", ctrl: "易制毒", product: "槽车运输", spec: "CP", pack: "槽罐车", unit: "吨", planQty: 32, realQty: 0, tractor: "皖HA3690", driver: "周建军", certId: 1, transId: 1, warehouseId: null, source: "ERP", sourceNo: "JT202508050190" },
{ id: 3, no: "ZX202609280354", status: "已完成", date: "2026-10-02", deliverDate: "2026-10-03", type: "直销单", docKind: "开单类", salesman: "BLJT8482 / BLJT9", dept: "南京 / 上海", shipMode: "车辆运输", loadFrom: "安庆市山峡化工有限责任公司", settler: "南京市秦淮区化工危险品有限公司", customer: "南京市秦淮区化工危险品有限公司", ctrl: "", product: "30%以上烧碱", spec: "30%", pack: "槽罐车", unit: "吨", planQty: 33, realQty: 33, tractor: "皖LB1283", driver: "胡明", certId: null, transId: null, warehouseId: null, archived: false, source: "直销", sourceNo: "ZX202609280354" },
{ id: 4, no: "PO202508050117", status: "已完成", date: "2026-10-02", deliverDate: "2026-10-02", type: "出库单", docKind: "选单类", salesman: "杨A3748", dept: "销售一部", shipMode: "槽车运输", loadFrom: "安庆市山峡化工有限公司", settler: "安庆市山峡化工有限公司", customer: "安庆市山峡化工有限公司", ctrl: "", product: "30%以上烧碱", spec: "32%", pack: "槽罐车", unit: "吨", planQty: 6, realQty: 6, tractor: "皖H57837", driver: "陈伟", certId: null, transId: null, warehouseId: "naoh", archived: false, source: "ERP", sourceNo: "JT202508050117" },
{ id: 5, no: "PO202508050116", status: "已完成", date: "2026-10-02", deliverDate: "2026-10-02", type: "出库单", docKind: "选单类", salesman: "杨A3748", dept: "销售一部", shipMode: "槽车运输", loadFrom: "安庆市山峡化工有限公司", settler: "南京市秦淮区化工危险品有限公司", customer: "南京市秦淮区化工危险品有限公司", ctrl: "", product: "30%-烧碱", spec: "32%", pack: "槽罐车", unit: "吨", planQty: 8, realQty: 8, tractor: "皖K92881", driver: "吴涛", certId: null, transId: null, warehouseId: "naoh", archived: false, source: "ERP", sourceNo: "JT202508050116" },
{ id: 6, no: "PO202508050109", status: "已完成", date: "2026-10-03", deliverDate: "2026-10-03", type: "出库单", docKind: "选单类", salesman: "杨A3742", dept: "销售一部", shipMode: "槽车运输", loadFrom: "武汉市鑫源化工有限公司", settler: "安徽鑫源新材料有限公司", customer: "安徽鑫源新材料有限公司", ctrl: "", product: "32%-烧碱", spec: "32%", pack: "槽罐车", unit: "吨", planQty: 15, realQty: 15, tractor: "鄂E·危D077", driver: "吴涛", certId: null, transId: null, warehouseId: "naoh", archived: false, source: "ERP", sourceNo: "JT202508050109" },
{ id: 7, no: "CK20260928001", status: "已安排", date: "2026-09-30", deliverDate: "2026-10-03", type: "出库单", docKind: "选单类", salesman: "周建军", dept: "运输调度组", shipMode: "槽车运输", loadFrom: "安庆市山峡化工有限公司", settler: "上饶市敏达精细化工有限公司", customer: "上饶市敏达精细化工有限公司", ctrl: "易制毒", product: "硫酸", spec: "98%", pack: "槽罐车", unit: "吨", planQty: 30, realQty: 0, tractor: "皖HA3690", driver: "周建军", certId: 1, transId: 1, warehouseId: null, source: "出库同步", sourceNo: "PO20260928001", taskNo: "SXFZ20261001-001" },
{ id: 8, no: "ZX20260925008", status: "待安排", date: "2026-10-02", deliverDate: "2026-10-02", type: "直销单", docKind: "开单类", salesman: "吴涛", dept: "运输调度组", shipMode: "车辆运输", loadFrom: "山峡猇亭库区", settler: "宜昌爆破器材公司", customer: "宜昌爆破器材公司", ctrl: "易制爆", product: "硝酸铵", spec: "工业级", pack: "袋装", unit: "吨", planQty: 12, realQty: null, tractor: "", driver: "", certId: null, transId: null, warehouseId: null, waitNotify: true, source: "直销同步", sourceNo: "PO20260925008" },
{ id: 9, no: "RZ20260921012", status: "已完成", date: "2026-10-01", deliverDate: "2026-10-02", type: "入直单", docKind: "选单类", salesman: "胡明", dept: "仓储组", shipMode: "槽车运输", loadFrom: "荆州长江溶剂厂", settler: "安庆市山峡化工有限公司", customer: "荆州长江溶剂厂", ctrl: "", product: "工业盐酸", spec: "31%", pack: "槽罐车", unit: "吨", planQty: 28, realQty: 28, tractor: "鄂E·危C902", driver: "胡明", certId: null, transId: null, warehouseId: null, archived: false, source: "入直同步", sourceNo: "PO20260921012", taskNo: "SXFZ20261001-002" },
{ id: 13, no: "RZ20261001006", status: "已完成", date: "2026-10-01", deliverDate: "2026-10-02", type: "入直单", docKind: "选单类", salesman: "胡明", dept: "仓储组", shipMode: "槽车运输", loadFrom: "铜陵锦帆物资有限责任公司", settler: "安庆市山峡化工有限公司", customer: "安庆市山峡化工有限公司", ctrl: "易制毒", product: "30%硫酸", spec: "30%", pack: "槽罐车", unit: "吨", planQty: 40, realQty: 40, tractor: "皖LB1283", driver: "胡明", certId: 3, shipCertId: 3, transId: 4, warehouseId: null, archived: false, certDeducted: true, deductedQty: 40, source: "入直同步", sourceNo: "PO20261001006" },
{ id: 10, no: "PO202508050083", status: "已作废", date: "2026-10-02", deliverDate: "2026-10-03", type: "出库单", docKind: "选单类", salesman: "杨磊", dept: "销售一部", shipMode: "槽车运输", loadFrom: "安庆市山峡化工有限公司", settler: "安徽鑫源新材料有限公司", customer: "安徽鑫源新材料有限公司", ctrl: "", product: "30%-工业硫酸", spec: "30%", pack: "槽罐车", unit: "吨", planQty: 50, realQty: null, tractor: "", driver: "", certId: null, transId: null, warehouseId: null, source: "ERP", sourceNo: "JT202508050083" },
{ id: 11, no: "PO202508050084", status: "已安排", date: "2026-10-02", deliverDate: "2026-10-03", type: "出库单", docKind: "选单类", salesman: "BLJT / masakazunak", dept: "销售一部", shipMode: "槽车运输", loadFrom: "南京市化工工程有限公司", settler: "安徽鑫源化工有限公司", customer: "安徽鑫源化工有限公司", ctrl: "", product: "32%-烧碱", spec: "32%", pack: "槽罐车", unit: "吨", planQty: 32.6, realQty: 0, tractor: "皖K92881", driver: "吴涛", certId: null, transId: null, warehouseId: null, source: "ERP", sourceNo: "JT202508050084" },
{ id: 12, no: "PO202508050082", status: "已完成", date: "2026-10-02", deliverDate: "2026-10-03", type: "出库单", docKind: "选单类", salesman: "BLJT / masakazunak", dept: "销售一部", shipMode: "槽车运输", loadFrom: "南京市化工工程有限公司", settler: "安徽鑫源化工有限公司", customer: "安徽鑫源化工有限公司", ctrl: "", product: "32%-工业硫酸", spec: "32%", pack: "槽罐车", unit: "吨", planQty: 33, realQty: 33, tractor: "皖HA3690", driver: "周建军", certId: null, transId: null, warehouseId: "h2so4-50", archived: true, archiveDate: "2026-10-02", source: "ERP", sourceNo: "JT202508050082" },
{ id: 14, no: "PO202610040001", status: "草稿", date: "2026-10-04", deliverDate: "2026-10-05", type: "出库单", docKind: "选单类", salesman: "杨磊", dept: "运输调度组", shipMode: "槽车运输", loadFrom: "安庆市山峡化工有限公司", settler: "安徽东泰化学试剂有限公司", customer: "安徽东泰化学试剂有限公司", ctrl: "", product: "32%液碱", spec: "32%", pack: "槽罐车", unit: "吨", planQty: 28, realQty: null, tractor: "", driver: "", certId: null, transId: null, warehouseId: null, source: "手工录入", sourceNo: "PO202610040001" },
{ id: 15, no: "FZ20260930001", status: "已安排", date: "2026-09-30", deliverDate: "2026-10-07", type: "出库单", docKind: "开单类", salesman: "调度员", dept: "仓储组", shipMode: "车辆运输", loadFrom: "安庆市山峡化工有限公司", settler: "江峰", customer: "江峰", ctrl: "", product: "工业硫酸", spec: "98%", pack: "吨桶", unit: "吨", planQty: 22, realQty: 0, tractor: "", driver: "", certId: null, transId: null, warehouseId: null, source: "手工录入", sourceNo: "FZ20260930001" },
{ id: 17, no: "PO202609200016", status: "已完成", date: "2026-09-22", deliverDate: "2026-09-22", type: "出库单", docKind: "选单类", salesman: "吴涛", dept: "运输调度组", shipMode: "车辆运输", loadFrom: "安庆市山峡化工有限公司", settler: "宜昌爆破器材公司", customer: "宜昌爆破器材公司", ctrl: "易制爆", product: "硝酸铵", spec: "工业级", pack: "袋装", unit: "吨", planQty: 16, realQty: 16, tractor: "鄂E·危D077", driver: "吴涛", certId: 2, transId: 3, warehouseId: null, archived: true, archiveDate: "2026-09-22", certDeducted: true, deductedQty: 16, source: "出库同步", sourceNo: "PO202609200016" },
{ id: 18, no: "PO202610080018", status: "已安排", date: "2026-10-08", deliverDate: "2026-10-09", type: "出库单", docKind: "选单类", salesman: "吴涛", dept: "运输调度组", shipMode: "车辆运输", loadFrom: "安庆市山峡化工有限公司", settler: "宜昌爆破器材公司", customer: "宜昌爆破器材公司", ctrl: "易制爆", product: "硝酸铵", spec: "工业级", pack: "袋装", unit: "吨", planQty: 32, realQty: 0, tractor: "鄂E·危D077", driver: "吴涛", certId: 2, transId: 3, warehouseId: null, source: "出库同步", sourceNo: "PO202610080018" },
{ id: 19, no: "PO202610010019", status: "待安排", date: "2026-10-01", deliverDate: "2026-10-01", type: "出库单", docKind: "开单类", salesman: "万金强", dept: "销售一部", shipMode: "槽车运输", loadFrom: "安庆市山峡化工有限公司", settler: "中新联科", customer: "中新联科", ctrl: "易制毒", product: "98%工业硫酸", spec: "98%", pack: "槽罐车", unit: "吨", planQty: 28, realQty: 0, tractor: "", driver: "", certId: null, transId: null, warehouseId: "h2so4-98", source: "ERP", sourceNo: "PO202610010019" },
{ id: 20, no: "RZ202610010020", status: "已安排", date: "2026-10-01", deliverDate: "2026-10-01", type: "入直单", docKind: "选单类", salesman: "胡明", dept: "仓储组", shipMode: "槽车运输", loadFrom: "铜陵锦帆物资有限责任公司", settler: "安庆市山峡化工有限公司", customer: "安庆市山峡化工有限公司", ctrl: "易制毒", product: "硫酸", spec: "98%", pack: "槽罐车", unit: "吨", planQty: 36, realQty: 0, tractor: "皖LB1283", driver: "胡明", certId: null, transId: null, warehouseId: "h2so4-98", source: "入直同步", sourceNo: "RZ202610010020" },
{ id: 21, no: "ZX202610010021", status: "已安排", date: "2026-10-01", deliverDate: "2026-10-01", type: "直销单", docKind: "开单类", salesman: "吴涛", dept: "运输调度组", shipMode: "槽车运输", loadFrom: "铜陵锦帆物资有限责任公司", settler: "上饶市敏达精细化工有限公司", customer: "上饶市敏达精细化工有限公司", ctrl: "易制毒", product: "硫酸", spec: "98%", pack: "槽罐车", unit: "吨", planQty: 32, realQty: null, tractor: "皖K92881", driver: "吴涛", certId: null, transId: null, warehouseId: null, source: "直销同步", sourceNo: "ZX202610010021" },
{ id: 22, no: "PO202610010022", status: "已安排", date: "2026-10-01", deliverDate: "2026-10-01", type: "出库单", docKind: "选单类", salesman: "杨磊", dept: "运输调度组", shipMode: "槽车运输", loadFrom: "安庆市山峡化工有限公司", settler: "上饶市敏达精细化工有限公司", customer: "上饶市敏达精细化工有限公司", ctrl: "易制毒", product: "硫酸", spec: "98%", pack: "槽罐车", unit: "吨", planQty: 28, realQty: 0, tractor: "皖H57837", driver: "陈伟", certId: null, transId: null, warehouseId: "h2so4-98", source: "ERP", sourceNo: "JT202610010022" },
{ id: 23, no: "PO202610010023", status: "已安排", date: "2026-10-01", deliverDate: "2026-10-01", type: "出库单", docKind: "选单类", salesman: "周建军", dept: "运输调度组", shipMode: "槽车运输", loadFrom: "安庆市山峡化工有限公司", settler: "上饶市敏达精细化工有限公司", customer: "上饶市敏达精细化工有限公司", ctrl: "易制毒", product: "硫酸", spec: "98%", pack: "槽罐车", unit: "吨", planQty: 30, realQty: 0, tractor: "皖HA3690", driver: "周建军", certId: 1, shipCertId: 1, transId: null, warehouseId: "h2so4-98", source: "出库同步", sourceNo: "PO202610010023" },
{ id: 24, no: "RZ202610010024", status: "已安排", date: "2026-10-01", deliverDate: "2026-10-01", type: "入直单", docKind: "选单类", salesman: "胡明", dept: "仓储组", shipMode: "槽车运输", loadFrom: "铜陵锦帆物资有限责任公司", settler: "安庆市山峡化工有限公司", customer: "安庆市山峡化工有限公司", ctrl: "易制毒", product: "硫酸", spec: "98%", pack: "槽罐车", unit: "吨", planQty: 30, realQty: 0, tractor: "皖K92881", driver: "吴涛", certId: null, transId: null, warehouseId: "h2so4-98", source: "入直同步", sourceNo: "RZ202610010024" },
{ id: 25, no: "ZX202610010025", status: "已安排", date: "2026-10-01", deliverDate: "2026-10-01", type: "直销单", docKind: "开单类", salesman: "吴涛", dept: "运输调度组", shipMode: "槽车运输", loadFrom: "铜陵锦帆物资有限责任公司", settler: "上饶市敏达精细化工有限公司", customer: "上饶市敏达精细化工有限公司", ctrl: "易制毒", product: "硫酸", spec: "98%", pack: "槽罐车", unit: "吨", planQty: 32, realQty: null, tractor: "皖LB1283", driver: "胡明", certId: null, transId: null, warehouseId: null, source: "直销同步", sourceNo: "ZX202610010025" },
{ id: 26, no: "RZ202610010026", status: "已安排", date: "2026-10-01", deliverDate: "2026-10-01", type: "入直单", docKind: "选单类", salesman: "胡明", dept: "仓储组", shipMode: "槽车运输", loadFrom: "铜陵锦帆物资有限责任公司", settler: "安庆市山峡化工有限公司", customer: "安庆市山峡化工有限公司", ctrl: "易制毒", product: "硫酸", spec: "98%", pack: "槽罐车", unit: "吨", planQty: 32, realQty: 0, tractor: "皖LB1283", driver: "胡明", certId: 3, shipCertId: 3, transId: null, warehouseId: "h2so4-98", source: "入直同步", sourceNo: "RZ202610010026" },
{ id: 27, no: "ZX202610010027", status: "已安排", date: "2026-10-01", deliverDate: "2026-10-01", type: "直销单", docKind: "开单类", salesman: "万金强", dept: "销售一部", shipMode: "槽车运输", loadFrom: "铜陵锦帆物资有限责任公司", settler: "上饶市敏达精细化工有限公司", customer: "上饶市敏达精细化工有限公司", ctrl: "易制毒", product: "硫酸", spec: "98%", pack: "槽罐车", unit: "吨", planQty: 30, realQty: null, tractor: "皖HA3690", driver: "周建军", certId: null, transId: null, warehouseId: null, source: "直销同步", sourceNo: "ZX202610010027" },
{ id: 28, no: "ZX202610010028", status: "已安排", date: "2026-10-01", deliverDate: "2026-10-01", type: "直销单", docKind: "开单类", salesman: "杨磊", dept: "运输调度组", shipMode: "槽车运输", loadFrom: "铜陵锦帆物资有限责任公司", settler: "上饶市敏达精细化工有限公司", customer: "上饶市敏达精细化工有限公司", ctrl: "易制毒", product: "硫酸", spec: "98%", pack: "槽罐车", unit: "吨", planQty: 28, realQty: null, tractor: "皖H57837", driver: "陈伟", certId: 3, procureCertId: 3, shipCertId: 1, transId: null, warehouseId: null, source: "直销同步", sourceNo: "ZX202610010028" },
{ id: 29, no: "ZX202610010029", status: "已安排", date: "2026-10-01", deliverDate: "2026-10-01", type: "直销单", docKind: "开单类", salesman: "吴涛", dept: "运输调度组", shipMode: "槽车运输", loadFrom: "铜陵锦帆物资有限责任公司", settler: "上饶市敏达精细化工有限公司", customer: "上饶市敏达精细化工有限公司", ctrl: "易制毒", product: "硫酸", spec: "98%", pack: "槽罐车", unit: "吨", planQty: 32, realQty: null, tractor: "皖K92881", driver: "吴涛", certId: 1, procureCertId: 3, shipCertId: 1, transId: 5, warehouseId: null, source: "直销同步", sourceNo: "ZX202610010029" },
{ id: 30, no: "ZX202610010030", status: "已安排", date: "2026-10-01", deliverDate: "2026-10-01", type: "直销单", docKind: "开单类", salesman: "万金强", dept: "销售一部", shipMode: "槽车运输", loadFrom: "安庆市山峡化工有限公司", settler: "上饶市敏达精细化工有限公司", customer: "上饶市敏达精细化工有限公司", ctrl: "易制毒", product: "硫酸", spec: "98%", pack: "槽罐车", unit: "吨", planQty: 26, realQty: null, tractor: "皖HA3690", driver: "周建军", certId: null, transId: null, warehouseId: null, source: "直销同步", sourceNo: "ZX202610010030" },
{ id: 31, no: "RZ202610010031", status: "已安排", date: "2026-10-01", deliverDate: "2026-10-01", type: "入直单", docKind: "选单类", salesman: "胡明", dept: "仓储组", shipMode: "槽车运输", loadFrom: "铜陵锦帆物资有限责任公司", settler: "安庆市山峡化工有限公司", customer: "安庆市山峡化工有限公司", ctrl: "易制毒", product: "硫酸", spec: "98%", pack: "槽罐车", unit: "吨", planQty: 28, realQty: 0, tractor: "皖K92881", driver: "吴涛", certId: null, transId: null, warehouseId: "h2so4-98", source: "入直同步", sourceNo: "RZ202610010031" },
{ id: 32, no: "PO202610010032", status: "已安排", date: "2026-10-01", deliverDate: "2026-10-01", type: "出库单", docKind: "选单类", salesman: "杨磊", dept: "运输调度组", shipMode: "槽车运输", loadFrom: "安庆市山峡化工有限公司", settler: "上饶市敏达精细化工有限公司", customer: "上饶市敏达精细化工有限公司", ctrl: "易制毒", product: "硫酸", spec: "98%", pack: "槽罐车", unit: "吨", planQty: 30, realQty: 0, tractor: "皖H57837", driver: "陈伟", certId: null, transId: null, warehouseId: "h2so4-98", source: "ERP", sourceNo: "JT202610010032" },
{ id: 33, no: "ZX202610010033", status: "已安排", date: "2026-10-01", deliverDate: "2026-10-01", type: "直销单", docKind: "开单类", salesman: "万金强", dept: "销售一部", shipMode: "槽车运输", loadFrom: "铜陵锦帆物资有限责任公司", settler: "上饶市敏达精细化工有限公司", customer: "上饶市敏达精细化工有限公司", ctrl: "易制毒", product: "硫酸", spec: "98%", pack: "槽罐车", unit: "吨", planQty: 29, realQty: null, tractor: "皖LB1283", driver: "胡明", certId: null, transId: null, warehouseId: null, source: "直销同步", sourceNo: "ZX202610010033" },
{ id: 34, no: "RZ202610010034", status: "已安排", date: "2026-10-01", deliverDate: "2026-10-01", type: "入直单", docKind: "选单类", salesman: "胡明", dept: "仓储组", shipMode: "槽车运输", loadFrom: "铜陵锦帆物资有限责任公司", settler: "安庆市山峡化工有限公司", customer: "安庆市山峡化工有限公司", ctrl: "易制毒", product: "硫酸", spec: "98%", pack: "槽罐车", unit: "吨", planQty: 32, realQty: 0, tractor: "皖HA3690", driver: "周建军", certId: null, transId: null, warehouseId: "h2so4-98", source: "入直同步", sourceNo: "RZ202610010034" },
{ id: 35, no: "PO202610010035", status: "已安排", date: "2026-10-01", deliverDate: "2026-10-01", type: "出库单", docKind: "选单类", salesman: "杨磊", dept: "运输调度组", shipMode: "槽车运输", loadFrom: "安庆市山峡化工有限公司", settler: "上饶市敏达精细化工有限公司", customer: "上饶市敏达精细化工有限公司", ctrl: "易制毒", product: "硫酸", spec: "98%", pack: "槽罐车", unit: "吨", planQty: 32, realQty: 0, tractor: "皖LB1283", driver: "胡明", certId: null, transId: null, warehouseId: "h2so4-98", source: "ERP", sourceNo: "JT202610010035" },
{ id: 36, no: "ZX202610010036", status: "已安排", date: "2026-10-01", deliverDate: "2026-10-01", type: "直销单", docKind: "开单类", salesman: "万金强", dept: "销售一部", shipMode: "槽车运输", loadFrom: "铜陵锦帆物资有限责任公司", settler: "上饶市敏达精细化工有限公司", customer: "上饶市敏达精细化工有限公司", ctrl: "易制毒", product: "硫酸", spec: "98%", pack: "槽罐车", unit: "吨", planQty: 30, realQty: null, tractor: "皖H57837", driver: "陈伟", certId: null, transId: null, warehouseId: null, source: "直销同步", sourceNo: "ZX202610010036" }
];
const planExtras = {
1: { deliverTime: "08:00", escort: "", contractNo: "SX/X-JT202508050191" },
2: { deliverTime: "08:30", escort: "李强", contractNo: "SX/X-JT202508050190" },
3: { deliverTime: "09:00", escort: "王磊", contractNo: "ZX-QH202609280354" },
4: { deliverTime: "10:00", escort: "赵鹏", contractNo: "SX/X-JT202508050117" },
5: { deliverTime: "13:00", escort: "孙磊", contractNo: "SX/X-JT202508050116" },
6: { deliverTime: "14:00", escort: "孙磊", contractNo: "SX/X-XY202508050109" },
7: { deliverTime: "07:30", escort: "李强", contractNo: "SX/X-JT20261003001" },
8: { deliverTime: "16:00", escort: "", contractNo: "SX/X-JT20261004001" },
9: { deliverTime: "09:30", escort: "王磊", contractNo: "SG-GX2026-01-0031" },
13: { deliverTime: "11:00", escort: "王磊", contractNo: "CG10120260930002" },
10: { deliverTime: "15:00", escort: "", contractNo: "SX/X-XY202508050083" },
11: { deliverTime: "08:20", escort: "孙磊", contractNo: "SX/X-XY202508050084" },
12: { deliverTime: "06:40", escort: "李强", contractNo: "SX/X-XY202508050082" },
14: { deliverTime: "08:00", escort: "", contractNo: "" },
15: { deliverTime: "10:00", escort: "", contractNo: "" },
16: { deliverTime: "08:00", escort: "", contractNo: "" },
17: { deliverTime: "09:00", escort: "孙磊", contractNo: "SX/X-YC202609200016" },
18: { deliverTime: "10:00", escort: "孙磊", contractNo: "SX/X-YC202610080018" },
19: { deliverTime: "11:00", escort: "", contractNo: "" },
20: { deliverTime: "09:30", escort: "王磊", contractNo: "CG10120261001020" },
21: { deliverTime: "13:00", escort: "李强", contractNo: "ZX-MD20261001021" },
22: { deliverTime: "14:00", escort: "赵鹏", contractNo: "SX/X-JT202610010022" },
23: { deliverTime: "08:40", escort: "李强", contractNo: "SX/X-JT202610010023" },
24: { deliverTime: "10:20", escort: "王磊", contractNo: "CG10120261001024" },
25: { deliverTime: "15:00", escort: "李强", contractNo: "ZX-MD20261001025" },
26: { deliverTime: "11:30", escort: "王磊", contractNo: "CG10120261001026" },
27: { deliverTime: "09:00", escort: "李强", contractNo: "ZX-MD20261001027" },
28: { deliverTime: "10:40", escort: "赵鹏", contractNo: "ZX-MD20261001028" },
29: { deliverTime: "14:20", escort: "李强", contractNo: "ZX-MD20261001029" },
30: { deliverTime: "16:00", escort: "王磊", contractNo: "ZX-MD20261001030" },
31: { deliverTime: "08:50", escort: "王磊", contractNo: "CG10120261001031" },
32: { deliverTime: "11:10", escort: "赵鹏", contractNo: "SX/X-JT202610010032" },
33: { deliverTime: "15:40", escort: "李强", contractNo: "ZX-MD20261001033" },
34: { deliverTime: "07:40", escort: "王磊", contractNo: "CG10120261001034" },
35: { deliverTime: "09:20", escort: "李强", contractNo: "SX/X-JT202610010035" },
36: { deliverTime: "12:10", escort: "赵鹏", contractNo: "ZX-MD20261001036" }
};
plans.forEach((p) => Object.assign(p, planExtras[p.id] || {}));
const carrierFleet = [
{ company: "安庆宏泰危险货物运输有限公司", plate: "皖HA3690", driver: "周建军", cap: 35 },
{ company: "安庆宏泰危险货物运输有限公司", plate: "皖H57837", driver: "陈伟", cap: 35 },
{ company: "安庆宏泰危险货物运输有限公司", plate: "皖LB1283", driver: "胡明", cap: 32 },
{ company: "安庆宏泰危险货物运输有限公司", plate: "皖K92881", driver: "吴涛", cap: 32 },
{ company: "宜昌安达危运有限公司", plate: "鄂E·危A123", driver: "周建军", cap: 32 },
{ company: "宜昌安达危运有限公司", plate: "鄂E·危B618", driver: "陈伟", cap: 32 },
{ company: "宜昌安达危运有限公司", plate: "鄂E·危D077", driver: "吴涛", cap: 32 },
{ company: "宜昌安达危运有限公司", plate: "鄂E·危C902", driver: "胡明", cap: 30 },
{ company: "山峡自有车队", plate: "鄂E·危E441", driver: "郑浩", cap: 28 }
];
const WARN_RATE = 0.1;
function defaultWarnQty(total) {
const n = Number(total);
if (!Number.isFinite(n) || n <= 0) return 0;
return Math.round(n * WARN_RATE * 100) / 100;
}
certs.forEach((c) => {
if (c.warnQty == null || c.warnQty === "") c.warnQty = defaultWarnQty(c.buyQty);
(c.transList || []).forEach((t) => {
if (t.warnQty == null || t.warnQty === "") t.warnQty = defaultWarnQty(t.transQty);
});
});
const labels = { dash: "总览", fleet: "车辆台账", fuel: "加油登记管理", fuelwarn: "油耗预警", etc: "ETC充值卡管理", oilcard: "加油充值卡管理", maint: "车辆维保管理", stats: "统计" };
const fleetMenuView = { dash: "fuel", fleet: "fuel", fuel: "fuel", fuelwarn: "fuel", etc: "etc", oilcard: "oilcard", maint: "maint", stats: "fuel" };
let currentFleetView = "etc";
const FUEL_NORM = 33 / 100;
const money = (n) => Number(n).toLocaleString("zh-CN", { maximumFractionDigits: 0 });
const stClass = (s) => s === "运行" ? "st-run" : s === "维保中" ? "st-fix" : "st-stop";
const applicants = ["吴九华", ...vehicles.map((v) => v.driver)].filter((n, i, a) => a.indexOf(n) === i);
function fillPlates() {
const fuelPlate = document.querySelector("#fuelForm [name=plate]");
if (fuelPlate) fuelPlate.innerHTML = vehicles.map((v) => `<option value="${v.plate}">${hazPlate(v)}</option>`).join("");
const plateOpts = `<option value="">请选择车牌</option>` + vehicles.map((v) => `<option value="${v.plate}">${hazPlate(v)}</option>`).join("");
const maintPlate = document.querySelector("#maintForm [name=plate]");
if (maintPlate) maintPlate.innerHTML = plateOpts;
const etcPlate = document.querySelector("#etcForm [name=plate]");
etcPlate.innerHTML = `<option value="">请选择自有车牌</option>` + vehicles.map((v) => `<option value="${v.plate}">${hazPlate(v)}</option>`).join("");
const appOpts = applicants.map((n) => `<option value="${n}">${n}(${n})</option>`).join("");
const etcApp = document.querySelector("#etcForm [name=applicant]");
const oilApp = document.querySelector("#oilCardForm [name=applicant]");
const maintApp = document.querySelector("#maintForm [name=applicant]");
if (etcApp) etcApp.innerHTML = appOpts;
if (oilApp) oilApp.innerHTML = appOpts;
if (maintApp) maintApp.innerHTML = appOpts;
const oilPlate = document.querySelector("#oilCardForm [name=plate]");
if (oilPlate) oilPlate.innerHTML = `<option value="">请选择自有车牌</option>` + vehicles.map((v) => `<option value="${v.plate}">${hazPlate(v)}</option>`).join("");
document.querySelector("#fuelForm [name=date]").value = today;
document.querySelector("#etcForm [name=date]").value = today;
const oilDate = document.querySelector("#oilCardForm [name=date]");
if (oilDate) oilDate.value = today;
const applyDate = document.querySelector("#maintForm [name=applyDate]");
const inDate = document.querySelector("#maintForm [name=inDate]");
if (applyDate) applyDate.value = today;
if (inDate) inDate.value = today;
}
function daysTo(d) {
return Math.ceil((new Date(d) - new Date(today)) / 86400000);
}
function showView(name) {
currentFleetView = name;
["dash", "fleet", "fuel", "fuelwarn", "etc", "oilcard", "maint", "stats"].forEach((v) => {
document.getElementById("view-" + v).classList.toggle("hidden", v !== name);
});
document.querySelectorAll("#subnav button").forEach((b) => b.classList.toggle("on", b.dataset.view === name));
setCrumb("车辆管理", labels[name]);
markNav(`[data-fleet-view="${fleetMenuView[name] || "etc"}"]`);
if (name === "dash") renderDash();
if (name === "fleet") renderFleet();
if (name === "fuel") renderFuel();
if (name === "fuelwarn") renderFuelWarn();
if (name === "etc") renderEtc();
if (name === "oilcard") renderOilCard();
if (name === "maint") renderMaint();
if (name === "stats") renderStats();
}
function toast(msg) {
const el = document.getElementById("toast");
el.textContent = msg;
el.classList.add("show");
setTimeout(() => el.classList.remove("show"), 1800);
}
function fuelSegments() {
const byPlate = {};
fuels.forEach((f) => {
(byPlate[f.plate] ||= []).push(f);
});
const rows = [];
vehicles.forEach((v) => {
const list = (byPlate[v.plate] || []).slice().sort((a, b) => a.km - b.km || a.date.localeCompare(b.date));
if (list.length < 2) {
rows.push({
plate: v.plate,
driver: v.driver,
fromDate: list[0]?.date || "—",
toDate: list[0]?.date || "—",
fromKm: list[0]?.km || v.km,
toKm: list[0]?.km || v.km,
km: 0,
liters: list[0]?.liters || 0,
rate: null,
per100: null,
status: list.length ? "不足" : "无记录"
});
return;
}
for (let i = 1; i < list.length; i++) {
const prev = list[i - 1];
const cur = list[i];
const km = cur.km - prev.km;
const liters = Number(cur.liters);
const rate = km > 0 ? liters / km : null;
let status = "正常";
if (km <= 0) status = "异常";
else if (rate > FUEL_NORM) status = "预警";
rows.push({
plate: v.plate,
driver: v.driver,
fromDate: prev.date,
toDate: cur.date,
fromKm: prev.km,
toKm: cur.km,
km,
liters,
rate,
per100: rate == null ? null : rate * 100,
status
});
}
});
const order = { 预警: 0, 异常: 1, 不足: 2, 无记录: 3, 正常: 4 };
return rows.sort((a, b) => order[a.status] - order[b.status] || a.plate.localeCompare(b.plate));
}
function fuelWarnCount() {
return fuelSegments().filter((r) => r.status === "预警").length;
}
function renderDash() {
const due = vehicles.filter((v) => daysTo(v.tankDue) <= 20 || daysTo(v.insDue) <= 20);
const fixing = vehicles.filter((v) => v.status === "维保中").length;
const warnN = fuelWarnCount();
document.getElementById("dashAlerts").innerHTML = `
<div class="alert red" style="cursor:pointer" onclick="showView('fuelwarn')">油耗超标预警 ${warnN} 条(标准 0.33 L/km)</div>
<div class="alert red">罐检 / 保险 20 日内到期 ${due.length} 辆</div>
<div class="alert">维保中 ${fixing} 辆,注意出厂登记</div>
<div class="alert ok">本月已登加油 ${fuels.length} 笔</div>`;
const fuelSum = fuels.reduce((s, x) => s + x.amount, 0);
const maintOpen = maints.filter((m) => !m.outDate).length;
document.getElementById("dashStats").innerHTML = [
["在册车辆", vehicles.length, `运行 ${vehicles.filter(v=>v.status==="运行").length} · 维保 ${fixing}`],
["本月加油(元)", money(fuelSum), `${fuels.reduce((s,x)=>s+x.liters,0)} 升`],
["未出厂维保", maintOpen, `记录 ${maints.length} 条`],
["本月 ETC 申请", etcs.length, `已提交 ${etcs.filter(x=>x.status==="已提交").length} · 草稿 ${etcs.filter(x=>x.status==="草稿").length}`]
].map(([k,v,s]) => `<div class="stat"><div class="k">${k}</div><div class="v">${v}</div><div class="s">${s}</div></div>`).join("");
document.getElementById("dashFleet").innerHTML = `<table><thead><tr><th>车牌</th><th>介质</th><th>驾驶员</th><th>状态</th><th>罐检到期</th></tr></thead><tbody>${
vehicles.map((v) => `<tr><td>${v.plate}</td><td>${v.media}</td><td>${v.driver}</td><td><span class="st ${stClass(v.status)}">${v.status}</span></td><td>${daysTo(v.tankDue) <= 20 ? `<span class="st st-due">${v.tankDue}</span>` : v.tankDue}</td></tr>`).join("")
}</tbody></table>`;
const events = [
...fuels.map((x) => ({ date: x.date, title: "加油 " + x.plate, d: x.liters + " 升 · " + x.station })),
...etcs.map((x) => ({ date: x.date, title: "ETC申请 " + x.plate, d: x.applicant + " · 期初 " + x.km + " km" })),
...maints.map((x) => ({ date: x.inDate, title: x.type + " " + x.plate, d: x.item }))
].sort((a, b) => b.date.localeCompare(a.date)).slice(0, 6);
document.getElementById("dashTl").innerHTML = events.map((e) => `<div class="tl"><div class="t">${e.date.slice(5)}</div><div><b>${e.title}</b><div class="d">${e.d}</div></div></div>`).join("");
const byPlate = {};
fuels.forEach((f) => { byPlate[f.plate] = (byPlate[f.plate] || 0) + f.liters; });
const max = Math.max(...Object.values(byPlate), 1);
document.getElementById("dashBars").innerHTML = Object.entries(byPlate).map(([p, n]) =>
`<div class="bar-row"><span>${p.replace("鄂E·","")}</span><div class="bar-track"><div class="bar-fill" style="width:${(n/max)*100}%"></div></div><span class="n">${n}</span></div>`
).join("");
const sb = document.getElementById("sideBadge");
if (sb) sb.textContent = due.length + maintOpen + warnN;
}
function renderFleet() {
const q = (document.getElementById("fleetQ").value || "").trim();
const st = document.getElementById("fleetSt").value;
const rows = vehicles.filter((v) => (!q || v.plate.includes(q) || v.driver.includes(q)) && (!st || v.status === st));
document.getElementById("fleetTable").innerHTML = `<table><thead><tr><th>车牌</th><th>车型</th><th>介质</th><th>驾驶员</th><th>押运员</th><th>里程</th><th>状态</th><th>罐检</th><th>保险</th><th>下次保养</th></tr></thead><tbody>${
rows.map((v) => `<tr><td>${v.plate}</td><td>${v.type}</td><td>${v.media}</td><td>${v.driver}</td><td>${v.escort}</td><td>${v.km.toLocaleString()}</td><td><span class="st ${stClass(v.status)}">${v.status}</span></td><td>${v.tankDue}</td><td>${v.insDue}</td><td>${v.nextMaint.toLocaleString()} km</td></tr>`).join("")
}</tbody></table>`;
}
function attachListOf(source) {
if (source === "etc") return etcs;
if (source === "oilcard") return oilcards;
if (source === "maint") return maints;
return fuels;
}
function attachChips(source, rowIndex) {
const items = (attachListOf(source)[rowIndex] || {}).attachments || [];
if (!items.length) return `<span class="st st-due">缺附件</span>`;
return `<div class="chips">${items.map((a, i) =>
a.url
? `<button class="chip" type="button" onclick="openAttach('${source}',${rowIndex},${i})">${a.name}</button>`
: `<span class="chip ghost" title="历史存档">${a.name}</span>`
).join("")}</div>`;
}
function ymShift(ym, n) {
const [y, m] = String(ym).split("-").map(Number);
const d = new Date(y, m - 1 + n, 1);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
}
function fuelsInMonth(plate, ym) {
return fuels.filter((f) => f.plate === plate && String(f.date).slice(0, 7) === ym);
}
function lastFuelKm(rows) {
const list = rows.filter((f) => f.km != null && f.km !== "").slice().sort((a, b) => String(a.date).localeCompare(String(b.date)));
return list.length ? Number(list[list.length - 1].km) : null;
}
function monthRunKm(plate, ym) {
const cur = lastFuelKm(fuelsInMonth(plate, ym));
const prev = lastFuelKm(fuelsInMonth(plate, ymShift(ym, -1)));
if (cur != null && prev != null) return Math.max(0, cur - prev);
const rows = fuelsInMonth(plate, ym).filter((f) => f.km != null).slice().sort((a, b) => String(a.date).localeCompare(String(b.date)));
if (rows.length >= 2) return Math.max(0, Number(rows[rows.length - 1].km) - Number(rows[0].km));
return 0;
}
function litersMomHtml(cur, prev) {
if (!prev) return `<em class="mom flat">环比 —</em>`;
const pct = ((cur - prev) / prev) * 100;
if (Math.abs(pct) < 0.05) return `<em class="mom flat">环比持平</em>`;
const up = pct > 0;
return `<em class="mom ${up ? "up" : "down"}">环比${up ? "↑" : "↓"}${Math.abs(pct).toFixed(1)}%</em>`;
}
function renderFuel() {
const ym = String(today).slice(0, 7);
const prevYm = ymShift(ym, -1);
const box = document.getElementById("fuelPlateCards");
if (box) {
box.innerHTML = vehicles.map((v) => {
const rows = fuelsInMonth(v.plate, ym);
const prevRows = fuelsInMonth(v.plate, prevYm);
const liters = rows.reduce((s, f) => s + Number(f.liters || 0), 0);
const prevLiters = prevRows.reduce((s, f) => s + Number(f.liters || 0), 0);
const runKm = monthRunKm(v.plate, ym);
const prevKm = monthRunKm(v.plate, prevYm);
return `<article class="fuel-plate-card" onclick="openFuelDlg('${v.plate}')">
<div class="plate">${hazPlate(v)}</div>
<div class="meta">${v.driver || "—"} · ${v.type || ""} · <span class="st ${stClass(v.status)}">${v.status}</span></div>
<div class="nums">
<div><b>${rows.length}</b><span>当月笔数</span>${litersMomHtml(rows.length, prevRows.length)}</div>
<div><b>${money(runKm)}</b><span>当月公里</span>${litersMomHtml(runKm, prevKm)}</div>
<div><b>${fmtTon(liters)}</b><span>升</span>${litersMomHtml(liters, prevLiters)}</div>
</div>
</article>`;
}).join("");
}
document.getElementById("fuelTable").innerHTML = `<table><thead><tr><th>日期</th><th>车辆</th><th>加油站</th><th>油品</th><th>升数</th><th>金额</th><th>里程</th><th>附件</th><th>备注</th></tr></thead><tbody>${
fuels.map((x, i) => `<tr><td>${x.date}</td><td>${hazPlate(x.plate)}</td><td>${x.station}</td><td>${x.oil}</td><td>${x.liters}</td><td>${x.amount}</td><td>${x.km}</td><td>${attachChips("fuel", i)}</td><td>${x.note || "—"}</td></tr>`).join("")
}</tbody></table>`;
}
function openFuelDlg(plate) {
const form = document.getElementById("fuelForm");
form.reset();
document.getElementById("fuelFileChips").innerHTML = "";
fillPlates();
if (plate) form.plate.value = plate;
const v = vehicles.find((x) => x.plate === form.plate.value);
if (v && form.km) form.km.value = v.km;
document.getElementById("fuelDialog").showModal();
}
function closeFuelDlg() {
document.getElementById("fuelDialog").close();
}
function fmtRate(n, digits) {
return n == null ? "—" : n.toFixed(digits);
}
function renderFuelWarn() {
const q = (document.getElementById("fuelwarnQ").value || "").trim();
const st = document.getElementById("fuelwarnSt").value;
const all = fuelSegments();
const warnN = all.filter((r) => r.status === "预警").length;
const okN = all.filter((r) => r.status === "正常").length;
const lackN = all.filter((r) => r.status === "不足" || r.status === "无记录" || r.status === "异常").length;
const rates = all.filter((r) => r.rate != null).map((r) => r.rate);
const maxRate = rates.length ? Math.max(...rates) : 0;
document.getElementById("fuelwarnKpis").innerHTML = [
["标准油耗", "0.33 L/km", "33 ÷ 100"],
["超标预警", warnN, "相邻加油区间"],
["正常区间", okN, "未超过标准"],
["数据不足 / 异常", lackN, "少于两次加油或里程未增"]
].map(([k, v, s]) => `<div class="stat"><div class="k">${k}</div><div class="v">${v}</div><div class="s">${s}</div></div>`).join("");
const latestByPlate = {};
all.filter((r) => r.rate != null).forEach((r) => {
const prev = latestByPlate[r.plate];
if (!prev || r.toDate > prev.toDate) latestByPlate[r.plate] = r;
});
const barMax = Math.max(maxRate, FUEL_NORM, 0.01);
document.getElementById("fuelwarnBars").innerHTML = Object.values(latestByPlate).map((r) => {
const over = r.rate > FUEL_NORM;
return `<div class="bar-row"><span>${hazPlate(r.plate)}</span><div class="bar-track"><div class="bar-fill${over ? " warn" : ""}" style="width:${(r.rate / barMax) * 100}%"></div><i class="bar-mark" style="left:${(FUEL_NORM / barMax) * 100}%" title="标准 0.33"></i></div><span class="n${over ? " rate-over" : ""}">${fmtRate(r.rate, 3)}</span></div>`;
}).join("") || `<div style="color:var(--muted)">暂无可对照的加油区间</div>`;
const rows = all.filter((r) => (!q || r.plate.includes(q) || hazPlate(r.plate).includes(q)) && (!st || (st === "不足" ? ["不足", "无记录", "异常"].includes(r.status) : r.status === st)));
const stHtml = (s) => s === "预警" || s === "异常" ? `<span class="st st-due">${s}</span>` : s === "正常" ? `<span class="st st-run">${s}</span>` : `<span class="st st-stop">${s}</span>`;
document.getElementById("fuelwarnTable").innerHTML = `<table><thead><tr><th>车辆</th><th>驾驶员</th><th>区间</th><th>起止里程</th><th>行驶(km)</th><th>加油(L)</th><th>L/km</th><th>L/100km</th><th>对照</th><th>状态</th></tr></thead><tbody>${
rows.map((r) => {
const over = r.status === "预警";
const delta = r.rate == null ? "—" : (r.rate - FUEL_NORM >= 0 ? "+" : "") + (r.rate - FUEL_NORM).toFixed(3);
return `<tr><td>${hazPlate(r.plate)}</td><td>${r.driver}</td><td>${r.fromDate} → ${r.toDate}</td><td>${Number(r.fromKm).toLocaleString()} → ${Number(r.toKm).toLocaleString()}</td><td>${r.km || "—"}</td><td>${r.liters || "—"}</td><td class="${over ? "rate-over" : ""}">${fmtRate(r.rate, 3)}</td><td class="${over ? "rate-over" : ""}">${fmtRate(r.per100, 1)}</td><td>${delta}</td><td>${stHtml(r.status)}</td></tr>`;
}).join("") || `<tr><td colspan="10" style="color:var(--muted)">没有符合条件的记录</td></tr>`
}</tbody></table>`;
}
function previewFuelFiles(input) {
const box = document.getElementById("fuelFileChips");
const files = [...input.files];
box.innerHTML = files.length
? files.map((f) => `<span class="chip ghost">${f.name}</span>`).join("")
: "";
}
function readFiles(fileList) {
return Promise.all([...fileList].map((file) => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve({ name: file.name, type: file.type, url: reader.result });
reader.onerror = reject;
reader.readAsDataURL(file);
})));
}
function openAttach(source, rowIndex, fileIndex) {
const file = source === "task"
? ((TASKS.find((x) => x.id === rowIndex) || {}).sheetPhotos || [])[fileIndex]
: (attachListOf(source)[rowIndex].attachments || [])[fileIndex];
if (!file) return;
document.getElementById("attachTitle").textContent = file.name;
const body = document.getElementById("attachBody");
if (file.type && file.type.startsWith("image/") && file.url) {
body.innerHTML = `<img alt="${file.name}" src="${file.url}" />`;
} else if (file.url) {
body.innerHTML = `<p style="margin-bottom:8px;color:var(--muted)">文件可新窗口打开查看。</p><a class="btn btn-primary" href="${file.url}" target="_blank" rel="noopener">打开 ${file.name}</a>`;
} else {
body.innerHTML = `<p>该附件已随历史记录存档,当前演示环境未保留原文件内容。</p>`;
}
document.getElementById("attachDialog").showModal();
}
function closeAttach() {
document.getElementById("attachDialog").close();
}
function etcsInMonth(plate, ym) {
return etcs.filter((x) =>
x.plate === plate &&
String(x.date).slice(0, 7) === ym &&
x.status !== "草稿" &&
(x.type === "ETC" || !x.type)
);
}
function renderEtc() {
const ym = String(today).slice(0, 7);
const prevYm = ymShift(ym, -1);
const box = document.getElementById("etcPlateCards");
if (box) {
box.innerHTML = vehicles.map((v) => {
const rows = etcsInMonth(v.plate, ym);
const prevRows = etcsInMonth(v.plate, prevYm);
const pay = rows.reduce((s, x) => s + Number(x.amount || 0), 0);
const prevPay = prevRows.reduce((s, x) => s + Number(x.amount || 0), 0);
const used = rows.reduce((s, x) => s + Number(x.used || 0), 0);
const prevUsed = prevRows.reduce((s, x) => s + Number(x.used || 0), 0);
const runKm = monthRunKm(v.plate, ym);
const prevKm = monthRunKm(v.plate, prevYm);
return `<article class="fuel-plate-card" onclick="openEtcDlg('${v.plate}')">
<div class="plate">${hazPlate(v)}</div>
<div class="meta">${v.driver || "—"} · ${v.type || ""} · <span class="st ${stClass(v.status)}">${v.status}</span></div>
<div class="nums four">
<div><b>${rows.length}</b><span>充值次数</span>${litersMomHtml(rows.length, prevRows.length)}</div>
<div><b>${money(runKm)}</b><span>当月公里</span>${litersMomHtml(runKm, prevKm)}</div>
<div><b>${money(pay)}</b><span>充值金额</span>${litersMomHtml(pay, prevPay)}</div>
<div><b>${money(used)}</b><span>使用金额</span>${litersMomHtml(used, prevUsed)}</div>
</div>
</article>`;
}).join("");
}
document.getElementById("etcTable").innerHTML = `<table><thead><tr><th>申请日期</th><th>申请人</th><th>车牌</th><th>充值类型</th><th>充值金额</th><th>使用金额</th><th>期初公里</th><th>状态</th><th>附件</th><th>备注</th></tr></thead><tbody>${
etcs.map((x, i) => `<tr><td>${x.date}</td><td>${x.applicant}</td><td>${hazPlate(x.plate)}</td><td>${x.type}</td><td>${money(x.amount || 0)}</td><td>${money(x.used || 0)}</td><td>${Number(x.km).toLocaleString()}</td><td><span class="st ${x.status==="已提交"?"st-run":"st-fix"}">${x.status}</span></td><td>${attachChips("etc", i)}</td><td>${x.note || "—"}</td></tr>`).join("")
}</tbody></table>`;
}
function previewMaintFiles(input) {
const box = document.getElementById("maintFileChips");
const files = [...input.files];
box.innerHTML = files.length
? files.map((f) => `<span class="chip ghost">${f.name}</span>`).join("")
: "";
}
function openMaintDlg(plate) {
const form = document.getElementById("maintForm");
form.reset();
document.getElementById("maintFileChips").innerHTML = "";
fillPlates();
if (plate) form.plate.value = plate;
document.getElementById("maintDialog").showModal();
}
function closeMaintDlg() {
document.getElementById("maintDialog").close();
}
function focusMaintPlate(plate) {
openMaintDlg(plate);
}
function renderMaint() {
const box = document.getElementById("maintPlateCards");
const yr = String(today).slice(0, 4);
const ym = String(today).slice(0, 7);
if (box) {
box.innerHTML = vehicles.map((v) => {
const yearRows = maints.filter((m) => m.plate === v.plate && String(m.inDate).slice(0, 4) === yr);
const monthRows = yearRows.filter((m) => String(m.inDate).slice(0, 7) === ym);
const yearAmt = yearRows.reduce((s, m) => s + Number(m.amount || 0), 0);
const monthAmt = monthRows.reduce((s, m) => s + Number(m.amount || 0), 0);
return `<article class="fuel-plate-card" onclick="focusMaintPlate('${v.plate}')">
<div class="plate">${hazPlate(v)}</div>
<div class="meta">${v.driver || "—"} · ${v.type || ""} · <span class="st ${stClass(v.status)}">${v.status}</span></div>
<div class="nums">
<div><b>${yearRows.length}</b><span>本年度维修次数</span></div>
<div><b>${money(monthAmt)}</b><span>本月合计</span></div>
<div><b>${money(monthAmt)} / ${money(yearAmt)}</b><span>本月 / 本年度合计</span></div>
</div>
</article>`;
}).join("");
}
document.getElementById("maintTable").innerHTML = `<table><thead><tr><th>申请日期</th><th>申请人</th><th>车牌</th><th>类型</th><th>开始日期</th><th>预计取车</th><th>维保内容</th><th>维修店</th><th>附件</th><th>备注</th></tr></thead><tbody>${
maints.map((x, i) => `<tr><td>${x.applyDate || x.inDate}</td><td>${x.applicant || "—"}</td><td>${hazPlate(x.plate)}</td><td>${x.type}</td><td>${x.inDate}</td><td>${x.outDate || "—"}</td><td>${x.item || "—"}</td><td>${x.shop || "—"}</td><td>${attachChips("maint", i)}</td><td>${x.note || "—"}</td></tr>`).join("")
}</tbody></table>`;
}
function renderStats() {
const fuelAmt = fuels.reduce((s, x) => s + Number(x.amount), 0);
const maintAmt = maints.reduce((s, x) => s + Number(x.amount), 0);
const liters = fuels.reduce((s, x) => s + Number(x.liters), 0);
document.getElementById("statKpis").innerHTML = [
["加油升数", liters, "本月已登记"],
["加油费用", money(fuelAmt) + " 元", ""],
["维保费用", money(maintAmt) + " 元", "含未出厂"],
["ETC 申请", etcs.length, `已提交 ${etcs.filter(x=>x.status==="已提交").length} 笔`]
].map(([k,v,s]) => `<div class="stat"><div class="k">${k}</div><div class="v">${v}</div><div class="s">${s || "&nbsp;"}</div></div>`).join("");
const byPlate = {};
fuels.forEach((f) => { byPlate[f.plate] = (byPlate[f.plate] || 0) + f.liters; });
const max = Math.max(...Object.values(byPlate), 1);
document.getElementById("statBars").innerHTML = Object.entries(byPlate).map(([p, n]) =>
`<div class="bar-row"><span>${p.replace("鄂E·","")}</span><div class="bar-track"><div class="bar-fill" style="width:${(n/max)*100}%"></div></div><span class="n">${n} L</span></div>`
).join("");
const total = fuelAmt + maintAmt || 1;
const segs = [
{ name: "加油", n: fuelAmt, c: "#148a3c" },
{ name: "维保", n: maintAmt, c: "#c2410c" }
];
let acc = 0;
const circ = 2 * Math.PI * 10;
document.getElementById("pie").innerHTML = segs.map((s) => {
const len = (s.n / total) * circ;
const dash = `${len} ${circ}`;
const rot = -90 + acc * 360;
acc += s.n / total;
return `<circle r="10" cx="16" cy="16" fill="none" stroke="${s.c}" stroke-width="4" stroke-dasharray="${dash}" transform="rotate(${rot} 16 16)"/>`;
}).join("");
document.getElementById("pieLegend").innerHTML = segs.map((s) =>
`<span><i class="sw" style="background:${s.c}"></i>${s.name} ${money(s.n)} 元</span>`
).join("");
document.getElementById("certTable").innerHTML = `<table><thead><tr><th>车牌</th><th>罐检到期</th><th>剩余</th><th>保险到期</th><th>剩余</th></tr></thead><tbody>${
vehicles.map((v) => `<tr><td>${v.plate}</td><td>${v.tankDue}</td><td>${daysTo(v.tankDue)} 天</td><td>${v.insDue}</td><td>${daysTo(v.insDue)} 天</td></tr>`).join("")
}</tbody></table>`;
}
function addFuel(e) {
e.preventDefault();
const input = e.target.querySelector("[name=attach]");
if (!input.files || !input.files.length) {
toast("请上传加油小票或发票");
return false;
}
const fd = new FormData(e.target);
const f = Object.fromEntries(fd);
delete f.attach;
readFiles(input.files).then((attachments) => {
fuels.unshift({ ...f, liters: +f.liters, amount: +f.amount, km: +f.km, attachments });
const v = vehicles.find((x) => x.plate === f.plate);
if (v) v.km = +f.km;
toast("加油已登记,附件已保存");
e.target.reset();
document.getElementById("fuelFileChips").innerHTML = "";
fillPlates();
closeFuelDlg();
renderFuel();
renderDash();
if (!document.getElementById("view-fuelwarn").classList.contains("hidden")) renderFuelWarn();
});
return false;
}
function previewEtcFiles(input) {
const box = document.getElementById("etcFileChips");
const files = [...input.files];
box.innerHTML = files.length
? files.map((f) => `<span class="chip ghost">${f.name}</span>`).join("")
: "";
}
function stepKm(delta) {
const el = document.querySelector("#etcForm [name=km]");
el.value = Math.max(0, (+el.value || 0) + delta);
}
function syncEtcKm() {
const plate = document.querySelector("#etcForm [name=plate]").value;
const v = vehicles.find((x) => x.plate === plate);
if (v) document.querySelector("#etcForm [name=km]").value = v.km;
}
function openEtcDlg(plate) {
const form = document.getElementById("etcForm");
form.reset();
document.getElementById("etcFileChips").innerHTML = "";
fillPlates();
if (plate) form.plate.value = plate;
syncEtcKm();
document.getElementById("etcDialog").showModal();
}
function closeEtcDlg() {
document.getElementById("etcDialog").close();
}
function saveEtc(submit) {
const form = document.getElementById("etcForm");
if (!form.reportValidity()) return;
const input = form.querySelector("[name=attach]");
if (!input.files || !input.files.length) {
toast("请上传附件");
return;
}
const f = Object.fromEntries(new FormData(form));
delete f.attach;
readFiles(input.files).then((attachments) => {
etcs.unshift({ ...f, km: +f.km, amount: +f.amount || 0, used: +f.used || 0, attachments, status: submit ? "已提交" : "草稿" });
toast(submit ? "ETC 申请已提交" : "ETC 申请已保存");
closeEtcDlg();
renderEtc();
renderDash();
});
}
function addMaint(e) {
e.preventDefault();
const form = document.getElementById("maintForm");
if (!form.reportValidity()) return false;
const f = Object.fromEntries(new FormData(form));
delete f.attach;
const input = form.querySelector("[name=attach]");
const save = (attachments) => {
maints.unshift({
...f,
amount: +f.amount || 0,
nextKm: f.nextKm ? +f.nextKm : "",
attachments: attachments || []
});
const v = vehicles.find((x) => x.plate === f.plate);
if (v && !f.outDate) v.status = "维保中";
toast("维保申请已提交");
closeMaintDlg();
renderMaint();
renderDash();
};
if (input && input.files && input.files.length) readFiles(input.files).then(save);
else save([]);
return false;
}
function transOf(c) { return c.transList || []; }
function findTrans(c, transId) { return transOf(c).find((t) => t.id === transId) || null; }
function isPlanDone(p) {
return !!(p && (p.status === "已完成" || p.archived));
}
function deductQty(p) {
if (!p || p.status === "已作废" || !isPlanDone(p)) return 0;
return Number(p.realQty != null ? p.realQty : p.planQty || 0);
}
function holdQty(p) {
if (!p || p.status === "已作废" || isPlanDone(p)) return 0;
return Number(p.planQty || 0);
}
function occupyQty(p) {
return deductQty(p) + holdQty(p);
}
function warnQtyOf(v, total) {
if (v !== "" && v != null) {
const n = Number(v);
if (Number.isFinite(n) && n >= 0) return n;
}
return defaultWarnQty(total);
}
function buyWarnQty(c) { return warnQtyOf(c && c.warnQty, c && c.buyQty); }
function transWarnQty(t) { return warnQtyOf(t && t.warnQty, t && t.transQty); }
function warnQtyField(certId, transId, unit) {
const c = certs.find((x) => x.id === certId);
const v = transId == null ? buyWarnQty(c) : transWarnQty(c ? findTrans(c, transId) : null);
const tid = transId == null ? "null" : transId;
return `<label class="warn-qty-edit" onclick="event.stopPropagation()"><input type="number" min="0" step="0.01" value="${v}" onchange="setCertWarnQty(${certId}, ${tid}, this.value)" /><span>${unit || "吨"}</span></label>`;
}
function ensureWarnQtyInputs(root) {
if (!root) return;
root.querySelectorAll("input[name=warnQty]").forEach((el) => {
const box = el.closest(".form") || el.closest(".extra-trans") || el.closest("#buyFormCol") || root;
const qtyEl = box.querySelector("[name=buyQty]") || box.querySelector("[name=transQty]");
if (el.value === "" || el.value == null) el.value = defaultWarnQty(qtyEl && qtyEl.value);
});
}
function setCertWarnQty(certId, transId, val) {
const c = certs.find((x) => x.id === certId);
if (!c) return;
if (val === "" || val == null) return;
const n = Number(val);
if (!Number.isFinite(n) || n < 0) {
toast("请输入有效预警量");
if (currentCertId === certId) renderCertDetail();
return;
}
if (transId == null || transId === "") {
c.warnQty = n;
toast(`购买证预警量已设为 ${fmtTon(n)}${c.unit || "吨"}`);
} else {
const t = findTrans(c, +transId);
if (!t) return;
t.warnQty = n;
toast(`运输证 ${t.transNo || ""} 预警量已设为 ${fmtTon(n)}${c.unit || "吨"}`);
}
if (currentCertId === certId) renderCertDetail();
renderCertList();
updateBadges();
}
function lowRemain(g, warn) {
const w = warnQtyOf(warn);
return g > 0 && g <= w + 1e-9;
}
function chemKey(s) {
const t = String(s || "");
if (/硝酸铵|硝铵/.test(t)) return "硝酸铵";
if (/盐酸/.test(t)) return "盐酸";
if (/硫酸|试剂酸/.test(t)) return "硫酸";
if (/槽车运输/.test(t)) return "硫酸";
return t.replace(/\d+(\.\d+)?%/g, "").replace(/工业|试剂/g, "").trim();
}
function planMatchesCert(p, c) {
if (!p || !c) return false;
if (p.ctrl && c.ctrl && p.ctrl !== c.ctrl) return false;
const a = chemKey(p.product);
const b = chemKey(c.product);
if (!a || !b) return true;
return a === b || a.includes(b) || b.includes(a);
}
function normOrg(s) {
return String(s || "").replace(/\s+/g, "").replace(/有限责任公司|股份有限公司|有限公司|集团|公司/g, "").toLowerCase();
}
function orgMatch(a, b) {
const x = normOrg(a), y = normOrg(b);
if (!x || !y) return false;
return x === y || x.includes(y) || y.includes(x);
}
function isShanxiaOrg(s) {
const t = String(s || "");
return /山峡化工|山峡/.test(t) || orgMatch(t, "安庆市山峡化工有限公司");
}
function planCertScene(p) {
if (isInboundPlan(p)) return "in";
if (isDirectSale(p)) return "direct";
return "out";
}
function planSceneLabel(scene) {
return scene === "in" ? "入库单" : scene === "direct" ? "直销单" : "出库单";
}
function planLoadParty(p) {
return (p && p.loadFrom) || "";
}
function planShipParty(p) {
if (!p) return "";
if (isInboundPlan(p)) return p.settler || p.customer || "安庆市山峡化工有限公司";
return p.customer || p.settler || "";
}
function planCustName(p) {
return planShipParty(p);
}
function certBuyerMatch(p, c) {
return certIsShipBuy(p, c);
}
function certIsShipBuy(p, c) {
if (!p || !c) return false;
if (!planMatchesCert(p, c)) return false;
return orgMatch(planShipParty(p), c.buyer);
}
function certIsProcure(p, c) {
if (!p || !c) return false;
if (!planMatchesCert(p, c)) return false;
if (orgMatch(planLoadParty(p), c.buyer)) return true;
return isShanxiaOrg(c.buyer) && orgMatch(planLoadParty(p), c.seller);
}
function planNeedProcureCert(p) {
return isDirectSale(p);
}
function planCertLinked(p) {
if (!p) return false;
if (!p.certId) return false;
if (planNeedProcureCert(p) && (!p.shipCertId || !p.procureCertId)) return false;
return true;
}
function isWaitListPlan(p) {
if (!p || p.status !== "已安排") return false;
if (p.ctrl !== "易制毒") return false;
return !!String(p.tractor || "").trim();
}
function isWaitLinkPlan(p) {
if (!isWaitListPlan(p)) return false;
return !p.certId || !p.transId || (planNeedProcureCert(p) && (!p.shipCertId || !p.procureCertId));
}
function waitPlanDay(p) {
return String((p && (p.deliverDate || p.date)) || "");
}
function waitWeekStart(dayStr) {
const d = new Date(String(dayStr) + "T00:00:00");
if (Number.isNaN(+d)) return 0;
const wd = d.getDay();
d.setDate(d.getDate() - (wd === 0 ? 6 : wd - 1));
d.setHours(0, 0, 0, 0);
return +d;
}
function isWaitTodayPlan(p) {
return waitPlanDay(p) === today;
}
function isWaitWeekPlan(p) {
const day = waitPlanDay(p);
if (!day) return false;
const t = Date.parse(day + "T00:00:00");
if (!Number.isFinite(t)) return false;
const w0 = waitWeekStart(today);
return t >= w0 && t < w0 + 7 * 86400000;
}
let waitLinkSceneTab = "";
function waitListSort(a, b) {
const sa = planCertScene(a), sb = planCertScene(b);
const order = { in: 0, out: 1, direct: 2 };
if (order[sa] !== order[sb]) return order[sa] - order[sb];
const aToday = (a.date === today || a.deliverDate === today) ? 0 : 1;
const bToday = (b.date === today || b.deliverDate === today) ? 0 : 1;
if (aToday !== bToday) return aToday - bToday;
return String(a.deliverDate || a.date).localeCompare(String(b.deliverDate || b.date));
}
function waitListPlans() {
return plans.filter((p) => isWaitListPlan(p) && (!waitLinkSceneTab || planCertScene(p) === waitLinkSceneTab)).sort(waitListSort);
}
function waitLinkPlans() {
return plans.filter((p) => isWaitLinkPlan(p) && (!waitLinkSceneTab || planCertScene(p) === waitLinkSceneTab)).sort(waitListSort);
}
function setWaitLinkScene(tab) {
waitLinkSceneTab = tab || "";
renderWaitPlans();
}
function platesOfCert(c) {
const set = new Set();
transOf(c).forEach((t) => platesOfTrans(t).forEach((pl) => set.add(pl)));
return [...set];
}
function transForPlate(c, plate) {
if (!c || !plate || certVoided(c)) return null;
const hits = transOf(c).filter((t) => transSelectable(c, t) && (platesOfTrans(t).includes(plate) || String(t.plates || "").includes(plate)));
return hits[0] || null;
}
function suggestVehicleFromCert(c, p) {
if (!c) return "";
const plates = platesOfCert(c);
if (p && p.tractor && plates.includes(p.tractor)) return p.tractor;
const ds = allDispatches(c);
let best = "", bestLeft = -Infinity;
ds.forEach((d) => {
const left = Number(d.qty || 0) - vehicleHold(d.plate, d.transId);
if (left > bestLeft) { bestLeft = left; best = d.plate; }
});
return best || plates[0] || (p && p.tractor) || "";
}
function rankCertForPlan(p, pred) {
return certs.map((c) => {
let score = 0;
const hit = pred(p, c);
const match = planMatchesCert(p, c);
if (hit) score += 10;
if (match) score += 3;
if (p.ctrl && c.ctrl && p.ctrl === c.ctrl) score += 2;
if (remainGap(c) + 1e-9 >= occupyQty(p)) score += 1;
if (transOf(c).some((x) => transItemRemain(x) + 1e-9 >= occupyQty(p))) score += 1;
if (transOf(c).some((x) => assignedSum(x) > 1e-9)) score += 4;
if (transOf(c).some((x) => transAssignLeft(x) + 1e-9 >= occupyQty(p))) score += 5;
if (p.tractor && transOf(c).some((t) => transSelectable(c, t) && platesOfTrans(t).includes(p.tractor))) score += 4;
if (certVoided(c)) score -= 50;
return { c, score, hit };
}).sort((a, b) => b.score - a.score);
}
function suggestShipCert(p) {
const hit = rankCertForPlan(p, certIsShipBuy).find((x) => x.hit && certSelectable(x.c));
return (hit && hit.c) || null;
}
function suggestProcureCert(p) {
const hit = rankCertForPlan(p, certIsProcure).find((x) => x.hit && certSelectable(x.c));
return (hit && hit.c) || null;
}
function suggestCertForPlan(p) {
if (planNeedProcureCert(p)) return suggestProcureCert(p) || suggestShipCert(p);
return suggestShipCert(p);
}
function suggestTransForPlan(c, p) {
const list = transOf(c).filter((t) => transSelectable(c, t));
if (!list.length) return null;
if (p && p.transId) {
const hit = findTrans(c, p.transId);
if (hit && transSelectable(c, hit)) return hit;
}
if (p && p.tractor) {
const hit = transForPlate(c, p.tractor);
if (hit) return hit;
}
const qty = occupyQty(p);
return list.find((t) => transItemRemain(t) + 1e-9 >= qty) || list[0];
}
function suggestVehicleForPlan(t, p) {
if (!t) return "";
const plates = platesOfTrans(t);
if (p && p.tractor && (plates.includes(p.tractor) || String(t.plates || "").includes(p.tractor))) return p.tractor;
const ds = t.dispatches || [];
let best = "", bestLeft = -Infinity;
ds.forEach((d) => {
const left = Number(d.qty || 0) - vehicleHold(d.plate, t.id);
if (left > bestLeft) { bestLeft = left; best = d.plate; }
});
return best || plates[0] || (p && p.tractor) || "";
}
function waitPickOf(p) {
const saved = waitLinkPicks[p.id] || {};
const scene = planCertScene(p);
const shipC = (saved.shipCertId && certs.find((x) => x.id === saved.shipCertId)) || suggestShipCert(p);
let c = (saved.certId && certs.find((x) => x.id === saved.certId)) || null;
if (!c) c = planNeedProcureCert(p) ? suggestProcureCert(p) : shipC;
if (!c) return { c: null, t: null, plate: "", shipC, scene, matchCust: false, matchShip: !!shipC && certIsShipBuy(p, shipC), matchProcure: !planNeedProcureCert(p), matchVeh: false };
const certPlates = platesOfCert(c);
let plate = saved.plate || "";
if (plate && !certPlates.includes(plate) && !transForPlate(c, plate)) plate = "";
if (!plate) plate = suggestVehicleFromCert(c, p);
let t = saved.transId ? findTrans(c, saved.transId) : null;
if (plate) {
const byPlate = transForPlate(c, plate);
if (byPlate && !saved.transId) t = byPlate;
if (t && plate && !platesOfTrans(t).includes(plate) && !String(t.plates || "").includes(plate)) t = byPlate || t;
}
if (!t) t = suggestTransForPlan(c, p);
if (t && plate && !platesOfTrans(t).includes(plate) && !String(t.plates || "").includes(plate) && saved.plate == null) {
plate = suggestVehicleForPlan(t, p);
}
const matchShip = !!(shipC && certIsShipBuy(p, shipC));
const matchProcure = !planNeedProcureCert(p) || !!(c && certIsProcure(p, c));
return {
c, t, plate, shipC, scene,
matchCust: matchShip,
matchShip, matchProcure,
matchVeh: !!(plate && t && (platesOfTrans(t).includes(plate) || String(t.plates || "").includes(plate)))
};
}
function afterLinkCalc(c, t, plate, qty, skipId, shipC) {
const buyAfter = remainGap(c, skipId) - qty;
const transAfter = t ? transItemGap(t, skipId) - qty : 0;
const shipAfter = shipC && shipC.id !== (c && c.id) ? remainGap(shipC, skipId) - qty : buyAfter;
const cap = t && plate ? Number(((t.dispatches || []).find((d) => d.plate === plate) || {}).qty || 0) : 0;
const vehAfter = plate ? cap - vehicleHold(plate, t && t.id, skipId) - qty : 0;
const room = vehicleRoom(c, t, plate, skipId, shipC);
const take = Math.min(qty, room);
const over = buyAfter < -1e-6 || transAfter < -1e-6 || shipAfter < -1e-6 || (cap > 0 && vehAfter < -1e-6);
const warn = !over && (
lowRemain(buyAfter, buyWarnQty(c)) ||
lowRemain(transAfter, transWarnQty(t)) ||
(shipC ? lowRemain(shipAfter, buyWarnQty(shipC)) : false) ||
lowRemain(remainAfterDone(c), buyWarnQty(c))
);
return { buyAfter, transAfter, shipAfter, vehAfter, cap, over, warn, room, take };
}
const waitLinkPicks = {};
function fmtTon(n) {
const x = Number(n || 0);
return Math.abs(x - Math.round(x)) < 1e-6 ? String(Math.round(x)) : x.toFixed(2);
}
function linkedPlansOf(c) {
return plans.filter((p) => p.status !== "已作废" && (p.certId === c.id || p.shipCertId === c.id || p.procureCertId === c.id));
}
function openLinkedPlans(c) {
return linkedPlansOf(c).filter((p) => !p.archived);
}
function reserved(c, skipId) {
return linkedPlansOf(c).reduce((s, p) => s + (p.id === skipId ? 0 : occupyQty(p)), 0);
}
function deductedOf(c, skipId) {
return linkedPlansOf(c).reduce((s, p) => s + (p.id === skipId ? 0 : deductQty(p)), 0);
}
function holdingOf(c, skipId) {
return linkedPlansOf(c).reduce((s, p) => s + (p.id === skipId ? 0 : holdQty(p)), 0);
}
function remainGap(c, skipId) {
return Number(c.buyQty || 0) - reserved(c, skipId);
}
function remainAfterDone(c, skipId) {
return Number(c.buyQty || 0) - deductedOf(c, skipId);
}
function remain(c, skipId) { return Math.max(0, remainGap(c, skipId)); }
function transItemHold(t, skipId) {
return plans.filter((p) => p.transId === t.id && p.status !== "已作废" && p.id !== skipId)
.reduce((s, p) => s + occupyQty(p), 0);
}
function transItemDeduct(t, skipId) {
return plans.filter((p) => p.transId === t.id && p.status !== "已作废" && p.id !== skipId)
.reduce((s, p) => s + deductQty(p), 0);
}
function transItemPlan(t, skipId) {
return plans.filter((p) => p.transId === t.id && p.status !== "已作废" && p.id !== skipId)
.reduce((s, p) => s + holdQty(p), 0);
}
function transItemGap(t, skipId) {
return Number(t.transQty || 0) - transItemHold(t, skipId);
}
function transItemRemain(t, skipId) { return Math.max(0, transItemGap(t, skipId)); }
function transItemWarn(t, skipId) {
if (!t) return false;
const g = transItemGap(t, skipId);
const afterDone = Number(t.transQty || 0) - transItemDeduct(t, skipId);
return g < -1e-6 || lowRemain(g, transWarnQty(t)) || lowRemain(afterDone, transWarnQty(t));
}
function transTotal(c) { return transOf(c).reduce((s, t) => s + Number(t.transQty || 0), 0); }
function transShipped(c) { return transOf(c).reduce((s, t) => s + Number(t.shipped || 0), 0); }
function transRemain(c, skipId) { return transOf(c).reduce((s, t) => s + transItemRemain(t, skipId), 0); }
function assignedSum(t) { return (t.dispatches || []).reduce((s, d) => s + Number(d.qty || 0), 0); }
function assignedSumAll(c) { return transOf(c).reduce((s, t) => s + assignedSum(t), 0); }
function unassignedTrans(t) { return Math.max(0, Number(t.transQty || 0) - assignedSum(t)); }
function vehicleHold(plate, transId, skipId) {
return plans.filter((p) => p.transId === transId && p.tractor === plate && p.status !== "已作废" && p.id !== skipId)
.reduce((s, p) => s + occupyQty(p), 0);
}
function vehiclePlanHold(plate, transId, skipId) {
return plans.filter((p) => p.transId === transId && p.tractor === plate && p.status !== "已作废" && p.id !== skipId)
.reduce((s, p) => s + holdQty(p), 0);
}
function vehicleDeduct(plate, transId, skipId) {
return plans.filter((p) => p.transId === transId && p.tractor === plate && p.status !== "已作废" && p.id !== skipId)
.reduce((s, p) => s + deductQty(p), 0);
}
function vehicleBoundQty(c, t, plate, skipId) {
if (!c || !t) return 0;
const cap = Number(((t.dispatches || []).find((d) => d.plate === plate) || {}).qty || 0);
const self = plate ? vehicleHold(plate, t.id, skipId) : 0;
const buyRoom = remainGap(c, skipId) + self;
const transRoom = transItemGap(t, skipId) + self;
const rooms = [buyRoom, transRoom];
if (cap > 0) rooms.push(cap);
return Math.max(0, Math.min(...rooms));
}
function vehicleRoom(c, t, plate, skipId, shipC) {
if (!c) return 0;
const rooms = [remainGap(c, skipId)];
if (t) rooms.push(transItemGap(t, skipId));
if (shipC && shipC.id !== c.id) rooms.push(remainGap(shipC, skipId));
const cap = t && plate ? Number(((t.dispatches || []).find((d) => d.plate === plate) || {}).qty || 0) : 0;
const used = t && plate ? vehicleHold(plate, t.id, skipId) : 0;
if (cap > 0) rooms.push(cap - used);
return Math.max(0, Math.min(...rooms));
}
function calcVehPlanQty(c, t, plate, p) {
const want = Number((p && p.planQty) || 0);
const shipC = p && p.shipCertId ? certs.find((x) => x.id === p.shipCertId) : null;
const room = vehicleRoom(c, t, plate, p && p.id, shipC);
return { want, room, qty: Math.min(want, room) };
}
function syncCertShipped(c) {
if (!c) return;
c.shipped = deductedOf(c);
transOf(c).forEach((t) => {
t.shipped = transItemDeduct(t);
(t.dispatches || []).forEach((d) => {
d.shipped = vehicleDeduct(d.plate, t.id);
});
});
}
function settleCertOnComplete(p) {
if (!p || !p.certId) return null;
const n = deductQty(p);
p.certDeducted = true;
p.deductedQty = n;
const cert = certs.find((c) => c.id === p.certId);
if (!cert) return null;
syncCertShipped(cert);
const shipC = p.shipCertId ? certs.find((x) => x.id === p.shipCertId) : null;
if (shipC && shipC.id !== cert.id) syncCertShipped(shipC);
const t = findTrans(cert, p.transId);
const buyLeft = remainAfterDone(cert);
const transLeft = t ? Number(t.transQty || 0) - transItemDeduct(t) : 0;
return {
cert, t, n, buyLeft, transLeft,
availBuy: remainGap(cert),
availTrans: t ? transItemGap(t) : 0
};
}
function certQtyWarn(c) {
return lowRemain(remainGap(c), buyWarnQty(c)) || lowRemain(remainAfterDone(c), buyWarnQty(c));
}
function transQtyWarn(t) {
if (!t) return false;
const afterDone = Number(t.transQty || 0) - transItemDeduct(t);
return transItemGap(t) < -1e-6 || lowRemain(transItemGap(t), transWarnQty(t)) || lowRemain(afterDone, transWarnQty(t));
}
function vehicleOver(d, t) {
const cap = Number(d.qty || 0);
if (cap <= 0) return vehicleHold(d.plate, t.id) > 1e-6;
return vehicleHold(d.plate, t.id) - cap > 1e-6;
}
function platesOfTrans(t) {
const set = new Set();
(t.dispatches || []).forEach((d) => { if (d.plate) set.add(d.plate); });
plans.forEach((p) => {
if (p.transId === t.id && p.status !== "已作废" && p.tractor) set.add(p.tractor);
});
return [...set];
}
function unplatedHold(c) {
return linkedPlansOf(c).filter((p) => !p.tractor).reduce((s, p) => s + occupyQty(p), 0);
}
function vehicleAlertOf(c, t, hold, cap, planHold, shipped) {
const unit = c.unit || "吨";
const buyG = remainGap(c);
const transG = transItemGap(t);
const buyDone = remainAfterDone(c);
const transDone = Number(t.transQty || 0) - transItemDeduct(t);
if (transG < -1e-6) return { level: "over", text: `本车计划 ${fmtTon(planHold || hold)}${unit},运输证已超证载 ${fmtTon(-transG)}${unit}` };
if (buyG < -1e-6) return { level: "over", text: `本车计划 ${fmtTon(planHold || hold)}${unit},购买证已超证载 ${fmtTon(-buyG)}${unit}` };
if (cap > 0 && hold - cap > 1e-6) return { level: "over", text: `本车计划 ${fmtTon(hold)}${unit} 超过分派 ${fmtTon(cap)}${unit}` };
if (cap <= 0 && hold > 1e-6) return { level: "warn", text: `本车计划 ${fmtTon(planHold || hold)}${unit},运输证尚未分派该车` };
if (lowRemain(transG, transWarnQty(t)) || lowRemain(transDone, transWarnQty(t))) return { level: "warn", text: `运输证余量 ${fmtTon(Math.min(transG, transDone))}${unit} ≤ ${fmtTon(transWarnQty(t))} 吨` };
if (lowRemain(buyG, buyWarnQty(c)) || lowRemain(buyDone, buyWarnQty(c))) return { level: "warn", text: `购买证余量 ${fmtTon(Math.min(buyG, buyDone))}${unit} ≤ ${fmtTon(buyWarnQty(c))} 吨` };
return { level: "", text: (planHold || shipped) ? `计划 ${fmtTon(planHold || 0)}${unit} · 已完成扣减 ${fmtTon(shipped || 0)}${unit}` : "正常" };
}
function vehicleRowsOf(c) {
const rows = transOf(c).flatMap((t) => platesOfTrans(t).map((plate) => {
const d = (t.dispatches || []).find((x) => x.plate === plate) || { plate, qty: 0, driver: "", shipped: 0 };
const hold = vehicleHold(plate, t.id);
const planHold = vehiclePlanHold(plate, t.id);
const shipped = vehicleDeduct(plate, t.id);
const cap = Number(d.qty || 0);
const bound = vehicleBoundQty(c, t, plate);
const alert = vehicleAlertOf(c, t, hold, cap, planHold, shipped);
return { plate, driver: d.driver || "", transId: t.id, transNo: t.transNo, carrier: t.carrier, cap, hold, planHold, shipped, bound, level: alert.level, text: alert.text };
}));
const loose = unplatedHold(c);
if (loose > 1e-6) {
const t = transOf(c)[0] || { transNo: "—", carrier: "", transQty: 0 };
rows.push({
plate: "未派车计划", driver: "—", transId: t.id, transNo: t.transNo || "—", carrier: t.carrier || "",
cap: 0, hold: loose, planHold: loose, shipped: 0, bound: 0, level: "warn", text: `有 ${fmtTon(loose)}${c.unit} 计划已关联证件但未指定车辆`
});
}
return rows;
}
function vehLinkedPlans(plate, transId) {
const tid = Number(transId);
if (plate === "未派车计划") {
return plans.filter((p) => p.status !== "已作废" && Number(p.transId) === tid && !String(p.tractor || "").trim());
}
const plateN = String(plate || "").trim();
return plans.filter((p) => p.status !== "已作废" && Number(p.transId) === tid && String(p.tractor || "").trim() === plateN);
}
function openVehPlanDlg(certId, transId, plate) {
const c = certs.find((x) => x.id === certId);
const t = c ? findTrans(c, transId) : null;
const rows = vehLinkedPlans(plate, transId);
const unit = (c && c.unit) || "吨";
document.getElementById("vehPlanTitle").textContent =
`${plate} · ${t ? t.transNo : "运输证"} · 关联计划 ${rows.length} 条`;
const sumPlan = rows.reduce((s, p) => s + Number(p.planQty || 0), 0);
const sumDone = rows.reduce((s, p) => s + Number(deductQty(p) || 0), 0);
document.getElementById("vehPlanBody").innerHTML = rows.length ? `<table>
<thead><tr><th>计划编号</th><th>状态</th><th>单据类型</th><th>品名</th><th>车牌</th><th>计划量</th><th>已完成量</th><th>送货时间</th></tr></thead>
<tbody>${rows.map((p) => `<tr>
<td class="cell-no">${p.no}</td>
<td>${planStHtml(p)}</td>
<td>${planTypeName(p)}</td>
<td>${p.product || "—"}${p.ctrl === "易制毒" ? `<span class="prod-tag red">易制毒</span>` : ""}</td>
<td>${p.tractor || "未派车"}</td>
<td class="num">${fmtTon(p.planQty)} ${p.unit || unit}</td>
<td class="num">${fmtTon(deductQty(p))} ${p.unit || unit}</td>
<td>${planDeliverAt(p)}</td>
</tr>`).join("")}</tbody>
<tfoot><tr>
<td colspan="5">合计</td>
<td class="num">${fmtTon(sumPlan)} ${unit}</td>
<td class="num">${fmtTon(sumDone)} ${unit}</td>
<td></td>
</tr></tfoot>
</table>` : `<div class="empty">该车辆在此运输证下暂无关联计划。</div>`;
const dlg = document.getElementById("vehPlanDialog");
if (dlg.open) dlg.close();
dlg.showModal();
}
function closeVehPlanDlg() {
document.getElementById("vehPlanDialog").close();
}
function vehicleWarnCount(c) {
return vehicleRowsOf(c).filter((r) => r.level).length;
}
function certOver(c) {
if (remainGap(c) < -1e-6) return true;
return transOf(c).some((t) => transItemGap(t) < -1e-6);
}
function transWarn(c) { return transOf(c).some((t) => transItemWarn(t)); }
function transCount(c) { return transOf(c).length; }
function transNos(c) { return transOf(c).map((t) => t.transNo).filter(Boolean); }
function transDueMin(c) {
const dates = [c.buyDue, ...transOf(c).map((t) => t.transDue)].filter(Boolean);
return dates.length ? dates.reduce((a, b) => a < b ? a : b) : today;
}
function allDispatches(c) {
return transOf(c).flatMap((t) => (t.dispatches || []).map((d) => ({ ...d, transId: t.id, transNo: t.transNo, carrier: t.carrier })));
}
function buyWarn(c) {
return remainGap(c) < -1e-6 || certQtyWarn(c);
}
function sameCompanyCars(company) {
return carrierFleet.filter((v) => v.company === company);
}
function fieldsOf(root) {
const o = {};
if (!root) return o;
root.querySelectorAll("input, select, textarea").forEach((el) => {
if (el.name && el.type !== "file") o[el.name] = el.value;
});
return o;
}
function fillFields(root, data) {
if (!root || !data) return;
Object.entries(data).forEach(([k, v]) => {
const el = root.querySelector(`[name="${k}"]`);
if (el) el.value = v;
});
}
function vehicleWarnText(d, t, unit) {
const hold = vehicleHold(d.plate, t.id);
const cap = Number(d.qty || 0);
if (cap > 0 && hold - cap > 1e-6) return `计划 ${hold}${unit} 超过分派 ${cap}${unit}`;
if (cap <= 0 && hold > 1e-6) return `计划 ${hold}${unit},尚未分派该车`;
if (transItemWarn(t)) return `运输证余量 ≤${fmtTon(transWarnQty(t))}吨`;
return "";
}
function certVoided(c) { return !!(c && c.voided); }
function transVoided(t) { return !!(t && t.voided); }
function certSelectable(c) { return !!c && !certVoided(c); }
function transSelectable(c, t) { return certSelectable(c) && !!t && !transVoided(t); }
function openVoidCertDlg(kind, certId, transId) {
const c = certs.find((x) => x.id === certId);
if (!c) return;
if (kind === "buy") {
if (certVoided(c)) { toast("该购买证已核销"); return; }
voidCertTarget = { kind, certId };
document.getElementById("voidCertTitle").textContent = "核销购买证";
document.getElementById("voidCertHint").textContent =
`购买证 ${c.buyNo} 当前余量 ${fmtTon(remain(c))}${c.unit || "吨"}。核销后不可再关联运输计划,已关联在途计划不受影响。`;
} else {
const t = findTrans(c, transId);
if (!t) return;
if (transVoided(t)) { toast("该运输证已核销"); return; }
voidCertTarget = { kind, certId, transId };
document.getElementById("voidCertTitle").textContent = "核销运输证";
document.getElementById("voidCertHint").textContent =
`运输证 ${t.transNo} 当前余量 ${fmtTon(transItemRemain(t))}${c.unit || "吨"}。核销后不可再关联运输计划,已关联在途计划不受影响。`;
}
document.getElementById("voidCertDialog").showModal();
}
function closeVoidCertDlg() {
document.getElementById("voidCertDialog").close();
voidCertTarget = null;
}
function confirmVoidCert() {
const tgt = voidCertTarget;
if (!tgt) return;
const c = certs.find((x) => x.id === tgt.certId);
if (!c) return;
if (tgt.kind === "buy") {
c.voided = true;
c.voidedAt = today;
c.voidQty = remain(c);
toast(`购买证 ${c.buyNo} 已核销,不可再关联`);
} else {
const t = findTrans(c, tgt.transId);
if (!t) return;
t.voided = true;
t.voidedAt = today;
t.voidQty = transItemRemain(t);
toast(`运输证 ${t.transNo} 已核销,不可再关联`);
}
closeVoidCertDlg();
refreshCertViews();
}
function certStatusOf(c) {
if (certVoided(c)) return "已核销";
const r = remainGap(c);
const tr = transRemain(c);
const due = daysTo(transDueMin(c));
if (certOver(c)) return "超证";
if (r <= 0 && tr <= 0) return "已用尽";
if (vehicleWarnCount(c)) return "车辆预警";
if (transWarn(c)) return "运输证预警";
if (due <= 15) return "即将到期";
if (buyWarn(c)) return "余量预警";
return "有余量";
}
function certStHtml(s) {
if (s === "有余量") return `<span class="st st-run">${s}</span>`;
if (s === "余量预警" || s === "即将到期") return `<span class="st st-fix">${s}</span>`;
if (s === "运输证预警") return `<span class="st st-due">运输证预警</span>`;
if (s === "车辆预警") return `<span class="st st-due">车辆计划预警</span>`;
if (s === "超证") return `<span class="st st-due">超证</span>`;
return `<span class="st st-due">${s}</span>`;
}
function remainHtml(c) {
const g = remainGap(c);
const r = remain(c);
const cls = g < -1e-6 ? "bad" : (r <= 0 ? "bad" : (buyWarn(c) || transWarn(c) ? "warn" : ""));
return `<span class="remain ${cls}">${g < -1e-6 ? g.toFixed(2) : r} ${c.unit || "吨"}</span><div class="muted">预警 ${fmtTon(buyWarnQty(c))}${c.unit || "吨"}</div>`;
}
function transRemainHtml(c) {
const gaps = transOf(c).map((t) => transItemGap(t));
const over = gaps.reduce((s, g) => s + Math.min(0, g), 0);
const r = transRemain(c);
const cls = over < -1e-6 || r <= 0 ? "bad" : (transWarn(c) ? "warn" : "");
if (over < -1e-6) return `<span class="remain ${cls}">超 ${(-over).toFixed(2)} ${c.unit || "吨"}</span>`;
const th = transOf(c).map((t) => transWarnQty(t));
const uniq = [...new Set(th.map((n) => fmtTon(n)))];
return `<span class="remain ${cls}">${r} ${c.unit || "吨"}</span><div class="muted">预警 ${uniq.join(" / ")}${c.unit || "吨"}</div>`;
}
function certWarnCount() {
return certs.filter((c) => ["余量预警", "运输证预警", "车辆预警", "即将到期", "已用尽", "超证"].includes(certStatusOf(c))).length;
}
function updateBadges() {
const needLink = plans.filter((p) => p.ctrl && !p.certId && p.status !== "已完成" && p.status !== "已作废").length;
const waitPrecursor = plans.filter(isWaitPrecursorPlan).length;
document.getElementById("planBadge").textContent = waitPrecursor || needLink;
document.getElementById("planBadge").style.display = (waitPrecursor || needLink) ? "" : "none";
const waitArch = plans.filter((p) => p.status === "已完成" && !p.archived).length;
const ab = document.getElementById("archiveBadge");
if (ab) ab.textContent = waitArch;
document.getElementById("certBadge").textContent = certWarnCount();
const expN = expenses.filter((x) => ["待财务初审", "待终审", "待付款", "待归档", "已驳回"].includes(x.status)).length
+ preExps.filter((p) => p.status === "待财务初审" || p.status === "待批准").length;
document.getElementById("expBadge").textContent = expN;
const tb = document.getElementById("taskBadge");
if (tb) tb.textContent = TASKS.filter((t) => t.status === "待执行" || t.status === "执行中").length;
const cb = document.getElementById("ctBadge");
if (cb) {
const n = contracts.filter((x) => x.status === "待审批" || x.status === "草稿").length;
cb.textContent = n;
cb.style.display = n ? "" : "none";
}
}
const WH_TREE = [
{
id: "h2so4", name: "硫酸库", code: "WH-H2SO4", dot: "acid", cap: 3600, unit: "吨",
manager: "刘洋", area: "高新库区酸罐区",
ops: ["采购入库", "销货出库", "稀释加工", "精制加工"],
desc: "98% 工业硫酸可直销出库、加水稀释。试剂硫酸单独成线。工业 / 试剂分罐,不得混兑。",
children: [
{ id: "h2so4-98", name: "98%工业硫酸库", code: "WH-H2SO4-98", cap: 720, unit: "吨", manager: "刘洋", area: "V0701A V0701B V0701C V0701D V070E V0701F", kind: "raw", line: "industrial", ops: ["采购入库", "销货出库", "稀释加工", "精制加工"], desc: "98%工业硫酸库原料罐:V0701A、V0701B、V0701C、V0701D、V070E、V0701F。可直销出库,可稀释加工成 30%、50% 或其他定制浓度。" },
{ id: "h2so4-sul", name: "硫磺酸库", code: "WH-H2SO4-S", cap: 80, unit: "吨", manager: "刘洋", area: "V0702C", kind: "raw", line: "industrial", ops: ["采购入库", "销货出库", "稀释加工", "精制加工"], desc: "98%硫磺酸存放于 V0702C。可直销出库,也可稀释加工。" },
{ id: "h2so4-rg", name: "试剂硫酸库", code: "WH-H2SO4-RG", cap: 400, unit: "吨", manager: "刘洋", area: "V0702A V0702B V0705B", kind: "raw", line: "reagent", ops: ["采购入库", "销货出库", "稀释加工", "精制加工"], desc: "试剂硫酸库原料罐:V0702A、V0702B、V0705B。可直销出库,也可稀释加工成 30%、50% 试剂酸或其他定制浓度。不与工业酸混用。" },
{ id: "h2so4-30", name: "30%工业硫酸库", code: "WH-H2SO4-30", cap: 800, unit: "吨", manager: "郑浩", area: "V0705A", kind: "product", line: "both", ops: ["销货出库"], desc: "30% 工业硫酸存放于 V0705A。试剂酸分罐存放,可分别出库。" },
{ id: "h2so4-50", name: "50%工业硫酸库", code: "WH-H2SO4-50", cap: 1000, unit: "吨", manager: "郑浩", area: "V0704", kind: "product", line: "both", ops: ["销货出库"], desc: "50% 工业硫酸存放于 V0704。50% 试剂酸存放于 V0301A、V0301B,分罐出库。" },
{ id: "h2so4-custom", name: "定制浓度库", code: "WH-H2SO4-DX", cap: 600, unit: "吨", manager: "郑浩", area: "成品罐区 3#", kind: "product", line: "both", ops: ["销货出库"], desc: "承接订单定制浓度(如 40%、70%、80% 等)。工业酸、试剂酸分罐。" }
]
},
{ id: "naoh", name: "液碱库", code: "WH-NAOH", dot: "alkali", cap: 450, unit: "吨", manager: "陈伟", area: "32%液碱 V0801 V0802 V0803", ops: ["进货入库", "销货出库"], desc: "32% 液碱存放于 V0801、V0802、V0803。", children: [
{ id: "naoh-32", name: "32%液碱库", code: "WH-NAOH-32", cap: 450, unit: "吨", manager: "陈伟", area: "V0801 V0802 V0803", kind: "product", ops: ["进货入库", "销货出库"], desc: "32% 液碱存放于 V0801、V0802、V0803。" }
] },
{ id: "hcl", name: "盐酸库", code: "WH-HCL", dot: "hcl", cap: 520, unit: "吨", manager: "刘洋", area: "盐酸罐区", ops: ["采购入库", "销货出库"], desc: "盐酸库分仓:31% 工业盐酸、31% 合成盐酸、36% 分析纯盐酸。", children: [
{ id: "hcl-31-ind", name: "31% 工业盐酸库", code: "WH-HCL-31I", cap: 200, unit: "吨", manager: "刘洋", area: "V0501 V0502", kind: "product", ops: ["采购入库", "销货出库"], desc: "31% 工业盐酸存放于 V0501、V0502。" },
{ id: "hcl-31", name: "31% 合成盐酸库", code: "WH-HCL-31", cap: 200, unit: "吨", manager: "刘洋", area: "V0601 V0602", kind: "product", ops: ["采购入库", "销货出库"], desc: "31% 合成盐酸存放于 V0601、V0602。" },
{ id: "hcl-36", name: "36% 分析纯盐酸库", code: "WH-HCL-36", cap: 120, unit: "吨", manager: "刘洋", area: "V0901 V0902", kind: "product", ops: ["采购入库", "销货出库"], desc: "36% 分析纯盐酸存放于 V0901、V0902。" }
] },
{
id: "pack", name: "分装库", code: "WH-PACK", dot: "pack", cap: 400, unit: "吨", manager: "胡明", area: "分装车间库",
ops: ["形态转换入库", "销货出库"], desc: "分装成品按品号管理。",
children: [
{ id: "pack-s98-25", name: "S-98-25PL", code: "S-98-25PL", cap: 40, unit: "吨", manager: "胡明", area: "分装库", desc: "98%工业硫酸 · 25kg塑桶" },
{ id: "pack-s98-5", name: "S-98-5PL", code: "S-98-5PL", cap: 20, unit: "吨", manager: "胡明", area: "分装库", desc: "98%工业硫酸 · 5kg塑桶" },
{ id: "pack-cp97-25", name: "CP-97-25PL", code: "CP-97-25PL", cap: 30, unit: "吨", manager: "胡明", area: "分装库", desc: "试剂硫酸 97% · 25kg塑桶" },
{ id: "pack-cp97-5", name: "CP-97-5PL", code: "CP-97-5PL", cap: 15, unit: "吨", manager: "胡明", area: "分装库", desc: "试剂硫酸 97% · 5kg塑桶" },
{ id: "pack-h31", name: "H-31-25PL", code: "H-31-25PL", cap: 30, unit: "吨", manager: "胡明", area: "分装库", desc: "31%盐酸 · 25kg塑桶" },
{ id: "pack-ah36", name: "AH-36-25PL", code: "AH-36-25PL", cap: 25, unit: "吨", manager: "胡明", area: "分装库", desc: "36%醋酸 · 25kg塑桶" },
{ id: "pack-c10", name: "C-10-25PL", code: "C-10-25PL", cap: 30, unit: "吨", manager: "胡明", area: "分装库", desc: "10%次氯酸钠 · 25kg塑桶" },
{ id: "pack-c13", name: "C-13-25PL", code: "C-13-25PL", cap: 25, unit: "吨", manager: "胡明", area: "分装库", desc: "13%次氯酸钠 · 25kg塑桶" }
]
},
{
id: "solid", name: "固体库", code: "WH-SOLID", dot: "solid", cap: 600, unit: "吨", manager: "吴涛", area: "固体危化库",
ops: ["进货入库", "销货出库"], desc: "固体库按货源分仓:片碱库(君正)、片碱库(红四方)、纯碱库(红四方)。",
children: [
{ id: "solid-jz", name: "片碱库(君正)", code: "WH-SOLID-JZ", cap: 200, unit: "吨", manager: "吴涛", area: "固体库 · 君正片碱区", ops: ["进货入库", "销货出库"], desc: "存放君正货源片碱。" },
{ id: "solid-hsf", name: "片碱库(红四方)", code: "WH-SOLID-HSF", cap: 200, unit: "吨", manager: "吴涛", area: "固体库 · 红四方片碱区", ops: ["进货入库", "销货出库"], desc: "存放红四方货源片碱。" },
{ id: "solid-soda", name: "纯碱库(红四方)", code: "WH-SOLID-SODA", cap: 200, unit: "吨", manager: "吴涛", area: "固体库 · 红四方纯碱区", ops: ["进货入库", "销货出库"], desc: "存放红四方货源纯碱。" }
]
}
];
const WH_STOCK = [
{ wh: "h2so4-98", name: "98%工业硫酸", grade: "industrial", conc: 98, qty: 326.0, cost: 410.0, unit: "吨", loc: "V0701A / V0701B / V0701C / V0701D / V070E / V0701F", tankCap: 480, tanks: [
{ id: "V0701A", qty: 58, cap: 80 }, { id: "V0701B", qty: 54, cap: 80 }, { id: "V0701C", qty: 52, cap: 80 },
{ id: "V0701D", qty: 56, cap: 80 }, { id: "V070E", qty: 50, cap: 80 }, { id: "V0701F", qty: 56, cap: 80 }
] },
{ wh: "h2so4-sul", name: "98%硫磺酸", grade: "industrial", conc: 98, qty: 42.0, cost: 390.0, unit: "吨", loc: "V0702C", tankCap: 80, tanks: [
{ id: "V0702C", qty: 42, cap: 80 }
] },
{ wh: "h2so4-rg", name: "试剂硫酸", grade: "reagent", conc: 98, qty: 86.5, cost: 1280.0, unit: "吨", loc: "V0702A / V0702B / V0705B", tankCap: 240, tanks: [
{ id: "V0702A", qty: 32, cap: 80 }, { id: "V0702B", qty: 30, cap: 80 }, { id: "V0705B", qty: 24.5, cap: 80 }
] },
{ wh: "h2so4-30", name: "30%工业硫酸", grade: "industrial", conc: 30, qty: 186.4, cost: 168.0, unit: "吨", loc: "V0705A", tankCap: 400, tanks: [
{ id: "V0705A", qty: 186.4, cap: 400 }
] },
{ wh: "h2so4-30", name: "30%试剂酸", grade: "reagent", conc: 30, qty: 42.0, cost: 460.0, unit: "吨", loc: "30%罐 2#", tankCap: 200 },
{ wh: "h2so4-50", name: "50%工业硫酸", grade: "industrial", conc: 50, qty: 312.8, cost: 248.6, unit: "吨", loc: "V0704", tankCap: 600, tanks: [
{ id: "V0704", qty: 312.8, cap: 600 }
] },
{ wh: "h2so4-50", name: "50%试剂酸", grade: "reagent", conc: 50, qty: 58.0, cost: 720.0, unit: "吨", loc: "V0301A / V0301B", tankCap: 250, tanks: [
{ id: "V0301A", qty: 30, cap: 125 }, { id: "V0301B", qty: 28, cap: 125 }
] },
{ wh: "h2so4-custom", name: "40%工业硫酸", grade: "industrial", conc: 40, qty: 36.0, cost: 210.0, unit: "吨", loc: "定制罐 1#", tankCap: 300 },
{ wh: "h2so4-custom", name: "70%试剂酸", grade: "reagent", conc: 70, qty: 18.0, cost: 980.0, unit: "吨", loc: "定制罐 2#", tankCap: 200 },
{ wh: "h2so4-custom", name: "32%工业硫酸", grade: "industrial", conc: 32, qty: 24.0, cost: 185.0, unit: "吨", loc: "定制罐 3#", tankCap: 200 },
{ wh: "hcl-31-ind", name: "31%工业盐酸", conc: 31, qty: 118.0, cost: 185.0, unit: "吨", loc: "V0501 / V0502", tankCap: 200, tanks: [
{ id: "V0501", qty: 62, cap: 100 }, { id: "V0502", qty: 56, cap: 100 }
] },
{ wh: "hcl-31", name: "31%合成盐酸", conc: 31, qty: 86.0, cost: 220.0, unit: "吨", loc: "V0601 / V0602", tankCap: 200, tanks: [
{ id: "V0601", qty: 44, cap: 100 }, { id: "V0602", qty: 42, cap: 100 }
] },
{ wh: "hcl-36", name: "36%分析纯盐酸", conc: 36, qty: 28.0, cost: 860.0, unit: "吨", loc: "V0901 / V0902", tankCap: 120, tanks: [
{ id: "V0901", qty: 16, cap: 60 }, { id: "V0902", qty: 12, cap: 60 }
] },
{ wh: "naoh-32", name: "32%液碱", qty: 420.0, cost: 980.0, unit: "吨", loc: "V0801 / V0802 / V0803", tankCap: 450, tanks: [
{ id: "V0801", qty: 145, cap: 150 }, { id: "V0802", qty: 140, cap: 150 }, { id: "V0803", qty: 135, cap: 150 }
] },
{ wh: "pack-s98-25", sku: "S-98-25PL", name: "98%工业硫酸", packSpec: "25kg塑桶", qty: 12.6, cost: 520.0, unit: "吨", loc: "S-98-25PL", tankCap: 40 },
{ wh: "pack-s98-5", sku: "S-98-5PL", name: "98%工业硫酸", packSpec: "5kg塑桶", qty: 4.8, cost: 560.0, unit: "吨", loc: "S-98-5PL", tankCap: 20 },
{ wh: "pack-cp97-25", sku: "CP-97-25PL", name: "试剂硫酸 97%", packSpec: "25kg塑桶", qty: 8.2, cost: 1380.0, unit: "吨", loc: "CP-97-25PL", tankCap: 30 },
{ wh: "pack-cp97-5", sku: "CP-97-5PL", name: "试剂硫酸 97%", packSpec: "5kg塑桶", qty: 2.4, cost: 1460.0, unit: "吨", loc: "CP-97-5PL", tankCap: 15 },
{ wh: "pack-h31", sku: "H-31-25PL", name: "31%盐酸", packSpec: "25kg塑桶", qty: 9.5, cost: 280.0, unit: "吨", loc: "H-31-25PL", tankCap: 30 },
{ wh: "pack-ah36", sku: "AH-36-25PL", name: "36%醋酸", packSpec: "25kg塑桶", qty: 6.1, cost: 420.0, unit: "吨", loc: "AH-36-25PL", tankCap: 25 },
{ wh: "pack-c10", sku: "C-10-25PL", name: "10%次氯酸钠", packSpec: "25kg塑桶", qty: 7.3, cost: 310.0, unit: "吨", loc: "C-10-25PL", tankCap: 30 },
{ wh: "pack-c13", sku: "C-13-25PL", name: "13%次氯酸钠", packSpec: "25kg塑桶", qty: 5.8, cost: 340.0, unit: "吨", loc: "C-13-25PL", tankCap: 25 },
{ wh: "solid-jz", name: "片碱", supplier: "君正", qty: 48.0, cost: 3050.0, unit: "吨", loc: "片碱库(君正)" },
{ wh: "solid-hsf", name: "片碱", supplier: "红四方", qty: 37.0, cost: 3120.0, unit: "吨", loc: "片碱库(红四方)" },
{ wh: "solid-soda", name: "纯碱", supplier: "红四方", qty: 52.0, cost: 1680.0, unit: "吨", loc: "纯碱库(红四方)" }
];
const WH_LEDGER = [
{ date: "2026-09-29", doc: "形态转换单", no: "ZX-20260929-004", wh: "h2so4-98", product: "98%工业硫酸", inQty: 0, inCost: 0, outQty: 10.2, outCost: 4182, balQty: 346.8, price: 410.0, balCost: 142188, note: "加水制成 50%工业硫酸 20 吨" },
{ date: "2026-09-29", doc: "形态转换单", no: "ZX-20260929-004", wh: "h2so4-50", product: "50%工业硫酸", inQty: 20.0, inCost: 4182, outQty: 0, outCost: 0, balQty: 312.8, price: 248.6, balCost: 77762, note: "由 98%工业硫酸加水" },
{ date: "2026-09-28", doc: "进货单", no: "JH-20260928-014", wh: "h2so4-98", product: "98%工业硫酸", partner: "湖北兴发化工集团股份有限公司", inQty: 64.2, inCost: 26322, outQty: 0, outCost: 0, balQty: 336.2, price: 410.0, balCost: 137842 },
{ date: "2026-09-28", doc: "销货单", no: "XH-20260928-031", wh: "h2so4-98", product: "98%工业硫酸", partner: "安徽海康药业股份有限公司", inQty: 0, inCost: 0, outQty: 32.0, outCost: 13120, balQty: 272.0, price: 410.0, balCost: 111520, note: "原料酸直接出库" },
{ date: "2026-09-27", doc: "形态转换单", no: "ZX-20260927-003", wh: "h2so4-rg", product: "试剂硫酸", inQty: 0, inCost: 0, outQty: 6.12, outCost: 7834, balQty: 86.5, price: 1280.0, balCost: 110720, note: "加工成 30%试剂酸 20 吨" },
{ date: "2026-09-27", doc: "形态转换单", no: "ZX-20260927-003", wh: "h2so4-30", product: "30%试剂酸", inQty: 20.0, inCost: 7834, outQty: 0, outCost: 0, balQty: 42.0, price: 460.0, balCost: 19320, note: "由试剂硫酸加工" },
{ date: "2026-09-26", doc: "进货单", no: "JH-20260926-011", wh: "h2so4-rg", product: "试剂硫酸", partner: "湖北兴发化工集团股份有限公司", inQty: 18.0, inCost: 23040, outQty: 0, outCost: 0, balQty: 92.6, price: 1280.0, balCost: 118528 },
{ date: "2026-09-26", doc: "形态转换单", no: "ZX-20260926-002", wh: "h2so4-98", product: "98%工业硫酸", inQty: 0, inCost: 0, outQty: 8.16, outCost: 3346, balQty: 304.0, price: 410.0, balCost: 124640, note: "加水制成 40%工业硫酸 20 吨" },
{ date: "2026-09-26", doc: "形态转换单", no: "ZX-20260926-002", wh: "h2so4-custom", product: "40%工业硫酸", inQty: 20.0, inCost: 3346, outQty: 0, outCost: 0, balQty: 36.0, price: 210.0, balCost: 7560, note: "定制浓度" },
{ date: "2026-09-25", doc: "销货单", no: "XH-20260925-019", wh: "h2so4-50", product: "50%工业硫酸", partner: "合肥衡工环境科技有限公司", inQty: 0, inCost: 0, outQty: 28.0, outCost: 6961, balQty: 292.8, price: 248.6, balCost: 72790 },
{ date: "2026-09-25", doc: "形态转换单", no: "ZX-20260925-002", wh: "h2so4-98", product: "98%工业硫酸", inQty: 0, inCost: 0, outQty: 8.0, outCost: 4160, balQty: 318.0, price: 410.0, balCost: 130380, note: "分装 25kg塑桶" },
{ date: "2026-09-25", doc: "形态转换单", no: "ZX-20260925-002", wh: "pack-s98-25", product: "S-98-25PL", inQty: 8.0, inCost: 4160, outQty: 0, outCost: 0, balQty: 12.6, price: 520.0, balCost: 6552, note: "98%工业硫酸分装 25kg塑桶" },
{ date: "2026-09-24", doc: "形态转换单", no: "ZX-20260924-004", wh: "h2so4-98", product: "98%工业硫酸", inQty: 0, inCost: 0, outQty: 6.12, outCost: 2510, balQty: 297.9, price: 410.0, balCost: 122139, note: "加水制成 30%工业硫酸 20 吨" },
{ date: "2026-09-24", doc: "形态转换单", no: "ZX-20260924-004", wh: "h2so4-30", product: "30%工业硫酸", inQty: 20.0, inCost: 2510, outQty: 0, outCost: 0, balQty: 186.4, price: 168.0, balCost: 31315, note: "由 98%工业硫酸加水" },
{ date: "2026-09-23", doc: "形态转换单", no: "ZX-20260923-007", wh: "h2so4-rg", product: "试剂硫酸", inQty: 0, inCost: 0, outQty: 4.0, outCost: 5120, balQty: 82.5, price: 1280.0, balCost: 105600, note: "分装 25kg塑桶" },
{ date: "2026-09-23", doc: "形态转换单", no: "ZX-20260923-007", wh: "pack-cp97-25", product: "CP-97-25PL", inQty: 4.0, inCost: 5120, outQty: 0, outCost: 0, balQty: 8.2, price: 1380.0, balCost: 11316, note: "试剂硫酸分装 25kg塑桶" },
{ date: "2026-09-24", doc: "进货单", no: "JH-20260924-009", wh: "naoh-32", product: "32%液碱", partner: "内蒙古君正能源化工集团股份有限公司", inQty: 58.0, inCost: 56840, outQty: 0, outCost: 0, balQty: 420.0, price: 980.0, balCost: 411600 },
{ date: "2026-09-24", doc: "销货单", no: "XH-20260924-022", wh: "naoh-32", product: "32%液碱", partner: "安徽海康药业股份有限公司", inQty: 0, inCost: 0, outQty: 26.5, outCost: 25970, balQty: 420.0, price: 980.0, balCost: 411600, note: "V0801 / V0802 / V0803" },
{ date: "2026-09-23", doc: "其他入库单", no: "QR-20260923-004", wh: "solid-jz", product: "片碱", partner: "内蒙古君正能源化工集团股份有限公司", inQty: 12.0, inCost: 36600, outQty: 0, outCost: 0, balQty: 48.0, price: 3050.0, balCost: 146400, note: "君正货源" },
{ date: "2026-09-23", doc: "进货单", no: "JH-20260923-008", wh: "solid-hsf", product: "片碱", partner: "安徽红四方股份有限公司", inQty: 10.0, inCost: 31200, outQty: 0, outCost: 0, balQty: 37.0, price: 3120.0, balCost: 115440, note: "红四方货源" },
{ date: "2026-09-22", doc: "材料出库单", no: "CL-20260922-005", wh: "naoh-32", product: "32%液碱", inQty: 0, inCost: 0, outQty: 15.0, outCost: 14700, balQty: 362.0, price: 980.0, balCost: 354760 },
{ date: "2026-09-22", doc: "形态转换单", no: "ZX-20260922-001", wh: "hcl-31", product: "31%合成盐酸", inQty: 0, inCost: 0, outQty: 2.5, outCost: 550, balQty: 83.5, price: 220.0, balCost: 18370, note: "分装 25kg塑桶" },
{ date: "2026-09-22", doc: "形态转换单", no: "ZX-20260922-001", wh: "pack-h31", product: "H-31-25PL", inQty: 2.5, inCost: 700, outQty: 0, outCost: 0, balQty: 9.5, price: 280.0, balCost: 2660, note: "31%合成盐酸分装 25kg塑桶" },
{ date: "2026-09-20", doc: "形态转换单", no: "ZX-20260920-008", wh: "hcl-31-ind", product: "31%工业盐酸", inQty: 0, inCost: 0, outQty: 3.0, outCost: 555, balQty: 115.0, price: 185.0, balCost: 21275, note: "分装 25kg塑桶" },
{ date: "2026-09-20", doc: "形态转换单", no: "ZX-20260920-008", wh: "pack-h31", product: "H-31-25PL", inQty: 3.0, inCost: 840, outQty: 0, outCost: 0, balQty: 9.5, price: 280.0, balCost: 2660, note: "31%工业盐酸分装 25kg塑桶" },
{ date: "2026-09-21", doc: "其他出库单", no: "QC-20260921-002", wh: "solid-soda", product: "纯碱", inQty: 0, inCost: 0, outQty: 3.0, outCost: 5040, balQty: 52.0, price: 1680.0, balCost: 87360, note: "红四方纯碱出库" },
{ date: "2026-09-20", doc: "销货单", no: "XH-20260920-018", wh: "h2so4-rg", product: "试剂硫酸", partner: "合肥衡工环境科技有限公司", inQty: 0, inCost: 0, outQty: 8.0, outCost: 10240, balQty: 80.6, price: 1280.0, balCost: 103168, note: "原料试剂酸直接出库" },
{ date: "2026-09-22", doc: "进货单", no: "JH-20260922-006", wh: "hcl-31-ind", product: "31%工业盐酸", partner: "铜陵锦帆物资有限责任公司", inQty: 28.0, inCost: 5180, outQty: 0, outCost: 0, balQty: 118.0, price: 185.0, balCost: 21830 },
{ date: "2026-09-21", doc: "销货单", no: "XH-20260921-016", wh: "hcl-31", product: "31%合成盐酸", partner: "合肥衡工环境科技有限公司", inQty: 0, inCost: 0, outQty: 18.0, outCost: 3960, balQty: 86.0, price: 220.0, balCost: 18920 },
{ date: "2026-09-19", doc: "进货单", no: "JH-20260919-005", wh: "hcl-36", product: "36%分析纯盐酸", partner: "湖北兴发化工集团股份有限公司", inQty: 8.0, inCost: 6880, outQty: 0, outCost: 0, balQty: 28.0, price: 860.0, balCost: 24080 },
];
let currentWh = "h2so4";
let currentWhDoc = "库存成本明细账";
let currentWhView = "info";
let whDocSeq = 10;
const WH_PROC_PARENTS = ["h2so4", "hcl", "pack", "naoh"];
const WH_PROCESS = [
{ no: "JG-20260929-001", date: "2026-09-29", mode: "dilute", rawName: "98%工业硫酸", rawWh: "h2so4-98", rawQty: 10.2, water: 9.8, product: "50%工业硫酸", toWh: "h2so4-50", outQty: 20, finIn: 20 },
{ no: "JG-20260927-002", date: "2026-09-27", mode: "dilute", rawName: "试剂硫酸", rawWh: "h2so4-rg", rawQty: 6.12, water: 13.88, product: "30%试剂酸", toWh: "h2so4-30", outQty: 20, finIn: 20 },
{ no: "JG-20260926-003", date: "2026-09-26", mode: "dilute", rawName: "98%工业硫酸", rawWh: "h2so4-98", rawQty: 8.16, water: 11.84, product: "40%工业硫酸", toWh: "h2so4-custom", outQty: 20, finIn: 20 },
{ no: "JG-20260924-004", date: "2026-09-24", mode: "dilute", rawName: "98%工业硫酸", rawWh: "h2so4-98", rawQty: 6.12, water: 13.88, product: "30%工业硫酸", toWh: "h2so4-30", outQty: 20, finIn: 20 },
{ no: "JG-20260925-005", date: "2026-09-25", mode: "convert", rawName: "98%工业硫酸", rawWh: "h2so4-98", rawQty: 8.0, water: 0, product: "S-98-25PL", toWh: "pack-s98-25", outQty: 8.0, finIn: 8.0 },
{ no: "JG-20260923-007", date: "2026-09-23", mode: "convert", rawName: "试剂硫酸", rawWh: "h2so4-rg", rawQty: 4.0, water: 0, product: "CP-97-25PL", toWh: "pack-cp97-25", outQty: 4.0, finIn: 4.0 },
{ no: "JG-20260922-006", date: "2026-09-22", mode: "convert", rawName: "31%合成盐酸", rawWh: "hcl-31", rawQty: 2.5, water: 0, product: "H-31-25PL", toWh: "pack-h31", outQty: 2.5, finIn: 2.5 },
{ no: "JG-20260920-008", date: "2026-09-20", mode: "convert", rawName: "31%工业盐酸", rawWh: "hcl-31-ind", rawQty: 3.0, water: 0, product: "H-31-25PL", toWh: "pack-h31", outQty: 3.0, finIn: 3.0 }
];
function whIdsOf(id) {
const node = WH_TREE.find((w) => w.id === id);
if (node) return [id, ...node.children.map((c) => c.id)];
return [id];
}
function whNameOf(id) {
for (const w of WH_TREE) {
if (w.id === id) return w.name;
const c = w.children.find((x) => x.id === id);
if (c) return w.name + " / " + c.name;
}
return id;
}
function warehouseSelectHtml(selected) {
return WH_TREE.map((w) => {
if (w.children.length) {
return `<optgroup label="${w.name}">${w.children.map((c) =>
`<option value="${c.id}" ${selected === c.id ? "selected" : ""}>${c.name}</option>`
).join("")}</optgroup>`;
}
return `<option value="${w.id}" ${selected === w.id ? "selected" : ""}>${w.name}</option>`;
}).join("");
}
function planTypeName(p) {
return p.type === "入直单" ? "入库单" : p.type;
}
function isInboundPlan(p) {
return p.type === "入直单" || p.type === "入库单";
}
function isDirectSale(p) {
return p.type === "直销单";
}
function warehouseForPlan(p) {
if (isDirectSale(p)) return "";
if (p.archived && p.warehouseId) return p.warehouseId;
return suggestWarehouse(p.product, p.spec) || p.warehouseId || "";
}
function parseConc(text) {
const m = String(text || "").match(/(\d+(?:\.\d+)?)\s*[%%]/);
return m ? Number(m[1]) : null;
}
function acidGradeOf(text) {
const p = String(text || "");
if (p.includes("试剂") || p.includes("精制")) return "reagent";
return "industrial";
}
function gradeName(g) {
return g === "reagent" ? "试剂酸" : "工业酸";
}
function suggestWarehouse(product, spec) {
const p = `${product || ""}${spec || ""}`.replace(/\s/g, "");
if (p.includes("分析纯") && p.includes("盐酸") || (p.includes("36%") && p.includes("盐酸") && !/塑桶|醋酸/.test(p))) return "hcl-36";
if (p.includes("合成盐酸")) return "hcl-31";
if (p.includes("工业盐酸") || (p.includes("31%") && p.includes("盐酸") && !/塑桶|H-31/.test(p))) return "hcl-31-ind";
if (p.includes("烧碱") || p.includes("液碱")) return "naoh-32";
if (p.includes("纯碱")) return "solid-soda";
if (p.includes("片碱") && (p.includes("君正") || p.includes("JZ"))) return "solid-jz";
if (p.includes("片碱") && (p.includes("红四方") || p.includes("HSF"))) return "solid-hsf";
if (p.includes("片碱")) return "solid";
if (/S-98|CP-97|H-31|AH-36|C-10|C-13|盐酸|醋酸|次氯|塑桶/.test(p)) return "pack";
if (p.includes("硫磺酸")) return "h2so4-sul";
const isAcid = /硫酸|试剂酸|硫磺酸/.test(p);
if (!isAcid) return "";
const conc = parseConc(p);
const grade = acidGradeOf(p);
if (conc != null) {
if (conc >= 95) {
if (grade === "reagent") return "h2so4-rg";
return "h2so4-98";
}
if (Math.abs(conc - 30) < 0.6) return "h2so4-30";
if (Math.abs(conc - 50) < 0.6) return "h2so4-50";
return "h2so4-custom";
}
if (grade === "reagent") return "h2so4-rg";
return "h2so4-98";
}
function warehouseCell(p) {
if (isDirectSale(p)) return `<span class="muted">无中转仓库</span>`;
const wh = warehouseForPlan(p);
if (!wh) return `<span class="muted">未匹配品名</span>`;
const stock = matchStock(wh, `${p.product || ""}${p.spec || ""}`);
const tank = stock ? `<div class="muted">${stock.loc}${stock.grade ? " · " + gradeName(stock.grade) : ""}</div>` : "";
return `<b>${whNameOf(wh)}</b>${tank}`;
}
function stockActCell(p) {
if (isDirectSale(p)) return `<span class="wh-act none">不记库存</span>`;
if (isInboundPlan(p)) return `<span class="wh-act in">入库 · 进货</span>`;
return `<span class="wh-act out">出库 · 销货</span>`;
}
function gradeHtml(s) {
if (s.grade === "industrial") return `<span class="grade ind">工业酸</span>`;
if (s.grade === "reagent") return `<span class="grade rg">试剂酸</span>`;
return "";
}
function matchStock(whId, product) {
const list = WH_STOCK.filter((s) => s.wh === whId);
if (!list.length) return null;
const compact = String(product || "").replace(/\s/g, "");
const exact = list.find((s) => {
const n = s.name.replace(/\s/g, "");
return compact.includes(n) || n.includes(compact);
});
if (exact) return exact;
const conc = parseConc(compact);
const grade = acidGradeOf(compact);
if (list.some((s) => s.grade)) {
return list.find((s) => s.grade === grade && (conc == null || Number(s.conc) === conc))
|| list.find((s) => s.grade === grade && conc != null && Math.abs(Number(s.conc) - conc) < 0.6)
|| list.find((s) => s.grade === grade)
|| list.find((s) => conc != null && Number(s.conc) === conc)
|| list[0];
}
if (conc != null) {
const byc = list.find((s) => parseConc(s.name) === conc);
if (byc) return byc;
}
return list[0];
}
function assignTankLoc(wh, grade) {
const map = {
"h2so4-30": { industrial: "V0705A", reagent: "30%罐 2#" },
"h2so4-50": { industrial: "V0704", reagent: "V0301A / V0301B" },
"h2so4-custom": { industrial: "定制罐 1#", reagent: "定制罐 2#" },
"h2so4-98": { industrial: "V0701A" },
"h2so4-sul": { industrial: "V0702C" },
"h2so4-rg": { reagent: "V0702A" }
};
return (map[wh] && map[wh][grade]) || "储罐";
}
function stockTankRows(s) {
if (s.tanks && s.tanks.length) {
return s.tanks.map((t) => ({
id: t.id,
qty: t.qty,
cap: t.cap || 80,
note: t.note || "",
label: t.note ? `${t.id}(${t.note})` : t.id
}));
}
return [{ id: s.loc, qty: s.qty, cap: s.tankCap || 200, note: "", label: s.loc }];
}
function tankChipList(stock) {
const labels = [];
stock.forEach((s) => {
stockTankRows(s).forEach((t) => {
String(t.label || "").split(/\s*[/、]\s*/).forEach((part) => {
const v = part.trim();
if (v && !labels.includes(v)) labels.push(v);
});
});
});
const numbered = labels.filter((x) => /V\d/i.test(x));
const rest = labels.filter((x) => !/V\d/i.test(x));
return numbered.concat(rest);
}
function tankGroupHtml(whId, stock) {
const chip = (id) => `<span class="chip ghost">${id}</span>`;
const all = whId === "h2so4";
const groups = [];
if (all || whId === "h2so4-98") {
groups.push(["98%工业硫酸库", ["V0701A", "V0701B", "V0701C", "V0701D", "V070E", "V0701F"]]);
}
if (all || whId === "h2so4-sul") {
groups.push(["硫磺酸库", ["V0702C"]]);
}
if (all || whId === "h2so4-rg") {
groups.push(["试剂硫酸库", ["V0702A", "V0702B", "V0705B"]]);
}
if (all || whId === "h2so4-30") {
groups.push(["30%工业硫酸库", ["V0705A"]]);
}
if (all || whId === "h2so4-50") {
groups.push(["50%工业硫酸库", ["V0704"]]);
groups.push(["50%试剂酸", ["V0301A", "V0301B"]]);
}
if (whId === "hcl" || whId === "hcl-31-ind") {
groups.push(["31% 工业盐酸库", ["V0501", "V0502"]]);
}
if (whId === "hcl" || whId === "hcl-31") {
groups.push(["31% 合成盐酸库", ["V0601", "V0602"]]);
}
if (whId === "hcl" || whId === "hcl-36") {
groups.push(["36% 分析纯盐酸库", ["V0901", "V0902"]]);
}
if (whId === "naoh" || whId === "naoh-32") {
groups.push(["32%液碱库", ["V0801", "V0802", "V0803"]]);
}
if (all === false && (whId === "pack" || String(whId).startsWith("pack"))) {
groups.push(["分装品号", ["S-98-25PL", "S-98-5PL", "CP-97-25PL", "CP-97-5PL", "H-31-25PL", "AH-36-25PL", "C-10-25PL", "C-13-25PL"]]);
}
if (whId === "solid" || String(whId).startsWith("solid-")) {
const solidBins = [];
if (whId === "solid" || whId === "solid-jz") solidBins.push("片碱库(君正)");
if (whId === "solid" || whId === "solid-hsf") solidBins.push("片碱库(红四方)");
if (whId === "solid" || whId === "solid-soda") solidBins.push("纯碱库(红四方)");
groups.push(["固体分仓", solidBins]);
}
if (!groups.length) {
const flat = tankChipList(stock);
return flat.length ? `<div class="wh-tank">${flat.map(chip).join("")}</div>` : "";
}
return groups.map(([lab, ids]) => `
<div class="tank-group">
<div class="tg-lab">${lab}</div>
<div class="wh-tank">${ids.map(chip).join("")}</div>
</div>`).join("");
}
function ensureStock(whId, product) {
let stock = matchStock(whId, product);
const compact = String(product || "").replace(/\s/g, "");
const conc = parseConc(compact);
const grade = acidGradeOf(compact);
if (stock && stock.grade && conc != null && Number(stock.conc) !== conc && Math.abs(Number(stock.conc) - conc) >= 0.6) {
stock = null;
}
if (stock) return stock;
const name = acidProductName(grade, conc == null ? 98 : conc, { wh: whId });
stock = {
wh: whId, name, grade, conc: conc == null ? 98 : conc, qty: 0, cost: 0, unit: "吨",
loc: assignTankLoc(whId, grade), tankCap: 200
};
WH_STOCK.push(stock);
return stock;
}
function whMeta(id) {
for (const w of WH_TREE) {
if (w.id === id) return w;
const c = w.children.find((x) => x.id === id);
if (c) return { ...c, parent: w.name, dot: w.dot };
}
return { name: id, cap: 0, unit: "吨" };
}
function stockOf(ids) {
return WH_STOCK.filter((s) => ids.includes(s.wh));
}
function ledgerOf(ids) {
return WH_LEDGER.filter((r) => ids.includes(r.wh));
}
function fmtT(n) {
return Number(n).toLocaleString("zh-CN", { maximumFractionDigits: 1 });
}
function fmtY(n) {
return Number(n).toLocaleString("zh-CN", { maximumFractionDigits: 0 });
}
function whLineLabel(line) {
if (line === "industrial") return `<span class="grade ind">工业酸</span>`;
if (line === "reagent") return `<span class="grade rg">试剂酸</span>`;
if (line === "both") return `<span class="grade ind">工业酸</span> <span class="grade rg">试剂酸</span>`;
return "";
}
function whKindLabel(kind) {
if (kind === "raw") return `<span class="grade raw">原料库</span>`;
if (kind === "product") return `<span class="grade fin">成品库</span>`;
return `<span class="grade fin">仓库</span>`;
}
function whOpsHtml(ops) {
return (ops || []).map((o) => `<span class="op-chip ${o.includes("出库") ? "out" : ""}">${o}</span>`).join("");
}
function parentWhOf(id) {
const node = WH_TREE.find((w) => w.id === id);
if (node) return node;
return WH_TREE.find((w) => w.children.some((c) => c.id === id));
}
function renderWhNav() {
const parent = parentWhOf(currentWh);
const tree = currentWhView === "proc" ? WH_TREE.filter((w) => WH_PROC_PARENTS.includes(w.id)) : WH_TREE;
const mains = tree.map((w) => {
const on = parent?.id === w.id;
return `
<button class="wh-nav-item ${w.dot} ${on ? "on" : ""}" type="button" onclick="selectWarehouse('${w.id}')">
<span class="wh-dot ${w.dot}"></span>${w.name}
</button>`;
}).join("");
let subs = "";
if (parent?.children?.length && (currentWhView !== "proc" || WH_PROC_PARENTS.includes(parent.id))) {
subs = `<div class="wh-nav-subs"><span class="wh-nav-subs-lab">子仓库</span>${parent.children.map((c) => `
<button class="wh-nav-child ${parent.dot} ${currentWh === c.id ? "on" : ""}" type="button" onclick="selectWarehouse('${c.id}')">
<span class="wh-dot ${parent.dot}"></span>${c.name}
</button>`).join("")}</div>`;
}
document.getElementById("whNav").innerHTML = `<div class="wh-nav-mains"><span class="wh-nav-lab">仓库</span>${mains}</div>${subs}`;
}
function renderWhKpis() {
const ids = whIdsOf(currentWh);
const stock = stockOf(ids);
const qty = stock.reduce((a, s) => a + s.qty, 0);
const cost = stock.reduce((a, s) => a + s.qty * s.cost, 0);
const cap = ids.reduce((a, id) => {
const m = whMeta(id);
return a + (m.children ? 0 : (m.cap || 0));
}, 0) || (whMeta(currentWh).cap || 0);
const use = cap ? Math.min(100, Math.round(qty / cap * 100)) : 0;
const inQty = ledgerOf(ids).reduce((a, r) => a + r.inQty, 0);
const outQty = ledgerOf(ids).reduce((a, r) => a + r.outQty, 0);
const low = stock.filter((s) => s.qty < 80);
document.getElementById("whKpis").innerHTML = `
<div class="wh-kpi"><div class="k">结存数量</div><div class="v">${fmtT(qty)}</div><div class="s">${whMeta(currentWh).unit || "吨"} · ${stock.length} 个品名</div></div>
<div class="wh-kpi"><div class="k">结存成本</div><div class="v">${fmtY(cost)}</div><div class="s">元 · 加权平均</div></div>
<div class="wh-kpi"><div class="k">库容占用</div><div class="v">${use}%</div><div class="s">${fmtT(qty)} / ${fmtT(cap)} 吨</div><div class="wh-cap"><i class="${use >= 85 ? "warn" : ""}" style="width:${use}%"></i></div></div>
<div class="wh-kpi"><div class="k">本期入 / 出</div><div class="v">${fmtT(inQty)}<span style="font-size:14px;color:var(--muted);font-weight:500"> / ${fmtT(outQty)}</span></div><div class="s">${low.length ? low.map((s) => s.name + "偏低").join(" · ") : "库存水位正常"}</div></div>`;
}
function fmtN(n, d) {
return Number(n).toLocaleString("zh-CN", { minimumFractionDigits: d, maximumFractionDigits: d });
}
function ledgerView(r) {
const inQty = Number(r.inQty || 0);
const outQty = Number(r.outQty || 0);
const inCost = Number(r.inCost || 0);
const outCost = Number(r.outCost || 0);
let docType = r.docType || r.doc;
if (r.doc === "形态转换单") docType = outQty ? "其他出库单" : "其他入库单";
if (r.doc === "组装单") docType = "其他入库单";
const bizMap = {
"销货单": "普通销售", "进货单": "采购入库", "材料出库单": "生产领料",
"其他出库单": "组装", "其他入库单": "组装", "形态转换单": "组装", "组装单": "组装",
"产成品入库单": "完工入库", "盘点单": "盘点"
};
return {
whName: whMeta(r.wh).name || whNameOf(r.wh),
product: r.product,
unit: r.unit || "吨",
no: r.no,
date: r.date,
postDate: r.postDate || r.date,
docType,
bizType: r.bizType || bizMap[r.doc] || bizMap[docType] || "",
partner: r.partner || "",
inQty, inCost, outQty, outCost,
inPrice: inQty ? inCost / inQty : 0,
outPrice: outQty ? outCost / outQty : 0,
balQty: Number(r.balQty || 0),
price: Number(r.price || 0),
balCost: Number(r.balCost || 0)
};
}
function renderWhTable() {
const ids = whIdsOf(currentWh);
const q = (document.getElementById("whQ").value || "").trim().toLowerCase();
const docSel = document.getElementById("whDocFilter").value;
let rows = ledgerOf(ids);
if (currentWhDoc && currentWhDoc !== "库存成本明细账") {
rows = rows.filter((r) => r.doc === currentWhDoc);
}
if (docSel) rows = rows.filter((r) => r.doc === docSel);
if (q) rows = rows.filter((r) => (r.no + r.product + r.doc + (r.partner || "") + (r.note || "")).toLowerCase().includes(q));
document.getElementById("whTableTitle").textContent = currentWhDoc === "库存成本明细账"
? "库存成本明细账 · " + whNameOf(currentWh)
: currentWhDoc + " · " + whNameOf(currentWh);
document.getElementById("whTableCount").textContent = rows.length + " 条";
if (currentWhDoc === "库存成本明细账") {
const views = rows.map(ledgerView);
const sumInQty = views.reduce((a, r) => a + r.inQty, 0);
const sumInAmt = views.reduce((a, r) => a + r.inCost, 0);
const sumOutQty = views.reduce((a, r) => a + r.outQty, 0);
const sumOutAmt = views.reduce((a, r) => a + r.outCost, 0);
const dash = `<td class="dash">—</td>`;
const qtyCell = (n, cls) => n ? `<td class="${cls}">${fmtT(n)}</td>` : dash;
const priceCell = (n, show) => show ? `<td>${fmtN(n, 2)}</td>` : dash;
const amtCell = (n, show) => show ? `<td class="amt">${fmtN(n, 2)}</td>` : dash;
document.getElementById("whTable").innerHTML = views.length ? `
<table class="proc-table wide">
<thead>
<tr>
<th>序号</th>
<th class="col-name">仓库名称</th>
<th class="col-name">物料名称</th>
<th>单号</th>
<th>日期</th>
<th>单据</th>
<th>业务</th>
<th>往来单位</th>
<th>入库量</th>
<th>入库单价</th>
<th>入库金额</th>
<th>出库量</th>
<th>出库单价</th>
<th>出库金额</th>
<th>结存量</th>
<th>结存单价</th>
<th>结存金额</th>
</tr>
</thead>
<tbody>${views.map((r, i) => `<tr>
<td>${i + 1}</td>
<td class="col-name">${r.whName}</td>
<td class="col-name"><b>${r.product}</b></td>
<td class="cell-no">${r.no}</td>
<td>${r.date}</td>
<td>${r.docType}</td>
<td>${r.bizType || "—"}</td>
<td>${r.partner || "—"}</td>
${qtyCell(r.inQty, "qty-in")}
${priceCell(r.inPrice, r.inQty)}
${amtCell(r.inCost, r.inQty)}
${qtyCell(r.outQty, "qty-out")}
${priceCell(r.outPrice, r.outQty)}
${amtCell(r.outCost, r.outQty)}
<td class="qty-bal">${fmtT(r.balQty)}</td>
<td>${fmtN(r.price, 2)}</td>
<td class="amt">${fmtN(r.balCost, 2)}</td>
</tr>`).join("")}</tbody>
<tfoot>
<tr>
<td colspan="8">合计</td>
<td class="qty-in">${fmtT(sumInQty)}</td>
<td class="dash">—</td>
<td class="amt">${fmtN(sumInAmt, 2)}</td>
<td class="qty-out">${fmtT(sumOutQty)}</td>
<td class="dash">—</td>
<td class="amt">${fmtN(sumOutAmt, 2)}</td>
<td class="dash">—</td>
<td class="dash">—</td>
<td class="dash">—</td>
</tr>
</tfoot>
</table>` : `<div class="empty">没有符合条件的明细。</div>`;
} else {
document.getElementById("whTable").innerHTML = rows.length ? `
<table class="proc-table">
<thead><tr><th>日期</th><th>单号</th><th>仓库</th><th>品名</th><th>入库</th><th>出库</th><th>金额</th><th>说明</th></tr></thead>
<tbody>${rows.map((r) => `<tr>
<td>${r.date}</td><td class="cell-no">${r.no}</td>
<td class="wh-path">${whNameOf(r.wh)}</td><td>${r.product}</td>
<td class="num">${r.inQty ? fmtT(r.inQty) : "—"}</td>
<td class="num">${r.outQty ? fmtT(r.outQty) : "—"}</td>
<td class="num">${fmtY(r.inCost || r.outCost)}</td>
<td class="muted">${r.note || "—"}</td>
</tr>`).join("")}</tbody>
</table>` : `<div class="empty">当前仓库没有「${currentWhDoc}」。</div>`;
}
}
function selectWarehouse(id) {
currentWh = id;
renderWarehouse();
}
function showWhView(view) {
currentWhView = view || "info";
document.querySelectorAll("#whSubnav button").forEach((b) => b.classList.toggle("on", b.dataset.view === currentWhView));
document.getElementById("view-wh-info").classList.toggle("hidden", currentWhView !== "info");
document.getElementById("view-wh-mode").classList.toggle("hidden", currentWhView !== "mode");
document.getElementById("view-wh-proc").classList.toggle("hidden", currentWhView !== "proc");
document.getElementById("view-wh-stock").classList.toggle("hidden", currentWhView !== "stock");
const titles = { info: "库存信息表", mode: "仓库信息表", proc: "加工单明细表", stock: "库存成本明细表" };
const descs = {
info: "查看当前仓库在库库存与储罐库存明细表。",
mode: "仓库档案与作业说明:出入库、稀释加工、精制加工。工业硫酸与试剂硫酸分线,不得混兑。",
proc: "仓库内加水 / 形态转换形成的加工明细。现场作业请用运输计划下发的任务单。",
stock: "按仓库查看库存成本明细账。稀释加工走形态转换:原料酸出库、成品入库。"
};
document.getElementById("whPageTitle").textContent = titles[currentWhView] || "库存信息表";
document.getElementById("whPageDesc").textContent = descs[currentWhView] || descs.info;
const acts = document.getElementById("whPageActions");
if (currentWhView === "stock") {
acts.innerHTML = `<button class="btn" type="button" onclick="whSelectDoc('形态转换单')">形态转换</button>
<button class="btn" type="button" onclick="whSelectDoc('盘点单')">盘点</button>`;
} else if (currentWhView === "mode") {
acts.innerHTML = "";
} else {
acts.innerHTML = `<button class="btn" type="button" onclick="showWhView('stock')">查看库存</button>`;
}
setCrumb("仓储管理", titles[currentWhView] || "库存信息表");
markNav(`[data-wh-view="${currentWhView}"]`);
document.querySelector('[data-group="warehouse"]')?.classList.add("open");
if (currentWhView === "proc") {
const p = parentWhOf(currentWh);
if (!WH_PROC_PARENTS.includes(p?.id)) {
currentWh = "h2so4";
renderWhNav();
renderWhProc();
} else {
renderWhNav();
}
} else {
renderWhNav();
}
}
function subWhDirHtml() {
const node = WH_TREE.find((w) => w.id === currentWh);
const children = node?.children || [];
if (!children.length) return "";
return `
<article class="card">
<div class="card-h"><h2>子库档案</h2><span class="muted">${children.length} 个仓</span></div>
<div class="card-b" style="overflow:auto">
<table class="wh-dir">
<thead><tr><th>仓库编码</th><th>名称</th><th>类型</th><th>产线</th><th>允许作业</th><th>结存</th><th>仓管</th></tr></thead>
<tbody>${children.map((c) => {
const qty = stockOf([c.id]).reduce((a, s) => a + s.qty, 0);
return `<tr class="row-link" onclick="selectWarehouse('${c.id}')">
<td class="cell-no">${c.code}</td>
<td><b>${c.name}</b></td>
<td>${whKindLabel(c.kind)}</td>
<td>${whLineLabel(c.line)}</td>
<td>${whOpsHtml(c.ops)}</td>
<td class="num">${fmtT(qty)}</td>
<td>${c.manager}</td>
</tr>`;
}).join("")}</tbody>
</table>
</div>
</article>`;
}
const INFO_STOCK_ITEMS = [
{ wh: "h2so4-98", name: "98%工业硫酸", tone: "tone-ind" },
{ wh: "h2so4-sul", name: "98%硫磺酸", tone: "tone-ind" },
{ wh: "h2so4-rg", name: "试剂硫酸", tone: "tone-rg" },
{ wh: "h2so4-50", name: "50%工业硫酸", tone: "tone-ind" },
{ wh: "h2so4-30", name: "30%工业硫酸", tone: "tone-ind" },
{ wh: "hcl-31", name: "31%合成盐酸", tone: "tone-hcl" }
];
function inStockCardHtml() {
const cards = INFO_STOCK_ITEMS.map((it) => {
const s = WH_STOCK.find((x) => x.wh === it.wh && x.name === it.name);
if (!s) return "";
const chips = stockTankRows(s).map((t) => `<span>${t.label}</span>`).join("");
return `
<article class="wh-mini ${it.tone}" onclick="selectWarehouse('${s.wh}')">
<div class="wh-mini-top">
<div class="wh-mini-name"><i></i><b>${s.name}</b></div>
<div class="qty">${fmtT(s.qty)}<span>${s.unit}</span></div>
</div>
<div class="tanks">${chips}</div>
</article>`;
}).join("");
return `<div class="wh-stock-cards">${cards}</div>`;
}
function renderWhInfo() {
const ids = whIdsOf(currentWh);
const html = `${inStockCardHtml()}${tankTableHtml(ids)}`;
document.getElementById("view-wh-info").innerHTML = html || `<article class="card"><div class="card-b"><div class="empty">该仓暂无库存明细。</div></div></article>`;
}
function renderWhProc() {
document.getElementById("view-wh-proc").innerHTML = processListHtml() + convertListHtml();
}
function renderWhMode() {
const isAcid = currentWh === "h2so4" || String(currentWh).startsWith("h2so4-");
const structHtml = isAcid ? `
<div class="wh-tree-box">
<div class="wh-branch">
<h4>原料库 · 可出库,也可加工</h4>
<button type="button" onclick="selectWarehouse('h2so4-98')"><span>98%工业硫酸库</span><span class="grade ind">工业酸</span></button>
<button type="button" onclick="selectWarehouse('h2so4-sul')"><span>硫磺酸库</span><span class="grade ind">工业酸</span></button>
<button type="button" onclick="selectWarehouse('h2so4-rg')"><span>试剂硫酸库</span><span class="grade rg">试剂酸</span></button>
</div>
<div class="wh-branch">
<h4>成品库 · 加水浓度</h4>
<button type="button" onclick="selectWarehouse('h2so4-30')"><span>30%工业硫酸库</span><span class="muted">V0705A</span></button>
<button type="button" onclick="selectWarehouse('h2so4-50')"><span>50%工业硫酸库</span><span class="muted">V0704</span></button>
<button type="button" onclick="selectWarehouse('h2so4-custom')"><span>定制浓度库</span><span class="muted">其他浓度</span></button>
</div>
</div>` : "";
document.getElementById("view-wh-mode").innerHTML = `
${subWhDirHtml()}
${isAcid ? `
<article class="card">
<div class="card-h"><h2>出入库 · 稀释加工 · 精制加工</h2><span class="grade raw">工业 / 试剂分线,不得混兑</span></div>
<div class="card-b">
<div class="wh-mode" style="margin-bottom:12px">
<h3>原料酸 · 98% 工业硫酸</h3>
<p>存放于 98%工业硫酸库。可以仓库出库直销;也可以稀释加工成 30%、50% 工业硫酸或其他定制浓度。工业酸不得与试剂酸混用。</p>
<div class="wh-proc">
<div class="box"><b>98%工业硫酸</b><span>98%工业硫酸库 · 可直销出库</span></div>
<div class="arr">→</div>
<div class="box"><b>稀释加工</b><span>硫酸质量守恒</span></div>
<div class="arr">→</div>
<div class="box"><b>30% / 50% / 定制</b><span>工业硫酸成品库</span></div>
</div>
</div>
<div class="wh-mode">
<h3>原料酸 · 试剂硫酸</h3>
<p>存放于试剂硫酸库。可以仓库出库直销;也可以稀释加工成 30%、50% 试剂酸,或其他定制浓度。试剂酸线独立储罐、独立转换,不与工业酸互兑。</p>
<div class="wh-proc">
<div class="box"><b>试剂硫酸</b><span>试剂硫酸库 · 可直销出库</span></div>
<div class="arr">→</div>
<div class="box"><b>稀释加工</b><span>浓度按订单定制</span></div>
<div class="arr">→</div>
<div class="box"><b>30% / 50% / 定制</b><span>试剂酸成品库</span></div>
</div>
</div>
${structHtml}
</div>
</article>` : ""}`;
}
function tankTableHtml(ids) {
const stocks = stockOf(ids).filter((s) => s.tanks || s.grade || s.sku || s.supplier || s.wh === "naoh" || s.wh === "naoh-32" || String(s.wh).startsWith("hcl") || s.wh === "pack" || String(s.wh).startsWith("solid"));
if (!stocks.length) return "";
const rows = [];
stocks.forEach((s) => {
stockTankRows(s).forEach((t) => rows.push({ s, t }));
});
return `<article class="card">
<div class="card-h"><h2>${ids.some((id) => id === "pack" || String(id).startsWith("solid")) ? "仓位 / 品号明细" : "储罐库存明细表"}</h2><span class="muted">${rows.length} 条</span></div>
<div class="card-b" style="overflow:auto">
<table>
<thead><tr><th>储罐位号</th><th>所属仓库</th><th>产线</th><th>储存品名</th><th>设计容积</th><th>现存量</th><th>占用</th><th>可收</th><th>状态</th></tr></thead>
<tbody>${rows.map(({ s, t }) => {
const cap = t.cap || s.tankCap || 80;
const use = cap ? Math.min(100, Math.round(t.qty / cap * 100)) : 0;
const room = Math.max(0, cap - t.qty);
const st = use >= 90 ? "将满" : t.qty < 15 ? "偏低" : "正常";
const cls = use >= 90 || t.qty < 15 ? "st-due" : "st-run";
return `<tr>
<td class="cell-no">${s.sku || t.label}</td>
<td class="wh-path">${whNameOf(s.wh)}</td>
<td>${gradeHtml(s) || (s.supplier ? `<span class="grade fin">${s.supplier}</span>` : "—")}</td>
<td class="wh-stock-name">${s.name}${s.packSpec ? " · " + s.packSpec : ""}${s.supplier ? "(" + s.supplier + ")" : ""}</td>
<td class="num">${fmtT(cap)} 吨</td>
<td class="num">${fmtT(t.qty)} 吨</td>
<td><div class="tank-use"><span class="bar"><i class="${use >= 90 ? "warn" : ""}" style="width:${use}%"></i></span>${use}%</div></td>
<td class="num">${fmtT(room)}</td>
<td><span class="st ${cls}">${st}</span></td>
</tr>`;
}).join("")}</tbody>
</table>
</div>
</article>`;
}
function processListHtml() {
const ids = whIdsOf(currentWh);
const docs = WH_PROCESS.filter((d) => ids.includes(d.rawWh) || ids.includes(d.toWh));
const sum = (key) => docs.reduce((a, d) => a + Number(d[key] || 0), 0);
const finSum = docs.reduce((a, d) => a + Number(d.finIn != null ? d.finIn : d.outQty || 0), 0);
const waterSum = docs.reduce((a, d) => a + Number(d.mode === "convert" ? 0 : d.water || 0), 0);
const body = docs.length ? docs.map((d, i) => {
const kind = d.mode === "convert" ? "形态转换" : "稀释加工";
return `<tr>
<td>${String(i + 1).padStart(2, "0")}</td>
<td>${d.date}</td>
<td class="cell-no">${d.no}</td>
<td>${kind}</td>
<td class="cell-no">${d.planNo || "—"}</td>
<td class="col-name">${whNameOf(d.rawWh)}</td>
<td>${d.rawName}</td>
<td>${fmtT(d.rawQty)}</td>
<td>${d.mode === "convert" ? "—" : fmtT(d.water || 0)}</td>
<td class="col-name">${whNameOf(d.toWh)}</td>
<td>${d.product}</td>
<td>${fmtT(d.outQty)}</td>
<td>${fmtT(d.finIn != null ? d.finIn : d.outQty)}</td>
</tr>`;
}).join("") : `<tr><td colspan="13" class="muted">暂无加工单。</td></tr>`;
const foot = docs.length ? `<tfoot><tr>
<td colspan="7">合计</td>
<td>${fmtT(sum("rawQty"))}</td>
<td>${fmtT(waterSum)}</td>
<td colspan="2"></td>
<td>${fmtT(sum("outQty"))}</td>
<td>${fmtT(finSum)}</td>
</tr></tfoot>` : "";
return `<article class="card">
<div class="card-h"><h2>加工单明细表</h2><span class="muted">${docs.length} 张</span></div>
<div class="card-b" style="overflow:auto">
<table class="proc-table wide">
<thead><tr>
<th>序号</th>
<th>加工日期</th>
<th>加工单号</th>
<th>类型</th>
<th>来源计划</th>
<th>投料仓库</th>
<th>投料品名</th>
<th>投料量(t)</th>
<th>加水量(t)</th>
<th>产出仓库</th>
<th>产出品名</th>
<th>产出数量(t)</th>
<th>产成品入库(t)</th>
</tr></thead>
<tbody>${body}</tbody>
${foot}
</table>
</div>
</article>`;
}
function convertListHtml() {
const ids = whIdsOf(currentWh);
const rows = WH_LEDGER.filter((r) => r.doc === "形态转换单" && ids.includes(r.wh));
const body = rows.length ? rows.map((r, i) => `<tr>
<td>${String(i + 1).padStart(2, "0")}</td>
<td>${r.date}</td>
<td class="cell-no">${r.no}</td>
<td class="col-name">${whNameOf(r.wh)}</td>
<td>${r.product}</td>
<td>${r.inQty ? fmtT(r.inQty) : "—"}</td>
<td>${r.outQty ? fmtT(r.outQty) : "—"}</td>
<td>${r.note || "—"}</td>
</tr>`).join("") : `<tr><td colspan="8" class="muted">暂无形态转换。</td></tr>`;
return `<article class="card">
<div class="card-h"><h2>形态转换明细</h2><span class="muted">${rows.length} 笔</span></div>
<div class="card-b" style="overflow:auto">
<table class="proc-table wide">
<thead><tr>
<th>序号</th>
<th>日期</th>
<th>转换单号</th>
<th>仓库名称</th>
<th>物料名称</th>
<th>入库(t)</th>
<th>出库(t)</th>
<th>说明</th>
</tr></thead>
<tbody>${body}</tbody>
</table>
</div>
</article>`;
}
function acidTargetWh(pct) {
if (pct === 30) return "h2so4-30";
if (pct === 50) return "h2so4-50";
return "h2so4-custom";
}
function acidProductName(grade, pct, raw) {
if (raw && raw.wh === "h2so4-sul" && pct === 98) return "98%硫磺酸";
if (grade === "reagent") return pct === 98 ? "试剂硫酸" : pct + "%试剂酸";
return pct === 98 ? "98%工业硫酸" : pct + "%工业硫酸";
}
function acidRaws() {
return WH_STOCK.filter((s) => s.wh === "h2so4-98" || s.wh === "h2so4-sul" || s.wh === "h2so4-rg");
}
function fillAcidTargets(raw, keep) {
const sel = document.getElementById("acidForm").target;
sel.innerHTML = `<option value="30">加水 · 30%</option>
<option value="50">加水 · 50%</option>
<option value="custom">加水 · 其他定制浓度</option>`;
if (keep && [...sel.options].some((o) => o.value === keep)) sel.value = keep;
else sel.value = "50";
}
function openAcidDlg() {
const form = document.getElementById("acidForm");
const raws = acidRaws();
const prefer = currentWh === "h2so4-rg" ? "h2so4-rg" : currentWh === "h2so4-sul" ? "h2so4-sul" : "h2so4-98";
form.rawId.innerHTML = raws.map((s) =>
`<option value="${s.wh}|${s.name}" ${s.wh === prefer ? "selected" : ""}>${s.name} · 结存 ${fmtT(s.qty)} 吨</option>`
).join("");
form.outQty.value = "20";
form.customPct.value = "";
onAcidRawChange();
document.getElementById("acidDlg").showModal();
}
function closeAcidDlg() { document.getElementById("acidDlg").close(); }
function onAcidRawChange() {
const form = document.getElementById("acidForm");
const [wh, name] = (form.rawId.value || "").split("|");
const raw = WH_STOCK.find((s) => s.wh === wh && s.name === name);
fillAcidTargets(raw, form.target.value);
onAcidTarget();
}
function onAcidTarget() {
const custom = document.getElementById("acidForm").target.value === "custom";
document.getElementById("acidCustomRow").style.display = custom ? "" : "none";
updateAcidCalc();
}
function acidCalc() {
const form = document.getElementById("acidForm");
const [wh, name] = (form.rawId.value || "").split("|");
const raw = WH_STOCK.find((s) => s.wh === wh && s.name === name);
if (!raw) return null;
const tgt = form.target.value;
const outQty = Number(form.outQty.value);
if (!outQty || outQty <= 0) return { raw, error: "请填写成品数量" };
const pct = tgt === "custom" ? Number(form.customPct.value) : Number(tgt);
if (!pct || pct <= 0 || pct >= raw.conc) return { raw, error: "请填写有效目标浓度(须低于原料浓度)" };
const rawQty = +(outQty * pct / raw.conc).toFixed(3);
const water = +(outQty - rawQty).toFixed(3);
return {
raw, mode: "dilute", pct, outQty, rawQty, water, loss: 0,
toWh: acidTargetWh(pct), product: acidProductName(raw.grade, pct, raw), grade: raw.grade,
loc: assignTankLoc(acidTargetWh(pct), raw.grade),
unitCost: +(raw.cost * raw.conc / pct).toFixed(2)
};
}
function updateAcidCalc() {
const c = acidCalc();
const box = document.getElementById("acidCalc");
if (!c) { box.textContent = "请选择原料酸。"; return; }
if (c.error) { box.textContent = c.error; return; }
box.innerHTML = `加水稀释:消耗 <b>${c.raw.name} ${fmtT(c.rawQty)} 吨</b>(库存 ${fmtT(c.raw.qty)})+ 工艺水 <b>${fmtT(c.water)} 吨</b>
→ 得到 <b>${c.product} ${fmtT(c.outQty)} 吨</b>,入 <b>${whNameOf(c.toWh)}</b>。产线(工业 / 试剂)保持不变。`;
}
function saveAcidConvert() {
const c = acidCalc();
if (!c || c.error) { toast(c?.error || "请完整填写"); return; }
if (c.rawQty > c.raw.qty + 1e-9) { toast(`原料酸不足,仅剩 ${fmtT(c.raw.qty)} 吨`); return; }
c.raw.qty = +(c.raw.qty - c.rawQty).toFixed(3);
let fin = WH_STOCK.find((s) => s.wh === c.toWh && s.name === c.product);
if (!fin) {
fin = { wh: c.toWh, name: c.product, grade: c.grade, conc: c.pct, qty: 0, cost: c.unitCost, unit: "吨", loc: c.loc };
WH_STOCK.push(fin);
}
fin.qty = +(fin.qty + c.outQty).toFixed(3);
const no = "ZX-" + today.replace(/-/g, "") + "-" + String(++whDocSeq).padStart(3, "0");
const cost = c.rawQty * c.raw.cost;
const noteOut = `稀释加工成 ${c.product} ${fmtT(c.outQty)} 吨`;
const noteIn = `由 ${c.raw.name} 加水 / 加工`;
WH_LEDGER.unshift({
date: today, doc: "形态转换单", no, wh: c.raw.wh, product: c.raw.name,
inQty: 0, inCost: 0, outQty: c.rawQty, outCost: cost,
balQty: c.raw.qty, price: c.raw.cost, balCost: c.raw.qty * c.raw.cost,
note: noteOut
});
WH_LEDGER.unshift({
date: today, doc: "形态转换单", no, wh: c.toWh, product: c.product,
inQty: c.outQty, inCost: cost, outQty: 0, outCost: 0,
balQty: fin.qty, price: fin.cost, balCost: fin.qty * fin.cost,
note: noteIn
});
WH_PROCESS.unshift({
no, date: today, batch: "TL-" + today.replace(/-/g, "") + "-" + String(whDocSeq).padStart(3, "0"),
mode: "dilute", rawName: c.raw.name, rawWh: c.raw.wh, rawQty: c.rawQty, rawConc: c.raw.conc,
water: c.water, loss: c.loss || 0, product: c.product, toWh: c.toWh, outQty: c.outQty,
finIn: c.outQty, diluteBal: fin.qty, grade: c.grade, loc: c.loc
});
closeAcidDlg();
toast(`已转换:${c.raw.name} ${fmtT(c.rawQty)} 吨 → ${c.product} ${fmtT(c.outQty)} 吨`);
renderWarehouse();
}
function whSelectDoc(doc) {
if (doc === "要货单") {
toast("要货单未启用,不进入库存成本明细账");
return;
}
currentWhDoc = doc;
const sel = document.getElementById("whDocFilter");
sel.value = doc === "库存成本明细账" ? "" : doc;
renderWhTable();
}
function onWhDocFilter() {
const v = document.getElementById("whDocFilter").value;
currentWhDoc = v || "库存成本明细账";
renderWhTable();
}
function renderWarehouse() {
renderWhNav();
renderWhInfo();
renderWhMode();
renderWhProc();
renderWhKpis();
renderWhTable();
showWhView(currentWhView);
}
function setCrumb(parent, child) {
document.getElementById("crumbMod").textContent = parent;
const tail = document.getElementById("crumbTail");
if (!child || child === parent) {
tail.classList.add("hidden");
} else {
tail.classList.remove("hidden");
document.getElementById("crumb").textContent = child;
}
}
function markNav(sel) {
document.querySelectorAll(".side-item").forEach((b) => b.classList.remove("active"));
const el = document.querySelector(sel);
if (!el) return;
el.classList.add("active");
el.closest(".nav-group")?.classList.add("open");
}
function hideAllMods() {
["mod-placeholder", "mod-product", "mod-quote", "mod-contract", "mod-fleet", "mod-plan", "mod-cert", "mod-expense", "mod-duty", "mod-staff", "mod-warehouse", "mod-archive", "mod-task"].forEach((id) => {
document.getElementById(id).classList.add("hidden");
});
}
function showPage(page) {
const meta = PLACE_PAGES[page];
if (!meta) return;
currentMod = page;
hideAllMods();
document.getElementById("mod-placeholder").classList.remove("hidden");
document.getElementById("phTitle").textContent = meta.title;
document.getElementById("phDesc").textContent = meta.desc;
document.getElementById("phCardTitle").textContent = meta.title;
document.getElementById("phComing").textContent = meta.coming || "当前演示重点在运输计划与购买证管理。";
setCrumb(meta.parent, meta.parent === meta.title ? "" : meta.title);
markNav(`[data-page="${page}"]`);
}
function showMod(mod) {
currentMod = mod;
hideAllMods();
const box = document.getElementById("mod-" + mod);
if (box) box.classList.remove("hidden");
markNav(`[data-mod="${mod}"]`);
if (mod === "fleet") {
showView(currentFleetView || "etc");
return;
}
if (mod === "product") {
setCrumb("CRM管理", "产品管理");
renderProducts();
}
if (mod === "quote") {
setCrumb("CRM管理", "报价单管理");
renderQuotes();
}
if (mod === "contract") {
setCrumb("CRM管理", "合同管理");
renderContracts();
}
if (mod === "plan") {
setCrumb("运输管理", "运输计划");
renderPlans();
}
if (mod === "cert") {
setCrumb("运输管理", "购买证管理");
showCertView(currentCertId ? "detail" : "wait");
}
if (mod === "expense") {
setCrumb("人事管理", "报销管理");
renderExpense();
}
if (mod === "duty") {
setCrumb("人事管理", "值班管理");
renderDuty();
}
if (mod === "staff") {
setCrumb("人事管理", "员工档案");
renderStaff();
}
if (mod === "warehouse") {
renderWarehouse();
return;
}
if (mod === "archive") {
setCrumb("运输管理", "运输归档");
renderArchive();
}
if (mod === "task") {
setCrumb("运输管理", "任务单管理");
renderTasks();
}
}
function daysBetween(a, b) {
return Math.round((new Date(b) - new Date(a)) / 86400000);
}
function expMoney(n) {
return "¥" + Number(n || 0).toLocaleString("zh-CN", { minimumFractionDigits: 2 });
}
function expFinalRole(x) {
return "总经理";
}
function expOverdue(x) {
if (!x.date || ["已归档", "已驳回"].includes(x.status)) return false;
return daysBetween(x.date, today) > 45;
}
function expBudgetLeft(dept) {
const b = EXP_BUDGET[dept] || { total: 0, used: 0 };
return b.total - b.used;
}
function expOpenLoan(user) {
return expLoans.find((l) => l.user === user && l.status === "未清" && l.remain > 0);
}
function expStatusPill(st) {
const map = {
"草稿": "st-stop", "已驳回": "st-due", "待财务初审": "st-fix", "待批准": "st-fix",
"待终审": "st-fix", "待付款": "st-run", "待归档": "st-run", "已归档": "st-stop",
"待批准": "st-fix", "已批准": "st-run"
};
const label = st === "待终审" ? "待领导审核" : st;
return `<span class="st ${map[st] || "st-stop"}">${label}</span>`;
}
function expLog(x, who, act, note) {
x.logs = x.logs || [];
const hh = new Date().getHours().toString().padStart(2, "0");
const mm = new Date().getMinutes().toString().padStart(2, "0");
x.logs.push({ t: today + " " + hh + ":" + mm, who, act, note: note || "" });
}
function expById(id) { return expenses.find((x) => x.id === id); }
function kindTag(k) {
const sq = k === "备用金" || k === "冲备用金";
return `<span class="kind-tag ${sq ? "sq" : "bx"}">${k}</span>`;
}
function prePending(p) { return p.status === "待财务初审" || p.status === "待批准"; }
function renderExpense() {
const flowEl = document.getElementById("expFlow");
flowEl.classList.remove("hidden");
const bxDraft = expenses.filter((x) => x.status === "草稿" || x.status === "已驳回").length;
const bxFin = expenses.filter((x) => x.status === "待财务初审").length;
const bxLead = expenses.filter((x) => x.status === "待终审").length;
const bxPay = expenses.filter((x) => x.status === "待付款").length;
const bxArch = expenses.filter((x) => x.status === "待归档").length;
const sqFin = preExps.filter(prePending).length;
const sqOk = preExps.filter((p) => p.status === "已批准").length;
const sqMine = preExps.length;
const bxOn = (key) => {
if (key === "我的报销") return expTab === "我的报销";
if (key === "待财务初审") return expTab === "待财务初审" && expFinKind !== "备用金";
return expTab === key;
};
const sqOn = (key) => {
if (key === "申请") return expTab === "备用金申请";
if (key === "待财务初审") return expTab === "待财务初审" && expFinKind !== "报销";
if (key === "冲销") return expTab === "借款";
return false;
};
const expCell = (it, last) => {
if (it.ghost) {
return `<div class="exp-cell ghost${last ? " last" : ""}"><span class="t"><b>${it.name}</b><em>${it.hint}</em></span></div>`;
}
return `<button class="exp-cell ${it.on ? "on" : ""}${last ? " last" : ""}" type="button" onclick="${it.click}">
<span class="t"><b>${it.name}</b><em>${it.hint}</em></span>
<span class="c">${it.n}</span>
</button>`;
};
const bxCells = [
{ hint: "贴发票填报", name: "经办填报", n: bxDraft, on: bxOn("我的报销"), click: "setExpTab('我的报销')" },
{ hint: "核发票预算", name: "财务核票", n: bxFin, on: bxOn("待财务初审"), click: "setExpTab('待财务初审','报销')" },
{ hint: "总经理审", name: "领导审核", n: bxLead, on: bxOn("待终审"), click: "setExpTab('待终审')" },
{ hint: "打款或冲销", name: "出纳付款", n: bxPay, on: bxOn("待付款"), click: "setExpTab('待付款')" },
{ hint: "凭证装订", name: "制单归档", n: bxArch, on: bxOn("待归档"), click: "setExpTab('待归档')" }
].map((it, i, a) => expCell(it, i === a.length - 1)).join("");
const sqCells = [
{ hint: "无票先借", name: "提出申请", n: sqMine, on: sqOn("申请"), click: "setExpTab('备用金申请')" },
{ hint: "核事由额度", name: "财务核额", n: sqFin, on: sqOn("待财务初审"), click: "setExpTab('待财务初审','备用金')" },
{ hint: "通过后可支", name: "批准待支", n: sqOk, on: expTab === "备用金申请" && !sqFin, click: "setExpTab('备用金申请')" },
{ hint: "未清余额", name: "报销冲销", n: expLoans.filter((l) => l.status === "未清").length, on: sqOn("冲销"), click: "setExpTab('借款')" },
{ ghost: true, name: "汇入报销", hint: "贴票后走上线" }
].map((it, i, a) => expCell(it, i === a.length - 1)).join("");
flowEl.innerHTML = `
<div class="exp-map">
<div class="exp-map-grid">
<div class="exp-map-head">
<div></div>
<div class="exp-colh"><b>1</b>发起</div>
<div class="exp-colh"><b>2</b>财务初审</div>
<div class="exp-colh"><b>3</b>审用</div>
<div class="exp-colh"><b>4</b>资金</div>
<div class="exp-colh"><b>5</b>收口</div>
</div>
<div class="exp-map-row bx">
<div class="exp-map-lab"><b>报销</b><span>事后凭票</span></div>
${bxCells}
</div>
<div class="exp-map-row sq">
<div class="exp-map-lab"><b>备用金</b><span>事前借款</span></div>
${sqCells}
</div>
</div>
</div>`;
const overdue = expenses.filter(expOverdue);
const rejectN = expenses.filter((x) => x.status === "已驳回").length;
const uncleared = expLoans.filter((l) => l.status === "未清").length;
document.getElementById("expAlerts").innerHTML = `
<div class="alert ${overdue.length ? "red" : "ok"}">逾期未结 ${overdue.length} 笔(业务结束超 45 天)</div>
<div class="alert ${rejectN ? "red" : "ok"}">待补资料重提 ${rejectN} 笔</div>
<div class="alert ${uncleared ? "" : "ok"}">未清借款 ${uncleared} 笔 · 前款不清后款不借</div>`;
const hints = {
"全部": "报销凭发票事后结算;备用金是借款核额后先支出事后凭票报销冲销。",
"我的报销": "经办人工作台:贴发票填报。若已有备用金,结算选冲销,勿再走一笔新付款。",
"备用金申请": "事前申请,财务初审通过后方可支出,事后必须凭票报销冲销。",
"待财务初审": "报销看发票,通过后去领导审核;备用金只核额度,通过后即可支出。",
"待终审": "仅报销单。总经理可同意或写明原因驳回。备用金不经领导审核。",
"待付款": "仅报销单。审批通过后出纳对公支付或打个人卡;有备用金/借款则冲销。",
"待归档": "仅报销单。会计录入凭证,报销单、发票、附件装订归档。",
"借款": "备用金/借款台账。报销时选择冲销;未结清不得再借。"
};
document.getElementById("expHint").textContent = hints[expTab] || "";
const bar = document.getElementById("expToolbar");
if (expTab === "备用金申请") {
bar.innerHTML = `<button class="btn btn-primary" type="button" onclick="openPreExpDlg()">+ 备用金申请</button>
<input id="expQ" type="search" placeholder="申请号 / 申请人 / 事项" oninput="renderExpense()" />`;
renderPreExpTable();
return;
}
if (expTab === "借款") {
bar.innerHTML = `<span class="muted">报销结算选「冲销借款」后在此核销余额</span>
<input id="expQ" type="search" placeholder="借款号 / 经办人" oninput="renderExpense()" />`;
renderLoanTable();
return;
}
if (expTab === "待财务初审") {
const nBx = expenses.filter((x) => x.status === "待财务初审").length;
const nSq = preExps.filter(prePending).length;
bar.innerHTML = `
<div class="plan-tabs" id="expFinTabs">
<button class="${!expFinKind ? "on" : ""}" type="button" onclick="setExpTab('待财务初审','')">全部 ${nBx + nSq}</button>
<button class="${expFinKind === "报销" ? "on" : ""}" type="button" onclick="setExpTab('待财务初审','报销')">报销 ${nBx}</button>
<button class="${expFinKind === "备用金" ? "on" : ""}" type="button" onclick="setExpTab('待财务初审','备用金')">备用金 ${nSq}</button>
</div>
<span class="split"></span>
<input id="expQ" type="search" placeholder="单号 / 经办人 / 事由" oninput="renderFinanceReview()" />`;
renderFinanceReview();
return;
}
bar.innerHTML = `
<button class="btn btn-primary" type="button" onclick="openExpenseDlg()">+ 填报报销单</button>
<button class="btn" type="button" onclick="openPreExpDlg()">备用金申请</button>
<span class="split"></span>
<input id="expQ" type="search" placeholder="单号 / 经办人 / 部门 / 事由" oninput="renderExpenseList()" />
<select id="expSt" onchange="renderExpenseList()">
<option value="">全部状态</option>
<option>草稿</option><option>待财务初审</option>
<option value="待终审">待领导审核</option><option>待付款</option><option>待归档</option>
<option>已归档</option><option>已驳回</option>
</select>`;
renderExpenseList();
}
function setExpTab(tab, kind) {
if (tab === "pre") tab = "备用金申请";
expTab = tab;
expFinKind = tab === "待财务初审" ? (kind || "") : "";
document.querySelectorAll("#expTabs button").forEach((b) => b.classList.toggle("on", b.dataset.tab === tab));
renderExpense();
}
function renderExpenseList() {
const qEl = document.getElementById("expQ");
const stEl = document.getElementById("expSt");
const q = (qEl?.value || "").trim();
const st = stEl?.value || "";
let rows = expenses.slice();
if (expTab === "我的报销") rows = rows.filter((x) => ["草稿", "已驳回"].includes(x.status) || x.user === "周建军");
else if (["待财务初审", "待终审", "待付款", "待归档"].includes(expTab)) rows = rows.filter((x) => x.status === expTab);
if (st) rows = rows.filter((x) => x.status === st);
if (q) rows = rows.filter((x) => [x.no, x.user, x.dept, x.type, x.remark, x.payWay].join(" ").includes(q));
document.getElementById("expCount").textContent = `共 ${rows.length} 条`;
document.getElementById("expTable").innerHTML = rows.length ? `<table class="erp-table" style="min-width:1180px">
<thead><tr><th>单号</th><th>经办人</th><th>部门</th><th>类别</th><th>金额</th><th>发生日</th><th>票据</th><th>结算</th><th>终审角色</th><th>状态</th></tr></thead>
<tbody>${rows.map((x) => `<tr>
<td class="cell-no row-link" onclick="openExpDetail(${x.id})">${kindTag("报销")}${x.preNo ? kindTag("冲备用金") : ""}${x.no}${expOverdue(x) ? '<span class="need-tag">逾期</span>' : ""}${x.overBudget ? '<span class="need-tag bao">超预算</span>' : ""}</td>
<td>${x.user}</td><td>${x.dept}</td><td>${x.type}</td>
<td class="num">${expMoney(x.amount)}</td><td>${x.date}</td>
<td>${(x.invoices || []).length} 张</td>
<td>${x.payWay}${x.loanNo ? `<div class="muted">${x.loanNo}</div>` : ""}</td>
<td>${expFinalRole(x)}</td>
<td>${expStatusPill(x.status)}</td>
</tr>`).join("")}</tbody></table>` : `<div class="empty">本节点暂无单据</div>`;
}
function expRowActs(x) {
const d = `<button class="btn btn-sm" type="button" onclick="openExpDetail(${x.id})">详情</button>`;
if (x.status === "草稿" || x.status === "已驳回") return `${d}<button class="btn btn-sm btn-primary" type="button" onclick="expSubmit(${x.id})">提交</button>`;
if (x.status === "待财务初审") return `${d}<button class="btn btn-sm btn-primary" type="button" onclick="expAdvance(${x.id})">初审通过</button><button class="btn btn-sm" type="button" onclick="openExpReject(${x.id})">退回</button>`;
if (x.status === "待终审") return `${d}<button class="btn btn-sm btn-primary" type="button" onclick="expAdvance(${x.id})">同意</button><button class="btn btn-sm" type="button" onclick="openExpReject(${x.id})">驳回</button>`;
if (x.status === "待付款") return `${d}<button class="btn btn-sm btn-primary" type="button" onclick="expAdvance(${x.id})">${x.payWay === "冲销借款" ? "冲借款" : "付款"}</button>`;
if (x.status === "待归档") return `${d}<button class="btn btn-sm btn-primary" type="button" onclick="expAdvance(${x.id})">制单归档</button>`;
return d;
}
function renderFinanceReview() {
const q = (document.getElementById("expQ")?.value || "").trim();
const bx = expenses.filter((x) => x.status === "待财务初审").map((x) => ({
kind: "报销", no: x.no, user: x.user, dept: x.dept, matter: x.type, amount: x.amount, date: x.date,
extra: (x.invoices || []).length + " 张发票" + (x.preNo ? " · 冲 " + x.preNo : ""), id: x.id
}));
const sq = preExps.filter(prePending).map((p) => ({
kind: "备用金", no: p.no, user: p.user, dept: p.dept, matter: p.type, amount: p.amount, date: p.date,
extra: p.remark, id: p.no
}));
const match = (r) => !q || [r.no, r.user, r.dept, r.matter, r.extra].join(" ").includes(q);
const bxRows = bx.filter(match);
const sqRows = sq.filter(match);
const showBx = expFinKind !== "备用金";
const showSq = expFinKind !== "报销";
const nBx = showBx ? bxRows.length : 0;
const nSq = showSq ? sqRows.length : 0;
const finTable = (kind, rows) => {
if (!rows.length) return `<div class="empty">${kind}暂无待初审</div>`;
const bx = kind === "报销";
return `<table class="erp-table" style="min-width:1080px">
<thead><tr><th>单号</th><th>经办人</th><th>部门</th><th>事项</th><th>金额</th><th>日期</th><th>初审看什么</th><th>通过后</th></tr></thead>
<tbody>${rows.map((r) => `<tr>
<td class="cell-no${bx ? " row-link" : ""}"${bx ? ` onclick="openExpDetail(${r.id})"` : ""}>${kindTag(r.kind)}${r.no}</td>
<td>${r.user}</td><td>${r.dept}</td><td>${r.matter}</td>
<td class="num">${expMoney(r.amount)}</td><td>${r.date}</td>
<td>${bx ? "发票真伪、附件、预算" : "事由与额度(无发票)"}<div class="muted">${r.extra || ""}</div></td>
<td>${bx ? "进入领导审核" : "批准先支出,事后凭票报销冲销"}</td>
</tr>`).join("")}</tbody></table>`;
};
document.getElementById("expCount").textContent = `待初审 ${nBx + nSq} 条(报销 ${nBx} · 备用金 ${nSq})`;
document.getElementById("expTable").innerHTML = `
${showBx ? `<div class="fin-sec">${finTable("报销", bxRows)}</div>` : ""}
${showSq ? `<div class="fin-sec">${finTable("备用金", sqRows)}</div>` : ""}`;
}
function renderPreExpTable() {
const q = (document.getElementById("expQ")?.value || "").trim();
const rows = preExps.filter((p) => !q || [p.no, p.user, p.dept, p.type, p.remark].join(" ").includes(q));
document.getElementById("expCount").textContent = `共 ${rows.length} 条备用金申请`;
document.getElementById("expTable").innerHTML = `<table class="erp-table" style="min-width:880px">
<thead><tr><th>申请号</th><th>申请人</th><th>部门</th><th>事项</th><th>预估金额</th><th>日期</th><th>状态</th><th>事由</th></tr></thead>
<tbody>${rows.map((p) => `<tr>
<td class="cell-no">${kindTag("备用金")}${p.no}</td><td>${p.user}</td><td>${p.dept}</td><td>${p.type}</td>
<td>${expMoney(p.amount)}</td><td>${p.date}</td><td>${expStatusPill(p.status)}</td><td>${p.remark}</td>
</tr>`).join("")}</tbody></table>`;
}
function renderLoanTable() {
const q = (document.getElementById("expQ")?.value || "").trim();
const rows = expLoans.filter((l) => !q || [l.no, l.user, l.dept].join(" ").includes(q));
document.getElementById("expCount").textContent = `共 ${rows.length} 笔借款`;
document.getElementById("expTable").innerHTML = `<table class="erp-table" style="min-width:880px">
<thead><tr><th>借款号</th><th>经办人</th><th>部门</th><th>借款额</th><th>未冲余额</th><th>日期</th><th>状态</th></tr></thead>
<tbody>${rows.map((l) => `<tr>
<td class="cell-no">${l.no}</td><td>${l.user}</td><td>${l.dept}</td>
<td>${expMoney(l.amount)}</td><td>${expMoney(l.remain)}</td><td>${l.date}</td>
<td>${l.status === "未清" ? '<span class="st st-fix">未清</span>' : '<span class="st st-run">已清</span>'}</td>
</tr>`).join("")}</tbody></table>`;
}
function previewExpFiles(input) {
expFiles = [...(input.files || [])].map((f) => f.name);
document.getElementById("expFileChips").innerHTML = expFiles.map((n) => `<span class="chip">${n}</span>`).join("") || "";
}
function expStaffDept(name) {
return EXP_STAFF.find((s) => s.name === name)?.dept || "行政人事";
}
function syncExpTitle() {
const f = document.getElementById("expenseForm");
if (!f) return;
const user = f.user.value;
const d = (f.date.value || today).replaceAll("-", "");
f.title.value = `${user}报销单${d}${String(expSeq).padStart(3, "0")}`;
}
function onExpUserChange() { syncExpTitle(); }
function expLineSum() {
return expLines.reduce((s, r) => s + Number(r.amount || 0), 0);
}
function renderExpLines() {
const body = document.getElementById("expLineBody");
const opts = `<option value="">请选择费用项目</option>` + EXP_TYPES.map((t) => `<option>${t}</option>`).join("");
body.innerHTML = expLines.map((r, i) => `<tr>
<td>${i + 1}</td>
<td><select class="${r.type ? "" : "placeholder"}" required onchange="expLines[${i}].type=this.value;this.classList.toggle('placeholder',!this.value)">${opts}</select></td>
<td><input type="number" min="0" step="0.01" value="${Number(r.amount || 0).toFixed(2)}" oninput="expLines[${i}].amount=this.value;updateExpSums()" /></td>
<td><input placeholder="请输入报销事由" value="${(r.reason || "").replaceAll('"', "&quot;")}" oninput="expLines[${i}].reason=this.value" /></td>
<td>
<div class="exp-datewrap ${r.date ? "has" : ""}">
<svg viewBox="0 0 24 24"><rect x="4" y="6" width="16" height="14" rx="2"/><path d="M8 4v4M16 4v4M4 11h16"/></svg>
<input type="date" value="${r.date || ""}" onchange="expLines[${i}].date=this.value;this.parentElement.classList.toggle('has',!!this.value)" aria-label="发生时间,可选" />
<span class="exp-date-ph">可选</span>
</div>
</td>
</tr>`).join("");
document.querySelectorAll("#expLineBody select").forEach((sel, i) => { sel.value = expLines[i].type || ""; sel.classList.toggle("placeholder", !sel.value); });
updateExpSums();
}
function addExpLine() {
expLines.push({ type: "", amount: 0, reason: "", date: "" });
renderExpLines();
}
function removeExpLine(i) {
expLines.splice(i, 1);
if (!expLines.length) expLines.push({ type: "", amount: 0, reason: "", date: "" });
renderExpLines();
}
function selectedInvoices() {
return invWallet.filter((x) => selectedInvIds.includes(x.id));
}
function updateExpSums() {
document.getElementById("expLineSum").textContent = expMoney(expLineSum());
const picked = selectedInvoices();
const used = picked.reduce((s, x) => s + x.amount, 0);
const bal = invWallet.reduce((s, x) => s + x.amount, 0) - used;
document.getElementById("expInvBal").textContent = picked.length ? expMoney(Math.max(0, bal)) : expMoney(0);
document.getElementById("expInvSum").textContent = picked.length ? expMoney(used) : expMoney(0);
document.getElementById("expInvChips").innerHTML = picked.map((x) =>
`<span class="chip">${x.name} ${expMoney(x.amount)}</span>`).join("");
}
function openInvPick() {
const box = document.getElementById("invPickList");
box.innerHTML = invWallet.map((x) => {
const on = selectedInvIds.includes(x.id);
return `<button class="inv-pick-item ${on ? "on" : ""}" type="button" data-id="${x.id}" onclick="toggleInvPick(${x.id})">
<input type="checkbox" ${on ? "checked" : ""} onclick="event.stopPropagation();toggleInvPick(${x.id})" />
<div><b>${x.name}</b><div class="muted">${x.date}</div></div>
<span class="amt">${expMoney(x.amount)}</span>
</button>`;
}).join("");
document.getElementById("invPickDialog").showModal();
}
function toggleInvPick(id) {
if (selectedInvIds.includes(id)) selectedInvIds = selectedInvIds.filter((x) => x !== id);
else selectedInvIds.push(id);
openInvPick();
}
function confirmInvPick() {
document.getElementById("invPickDialog").close();
updateExpSums();
}
function openExpenseDlg(preNo) {
const f = document.getElementById("expenseForm");
f.reset();
f.user.innerHTML = EXP_STAFF.map((s) => `<option value="${s.name}">${s.name}</option>`).join("");
f.user.value = "吴九华";
f.date.value = today;
expFiles = [];
selectedInvIds = [];
expLines = [{ type: "", amount: 0, reason: "", date: "" }];
document.getElementById("expFileChips").innerHTML = "";
if (preNo) {
const p = preExps.find((x) => x.no === preNo);
if (p) {
f.user.value = p.user;
f.remark.value = p.remark;
expLines = [{ type: p.type === "出差" ? "差旅费" : p.type === "招待" ? "招待费" : p.type === "采购" ? "采购费" : "其他", amount: p.amount, reason: p.remark, date: today }];
}
}
syncExpTitle();
renderExpLines();
document.getElementById("expenseDialog").showModal();
}
function closeExpenseDlg() { document.getElementById("expenseDialog").close(); }
function openPreExpDlg() {
document.getElementById("preExpForm").reset();
document.getElementById("preExpForm").user.value = "周建军";
document.getElementById("preExpDialog").showModal();
}
function savePreExp() {
const f = document.getElementById("preExpForm");
if (!f.reportValidity()) return;
const no = "SQ-" + today.replaceAll("-", "") + "-" + String(preSeq++).padStart(3, "0");
preExps.unshift({ no, user: f.user.value.trim(), dept: f.dept.value, type: f.type.value, amount: Number(f.amount.value), date: today, status: "待财务初审", remark: f.remark.value.trim() });
document.getElementById("preExpDialog").close();
expTab = "待财务初审";
expFinKind = "备用金";
updateBadges();
renderExpense();
toast("备用金已提交财务初审,通过后再支出");
}
function approvePreExp(no, ok) {
const p = preExps.find((x) => x.no === no);
if (!p) return;
p.status = ok ? "已批准" : "已驳回";
updateBadges();
renderExpense();
toast(ok ? "备用金财务初审通过,可以先支出,事后凭票报销冲销" : "备用金申请已驳回");
}
function saveExpense(submit) {
const f = document.getElementById("expenseForm");
if (!f.reportValidity()) return;
const lines = expLines.filter((r) => r.type || Number(r.amount) > 0 || (r.reason || "").trim());
if (!lines.length || lines.some((r) => !r.type || Number(r.amount) <= 0 || !(r.reason || "").trim())) {
toast("请完整填写费用项目、金额和报销事由");
return;
}
const user = f.user.value.trim();
const dept = expStaffDept(user);
const amt = expLineSum();
const type = lines[0].type;
const remark = (f.remark.value.trim() || lines.map((r) => r.reason).filter(Boolean).join(";"));
const invoices = selectedInvoices().map((x) => x.name).concat(expFiles);
const loan = expOpenLoan(user);
const payWay = loan ? "冲销借款" : "个人银行卡";
const loanNo = loan ? loan.no : "";
const over = amt > expBudgetLeft(dept);
const bizDate = lines.map((r) => r.date).filter(Boolean).sort()[0] || f.date.value;
const late = daysBetween(bizDate, today) > 45;
const no = "BX-" + (f.date.value || today).replaceAll("-", "") + "-" + String(expSeq++).padStart(3, "0");
const x = {
id: Date.now(), no, user, dept, type, amount: amt, title: f.title.value.trim(),
date: bizDate, submit: submit ? today : "", status: submit ? (late ? "已驳回" : "待财务初审") : "草稿",
remark, invoices, lines: lines.map((r) => ({ ...r, amount: Number(r.amount) })),
payWay, loanNo, bank: "", preNo: "", overBudget: over, logs: []
};
if (!submit) expLog(x, user, "保存草稿", f.title.value.trim());
else if (late) expLog(x, "系统", "逾期退回", "业务结束已超过 45 天,按制度不予受理");
else expLog(x, user, "提交报销", (x.invoices.length || 0) + " 张附件 / 发票");
expenses.unshift(x);
closeExpenseDlg();
expTab = submit && !late ? "待财务初审" : "我的报销";
updateBadges();
renderExpense();
toast(late ? "已超过报销时限,系统退回" : submit ? "已提交审批" : "已保存草稿");
}
function expSubmit(id) {
const x = expById(id);
if (!x) return;
if (!(x.invoices || []).length) { toast("请先在详情中确认已贴发票"); openExpDetail(id); return; }
if (expOverdue(x)) {
x.status = "已驳回";
expLog(x, "系统", "逾期退回", "业务结束已超过 45 天,按制度不予受理");
toast("逾期不予受理");
} else {
x.status = "待财务初审";
x.submit = today;
expLog(x, x.user, "提交报销", "补资料后重新发起");
toast("已提交财务初审");
}
updateBadges();
renderExpense();
}
function expAdvance(id) {
const x = expById(id);
if (!x) return;
if (x.status === "待财务初审") {
x.status = "待终审";
x.finalRole = expFinalRole(x);
expLog(x, "财务会计", "财务初审通过", x.overBudget ? "票据合规,超预算转总经理专项终审" : "发票、附件、制度与预算核验通过");
toast("财务初审通过,进入领导审核");
} else if (x.status === "待终审") {
x.status = "待付款";
expLog(x, expFinalRole(x), "终审同意", "");
toast("终审同意,请出纳付款");
} else if (x.status === "待付款") {
if (x.payWay === "冲销借款" && x.loanNo) {
const l = expLoans.find((i) => i.no === x.loanNo);
if (l) {
l.remain = Math.max(0, l.remain - x.amount);
if (l.remain === 0) l.status = "已清";
}
expLog(x, "出纳", "已冲借款", `冲销 ${x.loanNo}`);
toast("已冲销借款");
} else {
expLog(x, "出纳", "已付款", x.payWay + " " + expMoney(x.amount));
toast("出纳已付款");
}
x.status = "待归档";
} else if (x.status === "待归档") {
x.status = "已归档";
x.voucher = "记-" + today.replaceAll("-", "").slice(0, 6) + "-" + String(80 + (x.id % 20));
expLog(x, "会计", "制单归档", "凭证 " + x.voucher + ",发票装订归档");
toast("已制单归档");
}
updateBadges();
renderExpense();
const dlg = document.getElementById("expDetailDialog");
if (dlg.open) openExpDetail(id);
}
function openExpReject(id) {
expRejectId = id;
document.getElementById("expRejectForm").reset();
document.getElementById("expRejectDlg").showModal();
}
function confirmExpReject() {
const f = document.getElementById("expRejectForm");
if (!f.reportValidity()) return;
const x = expById(expRejectId);
if (!x) return;
const reason = f.reason.value.trim();
const who = x.status === "待财务初审" ? "财务会计" : x.status === "待终审" ? expFinalRole(x) : (x.dept + "负责人");
const act = x.status === "待财务初审" ? "退回补资料" : "驳回";
x.status = "已驳回";
expLog(x, who, act, reason);
document.getElementById("expRejectDlg").close();
document.getElementById("expDetailDialog").close();
updateBadges();
renderExpense();
toast("已退回经办人,请补资料后重提");
}
function openExpDetail(id) {
const x = expById(id);
if (!x) return;
document.getElementById("expDetailTitle").textContent = x.no + " · " + x.status;
const left = expBudgetLeft(x.dept);
document.getElementById("expDetailBody").innerHTML = `
<div class="split-h">
<div>
<h4>报销信息</h4>
<div class="kv">
${kv("经办人", x.user)}${kv("部门", x.dept)}${kv("类别", x.type)}
${kv("金额", expMoney(x.amount))}${kv("发生日", x.date)}${kv("提交日", x.submit || "—")}
${kv("结算", x.payWay)}${kv("借款", x.loanNo || "无")}${kv("账户", x.bank || "—")}
${kv("备用金申请", x.preNo || "无")}${kv("终审角色", expFinalRole(x))}
${kv("预算剩余", expMoney(left) + (x.overBudget ? "(本单超预算)" : ""))}
${kv("凭证", x.voucher || "—")}
</div>
<p style="margin-top:10px">${x.remark || ""}</p>
</div>
<div>
<h4>原始单据</h4>
<div class="chips">${(x.invoices || []).map((n) => `<span class="chip">${n}</span>`).join("") || `<span class="muted">尚未贴票</span>`}</div>
<h4 style="margin-top:16px">审批轨迹</h4>
<div class="exp-log">${(x.logs || []).map((l) => `<div class="row"><div class="muted">${l.t}</div><div>${l.who}</div><div><b>${l.act}</b><div class="muted">${l.note || ""}</div></div></div>`).join("") || `<div class="muted">暂无</div>`}</div>
</div>
</div>`;
document.getElementById("expDetailFoot").innerHTML = `
<button class="btn" type="button" onclick="document.getElementById('expDetailDialog').close()">关闭</button>
${expRowActs(x).replace(`<button class="btn btn-sm" type="button" onclick="openExpDetail(${x.id})">详情</button>`, "")}`;
document.getElementById("expDetailDialog").showModal();
}
function dutyPad(n) { return String(n).padStart(2, "0"); }
function dutyDateStr(y, m, d) { return `${y}-${dutyPad(m)}-${dutyPad(d)}`; }
function dutyOfMonth() {
const prefix = `${dutyYM.y}-${dutyPad(dutyYM.m)}-`;
return dutyShifts.filter((s) => s.date.startsWith(prefix));
}
function dutyChipText(s) {
const names = (s.people || []).join("、");
const post = s.post ? ` · ${s.post}` : "";
return `${DUTY_TYPE[s.type] || s.type} ${names}${post}`;
}
function renderDuty() {
document.getElementById("dutyMonthLab").textContent = `${dutyYM.y}年${dutyYM.m}月`;
const monthRows = dutyOfMonth();
const countType = (t) => monthRows.filter((s) => s.type === t).reduce((n, s) => n + (s.people || []).length, 0);
const nAm = countType("am");
const nPm = countType("pm");
const nWatch = countType("watch");
const nOther = countType("other");
const nSum = nAm + nPm + nWatch + nOther;
document.getElementById("dutyPills").innerHTML = `
<span class="duty-pill"><i class="am"></i>早班 <b>${nAm}</b></span>
<span class="duty-pill"><i class="pm"></i>晚班 <b>${nPm}</b></span>
<span class="duty-pill"><i class="watch"></i>值班 <b>${nWatch}</b></span>
<span class="duty-sum">本月合计 <b>${nSum}</b></span>`;
const peopleMap = {};
monthRows.forEach((s) => (s.people || []).forEach((p) => { peopleMap[p] = (peopleMap[p] || 0) + 1; }));
const peopleOrder = [...DUTY_STAFF].filter((p) => peopleMap[p]).sort((a, b) => peopleMap[b] - peopleMap[a] || DUTY_STAFF.indexOf(a) - DUTY_STAFF.indexOf(b));
document.getElementById("dutyPeople").innerHTML = peopleOrder.length
? `<span>本月人员</span>` + peopleOrder.map((p) => `<em>${p}</em> <b>${peopleMap[p]}</b>`).join("")
: `<span>本月人员</span><span>暂无</span>`;
const y = dutyYM.y, m = dutyYM.m;
const first = new Date(y, m - 1, 1);
const start = new Date(y, m - 1, 1 - first.getDay());
const last = new Date(y, m, 0);
const endPad = 6 - last.getDay();
const end = new Date(y, m - 1, last.getDate() + endPad);
const cells = [];
for (let t = start.getTime(); t <= end.getTime(); t += 86400000) {
const dt = new Date(t);
const yy = dt.getFullYear(), mm = dt.getMonth() + 1, dd = dt.getDate();
const key = dutyDateStr(yy, mm, dd);
const out = mm !== m;
const shifts = dutyShifts.filter((s) => s.date === key);
const chips = shifts.map((s) => `<span class="duty-chip ${s.type}">${dutyChipText(s)}</span>`).join("");
const empty = !out && !shifts.length ? `<span class="duty-empty">未排班,点击配置</span>` : "";
cells.push(`<button class="duty-day${out ? " out" : ""}${key === DUTY_TODAY ? " today" : ""}" type="button" onclick="openDutyDay('${key}')">
<div class="num">${dd}</div>${chips}${empty}
</button>`);
}
document.getElementById("dutyGrid").innerHTML = cells.join("");
}
function shiftDutyMonth(delta) {
dutyYM.m += delta;
if (dutyYM.m < 1) { dutyYM.m = 12; dutyYM.y -= 1; }
if (dutyYM.m > 12) { dutyYM.m = 1; dutyYM.y += 1; }
renderDuty();
}
function goDutyToday() {
const [y, m] = DUTY_TODAY.split("-").map(Number);
dutyYM = { y, m };
renderDuty();
}
function copyDutyLink() {
const url = location.href.split("#")[0] + "#/duty/schedule";
if (navigator.clipboard?.writeText) navigator.clipboard.writeText(url).catch(() => {});
toast("已复制值班排班链接");
}
function renderDutyStaffPick() {
document.getElementById("dutyStaffPick").innerHTML = DUTY_STAFF.map((n) =>
`<button class="chip pick${dutyPickPeople.includes(n) ? " on" : ""}" type="button" onclick="toggleDutyPerson('${n}')">${n}</button>`
).join("");
}
function toggleDutyPerson(name) {
if (dutyPickPeople.includes(name)) dutyPickPeople = dutyPickPeople.filter((x) => x !== name);
else dutyPickPeople.push(name);
renderDutyStaffPick();
}
function renderDutyExist() {
const rows = dutyShifts.filter((s) => s.date === dutyPickDate);
const box = document.getElementById("dutyExist");
box.innerHTML = rows.length
? `<div class="muted" style="margin-bottom:8px">当日已排</div><div class="duty-exist">${rows.map((s) =>
`<div class="row"><span class="duty-chip ${s.type}" style="width:auto;margin:0">${dutyChipText(s)}</span>
<button class="btn btn-sm" type="button" onclick="removeDutyShift(${s.id})">删除</button></div>`
).join("")}</div>`
: `<div class="muted">当日尚未排班</div>`;
}
function openDutyDay(date) {
dutyPickDate = date;
dutyPickPeople = [];
const f = document.getElementById("dutyForm");
f.type.value = "am";
f.post.value = "配酸";
document.getElementById("dutyDlgTitle").textContent = date + " 值班配置";
renderDutyStaffPick();
renderDutyExist();
document.getElementById("dutyDialog").showModal();
}
function saveDutyShift() {
if (!dutyPickDate) return;
if (!dutyPickPeople.length) { toast("请选择人员"); return; }
const f = document.getElementById("dutyForm");
const type = f.type.value;
const post = (f.post.value || "").trim();
const hit = dutyShifts.find((s) => s.date === dutyPickDate && s.type === type);
if (hit) {
hit.people = [...dutyPickPeople];
hit.post = post;
} else {
dutyShifts.push({ id: ++dutySeq, date: dutyPickDate, type, people: [...dutyPickPeople], post });
}
dutyPickPeople = [];
renderDutyStaffPick();
renderDutyExist();
renderDuty();
toast("已保存排班");
}
function removeDutyShift(id) {
dutyShifts = dutyShifts.filter((s) => s.id !== id);
renderDutyExist();
renderDuty();
toast("已删除");
}
const STAFF_DEPTS = ["行政人事", "财务部", "销售部", "运输部", "仓储部", "生产部", "配酸车间"];
let staffTab = "全部";
let staffPage = 1;
let staffPageSize = 20;
let staffSeq = 2025030;
let selectedStaffIds = new Set();
let staffDetailId = null;
let staffs = [
{ id: 1, no: "SX-2018001", name: "吴九华", gender: "女", dept: "行政人事", post: "人事主管", phone: "13805581201", hire: "2018-03-12", contractEnd: "2027-03-11", kind: "劳动合同", status: "在职", edu: "本科", native: "安徽安庆", emergency: "吴父", emergPhone: "13905580011", remark: "负责档案、考勤与值班排班。" },
{ id: 2, no: "SX-2016002", name: "万金强", gender: "男", dept: "销售部", post: "销售负责人", phone: "13905582002", hire: "2016-05-08", contractEnd: "2027-05-07", kind: "劳动合同", status: "在职", edu: "本科", native: "安徽安庆", emergency: "万妻", emergPhone: "13905582012", remark: "客户合同与报价对接。" },
{ id: 3, no: "SX-2017003", name: "周建军", gender: "男", dept: "运输部", post: "驾驶员", phone: "13705583003", hire: "2017-08-01", contractEnd: "2026-10-18", kind: "劳动合同", status: "在职", edu: "高中", native: "湖北宜昌", emergency: "周母", emergPhone: "13705583013", remark: "鄂E·危A123 主驾,危货从业资格在期。" },
{ id: 4, no: "SX-2019004", name: "陈伟", gender: "男", dept: "运输部", post: "驾驶员", phone: "13705584004", hire: "2019-04-16", contractEnd: "2027-04-15", kind: "劳动合同", status: "在职", edu: "中专", native: "安徽桐城", emergency: "陈妻", emergPhone: "13705584014", remark: "鄂E·危B618 主驾。" },
{ id: 5, no: "SX-2018005", name: "胡明", gender: "男", dept: "生产部", post: "生产班长", phone: "13605585005", hire: "2018-09-10", contractEnd: "2027-09-09", kind: "劳动合同", status: "在职", edu: "大专", native: "安徽怀宁", emergency: "胡父", emergPhone: "13605585015", remark: "配酸、分装现场负责人。" },
{ id: 6, no: "SX-2019006", name: "吴涛", gender: "男", dept: "销售部", post: "业务员", phone: "13505586006", hire: "2019-11-02", contractEnd: "2027-11-01", kind: "劳动合同", status: "在职", edu: "大专", native: "安徽安庆", emergency: "吴兄", emergPhone: "13505586016", remark: "" },
{ id: 7, no: "SX-2020007", name: "郑浩", gender: "男", dept: "仓储部", post: "仓管员", phone: "13405587007", hire: "2020-03-20", contractEnd: "2027-03-19", kind: "劳动合同", status: "在职", edu: "大专", native: "安徽潜山", emergency: "郑妻", emergPhone: "13405587017", remark: "硫酸库、液碱库。" },
{ id: 8, no: "SX-2024008", name: "刘洋", gender: "男", dept: "仓储部", post: "仓管员", phone: "13305588008", hire: "2024-08-01", contractEnd: "2026-10-25", kind: "劳动合同", status: "试用", edu: "大专", native: "安徽太湖", emergency: "刘父", emergPhone: "13305588018", remark: "试用期至 2026-11-01。" },
{ id: 9, no: "SX-2018009", name: "孙斌", gender: "男", dept: "运输部", post: "押运员", phone: "13205589009", hire: "2018-06-18", contractEnd: "2026-10-28", kind: "劳动合同", status: "在职", edu: "高中", native: "安徽宿松", emergency: "孙妻", emergPhone: "13205589019", remark: "危货押运证将到期,需复审。" },
{ id: 10, no: "SX-2017010", name: "吴自国", gender: "男", dept: "配酸车间", post: "配酸工", phone: "13105580110", hire: "2017-02-14", contractEnd: "2028-02-13", kind: "劳动合同", status: "在职", edu: "高中", native: "安徽安庆", emergency: "吴妻", emergPhone: "13105580120", remark: "值班排班骨干。" },
{ id: 11, no: "SX-2018011", name: "刘茂生", gender: "男", dept: "配酸车间", post: "配酸工", phone: "13105580111", hire: "2018-01-08", contractEnd: "2028-01-07", kind: "劳动合同", status: "在职", edu: "中专", native: "安徽安庆", emergency: "刘妻", emergPhone: "13105580121", remark: "" },
{ id: 12, no: "SX-2019012", name: "吴自泰", gender: "男", dept: "配酸车间", post: "配酸工", phone: "13105580112", hire: "2019-05-06", contractEnd: "2027-05-05", kind: "劳动合同", status: "在职", edu: "高中", native: "安徽安庆", emergency: "吴父", emergPhone: "13105580122", remark: "" },
{ id: 13, no: "SX-2020013", name: "黄天宝", gender: "男", dept: "配酸车间", post: "配酸工", phone: "13105580113", hire: "2020-07-01", contractEnd: "2027-06-30", kind: "劳动合同", status: "在职", edu: "中专", native: "安徽望江", emergency: "黄兄", emergPhone: "13105580123", remark: "" },
{ id: 14, no: "SX-2023014", name: "刘丹", gender: "女", dept: "配酸车间", post: "值班员", phone: "15905580114", hire: "2023-04-10", contractEnd: "2027-04-09", kind: "劳动合同", status: "在职", edu: "大专", native: "安徽安庆", emergency: "刘母", emergPhone: "15905580124", remark: "周末值班。" },
{ id: 15, no: "SX-2025015", name: "汪璇", gender: "女", dept: "行政人事", post: "人事文员", phone: "15805580115", hire: "2025-09-01", contractEnd: "2026-11-30", kind: "劳动合同", status: "试用", edu: "本科", native: "安徽合肥", emergency: "汪父", emergPhone: "15805580125", remark: "协助档案与考勤。" },
{ id: 16, no: "SX-2022016", name: "吴良芹", gender: "女", dept: "配酸车间", post: "值班员", phone: "15705580116", hire: "2022-03-21", contractEnd: "2027-03-20", kind: "劳动合同", status: "在职", edu: "高中", native: "安徽安庆", emergency: "吴夫", emergPhone: "15705580126", remark: "" },
{ id: 17, no: "SX-2016017", name: "李强", gender: "男", dept: "运输部", post: "押运员", phone: "15605580117", hire: "2016-11-11", contractEnd: "2027-11-10", kind: "劳动合同", status: "在职", edu: "高中", native: "湖北宜昌", emergency: "李妻", emergPhone: "15605580127", remark: "鄂E·危A123 押运。" },
{ id: 18, no: "SX-2017018", name: "王磊", gender: "男", dept: "运输部", post: "押运员", phone: "15505580118", hire: "2017-09-01", contractEnd: "2027-08-31", kind: "劳动合同", status: "在职", edu: "中专", native: "安徽枞阳", emergency: "王父", emergPhone: "15505580128", remark: "" },
{ id: 19, no: "SX-2018019", name: "陈静", gender: "女", dept: "财务部", post: "会计", phone: "13905580119", hire: "2018-01-15", contractEnd: "2027-01-14", kind: "劳动合同", status: "在职", edu: "本科", native: "安徽安庆", emergency: "陈母", emergPhone: "13905580129", remark: "报销初审、备用金核额。" },
{ id: 20, no: "SX-2020020", name: "赵鹏", gender: "男", dept: "运输部", post: "押运员", phone: "13705580120", hire: "2020-06-08", contractEnd: "2027-06-07", kind: "劳动合同", status: "在职", edu: "高中", native: "安徽岳西", emergency: "赵妻", emergPhone: "13705580130", remark: "" },
{ id: 21, no: "SX-2021021", name: "王辉", gender: "男", dept: "运输部", post: "调度员", phone: "13805580121", hire: "2021-02-22", contractEnd: "2027-02-21", kind: "劳动合同", status: "在职", edu: "大专", native: "安徽安庆", emergency: "王妻", emergPhone: "13805580131", remark: "运输计划与任务单调度。" },
{ id: 22, no: "SX-2015022", name: "张伟", gender: "男", dept: "生产部", post: "操作工", phone: "13605580122", hire: "2015-04-01", contractEnd: "2025-03-31", kind: "劳动合同", status: "离职", edu: "初中", native: "安徽安庆", emergency: "张父", emergPhone: "13605580132", remark: "2025-04-01 离职,档案留存。" }
];
function staffById(id) { return staffs.find((x) => x.id === id); }
function staffContractSoon(x) {
if (!x.contractEnd || x.status === "离职") return false;
const d = daysBetween(today, x.contractEnd);
return d >= 0 && d <= 30;
}
function staffStatusPill(st) {
if (st === "在职") return `<span class="st st-run">在职</span>`;
if (st === "试用") return `<span class="st st-fix">试用</span>`;
return `<span class="st st-stop">离职</span>`;
}
function filteredStaff() {
const q = (document.getElementById("staffQ")?.value || "").trim();
const dept = document.getElementById("staffDept")?.value || "";
let rows = staffs.slice();
if (staffTab !== "全部") rows = rows.filter((x) => x.status === staffTab);
if (dept) rows = rows.filter((x) => x.dept === dept);
if (q) rows = rows.filter((x) => [x.no, x.name, x.post, x.phone, x.dept].join(" ").includes(q));
return rows;
}
function setStaffTab(tab) {
staffTab = tab;
staffPage = 1;
document.querySelectorAll("#staffTabs button").forEach((b) => b.classList.toggle("on", b.dataset.tab === tab));
renderStaff();
}
function toggleStaff(id, on) {
if (on) selectedStaffIds.add(id); else selectedStaffIds.delete(id);
renderStaff();
}
function toggleStaffAll(on) {
const rows = filteredStaff();
const start = (staffPage - 1) * staffPageSize;
rows.slice(start, start + staffPageSize).forEach((x) => on ? selectedStaffIds.add(x.id) : selectedStaffIds.delete(x.id));
renderStaff();
}
function fillStaffDeptSelect(val) {
const sel = document.getElementById("staffDeptSelect");
if (sel) {
sel.innerHTML = STAFF_DEPTS.map((d) => `<option>${d}</option>`).join("");
if (val) sel.value = val;
}
const filter = document.getElementById("staffDept");
if (filter && !filter.dataset.ready) {
filter.innerHTML = `<option value="">全部部门</option>` + STAFF_DEPTS.map((d) => `<option>${d}</option>`).join("");
filter.dataset.ready = "1";
}
}
function renderStaff() {
fillStaffDeptSelect();
const onJob = staffs.filter((x) => x.status === "在职").length;
const trial = staffs.filter((x) => x.status === "试用").length;
const left = staffs.filter((x) => x.status === "离职").length;
const due = staffs.filter(staffContractSoon).length;
document.getElementById("staffStats").innerHTML = `
<div class="stat"><div class="k">在职</div><div class="v">${onJob}</div><div class="s">劳动合同 / 劳务在岗</div></div>
<div class="stat"><div class="k">试用</div><div class="v">${trial}</div><div class="s">试用期内人员</div></div>
<div class="stat"><div class="k">离职</div><div class="v">${left}</div><div class="s">档案留存</div></div>
<div class="stat"><div class="k">合同将到期</div><div class="v">${due}</div><div class="s">30 日内到期</div></div>`;
const rows = filteredStaff();
const pages = Math.max(1, Math.ceil(rows.length / staffPageSize));
if (staffPage > pages) staffPage = pages;
const start = (staffPage - 1) * staffPageSize;
const pageRows = rows.slice(start, start + staffPageSize);
const allOn = pageRows.length && pageRows.every((x) => selectedStaffIds.has(x.id));
document.getElementById("staffSelHint").textContent = `已选 ${selectedStaffIds.size} 项`;
document.getElementById("staffTable").innerHTML = pageRows.length ? `<table class="erp-table staff-table">
<thead><tr>
<th class="chk"><input type="checkbox" ${allOn ? "checked" : ""} onchange="toggleStaffAll(this.checked)" /></th>
<th>工号</th><th>姓名</th><th>性别</th><th>部门</th><th>岗位</th><th>手机</th><th>入职日期</th><th>合同到期</th><th>用工类型</th><th>状态</th>
</tr></thead>
<tbody>${pageRows.map((x) => `<tr>
<td class="chk"><input type="checkbox" ${selectedStaffIds.has(x.id) ? "checked" : ""} onchange="toggleStaff(${x.id}, this.checked)" /></td>
<td class="cell-no">${x.no}</td>
<td><span class="staff-name"><span class="staff-av">${x.name.slice(0, 1)}</span><button class="quote-link" type="button" onclick="openStaffDetail(${x.id})">${x.name}</button></span></td>
<td>${x.gender}</td><td>${x.dept}</td><td>${x.post}</td><td>${x.phone}</td>
<td>${x.hire}</td>
<td class="${staffContractSoon(x) ? "staff-due" : ""}">${x.contractEnd || "—"}${staffContractSoon(x) ? " 将到期" : ""}</td>
<td>${x.kind}</td>
<td>${staffStatusPill(x.status)}</td>
</tr>`).join("")}</tbody></table>` : `<div class="empty">暂无符合条件的员工</div>`;
const show = Math.min(pages, 6);
const nums = [];
for (let n = 1; n <= show; n++) nums.push(n);
document.getElementById("staffPager").innerHTML = `
<span class="muted">共 ${rows.length} 条</span>
<select class="page-size" onchange="staffPageSize=+this.value;staffPage=1;renderStaff()">
<option value="20" ${staffPageSize === 20 ? "selected" : ""}>20条/页</option>
<option value="50" ${staffPageSize === 50 ? "selected" : ""}>50条/页</option>
</select>
<button type="button" onclick="staffPage=Math.max(1, staffPage-1);renderStaff()">‹</button>
${nums.map((n) => `<button type="button" class="${n === staffPage ? "on" : ""}" onclick="staffPage=${n};renderStaff()">${n}</button>`).join("")}
<button type="button" onclick="staffPage=Math.min(${pages}, staffPage+1);renderStaff()">›</button>
<span class="muted">前往</span>
<input class="quote-goto" type="number" min="1" max="${pages}" value="${staffPage}" onchange="staffPage=Math.min(${pages}, Math.max(1, +this.value||1));renderStaff()" />
<span class="muted">页</span>`;
}
function openStaffDlg(id) {
const f = document.getElementById("staffForm");
f.reset();
const x = id ? staffById(id) : null;
fillStaffDeptSelect(x ? x.dept : "行政人事");
document.getElementById("staffDlgTitle").textContent = x ? "编辑员工" : "新增员工";
f.id.value = x ? x.id : "";
if (x) {
f.name.value = x.name; f.no.value = x.no; f.gender.value = x.gender;
f.post.value = x.post; f.phone.value = x.phone; f.hire.value = x.hire;
f.contractEnd.value = x.contractEnd; f.kind.value = x.kind; f.status.value = x.status;
f.edu.value = x.edu; f.native.value = x.native; f.emergency.value = x.emergency;
f.emergPhone.value = x.emergPhone; f.remark.value = x.remark || "";
} else {
f.hire.value = today; f.status.value = "试用"; f.kind.value = "劳动合同";
}
document.getElementById("staffDialog").showModal();
}
function saveStaff() {
const f = document.getElementById("staffForm");
if (!f.name.value.trim() || !f.post.value.trim()) { toast("请填写姓名和岗位"); return; }
const id = Number(f.id.value);
const row = {
name: f.name.value.trim(), no: f.no.value.trim(), gender: f.gender.value, dept: f.dept.value,
post: f.post.value.trim(), phone: f.phone.value.trim(), hire: f.hire.value, contractEnd: f.contractEnd.value,
kind: f.kind.value, status: f.status.value, edu: f.edu.value, native: f.native.value.trim(),
emergency: f.emergency.value.trim(), emergPhone: f.emergPhone.value.trim(), remark: f.remark.value.trim()
};
if (id) {
const x = staffById(id);
if (x) Object.assign(x, row);
} else {
if (!row.no) row.no = "SX-" + (++staffSeq);
staffs.unshift({ id: Date.now(), ...row });
}
document.getElementById("staffDialog").close();
renderStaff();
toast("已保存员工档案");
}
function openStaffDetail(id) {
const x = staffById(id);
if (!x) return;
staffDetailId = id;
document.getElementById("staffDetailTitle").textContent = x.name + " · " + x.no;
document.getElementById("staffDetailBody").innerHTML = `
<div class="split-h">
<div>
<h4>基本信息</h4>
<div class="kv">
${kv("工号", x.no)}${kv("姓名", x.name)}${kv("性别", x.gender)}
${kv("部门", x.dept)}${kv("岗位", x.post)}${kv("手机", x.phone)}
${kv("学历", x.edu)}${kv("籍贯", x.native || "—")}
</div>
</div>
<div>
<h4>合同与联系</h4>
<div class="kv">
${kv("状态", x.status)}${kv("用工类型", x.kind)}
${kv("入职日期", x.hire || "—")}${kv("合同到期", x.contractEnd || "—")}
${kv("紧急联系人", x.emergency || "—")}${kv("联系人电话", x.emergPhone || "—")}
</div>
<p style="margin-top:12px" class="muted">${x.remark || "无备注"}</p>
</div>
</div>`;
document.getElementById("staffDetailDialog").showModal();
}
function editStaffFromDetail() {
document.getElementById("staffDetailDialog").close();
if (staffDetailId) openStaffDlg(staffDetailId);
}
function editSelectedStaff() {
const ids = [...selectedStaffIds];
if (ids.length !== 1) { toast("请勾选一名员工后编辑"); return; }
openStaffDlg(ids[0]);
}
function leaveSelectedStaff() {
const ids = [...selectedStaffIds];
if (!ids.length) { toast("请先勾选员工"); return; }
let n = 0;
ids.forEach((id) => {
const x = staffById(id);
if (x && x.status !== "离职") { x.status = "离职"; x.remark = (x.remark ? x.remark + ";" : "") + today + " 办理离职"; n++; }
});
selectedStaffIds.clear();
renderStaff();
toast(n ? `已办理 ${n} 人离职` : "所选人员已是离职状态");
}
function showCertView(name, id) {
if (name === "detail") {
if (id != null) currentCertId = id;
if (currentCertId == null) {
toast("请先从列表选择一条证件");
name = "list";
}
}
const waitEl = document.getElementById("view-cert-wait");
if (waitEl) waitEl.classList.toggle("hidden", name !== "wait");
document.getElementById("view-cert-list").classList.toggle("hidden", name !== "list");
document.getElementById("view-cert-detail").classList.toggle("hidden", name !== "detail");
document.querySelectorAll("#certSubnav button").forEach((b) => b.classList.toggle("on", b.dataset.view === name));
document.getElementById("navDetail").disabled = currentCertId == null;
document.getElementById("crumb").textContent = name === "detail" ? "证件信息" : name === "wait" ? "待关联计划" : "证件列表";
if (name === "wait") renderWaitPlans();
if (name === "list") renderCertList();
if (name === "detail") renderCertDetail();
}
function kv(lab, val) {
return `<div class="lab">${lab}</div><div class="val">${val || "—"}</div>`;
}
function certThumbHtml(src, alt) {
if (!src) return "";
return `<button type="button" class="cert-thumb" onclick="openCertImg(this)"><img src="${src}" alt="${alt || "证件"}"></button>`;
}
function openCertImg(el) {
const img = el && (el.tagName === "IMG" ? el : el.querySelector("img"));
if (!img || !img.src) return;
const view = document.getElementById("certImgView");
const dlg = document.getElementById("certImgDialog");
view.src = img.src;
view.alt = img.alt || "证件原图";
if (dlg.open) dlg.close();
dlg.showModal();
}
function closeCertImg(ev) {
const dlg = document.getElementById("certImgDialog");
if (!dlg.open) return;
if (!ev || ev.target === dlg || ev.target === document.getElementById("certImgView")) dlg.close();
}
function renderWaitPlans() {
const waitList = waitListPlans();
const waitLinkAll = plans.filter(isWaitLinkPlan);
const todayN = waitLinkAll.filter(isWaitTodayPlan).length;
const weekN = waitLinkAll.filter(isWaitWeekPlan).length;
const buyWarnN = certs.filter((c) => buyWarn(c)).length;
const waitStats = document.getElementById("waitStats");
if (waitStats) {
waitStats.innerHTML = [
["今日待关联计划", todayN, "送货或计划日期为今天", ""],
["本周待关联计划", weekN, "本周一至周日", ""],
["购买证预警", buyWarnN, "余量 ≤ 自设预警量", "filterCertSt('余量预警')"]
].map(([k, v, s, fn]) => `<div class="stat${fn ? " stat-click" : ""}" ${fn ? `onclick="${fn}"` : ""}><div class="k">${k}</div><div class="v">${v}</div><div class="s">${s}</div></div>`).join("");
}
const waitBox = document.getElementById("certWaitPlans");
if (!waitBox) return;
const allWait = plans.filter(isWaitListPlan);
const nIn = allWait.filter((p) => planCertScene(p) === "in").length;
const nOut = allWait.filter((p) => planCertScene(p) === "out").length;
const nDirect = allWait.filter((p) => planCertScene(p) === "direct").length;
const tabs = [
["", `全部 ${allWait.length}`],
["in", `入库单 ${nIn}`],
["out", `出库单 ${nOut}`],
["direct", `直销单 ${nDirect}`]
].map(([k, lab]) => `<button type="button" class="${waitLinkSceneTab === k ? "on" : ""}" onclick="setWaitLinkScene('${k}')">${lab}</button>`).join("");
waitBox.innerHTML = `<div class="wait-scene-tabs">${tabs}</div>` + (waitList.length ? `<div class="wait-table-wrap"><table class="erp-table plan-table wait-table">
<thead><tr>
<th class="col-op-buy">购买证</th>
<th class="col-op-trans">运输证</th>
<th class="idx">序号</th>
<th class="col-no">计划编号</th>
<th>跟进状态</th>
<th>计划日期</th>
<th>送货时间</th>
<th>单据类型</th>
<th>车牌号</th>
<th>驾押人员</th>
<th>装货客户</th>
<th class="col-ship">送货客户</th>
<th>产品名称</th>
<th>产品规格</th>
<th>包装规格</th>
<th>运输证总量</th>
<th>本次计划数量</th>
<th>已发数量</th>
<th>余量</th>
<th>合同</th>
</tr></thead>
<tbody>${waitList.map((p, i) => waitLinkRowHtml(p, i)).join("")}</tbody>
</table></div>` : `<div class="empty">${waitLinkSceneTab ? "该场景暂无已安排、已选车号的易制毒计划。" : "暂无已安排、已选车号的易制毒运输计划。"}</div>`);
}
function renderCertList() {
const q = (document.getElementById("certQ").value || "").trim();
const st = document.getElementById("certSt").value;
const warnN = certs.filter((c) => certStatusOf(c) === "余量预警" || certStatusOf(c) === "即将到期").length;
const transN = certs.filter((c) => transWarn(c)).length;
const buyWarnN = certs.filter((c) => buyWarn(c)).length;
const overN = certs.filter((c) => certOver(c)).length;
const vehN = certs.reduce((s, c) => s + vehicleWarnCount(c), 0);
const todayN = certs.filter((c) => c.created === today).length;
const todayTransN = certs.reduce((s, c) => s + transOf(c).filter((t) => t.created === today).length, 0);
const waitLink = plans.filter(isWaitLinkPlan).length;
const vehRowsAll = certs.flatMap((c) => vehicleRowsOf(c).map((r) => ({ ...r, cert: c })));
const vehWarnRows = vehRowsAll.filter((r) => r.level);
const stepEl = document.getElementById("certSteps");
if (stepEl) {
const step2on = waitLink > 0;
const step3on = vehWarnRows.length > 0 || buyWarnN || transN || overN;
stepEl.innerHTML = [
["1", "上传证件", "上传购买证、运输证,核对证载数量与车号", `${certs.length} 本购买证 / ${todayTransN || certs.reduce((s, c) => s + transCount(c), 0)} 张运输证`, "openCertDlg(false)", ""],
["2", "人工关联计划", "核对证件、车辆后点击确认关联", waitLink ? `${waitLink} 条待确认` : "暂无待关联计划", "focusCertWait()", step2on ? "on" : ""],
["3", "余量预警", "完成后扣减已完成量;购买证或运输证余量 ≤ 自设预警量时预警", vehWarnRows.length ? `${vehWarnRows.length} 车次预警` : "关联、完成后自动计算", "focusCertVehWarn()", step3on ? "warn" : ""]
].map(([n, title, desc, s, fn, cls]) => `<div class="cert-step ${cls}" onclick="${fn}"><span class="n">${n}</span><div><b>${title}</b><p>${desc}</p><div class="s">${s}</div></div></div>`).join("");
}
document.getElementById("listAlerts").innerHTML = `
${overN ? `<div class="alert red">关联计划量已超过购买证或运输证证载 ${overN} 条</div>` : ""}
${vehN ? `<div class="alert red">关联车辆计划量预警 ${vehN} 车次(超证载 / 购买证或运输证余量 ≤ 自设预警量)</div>` : ""}
${transN ? `<div class="alert red">运输证余量预警 ${transN} 条</div>` : ""}
${buyWarnN ? `<div class="alert red">购买证余量预警 ${buyWarnN} 条</div>` : ""}
${warnN && !buyWarnN ? `<div class="alert red">购买证即将到期 ${warnN} 条</div>` : ""}
<div class="alert ok">在册购买证 ${certs.length} 条 · 待关联计划 ${waitLink} 条</div>`;
document.getElementById("listStats").innerHTML = [
["今日上传证件", todayN + " / " + todayTransN, "购买证 / 运输证", "openCertDlg(false)"],
["购买证预警", buyWarnN, "余量 ≤ 自设预警量", "filterCertSt('余量预警')"],
["运输证预警", transN, "余量 ≤ 自设预警量", "filterCertSt('运输证预警')"],
["车辆计划预警", vehN, "各车运输计划量对照两证", "focusCertVehWarn()"]
].map(([k, v, s, fn]) => `<div class="stat${fn ? " stat-click" : ""}" ${fn ? `onclick="${fn}"` : ""}><div class="k">${k}</div><div class="v">${v}</div><div class="s">${s}</div></div>`).join("");
renderWaitPlans();
const rows = certs.filter((c) => {
const blob = [c.buyNo, transNos(c).join(" "), c.product, c.buyer, c.seller,
transOf(c).map((t) => t.plates).join(" "), transOf(c).map((t) => t.consignee).join(" "),
transOf(c).map((t) => t.carrier).join(" ")].join(" ");
if (q && !blob.includes(q)) return false;
if (st && certStatusOf(c) !== st) return false;
return true;
});
document.getElementById("listCount").textContent = `共 ${rows.length} 条`;
document.getElementById("certListTable").innerHTML = rows.length ? `<table>
<thead><tr><th>状态</th><th>购买证号</th><th>运输证</th><th>品名</th><th>购买单位</th><th>运输公司</th><th>证载数量</th><th>关联计划量</th><th>已完成量</th><th>分派车辆</th><th>购买证余量</th><th>运输证余量</th></tr></thead>
<tbody>${rows.map((c) => {
const nos = transNos(c);
const carriers = [...new Set(transOf(c).map((t) => t.carrier).filter(Boolean))];
const cars = allDispatches(c);
const warnItems = transOf(c).filter(transItemWarn);
return `<tr class="row-link" onclick="showCertView('detail', ${c.id})">
<td>${certStHtml(certStatusOf(c))}</td>
<td class="cell-no">${c.buyNo}</td>
<td>${nos.length ? nos.map((n) => `<div>${n}</div>`).join("") : "—"}<div class="muted">${transCount(c)} 张</div></td>
<td>${c.product}<div class="muted">${c.ctrl || ""}</div></td>
<td>${c.buyer || "—"}</td>
<td>${carriers.join("<br>") || "—"}</td>
<td>${c.buyQty} ${c.unit}</td>
<td class="${remainGap(c) < -1e-6 ? "staff-due" : ""}">${fmtTon(holdingOf(c))} ${c.unit}<div class="muted">${openLinkedPlans(c).length} 条在途</div></td>
<td>${fmtTon(deductedOf(c))} ${c.unit}</td>
<td class="plate-cell">${cars.length ? `<table class="plate-mini"><thead><tr><th>车牌号</th><th>运输证总量</th><th>计划量</th><th>已完成量</th><th>余量</th></tr></thead><tbody>${cars.map((d) => {
const t = findTrans(c, d.transId);
const vh = vehiclePlanHold(d.plate, d.transId);
const done = vehicleDeduct(d.plate, d.transId);
const over = vehicleHold(d.plate, d.transId) - Number(d.qty || 0) > 1e-6;
const total = t ? Number(t.transQty || 0) : 0;
const left = t ? transItemGap(t) : 0;
const leftWarn = t && left <= transWarnQty(t) + 1e-9;
return `<tr><td>${d.plate}${over ? `<span class="need-tag">超</span>` : ""}</td><td>${fmtTon(total)}${c.unit}</td><td>${fmtTon(vh)}${c.unit}</td><td>${fmtTon(done)}${c.unit}</td><td class="${leftWarn ? "staff-due" : ""}">${fmtTon(left)}${c.unit}</td></tr>`;
}).join("")}</tbody></table>` : "—"}</td>
<td>${remainHtml(c)}</td>
<td>${transRemainHtml(c)}${warnItems.length ? `<div class="muted">${warnItems.length} 张预警</div>` : ""}</td>
</tr>`;
}).join("")}</tbody></table>` : `<div class="empty">没有记录。请先上传购买证及运输证。</div>`;
updateBadges();
}
function filterCertSt(st) {
const sel = document.getElementById("certSt");
if (sel) sel.value = st || "";
showCertView("list");
}
function focusCertWait() {
showCertView("wait");
}
function focusCertVehWarn() {
showCertView("list");
}
function waitCertOpts(p, selectedId, kind) {
const pred = kind === "procure" ? certIsProcure : certIsShipBuy;
const opts = [`<option value="">请选择</option>`];
certs.forEach((c) => {
const hit = pred(p, c);
if (!hit && c.id !== selectedId) return;
if (certVoided(c) && c.id !== selectedId) return;
opts.push(`<option value="${c.id}" ${c.id === selectedId ? "selected" : ""} ${certVoided(c) ? "disabled" : ""}>${c.buyNo} · ${certVoided(c) ? "已核销" : "余 " + fmtTon(remain(c)) + c.unit}${hit ? " ✓" : ""}</option>`);
});
return opts.join("");
}
function waitBuyLinked(p) {
if (planNeedProcureCert(p)) return !!(p.procureCertId && p.shipCertId);
return !!(p.certId || p.shipCertId);
}
function waitOpBtns(p) {
const buyOn = waitBuyLinked(p);
const transOn = !!p.transId;
const buyBtn = buyOn
? `<button class="btn btn-sm wait-btn-buy wait-on" type="button" data-wait-op="cancel-buy" data-plan-id="${p.id}" title="已关联,需先取消关联才能修改" onclick="cancelWaitBuyLink(${p.id})">已关联</button>`
: `<button class="btn btn-sm wait-btn-buy" type="button" data-wait-op="buy" data-plan-id="${p.id}" title="点击关联购买证" onclick="openWaitBuyDlg(${p.id})">待关联</button>`;
const transBtn = transOn
? `<button class="btn btn-sm wait-btn-trans wait-on" type="button" data-wait-op="cancel-trans" data-plan-id="${p.id}" title="已关联,需先取消关联才能修改" onclick="cancelWaitTransLink(${p.id})">已关联</button>`
: `<button class="btn btn-sm wait-btn-trans" type="button" data-wait-op="trans" data-plan-id="${p.id}" title="点击关联运输证" onclick="openWaitTransDlg(${p.id})">待关联</button>`;
return `<td class="col-op-buy">${buyBtn}</td><td class="col-op-trans">${transBtn}</td>`;
}
function cancelWaitBuyLink(planId) {
const p = plans.find((x) => x.id === planId);
if (!p || !waitBuyLinked(p)) return;
const extra = p.transId ? "将同时取消已关联的运输证。" : "";
if (!confirm(`已关联购买证不可直接修改。${extra}确定取消关联购买证?`)) return;
p.certId = null;
p.shipCertId = null;
p.procureCertId = null;
p.transId = null;
delete waitLinkPicks[planId];
toast(`${p.no} 已取消购买证关联`);
renderCertList();
updateBadges();
}
function cancelWaitTransLink(planId) {
const p = plans.find((x) => x.id === planId);
if (!p || !p.transId) return;
if (!confirm("已关联运输证不可直接修改。确定取消关联运输证?")) return;
p.transId = null;
if (waitLinkPicks[planId]) waitLinkPicks[planId].transId = "";
toast(`${p.no} 已取消运输证关联`);
renderCertList();
updateBadges();
}
function waitPlanTransOf(p) {
if (!p || !p.transId) return { c: null, t: null };
const ids = [p.certId, p.shipCertId, p.procureCertId].filter(Boolean);
for (const id of ids) {
const c = certs.find((x) => x.id === id);
const t = c ? findTrans(c, p.transId) : null;
if (t) return { c, t };
}
for (const c of certs) {
const t = findTrans(c, p.transId);
if (t) return { c, t };
}
return { c: null, t: null };
}
function waitLinkRowHtml(p, i) {
const { c, t } = waitPlanTransOf(p);
const unit = (c && c.unit) || p.unit || "吨";
const transTotal = t ? Number(t.transQty || 0) : null;
const sent = t ? transItemDeduct(t) : null;
const left = t ? transItemGap(t) : null;
const warnAt = t ? transWarnQty(t) : 0;
const leftWarn = left != null && left <= warnAt + 1e-9;
return `<tr>
${waitOpBtns(p)}
<td class="idx">${(i || 0) + 1}</td>
<td class="cell-no col-no">${p.no}</td>
<td>${planStHtml(p)}</td>
<td>${p.date}</td>
<td>${planDeliverAt(p)}</td>
<td>${planTypeName(p)}</td>
<td>${p.tractor || "—"}</td>
<td>${planCrew(p)}</td>
<td class="clip">${p.loadFrom || "—"}</td>
<td class="col-ship">${p.customer || p.settler || "—"}</td>
<td>${p.product || "—"}${p.ctrl === "易制毒" ? `<span class="prod-tag red">易制毒</span>` : ""}</td>
<td>${p.spec || "—"}</td>
<td>${p.pack || "—"}</td>
<td class="num">${transTotal == null ? "—" : `${fmtTon(transTotal)} ${unit}`}</td>
<td class="num qty">${p.planQty}${p.unit ? " " + p.unit : ""}</td>
<td class="num">${sent == null ? "—" : `${fmtTon(sent)} ${unit}`}</td>
<td class="num ${leftWarn ? "staff-due" : ""}">${left == null ? "—" : `${fmtTon(left)} ${unit}`}</td>
<td class="clip">${planContractNo(p)}</td>
</tr>`;
}
function waitPlanBarHtml(p) {
return `<div class="wait-plan-bar">
<span>计划 <b>${p.no}</b></span>
<span>${planTypeName(p)}</span>
<span>${p.product || "—"}${p.ctrl === "易制毒" ? `<span class="prod-tag red">易制毒</span>` : ""}</span>
<span>计划量 <b>${p.planQty}${p.unit ? " " + p.unit : ""}</b></span>
<span>车牌 <b>${p.tractor || "—"}</b></span>
<span>装货 ${p.loadFrom || "—"}</span>
<span>送货 ${p.customer || p.settler || "—"}</span>
</div>`;
}
function waitBuyCardHtml(c, extra, pickHtml) {
if (!c) return `<div class="empty">暂无购买证信息</div>`;
return `<div class="cert-pane buy">
<div class="cert-pane-h">
${pickHtml || ""}
<span class="cert-tag buy">购买证</span>
<h2>购买备案证明</h2>
${extra || ""}
</div>
<article class="card">
<div class="card-h"><h2>${c.buyNo || "未编号"}</h2></div>
<div class="card-b"><div class="kv">
${kv("编号", c.buyNo)}${kv("核发日期", c.buyDate)}
${kv("购买单位", c.buyer)}${kv("法定代表人", c.buyerLegal)}
${kv("电话", c.buyerTel)}${kv("住所", c.buyerAddr)}
${kv("销售单位", c.seller)}${kv("销售方法人", c.sellerLegal)}
${kv("购买物品", c.product)}${kv("用途", c.usage)}
${kv("数量", (c.buyQty || "—") + " " + (c.unit || ""))}${kv("有效次数", c.buyTimes)}
${kv("有效期限", (c.buyFrom || "") + " 至 " + (c.buyDue || ""))}
${kv("公安机关", c.buyPolice)}${kv("经办人", c.buyHandler)}
${kv("余量", fmtTon(remain(c)) + " " + (c.unit || ""))}
</div>${c.buyUrl ? `<img src="${c.buyUrl}" alt="购买证" style="margin-top:12px;max-height:160px;object-fit:contain;width:100%">` : ""}</div>
</article>
</div>`;
}
function waitTransCardHtml(c, t, plate, actionHtml) {
const hit = plate && (platesOfTrans(t).includes(plate) || String(t.plates || "").includes(plate));
const left = transItemRemain(t);
const warn = transItemWarn(t);
const used = transItemHold(t);
const tPct = Math.min(100, (used / (Number(t.transQty || 1) || 1)) * 100);
return `<article class="card wait-trans-sheet${hit ? " hit" : ""}">
<div class="card-h">
<h2>${t.transNo || "未编号"}</h2>
${hit ? `<span class="match-rec veh">匹配 ${plate}</span>` : ""}
${actionHtml || ""}
</div>
<div class="card-b"><div class="kv">
${kv("编号", t.transNo)}${kv("核发日期", t.transDate)}
${kv("货主", t.owner)}${kv("法定代表人", t.ownerLegal)}
${kv("运输物品", c.product)}${kv("数量", (t.transQty || "—") + " " + (c.unit || ""))}
${kv("包装", t.pack)}${kv("购买许可证号", t.permitNo)}
${kv("运输单位", t.carrier)}${kv("运输方式", t.mode)}
${kv("车号(次)", t.plates || platesOfTrans(t).join("、") || "—")}${kv("收货单位", t.consignee)}
${kv("运输路线", t.route)}${kv("申请单位", t.applicantUnit)}
${kv("有效期限", (t.transFrom || "") + " 至 " + (t.transDue || ""))}
${kv("公安机关", t.transPolice)}${kv("经办人", t.transHandler)}
</div>
<div style="margin-top:14px;color:var(--muted);font-size:12px">本证进度(已完成 ${transItemDeduct(t)} + 在途计划 ${transItemPlan(t)} / 运输证 ${t.transQty || 0})${warn ? ` · 余量 ${left} 吨预警` : ""}</div>
<div class="qty-bar"><div class="qty-fill${warn ? " warn" : ""}" style="width:${tPct}%"></div></div>
${t.transUrl ? `<img src="${t.transUrl}" alt="运输证" style="margin-top:12px;max-height:160px;object-fit:contain;width:100%">` : ""}
</div>
</article>`;
}
function waitDemoCertsForPlan(p) {
return certs.filter((c) => certSelectable(c) && (!p.ctrl || !c.ctrl || p.ctrl === c.ctrl));
}
function waitBuyCertTableHtml(p, kind, selectedId) {
const pred = kind === "procure" ? certIsProcure : certIsShipBuy;
const title = kind === "procure" ? "装货方购买证" : "送货方购买证";
const name = kind === "procure" ? "waitProcureCert" : "waitShipCert";
let rows = certs.filter((c) => (pred(p, c) || c.id === selectedId) && (!certVoided(c) || c.id === selectedId));
if (!rows.length) rows = waitDemoCertsForPlan(p);
if (!rows.length) return `<div class="empty">没有可关联的${title}(已核销证件不可选)。</div>`;
const live = rows.filter((c) => !certVoided(c));
const picked = (selectedId && !certVoided(certs.find((x) => x.id === selectedId)) && selectedId)
|| (kind === "procure" ? (suggestProcureCert(p) || {}).id : (suggestShipCert(p) || {}).id)
|| (live[0] && live[0].id);
if (!live.length) return `<div class="empty">匹配的${title}均已核销,无法再关联。</div>`;
return live.map((c) => {
const hit = pred(p, c);
const pick = `<label class="wait-cert-pick"><input type="radio" name="${name}" value="${c.id}" ${c.id === picked ? "checked" : ""} onchange="refreshWaitBuyTransPane()" /></label>`;
const extra = `<span class="muted">${title}</span>${hit ? `<span class="match-rec">已匹配</span>` : ""}`;
return waitBuyCardHtml(c, extra, pick);
}).join("");
}
function refreshWaitBuyTransPane() {
const p = plans.find((x) => x.id === waitBuyPlanId);
const box = document.getElementById("waitBuyTransPane");
if (!p || !box) return;
const ids = [...document.querySelectorAll("#waitBuyDialog [name=waitProcureCert]:checked, #waitBuyDialog [name=waitShipCert]:checked")]
.map((el) => +el.value).filter(Boolean);
const uniq = [...new Set(ids)];
if (!uniq.length) {
box.innerHTML = `<div class="empty">请先在左侧选择购买证,右侧带出该证下的运输证信息。</div>`;
return;
}
const plate = p.tractor || "";
const cards = [];
const seen = new Set();
uniq.forEach((id) => {
const c = certs.find((x) => x.id === id);
if (!certSelectable(c)) return;
const list = transOf(c).filter((t) => transSelectable(c, t));
const matched = list.filter((t) => plate && (platesOfTrans(t).includes(plate) || String(t.plates || "").includes(plate)));
const show = matched.length ? matched : list;
if (!show.length && list.length) show.push(...list);
show.forEach((t) => {
if (seen.has(t.id)) return;
seen.add(t.id);
cards.push(waitTransCardHtml(c, t, plate));
});
});
const oneSide = planNeedProcureCert(p);
box.innerHTML = `<div class="cert-pane trans">
<div class="cert-pane-h">
<span class="cert-tag trans">运输证</span>
<h2>运输备案证明</h2>
<span class="muted">${oneSide ? "任选一方" : `${cards.length} 张`}${plate ? ` · ${plate}` : ""}</span>
</div>
${cards.length ? cards.join("") : `<div class="empty">所选购买证下暂无对应车辆的运输证。</div>`}
</div>`;
}
function openWaitBuyDlg(planId) {
const p = plans.find((x) => x.id === planId);
if (!p) return;
if (waitBuyLinked(p)) { cancelWaitBuyLink(planId); return; }
if (!certs.length) { toast("请先上传购买证及运输证"); openCertDlg(false); return; }
waitBuyPlanId = planId;
const scene = planCertScene(p);
const needProcure = planNeedProcureCert(p);
document.getElementById("waitBuyHint").textContent = needProcure
? `${p.no} · 直销单 · 装货方、送货方均需关联购买证;运输证只需选择一方购买证下对应车辆的运输证`
: `${p.no} · ${planSceneLabel(scene)} · 请选择送货方购买证,右侧同步查看该证下运输证`;
const shipSel = p.shipCertId || (suggestShipCert(p) || {}).id;
const procSel = p.procureCertId || p.certId || (suggestProcureCert(p) || {}).id;
const pickHtml = needProcure
? waitBuyCertTableHtml(p, "procure", procSel) + `<div style="height:14px"></div>` + waitBuyCertTableHtml(p, "ship", shipSel)
: waitBuyCertTableHtml(p, "ship", shipSel);
document.getElementById("waitBuyBody").innerHTML = waitPlanBarHtml(p) + `<div class="wait-dlg-split">
<div class="wait-info-stack">${pickHtml}</div>
<div class="wait-info-stack" id="waitBuyTransPane"></div>
</div>`;
refreshWaitBuyTransPane();
const buyDlg = document.getElementById("waitBuyDialog");
if (buyDlg.open) buyDlg.close();
buyDlg.showModal();
}
function closeWaitBuyDlg() {
document.getElementById("waitBuyDialog").close();
waitBuyPlanId = null;
}
function saveWaitBuyCert() {
const p = plans.find((x) => x.id === waitBuyPlanId);
if (!p) return;
const shipEl = document.querySelector("#waitBuyDialog [name=waitShipCert]:checked");
const procEl = document.querySelector("#waitBuyDialog [name=waitProcureCert]:checked");
const shipId = shipEl ? +shipEl.value : 0;
const procId = procEl ? +procEl.value : 0;
if (planNeedProcureCert(p)) {
if (!procId || !shipId) { toast("直销单请同时选择装货方购买证和送货方购买证"); return; }
p.procureCertId = procId;
p.shipCertId = shipId;
p.certId = procId;
} else {
if (!shipId) { toast("请选择送货方购买证"); return; }
p.shipCertId = shipId;
p.procureCertId = null;
p.certId = shipId;
}
p.transId = null;
closeWaitBuyDlg();
toast(`${p.no} 已关联购买证`);
renderCertList();
updateBadges();
}
function waitTransRowsForPlan(p) {
const ids = planNeedProcureCert(p)
? [p.procureCertId || p.certId, p.shipCertId]
: [p.shipCertId || p.certId];
const uniq = [...new Set(ids.filter(Boolean))];
const plate = p.tractor || "";
const rows = [];
const seen = new Set();
uniq.forEach((id) => {
const c = certs.find((x) => x.id === id);
if (!c) return;
transOf(c).forEach((t) => {
if (!transSelectable(c, t)) return;
const hit = !plate || platesOfTrans(t).includes(plate) || String(t.plates || "").includes(plate);
if (seen.has(t.id)) return;
if (hit) {
seen.add(t.id);
rows.push({ c, t, hit: true });
}
});
});
if (!rows.length) {
uniq.forEach((id) => {
const c = certs.find((x) => x.id === id);
if (!c) return;
transOf(c).forEach((t) => {
if (!transSelectable(c, t) || seen.has(t.id)) return;
seen.add(t.id);
rows.push({ c, t, hit: false });
});
});
}
return rows;
}
function openWaitTransDlg(planId) {
const p = plans.find((x) => x.id === planId);
if (!p) return;
if (!waitBuyLinked(p)) {
toast(planNeedProcureCert(p) ? "直销单请先关联装货方、送货方购买证" : "请先关联购买证");
openWaitBuyDlg(planId);
return;
}
if (p.transId) { cancelWaitTransLink(planId); return; }
waitTransPlanId = planId;
const plate = p.tractor || "";
const rows = waitTransRowsForPlan(p);
const buyIds = [...new Set((planNeedProcureCert(p)
? [p.procureCertId || p.certId, p.shipCertId]
: [p.shipCertId || p.certId]).filter(Boolean))];
const buyCards = buyIds.map((id) => {
const c = certs.find((x) => x.id === id);
if (!c) return "";
const tag = planNeedProcureCert(p) && (id === p.shipCertId && id !== (p.procureCertId || p.certId))
? `<span class="match-rec">送货方</span>`
: (planNeedProcureCert(p) ? `<span class="match-rec">装货方</span>` : `<span class="match-rec">送货方</span>`);
return waitBuyCardHtml(c, tag);
}).join("") || `<div class="empty">请先关联购买证</div>`;
document.getElementById("waitTransHint").textContent = planNeedProcureCert(p)
? `${p.no} · 车牌 ${plate || "—"} · 装货方/送货方购买证已列左侧,运输证只需关联其中一方下对应该车的运输证`
: `${p.no} · 车牌 ${plate || "—"} · 先核对已关联购买证,再选择该证下对应该车的运输证`;
const transHtml = rows.length
? `<div class="cert-pane trans">
<div class="cert-pane-h">
<span class="cert-tag trans">运输证</span>
<h2>运输备案证明</h2>
<span class="muted">${rows.length} 张${planNeedProcureCert(p) ? " · 任选一方" : ""}</span>
</div>
${rows.map(({ c, t }) => waitTransCardHtml(c, t, plate,
`<button class="btn btn-sm btn-primary" type="button" onclick="saveWaitTransCert(${c.id}, ${t.id})">关联此证</button>`
)).join("")}
</div>`
: `<div class="empty">已关联购买证下没有车牌 ${plate || ""} 对应的运输证,请先追加运输证或核对车号。</div>`;
document.getElementById("waitTransBody").innerHTML = waitPlanBarHtml(p) + `<div class="wait-dlg-split">
<div class="wait-info-stack">${buyCards}</div>
<div class="wait-info-stack">${transHtml}</div>
</div>`;
const transDlg = document.getElementById("waitTransDialog");
if (transDlg.open) transDlg.close();
transDlg.showModal();
}
function closeWaitTransDlg() {
document.getElementById("waitTransDialog").close();
waitTransPlanId = null;
}
function saveWaitTransCert(certId, transId) {
const p = plans.find((x) => x.id === waitTransPlanId);
const c = certs.find((x) => x.id === certId);
const t = c ? findTrans(c, transId) : null;
if (!p || !c || !t) return;
const err = attachPlanToCert(p, c, t, true, p.tractor);
if (err) { toast(err); return; }
if (planNeedProcureCert(p) && (!p.shipCertId || !(p.procureCertId || p.certId))) {
toast("直销单还需同时关联装货方、送货方购买证");
closeWaitTransDlg();
openWaitBuyDlg(p.id);
return;
}
closeWaitTransDlg();
toast(`已关联运输证 ${t.transNo} · ${p.tractor || ""}`);
renderCertList();
updateBadges();
}
function onWaitShipCertChange(planId) {
const p = plans.find((x) => x.id === planId);
const sel = document.querySelector(`[data-wait-ship="${planId}"]`);
if (!p || !sel) return;
const shipId = +sel.value || "";
const saved = waitLinkPicks[planId] || {};
if (planNeedProcureCert(p)) {
waitLinkPicks[planId] = { ...saved, shipCertId: shipId };
} else {
const c = certs.find((x) => x.id === shipId);
const plate = saved.plate || (c && suggestVehicleFromCert(c, p)) || "";
const t = transForPlate(c, plate) || suggestTransForPlan(c, p);
waitLinkPicks[planId] = { certId: shipId, shipCertId: shipId, transId: t && t.id, plate };
}
renderCertList();
}
function onWaitProcureCertChange(planId) {
const p = plans.find((x) => x.id === planId);
const sel = document.querySelector(`[data-wait-procure="${planId}"]`);
if (!p || !sel) return;
const saved = waitLinkPicks[planId] || {};
const certId = +sel.value || "";
const c = certs.find((x) => x.id === certId);
const plate = saved.plate || (c && suggestVehicleFromCert(c, p)) || "";
const t = transForPlate(c, plate) || suggestTransForPlan(c, p);
waitLinkPicks[planId] = { ...saved, certId, transId: t && t.id, plate };
renderCertList();
}
function onWaitTransChange(planId) {
const p = plans.find((x) => x.id === planId);
const sel = document.querySelector(`[data-wait-trans="${planId}"]`);
if (!p || !sel) return;
const saved = waitLinkPicks[planId] || {};
const pick = waitPickOf(p);
const c = pick.c;
const t = c ? findTrans(c, +sel.value) : null;
let plate = saved.plate || pick.plate || "";
if (t && plate && !platesOfTrans(t).includes(plate) && !String(t.plates || "").includes(plate)) {
plate = suggestVehicleForPlan(t, p);
}
waitLinkPicks[planId] = { ...saved, certId: c && c.id, shipCertId: pick.shipC && pick.shipC.id, transId: +sel.value, plate };
renderCertList();
}
function onWaitVehChange(planId) {
const p = plans.find((x) => x.id === planId);
const sel = document.querySelector(`[data-wait-veh="${planId}"]`);
if (!sel) return;
const saved = waitLinkPicks[planId] || {};
const pick = waitPickOf(p);
const c = pick.c;
const plate = sel.value;
const t = transForPlate(c, plate);
waitLinkPicks[planId] = { ...saved, certId: c && c.id, shipCertId: pick.shipC && pick.shipC.id, transId: t ? t.id : "", plate };
renderCertList();
}
function applyWaitLinkToPlan(p, c, t, plate, shipC) {
if (planNeedProcureCert(p)) {
p.procureCertId = c.id;
p.shipCertId = shipC ? shipC.id : null;
} else {
p.shipCertId = (shipC && shipC.id) || c.id;
p.procureCertId = null;
}
return attachPlanToCert(p, c, t, true, plate);
}
function confirmWaitLink(planId, silent) {
const p = plans.find((x) => x.id === planId);
if (!p) return false;
if (!certs.length) {
if (!silent) { toast("请先上传购买证及运输证"); openCertDlg(false); }
return false;
}
const transSel = document.querySelector(`[data-wait-trans="${planId}"]`);
const vehSel = document.querySelector(`[data-wait-veh="${planId}"]`);
const shipSel = document.querySelector(`[data-wait-ship="${planId}"]`);
const procSel = document.querySelector(`[data-wait-procure="${planId}"]`);
const pick = waitPickOf(p);
const scene = planCertScene(p);
let c = pick.c;
if (planNeedProcureCert(p) && procSel && procSel.value) c = certs.find((x) => x.id === +procSel.value) || c;
else if (!planNeedProcureCert(p) && shipSel && shipSel.value) c = certs.find((x) => x.id === +shipSel.value) || c;
const shipC = (shipSel && shipSel.value && certs.find((x) => x.id === +shipSel.value)) || pick.shipC;
const t = (transSel && c && findTrans(c, +transSel.value)) || pick.t;
const plate = (vehSel && vehSel.value) || pick.plate;
if (!c || !t) {
if (!silent) {
toast(scene === "direct" ? "直销单请先匹配山峡采购证及运输证" : "请先匹配送货方购买证与运输证");
linkWaitPlan(planId);
}
return false;
}
if (planNeedProcureCert(p) && !shipC) {
if (!silent) toast("直销单还需关联送货方购买证");
return false;
}
const err = applyWaitLinkToPlan(p, c, t, plate, shipC);
if (err) {
if (!silent) toast(err);
return false;
}
delete waitLinkPicks[planId];
if (!silent) {
const after = afterLinkCalc(c, t, p.tractor, 0, p.id, shipC);
const flag = after.over ? ",已超证预警" : (after.warn ? `,余量已达自设预警量` : "");
const extra = planNeedProcureCert(p) && shipC ? `,送货方证 ${shipC.buyNo}` : "";
toast(`已关联 ${p.no} · ${planSceneLabel(scene)} → ${t.transNo}${p.tractor ? " · " + p.tractor : ""}${extra},本车运输计划量 ${fmtTon(p.vehPlanQty != null ? p.vehPlanQty : p.planQty)}${c.unit}${flag}`);
afterManualLink();
}
return true;
}
function afterManualLink() {
showMod("cert");
showCertView("list");
updateBadges();
setTimeout(() => {
if (waitLinkPlans().length) focusCertWait();
else focusCertVehWarn();
}, 40);
}
function confirmAllWaitLinks() {
const ids = waitLinkPlans().map((p) => p.id);
let n = 0;
ids.forEach((id) => {
const p = plans.find((x) => x.id === id);
const pick = waitPickOf(p || {});
if (!p || !pick.c || !pick.t || !pick.matchShip) return;
if (planCertScene(p) === "direct" && (!pick.matchProcure || !pick.shipC)) return;
if (confirmWaitLink(id, true)) n += 1;
});
if (!n) {
toast("没有已按客户、车辆自动匹配、可直接确认的计划");
return;
}
toast(`已人工关联 ${n} 条计划,已按购买证 / 运输证计算各车运输计划量`);
afterManualLink();
}
function linkWaitPlan(planId) {
const p = plans.find((x) => x.id === planId);
if (!p) return;
if (!certs.length) {
toast("请先上传购买证及运输证");
openCertDlg(false);
return;
}
const c = suggestCertForPlan(p) || certs[0];
if (!c) {
toast("没有可改选的购买证,请先上传证件");
showCertView("list");
return;
}
showCertView("detail", c.id);
openCertLinkPlanDlg(c.id, [p.id]);
}
function renderCertDetail() {
const c = certs.find((x) => x.id === currentCertId);
if (!c) { showCertView("list"); return; }
const list = transOf(c);
document.getElementById("navDetail").disabled = false;
document.getElementById("detailTitle").textContent = c.product + " · 购买证 / 运输证";
document.getElementById("detailSub").textContent =
`识别生成于 ${c.created} · 购买证 ${c.buyNo} · 运输证 ${list.length} 张`;
const r = remain(c);
const tr = transRemain(c);
const hold = holdingOf(c);
const done = deductedOf(c);
const unassignedAll = list.reduce((s, t) => s + unassignedTrans(t), 0);
const companies = [...new Set(list.map((t) => t.carrier).filter(Boolean))];
document.getElementById("detailStats").innerHTML = [
["购买证数量", c.buyQty + " " + c.unit, "购买备案数量", "tone-buy"],
["购买证剩余", (remainGap(c) < 0 ? remainGap(c).toFixed(2) : r) + " " + c.unit, `已完成扣减 ${fmtTon(done)} · 在途计划 ${fmtTon(hold)}`, "tone-buy"],
["运输证张数", list.length + " 张", `运量合计 ${transTotal(c)} ${c.unit} · 未分派 ${unassignedAll} ${c.unit}`, "tone-trans"],
["运输证剩余", tr + " " + c.unit, transWarn(c) ? `有运输证余量 ≤ 自设预警量` : `余量 ≤ 各证自设预警量时预警`, "tone-trans"]
].map(([k, v, s, tone]) => `<div class="stat ${tone || ""}"><div class="k">${k}</div><div class="v">${v}</div><div class="s">${s}</div></div>`).join("");
const warnItems = list.filter(transItemWarn);
const linked = linkedPlansOf(c);
const vehRows = vehicleRowsOf(c);
const waitN = waitLinkPlans().filter((p) => planMatchesCert(p, c)).length;
document.getElementById("certRelHint").innerHTML =
`① 已上传运输证 <em>${list.length}</em> 张 → ② 人工关联计划 <em>${linked.length}</em> 条(在途计划 ${fmtTon(hold)}${c.unit},已完成 ${fmtTon(done)}${c.unit})→ ③ 各车运输计划量 = 受购买证、运输证数量约束;完成后扣减实发量;余量 ≤ 自设预警量时预警。${waitN ? ` 另有 ${waitN} 条品名匹配计划待勾选。` : ""}`;
const formulaEl = document.getElementById("certQtyFormula");
if (formulaEl) {
const buyCls = remainGap(c) < -1e-6 ? "bad" : (buyWarn(c) ? "warn" : "");
formulaEl.innerHTML = [
`<span>购买证 <b>${fmtTon(c.buyQty)}</b>${c.unit} − 已完成 <b>${fmtTon(done)}</b>${c.unit} − 在途计划 <b>${fmtTon(hold)}</b>${c.unit} = 余量 <b class="${buyCls}">${fmtTon(remainGap(c))}</b>${c.unit}</span>`,
...list.map((t) => {
const used = transItemPlan(t);
const shippedT = transItemDeduct(t);
const g = transItemGap(t);
const cls = g < -1e-6 ? "bad" : (transItemWarn(t) ? "warn" : "");
return `<span>运输证 ${t.transNo} <b>${fmtTon(t.transQty)}</b>${c.unit} − 已完成 <b>${fmtTon(shippedT)}</b>${c.unit} − 在途计划 <b>${fmtTon(used)}</b>${c.unit} = 余量 <b class="${cls}">${fmtTon(g)}</b>${c.unit}</span>`;
})
].join("");
}
const warnCard = document.getElementById("certWarnQtyCard");
if (warnCard) {
warnCard.innerHTML = `<div class="card-h"><h2>余量预警设置</h2><span class="muted">余量 ≤ 该值时预警;默认按证件核准总量的 10%,可自行调整</span></div>
<div class="card-b"><table class="warn-qty-table">
<thead><tr><th>证件</th><th>证号</th><th>证载</th><th>当前余量</th><th>预警量</th></tr></thead>
<tbody>
<tr>
<td><span class="cert-tag buy">购买证</span></td>
<td class="cell-no">${c.buyNo || "—"}</td>
<td>${fmtTon(c.buyQty)} ${c.unit}</td>
<td>${fmtTon(remainGap(c))} ${c.unit}</td>
<td>${warnQtyField(c.id, null, c.unit)}</td>
</tr>
${list.map((t) => `<tr>
<td><span class="cert-tag trans">运输证</span></td>
<td class="cell-no">${t.transNo || "—"}</td>
<td>${fmtTon(t.transQty)} ${c.unit}</td>
<td>${fmtTon(transItemGap(t))} ${c.unit}</td>
<td>${warnQtyField(c.id, t.id, c.unit)}</td>
</tr>`).join("")}
</tbody>
</table></div>`;
}
document.getElementById("detailWarns").innerHTML = [
remainGap(c) < -1e-6 ? `<div class="alert red">购买证关联计划量已超证载 ${(-remainGap(c)).toFixed(2)} ${c.unit}。</div>` : "",
buyWarn(c) && remainGap(c) >= 0 ? `<div class="alert red">购买证余量 ${fmtTon(remain(c))} ${c.unit} ≤ ${fmtTon(buyWarnQty(c))} 吨预警。</div>` : "",
...warnItems.map((t) =>
`<div class="alert red">运输证 ${t.transNo} 余量 ${transItemRemain(t)} ${c.unit}(阈值 ${fmtTon(transWarnQty(t))} 吨)${transItemGap(t) < 0 ? ",已超证" : ",请尽快安排同公司车辆发运"}。</div>`
),
...vehRows.filter((r) => r.level === "over").map((r) =>
`<div class="alert red">${r.plate} 关联计划量 ${r.hold} ${c.unit},${r.text}。</div>`
)
].filter(Boolean).join("");
document.getElementById("certVehicleWarn").innerHTML = vehRows.length ? `<table>
<thead><tr><th>车辆</th><th>驾驶员</th><th>占用运输证</th><th>关联的运输计划</th><th>两证可运上限</th><th>关联计划量</th><th>已完成扣减</th><th>本车余量</th><th>购买证余量</th><th>运输证余量</th><th>预警</th></tr></thead>
<tbody>${vehRows.map((r) => {
const t = findTrans(c, r.transId) || { transQty: 0 };
const leftVeh = (r.cap || 0) - r.hold;
const buyLeft = remainGap(c);
const transLeft = transItemGap(t);
const nPlan = vehLinkedPlans(r.plate, r.transId).length;
const plateArg = String(r.plate).replace(/\\/g, "\\\\").replace(/'/g, "\\'");
return `<tr class="${r.level === "over" ? "veh-over" : ""}">
<td class="cell-no">${r.plate}</td>
<td>${r.driver || "—"}</td>
<td>${r.transNo}<div class="muted">${r.carrier || ""}</div></td>
<td><button class="plan-n-link${nPlan ? "" : " off"}" type="button" onclick="openVehPlanDlg(${c.id}, ${r.transId}, '${plateArg}')">${nPlan} 条</button></td>
<td>${fmtTon(r.bound || r.cap || 0)} ${c.unit}<div class="muted">分派 ${r.cap || 0}</div></td>
<td class="${r.level === "over" ? "staff-due" : ""}">${fmtTon(r.planHold || 0)} ${c.unit}${r.level === "over" ? ` <span class="need-tag">超</span>` : ""}</td>
<td>${fmtTon(r.shipped || 0)} ${c.unit}</td>
<td class="${leftVeh < -1e-6 ? "staff-due" : ""}">${leftVeh.toFixed(2)} ${c.unit}</td>
<td class="${buyLeft < -1e-6 ? "staff-due" : ""}">${buyLeft.toFixed(2)} ${c.unit}</td>
<td class="${transLeft < -1e-6 ? "staff-due" : ""}">${transLeft.toFixed(2)} ${c.unit}</td>
<td>${r.level ? `<span class="remain ${r.level === "over" ? "bad" : "warn"}">${r.text}</span>` : `<span class="remain">正常</span>`}</td>
</tr>`;
}).join("")}</tbody></table>` : `<div class="empty">上传运输证后会带出车号。人工勾选计划后,各车计划量对照购买证 / 运输证证载预警。</div>`;
document.getElementById("certLinkedPlans").innerHTML = linked.length ? `<table>
<thead><tr><th>计划编号</th><th>状态</th><th>品名</th><th>车辆</th><th>占用运输证</th><th>计划量</th><th>已完成扣减</th></tr></thead>
<tbody>${linked.map((p) => {
const t = findTrans(c, p.transId);
const over = t && p.tractor && vehicleOver({ plate: p.tractor, qty: ((t.dispatches || []).find((d) => d.plate === p.tractor) || {}).qty || 0 }, t);
const doneN = deductQty(p);
return `<tr class="${over ? "veh-over" : ""}">
<td class="cell-no">${p.no}</td>
<td>${planStHtml(p)}</td>
<td>${p.product}<div class="muted">${p.ctrl || ""}</div></td>
<td>${p.tractor || "未派车"}<div class="muted">${p.driver || ""}</div></td>
<td>${t ? t.transNo : "—"}</td>
<td class="num">${p.planQty} ${p.unit || c.unit}${over ? ` <span class="need-tag">超分派</span>` : ""}<div class="muted">两证可运 ${fmtTon(calcVehPlanQty(c, t, p.tractor, p).qty)}</div></td>
<td class="num">${doneN ? doneN + " " + (p.unit || c.unit) : "未扣减"}</td>
</tr>`;
}).join("")}</tbody></table>` : `<div class="empty">尚未关联计划。</div>`;
const transCards = list.map((t, i) => {
const left = transItemRemain(t);
const warn = transItemWarn(t);
const used = transItemHold(t);
const tPct = Math.min(100, (used / (Number(t.transQty || 1) || 1)) * 100);
return `<article class="card">
<div class="card-h">
<h2>${t.transNo || "未编号"}</h2>
<span class="chip ghost">${t.transFile || "已识别"}</span>
</div>
<div class="card-b"><div class="kv">
${kv("编号", t.transNo)}${kv("核发日期", t.transDate)}
${kv("货主", t.owner)}${kv("法定代表人", t.ownerLegal)}
${kv("运输物品", c.product)}${kv("数量", (t.transQty || "—") + " " + c.unit)}
${kv("预警量", warnQtyField(c.id, t.id, c.unit))}
${kv("包装", t.pack)}${kv("购买许可证号", t.permitNo)}
${kv("运输单位", t.carrier)}${kv("运输方式", t.mode)}
${kv("车号(次)", t.plates)}${kv("收货单位", t.consignee)}
${kv("运输路线", t.route)}${kv("申请单位", t.applicantUnit)}
${kv("有效期限", (t.transFrom || "") + " 至 " + (t.transDue || ""))}
${kv("公安机关", t.transPolice)}${kv("经办人", t.transHandler)}
</div>
<div style="margin-top:14px;color:var(--muted);font-size:12px">本证进度(已完成 ${transItemDeduct(t)} + 在途计划 ${transItemPlan(t)} / 运输证 ${t.transQty || 0})${warn ? ` · 余量 ${left} 吨预警` : ""}</div>
<div class="qty-bar"><div class="qty-fill${warn ? " warn" : ""}" style="width:${tPct}%"></div></div>
${certThumbHtml(t.transUrl, "运输证")}
</div>
</article>`;
}).join("");
const transBlock = transCards
? `<div class="trans-card-row">${transCards}</div>`
: `<article class="card"><div class="card-b"><div class="empty">尚未识别运输证,请点击「再识别一张运输证」。</div></div></article>`;
document.getElementById("detailSheet").innerHTML = `
<div class="cert-duo">
<div class="cert-pane buy">
<div class="cert-pane-h">
<span class="cert-tag buy">购买证</span>
<h2>购买备案证明</h2>
</div>
<article class="card">
<div class="card-h"><h2>${c.buyNo || "未编号"}</h2><span class="chip ghost">${c.buyFile || "已识别"}</span></div>
<div class="card-b"><div class="kv">
${kv("编号", c.buyNo)}${kv("核发日期", c.buyDate)}
${kv("购买单位", c.buyer)}${kv("法定代表人", c.buyerLegal)}
${kv("电话", c.buyerTel)}${kv("住所", c.buyerAddr)}
${kv("销售单位", c.seller)}${kv("销售方法人", c.sellerLegal)}
${kv("购买物品", c.product)}${kv("用途", c.usage)}
${kv("数量", c.buyQty + " " + c.unit)}${kv("预警量", warnQtyField(c.id, null, c.unit))}
${kv("有效次数", c.buyTimes)}
${kv("有效期限", (c.buyFrom || "") + " 至 " + (c.buyDue || ""))}
${kv("公安机关", c.buyPolice)}${kv("经办人", c.buyHandler)}
</div>${certThumbHtml(c.buyUrl, "购买证")}</div>
</article>
</div>
<div class="cert-pane trans">
<div class="cert-pane-h">
<span class="cert-tag trans">运输证</span>
<h2>运输备案证明</h2>
<span class="muted">${list.length} 张</span>
</div>
${transBlock}
</div>
</div>`;
document.getElementById("dispatchHint").textContent =
`本购买证挂 ${list.length} 张运输证${companies.length ? "(" + companies.join("、") + ")" : ""}。按各运输证数量分别分派同公司车辆;运输证合计剩余 ${tr}${c.unit}。`;
document.getElementById("detailDispatch").innerHTML = list.length ? list.map((t) => {
const cars = t.dispatches || [];
const left = transItemRemain(t);
return `<div style="margin-bottom:16px">
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px">
<strong><span class="cert-tag trans" style="margin-right:6px">运输证</span>${t.transNo}</strong>
<span class="muted">${t.carrier || "—"} · 运量 ${t.transQty || 0}${c.unit} · 已分 ${assignedSum(t)}${c.unit} · 未分 ${unassignedTrans(t)}${c.unit}${transItemWarn(t) ? ` · 余 ${left} 吨预警` : ""}</span>
<span style="display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)">预警量 ${warnQtyField(c.id, t.id, c.unit)}</span>
<div class="actions">
<button class="btn btn-sm" type="button" onclick="splitTransToVehicles(${c.id}, ${t.id})">按本证均分</button>
<button class="btn btn-sm btn-primary" type="button" onclick="openDispatchDlg(${t.id})">分派车辆</button>
</div>
</div>
${cars.length ? `<table>
<thead><tr><th>车牌</th><th>驾驶员</th><th>运输公司</th><th>分派运量</th><th>关联计划量</th><th>已运</th><th>对照预警</th></tr></thead>
<tbody>${cars.map((d, i) => {
const hold = vehicleHold(d.plate, t.id);
const planHold = vehiclePlanHold(d.plate, t.id);
const shipped = vehicleDeduct(d.plate, t.id);
const over = vehicleOver(d, t);
const alert = vehicleAlertOf(c, t, hold, Number(d.qty || 0), planHold, shipped);
return `<tr class="${over ? "veh-over" : ""}">
<td class="cell-no">${d.plate}</td><td>${d.driver || "—"}</td>
<td>${t.carrier || "—"}</td>
<td><input type="number" step="0.01" min="${shipped}" value="${d.qty}" style="width:88px;height:28px;border:1px solid var(--line);border-radius:6px;padding:0 8px"
onchange="updateDispatchQty(${t.id}, ${i}, this.value)" /></td>
<td class="${over ? "staff-due" : ""}">${fmtTon(planHold)} ${c.unit}${over ? ` <span class="need-tag">超</span>` : ""}</td>
<td>${fmtTon(shipped)} ${c.unit}</td>
<td>${alert.level ? `<span class="remain ${alert.level === "over" ? "bad" : "warn"}">${alert.text}</span>` : `<span class="remain">正常</span>`}</td>
</tr>`;
}).join("")}</tbody>
</table>` : `<div class="empty" style="padding:16px 0">本运输证尚未分派车辆。证件上的车号会在上传时自动带入。</div>`}
</div>`;
}).join("") : `<div class="empty">请先识别运输证,再按证分派车辆。</div>`;
updateBadges();
}
function fillCertForm(data) {
const form = document.getElementById("certForm");
Object.entries(data).forEach(([k, v]) => {
const el = form.querySelector(`[name="${k}"]`);
if (el) el.value = v;
});
}
function setOcrStatus(kind, text, ok) {
const el = document.getElementById(kind + "Status");
if (!el) return;
el.textContent = text;
el.classList.toggle("ok", !!ok);
}
function transSlotHtml(n) {
return `<div class="extra-trans" data-slot="${n}">
<h4><span class="cert-tag trans">运输证</span> 运输证信息(第 ${n + 1} 张)
<button class="btn btn-sm" type="button" style="margin-left:auto" onclick="this.closest('.extra-trans').remove()">移除</button></h4>
<label class="upload" style="margin-bottom:10px">
<input type="file" accept="image/*,.pdf" onchange="recognizeExtraTrans(this)" />
<span class="plus">+</span><span>上传本张运输证</span>
</label>
<img class="ocr-preview hidden extra-preview" alt="运输证预览" />
<div class="form trans-fields" style="grid-template-columns:1fr 1fr">
<label><span><span class="star">*</span>编号</span><input name="transNo" required /></label>
<label>核发日期<input name="transDate" type="date" /></label>
<label class="span2">货主<input name="owner" /></label>
<label>法定代表人<input name="ownerLegal" /></label>
<label>电话<input name="ownerTel" /></label>
<label class="span2">货主住所<input name="ownerAddr" /></label>
<label>包装<input name="pack" /></label>
<label>购买许可/备案证号<input name="permitNo" /></label>
<label class="span2">运输单位<input name="carrier" /></label>
<label>住所/电话<input name="carrierAddr" /></label>
<label>运输方式<input name="mode" /></label>
<label>车号(次)<input name="plates" /></label>
<label class="span2">运输路线<input name="route" /></label>
<label class="span2">收货单位<input name="consignee" /></label>
<label>运输数量<input name="transQty" type="number" step="0.01" /></label>
<label>余量预警<input name="warnQty" type="number" step="0.01" min="0" placeholder="核准总量 10%" title="默认按证件核准总量的 10% 预警,可改" /></label>
<label>有效次数<input name="transTimes" /></label>
<label>有效开始<input name="transFrom" type="date" /></label>
<label>有效截止<input name="transDue" type="date" /></label>
<label class="span2">申请单位<input name="applicantUnit" /></label>
<label class="span2">公安机关<input name="transPolice" /></label>
<label>经办人<input name="transHandler" /></label>
</div>
</div>`;
}
function addTransSlot() {
extraTransN += 1;
document.getElementById("extraTransSlots").insertAdjacentHTML("beforeend", transSlotHtml(extraTransN));
const box = document.querySelector(`#extraTransSlots .extra-trans[data-slot="${extraTransN}"]`);
bindExtraTransDrop(box);
}
function bindCertDrop(el, kind) {
if (!el || el.dataset.dropBound) return;
el.dataset.dropBound = "1";
el.addEventListener("dragover", (e) => { e.preventDefault(); el.classList.add("drag"); });
el.addEventListener("dragleave", () => el.classList.remove("drag"));
el.addEventListener("drop", (e) => {
e.preventDefault();
el.classList.remove("drag");
const file = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
if (!file) return;
const input = document.getElementById(kind === "buy" ? "buyFile" : "transFile");
if (!input) return;
const dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
recognize(kind, input);
});
}
function bindExtraTransDrop(box) {
if (!box || box.dataset.dropBound) return;
box.dataset.dropBound = "1";
box.addEventListener("dragover", (e) => { e.preventDefault(); box.classList.add("drag"); });
box.addEventListener("dragleave", () => box.classList.remove("drag"));
box.addEventListener("drop", (e) => {
e.preventDefault();
box.classList.remove("drag");
const file = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
const input = box.querySelector("input[type=file]");
if (!file || !input) return;
const dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
recognizeExtraTrans(input);
});
}
function recognize(kind, input) {
const file = input.files && input.files[0];
if (!file) return;
certFiles[kind] = file.name;
const preview = document.getElementById(kind === "buy" ? "buyPreview" : "transPreview");
const reader = new FileReader();
reader.onload = () => {
preview.src = reader.result;
preview.classList.remove("hidden");
certFiles[kind + "Url"] = reader.result;
};
reader.readAsDataURL(file);
setOcrStatus(kind, "正在识别…", false);
setTimeout(() => {
if (kind === "buy") {
fillFields(document.getElementById("buyFormCol"), ocrBuySample);
ensureWarnQtyInputs(document.getElementById("buyFormCol"));
} else {
fillFields(document.getElementById("transForm0"), ocrTransSample);
ensureWarnQtyInputs(document.getElementById("transForm0"));
}
setOcrStatus(kind, "识别完成,请核对后保存", true);
toast(kind === "buy" ? "购买证已识别" : "运输证已识别");
}, 600);
}
function recognizeExtraTrans(input) {
const file = input.files && input.files[0];
const box = input.closest(".extra-trans");
if (!file || !box) return;
const preview = box.querySelector(".extra-preview");
const reader = new FileReader();
reader.onload = () => {
preview.src = reader.result;
preview.classList.remove("hidden");
box.dataset.file = file.name;
box.dataset.url = reader.result;
};
reader.readAsDataURL(file);
setTimeout(() => {
fillFields(box, ocrTransSamples[extraTransN % ocrTransSamples.length]);
ensureWarnQtyInputs(box);
toast("运输证已识别");
}, 500);
}
function setBuyFieldsRequired(on) {
["buyNo", "buyer", "product", "buyQty"].forEach((name) => {
const el = document.querySelector(`#buyFormCol [name="${name}"]`);
if (el) el.required = on;
});
}
function openCertDlg(demo, buyId) {
const form = document.getElementById("certForm");
form.reset();
form.querySelector("[name=unit]").value = "吨";
ensureWarnQtyInputs(form);
attachBuyId = buyId || null;
extraTransN = 0;
document.getElementById("extraTransSlots").innerHTML = "";
certFiles.buy = certFiles.trans = null;
certFiles.buyUrl = certFiles.transUrl = "";
["buyPreview", "transPreview"].forEach((id) => {
document.getElementById(id).classList.add("hidden");
document.getElementById(id).removeAttribute("src");
});
document.getElementById("buyFile").value = "";
document.getElementById("transFile").value = "";
const appending = !!attachBuyId;
document.getElementById("buyOcrBox").classList.toggle("hidden", appending);
document.getElementById("buyFormCol").classList.toggle("hidden", appending);
setBuyFieldsRequired(!appending);
document.getElementById("certDlgTitle").textContent = appending ? "追加运输证" : "上传购买证及运输证";
document.getElementById("certDlgHint").textContent = appending
? "在已有购买证下继续上传运输证,车号将自动进入分派。"
: "先上传购买证和运输证并核对数量、车号;保存后再人工勾选对应运输计划。";
document.getElementById("certSaveBtn").textContent = appending ? "追加运输证" : "保存证件";
if (demo) {
fillFields(document.getElementById("buyFormCol"), ocrBuySample);
fillFields(document.getElementById("transForm0"), ocrTransSample);
certFiles.buy = "购买备案证明样例.png";
certFiles.trans = "运输备案证明样例.png";
certFiles.buyUrl = "购买备案证明样例.png";
certFiles.transUrl = "运输备案证明样例.png";
const bp = document.getElementById("buyPreview");
const tp = document.getElementById("transPreview");
bp.src = certFiles.buyUrl; bp.classList.remove("hidden");
tp.src = certFiles.transUrl; tp.classList.remove("hidden");
setOcrStatus("buy", "已填入购买备案证明样例", true);
setOcrStatus("trans", "已填入运输备案证明样例", true);
} else {
setOcrStatus("buy", appending ? "沿用已有购买证" : "待上传", appending);
setOcrStatus("trans", "待上传", false);
}
document.getElementById("certDialog").showModal();
}
function closeCertDlg() {
document.getElementById("certDialog").close();
attachBuyId = null;
setBuyFieldsRequired(true);
}
function transDraftFromFields(f, file, url) {
return {
id: ++transSeq, created: today, shipped: 0,
transNo: f.transNo, transDate: f.transDate, owner: f.owner, ownerLegal: f.ownerLegal,
ownerTel: f.ownerTel, ownerAddr: f.ownerAddr, pack: f.pack, permitNo: f.permitNo,
carrier: f.carrier, carrierAddr: f.carrierAddr, mode: f.mode, plates: f.plates,
route: f.route, consignee: f.consignee, transQty: f.transQty === "" ? 0 : +f.transQty,
transTimes: f.transTimes, transFrom: f.transFrom, transDue: f.transDue,
applicantUnit: f.applicantUnit, transPolice: f.transPolice, transHandler: f.transHandler,
warnQty: warnQtyOf(f.warnQty, f.transQty),
transFile: file || "运输证.jpg", transUrl: url || "", dispatches: []
};
}
function collectTransDrafts() {
const list = [];
const first = fieldsOf(document.getElementById("transForm0"));
if ((first.transNo || "").trim()) {
list.push(transDraftFromFields(first, certFiles.trans, certFiles.transUrl));
}
document.querySelectorAll("#extraTransSlots .extra-trans").forEach((box) => {
const f = fieldsOf(box);
if (!(f.transNo || "").trim()) return;
list.push(transDraftFromFields(f, box.dataset.file, box.dataset.url));
});
return list;
}
function saveCert() {
const form = document.getElementById("certForm");
if (!form.reportValidity()) return;
const transDrafts = collectTransDrafts();
if (!transDrafts.length) {
toast("请至少填写一张运输证编号");
return;
}
const hasTransFile = !!(certFiles.trans || certFiles.transUrl) ||
[...document.querySelectorAll("#extraTransSlots .extra-trans")].some((b) => b.dataset.file || b.dataset.url);
if (!attachBuyId && !(certFiles.buy || certFiles.buyUrl)) {
toast("请先上传购买证");
return;
}
if (!hasTransFile) {
toast("请先上传运输证");
return;
}
transDrafts.forEach(seedDispatchesOnTrans);
if (attachBuyId) {
const c = certs.find((x) => x.id === attachBuyId);
if (!c) return;
(c.transList ||= []).push(...transDrafts);
closeCertDlg();
toast(`已追加 ${transDrafts.length} 张运输证,可在本页设置预警量并分派车辆`);
showMod("cert");
showCertView("detail", c.id);
return;
}
const buy = fieldsOf(document.getElementById("buyFormCol"));
const rec = {
...buy, id: ++certSeq, created: today, shipped: 0,
buyQty: +buy.buyQty, unit: buy.unit || "吨",
warnQty: warnQtyOf(buy.warnQty, buy.buyQty),
ctrl: /硝|铵|炸药/.test(buy.product || "") ? "易制爆" : "易制毒",
buyFile: certFiles.buy || "购买证.jpg",
buyUrl: certFiles.buyUrl || "",
transList: transDrafts
};
certs.unshift(rec);
closeCertDlg();
showMod("cert");
showCertView("detail", rec.id);
const matchIds = waitLinkPlans().filter((p) => planMatchesCert(p, rec)).map((p) => p.id);
toast(matchIds.length
? `已保存。可在本页设置预警量、分派车辆;有 ${matchIds.length} 条计划可关联`
: "已保存。可在本页设置购买证 / 运输证预警量并分派车辆");
}
function seedDispatchesOnTrans(t) {
const raw = String(t.plates || "").split(/[、,,;;\s]+/).map((s) => s.trim()).filter(Boolean);
const company = t.carrier || "";
const seen = new Set();
t.dispatches = [];
raw.forEach((plate) => {
if (seen.has(plate)) return;
seen.add(plate);
let meta = carrierFleet.find((v) => v.plate === plate);
if (!meta) {
meta = { company: company || "未登记运输公司", plate, driver: "", cap: 32 };
if (company) carrierFleet.push(meta);
}
if (company && meta.company && meta.company !== company) return;
t.dispatches.push({ plate, driver: meta.driver || "", qty: 0, shipped: 0 });
});
if (t.dispatches.length) splitOneTrans(t, true);
}
function activeTrans() {
const c = certs.find((x) => x.id === currentCertId);
return c ? findTrans(c, currentTransId) : null;
}
function openDispatchDlg(transId) {
const c = certs.find((x) => x.id === currentCertId);
if (!c) return;
currentTransId = transId;
const t = findTrans(c, transId);
if (!t) return;
const form = document.getElementById("dispatchForm");
form.reset();
form.querySelector("[name=company]").value = t.carrier || "";
const used = new Set((t.dispatches || []).map((d) => d.plate));
let pool = sameCompanyCars(t.carrier).filter((v) => !used.has(v.plate));
if (!pool.length) pool = carrierFleet.filter((v) => !used.has(v.plate) && (!t.carrier || v.company === t.carrier));
const sel = form.querySelector("[name=plate]");
sel.innerHTML = pool.map((v) => `<option value="${v.plate}">${v.plate} · ${v.driver}(核定 ${v.cap} 吨)</option>`).join("")
+ `<option value="__new__">录入同公司其他车辆…</option>`;
if (!pool.length) sel.value = "__new__";
form.querySelector("[name=qty]").value = unassignedTrans(t) || "";
document.getElementById("dispatchDlgHint").textContent =
`「${t.carrier || "运输公司"}」车辆共用运输证 ${t.transNo}。证载 ${t.transQty || 0}${c.unit},未分派 ${unassignedTrans(t)}${c.unit},计划占用 ${transItemHold(t)}${c.unit}。`;
syncDispatchDriver();
document.getElementById("dispatchDialog").showModal();
}
function closeDispatchDlg() { document.getElementById("dispatchDialog").close(); }
function syncDispatchDriver() {
const plate = document.querySelector("#dispatchForm [name=plate]").value;
const isNew = plate === "__new__";
document.getElementById("dispatchNewPlateRow").classList.toggle("hidden", !isNew);
document.querySelector("#dispatchForm [name=newPlate]").required = isNew;
const v = carrierFleet.find((x) => x.plate === plate);
if (v) document.querySelector("#dispatchForm [name=driver]").value = v.driver;
}
function saveDispatch() {
const c = certs.find((x) => x.id === currentCertId);
const t = activeTrans();
const form = document.getElementById("dispatchForm");
if (!c || !t || !form.reportValidity()) return;
let plate = form.querySelector("[name=plate]").value;
if (plate === "__new__") {
plate = (form.querySelector("[name=newPlate]").value || "").trim();
if (!plate) {
toast("请填写同公司车牌");
return;
}
if (!carrierFleet.some((x) => x.plate === plate)) {
carrierFleet.push({ company: t.carrier || "", plate, driver: form.querySelector("[name=driver]").value, cap: 32 });
}
}
const driver = form.querySelector("[name=driver]").value;
const qty = +form.querySelector("[name=qty]").value;
const v = carrierFleet.find((x) => x.plate === plate);
if (v && t.carrier && v.company !== t.carrier) {
toast("只能分派同一运输公司的车辆,以便共用一张运输证");
return;
}
if ((t.dispatches || []).some((d) => d.plate === plate)) {
toast("该车已在本运输证下");
return;
}
if (qty - unassignedTrans(t) > 1e-6) {
toast(`分派量超过运输证未分派量 ${unassignedTrans(t)} ${c.unit}`);
return;
}
(t.dispatches ||= []).push({ plate, driver, qty, shipped: 0 });
t.plates = t.dispatches.map((d) => d.plate).join("、");
closeDispatchDlg();
toast(`已将 ${qty}${c.unit} 分派给 ${plate}`);
renderCertDetail();
}
function splitOneTrans(t, silent) {
if (!t || !(t.dispatches || []).length) {
if (!silent) toast("请先分派车辆");
return false;
}
const n = t.dispatches.length;
const total = Number(t.transQty || 0);
const base = Math.floor((total / n) * 100) / 100;
let acc = 0;
t.dispatches.forEach((d, i) => {
d.qty = i === n - 1 ? Math.round((total - acc) * 100) / 100 : base;
acc += d.qty;
if (Number(d.shipped) > Number(d.qty)) d.shipped = d.qty;
});
return true;
}
function splitTransToVehicles(certId, transId) {
const c = certs.find((x) => x.id === (certId || currentCertId));
const t = c ? findTrans(c, transId) : null;
if (!c || !t) return;
if (!splitOneTrans(t, false)) return;
toast(`已按运输证 ${t.transQty}${c.unit} 均分到 ${t.dispatches.length} 辆同公司车辆`);
renderCertDetail();
}
function updateDispatchQty(transId, index, value) {
const c = certs.find((x) => x.id === currentCertId);
const t = c ? findTrans(c, transId) : null;
if (!c || !t || !t.dispatches[index]) return;
const d = t.dispatches[index];
const qty = Math.max(Number(d.shipped || 0), +value || 0);
const others = assignedSum(t) - Number(d.qty);
if (others + qty - Number(t.transQty || 0) > 1e-6) {
toast(`分派合计不能超过运输证 ${t.transQty} ${c.unit}`);
renderCertDetail();
return;
}
d.qty = qty;
toast(`${d.plate} 分派运量已改为 ${qty} ${c.unit}`);
renderCertDetail();
}
function pickVehicleForTrans(t, needQty, plate) {
const list = (t.dispatches || []).slice();
if (plate) {
const hit = list.find((d) => d.plate === plate);
if (hit) return hit;
}
list.sort((a, b) =>
(Number(b.qty) - vehicleHold(b.plate, t.id)) - (Number(a.qty) - vehicleHold(a.plate, t.id)));
return list.find((d) => Number(d.qty) - vehicleHold(d.plate, t.id) + 1e-9 >= needQty) || list[0] || null;
}
function pickVehicleForPlan(cert, needQty, transId, plate) {
const t = transId ? findTrans(cert, transId) : transOf(cert)[0];
if (!t) return null;
return pickVehicleForTrans(t, needQty, plate);
}
function applyShipToTrans(t, plate, qty) {
if (!t || !(t.dispatches || []).length) return;
let left = qty;
const hit = t.dispatches.find((d) => d.plate === plate);
const order = hit ? [hit, ...t.dispatches.filter((d) => d !== hit)] : t.dispatches;
order.forEach((d) => {
if (left <= 0) return;
const room = Math.max(0, Number(d.qty) - Number(d.shipped || 0));
const take = Math.min(room, left);
d.shipped = Number(d.shipped || 0) + take;
left -= take;
});
}
function applyShipToDispatch(cert, plate, qty, transId) {
const t = transId ? findTrans(cert, transId) : transOf(cert)[0];
applyShipToTrans(t, plate, qty);
}
function planCrew(p) {
const names = [p.driver, p.escort].filter(Boolean);
return names.length ? names.join(" / ") : "—";
}
function planDeliverAt(p) {
const d = p.deliverDate || p.date;
if (!d) return "—";
return p.deliverTime ? `${d} ${p.deliverTime}` : d;
}
function planContractNo(p) {
return p.contractNo || "—";
}
function planStHtml(p) {
const s = typeof p === "string" ? p : p.status;
if (typeof p === "object" && p.waitNotify && s === "待安排") return `<span class="st-pill wait">等客户通知</span>`;
if (typeof p === "object" && p.archived) return `<span class="st-pill arch">已归档</span>`;
if (s === "已完成") return `<span class="st-pill fin">已完成</span>`;
if (s === "已安排") return `<span class="st-pill ok">已安排</span>`;
if (s === "已作废") return `<span class="st-pill void">已作废</span>`;
if (s === "草稿") return `<span class="st-pill">草稿</span>`;
if (s === "待安排") return `<span class="st-pill wait">待安排</span>`;
return `<span class="st-pill wait">${s || "待安排"}</span>`;
}
function planTabMatch(p, tab) {
if (tab === "待安排") return p.status === "待安排";
if (tab === "已安排") return p.status === "已安排";
if (tab === "草稿中") return p.status === "草稿";
if (tab === "入库单") return p.type === "入直单" || p.type === "入库单";
if (tab === "出库单") return p.type === "出库单";
if (tab === "直销单") return p.type === "直销单";
return true;
}
function planTabStatus(tab) {
if (tab === "待处理" || tab === "待安排") return "待安排";
if (tab === "审核中" || tab === "已安排") return "已安排";
if (tab === "草稿中") return "草稿";
if (tab === "已完成") return "已完成";
if (tab === "已作废") return "已作废";
return "";
}
function productCell(p) {
const tags = [];
if (p.shipMode === "槽车运输" && p.product !== "槽车运输") tags.push(`<span class="prod-tag orange">槽车运输</span>`);
if (p.ctrl === "易制毒") tags.push(`<span class="prod-tag red">易制毒</span>`);
if (p.ctrl === "易制爆") tags.push(`<span class="prod-tag orange">易制爆</span>`);
const name = p.product === "槽车运输" ? `<span class="prod-tag orange">槽车运输</span>` : (p.product || "—");
return `${name}${tags.join("")}`;
}
function matchCell(p) {
if (p.status === "已作废") return "—";
if (p.certId) return `<span class="match-tag yes">有匹配 [3]</span> <span class="match-tag ok">已匹配</span>`;
if (p.ctrl) return `<span class="match-tag yes">有匹配 [3]</span>`;
if (p.status === "已完成") return `<span class="match-tag yes">有匹配 [3]</span> <span class="match-tag ok">已匹配</span>`;
return "—";
}
function sourceCell(p) {
const cert = certs.find((c) => c.id === p.certId);
return `<div class="src-cell">
<span class="ico"><span class="src-dot">${(p.source || "手").slice(0, 1)}</span>${p.sourceNo || p.no}</span>
<span>${cert ? cert.buyNo : p.no}</span>
</div>`;
}
function planOps(p) {
if (p.status === "已作废") return "";
const taskBtn = `<button class="op-link" type="button" onclick="openPlanTaskDlg(${p.id})">${p.taskNo ? "查看任务单" : "下发"}</button>`;
if (p.status === "待安排") {
const extra = (p.ctrl && !p.certId) ? `<button class="op-link" type="button" onclick="openLinkCertDlg(${p.id})">关联</button>` : "";
return `${extra}<button class="op-link" type="button" onclick="arrangePlan(${p.id})">审核</button>${taskBtn}`;
}
if (p.status === "已安排") {
return `<button class="op-link" type="button" onclick="unapprovePlan(${p.id})">反审核</button><button class="op-link" type="button" onclick="completePlan(${p.id})">完成</button>${taskBtn}`;
}
if (p.status === "已完成" && !p.archived) {
return `<button class="op-link" type="button" onclick="showMod('archive')">去归档审核</button>${taskBtn}`;
}
return `<button class="op-link" type="button" onclick="showMod('archive')">查看归档</button>${taskBtn}`;
}
function selectedPlans() {
return plans.filter((p) => selectedPlanIds.has(p.id));
}
function togglePlanSel(id, on) {
if (on) selectedPlanIds.add(id);
else selectedPlanIds.delete(id);
const row = document.querySelector(`#planTable tbody input[data-id="${id}"]`)?.closest("tr");
if (row) row.classList.toggle("sel", on);
}
function togglePlanSelAll(on) {
document.querySelectorAll("#planTable tbody input[type=checkbox]").forEach((el) => {
el.checked = on;
togglePlanSel(+el.dataset.id, on);
});
}
function isWaitPrecursorPlan(p) {
return p.ctrl === "易制毒" && !p.certId && p.status !== "已完成" && p.status !== "已作废";
}
function isTomorrowWaitPlan(p) {
if (!p || p.status !== "待安排") return false;
const tom = dayShift(today, 1);
return p.date === tom || p.deliverDate === tom;
}
function isTodayPlan(p) {
if (!p || p.status === "已作废") return false;
return p.date === today || p.deliverDate === today;
}
function isTodayDonePlan(p) {
return isTodayPlan(p) && p.status === "已完成";
}
function isTodayOpenPlan(p) {
return isTodayPlan(p) && p.status !== "已完成";
}
function isWaitReviewPrecursorPlan(p) {
return !!(p && p.ctrl === "易制毒" && p.status === "待安排");
}
function isWaitArchivePlan(p) {
return !!(p && p.status === "已完成" && !p.archived);
}
function planCardMatch(p, key) {
if (key === "tomorrowWait") return isTomorrowWaitPlan(p);
if (key === "todayAll") return isTodayPlan(p);
if (key === "todayDone") return isTodayDonePlan(p);
if (key === "todayOpen") return isTodayOpenPlan(p);
if (key === "precursorReview") return isWaitReviewPrecursorPlan(p);
if (key === "waitArchive") return isWaitArchivePlan(p);
return true;
}
function resetPlanListTabs() {
planTab = "全部";
planDateTab = "全部";
document.querySelectorAll("#planTabs button").forEach((x) => x.classList.toggle("on", x.dataset.tab === "全部"));
document.querySelectorAll("#planDateTabs button").forEach((x) => x.classList.toggle("on", x.dataset.tab === "全部"));
}
function setPlanCardFilter(key) {
planCardFilter = planCardFilter === key ? "" : key;
if (planCardFilter) resetPlanListTabs();
renderPlans();
if (planCardFilter) document.getElementById("planTable")?.scrollIntoView({ behavior: "smooth", block: "start" });
}
function toggleUnlinkedOnly() {
setPlanCardFilter("precursorReview");
}
function toggleWaitPrecursorPlans() {
setPlanCardFilter("precursorReview");
}
function batchAudit() {
const list = selectedPlans();
if (!list.length) { toast("请先勾选计划"); return; }
const wait = list.find((p) => p.status === "待安排");
if (wait) { arrangePlan(wait.id); return; }
const ready = list.find((p) => p.status === "已安排");
if (ready) { unapprovePlan(ready.id); return; }
toast("所选计划无需审核或反审核");
}
function unapprovePlan(id) {
const p = plans.find((x) => x.id === id);
if (!p || p.status !== "已安排") return;
p.status = "待安排";
toast(`${p.no} 已反审核,回到待安排`);
renderPlans();
}
function batchLinkCert() {
goLinkBuyTransCert();
}
function goLinkBuyTransCert() {
showMod("cert");
if (!certs.length) {
toast("请先上传购买证及运输证");
openCertDlg(false);
return;
}
showCertView("list");
if (!waitLinkPlans().length) {
toast("暂无待关联计划,可在证件上勾选");
return;
}
toast("请人工核对运输证和车辆后点击「确认关联」");
setTimeout(focusCertWait, 40);
}
function suggestProcFromPlan(p) {
const text = `${p.product || ""} ${p.spec || ""}`;
const qty = Number(p.realQty != null ? p.realQty : p.planQty) || 0;
const conc = parseConc(text) || parseConc(p.spec);
const grade = acidGradeOf(text);
const isAcid = /硫酸|试剂酸|硫磺|槽车运输/.test(text) || p.spec === "CP";
const isHcl = /盐酸/.test(text);
const isAlkali = /烧碱|液碱/.test(text);
let draft;
if (isAcid && conc && conc < 90) {
const rawName = grade === "reagent" ? "试剂硫酸" : "98%工业硫酸";
const rawWh = grade === "reagent" ? "h2so4-rg" : "h2so4-98";
const rawQty = +(qty * conc / 98).toFixed(3);
const product = /槽车/.test(p.product || "")
? (grade === "reagent" ? conc + "%试剂酸" : conc + "%工业硫酸")
: p.product;
const round = conc < 40 ? 30 : conc < 70 ? 50 : 50;
const toWh = suggestWarehouse(product, p.spec) || acidTargetWh(round);
draft = { mode: "dilute", rawName, rawWh, rawQty, water: Math.max(0, +(qty - rawQty).toFixed(3)), product, toWh, outQty: qty };
} else if (isHcl) {
const toWh = suggestWarehouse(p.product, p.spec) || "hcl-31-ind";
draft = { mode: "convert", rawName: p.product, rawWh: toWh, rawQty: qty, water: 0, product: p.product, toWh, outQty: qty };
} else if (isAlkali) {
const toWh = suggestWarehouse(p.product, p.spec) || "naoh-32";
draft = { mode: "convert", rawName: p.product, rawWh: toWh, rawQty: qty, water: 0, product: p.product, toWh, outQty: qty };
} else {
const toWh = suggestWarehouse(p.product, p.spec) || warehouseForPlan(p) || "h2so4-50";
draft = { mode: "convert", rawName: p.product, rawWh: toWh || "h2so4-50", rawQty: qty, water: 0, product: p.product, toWh: toWh || "h2so4-50", outQty: qty };
}
return draft;
}
function suggestTaskKind(p) {
const text = `${p.product || ""} ${p.spec || ""} ${p.pack || ""}`;
if (/分装|袋装|PL|桶装|吨桶|吨袋|IBC/.test(text)) return "分装";
if (/精制|试剂/.test(text)) return "精制";
return "稀释";
}
function suggestTaskContent(list, kind) {
return list.map((p) => {
const qty = Number(p.realQty) > 0 ? p.realQty : p.planQty;
return `请按运输计划 ${p.no} 对 ${p.product}${p.spec ? "(" + p.spec + ")" : ""} ${qty}${p.unit} 执行${kind}作业。`;
}).join("\n");
}
function defaultPlanTime() {
return today + "T08:00";
}
function staffOptionsHtml(selected) {
return SITE_STAFF.map((n) => `<option ${n === selected ? "selected" : ""}>${n}</option>`).join("");
}
function suggestAssignee(p) {
const text = `${p.product || ""} ${p.spec || ""}`;
if (/盐酸/.test(text)) return "张伟(盐酸库)";
if (/烧碱|液碱/.test(text)) return "王磊(液碱库)";
if (/分装|袋装/.test(text)) return "陈刚(分装库)";
if (/精制|试剂/.test(text)) return "赵敏(精制岗)";
return "刘洋(硫酸库)";
}
function previewTaskNo(kind) {
const code = { 分装: "FZ", 稀释: "XS", 精制: "JZ" }[kind] || "FZ";
return "SX" + code + today.replace(/-/g, "") + "-" + String(taskSeq + 1).padStart(3, "0");
}
function nextTaskNo(kind) {
const code = { 分装: "FZ", 稀释: "XS", 精制: "JZ" }[kind] || "FZ";
return "SX" + code + today.replace(/-/g, "") + "-" + String(++taskSeq).padStart(3, "0");
}
function planTaskLinesOf(list) {
return list.map((p) => ({
planId: p.id,
planNo: p.no,
product: p.product || "—",
model: p.spec || "",
pack: p.pack || "",
unit: p.unit || "吨",
qty: Number(p.realQty) > 0 ? p.realQty : p.planQty,
done: false
}));
}
function taskLinesOf(t) {
if (t.lines && t.lines.length) return t.lines;
return [{ product: t.product || "—", model: t.spec || "", pack: t.pack || "", unit: t.unit || "吨", qty: t.qty, done: t.status === "已完成" }];
}
function renderPlanTaskLines(list) {
const lines = planTaskLinesOf(list);
const pad = lines.concat(Array.from({ length: Math.max(0, 5 - lines.length) }, () => null));
document.getElementById("planTaskLines").innerHTML = `<table class="task-line-table"><thead><tr>
<th>序号</th><th>产品名称</th><th>产品型号</th><th>包装规格</th><th>单位</th><th>数量</th><th>是否完成</th>
</tr></thead><tbody>${pad.map((ln, i) => ln ? `<tr>
<td>${i + 1}</td><td>${ln.product}</td><td>${ln.model || "—"}</td><td>${ln.pack || "—"}</td>
<td>${ln.unit}</td><td class="num">${ln.qty}</td><td>□ 完成</td>
</tr>` : `<tr><td>${i + 1}</td><td></td><td></td><td></td><td></td><td></td><td></td></tr>`).join("")}</tbody></table>`;
}
function onPlanTaskKindChange() {
const form = document.getElementById("planTaskForm");
if (form) form.no.value = previewTaskNo(form.kind.value);
}
function refreshTaskContent() {
onPlanTaskKindChange();
}
function taskStHtml(s) {
const cls = s === "已完成" ? "st-run" : s === "执行中" ? "st-fix" : "st-stop";
return `<span class="st ${cls}">${s}</span>`;
}
function openPlanTaskDlg(id) {
let list = [];
if (id != null && typeof id !== "object") {
const one = plans.find((x) => x.id === id);
if (one) list = [one];
} else {
list = selectedPlans().filter((x) => x.status !== "已作废");
}
if (!list.length) { toast("请先勾选运输计划,再点「下发任务单」"); return; }
if (list.some((x) => x.status === "已作废")) { toast("已作废计划不能下发任务单"); return; }
const linked = list.filter((x) => x.taskNo);
if (linked.length === list.length) {
const nos = [...new Set(linked.map((x) => x.taskNo))];
const t = nos.length === 1 ? TASKS.find((x) => x.no === nos[0]) : null;
if (t) { openTaskSheetDlg(t.id); return; }
toast("所选计划均已下发任务单");
showMod("task");
return;
}
list = list.filter((x) => !x.taskNo);
linkingPlanTaskIds = list.map((x) => x.id);
const form = document.getElementById("planTaskForm");
const kind = suggestTaskKind(list[0]);
const orderBy = String(list[0].salesman || "调度员").split("/")[0].trim() || "调度员";
form.kind.value = kind;
form.no.value = previewTaskNo(kind);
form.orderDate.value = today;
form.deliverDate.value = list[0].deliverDate || list[0].date || today;
form.orderBy.value = orderBy;
form.handler.innerHTML = staffOptionsHtml(suggestAssignee(list[0]));
form.maker.value = "郑丽娜";
form.auditor.value = "";
form.supervisor.value = "";
form.bucket.value = "桶循环";
form.remark.value = list.map((x) => x.customer || x.settler || "").filter(Boolean)[0] || "";
renderPlanTaskLines(list);
document.getElementById("planTaskHint").textContent =
list.length === 1
? `来源计划 ${list[0].no} · ${planTypeName(list[0])}。明细按附件任务单字段带出,确认后下发。`
: `已勾选 ${list.length} 条计划,将合并为 1 张${kind}任务单(产品明细多行)。`;
document.getElementById("planTaskDialog").showModal();
}
function closePlanTaskDlg() {
document.getElementById("planTaskDialog").close();
linkingPlanTaskIds = [];
}
function savePlanTask() {
const list = plans.filter((x) => linkingPlanTaskIds.includes(x.id));
if (!list.length) { toast("未找到来源计划"); return; }
const form = document.getElementById("planTaskForm");
if (!form.reportValidity()) return;
const f = Object.fromEntries(new FormData(form));
const lines = planTaskLinesOf(list);
const no = nextTaskNo(f.kind);
const first = lines[0];
TASKS.unshift({
id: taskSeq, no, kind: f.kind, status: "待执行",
date: f.orderDate || today,
orderDate: f.orderDate, deliverDate: f.deliverDate,
orderBy: f.orderBy, handler: f.handler, maker: f.maker || "郑丽娜",
auditor: f.auditor || "", supervisor: f.supervisor || "",
bucket: f.bucket || "桶循环", remark: f.remark || "",
planTime: (f.deliverDate || today) + "T08:00",
planId: list[0].id, planNo: list.map((x) => x.no).join("、"),
product: first.product, spec: first.model, pack: first.pack,
qty: lines.reduce((s, ln) => s + Number(ln.qty || 0), 0), unit: first.unit,
assignee: f.handler, lines
});
list.forEach((p) => { p.taskNo = no; });
closePlanTaskDlg();
toast(`已下发任务单 ${no},含 ${lines.length} 行明细`);
selectedPlanIds.clear();
renderPlans();
showMod("task");
openTaskSheetDlg(taskSeq);
}
function taskSheetBox(on) {
return `<span class="ts-box ${on ? "on" : ""}"></span>`;
}
function closeTaskSheetDlg() {
document.getElementById("taskSheetDialog").close();
}
function openTaskSheetDlg(id) {
const t = TASKS.find((x) => x.id === id);
if (!t) { toast("未找到任务单"); return; }
const lines = taskLinesOf(t);
const pad = lines.concat(Array.from({ length: Math.max(0, 5 - lines.length) }, () => ({})));
const b = t.bucket || "";
const blank = (v) => v || "";
document.querySelector("#taskSheetDialog h3").textContent = `山峡${t.kind}任务单`;
document.getElementById("taskSheetBody").innerHTML = `<div class="task-sheet">
<h3>山峡${t.kind}任务单</h3>
<div class="ts-no">NO: ${t.no}</div>
<table>
<colgroup>
<col style="width:9%"><col style="width:16%"><col style="width:14%"><col style="width:16%">
<col style="width:12%"><col style="width:12%"><col style="width:21%">
</colgroup>
<tr>
<td colspan="3">下单日期:${blank(t.orderDate || t.date)}</td>
<td colspan="4">交货日期:${blank(t.deliverDate)}</td>
</tr>
<tr>
<td colspan="3">下单人:${blank(t.orderBy)}</td>
<td colspan="4">经手人:${blank(t.handler || t.assignee)}</td>
</tr>
<tr>
<th>序号</th><th>产品名称</th><th>产品型号</th><th>包装规格</th><th>单位</th><th>数量</th><th>是否完成</th>
</tr>
${pad.map((ln, i) => `<tr>
<td class="idx">${i + 1}</td>
<td class="center">${blank(ln.product)}</td>
<td class="center">${blank(ln.model || ln.spec)}</td>
<td class="center">${blank(ln.pack)}</td>
<td class="center">${blank(ln.unit)}</td>
<td class="center">${ln.qty != null && ln.qty !== "" ? ln.qty : ""}</td>
<td class="center">${ln.product ? (ln.done ? "☑完成" : "□完成") : ""}</td>
</tr>`).join("")}
<tr>
<td class="center">备注</td>
<td colspan="6">${blank(t.remark)}
<span class="ts-checks">
${taskSheetBox(/全新/.test(b))}全新桶
${taskSheetBox(/回收/.test(b))}是否回收桶
${taskSheetBox(/循环/.test(b))}桶循环
</span>
</td>
</tr>
<tr>
<td colspan="7" class="ts-foot">制单人:${blank(t.maker)}  审核:${blank(t.auditor) || "________"}  生产主管:${blank(t.supervisor) || "________"}</td>
</tr>
</table>
</div>`;
document.getElementById("taskSheetDialog").showModal();
}
function nowLocal() {
const d = new Date();
const p = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
}
function selectedTasks() {
return TASKS.filter((t) => selectedTaskIds.has(t.id));
}
function toggleTaskSel(id, on) {
if (on) selectedTaskIds.add(id);
else selectedTaskIds.delete(id);
}
function toggleTaskSelAll(on) {
document.querySelectorAll("#taskTable tbody input[type=checkbox]").forEach((el) => {
el.checked = on;
toggleTaskSel(+el.dataset.id, on);
});
}
function renderTasks() {
const q = (document.getElementById("taskQ")?.value || "").trim();
const st = document.getElementById("taskSt")?.value || "";
const rows = TASKS.filter((t) => {
if (taskTab === "待执行" && t.status !== "待执行") return false;
if (taskTab === "已完成" && t.status !== "已完成") return false;
if (["稀释", "精制", "分装"].includes(taskTab) && t.kind !== taskTab) return false;
if (st && t.status !== st) return false;
const blob = [t.no, t.planNo, t.product, t.spec, t.pack, t.orderBy, t.handler, t.assignee, t.remark, t.maker, t.report].join(" ");
if (q && !blob.includes(q)) return false;
return true;
});
const kindN = (status) => ["稀释", "精制", "分装"].map((kind) =>
TASKS.filter((t) => t.kind === kind && (!status || t.status === status)).length
);
const slashTriple = (parts) => parts.map((p) => `<span>${p}</span>`).join("<i>/</i>");
const kindsTitle = `<span class="kinds">${slashTriple(["稀释", "精制", "分装"])}</span>`;
document.getElementById("taskStats").innerHTML = [
["待执行", slashTriple(kindN("待执行")), "wait"],
["执行中", slashTriple(kindN("执行中")), "run"],
["已完成", slashTriple(kindN("已完成")), "done"],
["全部", slashTriple(kindN("")), "all"]
].map(([k, v, cls]) => `<div class="stat"><div class="k"><span class="stat-name ${cls}">${k}</span>${kindsTitle}</div><div class="v">${v}</div></div>`).join("");
document.getElementById("taskHint").innerHTML = `当前 <b>${rows.length}</b> 条`;
document.getElementById("taskCount").textContent = `共 ${rows.length} 条`;
const allOn = rows.length && rows.every((t) => selectedTaskIds.has(t.id));
document.getElementById("taskTable").innerHTML = `<table class="erp-table task-ops"><thead><tr>
<th class="chk"><input type="checkbox" ${allOn ? "checked" : ""} onchange="toggleTaskSelAll(this.checked)" /></th>
<th>任务单号</th><th>类型</th><th>状态</th><th>下单日期</th><th>交货日期</th><th>下单人</th><th>经手人</th>
<th>产品名称</th><th>产品型号</th><th>包装规格</th><th>单位</th><th>数量</th><th>备注</th><th>制单人</th>
</tr></thead><tbody>${rows.length ? rows.map((t) => {
const lines = taskLinesOf(t);
const first = lines[0] || {};
const more = lines.length > 1 ? ` 等${lines.length}行` : "";
return `<tr class="${selectedTaskIds.has(t.id) ? "sel" : ""}">
<td class="chk"><input type="checkbox" data-id="${t.id}" ${selectedTaskIds.has(t.id) ? "checked" : ""} onchange="toggleTaskSel(${t.id}, this.checked)" /></td>
<td class="cell-no"><button class="op-link" type="button" onclick="openTaskSheetDlg(${t.id})">${t.no}</button></td>
<td>${t.kind}任务单</td>
<td>${taskStHtml(t.status)}</td>
<td>${t.orderDate || t.date || "—"}</td>
<td>${t.deliverDate || "—"}</td>
<td>${t.orderBy || "—"}</td>
<td>${t.handler || t.assignee || "—"}</td>
<td>${(first.product || t.product || "—") + more}</td>
<td>${first.model || t.spec || "—"}</td>
<td>${first.pack || t.pack || "—"}</td>
<td>${first.unit || t.unit || "—"}</td>
<td class="num">${first.qty != null ? first.qty : t.qty}</td>
<td class="clip">${[t.remark, t.bucket].filter(Boolean).join(" · ") || "—"}</td>
<td>${t.maker || "—"}</td>
</tr>`;
}).join("") : `<tr><td colspan="15" class="muted">暂无任务单。请在运输计划勾选后点「下发任务单」。</td></tr>`}</tbody></table>`;
updateBadges();
}
function taskOps(t) {
const sheet = `<button class="op-link" type="button" onclick="openTaskSheetDlg(${t.id})">查看单据</button>`;
if (t.status === "待执行") {
return `${sheet}<button class="op-link" type="button" onclick="receiveTask(${t.id})">接收任务</button><button class="op-link" type="button" onclick="openTaskReportDlg(${t.id})">回传完成</button>`;
}
if (t.status === "执行中") {
return `${sheet}<button class="op-link" type="button" onclick="openTaskReportDlg(${t.id})">回传完成</button>`;
}
return `${sheet}<button class="op-link" type="button" onclick="openTaskReportDlg(${t.id})">查看回传</button>`;
}
function receiveTask(id) {
const t = TASKS.find((x) => x.id === id);
if (!t || t.status !== "待执行") { toast("仅待执行任务可接收"); return; }
t.status = "执行中";
t.receivedAt = nowLocal();
toast(`${t.no} 现场已接收,可回传完成`);
renderTasks();
}
function batchReceiveTask() {
const list = selectedTasks().filter((t) => t.status === "待执行");
if (!list.length) { toast("请勾选待执行的任务单后再点「接收任务」"); return; }
list.forEach((t) => {
t.status = "执行中";
t.receivedAt = nowLocal();
});
toast(`已接收 ${list.length} 张任务单`);
selectedTaskIds.clear();
renderTasks();
}
function batchReportTask() {
const list = selectedTasks().filter((t) => t.status !== "已完成");
if (!list.length) { toast("请勾选未完成的任务单后再点「回传完成」"); return; }
openTaskReportDlg(list[0].id);
}
function renderTaskSheetPhotoList(photos, clickable) {
const thumbs = document.getElementById("taskSheetPhotoThumbs");
const chips = document.getElementById("taskSheetPhotoChips");
const list = photos || [];
thumbs.innerHTML = list.map((a, i) => {
if (a.url && (a.type || "").startsWith("image/")) {
const btn = clickable
? `<button type="button" onclick="openAttach('task',${reportingTaskId},${i})"><img alt="${a.name}" src="${a.url}" /></button>`
: `<img alt="${a.name}" src="${a.url}" />`;
return btn;
}
return "";
}).join("");
chips.innerHTML = list.map((a, i) =>
clickable && a.url
? `<button class="chip" type="button" onclick="openAttach('task',${reportingTaskId},${i})">${a.name}</button>`
: `<span class="chip ghost">${a.name}</span>`
).join("");
}
function previewTaskSheetPhotos(input) {
const files = [...input.files];
renderTaskSheetPhotoList(files.map((f) => ({
name: f.name,
type: f.type,
url: f.type.startsWith("image/") ? URL.createObjectURL(f) : ""
})), false);
}
function openTaskReportDlg(id) {
const t = TASKS.find((x) => x.id === id);
if (!t) return;
reportingTaskId = id;
const form = document.getElementById("taskReportForm");
const view = t.status === "已完成";
document.getElementById("taskReportTitle").textContent = view ? "查看回传" : "回传完成任务";
document.getElementById("taskReportHint").textContent = view
? `${t.no} · ${t.assignee} 已回传完成。`
: `${t.no} · ${t.kind}任务单 · ${t.product} ${t.qty}${t.unit}。现场完成后填写回传说明,并拍照上传纸质分装单。`;
form.no.value = t.no;
form.assignee.value = t.assignee;
form.doneTime.value = t.doneTime || nowLocal();
form.doneQty.value = t.doneQty != null ? t.doneQty : t.qty;
form.report.value = t.report || "";
["doneTime", "doneQty", "report"].forEach((n) => { form[n].readOnly = view; form[n].disabled = view; });
const photoInput = form.sheetPhotos;
photoInput.value = "";
photoInput.required = !view;
document.getElementById("taskSheetPhotoBtn").classList.toggle("hidden", view);
document.getElementById("taskSheetPhotoHint").textContent = view
? "已上传的纸质分装单照片,点击可查看。"
: "须拍摄已签字完成的纸质分装任务单,可多张。";
renderTaskSheetPhotoList(t.sheetPhotos || [], view);
document.getElementById("taskReportSave").classList.toggle("hidden", view);
document.getElementById("taskReportDialog").showModal();
}
function closeTaskReportDlg() {
document.getElementById("taskReportDialog").close();
reportingTaskId = null;
}
function saveTaskReport() {
const t = TASKS.find((x) => x.id === reportingTaskId);
if (!t) { toast("未找到任务单"); return; }
const form = document.getElementById("taskReportForm");
const photoInput = form.sheetPhotos;
if (!photoInput.files.length) {
toast("请拍照上传已完成的纸质分装单");
photoInput.focus();
return;
}
if (!form.reportValidity()) return;
const f = Object.fromEntries(new FormData(form));
readFiles(photoInput.files).then((photos) => {
if (t.status === "待执行") {
t.receivedAt = t.receivedAt || nowLocal();
}
t.status = "已完成";
t.doneTime = f.doneTime;
t.doneQty = +f.doneQty;
t.report = f.report;
t.sheetPhotos = photos;
if (t.lines && t.lines.length) t.lines.forEach((ln) => { ln.done = true; });
closeTaskReportDlg();
toast(`${t.no} 已回传完成`);
selectedTaskIds.delete(t.id);
renderTasks();
});
}
function voidPlan(id) {
const p = plans.find((x) => x.id === id);
if (!p || p.status === "已完成") return;
p.status = "已作废";
toast(`${p.no} 已作废`);
renderPlans();
}
function copyPlan() {
const rows = selectedPlans();
if (rows.length !== 1) { toast("请勾选一条计划后再复制"); return; }
const src = rows[0];
const no = "PO" + today.replace(/-/g, "") + String(++planSeq).padStart(3, "0");
plans.unshift({
...src, id: planSeq, no, status: "草稿", realQty: null, tractor: "", driver: "", escort: "",
certId: null, transId: null, warehouseId: null, archived: false, waitNotify: false,
source: "复制", sourceNo: src.no, taskNo: "", contractNo: src.contractNo || ""
});
selectedPlanIds.clear();
toast("已复制为草稿 " + no);
renderPlans();
}
function arrangeSelectedPlans() {
const list = selectedPlans().filter((p) => p.status === "待安排" || p.status === "草稿");
if (!list.length) { toast("请勾选待安排或草稿计划"); return; }
list.forEach((p) => arrangePlan(p.id));
}
function waitCustomerNotify() {
const list = selectedPlans().filter((p) => p.status === "待安排" || p.status === "草稿");
if (!list.length) { toast("请勾选待安排计划"); return; }
list.forEach((p) => { p.waitNotify = true; p.status = "待安排"; });
toast(`已标记 ${list.length} 条为等客户通知`);
renderPlans();
}
function printPlanDocs() {
const list = selectedPlans();
if (!list.length) { toast("请先勾选计划"); return; }
toast("演示:已打印 " + list.map((p) => p.no).join("、"));
}
function voidSelectedPlans() {
const list = selectedPlans().filter((p) => p.status !== "已完成" && p.status !== "已作废");
if (!list.length) { toast("请勾选可作废的计划"); return; }
list.forEach((p) => { p.status = "已作废"; p.waitNotify = false; });
selectedPlanIds.clear();
toast("已作废所选计划");
renderPlans();
}
function createTriParty() {
toast("演示:三方人车快速创建");
}
function renderPlanKpis() {
const box = document.getElementById("planKpis");
if (!box) return;
const todayAllN = plans.filter(isTodayPlan).length;
const todayDoneN = plans.filter(isTodayDonePlan).length;
const todayOpenN = plans.filter(isTodayOpenPlan).length;
const todayOn = ["todayAll", "todayDone", "todayOpen"].includes(planCardFilter);
const bit = (key, cls, label, n) =>
`<button class="${cls}${planCardFilter === key ? " on" : ""}" type="button" onclick="setPlanCardFilter('${key}')">${label ? `${label} ` : ""}<b>${n}</b></button>`;
box.innerHTML = [
`<div class="stat tone-wait ${planCardFilter === "tomorrowWait" ? "on" : ""}">
<div class="k">明日待安排计划</div>
<button class="v" type="button" onclick="setPlanCardFilter('tomorrowWait')">${plans.filter(isTomorrowWaitPlan).length}</button>
</div>`,
`<div class="stat tone-today ${todayOn ? "on" : ""}">
<div class="k">今日计划</div>
<div class="today-bits">
${bit("todayAll", "", "共", todayAllN)}
${bit("todayDone", "", "已完成", todayDoneN)}
${bit("todayOpen", "open", "未完成", todayOpenN)}
</div>
</div>`,
`<div class="stat tone-ctrl ${planCardFilter === "precursorReview" ? "on" : ""}">
<div class="k">待审核易制毒计划</div>
<button class="v" type="button" onclick="setPlanCardFilter('precursorReview')">${plans.filter(isWaitReviewPrecursorPlan).length}</button>
</div>`,
`<div class="stat tone-arch ${planCardFilter === "waitArchive" ? "on" : ""}">
<div class="k">待归档计划</div>
<button class="v" type="button" onclick="setPlanCardFilter('waitArchive')">${plans.filter(isWaitArchivePlan).length}</button>
</div>`
].join("");
}
function renderPlans() {
const q = (document.getElementById("planQ")?.value || "").trim();
const alerts = document.getElementById("planAlerts");
if (alerts) alerts.innerHTML = "";
const rows = plans.filter((p) => {
if (!planTabMatch(p, planTab)) return false;
const d = p.date || "";
if (planDateTab === "昨日" && d !== dayShift(today, -1)) return false;
if (planDateTab === "今日" && d !== today) return false;
if (planDateTab === "明日" && d !== dayShift(today, 1)) return false;
if (planDateTab === "本周") {
const [a, b] = quoteWeekRange();
if (d < a || d > b) return false;
}
if (planCardFilter && !planCardMatch(p, planCardFilter)) return false;
const blob = [p.no, p.product, p.customer, p.settler, p.tractor, p.driver, p.escort, p.salesman, p.sourceNo, p.loadFrom, p.contractNo, planTypeName(p)].join(" ");
if (q && !blob.includes(q)) return false;
return true;
});
const size = parseInt(document.getElementById("planPageSize").value, 10) || 20;
const pageRows = rows.slice(0, size);
renderPlanKpis();
document.getElementById("planCount").textContent = "状态全选";
const allOn = pageRows.length && pageRows.every((p) => selectedPlanIds.has(p.id));
document.getElementById("planTable").innerHTML = `<table class="erp-table plan-table"><thead><tr>
<th class="chk"><input type="checkbox" ${allOn ? "checked" : ""} onchange="togglePlanSelAll(this.checked)" /></th>
<th class="idx">序号</th>
<th class="col-no">计划编号</th>
<th>跟进状态</th>
<th>计划日期</th>
<th>送货时间</th>
<th>单据类型</th>
<th>车牌号</th>
<th>驾押人员</th>
<th>装货客户</th>
<th>送货客户</th>
<th>产品名称</th>
<th>产品规格</th>
<th>包装规格</th>
<th>计划数量</th>
<th>实际数量</th>
<th>合同</th>
<th>任务单</th>
</tr></thead><tbody>${pageRows.map((p, i) => {
const out = p.status === "已作废" ? "—" : (p.realQty == null ? 0 : p.realQty);
return `<tr class="${p.ctrl && !p.certId && p.status === "待安排" ? "row-wait" : ""}${selectedPlanIds.has(p.id) ? " sel" : ""}">
<td class="chk"><input type="checkbox" data-id="${p.id}" ${selectedPlanIds.has(p.id) ? "checked" : ""} onchange="togglePlanSel(${p.id}, this.checked)" /></td>
<td class="idx">${i + 1}</td>
<td class="cell-no col-no">${p.no}</td>
<td>${planStHtml(p)}</td>
<td>${p.date}</td>
<td>${planDeliverAt(p)}</td>
<td>${planTypeName(p)}</td>
<td>${p.tractor || "—"}</td>
<td>${planCrew(p)}</td>
<td class="clip">${p.loadFrom || "—"}</td>
<td class="clip">${p.customer || p.settler || "—"}</td>
<td>${p.product || "—"}</td>
<td>${p.spec || "—"}</td>
<td>${p.pack || "—"}</td>
<td class="num qty">${p.planQty}${p.unit ? " " + p.unit : ""}</td>
<td class="num out-qty">${out}${out !== "—" && p.unit ? " " + p.unit : ""}</td>
<td class="clip">${planContractNo(p)}</td>
<td>${p.taskNo ? `<button class="op-link" type="button" onclick="openPlanTaskDlg(${p.id})">${p.taskNo}</button>` : "—"}</td>
</tr>`;
}).join("")}</tbody></table>`;
updateBadges();
}
function openPlanDlg() {
const form = document.getElementById("planForm");
form.reset();
form.querySelector("[name=unit]").value = "吨";
form.querySelector("[name=date]").value = today;
document.getElementById("planDialog").showModal();
}
function closePlanDlg() { document.getElementById("planDialog").close(); }
function savePlan() {
const form = document.getElementById("planForm");
if (!form.reportValidity()) return;
const f = Object.fromEntries(new FormData(form));
plans.unshift({
...f, id: ++planSeq, no: "PO" + today.replace(/-/g, "") + String(planSeq).padStart(3, "0"),
status: "待安排", planQty: +f.planQty, realQty: null, tractor: "", driver: "", certId: null, warehouseId: null,
deliverDate: f.deliverDate || f.date,
salesman: f.salesman || "调度员",
dept: f.dept || "运输调度组",
shipMode: f.shipMode || "槽车运输",
spec: f.spec || "",
pack: f.pack || "槽罐车",
settler: f.settler || f.customer || "",
docKind: f.type === "直销单" ? "开单类" : "选单类",
source: "手工录入",
sourceNo: "PO" + today.replace(/-/g, "") + String(planSeq).padStart(3, "0"),
deliverTime: "",
escort: "",
contractNo: f.contractNo || ""
});
closePlanDlg();
toast("计划已保存为待安排");
renderPlans();
}
function arrangePlan(id) {
const p = plans.find((x) => x.id === id);
if (!p) return;
if (p.status === "已作废" || p.status === "已完成") {
toast("该计划不能安排");
return;
}
if (p.ctrl && !p.certId) {
toast("请先在购买证管理中人工勾选关联计划");
const c = suggestCertForPlan(p);
showMod("cert");
if (c) openCertLinkPlanDlg(c.id, [p.id]);
else openCertDlg(false);
return;
}
const cert = certs.find((c) => c.id === p.certId);
if (cert) {
const d = pickVehicleForPlan(cert, p.planQty, p.transId, p.tractor);
if (d) {
p.tractor = d.plate;
p.driver = d.driver || p.driver;
}
}
p.status = "已安排";
toast(p.tractor ? `已安排 ${p.tractor},分派运量来自同一运输公司运输证` : "已安排发运");
renderPlans();
}
function refreshCertViews() {
if (currentMod !== "cert") return;
if (document.getElementById("view-cert-detail")?.classList.contains("hidden")) renderCertList();
else renderCertDetail();
updateBadges();
}
function completePlan(id) {
const p = plans.find((x) => x.id === id);
if (!p) return;
if (p.status !== "已安排") {
toast("仅已安排的计划可标记完成");
return;
}
if (p.ctrl && !p.certId) {
toast("易制毒 / 易制爆计划请先关联购买证和运输证");
openLinkCertDlg(id);
return;
}
p.status = "已完成";
if (p.realQty == null) p.realQty = p.planQty;
p.archived = false;
p.warehouseId = isDirectSale(p) ? null : suggestWarehouse(p.product, p.spec);
let msg = `${p.no} 已完成,请到运输归档人工审核`;
if (p.certId) {
const s = settleCertOnComplete(p);
if (s) {
msg = `${p.no} 已完成,扣减 ${fmtTon(s.n)}${p.unit};购买证余 ${fmtTon(s.buyLeft)}${s.cert.unit},运输证余 ${fmtTon(s.transLeft)}${s.cert.unit}`;
if (lowRemain(s.buyLeft, buyWarnQty(s.cert)) || lowRemain(s.transLeft, transWarnQty(s.trans)) || lowRemain(s.availBuy, buyWarnQty(s.cert)) || lowRemain(s.availTrans, transWarnQty(s.trans))) {
msg += "(余量已达自设预警量)";
}
}
}
toast(msg);
renderPlans();
refreshCertViews();
}
function openArchiveDlg(id) {
const p = plans.find((x) => x.id === id);
if (!p) return;
if (p.status === "已作废") {
toast("已作废计划不能归档");
return;
}
if (p.status !== "已完成") {
toast("请先在运输计划中将单据标记为已完成");
return;
}
if (p.archived) {
toast("该单据已归档");
return;
}
if (p.ctrl && !p.certId) {
toast("请先关联购买证 / 运输证");
openLinkCertDlg(id);
return;
}
const qty = Number(p.realQty || p.planQty);
const cert = certs.find((c) => c.id === p.certId);
if (cert && remain(cert, p.id) + 1e-9 < qty) {
toast(`购买证余量不足:剩余 ${remain(cert, p.id)} ${cert.unit},本计划 ${qty} ${p.unit}`);
return;
}
if (cert && transRemain(cert, p.id) + 1e-9 < qty) {
toast(`运输证余量不足:剩余 ${transRemain(cert, p.id)} ${cert.unit},本计划 ${qty} ${p.unit}`);
return;
}
archivingPlanId = id;
const form = document.getElementById("archiveForm");
const wh = warehouseForPlan(p);
const whRow = document.getElementById("archiveWhRow");
if (isDirectSale(p)) {
document.getElementById("archiveHint").textContent = `${p.no} 为直销单,无中转仓库,审核通过后仅归档,不记入库存。`;
document.getElementById("archiveWhHint").textContent = "直销单不经过硫酸库 / 液碱库 / 分装库 / 固体库。";
whRow.style.display = "grid";
document.getElementById("archiveWhName").value = "无中转仓库";
form.warehouseId.value = "";
} else {
const action = isInboundPlan(p) ? "入库计入" : "出库扣减";
document.getElementById("archiveHint").textContent =
`${p.no} 为${planTypeName(p)},按品名「${p.product}」自动对应仓库,审核通过后${action}库存。`;
document.getElementById("archiveWhHint").textContent = isInboundPlan(p)
? "入库单:按品名进入对应仓库(进货入库)。"
: "出库单:按品名从对应仓库发出(销货出库)。";
whRow.style.display = "grid";
document.getElementById("archiveWhName").value = wh ? whNameOf(wh) : "未匹配到仓库,请核对品名";
form.warehouseId.value = wh || "";
}
document.getElementById("archiveNo").value = p.no;
document.getElementById("archiveProd").value = `${planTypeName(p)} · ${p.product}${p.spec ? " / " + p.spec : ""}`;
document.getElementById("archiveUnit").value = p.unit;
form.realQty.value = qty;
document.getElementById("archiveDialog").showModal();
}
function closeArchiveDlg() {
document.getElementById("archiveDialog").close();
archivingPlanId = null;
}
function recordWhFromArchive(p) {
if (isDirectSale(p) || !p.warehouseId) return;
const qty = Number(p.realQty || p.planQty);
const inbound = isInboundPlan(p);
const stock = matchStock(p.warehouseId, p.product);
const price = stock ? stock.cost : 0;
if (inbound) {
if (stock) stock.qty += qty;
WH_LEDGER.unshift({
date: today, doc: "进货单", no: p.no, wh: p.warehouseId, product: p.product,
inQty: qty, inCost: qty * price, outQty: 0, outCost: 0,
balQty: stock ? stock.qty : qty, price, balCost: stock ? stock.qty * price : qty * price,
note: "运输归档审核入库"
});
} else {
if (stock) stock.qty = Math.max(0, +(stock.qty - qty).toFixed(3));
WH_LEDGER.unshift({
date: today, doc: "销货单", no: p.no, wh: p.warehouseId, product: p.product,
inQty: 0, inCost: 0, outQty: qty, outCost: qty * price,
balQty: stock ? stock.qty : 0, price, balCost: stock ? stock.qty * price : 0,
note: "运输归档审核出库"
});
}
}
function applyArchive(p, qty) {
if (!p || p.archived) return "skip";
if (p.status !== "已完成") return "未完成";
if (p.ctrl && !p.certId) return "待关联证件";
const n = Number(qty != null ? qty : (p.realQty || p.planQty));
const cert = certs.find((c) => c.id === p.certId);
if (cert && remain(cert, p.id) + 1e-9 < n) return `购买证余量不足(剩 ${remain(cert, p.id)})`;
if (cert && transRemain(cert, p.id) + 1e-9 < n) return `运输证余量不足(剩 ${transRemain(cert, p.id)})`;
p.realQty = n;
p.status = "已完成";
if (isDirectSale(p)) {
p.warehouseId = null;
p.archived = true;
p.archiveDate = today;
if (cert) settleCertOnComplete(p);
return "直销";
}
const wh = suggestWarehouse(p.product, p.spec) || p.warehouseId;
if (!wh) return "未匹配仓库";
p.warehouseId = wh;
p.archived = true;
p.archiveDate = today;
recordWhFromArchive(p);
if (cert) settleCertOnComplete(p);
return isInboundPlan(p) ? "入库" : "出库";
}
function confirmArchive() {
const p = plans.find((x) => x.id === archivingPlanId);
if (!p) return;
const form = document.getElementById("archiveForm");
if (!form.reportValidity()) return;
const qty = Number(form.realQty.value);
const result = applyArchive(p, qty);
if (result === "未匹配仓库") {
toast("未能按品名匹配仓库,请核对品名后重试");
return;
}
if (result === "待关联证件") {
toast("请先关联购买证 / 运输证");
closeArchiveDlg();
openLinkCertDlg(p.id);
return;
}
if (result !== "入库" && result !== "出库" && result !== "直销" && result !== "skip") {
toast(result);
return;
}
if (result === "直销") toast(`${p.no} 已审核归档,直销单无中转仓库`);
else if (result === "skip") toast("该单据已归档");
else toast(`${p.no} 已审核归档,${result} ${whNameOf(p.warehouseId)}`);
closeArchiveDlg();
renderPlans();
showMod("archive");
}
function archiveOps(p) {
if (p.archived) return `<button class="op-link" type="button" onclick="showMod('warehouse')">看仓储</button>`;
return `<button class="op-link" type="button" onclick="openArchiveDlg(${p.id})">审核归档</button>`;
}
function toggleArchiveSel(id, on) {
if (on) selectedArchiveIds.add(id);
else selectedArchiveIds.delete(id);
}
function toggleArchiveSelAll(on) {
document.querySelectorAll("#archiveTable tbody input[type=checkbox]").forEach((el) => {
el.checked = on;
toggleArchiveSel(+el.dataset.id, on);
});
}
function batchReviewArchive() {
const pending = plans.filter((p) => p.status === "已完成" && !p.archived);
const picked = pending.filter((p) => selectedArchiveIds.has(p.id));
const list = picked.length ? picked : pending.filter((p) => archiveTab === "待审核" || archiveTab === "全部");
if (!list.length) { toast("没有待审核的已完成单据"); return; }
if (list.length === 1) {
openArchiveDlg(list[0].id);
return;
}
let ok = 0;
const fail = [];
list.forEach((p) => {
const r = applyArchive(p, p.realQty || p.planQty);
if (r === "入库" || r === "出库" || r === "直销") ok += 1;
else if (r !== "skip") fail.push(`${p.no} ${r}`);
});
selectedArchiveIds.clear();
toast(fail.length ? `已归档 ${ok} 条;${fail[0]}` : `已审核归档 ${ok} 条`);
renderPlans();
renderArchive();
}
function renderArchive() {
const sel = document.getElementById("archiveWh");
const keep = sel.value;
sel.innerHTML = `<option value="">全部仓库</option><option value="none">无中转仓库</option>` + warehouseSelectHtml(keep);
if (keep && ![...sel.options].some((o) => o.value === keep)) sel.value = "";
else sel.value = keep;
const q = (document.getElementById("archiveQ").value || "").trim();
const type = document.getElementById("archiveType").value;
const wh = sel.value;
const done = plans.filter((p) => p.status === "已完成");
const rows = done.filter((p) => {
if (archiveTab === "待审核" && p.archived) return false;
if (archiveTab === "已归档" && !p.archived) return false;
if (type && p.type !== type) return false;
if (wh === "none") { if (!isDirectSale(p)) return false; }
else if (wh && warehouseForPlan(p) !== wh) return false;
const blob = [p.no, p.product, p.customer, p.settler, p.tractor, p.driver, warehouseForPlan(p) ? whNameOf(warehouseForPlan(p)) : "无中转"].join(" ");
if (q && !blob.includes(q)) return false;
return true;
});
const ym = today.slice(0, 7);
const yr = today.slice(0, 4);
const inMonth = (d) => (d || "").slice(0, 7) === ym;
const inYear = (d) => (d || "").slice(0, 4) === yr;
const waitN = done.filter((p) => !p.archived && inMonth(p.date || p.deliverDate)).length;
const monthArchN = done.filter((p) => p.archived && inMonth(p.archiveDate || p.date)).length;
const unArchN = done.filter((p) => !p.archived).length;
const yearArchN = done.filter((p) => p.archived && inYear(p.archiveDate || p.date)).length;
document.getElementById("archiveStats").innerHTML = [
["当月待审核", waitN, "待人工审核"],
["当月已归档", monthArchN, "审核通过"],
["未归档", unArchN, "已完成尚未归档"],
["本年度归档数", yearArchN, "本年审核通过"]
].map(([k, v, s]) => `<div class="stat"><div class="k">${k}</div><div class="v">${v}</div><div class="s">${s}</div></div>`).join("");
const hint = document.getElementById("archiveHintBar");
if (hint) hint.innerHTML = `当前 <b>${archiveTab}</b> 共 <b>${rows.length}</b> 条`;
document.getElementById("archiveCount").textContent = archiveTab === "待审核" ? `待审核 ${rows.length} 条` : `共 ${rows.length} 条`;
const vis = rows.filter((p) => !p.archived);
const allOn = vis.length && vis.every((p) => selectedArchiveIds.has(p.id));
document.getElementById("archiveTable").innerHTML = rows.length ? `<table class="erp-table" style="min-width:1480px"><thead><tr>
<th class="chk"><input type="checkbox" ${allOn ? "checked" : ""} onchange="toggleArchiveSelAll(this.checked)" /></th>
<th class="idx">序号</th>
<th>单据编号</th><th>审核状态</th><th>单据类型</th><th>品名</th><th>规格</th>
<th>实发量</th><th>对应仓库</th><th>库存动作</th><th>客户</th><th>车牌</th><th>驾驶员</th><th>操作</th>
</tr></thead><tbody>${rows.map((p, i) => `<tr>
<td class="chk">${p.archived ? "" : `<input type="checkbox" data-id="${p.id}" ${selectedArchiveIds.has(p.id) ? "checked" : ""} onchange="toggleArchiveSel(${p.id}, this.checked)" />`}</td>
<td class="idx">${i + 1}</td>
<td class="cell-no">${p.no}</td>
<td>${p.archived ? `<span class="st-pill arch">已归档</span>` : `<span class="st-pill wait">待审核</span>`}</td>
<td>${planTypeName(p)}</td>
<td>${productCell(p)}</td>
<td>${p.spec || "—"}</td>
<td class="num qty">${p.realQty != null ? p.realQty : p.planQty} ${p.unit}</td>
<td>${warehouseCell(p)}</td>
<td>${stockActCell(p)}</td>
<td>${p.settler || p.customer || "—"}</td>
<td>${p.tractor || "—"}</td>
<td>${p.driver || "—"}</td>
<td>${archiveOps(p)}</td>
</tr>`).join("")}</tbody></table>` : `<div class="empty">${archiveTab === "待审核" ? "暂无待审核单据。请在运输计划中将已安排单据点「完成」。" : "没有符合条件的归档记录。"}</div>`;
updateBadges();
}
function openLinkCertDlg(planId) {
linkingPlanId = planId;
const p = plans.find((x) => x.id === planId);
if (!p) return;
document.getElementById("linkCertHint").textContent =
`${p.no} · ${p.product} ${p.planQty}${p.unit} · ${p.ctrl || "普通"}。请选择购买证下的一张运输证;关联后按该证车辆计划量预警。`;
const pairs = certs.flatMap((c) => transOf(c).map((t) => ({ c, t })));
document.getElementById("linkCertTable").innerHTML = pairs.length ? `<table><thead><tr>
<th>购买证</th><th>运输证 / 公司</th><th>品名</th><th>购买证余量</th><th>运输证余量</th><th>分派车辆</th><th></th>
</tr></thead><tbody>${pairs.map(({ c, t }) => {
const mismatch = c.product && p.product && !String(p.product).includes(c.product) && !String(c.product).includes(String(p.product).replace(/\d+%/g, "").trim());
const short = remainGap(c, p.id) < p.planQty || transItemGap(t, p.id) < p.planQty;
return `<tr>
<td class="cell-no">${c.buyNo}</td>
<td>${t.transNo}<div class="muted">${t.carrier || "—"}</div></td>
<td>${c.product}${mismatch ? `<div class="muted">与计划品名不同</div>` : ""}</td>
<td>${remainHtml(c)}</td>
<td>${transItemRemain(t)} ${c.unit}${transItemWarn(t) ? `<div class="muted">≤${fmtTon(transWarnQty(t))}${c.unit}预警</div>` : ""}</td>
<td>${(t.dispatches || []).map((d) => d.plate).join("、") || "未分派"}</td>
<td><button class="btn btn-sm btn-primary" type="button" onclick="linkCert(${c.id}, ${t.id})">关联此证</button>${short ? `<div class="muted">余量低于本计划</div>` : ""}</td>
</tr>`;
}).join("")}</tbody></table>` : `<div class="empty">没有可用证件,请先上传购买证及运输证。</div>`;
document.getElementById("linkCertDialog").showModal();
}
function closeLinkCertDlg() { document.getElementById("linkCertDialog").close(); linkingPlanId = null; }
function attachPlanToCert(p, c, t, allowOver, plate) {
const qty = occupyQty(p) || Number(p.planQty || 0);
if (!allowOver && remainGap(c, p.id) + 1e-9 < qty) return `购买证余量 ${remain(c, p.id)}${c.unit} 不足`;
if (!allowOver && transItemGap(t, p.id) + 1e-9 < qty) return `运输证 ${t.transNo} 余量 ${transItemRemain(t, p.id)}${c.unit} 不足`;
p.certId = c.id;
p.transId = t.id;
const want = plate || p.tractor || "";
const d = want
? ((t.dispatches || []).find((x) => x.plate === want) || { plate: want, driver: p.driver })
: pickVehicleForTrans(t, qty, p.tractor);
if (d && d.plate) {
p.tractor = d.plate;
p.driver = d.driver || p.driver;
}
const calc = calcVehPlanQty(c, t, p.tractor, p);
p.vehPlanQty = calc.qty;
p.linkBound = calc.room;
return "";
}
function linkCert(certId, transId) {
const p = plans.find((x) => x.id === linkingPlanId);
const c = certs.find((x) => x.id === certId);
const t = c ? findTrans(c, transId) : null;
if (!p || !c || !t) return;
const err = attachPlanToCert(p, c, t, true);
if (err) { toast(err); return; }
closeLinkCertDlg();
const over = p.tractor && t.dispatches && vehicleOver({ plate: p.tractor, qty: ((t.dispatches || []).find((d) => d.plate === p.tractor) || {}).qty || 0 }, t);
toast(`已关联 ${c.buyNo} / ${t.transNo}${p.tractor ? " · " + p.tractor : ""}${over ? "(该车计划量已超分派,已预警)" : ""}`);
renderPlans();
if (currentMod === "cert") renderCertDetail();
}
function unlinkCertPlan(planId) {
const p = plans.find((x) => x.id === planId);
if (!p) return;
p.certId = null;
p.transId = null;
p.shipCertId = null;
p.procureCertId = null;
toast(`${p.no} 已取消关联`);
if (currentMod === "cert") {
if (document.getElementById("view-cert-detail")?.classList.contains("hidden")) renderCertList();
else renderCertDetail();
} else renderPlans();
updateBadges();
}
function certAllVehOptions(c, plate) {
const ds = c ? allDispatches(c) : [];
const plates = [...new Set(ds.map((d) => d.plate).filter(Boolean))];
if (plate && !plates.includes(plate)) plates.unshift(plate);
const opts = [`<option value="">未指定车辆</option>`];
plates.forEach((pl) => {
const d = ds.find((x) => x.plate === pl);
const cap = d ? Number(d.qty || 0) : 0;
const tno = d && d.transNo ? d.transNo : "";
opts.push(`<option value="${pl}" ${pl === plate ? "selected" : ""}>${pl}${cap ? " · 分派 " + cap + "吨" : ""}${tno ? " · " + tno : ""}</option>`);
});
return opts.join("");
}
function certLinkVehOptions(t, plate) {
const fromPlates = t ? String(t.plates || "").split(/[、,,;;\s]+/).map((s) => s.trim()).filter(Boolean) : [];
const plates = t ? [...new Set([...(t.dispatches || []).map((d) => d.plate), ...fromPlates, plate].filter(Boolean))] : (plate ? [plate] : []);
const opts = [`<option value="">未指定车辆</option>`];
plates.forEach((pl) => {
const d = t && (t.dispatches || []).find((x) => x.plate === pl);
const cap = d ? Number(d.qty || 0) : 0;
opts.push(`<option value="${pl}" ${pl === plate ? "selected" : ""}>${pl}${cap ? " · 分派 " + cap + "吨" : ""}</option>`);
});
return opts.join("");
}
function openCertLinkPlanDlg(certId, focusPlanIds) {
const c = certs.find((x) => x.id === certId);
if (!c) return;
linkingCertId = certId;
currentCertId = certId;
linkingCertFocusIds = (focusPlanIds || []).map(Number);
const list = transOf(c);
if (!list.length) {
toast("请先上传运输证");
return;
}
document.getElementById("certLinkPlanHint").textContent =
`购买证 ${c.buyNo} · ${c.product} · 证载 ${c.buyQty}${c.unit}。勾选计划并指定运输证、车辆;余量 = 证载 − Σ关联车辆计划量,≤ 自设预警量时预警。`;
const qEl = document.getElementById("certLinkPlanQ");
if (qEl) qEl.value = "";
const cands = plans.filter((p) =>
p.status !== "已作废" &&
(p.certId === c.id || !p.certId) &&
(p.ctrl || /硫酸|硝酸|铵/.test(p.product || ""))
).sort((a, b) => {
const fa = linkingCertFocusIds.includes(a.id) ? 1 : 0;
const fb = linkingCertFocusIds.includes(b.id) ? 1 : 0;
if (fa !== fb) return fb - fa;
const ma = planMatchesCert(a, c) ? 1 : 0;
const mb = planMatchesCert(b, c) ? 1 : 0;
if (ma !== mb) return mb - ma;
const la = a.certId === c.id ? 1 : 0;
const lb = b.certId === c.id ? 1 : 0;
return lb - la;
});
document.getElementById("certLinkPlanTable").innerHTML = cands.length ? `<table class="erp-table"><thead><tr>
<th class="chk"></th><th>计划编号</th><th>状态</th><th>品名</th><th>客户</th><th>计划量</th><th>两证可运</th><th>占用运输证</th><th>关联车辆</th>
</tr></thead><tbody>${cands.map((p) => {
const on = p.certId === c.id || linkingCertFocusIds.includes(p.id);
const match = planMatchesCert(p, c);
const tid = (suggestTransForPlan(c, p) || list[0]).id;
const t = findTrans(c, tid);
const blob = [p.no, p.product, p.customer, p.settler, p.tractor, p.ctrl].join(" ");
return `<tr data-blob="${blob}">
<td class="chk"><input type="checkbox" data-plan="${p.id}" ${on ? "checked" : ""} onchange="previewCertLinkPlans()" /></td>
<td class="cell-no">${p.no}${linkingCertFocusIds.includes(p.id) ? `<span class="match-rec">待关联</span>` : ""}</td>
<td>${planStHtml(p)}</td>
<td>${p.product}${match ? `<span class="match-rec">匹配</span>` : ""}<div class="muted">${p.ctrl || ""}</div></td>
<td class="clip">${p.customer || p.settler || "—"}</td>
<td class="num">${p.planQty} ${p.unit}</td>
<td class="num" data-bound-for="${p.id}">${fmtTon(calcVehPlanQty(c, t, p.tractor, p).qty)} ${c.unit}</td>
<td><select data-trans-for="${p.id}" onchange="syncCertLinkRow(${p.id})">${list.map((x) =>
`<option value="${x.id}" ${x.id === tid ? "selected" : ""}>${x.transNo} · 余 ${fmtTon(transItemRemain(x))}${c.unit}</option>`
).join("")}</select></td>
<td><select data-veh-for="${p.id}" onchange="previewCertLinkPlans()">${certLinkVehOptions(t, p.tractor)}</select></td>
</tr>`;
}).join("")}</tbody></table>` : `<div class="empty">没有可关联的易制毒 / 易制爆计划。</div>`;
document.getElementById("certLinkPlanDialog").showModal();
previewCertLinkPlans();
}
function filterCertLinkPlanRows() {
const q = (document.getElementById("certLinkPlanQ")?.value || "").trim();
document.querySelectorAll("#certLinkPlanTable tbody tr").forEach((tr) => {
const blob = tr.getAttribute("data-blob") || "";
tr.style.display = !q || blob.includes(q) ? "" : "none";
});
}
function syncCertLinkRow(planId) {
const c = certs.find((x) => x.id === linkingCertId);
if (!c) return;
const transSel = document.querySelector(`#certLinkPlanTable [data-trans-for="${planId}"]`);
const vehSel = document.querySelector(`#certLinkPlanTable [data-veh-for="${planId}"]`);
const p = plans.find((x) => x.id === planId);
const t = findTrans(c, +(transSel && transSel.value));
if (vehSel) vehSel.innerHTML = certLinkVehOptions(t, (vehSel.value || (p && p.tractor) || ""));
previewCertLinkPlans();
}
function certLinkDrafts() {
const c = certs.find((x) => x.id === linkingCertId);
if (!c) return [];
return [...document.querySelectorAll("#certLinkPlanTable [data-plan]:checked")].map((el) => {
const p = plans.find((x) => x.id === +el.dataset.plan);
if (!p) return null;
const transSel = document.querySelector(`#certLinkPlanTable [data-trans-for="${p.id}"]`);
const vehSel = document.querySelector(`#certLinkPlanTable [data-veh-for="${p.id}"]`);
const t = findTrans(c, +(transSel && transSel.value));
const plate = (vehSel && vehSel.value) || p.tractor || "";
return { p, t, plate, qty: occupyQty(p) };
}).filter((x) => x && x.t);
}
function previewCertLinkPlans() {
const box = document.getElementById("certLinkPreview");
const c = certs.find((x) => x.id === linkingCertId);
if (!box || !c) return;
const drafts = certLinkDrafts();
drafts.forEach(({ p, t, plate }) => {
const cell = document.querySelector(`[data-bound-for="${p.id}"]`);
if (cell) cell.textContent = `${fmtTon(calcVehPlanQty(c, t, plate, p).qty)} ${c.unit}`;
});
const shown = new Set([...document.querySelectorAll("#certLinkPlanTable [data-plan]")].map((el) => +el.dataset.plan));
const keep = linkedPlansOf(c).filter((p) => !shown.has(p.id));
const used = drafts.reduce((s, x) => s + x.qty, 0) + keep.reduce((s, p) => s + occupyQty(p), 0);
const buyLeft = Number(c.buyQty || 0) - used;
const byTrans = {};
const veh = {};
drafts.forEach((x) => {
byTrans[x.t.id] = (byTrans[x.t.id] || 0) + x.qty;
if (x.plate) veh[x.t.id + "|" + x.plate] = (veh[x.t.id + "|" + x.plate] || 0) + x.qty;
});
keep.forEach((p) => {
if (p.transId) byTrans[p.transId] = (byTrans[p.transId] || 0) + occupyQty(p);
if (p.transId && p.tractor) veh[p.transId + "|" + p.tractor] = (veh[p.transId + "|" + p.tractor] || 0) + occupyQty(p);
});
const transTips = Object.entries(byTrans).map(([tid, hold]) => {
const t = findTrans(c, +tid);
if (!t) return "";
const left = Number(t.transQty || 0) - hold;
if (left < -1e-6) return `${t.transNo} 超 ${(-left).toFixed(2)}${c.unit}`;
if (lowRemain(left, transWarnQty(t))) return `${t.transNo} 余 ${left}${c.unit}(≤${fmtTon(transWarnQty(t))})`;
return `${t.transNo} 余 ${left}${c.unit}`;
});
const vehTips = [];
Object.entries(veh).forEach(([key, hold]) => {
const [tid, plate] = key.split("|");
const t = findTrans(c, +tid);
const d = t && (t.dispatches || []).find((x) => x.plate === plate);
const cap = d ? Number(d.qty || 0) : 0;
if (cap > 0 && hold - cap > 1e-6) vehTips.push(`${plate} 计划 ${hold}${c.unit} > 分派 ${cap}${c.unit}`);
else if (cap <= 0 && hold > 1e-6) vehTips.push(`${plate} 计划 ${hold}${c.unit},运输证尚未分派该车`);
});
let cls = "";
const bits = [
`已勾选 ${drafts.length} 条,各车运输计划量 = min(计划量, 购买证余量, 运输证余量, 车辆分派)`,
`关联合计 ${fmtTon(used)}${c.unit}`,
`购买证 ${fmtTon(c.buyQty)} − 已完成/在途 = 余 ${fmtTon(buyLeft)}${c.unit}`,
...transTips
];
const transOver = Object.entries(byTrans).some(([tid, hold]) => {
const t = findTrans(c, +tid);
return t && Number(t.transQty || 0) - hold < -1e-6;
});
if (buyLeft < -1e-6 || transOver || vehTips.length) {
cls = "over";
if (buyLeft < -1e-6) bits.push(`购买证超 ${(-buyLeft).toFixed(2)}${c.unit}`);
bits.push(...vehTips);
} else if ((buyLeft > 0 && buyLeft <= buyWarnQty(c)) || transTips.some((s) => s.includes("≤"))) {
cls = "warn";
bits.push(`余量 ≤ 预警量 ${fmtTon(buyWarnQty(c))}${c.unit},保存后预警`);
} else if (vehTips.length === 0) {
bits.push("车辆计划量未超分派");
}
box.className = "link-preview" + (cls ? " " + cls : "");
box.textContent = bits.filter(Boolean).join(" · ");
}
function closeCertLinkPlanDlg() {
document.getElementById("certLinkPlanDialog").close();
linkingCertId = null;
linkingCertFocusIds = [];
}
function saveCertLinkPlans() {
const c = certs.find((x) => x.id === linkingCertId);
if (!c) return;
const drafts = certLinkDrafts();
const missing = [...document.querySelectorAll("#certLinkPlanTable [data-plan]:checked")].length - drafts.length;
if (missing > 0) { toast("请为勾选的计划选择运输证"); return; }
const picked = drafts.map((x) => x.p.id);
const shown = [...document.querySelectorAll("#certLinkPlanTable [data-plan]")].map((el) => +el.dataset.plan);
let n = 0;
let warn = 0;
plans.forEach((p) => {
if (p.certId === c.id && shown.includes(p.id) && !picked.includes(p.id)) {
p.certId = null;
p.transId = null;
}
});
drafts.forEach(({ p, t, plate }) => {
attachPlanToCert(p, c, t, true, plate);
n += 1;
const d = (t.dispatches || []).find((x) => x.plate === p.tractor) || { plate: p.tractor, qty: 0 };
if (p.tractor && vehicleOver(d, t)) warn += 1;
});
closeCertLinkPlanDlg();
toast(warn ? `已关联 ${n} 条计划,已按两证计算各车运输计划量,其中 ${warn} 车超分派预警` : `已人工关联 ${n} 条计划,已按购买证 / 运输证计算各车运输计划量`);
showMod("cert");
showCertView("detail", c.id);
updateBadges();
}
document.getElementById("subnav").addEventListener("click", (e) => {
const b = e.target.closest("button");
if (b) showView(b.dataset.view);
});
document.getElementById("certSubnav").addEventListener("click", (e) => {
const b = e.target.closest("button");
if (!b || b.disabled) return;
showCertView(b.dataset.view);
});
document.getElementById("expTabs").addEventListener("click", (e) => {
const b = e.target.closest("button");
if (!b) return;
setExpTab(b.dataset.tab);
});
document.getElementById("planTabs").addEventListener("click", (e) => {
const b = e.target.closest("button");
if (!b) return;
planCardFilter = "";
planTab = b.dataset.tab;
document.querySelectorAll("#planTabs button").forEach((x) => x.classList.toggle("on", x === b));
renderPlans();
});
document.getElementById("planDateTabs").addEventListener("click", (e) => {
const b = e.target.closest("button");
if (!b) return;
planCardFilter = "";
planDateTab = b.dataset.tab;
document.querySelectorAll("#planDateTabs button").forEach((x) => x.classList.toggle("on", x === b));
renderPlans();
});
document.getElementById("quoteTabs").addEventListener("click", (e) => {
const b = e.target.closest("button");
if (!b) return;
quoteTab = b.dataset.tab;
quotePage = 1;
document.querySelectorAll("#quoteTabs button").forEach((x) => x.classList.toggle("on", x === b));
renderQuotes();
});
document.getElementById("ctTabs").addEventListener("click", (e) => {
const b = e.target.closest("button");
if (!b) return;
ctTab = b.dataset.tab;
ctPage = 1;
document.querySelectorAll("#ctTabs button").forEach((x) => x.classList.toggle("on", x === b));
renderContracts();
});
document.getElementById("archiveTabs").addEventListener("click", (e) => {
const b = e.target.closest("button");
if (!b) return;
archiveTab = b.dataset.tab;
document.querySelectorAll("#archiveTabs button").forEach((x) => x.classList.toggle("on", x === b));
renderArchive();
});
document.getElementById("taskTabs").addEventListener("click", (e) => {
const b = e.target.closest("button");
if (!b) return;
taskTab = b.dataset.tab;
document.querySelectorAll("#taskTabs button").forEach((x) => x.classList.toggle("on", x === b));
renderTasks();
});
document.querySelector(".sidebar").addEventListener("click", (e) => {
const tog = e.target.closest("[data-toggle]");
if (tog) {
const group = tog.closest(".nav-group");
const willOpen = !group.classList.contains("open");
group.classList.toggle("open");
if (willOpen && !group.querySelector(".side-sub.active")) {
const first = group.querySelector("[data-mod], [data-page]");
if (first?.dataset.mod) {
if (first.dataset.whView) currentWhView = first.dataset.whView;
if (first.dataset.fleetView) currentFleetView = first.dataset.fleetView;
showMod(first.dataset.mod);
} else if (first?.dataset.page) showPage(first.dataset.page);
}
return;
}
const leaf = e.target.closest("[data-mod], [data-page]");
if (!leaf) return;
if (leaf.dataset.mod) {
if (leaf.dataset.whView) currentWhView = leaf.dataset.whView;
if (leaf.dataset.fleetView) currentFleetView = leaf.dataset.fleetView;
showMod(leaf.dataset.mod);
} else showPage(leaf.dataset.page);
});
document.getElementById("globalSearch").addEventListener("keydown", (e) => {
if (e.key !== "Enter") return;
const q = e.target.value;
if (currentMod === "cert") {
document.getElementById("certQ").value = q;
showCertView("list");
} else if (currentMod === "plan") {
document.getElementById("planQ").value = q;
renderPlans();
} else if (currentMod === "product") {
document.getElementById("prodQ").value = q;
prodPage = 1;
renderProducts();
} else if (currentMod === "quote") {
document.getElementById("quoteQ").value = q;
quotePage = 1;
renderQuotes();
} else if (currentMod === "contract") {
document.getElementById("ctQ").value = q;
ctPage = 1;
renderContracts();
} else if (currentMod === "archive") {
document.getElementById("archiveQ").value = q;
renderArchive();
} else if (currentMod === "fleet") {
document.getElementById("fleetQ").value = q;
showView("fleet");
} else if (currentMod === "expense") {
setExpTab("全部");
const box = document.getElementById("expQ");
if (box) box.value = q;
if (typeof renderExpenseList === "function") renderExpenseList();
} else if (currentMod === "staff") {
const box = document.getElementById("staffQ");
if (box) box.value = q;
staffPage = 1;
renderStaff();
}
});
fillPlates();
updateBadges();
bindCertDrop(document.getElementById("buyOcrBox"), "buy");
bindCertDrop(document.getElementById("transOcrBox"), "trans");
document.getElementById("certForm")?.addEventListener("input", (e) => {
const n = e.target && e.target.name;
if (n !== "buyQty" && n !== "transQty") return;
const box = e.target.closest(".form") || e.target.closest(".extra-trans") || e.target.closest("#buyFormCol");
const warnEl = box && box.querySelector("[name=warnQty]");
if (warnEl) warnEl.value = defaultWarnQty(e.target.value);
});
showMod("cert");
document.addEventListener("click", () => {
document.getElementById("ctExportDrop")?.classList.remove("open");
document.getElementById("prodExportDrop")?.classList.remove("open");
});
</script>
</body>
</html>