feat: 细胞存储缴费催缴、统一样本信息与协议支付能力增强
汇总近期业务改动:催缴菜单与后端、样本信息快捷登记与进度、运费关联、客户档案扩展、协议优惠/期次与押金划转,并同步 SQL 迁移与管理端页面收敛。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
2d3472f5c4
commit
db31990ebe
235 files changed
+14898
-10196
No files matched your search
@@ -0,0 +1,746 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>配置运费 · 关联已有 — 列表展示方案 Demo</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
:root {
|
||||
--primary: #10b981;
|
||||
--primary-dark: #059669;
|
||||
--primary-bg: #ecfdf5;
|
||||
--bg-page: #f8fafc;
|
||||
--panel: #ffffff;
|
||||
--text: #0f172a;
|
||||
--text-secondary: #334155;
|
||||
--text-muted: #64748b;
|
||||
--border: #e2e8f0;
|
||||
--radius: 10px;
|
||||
--radius-sm: 8px;
|
||||
--warning: #d97706;
|
||||
--warning-bg: #fffbeb;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
background: var(--bg-page);
|
||||
}
|
||||
.page {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 24px 64px;
|
||||
}
|
||||
.hero {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.hero h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.hero h1::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
height: 1.1em;
|
||||
margin-right: 10px;
|
||||
vertical-align: -0.15em;
|
||||
border-radius: 2px;
|
||||
background: var(--primary);
|
||||
}
|
||||
.hero p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
max-width: 720px;
|
||||
}
|
||||
.context-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 16px 0 0;
|
||||
}
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
background: var(--primary-bg);
|
||||
color: var(--primary-dark);
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
.pill.muted {
|
||||
background: #f1f5f9;
|
||||
color: var(--text-muted);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.section {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px 20px 16px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.section-head h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.section-head .desc {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.badge {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.badge.now {
|
||||
background: #f1f5f9;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.badge.rec {
|
||||
background: var(--primary-bg);
|
||||
color: var(--primary-dark);
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
.badge.alt {
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning);
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
/* Dialog chrome */
|
||||
.dialog {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
.dialog-hd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
.dialog-hd .x {
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
cursor: default;
|
||||
}
|
||||
.dialog-bd { padding: 14px 18px 8px; }
|
||||
.hint {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.tabs {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.tab {
|
||||
padding: 7px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
background: #fff;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.tab:last-child { border-right: none; }
|
||||
.tab.active {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dialog-ft {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: #fafbfc;
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
color: var(--text-secondary);
|
||||
cursor: default;
|
||||
}
|
||||
.btn.main {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
table.list {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
table.list th,
|
||||
table.list td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
table.list th {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
background: #f8fafc;
|
||||
white-space: nowrap;
|
||||
}
|
||||
table.list tr:nth-child(even) td { background: #fafbfc; }
|
||||
table.list tr.selected td {
|
||||
background: var(--primary-bg) !important;
|
||||
outline: none;
|
||||
}
|
||||
table.list tr.selected {
|
||||
box-shadow: inset 3px 0 0 var(--primary);
|
||||
}
|
||||
table.list td.right { text-align: right; }
|
||||
table.list td.center { text-align: center; }
|
||||
.muted { color: var(--text-muted); }
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.6;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.tag.primary {
|
||||
background: var(--primary-bg);
|
||||
color: var(--primary-dark);
|
||||
border-color: #a7f3d0;
|
||||
}
|
||||
.tag.success {
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
border-color: #a7f3d0;
|
||||
}
|
||||
.tag.warning {
|
||||
background: #fffbeb;
|
||||
color: #b45309;
|
||||
border-color: #fde68a;
|
||||
}
|
||||
.tag.muted {
|
||||
background: #f1f5f9;
|
||||
color: var(--text-muted);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
max-width: 280px;
|
||||
}
|
||||
.chip {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
padding: 1px 7px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
background: #f1f5f9;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.chip.more {
|
||||
background: #fff;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.summary-line {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.summary-line .k {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.expand-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
color: var(--primary-dark);
|
||||
cursor: default;
|
||||
}
|
||||
.detail-row td {
|
||||
background: #f8fafc !important;
|
||||
padding: 0 12px 12px 36px !important;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.detail-box {
|
||||
margin-top: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px dashed #a7f3d0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: #fff;
|
||||
}
|
||||
.detail-box table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
.detail-box th,
|
||||
.detail-box td {
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
text-align: left;
|
||||
}
|
||||
.detail-box th {
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
background: transparent;
|
||||
}
|
||||
.detail-box tr:last-child td { border-bottom: none; }
|
||||
|
||||
.pros-cons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.pros-cons { grid-template-columns: 1fr; }
|
||||
}
|
||||
.pros, .cons {
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
}
|
||||
.pros {
|
||||
background: var(--primary-bg);
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
.cons {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
.pros h4, .cons h4 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.pros ul, .cons ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.vote {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.vote label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
min-width: 200px;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.vote label:hover {
|
||||
border-color: #a7f3d0;
|
||||
}
|
||||
.vote input { accent-color: var(--primary); }
|
||||
.vote label.picked {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary-bg);
|
||||
box-shadow: 0 0 0 1px var(--primary);
|
||||
}
|
||||
.footer-note {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.choose-panel {
|
||||
border: 2px solid var(--primary);
|
||||
background: linear-gradient(180deg, #ecfdf5 0%, #fff 48%);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<header class="hero">
|
||||
<h1>配置运费 → 关联已有:列表展示方案</h1>
|
||||
<p>
|
||||
采集协议单(细胞存储 / 科研捐赠)与「运费信息」页共用「配置运费」弹窗。
|
||||
当前「关联已有」仅展示运输方式、金额、关联数、创建时间,难以判断该挂哪一笔批次。
|
||||
下方对比三种展示方式(示例数据为假数据)。
|
||||
</p>
|
||||
<div class="context-bar">
|
||||
<span class="pill">入口:协议单 · 样本及运输信息</span>
|
||||
<span class="pill">入口:运费信息 · 配置运费</span>
|
||||
<span class="pill muted">API 已有 includeItems(协议单号 / 样本编码 / 来源)</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- A: Status quo -->
|
||||
<section class="section">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>方案 A · 现状(过简)</h2>
|
||||
<p class="desc">仅批次摘要四列,多笔「顺丰快递 / 同金额」时无法区分。</p>
|
||||
</div>
|
||||
<span class="badge now">现状</span>
|
||||
</div>
|
||||
<div class="dialog">
|
||||
<div class="dialog-hd">配置运费 <span class="x">✕</span></div>
|
||||
<div class="dialog-bd">
|
||||
<p class="hint">将运费应用到已选中的 2 个样本(可跨协议共用一笔)。</p>
|
||||
<div class="tabs">
|
||||
<span class="tab">新建运费</span>
|
||||
<span class="tab active">关联已有</span>
|
||||
</div>
|
||||
<table class="list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>运输方式</th>
|
||||
<th style="text-align:right">运费(元)</th>
|
||||
<th style="text-align:center">关联样本数</th>
|
||||
<th>创建时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="selected">
|
||||
<td>顺丰快递</td>
|
||||
<td class="right">¥280.00</td>
|
||||
<td class="center">3</td>
|
||||
<td>2026-10-07 14:22</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>顺丰快递</td>
|
||||
<td class="right">¥280.00</td>
|
||||
<td class="center">2</td>
|
||||
<td>2026-10-06 09:10</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>专线车</td>
|
||||
<td class="right">¥1,200.00</td>
|
||||
<td class="center">5</td>
|
||||
<td>2026-10-05 16:48</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="dialog-ft">
|
||||
<button class="btn" type="button">取消</button>
|
||||
<button class="btn main" type="button">确定应用</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pros-cons">
|
||||
<div class="pros"><h4>优点</h4><ul><li>实现简单、弹窗窄</li></ul></div>
|
||||
<div class="cons"><h4>问题</h4><ul><li>同方式同金额无法辨认</li><li>看不到已挂了哪些协议/样本</li></ul></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- B: Summary columns -->
|
||||
<section class="section">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>方案 B · 批次行内摘要(推荐)</h2>
|
||||
<p class="desc">
|
||||
列表请求带 <code>includeItems=true</code>,增加「已关联样本」摘要列:来源 Tag + 协议单号/样本编码 chips(超出折叠为 +N)。
|
||||
弹窗略加宽,仍保持一行一批次、点选即关联。
|
||||
</p>
|
||||
</div>
|
||||
<span class="badge rec">推荐</span>
|
||||
</div>
|
||||
<div class="dialog">
|
||||
<div class="dialog-hd">配置运费 <span class="x">✕</span></div>
|
||||
<div class="dialog-bd">
|
||||
<p class="hint">将运费应用到已选中的 2 个样本(可跨协议共用一笔)。点击一行选择要关联的批次。</p>
|
||||
<div class="tabs">
|
||||
<span class="tab">新建运费</span>
|
||||
<span class="tab active">关联已有</span>
|
||||
</div>
|
||||
<table class="list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>运输方式</th>
|
||||
<th style="text-align:right">运费(元)</th>
|
||||
<th style="text-align:center">关联数</th>
|
||||
<th>已关联样本</th>
|
||||
<th>创建时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="selected">
|
||||
<td><span class="tag primary">顺丰快递</span></td>
|
||||
<td class="right">¥280.00</td>
|
||||
<td class="center">3</td>
|
||||
<td>
|
||||
<div class="chips">
|
||||
<span class="tag success">细胞存储</span>
|
||||
<span class="chip">XY-2026-1001</span>
|
||||
<span class="chip">S-A001</span>
|
||||
<span class="chip">S-A002</span>
|
||||
<span class="chip more">+1</span>
|
||||
</div>
|
||||
<div class="summary-line"><span class="k">协议</span> XY-2026-1001 · XY-2026-1008</div>
|
||||
</td>
|
||||
<td>2026-10-07 14:22</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="tag primary">顺丰快递</span></td>
|
||||
<td class="right">¥280.00</td>
|
||||
<td class="center">2</td>
|
||||
<td>
|
||||
<div class="chips">
|
||||
<span class="tag warning">科研捐赠</span>
|
||||
<span class="chip">JZ-2026-0033</span>
|
||||
<span class="chip">D-B011</span>
|
||||
<span class="chip">D-B012</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>2026-10-06 09:10</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="tag primary">专线车</span></td>
|
||||
<td class="right">¥1,200.00</td>
|
||||
<td class="center">5</td>
|
||||
<td>
|
||||
<div class="chips">
|
||||
<span class="tag success">细胞存储</span>
|
||||
<span class="tag warning">科研捐赠</span>
|
||||
<span class="chip">XY-2026-0888</span>
|
||||
<span class="chip">JZ-2026-0012</span>
|
||||
<span class="chip more">+3 样本</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>2026-10-05 16:48</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="dialog-ft">
|
||||
<button class="btn" type="button">取消</button>
|
||||
<button class="btn main" type="button">确定应用</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pros-cons">
|
||||
<div class="pros">
|
||||
<h4>优点</h4>
|
||||
<ul>
|
||||
<li>一眼区分同金额批次</li>
|
||||
<li>复用现有 list + includeItems,无需新接口</li>
|
||||
<li>协议单弹窗与运费页弹窗可共用同一表格结构</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="cons">
|
||||
<h4>取舍</h4>
|
||||
<ul>
|
||||
<li>多样本时 chips 需截断</li>
|
||||
<li>弹窗建议加宽至约 900~960px</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- C: Expand detail -->
|
||||
<section class="section">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>方案 C · 行展开明细</h2>
|
||||
<p class="desc">
|
||||
主表仍较精简;点击「明细」展开已关联样本表(来源 / 协议单号 / 样本编码 / 样本类型),对齐「运费信息」台账字段。
|
||||
</p>
|
||||
</div>
|
||||
<span class="badge alt">备选</span>
|
||||
</div>
|
||||
<div class="dialog">
|
||||
<div class="dialog-hd">配置运费 <span class="x">✕</span></div>
|
||||
<div class="dialog-bd">
|
||||
<p class="hint">将运费应用到已选中的 2 个样本。可展开查看批次下样本后再选择。</p>
|
||||
<div class="tabs">
|
||||
<span class="tab">新建运费</span>
|
||||
<span class="tab active">关联已有</span>
|
||||
</div>
|
||||
<table class="list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:72px"></th>
|
||||
<th>运输方式</th>
|
||||
<th style="text-align:right">运费(元)</th>
|
||||
<th style="text-align:center">关联数</th>
|
||||
<th>创建时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="selected">
|
||||
<td><span class="expand-btn">▾ 明细</span></td>
|
||||
<td><span class="tag primary">顺丰快递</span></td>
|
||||
<td class="right">¥280.00</td>
|
||||
<td class="center">3</td>
|
||||
<td>2026-10-07 14:22</td>
|
||||
</tr>
|
||||
<tr class="detail-row">
|
||||
<td colspan="5">
|
||||
<div class="detail-box">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>来源</th>
|
||||
<th>协议单号</th>
|
||||
<th>样本编码</th>
|
||||
<th>样本类型</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><span class="tag success">细胞存储</span></td>
|
||||
<td>XY-2026-1001</td>
|
||||
<td>S-A001</td>
|
||||
<td>脐带血</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="tag success">细胞存储</span></td>
|
||||
<td>XY-2026-1001</td>
|
||||
<td>S-A002</td>
|
||||
<td>脐带血</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="tag success">细胞存储</span></td>
|
||||
<td>XY-2026-1008</td>
|
||||
<td>S-C019</td>
|
||||
<td>胎盘</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="expand-btn">▸ 明细</span></td>
|
||||
<td><span class="tag primary">顺丰快递</span></td>
|
||||
<td class="right">¥280.00</td>
|
||||
<td class="center">2</td>
|
||||
<td>2026-10-06 09:10</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="expand-btn">▸ 明细</span></td>
|
||||
<td><span class="tag primary">专线车</span></td>
|
||||
<td class="right">¥1,200.00</td>
|
||||
<td class="center">5</td>
|
||||
<td>2026-10-05 16:48</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="dialog-ft">
|
||||
<button class="btn" type="button">取消</button>
|
||||
<button class="btn main" type="button">确定应用</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pros-cons">
|
||||
<div class="pros">
|
||||
<h4>优点</h4>
|
||||
<ul>
|
||||
<li>明细完整,与运费台账一致</li>
|
||||
<li>默认视图不拥挤</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="cons">
|
||||
<h4>取舍</h4>
|
||||
<ul>
|
||||
<li>多一次展开操作,扫一眼成本更高</li>
|
||||
<li>交互与表格高度略复杂</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Choose -->
|
||||
<section class="section choose-panel">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>请选择倾向方案</h2>
|
||||
<p class="desc">点选后告诉我你的选择(或回复 A / B / C)。也可回复「B + 可展开看全量」等组合。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vote" id="vote">
|
||||
<label data-v="a">
|
||||
<input type="radio" name="plan" value="a" />
|
||||
<span><strong>A</strong> 保持现状</span>
|
||||
</label>
|
||||
<label data-v="b">
|
||||
<input type="radio" name="plan" value="b" checked />
|
||||
<span><strong>B</strong> 行内摘要(推荐)</span>
|
||||
</label>
|
||||
<label data-v="c">
|
||||
<input type="radio" name="plan" value="c" />
|
||||
<span><strong>C</strong> 行展开明细</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="footer-note">
|
||||
文件位置:<code>docs/demos/2026-10-08-freight-link-existing-demo.html</code>
|
||||
· 用浏览器直接打开即可预览。
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
<script>
|
||||
const vote = document.getElementById('vote')
|
||||
function sync() {
|
||||
vote.querySelectorAll('label').forEach((lab) => {
|
||||
const input = lab.querySelector('input')
|
||||
lab.classList.toggle('picked', !!(input && input.checked))
|
||||
})
|
||||
}
|
||||
vote.addEventListener('change', sync)
|
||||
sync()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,964 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>缴费信息 · 单列表(Excel 表头)Demo</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
:root {
|
||||
--primary: #10b981;
|
||||
--primary-dark: #059669;
|
||||
--primary-bg: #ecfdf5;
|
||||
--bg-page: #f8fafc;
|
||||
--panel: #ffffff;
|
||||
--text: #0f172a;
|
||||
--text-secondary: #334155;
|
||||
--text-muted: #64748b;
|
||||
--border: #d9e8e2;
|
||||
--table-head: #f8fafc;
|
||||
--row-alt: #fafafa;
|
||||
--row-hover: #f1f5f9;
|
||||
--radius: 10px;
|
||||
--radius-sm: 8px;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
--warning: #d97706;
|
||||
--warning-bg: #fffbeb;
|
||||
--danger: #dc2626;
|
||||
--p1: #dbeafe;
|
||||
--p2: #ffedd5;
|
||||
--p3: #fef9c3;
|
||||
--p4: #dcfce7;
|
||||
--p5: #ffedd5;
|
||||
--p6: #d1fae5;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
background: var(--bg-page);
|
||||
}
|
||||
.page {
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 24px 20px 80px;
|
||||
}
|
||||
.hero {
|
||||
max-width: 960px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.hero h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.hero h1::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
height: 1.1em;
|
||||
margin-right: 10px;
|
||||
vertical-align: -0.15em;
|
||||
border-radius: 2px;
|
||||
background: var(--primary);
|
||||
}
|
||||
.hero p {
|
||||
margin: 0 0 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.context-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
background: var(--primary-bg);
|
||||
color: var(--primary-dark);
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
.pill.warn {
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning);
|
||||
border-color: rgba(217, 119, 6, 0.25);
|
||||
}
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 16px;
|
||||
margin: 16px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.swatch {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #fff;
|
||||
}
|
||||
.panel-top h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.panel-top h2::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 3px;
|
||||
height: 1em;
|
||||
margin-right: 8px;
|
||||
vertical-align: -0.1em;
|
||||
border-radius: 2px;
|
||||
background: var(--primary);
|
||||
}
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.filter-block {
|
||||
padding: 14px 18px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.filter-item label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.filter-item input,
|
||||
.filter-item select {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
}
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
color: var(--text-secondary);
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn:hover { background: var(--row-hover); }
|
||||
.btn-main {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-main:hover { background: var(--primary-dark); }
|
||||
.table-scroll {
|
||||
overflow: auto;
|
||||
max-height: calc(100vh - 280px);
|
||||
}
|
||||
table.list-table {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
font-size: 13px;
|
||||
}
|
||||
.list-table th,
|
||||
.list-table td {
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-right: 1px solid #eef2f0;
|
||||
padding: 8px 10px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.list-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
background: var(--table-head);
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
.list-table thead tr.group-row th {
|
||||
top: 0;
|
||||
z-index: 4;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.list-table thead tr.sub-row th {
|
||||
top: 34px;
|
||||
z-index: 3;
|
||||
}
|
||||
.list-table tbody tr:nth-child(even) td { background: var(--row-alt); }
|
||||
.list-table tbody tr:hover td { background: var(--row-hover); }
|
||||
.sticky-left {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
background: #fff !important;
|
||||
box-shadow: 2px 0 0 rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
.list-table tbody tr:nth-child(even) .sticky-left { background: var(--row-alt) !important; }
|
||||
.list-table tbody tr:hover .sticky-left { background: var(--row-hover) !important; }
|
||||
.list-table thead .sticky-left {
|
||||
z-index: 5;
|
||||
background: var(--table-head) !important;
|
||||
}
|
||||
.g-base { background: #f1f5f9 !important; color: var(--text-secondary); }
|
||||
.g-sum { background: #ecfdf5 !important; color: var(--primary-dark); }
|
||||
.g-p1 { background: var(--p1) !important; color: #1e40af; }
|
||||
.g-p2 { background: var(--p2) !important; color: #9a3412; }
|
||||
.g-p3 { background: var(--p3) !important; color: #854d0e; }
|
||||
.g-p4 { background: var(--p4) !important; color: #166534; }
|
||||
.g-p5 { background: var(--p5) !important; color: #9a3412; }
|
||||
.g-p6 { background: var(--p6) !important; color: #065f46; }
|
||||
.sub-p1 { background: #eff6ff !important; }
|
||||
.sub-p2 { background: #fff7ed !important; }
|
||||
.sub-p3 { background: #fefce8 !important; }
|
||||
.sub-p4 { background: #f0fdf4 !important; }
|
||||
.sub-p5 { background: #fff7ed !important; }
|
||||
.sub-p6 { background: #ecfdf5 !important; }
|
||||
.empty-period {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
.amount-link {
|
||||
color: var(--primary-dark);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dashed rgba(5, 150, 105, 0.45);
|
||||
cursor: pointer;
|
||||
}
|
||||
.amount-link:hover {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.tag-success { background: var(--primary-bg); color: var(--primary-dark); }
|
||||
.tag-warning { background: var(--warning-bg); color: var(--warning); }
|
||||
.tag-info { background: #f1f5f9; color: #64748b; }
|
||||
.tag-primary { background: #eff6ff; color: #1d4ed8; }
|
||||
.tag-danger { background: #fef2f2; color: var(--danger); }
|
||||
.muted { color: var(--text-muted); }
|
||||
.customer-cell { text-align: left; min-width: 100px; }
|
||||
.customer-cell .sub { font-size: 12px; color: var(--text-muted); }
|
||||
.pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.notes {
|
||||
max-width: 960px;
|
||||
margin-top: 20px;
|
||||
padding: 16px 18px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.notes h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.notes ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.notes li { margin: 4px 0; }
|
||||
/* Modal */
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.35);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
padding: 24px;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
.backdrop.is-open { display: flex; }
|
||||
.dialog {
|
||||
width: min(720px, 100%);
|
||||
max-height: calc(100vh - 48px);
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.18);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.dialog-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
.dialog-close {
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.dialog-body {
|
||||
padding: 16px 18px;
|
||||
overflow: auto;
|
||||
}
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.summary-item {
|
||||
background: var(--table-head);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.summary-item label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.summary-item strong {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.section-title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.transfer-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px;
|
||||
margin-bottom: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
.transfer-card:last-child { margin-bottom: 0; }
|
||||
.transfer-top {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.transfer-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 6px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.transfer-meta span { color: var(--text-muted); }
|
||||
.attach-row {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.attach-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
border-radius: 6px;
|
||||
background: var(--primary-bg);
|
||||
color: var(--primary-dark);
|
||||
font-size: 12px;
|
||||
border: 1px dashed #a7f3d0;
|
||||
}
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--table-head);
|
||||
}
|
||||
.audit-timeline {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed var(--border);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.audit-timeline div { margin: 2px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="hero">
|
||||
<h1>缴费信息 · 单列表 Demo(对齐 Excel 表头)</h1>
|
||||
<p>
|
||||
去掉「已缴费 / 确认中 / 成功 / 按样本」四个 Tab,改为一行一样本:左侧基础信息 + 右侧第 1~6 期缴费块。
|
||||
趸交 / 分改趸仅第 1 期有值;分期填满 1~6 期(无数据期显示 —)。
|
||||
<strong>点击各期「实缴金额」</strong>打开弹窗,查看该期单笔/多笔转账与财务审核。
|
||||
</p>
|
||||
<div class="context-bar">
|
||||
<span class="pill">一行 = 一个样本缴费方案</span>
|
||||
<span class="pill">期块固定 6 列组(对齐 Excel)</span>
|
||||
<span class="pill warn">实缴金额可点击 → 转账明细弹窗</span>
|
||||
</div>
|
||||
<div class="legend">
|
||||
<span class="legend-item"><i class="swatch" style="background:var(--p1)"></i>第1期</span>
|
||||
<span class="legend-item"><i class="swatch" style="background:var(--p2)"></i>第2期</span>
|
||||
<span class="legend-item"><i class="swatch" style="background:var(--p3)"></i>第3期</span>
|
||||
<span class="legend-item"><i class="swatch" style="background:var(--p4)"></i>第4期</span>
|
||||
<span class="legend-item"><i class="swatch" style="background:var(--p5)"></i>第5期</span>
|
||||
<span class="legend-item"><i class="swatch" style="background:var(--p6)"></i>第6期</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-top">
|
||||
<h2>缴费信息</h2>
|
||||
<div class="hint">横向滚动查看各期 · 姓名列左侧冻结</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-block">
|
||||
<div class="filter-grid">
|
||||
<div class="filter-item">
|
||||
<label>关键词</label>
|
||||
<input placeholder="姓名 / 手机 / 合同编号" />
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<label>缴费方式</label>
|
||||
<select>
|
||||
<option>全部</option>
|
||||
<option>趸交</option>
|
||||
<option>分期</option>
|
||||
<option>分改趸</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<label>到款状态</label>
|
||||
<select>
|
||||
<option>全部</option>
|
||||
<option>待缴费</option>
|
||||
<option>部分已缴</option>
|
||||
<option>缴费确认中</option>
|
||||
<option>已缴清</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<label>签署日期</label>
|
||||
<input placeholder="开始 — 结束" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<button type="button" class="btn">重置</button>
|
||||
<button type="button" class="btn btn-main">搜索</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-scroll">
|
||||
<table class="list-table" id="mainTable">
|
||||
<thead>
|
||||
<tr class="group-row">
|
||||
<th class="g-base sticky-left" colspan="11">基础信息</th>
|
||||
<th class="g-p1" colspan="5">第一期</th>
|
||||
<th class="g-p2" colspan="5">第二期</th>
|
||||
<th class="g-p3" colspan="5">第三期</th>
|
||||
<th class="g-p4" colspan="5">第四期</th>
|
||||
<th class="g-p5" colspan="5">第五期</th>
|
||||
<th class="g-p6" colspan="5">第六期</th>
|
||||
<th class="g-sum" colspan="3">汇总</th>
|
||||
</tr>
|
||||
<tr class="sub-row">
|
||||
<th class="sticky-left">序号</th>
|
||||
<th>姓名</th>
|
||||
<th>手机号</th>
|
||||
<th>合同编号</th>
|
||||
<th>合同签署日期</th>
|
||||
<th>存储年限</th>
|
||||
<th>存储项目</th>
|
||||
<th>合同总额</th>
|
||||
<th>缴费方式</th>
|
||||
<th>优惠金额</th>
|
||||
<th>应收总额</th>
|
||||
<!-- period headers x6 -->
|
||||
<th class="sub-p1">应缴金额</th>
|
||||
<th class="sub-p1">实缴金额</th>
|
||||
<th class="sub-p1">缴费日期</th>
|
||||
<th class="sub-p1">支付方式</th>
|
||||
<th class="sub-p1">财务审核</th>
|
||||
<th class="sub-p2">应缴金额</th>
|
||||
<th class="sub-p2">实缴金额</th>
|
||||
<th class="sub-p2">缴费日期</th>
|
||||
<th class="sub-p2">支付方式</th>
|
||||
<th class="sub-p2">财务审核</th>
|
||||
<th class="sub-p3">应缴金额</th>
|
||||
<th class="sub-p3">实缴金额</th>
|
||||
<th class="sub-p3">缴费日期</th>
|
||||
<th class="sub-p3">支付方式</th>
|
||||
<th class="sub-p3">财务审核</th>
|
||||
<th class="sub-p4">应缴金额</th>
|
||||
<th class="sub-p4">实缴金额</th>
|
||||
<th class="sub-p4">缴费日期</th>
|
||||
<th class="sub-p4">支付方式</th>
|
||||
<th class="sub-p4">财务审核</th>
|
||||
<th class="sub-p5">应缴金额</th>
|
||||
<th class="sub-p5">实缴金额</th>
|
||||
<th class="sub-p5">缴费日期</th>
|
||||
<th class="sub-p5">支付方式</th>
|
||||
<th class="sub-p5">财务审核</th>
|
||||
<th class="sub-p6">应缴金额</th>
|
||||
<th class="sub-p6">实缴金额</th>
|
||||
<th class="sub-p6">缴费日期</th>
|
||||
<th class="sub-p6">支付方式</th>
|
||||
<th class="sub-p6">财务审核</th>
|
||||
<th>累计实缴</th>
|
||||
<th>待缴金额</th>
|
||||
<th>备注</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pager">共 3 条 · 演示数据</div>
|
||||
</section>
|
||||
|
||||
<div class="notes">
|
||||
<h3>与现有系统字段映射(Demo 说明)</h3>
|
||||
<ul>
|
||||
<li><strong>一行粒度</strong>:样本缴费方案(与现「按样本」一致),不是按转账笔、也不是按协议整单。</li>
|
||||
<li><strong>趸交 / 分改趸</strong>:仅第 1 期有 schedule;第 2~6 期显示 —。</li>
|
||||
<li><strong>分期</strong>:按实际期数填 1~N;本 Demo 固定展示 6 列组,未用到的期显示 —(产品若确认最多 6 期则与 Excel 一致)。</li>
|
||||
<li><strong>期块「实缴金额」</strong>:该期全部转账金额合计(含确认中);点击后弹窗列出每笔转账与 pay_status / 财务审核时间线。</li>
|
||||
<li><strong>期块「缴费日期 / 支付方式 / 财务审核」</strong>:多笔时 Demo 取「最近一笔成功/确认中」摘要;完整明细以弹窗为准。</li>
|
||||
<li><strong>原流程三 Tab</strong>:待缴费/确认中/成功可由筛选「到款状态」或行内 Tag 替代,不再分 Tab。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="backdrop" id="backdrop" role="dialog" aria-modal="true">
|
||||
<div class="dialog">
|
||||
<div class="dialog-header">
|
||||
<h3 id="dlgTitle">第 N 期 · 转账记录</h3>
|
||||
<button type="button" class="dialog-close" id="dlgClose" aria-label="关闭">×</button>
|
||||
</div>
|
||||
<div class="dialog-body" id="dlgBody"></div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn" id="dlgCloseBtn">关闭</button>
|
||||
<button type="button" class="btn btn-main" id="dlgConfirmBtn">缴费确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const money = (n) =>
|
||||
n == null || n === '' || Number.isNaN(Number(n))
|
||||
? '—'
|
||||
: Number(n).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
|
||||
const payModeTag = (m) => {
|
||||
if (m === 'dj') return '<span class="tag tag-success">趸交</span>'
|
||||
if (m === 'fgd') return '<span class="tag tag-warning">分改趸</span>'
|
||||
if (m === 'fq') return '<span class="tag tag-primary">分期</span>'
|
||||
return '<span class="tag tag-info">—</span>'
|
||||
}
|
||||
|
||||
const auditTag = (s) => {
|
||||
const map = {
|
||||
yjf: ['tag-success', '已通过'],
|
||||
jfqrz: ['tag-warning', '确认中'],
|
||||
dfj: ['tag-info', '待缴费'],
|
||||
partial: ['tag-primary', '部分到账'],
|
||||
none: ['tag-info', '—']
|
||||
}
|
||||
const [cls, label] = map[s] || map.none
|
||||
return `<span class="tag ${cls}">${label}</span>`
|
||||
}
|
||||
|
||||
/** Demo rows: base + schedules[0..5] each with transfers[] */
|
||||
const rows = [
|
||||
{
|
||||
id: 'r1',
|
||||
name: '张敏',
|
||||
phone: '138****6210',
|
||||
protocolNo: 'XY-2026-0312',
|
||||
signDate: '2026-03-12',
|
||||
years: '20年',
|
||||
project: '脐带血干细胞',
|
||||
contractAmount: 22880,
|
||||
payMode: 'dj',
|
||||
discount: 880,
|
||||
receivable: 22000,
|
||||
remark: '趸交一次结清',
|
||||
schedules: [
|
||||
{
|
||||
due: 22000,
|
||||
paid: 22000,
|
||||
payDate: '2026-03-15',
|
||||
payType: '银行转账',
|
||||
audit: 'yjf',
|
||||
transfers: [
|
||||
{
|
||||
id: 't1',
|
||||
amount: 22000,
|
||||
type: '银行转账',
|
||||
time: '2026-03-15 14:22',
|
||||
status: 'yjf',
|
||||
voucher: '回单-0315.pdf',
|
||||
auditLog: [
|
||||
'03-15 14:30 提交财务确认',
|
||||
'03-15 16:02 财务审核通过'
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
null, null, null, null, null
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'r2',
|
||||
name: '李芳',
|
||||
phone: '159****8831',
|
||||
protocolNo: 'XY-2026-0418',
|
||||
signDate: '2026-04-18',
|
||||
years: '20年',
|
||||
project: '脐带间充质',
|
||||
contractAmount: 29800,
|
||||
payMode: 'fq',
|
||||
discount: 0,
|
||||
receivable: 29800,
|
||||
remark: '六期分期;第1期有两笔转账',
|
||||
schedules: [
|
||||
{
|
||||
due: 5000,
|
||||
paid: 5000,
|
||||
payDate: '2026-04-20',
|
||||
payType: '多笔',
|
||||
audit: 'yjf',
|
||||
transfers: [
|
||||
{
|
||||
id: 't2a',
|
||||
amount: 3000,
|
||||
type: '微信',
|
||||
time: '2026-04-19 10:11',
|
||||
status: 'yjf',
|
||||
voucher: 'wx-0419.jpg',
|
||||
auditLog: ['04-19 提交', '04-19 财务通过']
|
||||
},
|
||||
{
|
||||
id: 't2b',
|
||||
amount: 2000,
|
||||
type: '银行转账',
|
||||
time: '2026-04-20 09:40',
|
||||
status: 'yjf',
|
||||
voucher: '银行回单.pdf',
|
||||
auditLog: ['04-20 提交', '04-20 财务通过']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
due: 4960,
|
||||
paid: 4960,
|
||||
payDate: '2026-05-18',
|
||||
payType: '银行转账',
|
||||
audit: 'yjf',
|
||||
transfers: [
|
||||
{
|
||||
id: 't3',
|
||||
amount: 4960,
|
||||
type: '银行转账',
|
||||
time: '2026-05-18 11:05',
|
||||
status: 'yjf',
|
||||
voucher: '回单-0518.pdf',
|
||||
auditLog: ['05-18 提交', '05-18 财务通过']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
due: 4960,
|
||||
paid: 2000,
|
||||
payDate: '2026-06-18',
|
||||
payType: 'POS',
|
||||
audit: 'jfqrz',
|
||||
transfers: [
|
||||
{
|
||||
id: 't4',
|
||||
amount: 2000,
|
||||
type: 'POS',
|
||||
time: '2026-06-18 15:20',
|
||||
status: 'jfqrz',
|
||||
voucher: 'pos小票.jpg',
|
||||
auditLog: ['06-18 16:01 已提交财务,待审核']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
due: 4960,
|
||||
paid: 0,
|
||||
payDate: null,
|
||||
payType: null,
|
||||
audit: 'dfj',
|
||||
transfers: []
|
||||
},
|
||||
{
|
||||
due: 4960,
|
||||
paid: 0,
|
||||
payDate: null,
|
||||
payType: null,
|
||||
audit: 'dfj',
|
||||
transfers: []
|
||||
},
|
||||
{
|
||||
due: 4960,
|
||||
paid: 0,
|
||||
payDate: null,
|
||||
payType: null,
|
||||
audit: 'dfj',
|
||||
transfers: []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'r3',
|
||||
name: '王丽',
|
||||
phone: '186****2209',
|
||||
protocolNo: 'XY-2025-1120',
|
||||
signDate: '2025-11-20',
|
||||
years: '20年',
|
||||
project: '脐带血干细胞',
|
||||
contractAmount: 26800,
|
||||
payMode: 'fgd',
|
||||
discount: 800,
|
||||
receivable: 26000,
|
||||
remark: '原分期后改为趸交结清',
|
||||
schedules: [
|
||||
{
|
||||
due: 26000,
|
||||
paid: 26000,
|
||||
payDate: '2026-01-08',
|
||||
payType: '银行转账',
|
||||
audit: 'yjf',
|
||||
transfers: [
|
||||
{
|
||||
id: 't5',
|
||||
amount: 10000,
|
||||
type: '银行转账',
|
||||
time: '2025-12-01 09:00',
|
||||
status: 'yjf',
|
||||
voucher: '分期首付.pdf',
|
||||
auditLog: ['已通过(历史分期首付)']
|
||||
},
|
||||
{
|
||||
id: 't6',
|
||||
amount: 16000,
|
||||
type: '银行转账',
|
||||
time: '2026-01-08 13:40',
|
||||
status: 'yjf',
|
||||
voucher: '分改趸补缴.pdf',
|
||||
auditLog: ['01-08 提交分改趸补缴', '01-08 财务通过']
|
||||
}
|
||||
]
|
||||
},
|
||||
null, null, null, null, null
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
function periodCells(row, sch, periodIndex) {
|
||||
if (!sch) {
|
||||
return Array(5)
|
||||
.fill('<td class="empty-period">—</td>')
|
||||
.join('')
|
||||
}
|
||||
const paidClickable =
|
||||
(sch.transfers && sch.transfers.length > 0) || Number(sch.paid) > 0
|
||||
const paidHtml = paidClickable
|
||||
? `<a class="amount-link" href="javascript:void(0)" data-row="${row.id}" data-period="${periodIndex}">${money(sch.paid)}</a>`
|
||||
: `<span class="muted">${money(sch.paid)}</span>`
|
||||
return [
|
||||
`<td>${money(sch.due)}</td>`,
|
||||
`<td>${paidHtml}</td>`,
|
||||
`<td>${sch.payDate || '—'}</td>`,
|
||||
`<td>${sch.payType || '—'}</td>`,
|
||||
`<td>${auditTag(sch.audit)}</td>`
|
||||
].join('')
|
||||
}
|
||||
|
||||
function renderRows() {
|
||||
const tbody = document.getElementById('tbody')
|
||||
tbody.innerHTML = rows
|
||||
.map((row, idx) => {
|
||||
const paidSum = row.schedules.reduce(
|
||||
(s, sch) => s + (sch && sch.paid ? Number(sch.paid) : 0),
|
||||
0
|
||||
)
|
||||
const dueRemain = Math.max(0, Number(row.receivable) - paidSum)
|
||||
const periods = row.schedules
|
||||
.map((sch, i) => periodCells(row, sch, i))
|
||||
.join('')
|
||||
return `<tr>
|
||||
<td class="sticky-left">${idx + 1}</td>
|
||||
<td class="customer-cell"><div>${row.name}</div></td>
|
||||
<td>${row.phone}</td>
|
||||
<td>${row.protocolNo}</td>
|
||||
<td>${row.signDate}</td>
|
||||
<td>${row.years}</td>
|
||||
<td>${row.project}</td>
|
||||
<td>${money(row.contractAmount)}</td>
|
||||
<td>${payModeTag(row.payMode)}</td>
|
||||
<td>${money(row.discount)}</td>
|
||||
<td>${money(row.receivable)}</td>
|
||||
${periods}
|
||||
<td><strong style="color:var(--primary-dark)">${money(paidSum)}</strong></td>
|
||||
<td>${money(dueRemain)}</td>
|
||||
<td class="muted">${row.remark || '—'}</td>
|
||||
</tr>`
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
function openDialog(rowId, periodIndex) {
|
||||
const row = rows.find((r) => r.id === rowId)
|
||||
if (!row) return
|
||||
const sch = row.schedules[periodIndex]
|
||||
if (!sch) return
|
||||
const title = document.getElementById('dlgTitle')
|
||||
title.textContent = `${row.name} · ${row.protocolNo} · 第 ${periodIndex + 1} 期转账记录`
|
||||
const transfers = sch.transfers || []
|
||||
const body = document.getElementById('dlgBody')
|
||||
body.innerHTML = `
|
||||
<div class="summary-grid">
|
||||
<div class="summary-item"><label>应缴金额</label><strong>${money(sch.due)}</strong></div>
|
||||
<div class="summary-item"><label>实缴合计</label><strong style="color:var(--primary-dark)">${money(sch.paid)}</strong></div>
|
||||
<div class="summary-item"><label>欠缴</label><strong>${money(Math.max(0, Number(sch.due) - Number(sch.paid)))}</strong></div>
|
||||
<div class="summary-item"><label>缴费方式</label><strong>${row.payMode === 'dj' ? '趸交' : row.payMode === 'fgd' ? '分改趸' : '分期'}</strong></div>
|
||||
<div class="summary-item"><label>本期财务审核</label><strong>${auditTag(sch.audit)}</strong></div>
|
||||
<div class="summary-item"><label>转账笔数</label><strong>${transfers.length} 笔</strong></div>
|
||||
</div>
|
||||
<div class="section-title">转账明细(含财务审核)</div>
|
||||
${
|
||||
transfers.length
|
||||
? transfers
|
||||
.map(
|
||||
(tr, i) => `
|
||||
<div class="transfer-card">
|
||||
<div class="transfer-top">
|
||||
<strong>第 ${i + 1} 笔</strong>
|
||||
${auditTag(tr.status)}
|
||||
<span style="color:var(--primary-dark);font-weight:600">${money(tr.amount)}</span>
|
||||
</div>
|
||||
<div class="transfer-meta">
|
||||
<div><span>支付方式</span> · ${tr.type}</div>
|
||||
<div><span>转账时间</span> · ${tr.time}</div>
|
||||
</div>
|
||||
<div class="attach-row"><span class="attach-chip">${tr.voucher || '无凭证'}</span></div>
|
||||
<div class="audit-timeline">
|
||||
${(tr.auditLog || []).map((line) => `<div>• ${line}</div>`).join('') || '<div>• 暂无审核记录</div>'}
|
||||
</div>
|
||||
</div>`
|
||||
)
|
||||
.join('')
|
||||
: '<div class="muted">暂无转账笔。可在此发起「缴费确认」创建第一笔。</div>'
|
||||
}
|
||||
`
|
||||
const confirmBtn = document.getElementById('dlgConfirmBtn')
|
||||
confirmBtn.style.display =
|
||||
sch.audit === 'dfj' || (Number(sch.due) > Number(sch.paid) && sch.audit !== 'jfqrz')
|
||||
? 'inline-flex'
|
||||
: 'none'
|
||||
document.getElementById('backdrop').classList.add('is-open')
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
document.getElementById('backdrop').classList.remove('is-open')
|
||||
}
|
||||
|
||||
document.getElementById('tbody').addEventListener('click', (e) => {
|
||||
const a = e.target.closest('.amount-link')
|
||||
if (!a) return
|
||||
openDialog(a.dataset.row, Number(a.dataset.period))
|
||||
})
|
||||
document.getElementById('dlgClose').addEventListener('click', closeDialog)
|
||||
document.getElementById('dlgCloseBtn').addEventListener('click', closeDialog)
|
||||
document.getElementById('backdrop').addEventListener('click', (e) => {
|
||||
if (e.target.id === 'backdrop') closeDialog()
|
||||
})
|
||||
document.getElementById('dlgConfirmBtn').addEventListener('click', () => {
|
||||
alert('Demo:此处复用现有 PaymentReceiptConfirmDialog(上传凭证 / 仅保存 / 提交财务确认)')
|
||||
})
|
||||
|
||||
renderRows()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,514 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>采集协议单 · 胶囊 Tab 高度对比 Demo</title>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #10b981;
|
||||
--primary-dark: #059669;
|
||||
--primary-bg: #ecfdf5;
|
||||
--mint-text: #0f172a;
|
||||
--mint-text-muted: #64748b;
|
||||
--border: #e2e8f0;
|
||||
--surface: #fff;
|
||||
--page-bg: #f1f5f9;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family:
|
||||
Inter,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
'Segoe UI',
|
||||
sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--mint-text);
|
||||
background: var(--page-bg);
|
||||
}
|
||||
|
||||
.banner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 20px 28px;
|
||||
background: linear-gradient(180deg, #ecfdf5 0%, #fff 100%);
|
||||
border-bottom: 1px solid #d1fae5;
|
||||
}
|
||||
|
||||
.banner h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.banner p {
|
||||
margin: 0;
|
||||
color: var(--mint-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.banner code {
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: #f1f5f9;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.card__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.card__head h2 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--mint-text-muted);
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tag--current {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.tag--rec {
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.tag--alt {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.panel-top {
|
||||
padding: 16px 24px 0;
|
||||
}
|
||||
|
||||
.panel-top h3 {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
padding-left: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.panel-top h3::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 3px;
|
||||
bottom: 3px;
|
||||
width: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
/* —— 与 web-admin app.scss panel-pill-tabs 对齐 —— */
|
||||
.panel-pill-tabs {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 12px 24px 4px;
|
||||
}
|
||||
|
||||
.panel-pill-tabs__track {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
background: linear-gradient(180deg, var(--primary-bg) 0%, #fff 100%);
|
||||
border: 1px solid #d1fae5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.panel-pill-tabs__btn {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 108px;
|
||||
padding: 8px 20px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
color: var(--mint-text-muted);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.panel-pill-tabs__btn:hover:not(.is-active) {
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.panel-pill-tabs__btn.is-active {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
box-shadow: 0 1px 3px rgba(16, 185, 129, 0.28);
|
||||
}
|
||||
|
||||
/* A:现状 --lg */
|
||||
.panel-pill-tabs--lg {
|
||||
margin: 16px 24px 8px;
|
||||
}
|
||||
|
||||
.panel-pill-tabs--lg .panel-pill-tabs__track {
|
||||
gap: 6px;
|
||||
padding: 5px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.panel-pill-tabs--lg .panel-pill-tabs__btn {
|
||||
min-width: 140px;
|
||||
padding: 10px 28px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* C:中间档 --md(提案) */
|
||||
.panel-pill-tabs--md {
|
||||
margin: 12px 24px 6px;
|
||||
}
|
||||
|
||||
.panel-pill-tabs--md .panel-pill-tabs__track {
|
||||
gap: 5px;
|
||||
padding: 4px;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.panel-pill-tabs--md .panel-pill-tabs__btn {
|
||||
min-width: 124px;
|
||||
padding: 8px 24px;
|
||||
border-radius: 7px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.list-placeholder {
|
||||
margin: 12px 24px 20px;
|
||||
padding: 28px 16px;
|
||||
border: 1px dashed #cbd5e1;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
color: var(--mint-text-muted);
|
||||
font-size: 13px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.measure {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 0 24px 12px;
|
||||
}
|
||||
|
||||
.measure__bar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
background: #f1f5f9;
|
||||
color: var(--mint-text-muted);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.measure__bar strong {
|
||||
color: var(--mint-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card__note {
|
||||
padding: 12px 20px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
color: var(--mint-text-muted);
|
||||
}
|
||||
|
||||
.card__note strong {
|
||||
color: var(--mint-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stack-compare {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
padding: 16px 20px 20px;
|
||||
}
|
||||
|
||||
@media (min-width: 720px) {
|
||||
.stack-compare {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
align-items: end;
|
||||
}
|
||||
}
|
||||
|
||||
.mini {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.mini__label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--mint-text);
|
||||
}
|
||||
|
||||
.mini .panel-pill-tabs {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ruler {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: repeating-linear-gradient(
|
||||
90deg,
|
||||
#94a3b8 0,
|
||||
#94a3b8 1px,
|
||||
transparent 1px,
|
||||
transparent 8px
|
||||
);
|
||||
opacity: 0.45;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="banner">
|
||||
<div>
|
||||
<h1>采集协议单 · 胶囊 Tab 高度对比</h1>
|
||||
<p>
|
||||
对照线上 <code>panel-pill-tabs</code> / <code>--lg</code>,评估压缩高度后的观感。点击 Tab
|
||||
可切换选中态。
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="page">
|
||||
<!-- 并排尺子 -->
|
||||
<section class="card">
|
||||
<div class="card__head">
|
||||
<h2>一屏并排(便于比高度)</h2>
|
||||
<span class="meta">对齐底边,看垂直占用</span>
|
||||
</div>
|
||||
<div class="stack-compare" id="side-by-side">
|
||||
<div class="mini" data-variant="lg">
|
||||
<span class="mini__label">A · 现状 --lg</span>
|
||||
<div class="panel-pill-tabs panel-pill-tabs--lg">
|
||||
<div class="panel-pill-tabs__track" role="tablist">
|
||||
<button type="button" class="panel-pill-tabs__btn is-active">细胞存储</button>
|
||||
<button type="button" class="panel-pill-tabs__btn">科研捐赠</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ruler"></div>
|
||||
<span class="meta measure-h">—</span>
|
||||
</div>
|
||||
<div class="mini" data-variant="default">
|
||||
<span class="mini__label">B · 默认(推荐)</span>
|
||||
<div class="panel-pill-tabs">
|
||||
<div class="panel-pill-tabs__track" role="tablist">
|
||||
<button type="button" class="panel-pill-tabs__btn is-active">细胞存储</button>
|
||||
<button type="button" class="panel-pill-tabs__btn">科研捐赠</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ruler"></div>
|
||||
<span class="meta measure-h">—</span>
|
||||
</div>
|
||||
<div class="mini" data-variant="md">
|
||||
<span class="mini__label">C · 中间档 --md</span>
|
||||
<div class="panel-pill-tabs panel-pill-tabs--md">
|
||||
<div class="panel-pill-tabs__track" role="tablist">
|
||||
<button type="button" class="panel-pill-tabs__btn is-active">细胞存储</button>
|
||||
<button type="button" class="panel-pill-tabs__btn">科研捐赠</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ruler"></div>
|
||||
<span class="meta measure-h">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- A -->
|
||||
<section class="card" data-panel>
|
||||
<div class="card__head">
|
||||
<h2>A · 现状 panel-pill-tabs--lg</h2>
|
||||
<span class="tag tag--current">当前线上</span>
|
||||
</div>
|
||||
<div class="panel-top"><h3>采集协议单</h3></div>
|
||||
<div class="panel-pill-tabs panel-pill-tabs--lg" data-tabs>
|
||||
<div class="panel-pill-tabs__track" role="tablist" aria-label="协议来源">
|
||||
<button type="button" class="panel-pill-tabs__btn is-active">细胞存储</button>
|
||||
<button type="button" class="panel-pill-tabs__btn">科研捐赠</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="measure">
|
||||
<span class="measure__bar">Tab 区域高度(含上下 margin)≈ <strong data-h>—</strong></span>
|
||||
</div>
|
||||
<div class="list-placeholder">下方列表区占位(筛选 / 表格…)</div>
|
||||
<div class="card__note">
|
||||
<strong>尺寸:</strong>margin 16/8 · track padding 5 · btn padding 10×28 · 字号 14 ·
|
||||
min-width 140
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- B -->
|
||||
<section class="card" data-panel>
|
||||
<div class="card__head">
|
||||
<h2>B · 默认 panel-pill-tabs(去掉 --lg)</h2>
|
||||
<span class="tag tag--rec">方案 1 推荐</span>
|
||||
</div>
|
||||
<div class="panel-top"><h3>采集协议单</h3></div>
|
||||
<div class="panel-pill-tabs" data-tabs>
|
||||
<div class="panel-pill-tabs__track" role="tablist" aria-label="协议来源">
|
||||
<button type="button" class="panel-pill-tabs__btn is-active">细胞存储</button>
|
||||
<button type="button" class="panel-pill-tabs__btn">科研捐赠</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="measure">
|
||||
<span class="measure__bar">Tab 区域高度(含上下 margin)≈ <strong data-h>—</strong></span>
|
||||
</div>
|
||||
<div class="list-placeholder">下方列表区占位(筛选 / 表格…)</div>
|
||||
<div class="card__note">
|
||||
<strong>尺寸:</strong>margin 12/4 · track padding 4 · btn padding 8×20 · 字号 13 ·
|
||||
min-width 108 · 仅改本页 class,全站 --lg 不变
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- C -->
|
||||
<section class="card" data-panel>
|
||||
<div class="card__head">
|
||||
<h2>C · 中间档 panel-pill-tabs--md(提案)</h2>
|
||||
<span class="tag tag--alt">方案 3</span>
|
||||
</div>
|
||||
<div class="panel-top"><h3>采集协议单</h3></div>
|
||||
<div class="panel-pill-tabs panel-pill-tabs--md" data-tabs>
|
||||
<div class="panel-pill-tabs__track" role="tablist" aria-label="协议来源">
|
||||
<button type="button" class="panel-pill-tabs__btn is-active">细胞存储</button>
|
||||
<button type="button" class="panel-pill-tabs__btn">科研捐赠</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="measure">
|
||||
<span class="measure__bar">Tab 区域高度(含上下 margin)≈ <strong data-h>—</strong></span>
|
||||
</div>
|
||||
<div class="list-placeholder">下方列表区占位(筛选 / 表格…)</div>
|
||||
<div class="card__note">
|
||||
<strong>尺寸:</strong>margin 12/6 · track padding 4 · btn padding 8×24 · 字号 14 ·
|
||||
min-width 124 · 需新增全局修饰类
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function bindTabs(root) {
|
||||
const track = root.querySelector('.panel-pill-tabs__track')
|
||||
if (!track) return
|
||||
track.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.panel-pill-tabs__btn')
|
||||
if (!btn || !track.contains(btn)) return
|
||||
track.querySelectorAll('.panel-pill-tabs__btn').forEach((b) => b.classList.remove('is-active'))
|
||||
btn.classList.add('is-active')
|
||||
})
|
||||
}
|
||||
|
||||
function measureOuter(el) {
|
||||
const style = getComputedStyle(el)
|
||||
const mt = parseFloat(style.marginTop) || 0
|
||||
const mb = parseFloat(style.marginBottom) || 0
|
||||
return Math.round(el.getBoundingClientRect().height + mt + mb)
|
||||
}
|
||||
|
||||
function measureTrack(el) {
|
||||
const track = el.querySelector('.panel-pill-tabs__track')
|
||||
return track ? Math.round(track.getBoundingClientRect().height) : 0
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-tabs]').forEach(bindTabs)
|
||||
document.querySelectorAll('#side-by-side .mini .panel-pill-tabs').forEach(bindTabs)
|
||||
|
||||
function refreshMeasures() {
|
||||
document.querySelectorAll('[data-panel]').forEach((panel) => {
|
||||
const tabs = panel.querySelector('[data-tabs]')
|
||||
const label = panel.querySelector('[data-h]')
|
||||
if (!tabs || !label) return
|
||||
label.textContent = measureOuter(tabs) + 'px(轨道 ' + measureTrack(tabs) + 'px)'
|
||||
})
|
||||
document.querySelectorAll('#side-by-side .mini').forEach((mini) => {
|
||||
const tabs = mini.querySelector('.panel-pill-tabs')
|
||||
const label = mini.querySelector('.measure-h')
|
||||
if (!tabs || !label) return
|
||||
label.textContent =
|
||||
'外高 ' + measureOuter(tabs) + 'px · 轨道 ' + measureTrack(tabs) + 'px'
|
||||
})
|
||||
}
|
||||
|
||||
refreshMeasures()
|
||||
window.addEventListener('resize', refreshMeasures)
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,7 +3,8 @@
|
||||
-- 说明:
|
||||
-- 1) biz_collection_protocol 增量字段用 information_schema 判断后 ADD,可重复执行
|
||||
-- 2) payment_model_version 默认 2;首次升级将存量行置为 1(已有 version=1 则跳过 UPDATE)
|
||||
-- 3) 新表 CREATE TABLE IF NOT EXISTS
|
||||
-- 3) 新表 CREATE TABLE IF NOT EXISTS,校对规则与存量库一致:utf8mb4_unicode_ci
|
||||
-- 4) 第 9 节 CONVERT 用于已建表纠偏(可重复执行)
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 1) 采集协议单增量字段
|
||||
@@ -75,7 +76,7 @@ CREATE TABLE IF NOT EXISTS `biz_protocol_sample_payment` (
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_psp_tenant_protocol` (`tenant_no`,`collection_protocol_id`,`del_flag`),
|
||||
KEY `idx_psp_tenant_sample` (`tenant_no`,`sample_code`,`del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采集协议单-按样本缴费方案';
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采集协议单-按样本缴费方案';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 3) 应缴批次
|
||||
@@ -100,7 +101,7 @@ CREATE TABLE IF NOT EXISTS `biz_protocol_payment_schedule` (
|
||||
`del_flag` smallint(6) NOT NULL DEFAULT '0' COMMENT '删除标记',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_pps_tenant_protocol` (`tenant_no`,`collection_protocol_id`,`del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采集协议单-应缴批次';
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采集协议单-应缴批次';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 4) 转账笔(无退费字段)
|
||||
@@ -131,7 +132,7 @@ CREATE TABLE IF NOT EXISTS `biz_protocol_payment_transfer` (
|
||||
KEY `idx_ppt_tenant_protocol` (`tenant_no`,`collection_protocol_id`,`del_flag`),
|
||||
KEY `idx_ppt_tenant_sample` (`tenant_no`,`sample_code`,`del_flag`),
|
||||
KEY `idx_ppt_tenant_pay_status` (`tenant_no`,`pay_status`,`del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采集协议单-缴费转账笔';
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采集协议单-缴费转账笔';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 5) 协议退费登记
|
||||
@@ -155,7 +156,7 @@ CREATE TABLE IF NOT EXISTS `biz_protocol_refund_record` (
|
||||
`del_flag` smallint(6) NOT NULL DEFAULT '0' COMMENT '删除标记',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_prr_tenant_protocol` (`tenant_no`,`collection_protocol_id`,`del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采集协议单-退费登记';
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采集协议单-退费登记';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 6) 预签约订单
|
||||
@@ -171,8 +172,9 @@ CREATE TABLE IF NOT EXISTS `biz_presign_order` (
|
||||
`sample_collect_time` datetime DEFAULT NULL COMMENT '采集时间',
|
||||
`pay_mode` varchar(8) DEFAULT NULL COMMENT '整单缴费方式 dj/fq',
|
||||
`contract_amount` decimal(18,2) DEFAULT NULL COMMENT '整单合同金额',
|
||||
`collector_user_id` varchar(64) DEFAULT NULL COMMENT '采集专员用户ID',
|
||||
`sales_user_id` varchar(64) DEFAULT NULL COMMENT '市场专员用户ID',
|
||||
`region_manager_user_id` varchar(64) DEFAULT NULL COMMENT '地区经理用户ID',
|
||||
`region_manager_user_id` varchar(64) DEFAULT NULL COMMENT '采集经理/地区经理用户ID',
|
||||
`order_status` varchar(16) DEFAULT NULL COMMENT '订单状态 draft/active/converted/void',
|
||||
`converted_protocol_id` varchar(64) DEFAULT NULL COMMENT '转成的正式协议ID',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
@@ -186,7 +188,17 @@ CREATE TABLE IF NOT EXISTS `biz_presign_order` (
|
||||
`del_flag` smallint(6) NOT NULL DEFAULT '0' COMMENT '删除标记',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_po_tenant_protocol_no` (`tenant_no`,`protocol_no`,`del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预签约订单';
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预签约订单';
|
||||
|
||||
-- 已建表环境增量:采集专员
|
||||
SET @col_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'biz_presign_order' AND COLUMN_NAME = 'collector_user_id'
|
||||
);
|
||||
SET @sql := IF(@col_exists = 0,
|
||||
'ALTER TABLE `biz_presign_order` ADD COLUMN `collector_user_id` VARCHAR(64) DEFAULT NULL COMMENT ''采集专员用户ID'' AFTER `contract_amount`',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 7) 预签约样本类型
|
||||
@@ -206,7 +218,7 @@ CREATE TABLE IF NOT EXISTS `biz_presign_order_sample_type` (
|
||||
`del_flag` smallint(6) NOT NULL DEFAULT '0' COMMENT '删除标记',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_post_presign` (`tenant_no`,`presign_order_id`,`del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预签约订单-样本类型';
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预签约订单-样本类型';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 8) 预签约意向金转账笔
|
||||
@@ -233,4 +245,15 @@ CREATE TABLE IF NOT EXISTS `biz_presign_transfer` (
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_pt_tenant_pay_status` (`tenant_no`,`pay_status`,`del_flag`),
|
||||
KEY `idx_pt_presign` (`tenant_no`,`presign_order_id`,`del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预签约订单-意向金转账笔';
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预签约订单-意向金转账笔';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 9) 已建表校对规则对齐存量库 utf8mb4_unicode_ci(避免 JOIN 报 1267 mix of collations)
|
||||
-- ---------------------------------------------------------------------------
|
||||
ALTER TABLE `biz_protocol_sample_payment` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE `biz_protocol_payment_schedule` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE `biz_protocol_payment_transfer` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE `biz_protocol_refund_record` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE `biz_presign_order` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE `biz_presign_order_sample_type` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE `biz_presign_transfer` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
@@ -1,18 +0,0 @@
|
||||
-- 菜单更名:细胞采集基础信息维护 → 采集地区及医院
|
||||
-- 对应前端路由/静态菜单:sqxp.ts、backend/app/api/menu.py
|
||||
|
||||
UPDATE `sys_menu`
|
||||
SET
|
||||
`menu_name` = '采集地区及医院',
|
||||
`update_time` = NOW()
|
||||
WHERE `id` = '123'
|
||||
AND `del_flag` = 0;
|
||||
|
||||
-- 兼容按权限标识匹配(若环境环境 id 不同)
|
||||
UPDATE `sys_menu`
|
||||
SET
|
||||
`menu_name` = '采集地区及医院',
|
||||
`update_time` = NOW()
|
||||
WHERE `del_flag` = 0
|
||||
AND `perms` = 'biz:collectionRegion:list'
|
||||
AND `menu_name` <> '采集地区及医院';
|
||||
@@ -1,20 +0,0 @@
|
||||
-- OA 审批实例评论
|
||||
CREATE TABLE IF NOT EXISTS `oa_instance_comment` (
|
||||
`id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '主键ID',
|
||||
`instance_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '所属实例ID',
|
||||
`content` varchar(1024) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '评论正文',
|
||||
`attachment_refs_json` text COLLATE utf8mb4_unicode_ci COMMENT '引用表单附件快照 JSON',
|
||||
`status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'normal' COMMENT 'normal/revoked',
|
||||
`revoked_time` datetime DEFAULT NULL COMMENT '撤回时间',
|
||||
`tenant_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人id(评论作者)',
|
||||
`create_dept_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '更新人id',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`del_flag` smallint(6) NOT NULL DEFAULT '0' COMMENT '删除标记',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_oa_comment_tenant_inst_time` (`tenant_no`,`instance_id`,`create_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='OA审批实例评论';
|
||||
@@ -1,97 +0,0 @@
|
||||
-- 评论 @ 用户:查看授权(不写入抄送任务)
|
||||
ALTER TABLE `oa_instance_comment`
|
||||
ADD COLUMN `mention_user_ids_json` text COLLATE utf8mb4_unicode_ci NULL COMMENT '评论@用户ID列表 JSON' AFTER `revoked_time`;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `oa_instance_view_grant` (
|
||||
`id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '主键ID',
|
||||
`instance_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实例ID',
|
||||
`user_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '被授权查看人',
|
||||
`source` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'comment_mention' COMMENT 'comment_mention',
|
||||
`comment_id` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源评论ID,撤回评论不删除授权',
|
||||
`granted_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '授权人',
|
||||
`tenant_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人id',
|
||||
`create_dept_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '更新人id',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`del_flag` smallint(6) NOT NULL DEFAULT '0' COMMENT '删除标记',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_oa_view_grant_user` (`tenant_no`,`instance_id`,`user_id`),
|
||||
KEY `idx_oa_view_grant_user_time` (`tenant_no`,`user_id`,`create_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='OA审批实例查看授权';
|
||||
|
||||
-- 移动端「提到我的」:挂在与「抄送我的」相同的父菜单下,并复制其角色授权
|
||||
INSERT INTO `sys_menu` (
|
||||
`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`,
|
||||
`is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`,
|
||||
`create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
|
||||
)
|
||||
SELECT
|
||||
MD5(CONCAT('oa-mention-', s.`tenant_no`, '-', s.`id`)),
|
||||
'提到我的',
|
||||
s.`parent_id`,
|
||||
s.`menu_type`,
|
||||
'/pages/oa/mentions/index',
|
||||
s.`component`,
|
||||
'oa:mention',
|
||||
s.`is_frame`,
|
||||
s.`visible`,
|
||||
s.`status`,
|
||||
s.`tenant_no`,
|
||||
IFNULL(s.`order_num`, 0) + 1,
|
||||
'评论@后可查看的审批单',
|
||||
s.`create_by`,
|
||||
s.`create_dept_id`,
|
||||
NOW(),
|
||||
s.`update_by`,
|
||||
NOW(),
|
||||
0
|
||||
FROM `sys_menu` s
|
||||
WHERE s.`del_flag` = 0
|
||||
AND s.`menu_name` = '抄送我的'
|
||||
AND s.`path` LIKE '%oa/cc%'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM `sys_menu` x
|
||||
WHERE x.`del_flag` = 0
|
||||
AND x.`tenant_no` = s.`tenant_no`
|
||||
AND x.`path` LIKE '%oa/mentions%'
|
||||
);
|
||||
|
||||
INSERT INTO `sys_role_menu` (
|
||||
`id`, `role_id`, `menu_id`, `tenant_no`, `order_num`, `remark`,
|
||||
`create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
|
||||
)
|
||||
SELECT
|
||||
MD5(CONCAT('oa-mention-role-', rm.`role_id`, '-', m.`id`)),
|
||||
rm.`role_id`,
|
||||
m.`id`,
|
||||
m.`tenant_no`,
|
||||
NULL,
|
||||
NULL,
|
||||
rm.`create_by`,
|
||||
IFNULL(rm.`create_dept_id`, ''),
|
||||
NOW(),
|
||||
rm.`update_by`,
|
||||
NOW(),
|
||||
0
|
||||
FROM `sys_menu` m
|
||||
JOIN `sys_menu` cc
|
||||
ON cc.`del_flag` = 0
|
||||
AND cc.`tenant_no` = m.`tenant_no`
|
||||
AND cc.`parent_id` = m.`parent_id`
|
||||
AND cc.`menu_name` = '抄送我的'
|
||||
AND cc.`path` LIKE '%oa/cc%'
|
||||
JOIN `sys_role_menu` rm
|
||||
ON rm.`del_flag` = 0
|
||||
AND rm.`menu_id` = cc.`id`
|
||||
WHERE m.`del_flag` = 0
|
||||
AND m.`path` LIKE '%oa/mentions%'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM `sys_role_menu` x
|
||||
WHERE x.`del_flag` = 0
|
||||
AND x.`role_id` = rm.`role_id`
|
||||
AND x.`menu_id` = m.`id`
|
||||
);
|
||||
@@ -1,68 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- 清空 OA 审批「运行时实例」数据(上线前清理测试数据用)
|
||||
--
|
||||
-- 删除范围(物理删除):
|
||||
-- oa_instance_view_grant 评论@产生的查看授权
|
||||
-- oa_instance_comment 实例评论
|
||||
-- oa_relation 关联审批
|
||||
-- oa_instance_event 实例操作事件
|
||||
-- oa_task 待办/已办/抄送任务
|
||||
-- oa_instance 审批实例
|
||||
-- oa_instance_draft 发起审批草稿(非模板设计草稿)
|
||||
--
|
||||
-- 保留(不删除):
|
||||
-- oa_app_group / oa_app / oa_app_version 流程定义与已发布版本
|
||||
-- oa_receive_account 收款账户主数据
|
||||
--
|
||||
-- 使用前请先备份;默认清理全部租户。若只清某一租户,取消下方 @tenant_no 注释并赋值。
|
||||
-- =============================================================================
|
||||
|
||||
-- SET @tenant_no = 'zkxw';
|
||||
|
||||
START TRANSACTION;
|
||||
|
||||
-- 1) 评论查看授权
|
||||
DELETE FROM `oa_instance_view_grant`
|
||||
-- WHERE `tenant_no` = @tenant_no
|
||||
;
|
||||
|
||||
-- 2) 实例评论
|
||||
DELETE FROM `oa_instance_comment`
|
||||
-- WHERE `tenant_no` = @tenant_no
|
||||
;
|
||||
|
||||
-- 3) 关联审批
|
||||
DELETE FROM `oa_relation`
|
||||
-- WHERE `tenant_no` = @tenant_no
|
||||
;
|
||||
|
||||
-- 4) 实例事件
|
||||
DELETE FROM `oa_instance_event`
|
||||
-- WHERE `tenant_no` = @tenant_no
|
||||
;
|
||||
|
||||
-- 5) 审批任务
|
||||
DELETE FROM `oa_task`
|
||||
-- WHERE `tenant_no` = @tenant_no
|
||||
;
|
||||
|
||||
-- 6) 审批实例
|
||||
DELETE FROM `oa_instance`
|
||||
-- WHERE `tenant_no` = @tenant_no
|
||||
;
|
||||
|
||||
-- 7) 发起草稿
|
||||
DELETE FROM `oa_instance_draft`
|
||||
-- WHERE `tenant_no` = @tenant_no
|
||||
;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- 执行后核对(期望均为 0)
|
||||
SELECT 'oa_instance_view_grant' AS tbl, COUNT(*) AS cnt FROM `oa_instance_view_grant`
|
||||
UNION ALL SELECT 'oa_instance_comment', COUNT(*) FROM `oa_instance_comment`
|
||||
UNION ALL SELECT 'oa_relation', COUNT(*) FROM `oa_relation`
|
||||
UNION ALL SELECT 'oa_instance_event', COUNT(*) FROM `oa_instance_event`
|
||||
UNION ALL SELECT 'oa_task', COUNT(*) FROM `oa_task`
|
||||
UNION ALL SELECT 'oa_instance', COUNT(*) FROM `oa_instance`
|
||||
UNION ALL SELECT 'oa_instance_draft', COUNT(*) FROM `oa_instance_draft`;
|
||||
@@ -0,0 +1,73 @@
|
||||
-- =============================================================================
|
||||
-- 预签约订单菜单:插入 sys_menu,并授权给已有「采集协议单」的角色
|
||||
-- 测试库 zkxw-sc 已确认原先没有该菜单行。
|
||||
-- 菜单位置:采集协议单 之下、缴费信息/样本信息管理 之上
|
||||
-- id: 50000000000000000000000000000020
|
||||
-- perms: cell-storage:presign-order:list
|
||||
-- =============================================================================
|
||||
|
||||
INSERT INTO sys_menu (
|
||||
id, menu_name, parent_id, menu_type, path, component, perms,
|
||||
is_frame, visible, status, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT
|
||||
'50000000000000000000000000000020',
|
||||
'预签约订单',
|
||||
'CEL00000000000000000000000000001',
|
||||
'C',
|
||||
'presign-order',
|
||||
'/cell-storage/presign-order/index',
|
||||
'cell-storage:presign-order:list',
|
||||
1,
|
||||
'1',
|
||||
'1',
|
||||
'zkxw',
|
||||
3,
|
||||
'细胞存储:预签约订单',
|
||||
NULL,
|
||||
'',
|
||||
NOW(),
|
||||
NULL,
|
||||
NOW(),
|
||||
0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM sys_menu m
|
||||
WHERE m.del_flag = 0
|
||||
AND (
|
||||
m.id = '50000000000000000000000000000020'
|
||||
OR m.perms = 'cell-storage:presign-order:list'
|
||||
)
|
||||
);
|
||||
|
||||
-- 已有采集协议单权限的角色补授权(幂等)
|
||||
INSERT INTO sys_role_menu (
|
||||
id, role_id, menu_id, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT
|
||||
MD5(CONCAT(r.id, '|50000000000000000000000000000020')),
|
||||
r.id,
|
||||
'50000000000000000000000000000020',
|
||||
IFNULL(NULLIF(r.tenant_no, ''), 'zkxw'),
|
||||
0,
|
||||
'grant presign-order to protocol-audit roles',
|
||||
NULL,
|
||||
'',
|
||||
NOW(),
|
||||
NULL,
|
||||
NOW(),
|
||||
0
|
||||
FROM sys_role r
|
||||
INNER JOIN sys_role_menu rm
|
||||
ON rm.role_id = r.id
|
||||
AND rm.menu_id = '50000000000000000000000000000003'
|
||||
AND rm.del_flag = 0
|
||||
WHERE r.del_flag = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sys_role_menu x
|
||||
WHERE x.role_id = r.id
|
||||
AND x.menu_id = '50000000000000000000000000000020'
|
||||
AND x.del_flag = 0
|
||||
);
|
||||
@@ -87,11 +87,33 @@ WHERE NOT EXISTS (
|
||||
)
|
||||
);
|
||||
|
||||
-- 3) 可选:为已有角色补授权(将 super_admin 角色 id 替换为实际值后取消注释)
|
||||
-- INSERT INTO sys_role_menu (id, role_id, menu_id, tenant_no, create_time, update_time, del_flag)
|
||||
-- SELECT UUID(), '<role_id>', '904b5173519411f1944300163e3bdd96', 'zkxw', NOW(), NOW(), 0
|
||||
-- FROM DUAL
|
||||
-- WHERE NOT EXISTS (
|
||||
-- SELECT 1 FROM sys_role_menu rm
|
||||
-- WHERE rm.menu_id = '904b5173519411f1944300163e3bdd96' AND rm.role_id = '<role_id>' AND rm.del_flag = 0
|
||||
-- );
|
||||
-- 3) 已有「采集协议单」权限的角色补授权(幂等)
|
||||
INSERT INTO sys_role_menu (
|
||||
id, role_id, menu_id, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT
|
||||
MD5(CONCAT(r.id, '|904b5173519411f1944300163e3bdd96')),
|
||||
r.id,
|
||||
'904b5173519411f1944300163e3bdd96',
|
||||
IFNULL(NULLIF(r.tenant_no, ''), 'zkxw'),
|
||||
0,
|
||||
'grant payment-info to protocol-audit roles',
|
||||
NULL,
|
||||
'',
|
||||
NOW(),
|
||||
NULL,
|
||||
NOW(),
|
||||
0
|
||||
FROM sys_role r
|
||||
INNER JOIN sys_role_menu rm
|
||||
ON rm.role_id = r.id
|
||||
AND rm.menu_id = '50000000000000000000000000000003'
|
||||
AND rm.del_flag = 0
|
||||
WHERE r.del_flag = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sys_role_menu x
|
||||
WHERE x.role_id = r.id
|
||||
AND x.menu_id = '904b5173519411f1944300163e3bdd96'
|
||||
AND x.del_flag = 0
|
||||
);
|
||||
@@ -0,0 +1,29 @@
|
||||
-- =============================================================================
|
||||
-- 下线「科研捐赠 → 样本信息管理」侧栏菜单(2026-10-06)
|
||||
-- =============================================================================
|
||||
--
|
||||
-- 【主路径 — 前端固定菜单】
|
||||
-- 当前 Web 管理端使用 VITE_ACCESS_MODE=frontend,菜单树已从以下两处移除该子项:
|
||||
-- - web-admin/src/router/modules/sqxp.ts
|
||||
-- - backend/app/api/menu.py
|
||||
-- 样本信息仍从「细胞存储 → 样本信息管理」进入(统一页含科研捐赠 Tab)。
|
||||
-- 前端模式环境 **无需执行** 本脚本;保留供 DB 菜单模式同步。
|
||||
--
|
||||
-- 科研捐赠目录 parent_id: RSC00000000000000000000000000001
|
||||
-- 样本信息 id: 60000000000000000000000000000004
|
||||
-- perms: research-donation:sample-info:list
|
||||
-- visible: '0'=隐藏(权限码保留,统一页 Tab / 接口授权仍可用)
|
||||
-- =============================================================================
|
||||
|
||||
UPDATE sys_menu
|
||||
SET visible = '0',
|
||||
update_time = NOW()
|
||||
WHERE del_flag = 0
|
||||
AND (
|
||||
id = '60000000000000000000000000000004'
|
||||
OR perms = 'research-donation:sample-info:list'
|
||||
OR (
|
||||
path = 'sample-info'
|
||||
AND parent_id = 'RSC00000000000000000000000000001'
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
-- 客户主档:通用个人档案字段
|
||||
-- Spec: docs/superpowers/specs/2026-10-08-customer-profile-expand-sync-design.md
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
ALTER TABLE `biz_customer`
|
||||
ADD COLUMN `birth_ym` char(6) DEFAULT NULL COMMENT '出生年月YYYYMM' AFTER `age`,
|
||||
ADD COLUMN `ethnic` varchar(32) DEFAULT NULL COMMENT '民族' AFTER `birth_ym`,
|
||||
ADD COLUMN `email` varchar(128) DEFAULT NULL COMMENT '邮箱' AFTER `ethnic`,
|
||||
ADD COLUMN `address` varchar(512) DEFAULT NULL COMMENT '家庭住址' AFTER `email`;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- 采集协议 / 科研捐赠:地区经理用户ID
|
||||
-- Spec: docs/superpowers/specs/2026-10-08-protocol-market-specialist-region-manager-design.md
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
ALTER TABLE `biz_protocol_collection_info`
|
||||
ADD COLUMN `region_manager_user_id` varchar(64) DEFAULT NULL COMMENT '地区经理用户ID' AFTER `sales_user_id`;
|
||||
|
||||
ALTER TABLE `biz_research_donation_collection`
|
||||
ADD COLUMN `region_manager_user_id` varchar(64) DEFAULT NULL COMMENT '地区经理用户ID' AFTER `sales_user_id`;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- =============================================================================
|
||||
-- 侧栏二级菜单「细胞存储」更名为「存储服务」
|
||||
-- id: CEL00000000000000000000000000001
|
||||
-- 原因:该目录下统一承载细胞存储与科研捐赠业务入口
|
||||
--
|
||||
-- 【前端固定菜单】同步修改:
|
||||
-- - web-admin/src/router/modules/sqxp.ts
|
||||
-- - backend/app/api/menu.py
|
||||
-- =============================================================================
|
||||
|
||||
UPDATE sys_menu
|
||||
SET menu_name = '存储服务',
|
||||
remark = CASE
|
||||
WHEN remark IS NULL OR TRIM(remark) = '' THEN '存储服务:含细胞存储与科研捐赠统一业务入口'
|
||||
WHEN remark LIKE '%细胞存储%' THEN REPLACE(remark, '细胞存储', '存储服务')
|
||||
ELSE remark
|
||||
END,
|
||||
update_time = NOW()
|
||||
WHERE del_flag = 0
|
||||
AND id = 'CEL00000000000000000000000000001'
|
||||
AND menu_name = '细胞存储';
|
||||
@@ -0,0 +1,30 @@
|
||||
-- 若已按旧规则 YF+YYYY-MM-DD+序号 回填,纠正为 YF+YYYYMMDD+001~999
|
||||
|
||||
SET @grp := '';
|
||||
SET @seq := 0;
|
||||
|
||||
UPDATE biz_sample_freight f
|
||||
INNER JOIN (
|
||||
SELECT
|
||||
id,
|
||||
CONCAT(
|
||||
'YF',
|
||||
DATE_FORMAT(create_time, '%Y%m%d'),
|
||||
LPAD(
|
||||
@seq := IF(
|
||||
@grp = CONCAT(tenant_no, '|', DATE_FORMAT(create_time, '%Y%m%d')),
|
||||
@seq + 1,
|
||||
1
|
||||
),
|
||||
3,
|
||||
'0'
|
||||
)
|
||||
) AS new_no,
|
||||
@grp := CONCAT(tenant_no, '|', DATE_FORMAT(create_time, '%Y%m%d')) AS _grp
|
||||
FROM biz_sample_freight
|
||||
ORDER BY tenant_no ASC, create_time ASC, id ASC
|
||||
) t ON f.id = t.id
|
||||
SET f.freight_no = t.new_no;
|
||||
|
||||
ALTER TABLE biz_sample_freight
|
||||
MODIFY COLUMN freight_no VARCHAR(32) NOT NULL DEFAULT '' COMMENT '运费批次号 YF+YYYYMMDD+序号';
|
||||
@@ -0,0 +1,35 @@
|
||||
-- 运费批次号:YF + YYYYMMDD + 001~999(按租户、按日递增)
|
||||
|
||||
ALTER TABLE biz_sample_freight
|
||||
ADD COLUMN freight_no VARCHAR(32) NOT NULL DEFAULT '' COMMENT '运费批次号 YF+YYYYMMDD+序号' AFTER id;
|
||||
|
||||
-- 按租户 + 创建日回填历史数据
|
||||
SET @grp := '';
|
||||
SET @seq := 0;
|
||||
|
||||
UPDATE biz_sample_freight f
|
||||
INNER JOIN (
|
||||
SELECT
|
||||
id,
|
||||
CONCAT(
|
||||
'YF',
|
||||
DATE_FORMAT(create_time, '%Y%m%d'),
|
||||
LPAD(
|
||||
@seq := IF(
|
||||
@grp = CONCAT(tenant_no, '|', DATE_FORMAT(create_time, '%Y%m%d')),
|
||||
@seq + 1,
|
||||
1
|
||||
),
|
||||
3,
|
||||
'0'
|
||||
)
|
||||
) AS new_no,
|
||||
@grp := CONCAT(tenant_no, '|', DATE_FORMAT(create_time, '%Y%m%d')) AS _grp
|
||||
FROM biz_sample_freight
|
||||
ORDER BY tenant_no ASC, create_time ASC, id ASC
|
||||
) t ON f.id = t.id
|
||||
SET f.freight_no = t.new_no
|
||||
WHERE f.freight_no = '' OR f.freight_no IS NULL;
|
||||
|
||||
ALTER TABLE biz_sample_freight
|
||||
ADD UNIQUE KEY uk_sample_freight_tenant_no (tenant_no, freight_no);
|
||||
@@ -0,0 +1,74 @@
|
||||
-- =============================================================================
|
||||
-- 催缴信息菜单:插在缴费信息之下、样本信息管理之上
|
||||
-- id: 50000000000000000000000000000022
|
||||
-- perms / menuCode: cellStorage:paymentChase:list
|
||||
-- =============================================================================
|
||||
|
||||
INSERT INTO sys_menu (
|
||||
id, menu_name, parent_id, menu_type, path, component, perms,
|
||||
is_frame, visible, status, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT
|
||||
'50000000000000000000000000000022',
|
||||
'催缴信息',
|
||||
'CEL00000000000000000000000000001',
|
||||
'C',
|
||||
'payment-chase',
|
||||
'/cell-storage/payment-chase/index',
|
||||
'cellStorage:paymentChase:list',
|
||||
1,
|
||||
'1',
|
||||
'1',
|
||||
'zkxw',
|
||||
5,
|
||||
'细胞存储:催缴信息(逾期∪近30天)',
|
||||
NULL,
|
||||
'',
|
||||
NOW(),
|
||||
NULL,
|
||||
NOW(),
|
||||
0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM sys_menu m
|
||||
WHERE m.del_flag = 0
|
||||
AND (
|
||||
m.id = '50000000000000000000000000000022'
|
||||
OR m.perms = 'cellStorage:paymentChase:list'
|
||||
)
|
||||
);
|
||||
|
||||
-- 已有缴费信息权限的角色补授权(幂等)
|
||||
INSERT INTO sys_role_menu (
|
||||
id, role_id, menu_id, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT
|
||||
MD5(CONCAT(r.id, '|50000000000000000000000000000022')),
|
||||
r.id,
|
||||
'50000000000000000000000000000022',
|
||||
IFNULL(NULLIF(r.tenant_no, ''), 'zkxw'),
|
||||
0,
|
||||
'grant payment-chase to payment-info roles',
|
||||
NULL,
|
||||
'',
|
||||
NOW(),
|
||||
NULL,
|
||||
NOW(),
|
||||
0
|
||||
FROM sys_role r
|
||||
INNER JOIN sys_role_menu rm
|
||||
ON rm.role_id = r.id
|
||||
AND rm.del_flag = 0
|
||||
INNER JOIN sys_menu m
|
||||
ON m.id = rm.menu_id
|
||||
AND m.del_flag = 0
|
||||
AND m.perms = 'cellStorage:paymentInfo:list'
|
||||
WHERE r.del_flag = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sys_role_menu x
|
||||
WHERE x.del_flag = 0
|
||||
AND x.role_id = r.id
|
||||
AND x.menu_id = '50000000000000000000000000000022'
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
-- 意向金不落协议侧快照表:正式协议通过 biz_collection_protocol.source_presign_order_id
|
||||
-- 关联预签订单,读写同一份 biz_presign_transfer,保证财务审批/调整两边一致。
|
||||
--
|
||||
-- 若此前已执行过「创建 biz_protocol_deposit_transfer」的旧脚本,执行下方 DROP 清理。
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
DROP TABLE IF EXISTS `biz_protocol_deposit_transfer`;
|
||||
@@ -0,0 +1,736 @@
|
||||
# 统一样本信息管理 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 细胞存储与科研捐赠共用一个「样本信息管理」页(两个来源 Tab、一条样本一行),列表只读展示采集/接收/质检/入库标识,细胞存储另展示证/保险已办理,详情里轻量办理证与保险;采集协议单去掉储存证、满月待办 Tab。
|
||||
|
||||
**Architecture:** 进度标识由现有接收单、入库质检工单、入库放行审核按 `tenant_no + sample_code` 派生,不建总表。证/保险仍写 `biz_protocol_storage_cert` / `biz_protocol_fullmoon_todo`(协议级),补 `handle_time`、`handler_user_id`。列表扩展现有两个 sample-collections 接口;办理走独立 PUT。前端两路由共用同一列表页。
|
||||
|
||||
**Tech Stack:** MySQL 5.7 / FastAPI + SQLAlchemy / pytest unittest / Vue 3 + Element Plus / pnpm
|
||||
|
||||
**Design Doc:** `docs/superpowers/specs/2026-10-05-unified-sample-info-design.md`
|
||||
|
||||
**Reuse:** `backend/app/services/protocol_sample_list_summary.py`(接收/质检摘要)、`qc_sms_notification._qc_status_label` 语义、`storage_entry_sms_notification._storage_status_label`、协议详情里已有的接收/质检/入库只读组件。
|
||||
|
||||
---
|
||||
|
||||
## File Structure Map
|
||||
|
||||
### New files
|
||||
|
||||
| File | Responsibility |
|
||||
|------|----------------|
|
||||
| `docs/sql/migrations/2026-10-05-sample-info-handle-fields.sql` | 两表 ALTER 增加办理时间、办理人 |
|
||||
| `backend/app/services/sample_info_progress.py` | 采集/接收/质检/入库展示码、证/保险已办理判定、同步 status 日期 |
|
||||
| `backend/tests/test_sample_info_progress.py` | 上述纯函数单测 |
|
||||
| `backend/tests/test_sample_info_handle.py` | 办理 upsert 同步字段单测(mock db 或纯函数 + crud 辅助) |
|
||||
| `web-admin/src/views/cell-storage/sample-info/UnifiedSampleInfoPage.vue` | 两来源 Tab + 筛选 + 列表 + 打开详情 |
|
||||
| `web-admin/src/views/cell-storage/sample-info/SampleProgressTags.ts` | 列表 Tag 的 type/label 映射(可与 registry 合并,见 Task 7) |
|
||||
| `web-admin/src/components/business/protocol-handle-panel/index.vue` | 证/保险办理表单(时间、人员、附件、备注、保存) |
|
||||
|
||||
### Modified files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `backend/app/models/models.py` | `BizProtocolStorageCert`、`BizProtocolFullmoonTodo` 加列 |
|
||||
| `backend/app/crud/collection_protocol.py` | dict/upsert 支持新字段;`PUT` 办理辅助 |
|
||||
| `backend/app/crud/protocol_sample_collection.py` | 列表富化 + 进度筛选 |
|
||||
| `backend/app/crud/research_donation_sample_collection.py` | 同上(无证/保险) |
|
||||
| `backend/app/api/protocol_sample_collection.py` | 新 query 参数 |
|
||||
| `backend/app/api/research_donation_sample_collection.py` | 新 query 参数 |
|
||||
| `backend/app/api/collection_protocol.py` | `PUT /storage-certs/{id}`、`PUT /fullmoon-todos/{id}/handle`;bundle 返回办理字段 |
|
||||
| `web-admin/src/api/protocol-sample-collection.ts` | 筛选参数 |
|
||||
| `web-admin/src/api/research-donation-sample-collection.ts` | 筛选参数 |
|
||||
| `web-admin/src/api/admin.ts` | 办理 PUT(或新 `sample-info-handle.ts`) |
|
||||
| `web-admin/src/views/cell-storage/sample-info/index.vue` | 改为挂载统一页,默认细胞存储 |
|
||||
| `web-admin/src/views/research-donation/sample-info/index.vue` | 挂载同一页,默认科研捐赠 |
|
||||
| `web-admin/src/views/cell-storage/protocol-audit/index.vue` | 只留协议单 Tab |
|
||||
| `web-admin/src/views/cell-storage/storage-cert/index.vue` | 跳转样本信息 |
|
||||
| `web-admin/src/views/cell-storage/fullmoon-todo/index.vue` | 跳转样本信息 |
|
||||
| `web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue` | 高亮样本;证/保险改为办理面板 |
|
||||
| `web-admin/src/views/research-donation/list/DonationViewDialog.vue` | 高亮样本(进度组件已有则只加 highlight) |
|
||||
| `web-admin/src/utils/status-tag-registry.ts` | 进度/办理 Tag |
|
||||
|
||||
### Stop using as workbenches (delete after统一页可用,或先停引用)
|
||||
|
||||
- `protocol-audit/StorageCertTab.vue`、`FullmoonTodoTab.vue`
|
||||
- `SampleCollectionInfoTab.vue`、`SampleReceptionInfoTab.vue`、`SampleQcInfoTab.vue`、`StorageEntryInfoTab.vue`
|
||||
- `research-donation/sample-info/DonationSampleCollectionInfoTab.vue`
|
||||
|
||||
短信 API **不要删**。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: SQL 与 Model 列
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/sql/migrations/2026-10-05-sample-info-handle-fields.sql`
|
||||
- Modify: `backend/app/models/models.py`(`BizProtocolStorageCert` 约 L1597、`BizProtocolFullmoonTodo` 约 L3613)
|
||||
|
||||
- [ ] **Step 1: 写迁移 SQL**
|
||||
|
||||
MySQL 5.7,列加在业务字段末、公共字段前。`handle_date` 已存在于满月表,新列名为 `handle_time`(DATETIME)。
|
||||
|
||||
```sql
|
||||
-- 储存证 / 满月保险轻量办理字段(2026-10-05)
|
||||
ALTER TABLE `biz_protocol_storage_cert`
|
||||
ADD COLUMN `handle_time` datetime DEFAULT NULL COMMENT '办理时间' AFTER `make_date`,
|
||||
ADD COLUMN `handler_user_id` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '办理人用户ID' AFTER `handle_time`;
|
||||
|
||||
ALTER TABLE `biz_protocol_fullmoon_todo`
|
||||
ADD COLUMN `handle_time` datetime DEFAULT NULL COMMENT '办理时间' AFTER `handle_date`,
|
||||
ADD COLUMN `handler_user_id` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '办理人用户ID' AFTER `handle_time`;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Model 增加同名 Column**
|
||||
|
||||
在 `make_date` 后、`tenant_no` 前:
|
||||
|
||||
```python
|
||||
handle_time = Column(DateTime, nullable=True, comment="办理时间")
|
||||
handler_user_id = Column(String(64), nullable=True, comment="办理人用户ID")
|
||||
```
|
||||
|
||||
满月表:`handle_date` 列之后同样两列。不要删除保险公司旧列。
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/sql/migrations/2026-10-05-sample-info-handle-fields.sql backend/app/models/models.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(db): add handle_time and handler_user_id for storage cert and fullmoon todo.
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 进度与已办理纯函数(TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/services/sample_info_progress.py`
|
||||
- Test: `backend/tests/test_sample_info_progress.py`
|
||||
|
||||
在仓库根目录 `backend/` 下跑 pytest(已有 `tests/test_protocol_list_sample_reception_qc.py` 风格:`unittest` + 直接 import 纯函数)。
|
||||
|
||||
- [ ] **Step 1: 写失败测试**
|
||||
|
||||
```python
|
||||
"""样本信息管理:进度展示码与证/保险已办理判定。"""
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.services.sample_info_progress import (
|
||||
collection_progress,
|
||||
insurance_handled,
|
||||
qc_progress,
|
||||
reception_progress,
|
||||
storage_cert_handled,
|
||||
storage_progress,
|
||||
sync_handle_status_fields,
|
||||
)
|
||||
|
||||
|
||||
class TestCollectionProgress(unittest.TestCase):
|
||||
def test_empty(self):
|
||||
self.assertEqual(collection_progress(""), ("uncollected", "未采集"))
|
||||
|
||||
def test_collected_includes_mid_states(self):
|
||||
for st in ("ycj", "jcz", "yrk"):
|
||||
self.assertEqual(collection_progress(st)[0], "collected")
|
||||
self.assertEqual(collection_progress("ycj")[1], "已采集")
|
||||
|
||||
def test_void(self):
|
||||
self.assertEqual(collection_progress("yzf"), ("voided", "已作废"))
|
||||
|
||||
|
||||
class TestReceptionProgress(unittest.TestCase):
|
||||
def test_missing_or_pending(self):
|
||||
self.assertEqual(reception_progress(None)[0], "none")
|
||||
self.assertEqual(reception_progress({"acceptConclusion": "dqr"})[0], "none")
|
||||
self.assertEqual(reception_progress({"acceptConclusion": ""})[0], "none")
|
||||
|
||||
def test_accept_reject(self):
|
||||
self.assertEqual(reception_progress({"acceptConclusion": "js"}), ("js", "已接收"))
|
||||
self.assertEqual(reception_progress({"acceptConclusion": "bj"}), ("bj", "拒收"))
|
||||
|
||||
|
||||
class TestQcProgress(unittest.TestCase):
|
||||
def test_none(self):
|
||||
self.assertEqual(qc_progress(None)[0], "incomplete")
|
||||
|
||||
def test_complete_pass_or_fail(self):
|
||||
self.assertEqual(qc_progress({"qcConclusion": "tg", "qcWorkOrderStatus": "ywc"})[0], "complete")
|
||||
self.assertEqual(qc_progress({"qcConclusion": "btg", "qcWorkOrderStatus": "ywc"})[0], "complete")
|
||||
|
||||
def test_void(self):
|
||||
self.assertEqual(qc_progress({"qcWorkOrderStatus": "yzf"})[0], "voided")
|
||||
|
||||
def test_in_progress(self):
|
||||
self.assertEqual(qc_progress({"qcWorkOrderStatus": "ypz", "qcConclusion": ""})[0], "incomplete")
|
||||
|
||||
|
||||
class TestStorageProgress(unittest.TestCase):
|
||||
def test_none_or_pending(self):
|
||||
self.assertEqual(storage_progress(None)[0], "none")
|
||||
self.assertEqual(storage_progress({"storageStatus": "drk"})[0], "none")
|
||||
|
||||
def test_in_and_reject(self):
|
||||
self.assertEqual(storage_progress({"storageStatus": "yrk"})[0], "yrk")
|
||||
self.assertEqual(
|
||||
storage_progress({"storageStatus": "drk", "reviewConclusion": "btg"})[0],
|
||||
"rejected",
|
||||
)
|
||||
|
||||
|
||||
class TestHandleFlags(unittest.TestCase):
|
||||
def test_cert_handle_time_wins(self):
|
||||
row = SimpleNamespace(handle_time=datetime(2026, 10, 1, 8, 0), make_status="0", make_date="")
|
||||
self.assertTrue(storage_cert_handled(row))
|
||||
|
||||
def test_cert_legacy_make_status(self):
|
||||
row = SimpleNamespace(handle_time=None, make_status="1", make_date="")
|
||||
self.assertTrue(storage_cert_handled(row))
|
||||
|
||||
def test_cert_legacy_make_date(self):
|
||||
row = SimpleNamespace(handle_time=None, make_status="0", make_date="20260101")
|
||||
self.assertTrue(storage_cert_handled(row))
|
||||
|
||||
def test_cert_empty(self):
|
||||
self.assertFalse(storage_cert_handled(None))
|
||||
row = SimpleNamespace(handle_time=None, make_status="0", make_date="")
|
||||
self.assertFalse(storage_cert_handled(row))
|
||||
|
||||
def test_insurance_legacy_todo_or_date(self):
|
||||
self.assertTrue(
|
||||
insurance_handled(SimpleNamespace(handle_time=None, todo_status="1", handle_date=""))
|
||||
)
|
||||
self.assertTrue(
|
||||
insurance_handled(SimpleNamespace(handle_time=None, todo_status="0", handle_date="20260131"))
|
||||
)
|
||||
self.assertFalse(
|
||||
insurance_handled(SimpleNamespace(handle_time=None, todo_status="0", handle_date=""))
|
||||
)
|
||||
|
||||
|
||||
class TestSyncHandleStatus(unittest.TestCase):
|
||||
def test_set_and_clear(self):
|
||||
ht = datetime(2026, 10, 5, 15, 0, 0)
|
||||
st, ymd = sync_handle_status_fields(ht)
|
||||
self.assertEqual(st, "1")
|
||||
self.assertEqual(ymd, "20261005")
|
||||
st2, ymd2 = sync_handle_status_fields(None)
|
||||
self.assertEqual(st2, "0")
|
||||
self.assertEqual(ymd2, "")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测确认失败**
|
||||
|
||||
Run: `cd backend && python -m pytest tests/test_sample_info_progress.py -v`
|
||||
|
||||
Expected: FAIL(`sample_info_progress` 未定义)
|
||||
|
||||
- [ ] **Step 3: 实现 `sample_info_progress.py`**
|
||||
|
||||
约定展示码(列表 `xxxFlag` / `xxxFlagLabel`):
|
||||
|
||||
| 领域 | code | 文案 |
|
||||
|------|------|------|
|
||||
| 采集 | `uncollected` / `collected` / `voided` | 未采集 / 已采集 / 已作废 |
|
||||
| 接收 | `none` / `js` / `bj` | 未接收 / 已接收 / 拒收 |
|
||||
| 质检 | `incomplete` / `complete` / `voided` | 未完成 / 已完成 / 已作废 |
|
||||
| 入库 | `none` / `yrk` / `rejected` | 未入库 / 已入库 / 拒绝入库 |
|
||||
|
||||
入库拒绝:与短信列表一致——`storage_status != yrk` 且 `review_conclusion == btg`。
|
||||
|
||||
```python
|
||||
def collection_progress(sample_status: str) -> tuple:
|
||||
st = (sample_status or "").strip()
|
||||
if not st:
|
||||
return "uncollected", "未采集"
|
||||
if st == "yzf":
|
||||
return "voided", "已作废"
|
||||
if st in ("ycj", "jcz", "yrk"):
|
||||
return "collected", "已采集"
|
||||
return "uncollected", "未采集"
|
||||
|
||||
|
||||
def reception_progress(reception: dict | None) -> tuple:
|
||||
if not reception:
|
||||
return "none", "未接收"
|
||||
c = (reception.get("acceptConclusion") or "").strip()
|
||||
if c == "js":
|
||||
return "js", "已接收"
|
||||
if c == "bj":
|
||||
return "bj", "拒收"
|
||||
return "none", "未接收"
|
||||
|
||||
|
||||
def qc_progress(qc: dict | None) -> tuple:
|
||||
if not qc:
|
||||
return "incomplete", "未完成"
|
||||
if (qc.get("qcWorkOrderStatus") or "").strip() == "yzf":
|
||||
return "voided", "已作废"
|
||||
conc = (qc.get("qcConclusion") or "").strip()
|
||||
if conc in ("tg", "btg"):
|
||||
return "complete", "已完成"
|
||||
return "incomplete", "未完成"
|
||||
|
||||
|
||||
def storage_progress(review: dict | None) -> tuple:
|
||||
if not review:
|
||||
return "none", "未入库"
|
||||
if (review.get("storageStatus") or "").strip() == "yrk":
|
||||
return "yrk", "已入库"
|
||||
if (review.get("reviewConclusion") or "").strip() == "btg":
|
||||
return "rejected", "拒绝入库"
|
||||
return "none", "未入库"
|
||||
|
||||
|
||||
def storage_cert_handled(row) -> bool:
|
||||
if row is None:
|
||||
return False
|
||||
if getattr(row, "handle_time", None):
|
||||
return True
|
||||
if (getattr(row, "make_status", None) or "").strip() == "1":
|
||||
return True
|
||||
return bool((getattr(row, "make_date", None) or "").strip())
|
||||
|
||||
|
||||
def insurance_handled(row) -> bool:
|
||||
if row is None:
|
||||
return False
|
||||
if getattr(row, "handle_time", None):
|
||||
return True
|
||||
if (getattr(row, "todo_status", None) or "").strip() == "1":
|
||||
return True
|
||||
return bool((getattr(row, "handle_date", None) or "").strip())
|
||||
|
||||
|
||||
def sync_handle_status_fields(handle_time):
|
||||
if handle_time is None:
|
||||
return "0", ""
|
||||
return "1", handle_time.strftime("%Y%m%d")
|
||||
```
|
||||
|
||||
把 `sync_handle_status_fields` 写清楚:`handle_time is None` → `("0", "")`,否则 `("1", YYYYMMDD)`。
|
||||
|
||||
- [ ] **Step 4: pytest 通过**
|
||||
|
||||
Run: `cd backend && python -m pytest tests/test_sample_info_progress.py -v`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/services/sample_info_progress.py backend/tests/test_sample_info_progress.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(backend): add sample info progress flag helpers.
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 办理 upsert 同步字段(TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/crud/collection_protocol.py`(`_storage_cert_to_dict` L607、`upsert_storage_cert` L3348、`_fullmoon_todo_to_dict` L3434、`upsert_fullmoon_todo` L3476)
|
||||
- Test: `backend/tests/test_sample_info_handle.py`
|
||||
|
||||
- [ ] **Step 1: 测试 `apply_handle_payload` 纯逻辑**(可放在 `sample_info_progress.py`)
|
||||
|
||||
```python
|
||||
from datetime import datetime
|
||||
from app.services.sample_info_progress import apply_handle_payload
|
||||
|
||||
def test_apply_sets_status():
|
||||
out = apply_handle_payload(
|
||||
handle_time=datetime(2026, 10, 5, 9, 0, 0),
|
||||
handler_user_id="u1",
|
||||
attachments="http://a.png",
|
||||
remark="ok",
|
||||
)
|
||||
assert out["handle_time"].year == 2026
|
||||
assert out["handler_user_id"] == "u1"
|
||||
assert out["make_or_todo_status"] == "1"
|
||||
assert out["legacy_date"] == "20261005"
|
||||
|
||||
def test_apply_clear():
|
||||
out = apply_handle_payload(handle_time=None, handler_user_id="u1", attachments="", remark="")
|
||||
assert out["handle_time"] is None
|
||||
assert out["handler_user_id"] is None
|
||||
assert out["make_or_todo_status"] == "0"
|
||||
assert out["legacy_date"] == ""
|
||||
```
|
||||
|
||||
规则:清空办理时间时 **同时清空** `handler_user_id`(与 spec 一致)。有办理时间时若 `handler_user_id` 为空,upsert 层抛 `ValueError("填写办理时间时必须指定办理人员")`。
|
||||
|
||||
- [ ] **Step 2: pytest 失败后实现 `apply_handle_payload`,再接到 upsert**
|
||||
|
||||
`upsert_storage_cert` 增加字段:`handle_time`、`handler_user_id`;写入时调用 `sync_handle_status_fields` 设置 `make_status`、`make_date`。
|
||||
|
||||
`upsert_fullmoon_todo`:写入 `handle_time`、`handler_user_id`,同步 `todo_status`、`handle_date`。调用方 **不要** 传入 `insurance_company_*` 等键(exclude)。
|
||||
|
||||
`_storage_cert_to_dict` / `_fullmoon_todo_to_dict` 增加:
|
||||
|
||||
```python
|
||||
"handleTime": _fmt_dt(s.handle_time),
|
||||
"handlerUserId": s.handler_user_id or "",
|
||||
"handled": storage_cert_handled(s), # 保险用 insurance_handled
|
||||
```
|
||||
|
||||
办理人姓名在 API 层批量查 `sys_user.nick_name`,CRUD dict 可只带 id。
|
||||
|
||||
- [ ] **Step 3: pytest 通过后 Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(backend): sync storage cert and insurance handle time with legacy status.
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 列表富化与筛选
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/crud/protocol_sample_collection.py`
|
||||
- Modify: `backend/app/crud/research_donation_sample_collection.py`
|
||||
- Modify: `backend/app/api/protocol_sample_collection.py`
|
||||
- Modify: `backend/app/api/research_donation_sample_collection.py`
|
||||
- Modify: `backend/app/services/protocol_sample_list_summary.py`(接收多条时按 `reception_time` 取最晚)
|
||||
|
||||
**列表每行增加(camelCase):**
|
||||
|
||||
- `collectionFlag`, `collectionFlagLabel`
|
||||
- `receptionFlag`, `receptionFlagLabel`, `receptionTime`(可空)
|
||||
- `qcFlag`, `qcFlagLabel`, `qcCompleteTime`(可空)
|
||||
- `storageFlag`, `storageFlagLabel`, `storageTime`(可空)
|
||||
- 仅细胞存储:`storageCertHandled`, `insuranceHandled`(bool)
|
||||
|
||||
科研捐赠 **禁止** 返回证/保险 handled 字段。
|
||||
|
||||
**Query(两接口):** `collectionFlag`, `receptionFlag`, `qcFlag`, `storageFlag`;细胞存储另加 `storageCertHandled`、`insuranceHandled`(`1`/`0` 或 `true`/`false`)。
|
||||
|
||||
实现要点:
|
||||
|
||||
1. 先按现有分页查出样本行。
|
||||
2. 收集本页 `sample_code`、`collection_protocol_id`。
|
||||
3. 接收:在 `get_list_reception_summary_map_by_sample_codes` 中同码多条时保留 `reception_time` 最大的一条(改循环:不要 `if code in out: continue` 先到先得;改为比较时间)。同时返回 `receptionTime`。
|
||||
4. 质检:复用 `get_list_qc_conclusion_map_by_sample_codes(..., reception_source=...)`。
|
||||
5. 入库:按 `tenant_no + sample_code + reception_source` 查 `BizStorageReleaseReview`,`del_flag=0`,同码多条取 `update_time`/`storage_time` 最晚。字段 `storageStatus`、`reviewConclusion`、`storageTime`。
|
||||
6. 证/保险:按本页 `protocol_id` 批量查两表,用 Task 2 的 handled。
|
||||
7. **筛选必须在 count/offset 之前生效**,避免分页错位:对 flag 过滤用 EXISTS / 子查询,或先筛 sample id 再 paginate。不要先 page 再在 Python 里丢掉行。
|
||||
|
||||
采集筛选 SQL:
|
||||
|
||||
- `uncollected`:`sample_status` IS NULL OR `''`
|
||||
- `collected`:`IN ('ycj','jcz','yrk')`
|
||||
- `voided`:`= 'yzf'`
|
||||
|
||||
保留旧参数 `sampleStatus` 时:若传入 `ycj` 等原始码仍过滤该码;新前端只传 `collectionFlag`。
|
||||
|
||||
- [ ] **Step 1: 补测接收 map 取最晚**
|
||||
|
||||
在 `tests/test_protocol_list_sample_reception_qc.py` 增加:两条 mock `reception_time` 不同,断言留下较晚且 `js`。
|
||||
|
||||
- [ ] **Step 2: 实现 map + list enrich + API 参数**
|
||||
|
||||
细胞存储 `list_protocol_sample_collections` 签名增加上述 filter。捐赠 crud 同样,无 cert 参数。
|
||||
|
||||
- [ ] **Step 3:** `cd backend && python -m pytest tests/test_protocol_list_sample_reception_qc.py tests/test_sample_info_progress.py -v`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(backend): enrich sample collection lists with progress and handle flags.
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 办理 HTTP API 与详情 bundle
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/api/collection_protocol.py`
|
||||
|
||||
- [ ] **Step 1: Pydantic body**
|
||||
|
||||
```python
|
||||
class StorageCertHandleBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
handle_time: Optional[datetime] = Field(None, alias="handleTime")
|
||||
handler_user_id: Optional[str] = Field(None, alias="handlerUserId")
|
||||
cert_image_urls: Optional[str] = Field(None, alias="certImageUrls")
|
||||
remark: Optional[str] = None
|
||||
|
||||
class FullmoonHandleBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
handle_time: Optional[datetime] = Field(None, alias="handleTime")
|
||||
handler_user_id: Optional[str] = Field(None, alias="handlerUserId")
|
||||
insurance_attachments: Optional[str] = Field(None, alias="insuranceAttachments")
|
||||
handle_remark: Optional[str] = Field(None, alias="handleRemark")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 路由**
|
||||
|
||||
- `PUT /web/admin/storage-certs/{protocol_id}`:鉴权与协议更新相同(`require_admin_or_super_admin` 或样本信息 menu code 并集,与现有 storage-certs GET 对齐)。校验协议存在、租户、数据权限。`handle_time` 有值则 `handler_user_id` 必填。调用 `upsert_storage_cert`。
|
||||
- `PUT /web/admin/fullmoon-todos/{protocol_id}/handle`:同样。`model_dump` **只含** 上述四字段,禁止 spread 进保险公司字段。
|
||||
- 不删除 `PUT .../insurance`。
|
||||
|
||||
协议详情 bundle 里 `storageCert`、`fullmoonTodo` 带上 `handleTime`、`handlerUserId`、`handlerUserName`、`handled`。
|
||||
|
||||
- [ ] **Step 3: 无 DB 的校验测**(handler 缺失 → ValueError)放 `test_sample_info_handle.py`。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(api): add storage cert and fullmoon lightweight handle endpoints.
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 前端 API 与 Tag 注册
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/api/protocol-sample-collection.ts`
|
||||
- Modify: `web-admin/src/api/research-donation-sample-collection.ts`
|
||||
- Modify: `web-admin/src/api/admin.ts`(或新建 `web-admin/src/api/sample-info-handle.ts`)
|
||||
- Modify: `web-admin/src/utils/status-tag-registry.ts`
|
||||
|
||||
- [ ] **Step 1: list params 增加 flag 字段**(与后端 alias 一致)。
|
||||
|
||||
- [ ] **Step 2: 办理函数**
|
||||
|
||||
```ts
|
||||
export function fetchUpdateStorageCertHandle(protocolId: string, data: {
|
||||
handleTime?: string | null
|
||||
handlerUserId?: string | null
|
||||
certImageUrls?: string
|
||||
remark?: string
|
||||
}) {
|
||||
return request.put({ url: `/web/admin/storage-certs/${protocolId}`, params: data })
|
||||
}
|
||||
|
||||
export function fetchUpdateFullmoonHandle(protocolId: string, data: {
|
||||
handleTime?: string | null
|
||||
handlerUserId?: string | null
|
||||
insuranceAttachments?: string
|
||||
handleRemark?: string
|
||||
}) {
|
||||
return request.put({ url: `/web/admin/fullmoon-todos/${protocolId}/handle`, params: data })
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: registry**
|
||||
|
||||
```ts
|
||||
export const sampleCollectionFlag: StatusRegistry = {
|
||||
uncollected: { label: '未采集', tagType: 'info' },
|
||||
collected: { label: '已采集', tagType: 'success' },
|
||||
voided: { label: '已作废', tagType: 'danger' }
|
||||
}
|
||||
export const sampleReceptionFlag: StatusRegistry = {
|
||||
none: { label: '未接收', tagType: 'info' },
|
||||
js: { label: '已接收', tagType: 'success' },
|
||||
bj: { label: '拒收', tagType: 'danger' }
|
||||
}
|
||||
export const sampleQcFlag: StatusRegistry = {
|
||||
incomplete: { label: '未完成', tagType: 'warning' },
|
||||
complete: { label: '已完成', tagType: 'success' },
|
||||
voided: { label: '已作废', tagType: 'danger' }
|
||||
}
|
||||
export const sampleStorageFlag: StatusRegistry = {
|
||||
none: { label: '未入库', tagType: 'info' },
|
||||
yrk: { label: '已入库', tagType: 'success' },
|
||||
rejected: { label: '拒绝入库', tagType: 'danger' }
|
||||
}
|
||||
export const handleDoneFlag: StatusRegistry = {
|
||||
'1': { label: '已办理', tagType: 'success' },
|
||||
'0': { label: '未办理', tagType: 'info' }
|
||||
}
|
||||
```
|
||||
|
||||
导出 label/tagType getter,与文件内现有工厂一致。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(web-admin): add sample info list params, handle APIs, and progress tags.
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 统一列表页
|
||||
|
||||
**Files:**
|
||||
- Create: `web-admin/src/views/cell-storage/sample-info/UnifiedSampleInfoPage.vue`
|
||||
- Modify: `web-admin/src/views/cell-storage/sample-info/index.vue`
|
||||
- Modify: `web-admin/src/views/research-donation/sample-info/index.vue`
|
||||
|
||||
对照:`SampleCollectionInfoTab.vue` 的表格骨架 + `protocol-audit/index.vue` 的 pill Tab。UI:Fresh Mint、筛选无重复标题、按钮带 `el-icon`。
|
||||
|
||||
Props:`defaultSource: 'cell_storage' | 'research_donation'`。
|
||||
|
||||
- [ ] **Step 1: Tab 与权限**
|
||||
|
||||
两 Tab 文案:细胞存储 / 科研捐赠。`query.tab` 同步。用当前路由 `meta.menuCode` 与用户菜单树判断 Tab 可见性(无科研捐赠权限则只显示细胞存储,反之亦然)。从细胞存储菜单进入默认 `cell_storage`。
|
||||
|
||||
- [ ] **Step 2: 筛选**
|
||||
|
||||
关键词 placeholder:`姓名/样本编码/入库编码`。采集/接收/质检/入库下拉用 Task 2 的 code。细胞存储 Tab 增加储存证、保险:已办理 / 未办理。防抖 300ms 与现页一致。
|
||||
|
||||
- [ ] **Step 3: 表格列**
|
||||
|
||||
与 spec §4.2:状态 Tag、样本编码用 `SampleCodeWithSpecialDisplay`。四个进度 +(存储 Tab)两列办理。操作列仅「查看」,`fixed="right"`,`min-width` ≥ 260。双击查看。分页 10/20/50。
|
||||
|
||||
细胞存储 `fetchProtocolSampleCollectionList`;捐赠 `fetchResearchDonationSampleCollectionList`。
|
||||
|
||||
- [ ] **Step 4: 查看**
|
||||
|
||||
细胞存储:`ProtocolViewDialog`,`protocol-id = collectionProtocolId`,传入 `highlight-sample-code`。捐赠:`DonationViewDialog` + `researchDonationId`。`show-audit-history` 可保留;不要再靠 `show-storage-cert` 只读拼盘作为办理入口。
|
||||
|
||||
- [ ] **Step 5: 两个 index 只包一层**
|
||||
|
||||
```vue
|
||||
<UnifiedSampleInfoPage default-source="cell_storage" />
|
||||
```
|
||||
|
||||
科研捐赠:`default-source="research_donation"`。科研捐赠页的 `panel-top`「样本信息管理」标题:统一页自带 pill Tab 后,按 Fresh Mint **单标题**——标题放统一页 `h2` 一次即可,子 index 不要再套一个 h2。
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(web-admin): unify sample info management into two source tabs.
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 办理面板 + 协议详情
|
||||
|
||||
**Files:**
|
||||
- Create: `web-admin/src/components/business/protocol-handle-panel/index.vue`
|
||||
- Modify: `web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue`
|
||||
- Modify: `web-admin/src/views/research-donation/list/DonationViewDialog.vue`(高亮当前样本)
|
||||
|
||||
- [ ] **Step 1: `protocol-handle-panel`**
|
||||
|
||||
`label-position="top"`,`.dialog-form-layout` 内嵌即可(本身是 section 不是独立 dialog)。字段:`el-date-picker` datetime `value-format="YYYY-MM-DD HH:mm:ss"`;办理人 `el-select` filterable,远程 `fetchUserList({ page: 1, pageSize: 50, keyword, status: '1' })`,空表单默认 `userStore` 当前用户 id/名。附件卡片对齐 `ProtocolFormDialog` 协议附件(`fetchUploadAttachment`,folder 用 `storage-cert` / `fullmoon-todo`)。备注 textarea。底栏「保存」Check 图标。有办理时间无办理人前端拦截。保存成功 `ElMessage.success` 并 `emit('saved')`。
|
||||
|
||||
两种 mode:`storage-cert` | `insurance`,调用对应 PUT。
|
||||
|
||||
- [ ] **Step 2: ProtocolViewDialog**
|
||||
|
||||
- props:`highlightSampleCode?: string`。匹配的 `.sample-card` 加高亮 class(主色描边)。`onMounted`/`watch` bundle 就绪后 `scrollIntoView` 该卡片。
|
||||
- 替换基本信息里旧储存证只读行、客户下「新生儿保险信息」只读大段:改为两个 `protocol-handle-panel`(仅 `source` 为细胞存储详情时显示)。打开后用 bundle 预填;保存后 `reload` bundle。
|
||||
- 样本卡片已有接收/质检/入库只读,不重复造四段表单;确保入库组件在捐赠详情同样存在。
|
||||
|
||||
- [ ] **Step 3: DonationViewDialog**
|
||||
|
||||
同样 `highlightSampleCode` + 滚动。不要加证/保险面板。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(web-admin): add cert and insurance handle panels on protocol detail.
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: 撤协议单 Tab 与旧路由
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/cell-storage/protocol-audit/index.vue`
|
||||
- Modify: `web-admin/src/views/cell-storage/storage-cert/index.vue`
|
||||
- Modify: `web-admin/src/views/cell-storage/fullmoon-todo/index.vue`
|
||||
- Modify: `web-admin/src/views/cell-storage/sample-reception-info/index.vue`(若仍指向旧 tab)
|
||||
- Modify: `web-admin/src/views/cell-storage/storage-entry-info/index.vue`
|
||||
|
||||
- [ ] **Step 1:** `protocol-audit/index.vue` 的 `entityTabs` 只留 `protocol`;删除 `StorageCertTab`/`FullmoonTodoTab` 引用。`?tab=storage-cert|fullmoon-todo` 时 `replace` 到 `CellStorageSampleInfo`。
|
||||
|
||||
- [ ] **Step 2:** 储存证/满月隐藏页:
|
||||
|
||||
```ts
|
||||
router.replace({ name: 'CellStorageSampleInfo', query: { ...route.query, tab: 'cell_storage' } })
|
||||
```
|
||||
|
||||
- [ ] **Step 3:** 确认样本信息页不再 import 四个旧 Tab。删除或停止引用 `StorageCertTab.vue`、`FullmoonTodoTab.vue`、四个 `Sample*InfoTab.vue`、`DonationSampleCollectionInfoTab.vue`(无引用后再删文件,避免死代码)。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "$(cat <<'EOF'
|
||||
refactor(web-admin): remove storage cert and fullmoon tabs from protocol audit.
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: 图谱与回归
|
||||
|
||||
- [ ] **Step 1:** `./scripts/update-graph.sh all`(或分别 backend/frontend)。
|
||||
|
||||
- [ ] **Step 2:** `cd backend && python -m pytest tests/test_sample_info_progress.py tests/test_sample_info_handle.py tests/test_protocol_list_sample_reception_qc.py -v`
|
||||
|
||||
- [ ] **Step 3:** 浏览器(有环境时):细胞存储样本信息四标识、多样本改证后两行已办理一致、清空办理时间变未办理、科研捐赠无证列、采集协议单无证/满月 Tab、旧储存证 URL 跳转。无浏览器则注明未做 UI 点验。
|
||||
|
||||
- [ ] **Step 4: Commit** 图谱(若有 diff)
|
||||
|
||||
```bash
|
||||
git commit -m "$(cat <<'EOF'
|
||||
chore: update code graphs after unified sample info.
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Spec coverage
|
||||
|
||||
| Spec | Task |
|
||||
|------|------|
|
||||
| 一页两 Tab、默认来源、权限 | 7 |
|
||||
| 样本行、列、只读进度 | 2, 4, 7 |
|
||||
| 证/保险已办理兼容旧数据 | 2, 3, 4 |
|
||||
| 详情进度 + 办理四字段 | 5, 8(进度复用已有只读组件) |
|
||||
| 独立 PUT、不同步保险公司字段 | 3, 5 |
|
||||
| 协议单撤 Tab、旧路由 | 9 |
|
||||
| 不发短信、不建总表 | 全程禁止 |
|
||||
| 测试要点 | 2–5, 10 |
|
||||
|
||||
## Placeholder / 一致性
|
||||
|
||||
- Flag code 全计划统一为 `uncollected|collected|voided`、`none|js|bj`、`incomplete|complete|voided`、`none|yrk|rejected`。
|
||||
- 办理清空时 handler 一并清空。
|
||||
- 列表筛选不得「先分页再过滤」。
|
||||
@@ -37,7 +37,7 @@
|
||||
- Create: `docs/sql/migrations/2026-10-08-customer-profile-expand.sql`
|
||||
- Modify: `backend/app/models/models.py`(`BizCustomer`,约 327–347 行)
|
||||
|
||||
- [ ] **Step 1: 写迁移 SQL**
|
||||
- [x] **Step 1: 写迁移 SQL**
|
||||
|
||||
```sql
|
||||
-- 客户主档:通用个人档案字段
|
||||
@@ -52,7 +52,7 @@ ALTER TABLE `biz_customer`
|
||||
ADD COLUMN `address` varchar(512) DEFAULT NULL COMMENT '家庭住址' AFTER `email`;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: ORM 增加字段**
|
||||
- [x] **Step 2: ORM 增加字段**
|
||||
|
||||
在 `BizCustomer` 中 `age` 与 `phone_no` 之间(或与迁移 AFTER 顺序一致)增加:
|
||||
|
||||
@@ -65,7 +65,7 @@ address = Column(String(512), nullable=True, comment="家庭住址")
|
||||
|
||||
确认文件顶部已 `from sqlalchemy import ... CHAR`(或与同文件其他 `CHAR` 用法一致)。
|
||||
|
||||
- [ ] **Step 3: 本地执行迁移**(按 `.env.local` 库)
|
||||
- [x] **Step 3: 本地执行迁移**(按 `.env.local` 库)
|
||||
|
||||
```bash
|
||||
mysql -h 127.0.0.1 -u <user> -p <database> < docs/sql/migrations/2026-10-08-customer-profile-expand.sql
|
||||
@@ -81,7 +81,7 @@ Expected: 无报错;`SHOW COLUMNS FROM biz_customer LIKE 'birth_ym';` 有行
|
||||
- Modify: `backend/app/crud/customer.py`
|
||||
- Modify: `backend/app/api/basic_info.py`(`CustomerCreateBody` / `CustomerUpdateBody`)
|
||||
|
||||
- [ ] **Step 1: 扩展 `_row_to_dict`**
|
||||
- [x] **Step 1: 扩展 `_row_to_dict`**
|
||||
|
||||
在 `age` 后增加:
|
||||
|
||||
@@ -92,7 +92,7 @@ Expected: 无报错;`SHOW COLUMNS FROM biz_customer LIKE 'birth_ym';` 有行
|
||||
"address": c.address or "",
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 扩展 `create_customer` / `update_customer` 签名与赋值**
|
||||
- [x] **Step 2: 扩展 `create_customer` / `update_customer` 签名与赋值**
|
||||
|
||||
`create_customer` 增加可选参数:
|
||||
|
||||
@@ -114,7 +114,7 @@ address=(address or "").strip() or None,
|
||||
|
||||
`update_customer`:各参数 `is not None` 时写入;空串写 `None`(与 phone 等一致)。
|
||||
|
||||
- [ ] **Step 3: 扩展 Pydantic Body**
|
||||
- [x] **Step 3: 扩展 Pydantic Body**
|
||||
|
||||
`CustomerCreateBody` / `CustomerUpdateBody` 增加:
|
||||
|
||||
@@ -127,7 +127,7 @@ address: Optional[str] = None
|
||||
|
||||
创建/更新路由调用 `create_customer` / `update_customer` 时传入这四个字段(若路由内是 `body.xxx` 展开,一并补上)。
|
||||
|
||||
- [ ] **Step 4: 快速冒烟(可选)**
|
||||
- [x] **Step 4: 快速冒烟(可选)**
|
||||
|
||||
启动后端后 `POST /api/web/admin/customers` 带 `birthYm`/`ethnic`/`email`/`address`,`GET` 列表项应原样返回。
|
||||
|
||||
@@ -148,7 +148,7 @@ address: Optional[str] = None
|
||||
- `_create_role_customer` / `create_customer` 调用传入新字段。
|
||||
- `_apply_direct_identity` 可改为调用 `apply_role_profile_overwrite`,或扩展为同样覆盖四字段。
|
||||
|
||||
- [ ] **Step 1: 写失败单测(覆盖 + 空不覆盖 + 母亲扩展)**
|
||||
- [x] **Step 1: 写失败单测(覆盖 + 空不覆盖 + 母亲扩展)**
|
||||
|
||||
在 `test_party_customer_sync.py` 追加:
|
||||
|
||||
@@ -229,7 +229,7 @@ class TestApplyRoleProfileOverwrite(unittest.TestCase):
|
||||
self.assertEqual(linked.address, "新址")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测确认失败**
|
||||
- [x] **Step 2: 跑测确认失败**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && pytest tests/test_party_customer_sync.py::TestApplyRoleProfileOverwrite -v
|
||||
@@ -237,7 +237,7 @@ cd backend && source venv/bin/activate && pytest tests/test_party_customer_sync.
|
||||
|
||||
Expected: FAIL(`apply_role_profile_overwrite` 未定义或行为不符)。
|
||||
|
||||
- [ ] **Step 3: 实现 `apply_role_profile_overwrite` 与角色映射**
|
||||
- [x] **Step 3: 实现 `apply_role_profile_overwrite` 与角色映射**
|
||||
|
||||
示例实现骨架:
|
||||
|
||||
@@ -287,7 +287,7 @@ def apply_role_profile_overwrite(target: Any, profile: Any) -> None:
|
||||
|
||||
同步调整 `_apply_direct_identity`,使姓名/电话/身份证/扩展字段走覆盖语义(与单测 `test_name_and_id_card_keeps_linked_id_when_not_found` 兼容:有值仍写入)。
|
||||
|
||||
- [ ] **Step 4: 全量跑该文件**
|
||||
- [x] **Step 4: 全量跑该文件**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && pytest tests/test_party_customer_sync.py -v
|
||||
@@ -305,7 +305,7 @@ Expected: 全部 PASS(含原有 merge 空才补用例)。
|
||||
- Modify: `web-admin/src/types/api.d.ts`(`Api.BizCustomer.CustomerItem`)
|
||||
- Modify: `web-admin/src/views/basic-info/customer/index.vue`
|
||||
|
||||
- [ ] **Step 1: 扩展 `CustomerItem`**
|
||||
- [x] **Step 1: 扩展 `CustomerItem`**
|
||||
|
||||
```typescript
|
||||
birthYm: string
|
||||
@@ -314,7 +314,7 @@ email: string
|
||||
address: string
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 表单增加四项**
|
||||
- [x] **Step 2: 表单增加四项**
|
||||
|
||||
在 `formData` / `resetForm` / `handleEdit` / `handleSubmit` payload 增加 `birthYm`、`ethnic`、`email`、`address`。
|
||||
|
||||
@@ -359,7 +359,7 @@ address: string
|
||||
|
||||
复用 `@/api/ethnic` 的 `fetchEthnicList`(对齐 `ProtocolFormDialog` 的 `loadEthnicOptions`)。页面 `onMounted` 或打开弹窗时加载民族列表。
|
||||
|
||||
- [ ] **Step 3: 查看弹窗 `viewSections` 基本信息增加**
|
||||
- [x] **Step 3: 查看弹窗 `viewSections` 基本信息增加**
|
||||
|
||||
```typescript
|
||||
{ label: '出生年月', value: formatYm(row.birthYm) }, // 可内联:YYYYMM → YYYY-MM
|
||||
@@ -370,7 +370,7 @@ address: string
|
||||
|
||||
列表列可不加(spec 允许);至少查看齐全。
|
||||
|
||||
- [ ] **Step 4: 本地 UI 冒烟**
|
||||
- [x] **Step 4: 本地 UI 冒烟**
|
||||
|
||||
打开 `/#/market/market-info/customer`:新增带四字段 → 保存 → 查看可见 → 编辑改邮箱再保存。
|
||||
|
||||
@@ -382,7 +382,7 @@ address: string
|
||||
- Modify: `web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue`(`onMotherCustomerSelect`)
|
||||
- Modify: `web-admin/src/views/research-donation/list/DonationFormDialog.vue`(同名逻辑)
|
||||
|
||||
- [ ] **Step 1: 母亲选中时回填**
|
||||
- [x] **Step 1: 母亲选中时回填**
|
||||
|
||||
在现有赋值后增加(仅填主档有值的字段;若希望「只填表单空位」,用 `if (!formData.motherEmail) ...` 判断):
|
||||
|
||||
@@ -416,13 +416,13 @@ if (!(formData.motherEthnic || '').trim() && ethnic) {
|
||||
|
||||
父亲侧无协议扩展字段源,无需回填四新字段。
|
||||
|
||||
- [ ] **Step 2: 科研捐赠表单同样改 `onMotherCustomerSelect`**
|
||||
- [x] **Step 2: 科研捐赠表单同样改 `onMotherCustomerSelect`**
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 验收与图谱
|
||||
|
||||
- [ ] **Step 1: 跑后端相关测试**
|
||||
- [x] **Step 1: 跑后端相关测试**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && pytest tests/test_party_customer_sync.py tests/test_customer_relations.py -v
|
||||
@@ -430,7 +430,7 @@ cd backend && source venv/bin/activate && pytest tests/test_party_customer_sync.
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
- [ ] **Step 2: 按 spec §6 手工验收**
|
||||
- [x] **Step 2: 按 spec §6 手工验收**
|
||||
|
||||
1. 协议填全母亲扩展字段保存 → 当事人有快照 + 客户主档一致。
|
||||
2. 客户管理改电话 → 旧协议查看仍是当事人旧电话。
|
||||
@@ -439,7 +439,7 @@ Expected: PASS。
|
||||
5. 客户管理独立维护四字段。
|
||||
6. 父亲同步不误清空客户已有邮箱/住址。
|
||||
|
||||
- [ ] **Step 3: 更新知识图谱**
|
||||
- [x] **Step 3: 更新知识图谱**
|
||||
|
||||
```bash
|
||||
./scripts/update-graph.sh all
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
# 配置运费「关联已有」列表 enrichment Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 让协议单与运费信息页「配置运费 → 关联已有」列表对齐「运费信息」主表信息密度(合并单元格 + 样本明细 + 创建人),便于用户判断关联哪一笔批次。
|
||||
|
||||
**Architecture:** 抽出 `flattenSampleFreights` 工具与 `SampleFreightPickTable` 选批表格组件;两处弹窗改为 `includeItems: true` 加载并嵌入该组件;按 `freightId` 单选,不改 create/link API。
|
||||
|
||||
**Tech Stack:** Vue 3 + Element Plus + TypeScript(web-admin);既有 `fetchSampleFreightList`。
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-10-08-freight-link-existing-enrich-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| Create: `web-admin/src/utils/sample-freight-flatten.ts` | flatten + spanMethod |
|
||||
| Create: `web-admin/src/components/business/sample-freight-pick-table/index.vue` | 关联已有选批表 |
|
||||
| Modify: `web-admin/src/views/cell-storage/sample-freight/FreightConfigDialog.vue` | 嵌入选批表、加宽、keyword |
|
||||
| Modify: `web-admin/src/components/business/sample-transport-form-section/index.vue` | 嵌入选批表、加宽、includeItems |
|
||||
| Modify(可选): `web-admin/src/views/cell-storage/sample-freight/index.vue` | 改用公共 flatten(减少重复) |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: flatten 工具
|
||||
|
||||
**Files:**
|
||||
- Create: `web-admin/src/utils/sample-freight-flatten.ts`
|
||||
|
||||
- [x] **Step 1: 新增工具文件**
|
||||
|
||||
```ts
|
||||
import type { SampleFreight } from '@/api/sample-freight'
|
||||
|
||||
export type SampleFreightFlatRow = {
|
||||
rowKey: string
|
||||
freightId: string
|
||||
rowspan: number
|
||||
isFirst: boolean
|
||||
transportMode: string
|
||||
transportModeLabel: string
|
||||
freightAmount: number
|
||||
linkedSampleCount: number
|
||||
createBy?: string
|
||||
createByName?: string
|
||||
createTime: string
|
||||
sampleSource?: string
|
||||
protocolNo?: string
|
||||
sampleCode?: string
|
||||
sampleTypeName?: string
|
||||
sampleId?: string
|
||||
protocolId?: string
|
||||
/** 原始批次,选中时回传 */
|
||||
freight: SampleFreight
|
||||
}
|
||||
|
||||
export function flattenSampleFreights(list: SampleFreight[]): SampleFreightFlatRow[] {
|
||||
const rows: SampleFreightFlatRow[] = []
|
||||
for (const f of list) {
|
||||
const items = f.items?.length ? f.items : [null]
|
||||
items.forEach((it, idx) => {
|
||||
rows.push({
|
||||
rowKey: `${f.id}|${it?.sampleId || idx}`,
|
||||
freightId: f.id,
|
||||
rowspan: idx === 0 ? items.length : 0,
|
||||
isFirst: idx === 0,
|
||||
transportMode: f.transportMode,
|
||||
transportModeLabel: f.transportModeLabel || f.transportMode,
|
||||
freightAmount: f.freightAmount,
|
||||
linkedSampleCount: f.linkedSampleCount ?? (f.items?.length || 0),
|
||||
createBy: f.createBy || '',
|
||||
createByName: f.createByName || f.createBy || '',
|
||||
createTime: f.createTime || '',
|
||||
sampleSource: it?.sampleSource,
|
||||
protocolNo: it?.protocolNo,
|
||||
sampleCode: it?.sampleCode,
|
||||
sampleTypeName: it?.sampleTypeName,
|
||||
sampleId: it?.sampleId,
|
||||
protocolId: it?.protocolId,
|
||||
freight: f
|
||||
})
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
const MERGE_PROPS = new Set([
|
||||
'selection',
|
||||
'radio',
|
||||
'transportMode',
|
||||
'freightAmount',
|
||||
'linkedSampleCount',
|
||||
'createByName',
|
||||
'createTime'
|
||||
])
|
||||
|
||||
export function sampleFreightSpanMethod({
|
||||
column,
|
||||
row
|
||||
}: {
|
||||
column: { type?: string; property?: string }
|
||||
row: SampleFreightFlatRow
|
||||
}): { rowspan: number; colspan: number } {
|
||||
const prop = column.type === 'selection' ? 'selection' : column.property || ''
|
||||
if (MERGE_PROPS.has(prop)) {
|
||||
if (row.rowspan > 0) return { rowspan: row.rowspan, colspan: 1 }
|
||||
return { rowspan: 0, colspan: 0 }
|
||||
}
|
||||
return { rowspan: 1, colspan: 1 }
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 2: 确认无 TS 错误后进入 Task 2**(本工具无独立单测;逻辑与主列表一致)
|
||||
|
||||
---
|
||||
|
||||
### Task 2: SampleFreightPickTable 组件
|
||||
|
||||
**Files:**
|
||||
- Create: `web-admin/src/components/business/sample-freight-pick-table/index.vue`
|
||||
|
||||
- [x] **Step 1: 实现选批表格**
|
||||
|
||||
Props:
|
||||
- `list: SampleFreight[]`
|
||||
- `loading?: boolean`
|
||||
- `modelValue: string`(选中的 freightId)
|
||||
- `maxHeight?: number | string`(默认 280)
|
||||
- `keyword?: string`(可选,仅展示由父级控制检索时可不内置)
|
||||
|
||||
Emits:
|
||||
- `update:modelValue`
|
||||
- `select` → `SampleFreight | null`
|
||||
|
||||
行为:
|
||||
- 内部 `flattenSampleFreights` + `sampleFreightSpanMethod`
|
||||
- 第一列 radio(`prop="radio"`),仅 `isFirst` 行显示;点击任意行 `setCurrentRow` / 设置 modelValue 为该行 `freightId`
|
||||
- 列:运输方式 Tag、运费、关联数、来源 Tag、协议单号、样本编码、样本类型、创建人、创建时间
|
||||
- 金额用 `formatMoneyDisplay`
|
||||
- `@row-click` 选中整批
|
||||
|
||||
模板要点(radio 列):
|
||||
|
||||
```vue
|
||||
<el-table-column prop="radio" label="" min-width="48" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-radio
|
||||
v-if="row.isFirst"
|
||||
:model-value="modelValue"
|
||||
:value="row.freightId"
|
||||
@change="emitSelect(row.freight)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
```
|
||||
|
||||
`@row-click="(row) => emitSelect(row.freight)"`。
|
||||
|
||||
- [x] **Step 2: 肉眼确认组件可被 import**
|
||||
|
||||
---
|
||||
|
||||
### Task 3: FreightConfigDialog 接入
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/cell-storage/sample-freight/FreightConfigDialog.vue`
|
||||
|
||||
- [x] **Step 1: 弹窗 width 改为 `1020px`**
|
||||
|
||||
- [x] **Step 2: 「关联已有」区替换为 keyword + SampleFreightPickTable**
|
||||
|
||||
```vue
|
||||
<div v-else class="link-existing-block" style="margin-bottom: 12px">
|
||||
<div class="sample-pick-toolbar" style="margin-bottom: 10px">
|
||||
<el-input
|
||||
v-model="existingKeyword"
|
||||
placeholder="运输方式/协议单号/样本编码"
|
||||
clearable
|
||||
style="width: 260px"
|
||||
@keyup.enter="loadExistingFreights"
|
||||
/>
|
||||
<el-button class="btn-ghost" @click="loadExistingFreights">
|
||||
<el-icon><Search /></el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
</div>
|
||||
<SampleFreightPickTable
|
||||
v-model="selectedFreightId"
|
||||
:list="existingFreightList"
|
||||
:loading="existingLoading"
|
||||
@select="onPickFreight"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [x] **Step 3: `loadExistingFreights` 传 `includeItems: true` 与 `keyword`**
|
||||
|
||||
```ts
|
||||
const res = await fetchSampleFreightList({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
includeItems: true,
|
||||
keyword: existingKeyword.value || undefined
|
||||
})
|
||||
```
|
||||
|
||||
- [x] **Step 4: 用 `selectedFreightId` + `selectedExisting` 同步;open/closed 时清空 keyword 与选中**
|
||||
|
||||
- [x] **Step 5: 提交时仍用 `selectedExisting.value.id`(或由 id 从 list 找回)**
|
||||
|
||||
---
|
||||
|
||||
### Task 4: SampleTransportFormSection 接入
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/components/business/sample-transport-form-section/index.vue`
|
||||
|
||||
- [x] **Step 1: 配置运费弹窗 width 改为 `1020px`**
|
||||
|
||||
- [x] **Step 2: 「关联已有」旧 `el-table` 换成 `SampleFreightPickTable` + 可选 keyword(与 Task 3 一致)**
|
||||
|
||||
- [x] **Step 3: `openFreightDialog` 中 list 请求加 `includeItems: true`**
|
||||
|
||||
- [x] **Step 4: `selectedExistingFreight` 由 pick 的 `@select` / `v-model` 更新;`confirmFreight` 的 link 分支不变**
|
||||
|
||||
---
|
||||
|
||||
### Task 5:(可选)主列表复用 flatten
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/cell-storage/sample-freight/index.vue`
|
||||
|
||||
- [x] **Step 1: 删除本地 `flattenFreights` / 调整 `spanMethod`,改 import `flattenSampleFreights` + `sampleFreightSpanMethod`**
|
||||
- [x] **Step 2: FlatRow 可复用 `SampleFreightFlatRow`(操作列仍本地;span 合并 props 若含 `actions`,在页面包装一层把 `actions` 加入 merge 集合,或扩展 util 支持 `extraMergeProps`)**
|
||||
|
||||
若扩展成本高可跳过本 Task,验收不依赖此项。
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 验收
|
||||
|
||||
- [x] **Step 1: 运费信息页 → 配置运费 → 关联已有:列齐全、合并正确、搜索可用、可单选并提交**(代码已接入,请浏览器手工点验)
|
||||
- [x] **Step 2: 细胞存储协议单 → 配置运费 → 关联已有:同上**(共用组件已接入)
|
||||
- [x] **Step 3: 科研捐赠协议单 → 配置运费 → 关联已有:同上(共用 SampleTransportFormSection)**
|
||||
- [x] **Step 4: 运行 `./scripts/update-graph.sh frontend`**
|
||||
|
||||
---
|
||||
|
||||
## Spec coverage checklist
|
||||
|
||||
| Spec 要求 | Task |
|
||||
|-----------|------|
|
||||
| 对齐运费信息列(含创建人) | 2 |
|
||||
| 两入口一致 | 3, 4 |
|
||||
| includeItems=true | 3, 4 |
|
||||
| 按 freightId 单选 | 2 |
|
||||
| 弹窗加宽 | 3, 4 |
|
||||
| 轻量 keyword | 3, 4 |
|
||||
| 不改 create/link 规则 | 3, 4 保持现有提交 |
|
||||
| 创建人后端已有 | 前置已完成 |
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes
|
||||
|
||||
- 无 TBD/占位。
|
||||
- `SampleFreightFlatRow.freight` 保证选中后无需再查 map。
|
||||
- radio 列 `prop="radio"` 须在 MERGE_PROPS 中,与 selection 同行为。
|
||||
@@ -0,0 +1,425 @@
|
||||
# 采集协议单市场专员 / 地区经理联动 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 细胞存储与科研捐赠采集单将「销售人员」改为「市场专员」,按专员关联地区自动带出采集地区,并新增可改选的「地区经理」落库。
|
||||
|
||||
**Architecture:** 在两张采集信息表增加 `region_manager_user_id`;新增 `GET .../collection-regions/by-staff-user/{userId}` 返回用户绑定地区;前端表单联动对齐预签单 `applyDefaultRegionManager`。列表列改名,查看弹窗与协议审核卡片展示地区经理。
|
||||
|
||||
**Tech Stack:** MySQL 5.7、FastAPI + SQLAlchemy、Vue 3 + Element Plus、pytest
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-10-08-protocol-market-specialist-region-manager-design.md`
|
||||
|
||||
**Commits:** 本会话默认不自动 `git commit`(除非用户明确要求);完成各 Task 后用 checkbox 勾选即可。
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
| File | Responsibility |
|
||||
|------|----------------|
|
||||
| `docs/sql/migrations/2026-10-08-protocol-region-manager.sql` | 两表加 `region_manager_user_id` |
|
||||
| `backend/app/models/models.py` | `BizProtocolCollectionInfo.region_manager_user_id` |
|
||||
| `backend/app/models/research_donation.py` | `BizResearchDonationCollection.region_manager_user_id` |
|
||||
| `backend/app/crud/collection_region.py` | `list_regions_by_staff_user` |
|
||||
| `backend/app/api/basic_info.py` | HTTP 路由 `by-staff-user` |
|
||||
| `backend/app/crud/collection_protocol.py` | 采集信息 dict / upsert / 列表与详情昵称 |
|
||||
| `backend/app/crud/research_donation.py` | 同上 |
|
||||
| `backend/tests/test_collection_regions_by_staff_user.py` | 按用户查地区纯函数/CRUD 单测(可用 mock Session 或 helper) |
|
||||
| `web-admin/src/api/admin.ts` | `fetchRegionsByStaffUser` |
|
||||
| `web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue` | 字段顺序 + 联动 |
|
||||
| `web-admin/src/views/research-donation/list/DonationFormDialog.vue` | 同上 |
|
||||
| `web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue` | 文案 + 地区经理 |
|
||||
| `web-admin/src/views/research-donation/list/DonationViewDialog.vue` | 同上 |
|
||||
| `web-admin/src/views/cell-storage/protocol/index.vue` | 列名 |
|
||||
| `web-admin/src/views/research-donation/list/index.vue` | 列名 |
|
||||
| `web-admin/src/views/cell-storage/protocol-audit/DonationProtocolTab.vue` | 文案 + 地区经理 |
|
||||
| `web-admin/src/views/cell-storage/protocol-audit/ProtocolAuditTab.vue` | 若有销售人员展示则同步;补地区经理 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: SQL 迁移 + ORM 字段
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/sql/migrations/2026-10-08-protocol-region-manager.sql`
|
||||
- Modify: `backend/app/models/models.py`(`BizProtocolCollectionInfo`)
|
||||
- Modify: `backend/app/models/research_donation.py`(`BizResearchDonationCollection`)
|
||||
|
||||
- [ ] **Step 1: 写迁移 SQL**
|
||||
|
||||
```sql
|
||||
-- 采集协议 / 科研捐赠:地区经理用户ID
|
||||
-- Spec: docs/superpowers/specs/2026-10-08-protocol-market-specialist-region-manager-design.md
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
ALTER TABLE `biz_protocol_collection_info`
|
||||
ADD COLUMN `region_manager_user_id` varchar(64) DEFAULT NULL COMMENT '地区经理用户ID' AFTER `sales_user_id`;
|
||||
|
||||
ALTER TABLE `biz_research_donation_collection`
|
||||
ADD COLUMN `region_manager_user_id` varchar(64) DEFAULT NULL COMMENT '地区经理用户ID' AFTER `sales_user_id`;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: ORM 增加字段**
|
||||
|
||||
在 `BizProtocolCollectionInfo` 中 `sales_user_id` 后增加:
|
||||
|
||||
```python
|
||||
region_manager_user_id = Column(String(64), nullable=True, comment="地区经理用户ID")
|
||||
```
|
||||
|
||||
并将 `sales_user_id` 的 comment 改为 `"市场专员用户ID"`。
|
||||
|
||||
在 `BizResearchDonationCollection` 同样处理。
|
||||
|
||||
- [ ] **Step 3: 本地执行迁移**(按 `.env.local` 库)
|
||||
|
||||
```bash
|
||||
# 示例
|
||||
mysql -h 127.0.0.1 -u <user> -p <database> < docs/sql/migrations/2026-10-08-protocol-region-manager.sql
|
||||
```
|
||||
|
||||
Expected: `SHOW COLUMNS` 两表均可见 `region_manager_user_id`。
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `list_regions_by_staff_user` + 单测(TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/tests/test_collection_regions_by_staff_user.py`
|
||||
- Modify: `backend/app/crud/collection_region.py`
|
||||
- Modify: `backend/app/api/basic_info.py`
|
||||
- Modify: `web-admin/src/api/admin.ts`
|
||||
|
||||
- [ ] **Step 1: 写失败测试(排序与映射 helper)**
|
||||
|
||||
在 `collection_region.py` 将「行 → 地区 dict 列表」抽成可测纯函数更稳妥。先写:
|
||||
|
||||
```python
|
||||
# backend/tests/test_collection_regions_by_staff_user.py
|
||||
import unittest
|
||||
|
||||
from app.crud.collection_region import map_staff_region_rows_to_list
|
||||
|
||||
|
||||
class RegionsByStaffUserTests(unittest.TestCase):
|
||||
def test_map_preserves_order_and_fields(self):
|
||||
rows = [
|
||||
("r2", "华东"),
|
||||
("r1", "华南"),
|
||||
]
|
||||
out = map_staff_region_rows_to_list(rows)
|
||||
self.assertEqual(
|
||||
out,
|
||||
[{"id": "r2", "regionName": "华东"}, {"id": "r1", "regionName": "华南"}],
|
||||
)
|
||||
|
||||
def test_map_skips_empty_id(self):
|
||||
rows = [("", "无"), ("r1", "华南")]
|
||||
out = map_staff_region_rows_to_list(rows)
|
||||
self.assertEqual(out, [{"id": "r1", "regionName": "华南"}])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测确认失败**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && pytest tests/test_collection_regions_by_staff_user.py -v
|
||||
```
|
||||
|
||||
Expected: FAIL import / `map_staff_region_rows_to_list` 不存在。
|
||||
|
||||
- [ ] **Step 3: 实现 helper + CRUD 查询**
|
||||
|
||||
```python
|
||||
def map_staff_region_rows_to_list(rows: List[Tuple[str, str]]) -> List[Dict[str, Any]]:
|
||||
out: List[Dict[str, Any]] = []
|
||||
for rid, name in rows:
|
||||
rid = (rid or "").strip()
|
||||
if not rid:
|
||||
continue
|
||||
out.append({"id": rid, "regionName": (name or "").strip()})
|
||||
return out
|
||||
|
||||
|
||||
def list_regions_by_staff_user(
|
||||
db: Session, user_id: str, tenant_no: Optional[str] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
uid = (user_id or "").strip()
|
||||
if not uid:
|
||||
return []
|
||||
q = (
|
||||
db.query(BizCollectionRegionStaff.region_id, BizCollectionRegion.region_name)
|
||||
.join(
|
||||
BizCollectionRegion,
|
||||
BizCollectionRegion.id == BizCollectionRegionStaff.region_id,
|
||||
)
|
||||
.filter(
|
||||
BizCollectionRegionStaff.user_id == uid,
|
||||
BizCollectionRegionStaff.del_flag == 0,
|
||||
BizCollectionRegion.del_flag == 0,
|
||||
)
|
||||
)
|
||||
if tenant_no:
|
||||
q = q.filter(
|
||||
BizCollectionRegionStaff.tenant_no == tenant_no,
|
||||
BizCollectionRegion.tenant_no == tenant_no,
|
||||
)
|
||||
q = q.order_by(
|
||||
BizCollectionRegionStaff.order_num.asc(),
|
||||
BizCollectionRegionStaff.create_time.desc(),
|
||||
)
|
||||
return map_staff_region_rows_to_list([(r[0], r[1]) for r in q.all()])
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 再跑测 PASS**
|
||||
|
||||
```bash
|
||||
pytest tests/test_collection_regions_by_staff_user.py -v
|
||||
```
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
- [ ] **Step 5: 注册 API**
|
||||
|
||||
在 `basic_info.py` 中,放在 `@router.get("/collection-regions")` 附近、**先于**带 `{region_id}` 的路由亦可(路径静态段已足够区分):
|
||||
|
||||
```python
|
||||
@router.get("/collection-regions/by-staff-user/{user_id}")
|
||||
async def list_regions_by_staff_user(
|
||||
user_id: str,
|
||||
user: dict = Depends(require_menu_code("biz:collectionRegion:list")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items = region_svc.list_regions_by_staff_user(db, user_id=user_id, tenant_no=tenant_no)
|
||||
return success_response({"list": items})
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 前端 API**
|
||||
|
||||
在 `web-admin/src/api/admin.ts` 的 staff API 附近增加:
|
||||
|
||||
```typescript
|
||||
export function fetchRegionsByStaffUser(userId: string) {
|
||||
return request.get<{ list: Api.BizCollectionRegion.RegionItem[] }>({
|
||||
url: `/web/admin/collection-regions/by-staff-user/${userId}`
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
(若 `RegionItem` 仅需 `id`/`regionName`,可复用;否则用内联类型 `{ id: string; regionName: string }`。)
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 后端采集信息读写 `regionManagerUserId`
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/crud/collection_protocol.py`
|
||||
- Modify: `backend/app/crud/research_donation.py`
|
||||
- Test: 可在现有 save-bundle 测试中补断言;若无 DB fixture,至少保证 `_collection_info_to_dict` / `_STR_FIELDS` 改全(手工用 grep 复核字段一致性)
|
||||
|
||||
- [ ] **Step 1: `collection_protocol.py`**
|
||||
|
||||
1. `_collection_info_to_dict` 增加:
|
||||
`"regionManagerUserId": c.region_manager_user_id or ""`
|
||||
2. `upsert_collection_info` 的 `_STR_FIELDS` 增加 `"region_manager_user_id"`;创建分支 setattr 同样包含该字段。
|
||||
3. 列表 enrichment(约 1051 / 1168 行附近):收集 `regionManagerUserId` 进 `all_user_ids`,设置 `d["regionManagerUserName"]`。
|
||||
4. 详情 enrichment(约 1281 行):同上填充 `ci_dict["regionManagerUserName"]`。
|
||||
|
||||
- [ ] **Step 2: `research_donation.py`**
|
||||
|
||||
1. `_collection_to_dict` 增加 `regionManagerUserId`。
|
||||
2. `_COLL_STR_FIELDS` 增加 `"region_manager_user_id"`。
|
||||
3. 列表 / 详情昵称 map 同步 `regionManagerUserName`(与 `salesUserName` 同样路径)。
|
||||
|
||||
- [ ] **Step 3: grep 复核**
|
||||
|
||||
```bash
|
||||
rg "region_manager_user_id|regionManagerUserId" backend/app/crud/collection_protocol.py backend/app/crud/research_donation.py backend/app/models
|
||||
```
|
||||
|
||||
Expected: to_dict、upsert/create/update 字段列表均出现。
|
||||
|
||||
---
|
||||
|
||||
### Task 4: `ProtocolFormDialog.vue` 联动
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue`
|
||||
|
||||
- [ ] **Step 1: 模板字段顺序与控件**
|
||||
|
||||
采集信息区改为:
|
||||
|
||||
1. 市场专员(原销售人员 select,label 改文案)
|
||||
2. 地区经理(新 `el-select`,对齐预签单采集经理:remote `fetchUserList`、`regionManagerUserId`、`managerUserOptions`、`managerLoading`)
|
||||
3. 采集地区
|
||||
4. 采集医院 …
|
||||
其余不变。
|
||||
|
||||
- [ ] **Step 2: 状态**
|
||||
|
||||
```typescript
|
||||
const regionManagerUserId = ref<string | null>(null)
|
||||
const managerUserOptions = ref<Api.User.UserItem[]>([])
|
||||
const managerLoading = ref(false)
|
||||
const linkedRegionMode = ref(false) // true=专员关联地区限制下拉
|
||||
```
|
||||
|
||||
`formData` 增加 `regionManagerUserId: ''`;reset / 新建默认 / 编辑 hydrate 同步处理。
|
||||
|
||||
- [ ] **Step 3: 核心联动函数**
|
||||
|
||||
参考预签单 `applyDefaultRegionManager`,并新增:
|
||||
|
||||
```typescript
|
||||
async function applyRegionsForSalesUser(userId: string | null, opts?: { preferKeepRegion?: boolean }) {
|
||||
const uid = (userId || '').trim()
|
||||
if (!uid) {
|
||||
linkedRegionMode.value = false
|
||||
formData.regionId = ''
|
||||
formData.hospitalId = ''
|
||||
regionManagerUserId.value = null
|
||||
formData.regionManagerUserId = ''
|
||||
managerUserOptions.value = []
|
||||
regionOptions.value = []
|
||||
hospitalOptions.value = []
|
||||
return
|
||||
}
|
||||
const res: any = await fetchRegionsByStaffUser(uid)
|
||||
const list = (res?.list || []) as Array<{ id: string; regionName: string }>
|
||||
if (list.length > 0) {
|
||||
linkedRegionMode.value = true
|
||||
regionOptions.value = list as any
|
||||
const keep =
|
||||
opts?.preferKeepRegion &&
|
||||
formData.regionId &&
|
||||
list.some((x) => x.id === formData.regionId)
|
||||
const nextId = keep ? formData.regionId : list[0].id
|
||||
const regionChanged = nextId !== formData.regionId
|
||||
formData.regionId = nextId
|
||||
if (regionChanged) {
|
||||
formData.hospitalId = ''
|
||||
hospitalOptions.value = []
|
||||
await loadHospitalsByRegion(nextId, '')
|
||||
await applyDefaultRegionManager(nextId)
|
||||
}
|
||||
} else {
|
||||
linkedRegionMode.value = false
|
||||
formData.regionId = ''
|
||||
formData.hospitalId = ''
|
||||
hospitalOptions.value = []
|
||||
regionOptions.value = []
|
||||
regionManagerUserId.value = null
|
||||
formData.regionManagerUserId = ''
|
||||
managerUserOptions.value = []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`onSalesUserChange`:写完 `salesUserId` 后 `await applyRegionsForSalesUser(val)`。
|
||||
|
||||
`onRegionChange`:现有清医院逻辑后追加 `await applyDefaultRegionManager(val)`。
|
||||
|
||||
新建默认当前用户后:`await applyRegionsForSalesUser(uid, { preferKeepRegion: false })`。
|
||||
|
||||
编辑 hydrate:先填库内 `regionId`/`salesUserId`/`regionManagerUserId` 与 stub;**不要**在初次 hydrate 时强制覆盖经理。可设 `linkedRegionMode`:若专员有关联地区则加载 linked 列表并 preferKeepRegion。
|
||||
|
||||
- [ ] **Step 4: 地区下拉行为**
|
||||
|
||||
- `linkedRegionMode === true`:去掉 remote,或 remote 仅本地 filter `regionOptions`;placeholder「请选择关联地区」。
|
||||
- `linkedRegionMode === false`:保持现有 `remoteSearchRegions`。
|
||||
|
||||
- [ ] **Step 5: 保存 payload**
|
||||
|
||||
`collectionInfo` 增加 `regionManagerUserId: formData.regionManagerUserId || undefined`。
|
||||
|
||||
---
|
||||
|
||||
### Task 5: `DonationFormDialog.vue` 同步
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/research-donation/list/DonationFormDialog.vue`
|
||||
|
||||
- [ ] **Step 1–5:** 与 Task 4 **相同行为与字段名**(复制联动逻辑,保持两套弹窗一致;勿抽大 refactor,除非改动极小可复制函数)。
|
||||
|
||||
校验点:新建默认专员、多地区默认第一、改地区带经理、保存含 `regionManagerUserId`、编辑回显。
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 查看弹窗 / 列表 / 审核卡片文案与展示
|
||||
|
||||
**Files:**
|
||||
- Modify: `ProtocolViewDialog.vue`、`DonationViewDialog.vue`
|
||||
- Modify: `protocol/index.vue`、`research-donation/list/index.vue`
|
||||
- Modify: `DonationProtocolTab.vue`;检查 `ProtocolAuditTab.vue` 是否展示销售人员(若无销售人员字段则仅在有区域信息处补地区经理,或跳过)
|
||||
|
||||
- [ ] **Step 1:** 所有「销售人员」label →「市场专员」。
|
||||
|
||||
- [ ] **Step 2:** 查看弹窗在市场专员旁增加「地区经理」展示(`regionManagerUserName`,无则 —)。
|
||||
|
||||
- [ ] **Step 3:** 列表列 `label="销售人员"` → `市场专员`;不加地区经理列。
|
||||
|
||||
- [ ] **Step 4:** `DonationProtocolTab`:销售人员→市场专员;增加地区经理 kv(`row.regionManagerUserName`)。确认列表 API 已回填该字段(Task 3)。
|
||||
|
||||
- [ ] **Step 5:** 细胞存储审核卡片若当前无销售人员行,按需在采集信息块增加市场专员/地区经理(与捐赠卡片对齐);若产品仅捐赠卡片有销售人员,至少捐赠侧完整。
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 验证
|
||||
|
||||
- [ ] **Step 1: 后端测试**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate
|
||||
pytest tests/test_collection_regions_by_staff_user.py tests/test_collection_region_staff_helpers.py -v
|
||||
```
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
- [ ] **Step 2: 字段一致性(若脚本适用)**
|
||||
|
||||
```bash
|
||||
python scripts/check_field_consistency.py
|
||||
```
|
||||
|
||||
若脚本不覆盖这两张表,则人工确认 Create/Update/`_row_to_dict`/前端 form 含 `regionManagerUserId`。
|
||||
|
||||
- [ ] **Step 3: 手工冒烟**
|
||||
|
||||
1. 采集地区模块:用户 A 绑定地区 R1、R2;R1 设负责人 L1。
|
||||
2. 细胞存储新建协议:选市场专员 A → 地区默认 R1,下拉仅 R1/R2;地区经理为 L1;可改选。
|
||||
3. 改地区到 R2 → 经理变为 R2 负责人。
|
||||
4. 保存再编辑:三项均回显。
|
||||
5. 科研捐赠重复 2–4。
|
||||
6. 专员无绑定时:地区可全量检索,经理随地区带出。
|
||||
|
||||
- [ ] **Step 4: 图谱增量(改完后)**
|
||||
|
||||
```bash
|
||||
./scripts/update-graph.sh all
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Spec coverage checklist
|
||||
|
||||
| Spec 要求 | Task |
|
||||
|-----------|------|
|
||||
| 文案改名市场专员 | 4, 5, 6 |
|
||||
| 按专员带地区、多地区默认第一可改 | 2, 4, 5 |
|
||||
| 专员可检索全部用户 | 4, 5(保留 remote) |
|
||||
| 地区经理默认可改选并落库 | 1, 3, 4, 5 |
|
||||
| 细胞存储 + 科研捐赠一致 | 4, 5, 6 |
|
||||
| by-staff-user API | 2 |
|
||||
| 列表不加经理列、弹窗/卡片展示 | 6 |
|
||||
|
||||
## Self-review notes
|
||||
|
||||
- 无 TBD;路径与字段名 `regionManagerUserId` / `region_manager_user_id` 全文一致。
|
||||
- 路由静态段 `by-staff-user` 不与 `{region_id}` 冲突。
|
||||
- 编辑 hydrate 不得用联动覆盖已存经理(`preferKeepRegion` + 跳过初次 applyDefault)。
|
||||
@@ -0,0 +1,868 @@
|
||||
# 缴费信息 Excel 单列表 + 分改趸 + 催缴信息 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 将【缴费信息】改为 Excel 式单列表(基础信息 + 固定 1~6 期),支持分改趸 Tag 与协议单内 `fq→fgd` 并期,并新增【催缴信息】菜单(逾期 ∪ 未来 30 天待催期次)。
|
||||
|
||||
**Architecture:** 后端用纯函数计算「期摘要」与「分改趸并期」结果,CRUD/API 负责持久化与列表聚合;缴费信息前端去掉 4 Tab,宽表消费增强后的 `GET /sample-payments`;催缴独立 `GET /payment-chase`;写确认仍走现有 `payment-transfers`。协议单 `SamplePaymentPanel` 增加 `fgd`,保存时检测模式切换触发并期。
|
||||
|
||||
**Tech Stack:** MySQL 5.7 · FastAPI · SQLAlchemy · Vue 3 · Element Plus · pnpm · pytest/unittest
|
||||
|
||||
**Design spec:** `docs/superpowers/specs/2026-10-09-payment-info-excel-list-and-chase-design.md`
|
||||
**UI Demo:** `docs/demos/2026-10-08-payment-info-excel-list-demo.html`
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `backend/app/services/sample_payment_period_summary.py` | **新建** 单期摘要(实缴展示合计、摘要日期/方式/审核码) |
|
||||
| `backend/tests/test_sample_payment_period_summary.py` | **新建** 期摘要单测 |
|
||||
| `backend/app/services/sample_payment_fgd_convert.py` | **新建** `fq→fgd` 并期纯函数(输入 schedules+transfers 字典 → 输出变更指令) |
|
||||
| `backend/tests/test_sample_payment_fgd_convert.py` | **新建** 并期单测 |
|
||||
| `backend/app/crud/protocol_sample_payment.py` | 允许 `fgd`;保存时调用并期;列表补期摘要与协议/样本字段;催缴查询 |
|
||||
| `backend/app/api/protocol_sample_payment.py` | `GET /payment-chase`;保存路径透传 `fgd` |
|
||||
| `backend/tests/test_payment_chase_query.py` | **新建** 催缴纳入规则纯函数/查询辅助单测 |
|
||||
| `web-admin/src/utils/status-tag-registry.ts` | `payMode.fgd`;催缴状态 Tag(可选) |
|
||||
| `web-admin/src/api/protocol-sample-payment.ts` | 列表类型扩展;`fetchPaymentChaseList` |
|
||||
| `web-admin/src/views/cell-storage/protocol/SamplePaymentPanel.vue` | 分改趸选项 + 并期确认提示 |
|
||||
| `web-admin/src/utils/protocol-payment-presets.ts` / `protocol-sample-payment.ts` | `PayMode` 含 `fgd`;分期类判断不含误伤 |
|
||||
| `web-admin/src/views/cell-storage/payment-info/index.vue` | 去 Tab,挂载宽表 |
|
||||
| `web-admin/src/views/cell-storage/payment-info/PaymentExcelList.vue` | **新建** Excel 宽表 + 期弹窗 |
|
||||
| `web-admin/src/views/cell-storage/payment-chase/index.vue` | **新建** 催缴列表页 |
|
||||
| `web-admin/src/router/modules/sqxp.ts` | 注册 `payment-chase`,紧挨缴费信息之后 |
|
||||
| `backend/app/api/menu.py` | 静态菜单树插入催缴信息 |
|
||||
| `docs/sql/migrations/2026-10-09-payment-chase-menu.sql` | **新建** DB 菜单 + 角色授权 |
|
||||
| (可选保留)`PaymentBySampleTab.vue` / `PaymentInfoTab.vue` | 本页不再引用;勿删文件除非确认无其它入口 |
|
||||
|
||||
---
|
||||
|
||||
## 依赖顺序
|
||||
|
||||
```text
|
||||
Task 1 期摘要纯函数
|
||||
→ Task 2 分改趸并期纯函数
|
||||
→ Task 3 CRUD 允许 fgd + 保存并期落地
|
||||
→ Task 4 sample-payments 列表宽表字段
|
||||
→ Task 5 payment-chase API
|
||||
→ Task 6 前端 Tag + API
|
||||
→ Task 7 SamplePaymentPanel 分改趸
|
||||
→ Task 8 缴费信息 Excel 宽表页
|
||||
→ Task 9 催缴信息页 + 路由/菜单
|
||||
→ Task 10 菜单 SQL + 图谱 + 冒烟
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 期摘要纯函数
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/services/sample_payment_period_summary.py`
|
||||
- Create: `backend/tests/test_sample_payment_period_summary.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
```python
|
||||
"""单期展示摘要:实缴合计、日期、方式、财务审核码"""
|
||||
|
||||
from decimal import Decimal
|
||||
import unittest
|
||||
|
||||
from app.services.sample_payment_period_summary import summarize_period
|
||||
|
||||
|
||||
class TestPeriodSummary(unittest.TestCase):
|
||||
def test_empty_transfers(self):
|
||||
r = summarize_period(
|
||||
due_amount=Decimal("1000"),
|
||||
transfers=[],
|
||||
)
|
||||
self.assertEqual(r["displayPaidAmount"], Decimal("0"))
|
||||
self.assertEqual(r["payDate"], "")
|
||||
self.assertEqual(r["payTypeSummary"], "")
|
||||
self.assertEqual(r["financeAudit"], "dfj")
|
||||
|
||||
def test_multi_transfer_summary(self):
|
||||
r = summarize_period(
|
||||
due_amount=Decimal("5000"),
|
||||
transfers=[
|
||||
{
|
||||
"transferAmount": Decimal("3000"),
|
||||
"transferType": "wxzz",
|
||||
"transferTime": "2026-04-19 10:11:00",
|
||||
"payStatus": "yjf",
|
||||
},
|
||||
{
|
||||
"transferAmount": Decimal("2000"),
|
||||
"transferType": "yhkzz",
|
||||
"transferTime": "2026-04-20 09:40:00",
|
||||
"payStatus": "yjf",
|
||||
},
|
||||
],
|
||||
)
|
||||
self.assertEqual(r["displayPaidAmount"], Decimal("5000"))
|
||||
self.assertEqual(r["payDate"], "2026-04-20")
|
||||
self.assertEqual(r["payTypeSummary"], "多笔")
|
||||
self.assertEqual(r["financeAudit"], "yjf")
|
||||
|
||||
def test_confirming_wins(self):
|
||||
r = summarize_period(
|
||||
due_amount=Decimal("4960"),
|
||||
transfers=[
|
||||
{
|
||||
"transferAmount": Decimal("2000"),
|
||||
"transferType": "pos",
|
||||
"transferTime": "2026-06-18 15:20:00",
|
||||
"payStatus": "jfqrz",
|
||||
}
|
||||
],
|
||||
)
|
||||
self.assertEqual(r["displayPaidAmount"], Decimal("2000"))
|
||||
self.assertEqual(r["financeAudit"], "jfqrz")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify fail**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && python -m pytest tests/test_sample_payment_period_summary.py -v
|
||||
```
|
||||
|
||||
Expected: FAIL import error
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
```python
|
||||
# backend/app/services/sample_payment_period_summary.py
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, Iterable, List, Mapping
|
||||
|
||||
|
||||
def _dec(v: Any) -> Decimal:
|
||||
if v is None or v == "":
|
||||
return Decimal("0")
|
||||
return Decimal(str(v))
|
||||
|
||||
|
||||
def _status(row: Mapping[str, Any], *keys: str) -> str:
|
||||
for k in keys:
|
||||
if k in row and row[k] is not None:
|
||||
return str(row[k]).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def summarize_period(
|
||||
*,
|
||||
due_amount: Any,
|
||||
transfers: Iterable[Mapping[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
displayPaidAmount: yjf + jfqrz 合计(列表「实缴」展示)
|
||||
financeAudit: jfqrz > (paid>=due 且无确认中 → yjf) > (0<paid<due → partial) > dfj
|
||||
payDate: 最近一笔 transferTime 的日期部分
|
||||
payTypeSummary: 0 笔空;1 笔用该笔 type;多笔「多笔」
|
||||
"""
|
||||
trs: List[Mapping[str, Any]] = list(transfers or [])
|
||||
display_paid = sum(
|
||||
(
|
||||
_dec(t.get("transferAmount", t.get("transfer_amount")))
|
||||
for t in trs
|
||||
if _status(t, "payStatus", "pay_status") in ("yjf", "jfqrz")
|
||||
),
|
||||
Decimal("0"),
|
||||
)
|
||||
due = _dec(due_amount)
|
||||
has_jfqrz = any(_status(t, "payStatus", "pay_status") == "jfqrz" for t in trs)
|
||||
confirmed = sum(
|
||||
(
|
||||
_dec(t.get("transferAmount", t.get("transfer_amount")))
|
||||
for t in trs
|
||||
if _status(t, "payStatus", "pay_status") == "yjf"
|
||||
),
|
||||
Decimal("0"),
|
||||
)
|
||||
if has_jfqrz:
|
||||
audit = "jfqrz"
|
||||
elif due > 0 and confirmed >= due:
|
||||
audit = "yjf"
|
||||
elif confirmed > 0:
|
||||
audit = "partial"
|
||||
else:
|
||||
audit = "dfj"
|
||||
|
||||
def _time_key(t: Mapping[str, Any]) -> str:
|
||||
return str(t.get("transferTime") or t.get("transfer_time") or "")
|
||||
|
||||
latest = max(trs, key=_time_key) if trs else None
|
||||
pay_date = ""
|
||||
if latest:
|
||||
raw = _time_key(latest)
|
||||
pay_date = raw[:10] if len(raw) >= 10 else raw
|
||||
|
||||
types = [
|
||||
_status(t, "transferType", "transfer_type")
|
||||
for t in trs
|
||||
if _status(t, "transferType", "transfer_type")
|
||||
]
|
||||
if len(types) == 0:
|
||||
pay_type = ""
|
||||
elif len(set(types)) == 1 and len(types) == 1:
|
||||
pay_type = types[0]
|
||||
else:
|
||||
pay_type = "多笔"
|
||||
|
||||
return {
|
||||
"displayPaidAmount": display_paid,
|
||||
"payDate": pay_date,
|
||||
"payTypeSummary": pay_type,
|
||||
"financeAudit": audit,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests PASS**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && python -m pytest tests/test_sample_payment_period_summary.py -v
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**(仅当用户要求提交时执行)
|
||||
|
||||
```bash
|
||||
git add backend/app/services/sample_payment_period_summary.py backend/tests/test_sample_payment_period_summary.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat: add sample payment period summary helper for excel list cells
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 分改趸并期纯函数
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/services/sample_payment_fgd_convert.py`
|
||||
- Create: `backend/tests/test_sample_payment_fgd_convert.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
```python
|
||||
"""fq → fgd:软删后续期,剩余欠缴并入第一期未清期"""
|
||||
|
||||
from decimal import Decimal
|
||||
import unittest
|
||||
|
||||
from app.services.sample_payment_fgd_convert import plan_fq_to_fgd_merge
|
||||
|
||||
|
||||
class TestFgdConvert(unittest.TestCase):
|
||||
def test_merge_into_period_3(self):
|
||||
schedules = [
|
||||
{"id": "s1", "scheduleNo": 1, "dueAmount": Decimal("5000"), "scheduleStatus": "cleared"},
|
||||
{"id": "s2", "scheduleNo": 2, "dueAmount": Decimal("5000"), "scheduleStatus": "cleared"},
|
||||
{"id": "s3", "scheduleNo": 3, "dueAmount": Decimal("5000"), "scheduleStatus": "open"},
|
||||
{"id": "s4", "scheduleNo": 4, "dueAmount": Decimal("5000"), "scheduleStatus": "open"},
|
||||
{"id": "s5", "scheduleNo": 5, "dueAmount": Decimal("5000"), "scheduleStatus": "open"},
|
||||
{"id": "s6", "scheduleNo": 6, "dueAmount": Decimal("4800"), "scheduleStatus": "open"},
|
||||
]
|
||||
transfers = [
|
||||
{"scheduleId": "s1", "payStatus": "yjf", "transferAmount": Decimal("5000")},
|
||||
{"scheduleId": "s2", "payStatus": "yjf", "transferAmount": Decimal("5000")},
|
||||
]
|
||||
plan = plan_fq_to_fgd_merge(schedules=schedules, transfers=transfers)
|
||||
self.assertEqual(plan["targetScheduleId"], "s3")
|
||||
self.assertEqual(plan["newDueAmount"], Decimal("19800")) # 5000+5000+5000+4800
|
||||
self.assertEqual(set(plan["softDeleteScheduleIds"]), {"s4", "s5", "s6"})
|
||||
self.assertEqual(plan["newInstallmentCount"], 3)
|
||||
|
||||
def test_reject_when_jfqrz(self):
|
||||
schedules = [
|
||||
{"id": "s1", "scheduleNo": 1, "dueAmount": Decimal("1000"), "scheduleStatus": "open"},
|
||||
{"id": "s2", "scheduleNo": 2, "dueAmount": Decimal("1000"), "scheduleStatus": "open"},
|
||||
]
|
||||
transfers = [
|
||||
{"scheduleId": "s1", "payStatus": "jfqrz", "transferAmount": Decimal("500")},
|
||||
]
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
plan_fq_to_fgd_merge(schedules=schedules, transfers=transfers)
|
||||
self.assertIn("确认中", str(ctx.exception))
|
||||
|
||||
def test_reject_when_later_period_has_yjf(self):
|
||||
schedules = [
|
||||
{"id": "s1", "scheduleNo": 1, "dueAmount": Decimal("1000"), "scheduleStatus": "open"},
|
||||
{"id": "s2", "scheduleNo": 2, "dueAmount": Decimal("1000"), "scheduleStatus": "open"},
|
||||
]
|
||||
transfers = [
|
||||
{"scheduleId": "s2", "payStatus": "yjf", "transferAmount": Decimal("1000")},
|
||||
]
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
plan_fq_to_fgd_merge(schedules=schedules, transfers=transfers)
|
||||
self.assertIn("后续期", str(ctx.exception))
|
||||
```
|
||||
|
||||
说明:`newDueAmount` = 目标期已确认实缴 + 所有未清期欠缴之和。上例目标期无实缴,欠缴合计 5000+5000+5000+4800=19800。
|
||||
|
||||
- [ ] **Step 2: Run FAIL**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && python -m pytest tests/test_sample_payment_fgd_convert.py -v
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
```python
|
||||
# backend/app/services/sample_payment_fgd_convert.py
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Set
|
||||
|
||||
from app.services.protocol_transfer_status import schedule_paid_and_arrears
|
||||
|
||||
|
||||
def _dec(v: Any) -> Decimal:
|
||||
if v is None or v == "":
|
||||
return Decimal("0")
|
||||
return Decimal(str(v))
|
||||
|
||||
|
||||
def _status(row: Mapping[str, Any], *keys: str) -> str:
|
||||
for k in keys:
|
||||
if k in row and row[k] is not None:
|
||||
return str(row[k]).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def plan_fq_to_fgd_merge(
|
||||
*,
|
||||
schedules: Iterable[Mapping[str, Any]],
|
||||
transfers: Iterable[Mapping[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
sch_list = [
|
||||
s
|
||||
for s in schedules
|
||||
if _status(s, "scheduleStatus", "schedule_status") != "void"
|
||||
and not s.get("_deleted")
|
||||
]
|
||||
sch_list.sort(key=lambda s: int(s.get("scheduleNo") or s.get("schedule_no") or 0))
|
||||
tr_list = list(transfers or [])
|
||||
|
||||
if any(_status(t, "payStatus", "pay_status") == "jfqrz" for t in tr_list):
|
||||
raise ValueError("存在缴费确认中的转账,请先完成财务确认")
|
||||
|
||||
by_sch: Dict[str, List[Mapping[str, Any]]] = {}
|
||||
for t in tr_list:
|
||||
sid = str(t.get("scheduleId") or t.get("schedule_id") or "")
|
||||
by_sch.setdefault(sid, []).append(t)
|
||||
|
||||
def _arrears(s: Mapping[str, Any]) -> Decimal:
|
||||
sid = str(s.get("id") or "")
|
||||
confirmed = [
|
||||
_dec(t.get("transferAmount", t.get("transfer_amount")))
|
||||
for t in by_sch.get(sid, [])
|
||||
if _status(t, "payStatus", "pay_status") == "yjf"
|
||||
]
|
||||
_paid, arr = schedule_paid_and_arrears(
|
||||
due=s.get("dueAmount", s.get("due_amount")),
|
||||
confirmed_transfers=confirmed,
|
||||
)
|
||||
return _dec(arr)
|
||||
|
||||
target = None
|
||||
for s in sch_list:
|
||||
if _arrears(s) > 0 or _status(s, "scheduleStatus", "schedule_status") != "cleared":
|
||||
if _arrears(s) > 0:
|
||||
target = s
|
||||
break
|
||||
if _status(s, "scheduleStatus", "schedule_status") != "cleared":
|
||||
target = s
|
||||
break
|
||||
if target is None:
|
||||
raise ValueError("没有可合并的未清期次")
|
||||
|
||||
target_no = int(target.get("scheduleNo") or target.get("schedule_no") or 0)
|
||||
later = [
|
||||
s
|
||||
for s in sch_list
|
||||
if int(s.get("scheduleNo") or s.get("schedule_no") or 0) > target_no
|
||||
]
|
||||
for s in later:
|
||||
sid = str(s.get("id") or "")
|
||||
if any(
|
||||
_status(t, "payStatus", "pay_status") == "yjf" for t in by_sch.get(sid, [])
|
||||
):
|
||||
raise ValueError("后续期已有已确认转账,无法合并为分改趸")
|
||||
|
||||
remain = sum((_arrears(s) for s in sch_list), Decimal("0"))
|
||||
tid = str(target.get("id") or "")
|
||||
target_confirmed = sum(
|
||||
(
|
||||
_dec(t.get("transferAmount", t.get("transfer_amount")))
|
||||
for t in by_sch.get(tid, [])
|
||||
if _status(t, "payStatus", "pay_status") == "yjf"
|
||||
),
|
||||
Decimal("0"),
|
||||
)
|
||||
new_due = target_confirmed + remain
|
||||
soft_ids = [str(s.get("id") or "") for s in later]
|
||||
kept = [s for s in sch_list if str(s.get("id") or "") not in set(soft_ids)]
|
||||
|
||||
return {
|
||||
"targetScheduleId": tid,
|
||||
"newDueAmount": new_due,
|
||||
"softDeleteScheduleIds": soft_ids,
|
||||
"newInstallmentCount": len(kept),
|
||||
}
|
||||
```
|
||||
|
||||
若 `test_merge_into_period_3` 因「cleared 且欠缴=0 跳过」逻辑与 `_arrears` 口径不一致,以测试期望为准微调:目标期必须是第一期 `arrears>0` 的期。
|
||||
|
||||
- [ ] **Step 4: Run PASS**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && python -m pytest tests/test_sample_payment_fgd_convert.py -v
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**(用户要求时)
|
||||
|
||||
---
|
||||
|
||||
### Task 3: CRUD 允许 fgd + 并期落地
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/crud/protocol_sample_payment.py`(`mode not in (...)` 加入 `fgd`;在 `_save_sample_payments` / `upsert` 路径于 `pay_mode` 从 `fq` 变为 `fgd` 时调用并期并写库)
|
||||
- Modify: `backend/app/api/protocol_sample_payment.py`(Pydantic `pay_mode` 校验若有枚举则含 `fgd`)
|
||||
|
||||
- [ ] **Step 1: 放宽枚举**
|
||||
|
||||
将:
|
||||
|
||||
```python
|
||||
if mode not in ("dj", "fq", "djfq"):
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```python
|
||||
if mode not in ("dj", "fq", "djfq", "fgd"):
|
||||
```
|
||||
|
||||
API Body / Schema 中同类校验一并改。
|
||||
|
||||
- [ ] **Step 2: 增加应用并期函数**
|
||||
|
||||
在 `protocol_sample_payment.py` 增加(示意):
|
||||
|
||||
```python
|
||||
def apply_fq_to_fgd_merge(
|
||||
db: Session,
|
||||
*,
|
||||
sample_payment_id: str,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str],
|
||||
) -> None:
|
||||
assert_sample_payment_editable(db, sample_payment_id=sample_payment_id, tenant_no=tenant_no)
|
||||
sp = _get_sample_payment_or_raise(db, sample_payment_id=sample_payment_id, tenant_no=tenant_no)
|
||||
schedules = list_active_schedules(...) # 现有查询未软删未 void
|
||||
transfers = list_transfers_for_schedules(...)
|
||||
plan = plan_fq_to_fgd_merge(
|
||||
schedules=[_schedule_to_dict(...) for ...], # 或直接用 ORM 字段拼 dict
|
||||
transfers=[...],
|
||||
)
|
||||
# 更新 target.due_amount = plan["newDueAmount"]
|
||||
# 软删 softDeleteScheduleIds 及其 dfj 转账
|
||||
# sp.pay_mode = "fgd"; sp.installment_count = plan["newInstallmentCount"]
|
||||
db.flush()
|
||||
```
|
||||
|
||||
在保存样本方案循环中:若库中原 `pay_mode=="fq"` 且请求 `pay_mode=="fgd"`,先 `apply_fq_to_fgd_merge`,再保存其余字段;**不要**对已缴期调用盲目 `replace_schedules` 全量重建。
|
||||
|
||||
- [ ] **Step 3: 手工或集成测**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && python -m pytest tests/test_sample_payment_fgd_convert.py tests/test_sample_payment_period_summary.py -v
|
||||
```
|
||||
|
||||
若已有保存相关测试,补一条「mock DB 调用 apply」或轻量集成测。
|
||||
|
||||
- [ ] **Step 4: Commit**(用户要求时)
|
||||
|
||||
---
|
||||
|
||||
### Task 4: `GET /sample-payments` 宽表字段
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/crud/protocol_sample_payment.py` → `list_sample_payments_page` 行字典
|
||||
- Modify: `backend/app/api/protocol_sample_payment.py`(若需)
|
||||
|
||||
- [ ] **Step 1: 扩展列表行字段**
|
||||
|
||||
每行在现有摘要上增加:
|
||||
|
||||
```python
|
||||
{
|
||||
# 已有: id, protocolNo, motherName, motherPhone, sampleCode, payMode,
|
||||
# contractAmount, paidAmount, dueRemainAmount, progressStatus, canConfirm, ...
|
||||
"signDate": "...", # 协议签署日
|
||||
"storageYears": "...", # 样本存储年限
|
||||
"sampleTypeName": "...", # 存储项目
|
||||
"discountAmount": None, # 无字段则 null
|
||||
"receivableAmount": float(...), # 有效期 due 合计
|
||||
"remark": "...",
|
||||
"periods": [
|
||||
# 长度固定 6;无期用 null
|
||||
{
|
||||
"scheduleId": "s3",
|
||||
"scheduleNo": 3,
|
||||
"dueAmount": 5000.0,
|
||||
"displayPaidAmount": 2000.0,
|
||||
"payDate": "2026-06-18",
|
||||
"payTypeSummary": "pos", # 或字典 label 由前端转
|
||||
"financeAudit": "jfqrz",
|
||||
},
|
||||
None,
|
||||
...
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
实现要点:
|
||||
|
||||
- 批量加载 schedules + transfers,用 `summarize_period` 填 `periods[scheduleNo-1]`(`scheduleNo` 1..6)。
|
||||
- 批量 JOIN/查询协议签署日、样本 `storage_years`、样本类型名、当事人手机(避免 N+1)。
|
||||
- `payMode=fgd` 可筛选。
|
||||
|
||||
- [ ] **Step 2: 详情接口**保持完整 `schedules[].transfers`,供弹窗使用(已有则只补缺失展示字段)。
|
||||
|
||||
- [ ] **Step 3: 跑相关单测 / 手工打一条 list 响应检查 `periods` 长度恒为 6**
|
||||
|
||||
- [ ] **Step 4: Commit**(用户要求时)
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 催缴 API
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/services/payment_chase.py`(纳入规则纯函数,便于测)
|
||||
- Create: `backend/tests/test_payment_chase.py`
|
||||
- Modify: `backend/app/crud/protocol_sample_payment.py` → `list_payment_chase_page`
|
||||
- Modify: `backend/app/api/protocol_sample_payment.py` → `GET /payment-chase`
|
||||
|
||||
- [ ] **Step 1: 纯函数单测**
|
||||
|
||||
```python
|
||||
from datetime import date
|
||||
import unittest
|
||||
|
||||
from app.services.payment_chase import is_chase_candidate
|
||||
|
||||
|
||||
class TestChase(unittest.TestCase):
|
||||
def test_overdue(self):
|
||||
self.assertTrue(
|
||||
is_chase_candidate(
|
||||
due_date=date(2026, 9, 1),
|
||||
today=date(2026, 10, 9),
|
||||
arrears=Decimal("100"),
|
||||
)
|
||||
)
|
||||
|
||||
def test_within_30(self):
|
||||
self.assertTrue(
|
||||
is_chase_candidate(
|
||||
due_date=date(2026, 11, 1),
|
||||
today=date(2026, 10, 9),
|
||||
arrears=Decimal("100"),
|
||||
)
|
||||
)
|
||||
|
||||
def test_beyond_30(self):
|
||||
self.assertFalse(
|
||||
is_chase_candidate(
|
||||
due_date=date(2026, 11, 10),
|
||||
today=date(2026, 10, 9),
|
||||
arrears=Decimal("100"),
|
||||
)
|
||||
)
|
||||
|
||||
def test_zero_arrears(self):
|
||||
self.assertFalse(
|
||||
is_chase_candidate(
|
||||
due_date=date(2026, 9, 1),
|
||||
today=date(2026, 10, 9),
|
||||
arrears=Decimal("0"),
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
(`from decimal import Decimal` 写入测试文件顶部。)
|
||||
|
||||
- [ ] **Step 2: 实现 `is_chase_candidate`**
|
||||
|
||||
```python
|
||||
# backend/app/services/payment_chase.py
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _dec(v: Any) -> Decimal:
|
||||
if v is None or v == "":
|
||||
return Decimal("0")
|
||||
return Decimal(str(v))
|
||||
|
||||
|
||||
def is_chase_candidate(*, due_date: date, today: date, arrears: Any) -> bool:
|
||||
if _dec(arrears) <= 0:
|
||||
return False
|
||||
if due_date < today:
|
||||
return True
|
||||
return today <= due_date <= today + timedelta(days=30)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: CRUD 分页**
|
||||
|
||||
`list_payment_chase_page(db, tenant_no, user, page, pageSize, keyword, chaseType, payMode, salesUserId)`:
|
||||
|
||||
- 主查有效 schedule(未软删、非 void)JOIN sample_payment + protocol + party + collection_info(市场专员)。
|
||||
- SQL 侧尽量用 `due_date < today OR (due_date BETWEEN today AND today+30)`,欠缴在内存或子查询用已确认转账计算(与现 schedule 欠缴口径一致)。
|
||||
- `chaseType`: `overdue` | `upcoming` | 空=全部。
|
||||
- 排序:逾期天数 DESC,`due_date` ASC,`protocol_no`。
|
||||
- `apply_data_scope` 按协议。
|
||||
|
||||
行字段:`scheduleId`, `samplePaymentId`, `protocolId`, `protocolNo`, `motherName`, `motherPhone`, `sampleCode`, `salesUserName`, `payMode`, `scheduleNo`, `dueDate`, `dueAmount`, `paidAmount`, `arrearsAmount`, `chaseStatus`(`overdue`|`upcoming`), `daysOverdue` / `daysUntilDue`, `protocolStatus`。
|
||||
|
||||
- [ ] **Step 4: 路由**(注意放在 `/sample-payments/{id}` 之前或使用静态路径 `/payment-chase` 避免冲突)
|
||||
|
||||
```python
|
||||
@router.get("/payment-chase")
|
||||
async def list_payment_chase(...):
|
||||
...
|
||||
```
|
||||
|
||||
- [ ] **Step 5: pytest PASS + Commit**(用户要求时)
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 前端 Tag + API
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/utils/status-tag-registry.ts`
|
||||
- Modify: `web-admin/src/api/protocol-sample-payment.ts`
|
||||
- Modify: `web-admin/src/utils/protocol-payment-presets.ts`(`PayMode` 含 `fgd`;`isInstallmentPayMode`:**不要**把 `fgd` 当普通分期 UI,除非产品要同一套期数表单项——建单 `fgd` 需要期数配置时单独 `isMultiSchedulePayMode = fq|djfq|fgd`)
|
||||
|
||||
- [ ] **Step 1: Tag**
|
||||
|
||||
```ts
|
||||
export const payMode: StatusRegistry = {
|
||||
dj: { label: '趸交', tagType: 'primary' },
|
||||
fq: { label: '分期', tagType: 'warning' },
|
||||
djfq: { label: '趸交+分期', tagType: 'warning' },
|
||||
fgd: { label: '分改趸', tagType: 'warning' }
|
||||
}
|
||||
|
||||
export const paymentChaseStatus: StatusRegistry = {
|
||||
overdue: { label: '已逾期', tagType: 'danger' },
|
||||
upcoming: { label: '即将到期', tagType: 'warning' }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: API**
|
||||
|
||||
```ts
|
||||
export function fetchPaymentChaseList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
chaseType?: string
|
||||
payMode?: string
|
||||
salesUserId?: string
|
||||
}) {
|
||||
return request.get<PageList<Record<string, unknown>>>({
|
||||
url: '/web/admin/payment-chase',
|
||||
params
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**(用户要求时)
|
||||
|
||||
---
|
||||
|
||||
### Task 7: SamplePaymentPanel 分改趸
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/cell-storage/protocol/SamplePaymentPanel.vue`
|
||||
- 同步只读展示处:`payMode === 'fq' ? '分期' : '趸交'` 等硬编码改为 `payModeLabel`
|
||||
|
||||
- [ ] **Step 1: 单选增加分改趸**
|
||||
|
||||
```vue
|
||||
<el-radio-group :model-value="pay.payMode" @change="(v) => onPayModeChange(pay, v)">
|
||||
<el-radio value="dj">趸交</el-radio>
|
||||
<el-radio value="fq">分期</el-radio>
|
||||
<el-radio value="fgd">分改趸</el-radio>
|
||||
</el-radio-group>
|
||||
```
|
||||
|
||||
(若双样本仍有 `djfq`,保持现有入口,不删除。)
|
||||
|
||||
- [ ] **Step 2: `fq` → `fgd` 确认**
|
||||
|
||||
在 `onPayModeChange` 中:若原值 `fq` 且新值 `fgd`,`ElMessageBox.confirm('改为分改趸将合并后续未缴期至当前未清期,是否继续?')`,取消则回滚。
|
||||
|
||||
- [ ] **Step 3: 表单字段**
|
||||
|
||||
`fgd` 显示期数/应缴配置(与分期相同的多期表单项),趸交仍单期。
|
||||
|
||||
- [ ] **Step 4: 浏览器冒烟** — 编辑协议改分改趸保存成功。
|
||||
|
||||
- [ ] **Step 5: Commit**(用户要求时)
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 缴费信息 Excel 宽表页
|
||||
|
||||
**Files:**
|
||||
- Create: `web-admin/src/views/cell-storage/payment-info/PaymentExcelList.vue`
|
||||
- Modify: `web-admin/src/views/cell-storage/payment-info/index.vue`
|
||||
- 对照 Demo:`docs/demos/2026-10-08-payment-info-excel-list-demo.html`
|
||||
- 复用:`PaymentReceiptConfirmDialog`、`ProtocolViewDialog`、`fetchSamplePaymentList` / `fetchSamplePaymentDetail`
|
||||
|
||||
- [ ] **Step 1: 重写 `index.vue`**
|
||||
|
||||
去掉 4 个 Pill Tab 与 `PaymentInfoTab` / `PaymentBySampleTab` 引用;仅渲染:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<section class="panel">
|
||||
<div class="panel-top">
|
||||
<h2 class="title-bar-heading">缴费信息</h2>
|
||||
</div>
|
||||
<PaymentExcelList />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
(若 `App.vue` `panelTitleBarTextRules` 需登记「缴费信息」则补上。)
|
||||
|
||||
- [ ] **Step 2: 实现 `PaymentExcelList.vue`**
|
||||
|
||||
- 筛选区对齐现按样本筛选项 + `fgd`。
|
||||
- `el-table` 或原生表:双行表头(基础 / 第1~6期 / 汇总);期列背景色参考 Demo CSS 变量。
|
||||
- `periods[i].displayPaidAmount` 用链接按钮;点击 → `fetchSamplePaymentDetail` → 取对应 `schedule.transfers` 打开弹窗(可内嵌简单转账列表 + 底部缴费确认)。
|
||||
- 左侧冻结列:用 `el-table` `fixed` 或容器 `overflow:auto` + sticky(与 Demo 一致即可)。
|
||||
- `?tab=` 旧参数:忽略或映射 `progressStatus` 筛选(spec §7)。
|
||||
|
||||
- [ ] **Step 3: 样式** — scoped SCSS,色值用 Fresh Mint + 期次浅色底,勿用小程序绿。
|
||||
|
||||
- [ ] **Step 4: 本地打开 `/#/market/cell-storage/payment-info` 冒烟**(横向滚动、点实缴、确认弹窗)。
|
||||
|
||||
- [ ] **Step 5: Commit**(用户要求时)
|
||||
|
||||
---
|
||||
|
||||
### Task 9: 催缴信息页 + 路由
|
||||
|
||||
**Files:**
|
||||
- Create: `web-admin/src/views/cell-storage/payment-chase/index.vue`
|
||||
- Modify: `web-admin/src/router/modules/sqxp.ts`
|
||||
- Modify: `backend/app/api/menu.py`
|
||||
|
||||
- [ ] **Step 1: 路由**(紧接 `payment-info` 之后)
|
||||
|
||||
```ts
|
||||
{
|
||||
name: 'CellStoragePaymentChase',
|
||||
path: 'payment-chase',
|
||||
component: '/cell-storage/payment-chase/index',
|
||||
meta: {
|
||||
title: '催缴信息',
|
||||
keepAlive: true,
|
||||
menuCode: 'cellStorage:paymentChase:list'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `menu.py` 静态树**在缴费信息节点后插入同结构项(`path: payment-chase`,`menuCode: cellStorage:paymentChase:list`,新 id 如 `50000000000000000000000000000022`)。
|
||||
|
||||
- [ ] **Step 3: 列表页**对齐 `unit/order`:筛选 + `list-table` + 分页;列见 spec §4;催缴状态用 `paymentChaseStatus` Tag;操作「协议单」打开 `ProtocolViewDialog`;可选「缴费信息」`router.push({ name: 'CellStoragePaymentInfo', query: { samplePaymentId } })`。
|
||||
|
||||
- [ ] **Step 4: 冒烟** `/#/market/cell-storage/payment-chase`
|
||||
|
||||
- [ ] **Step 5: Commit**(用户要求时)
|
||||
|
||||
---
|
||||
|
||||
### Task 10: 菜单 SQL + 图谱 + 回归
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/sql/migrations/2026-10-09-payment-chase-menu.sql`
|
||||
- Run: `./scripts/update-graph.sh all`(或 backend + frontend)
|
||||
|
||||
- [ ] **Step 1: SQL**(幂等 INSERT,parent 与缴费信息相同;`order_num` 介于缴费信息与样本信息之间;授权给已有 `cellStorage:paymentInfo:list` 的角色)
|
||||
|
||||
参考 `docs/sql/migrations/2026-10-05-presign-order-menu.sql` 写法,perms 使用 `cellStorage:paymentChase:list`(与前端 `menuCode` 一致)。
|
||||
|
||||
- [ ] **Step 2: 后端测试**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && python -m pytest \
|
||||
tests/test_sample_payment_period_summary.py \
|
||||
tests/test_sample_payment_fgd_convert.py \
|
||||
tests/test_payment_chase.py \
|
||||
tests/test_sample_payment_progress.py -v
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 前端**
|
||||
|
||||
```bash
|
||||
cd web-admin && pnpm lint
|
||||
```
|
||||
|
||||
(有时间再 `pnpm build:typecheck`。)
|
||||
|
||||
- [ ] **Step 4: 更新图谱**
|
||||
|
||||
```bash
|
||||
./scripts/update-graph.sh all
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 对照 spec 勾选验收** — Excel 宽表、分改趸建单/并期、催缴纳入规则、一期多笔弹窗。
|
||||
|
||||
---
|
||||
|
||||
## Spec 覆盖自检
|
||||
|
||||
| Spec 要求 | Task |
|
||||
|-----------|------|
|
||||
| 缴费信息去 4 Tab → Excel 6 期 | 8 |
|
||||
| 点实缴看转账/审核 | 8(依赖 4 详情) |
|
||||
| `fgd` Tag | 3, 6, 7 |
|
||||
| 建单可选分改趸 | 7 |
|
||||
| `fq→fgd` 并期软删后续期 | 2, 3 |
|
||||
| 并期仅协议单 | 7(缴费页不提供) |
|
||||
| 催缴菜单 + 逾期∪30天 | 5, 9, 10 |
|
||||
| 催缴一行一期 | 5, 9 |
|
||||
| data scope | 4, 5 |
|
||||
| 保留 `djfq` | 3, 7 |
|
||||
| Demo 对齐 | 8 |
|
||||
|
||||
**占位符扫描:** 无 TBD;Commit 步骤标注「仅用户要求时」以符合仓库提交约定。
|
||||
|
||||
---
|
||||
|
||||
## Execution Handoff
|
||||
|
||||
Plan complete and saved to `docs/superpowers/plans/2026-10-09-payment-info-excel-list-and-chase.md`.
|
||||
|
||||
**Two execution options:**
|
||||
|
||||
1. **Subagent-Driven(推荐)** — 每个 Task 开新子代理,Task 间复查
|
||||
2. **Inline Execution** — 本会话按 `executing-plans` 连续做,设检查点
|
||||
|
||||
Which approach?
|
||||
@@ -0,0 +1,286 @@
|
||||
# 缴费期次列口径 + 应缴批次可编辑 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 缴费信息宽表每期展示应缴金额/应缴时间/已缴金额/已缴时间/欠缴金额;协议单按样本缴费支持单期改应缴金额与时间,且保存时各期合计必须等于合同金额。
|
||||
|
||||
**Architecture:** 期摘要逻辑集中在 `summarize_period`;列表 `_build_periods_array` 透出 `dueDate`/`arrearsAmount`;保存校验抽成纯函数供 CRUD 与前端共用思路;`SamplePaymentPanel` 单期编辑不触发 `rebuildSchedules`。
|
||||
|
||||
**Tech Stack:** FastAPI / SQLAlchemy / unittest;Vue 3 + Element Plus;现有 `PaymentExcelList` / `SamplePaymentPanel`。
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-10-09-payment-period-columns-and-schedule-edit-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
| File | Responsibility |
|
||||
|------|----------------|
|
||||
| `backend/app/services/sample_payment_period_summary.py` | 已缴含 dfj;首笔 payDate;arrearsAmount |
|
||||
| `backend/tests/test_sample_payment_period_summary.py` | 期摘要单测 |
|
||||
| `backend/app/services/sample_payment_schedule_validate.py` | 新建:合同金额 vs 各期应缴合计校验 |
|
||||
| `backend/tests/test_sample_payment_schedule_validate.py` | 校验单测 |
|
||||
| `backend/app/crud/protocol_sample_payment.py` | periods 字段;save 时调校验 |
|
||||
| `web-admin/.../PaymentExcelList.vue` | 宽表 5 子列 |
|
||||
| `web-admin/.../SamplePaymentPanel.vue` | 期次可编辑 |
|
||||
| `web-admin/src/utils/protocol-sample-payment.ts` | 前端合计校验 helper |
|
||||
| `web-admin/.../ProtocolFormDialog.vue` | 提交前硬拦 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 期摘要口径(TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/services/sample_payment_period_summary.py`
|
||||
- Modify: `backend/tests/test_sample_payment_period_summary.py`
|
||||
|
||||
- [x] **Step 1: 扩展失败用例**
|
||||
|
||||
在 `test_sample_payment_period_summary.py` 增加/改写:
|
||||
|
||||
```python
|
||||
def test_dfj_counts_as_display_paid(self):
|
||||
r = summarize_period(
|
||||
due_amount=Decimal("1000"),
|
||||
transfers=[
|
||||
{
|
||||
"transferAmount": Decimal("400"),
|
||||
"transferType": "wxzz",
|
||||
"transferTime": "2026-04-19 10:00:00",
|
||||
"payStatus": "dfj",
|
||||
}
|
||||
],
|
||||
)
|
||||
self.assertEqual(r["displayPaidAmount"], Decimal("400"))
|
||||
self.assertEqual(r["arrearsAmount"], Decimal("600"))
|
||||
self.assertEqual(r["payDate"], "2026-04-19")
|
||||
|
||||
def test_pay_date_is_first_transfer_not_latest(self):
|
||||
r = summarize_period(
|
||||
due_amount=Decimal("5000"),
|
||||
transfers=[
|
||||
{
|
||||
"transferAmount": Decimal("3000"),
|
||||
"transferType": "wxzz",
|
||||
"transferTime": "2026-04-19 10:11:00",
|
||||
"payStatus": "yjf",
|
||||
},
|
||||
{
|
||||
"transferAmount": Decimal("2000"),
|
||||
"transferType": "yhkzz",
|
||||
"transferTime": "2026-04-20 09:40:00",
|
||||
"payStatus": "yjf",
|
||||
},
|
||||
],
|
||||
)
|
||||
self.assertEqual(r["payDate"], "2026-04-19") # 首笔,非 04-20
|
||||
self.assertEqual(r["arrearsAmount"], Decimal("0"))
|
||||
```
|
||||
|
||||
并把原 `test_multi_transfer_summary` 的 `payDate` 期望从 `2026-04-20` 改为 `2026-04-19`;`test_empty_transfers` 断言 `arrearsAmount == Decimal("1000")`。
|
||||
|
||||
- [ ] **Step 2: 跑测确认失败**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && pytest tests/test_sample_payment_period_summary.py -v
|
||||
```
|
||||
|
||||
Expected: FAIL(旧逻辑取最晚;缺 arrears;dfj 未计入)
|
||||
|
||||
- [ ] **Step 3: 实现 `summarize_period`**
|
||||
|
||||
- `displayPaidAmount`:`pay_status in ("dfj", "jfqrz", "yjf")`
|
||||
- `payDate`:`trs[0]` 的时间日期部分(调用方已按 `create_time ASC` 传入)
|
||||
- `arrearsAmount`:`max(due - display_paid, 0)`
|
||||
- 保留 `payTypeSummary` / `financeAudit`(financeAudit 仍仅看 yjf/jfqrz 语义不变)
|
||||
|
||||
- [ ] **Step 4: 跑测通过**
|
||||
|
||||
```bash
|
||||
pytest tests/test_sample_payment_period_summary.py -v
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
---
|
||||
|
||||
### Task 2: periods 数组透出 dueDate / arrearsAmount
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/crud/protocol_sample_payment.py` → `_build_periods_array`
|
||||
|
||||
- [ ] **Step 1: 在 periods 元素中增加字段**
|
||||
|
||||
```python
|
||||
periods.append(
|
||||
{
|
||||
"scheduleId": s.id,
|
||||
"scheduleNo": no,
|
||||
"dueAmount": float(s.due_amount) if s.due_amount is not None else None,
|
||||
"dueDate": _fmt_ymd8(s.due_date),
|
||||
"displayPaidAmount": float(summary["displayPaidAmount"]),
|
||||
"arrearsAmount": float(summary["arrearsAmount"]),
|
||||
"payDate": summary.get("payDate") or "",
|
||||
"payTypeSummary": summary.get("payTypeSummary") or "",
|
||||
"financeAudit": summary.get("financeAudit") or "dfj",
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 手动/现有列表接口冒烟**(有环境时);无环境则依赖 Task 1 单测 + 前端联调。
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 保存校验纯函数(TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/services/sample_payment_schedule_validate.py`
|
||||
- Create: `backend/tests/test_sample_payment_schedule_validate.py`
|
||||
- Modify: `backend/app/crud/protocol_sample_payment.py` → `save_sample_payments_for_protocol`
|
||||
|
||||
- [ ] **Step 1: 写失败测试**
|
||||
|
||||
```python
|
||||
from decimal import Decimal
|
||||
import unittest
|
||||
from app.services.sample_payment_schedule_validate import (
|
||||
schedule_dues_match_contract,
|
||||
schedule_dues_mismatch_message,
|
||||
)
|
||||
|
||||
class TestScheduleValidate(unittest.TestCase):
|
||||
def test_match(self):
|
||||
ok, msg = schedule_dues_match_contract(
|
||||
contract_amount=Decimal("1000"),
|
||||
schedules=[{"dueAmount": 600}, {"dueAmount": 400}],
|
||||
)
|
||||
self.assertTrue(ok)
|
||||
self.assertIsNone(msg)
|
||||
|
||||
def test_mismatch(self):
|
||||
ok, msg = schedule_dues_match_contract(
|
||||
contract_amount=Decimal("1000"),
|
||||
schedules=[{"dueAmount": 600}, {"dueAmount": 300}],
|
||||
)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("900", msg or "")
|
||||
self.assertIn("1000", msg or "")
|
||||
|
||||
def test_skip_void(self):
|
||||
ok, _ = schedule_dues_match_contract(
|
||||
contract_amount=Decimal("500"),
|
||||
schedules=[
|
||||
{"dueAmount": 500, "scheduleStatus": "open"},
|
||||
{"dueAmount": 200, "scheduleStatus": "void"},
|
||||
],
|
||||
)
|
||||
self.assertTrue(ok)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测 FAIL → 实现 → PASS**
|
||||
|
||||
实现要点:
|
||||
|
||||
```python
|
||||
def schedule_dues_match_contract(*, contract_amount, schedules, tol=Decimal("0.015")):
|
||||
# 排除 schedule_status == void;Σ dueAmount 与 contract 比较
|
||||
# 返回 (True, None) 或 (False, "样本合同金额 … 与各期应缴合计 … 不一致,请调整后再保存")
|
||||
```
|
||||
|
||||
在 `save_sample_payments_for_protocol` 循环内、`sync_schedules_and_transfers` 之前(fgd merge 分支除外或 merge 后亦校验):
|
||||
|
||||
```python
|
||||
ok, msg = schedule_dues_match_contract(
|
||||
contract_amount=raw.get("contract_amount", raw.get("contractAmount")),
|
||||
schedules=raw.get("schedules") or [],
|
||||
)
|
||||
if not ok:
|
||||
raise ValueError(msg)
|
||||
```
|
||||
|
||||
fgd 并期后跳过前端 schedules 同步时:并期后库内数据由服务保证;可对 merge 后的 target 再读库校验,或并期路径单独算。**最小实现:** 仅对走 `sync_schedules_and_transfers` 的路径校验 payload;`fq→fgd` continue 前不校验 payload(并期改 due)。
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 前端宽表列
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/cell-storage/payment-info/PaymentExcelList.vue`
|
||||
|
||||
- [ ] **Step 1: 替换第 N 期子列模板**
|
||||
|
||||
五列:应缴费金额(元)、应缴费时间、已缴费金额(可点)、已缴费时间、欠缴费金额。
|
||||
|
||||
绑定:
|
||||
|
||||
- 应缴金额 → `periodDue`
|
||||
- 应缴时间 → `periodField(..., 'dueDate')` 或专用 formatter
|
||||
- 已缴费金额 → `periodPaid`(`displayPaidAmount`)
|
||||
- 已缴费时间 → `periodField(..., 'payDate')`
|
||||
- 欠缴 → `periodField` / `formatAmount(arrearsAmount)`
|
||||
|
||||
- [ ] **Step 2: 扩展 `PeriodCell` 类型** 含 `dueDate?`、`arrearsAmount?`;移除对支付方式/财务审核列的依赖(弹窗保留)。
|
||||
|
||||
---
|
||||
|
||||
### Task 5: SamplePaymentPanel 单期可编辑
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/cell-storage/protocol/SamplePaymentPanel.vue`
|
||||
|
||||
- [ ] **Step 1: 期头增加控件**
|
||||
|
||||
在 `schedule-block__head` 旁或下方增加一行表单:
|
||||
|
||||
- `AmountInput` → `sch.dueAmount`,`@update` 调 `patchSchedule(pIdx, sIdx, { dueAmount })`(**rebuild=false**)
|
||||
- `el-date-picker` → `sch.dueDate`,`value-format="YYYYMMDD"`
|
||||
- `:disabled="isScheduleDueLocked(sch)"`:`(sch.transfers||[]).some(t => t.payStatus==='yjf')`
|
||||
|
||||
- [ ] **Step 2: `patchSchedule` 只改该期,不 `rebuildSchedules`**
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 前端保存硬拦
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/utils/protocol-sample-payment.ts`
|
||||
- Modify: `web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue`
|
||||
|
||||
- [ ] **Step 1: helper**
|
||||
|
||||
```typescript
|
||||
export function sampleScheduleDuesMismatchHint(pay: SamplePaymentLike): string | null {
|
||||
const contract = Number(pay.contractAmount)
|
||||
if (!Number.isFinite(contract)) return null
|
||||
const schedules = (pay.schedules || []).filter(
|
||||
(s) => String((s as any).scheduleStatus || 'open') !== 'void'
|
||||
)
|
||||
const sum = schedules.reduce((s, x) => s + (Number(x.dueAmount) || 0), 0)
|
||||
if (Math.abs(contract - sum) > 0.015) {
|
||||
return `样本 ${pay.sampleCode || ''} 合同金额 ${contract.toFixed(2)} 与各期应缴合计 ${sum.toFixed(2)} 不一致,请调整后再保存`
|
||||
}
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `handleSubmit` v2 分支** 对每个 `samplePayments` 项调用;有 hint 则 `ElMessageBox.alert` / `ElMessage.warning` 并 `return`(硬拦)。
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 验证与图谱
|
||||
|
||||
- [ ] `pytest tests/test_sample_payment_period_summary.py tests/test_sample_payment_schedule_validate.py -v`
|
||||
- [ ] 前端相关文件无 lint 明显错误
|
||||
- [ ] `./scripts/update-graph.sh backend`(若改了 backend 模块结构)
|
||||
|
||||
---
|
||||
|
||||
## Spec coverage
|
||||
|
||||
| Spec | Task |
|
||||
|------|------|
|
||||
| §2 五列口径 | 1, 2, 4 |
|
||||
| §2.2 summarize_period | 1 |
|
||||
| §3.1 单期可编辑 / yjf 只读 / 不重建 | 5 |
|
||||
| §3.3 前后端硬拦 | 3, 6 |
|
||||
| §5 测试 | 1, 3, 7 |
|
||||
@@ -0,0 +1,61 @@
|
||||
# 优惠价驱动缴费金额 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 废除采集协议单价目表默认金额;样本合同/合计/协议总默认均由优惠价驱动;分期金额手填且保存强校验。
|
||||
|
||||
**Architecture:** 纯函数在 `protocol-sample-payment.ts` 解析优惠价与校验;`SamplePaymentPanel` 生成空金额期次;`ProtocolFormDialog` 去掉 `resolvePaymentPreset` 套用;后端 schedule 校验补齐每期金额与应缴日必填。
|
||||
|
||||
**Tech Stack:** Vue 3 + TypeScript(web-admin)、FastAPI(backend)、node:test / pytest
|
||||
|
||||
**Design spec:** `docs/superpowers/specs/2026-10-09-protocol-preferential-price-payment-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
| File | Responsibility |
|
||||
|------|----------------|
|
||||
| `web-admin/src/utils/protocol-sample-payment.ts` | 优惠价解析、合计、分期必填校验文案;删除硬编码 preset |
|
||||
| `web-admin/src/utils/protocol-sample-payment.test.ts` | 单测 |
|
||||
| `web-admin/src/utils/protocol-payment-presets.ts` | 删除 PRESETS / resolvePaymentPreset |
|
||||
| `web-admin/src/views/cell-storage/protocol/SamplePaymentPanel.vue` | 新建/改价/分期空金额 |
|
||||
| `web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue` | 去掉价目套用;保存调新校验 |
|
||||
| `backend/app/services/sample_payment_schedule_validate.py` | 各期金额+应缴日必填 |
|
||||
| `backend/tests/test_sample_payment_schedule_validate.py` | 后端单测 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 工具函数与单测(去价目、分期必填)
|
||||
|
||||
- [x] 改写 `resolveSampleContractAmount`:仅优惠价,无则 `null`(不再回落 22880)
|
||||
- [x] 删除或掏空 `resolvePerSamplePreset` 硬编码
|
||||
- [x] 新增 `sampleScheduleRequiredFieldsHint(pay)`:分期每期金额与 dueDate
|
||||
- [x] 更新 `protocol-sample-payment.test.ts`;`node --import tsx --test` 通过
|
||||
|
||||
### Task 2: SamplePaymentPanel
|
||||
|
||||
- [x] `emptyDjPayment` / 改价同步:合同 = 优惠价
|
||||
- [x] 打开编辑:不强制用优惠价覆盖库内合同(仅改价 force)
|
||||
- [x] 切分期:`dueAmount` 留空;dueDate 仍按公式
|
||||
- [x] 去掉对 `resolvePerSamplePreset` / 默认分期金额模板的依赖
|
||||
|
||||
### Task 3: ProtocolFormDialog
|
||||
|
||||
- [x] 删除/停用 `applyPaymentPresetForMode`、样本数 watch 套价目、`resolvePaymentPreset` 引用
|
||||
- [x] v2 保存前调用分期必填校验
|
||||
- [x] 协议总金额跟随逻辑保持「默认同步可手改」
|
||||
|
||||
### Task 4: protocol-payment-presets 清理
|
||||
|
||||
- [x] 删除 `PRESETS` 与 `resolvePaymentPreset`;修复所有引用点
|
||||
|
||||
### Task 5: 后端校验
|
||||
|
||||
- [x] schedule validate:非 void 期 `due_amount` 与 `due_date` 必填
|
||||
- [x] pytest 覆盖
|
||||
|
||||
### Task 6: 验证与图谱
|
||||
|
||||
- [x] 前端单测 + 后端相关 pytest
|
||||
- [x] `./scripts/update-graph.sh frontend`(若改后端则 `all`)
|
||||
@@ -0,0 +1,135 @@
|
||||
# 缴费明细弹窗响应式宽度 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 增大缴费信息列表“缴费明细”按钮所打开弹窗的可用宽度,并让基础信息中的缴费方式、缴费进度 Tag 恢复内容自适应宽度。
|
||||
|
||||
**Architecture:** 保持 `PaymentExcelList.vue` 的按钮和弹窗调用链不变,只修改其复用组件 `sample-payment-detail-dialog/index.vue`。弹窗使用带最大值的视口响应式宽度;两个目标 Tag 使用专用类收缩,不影响批次和转账表格中的其他 Tag。
|
||||
|
||||
**Tech Stack:** Vue 3、TypeScript、Element Plus、SCSS、Node.js assert/tsx
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
- Create: `web-admin/scripts/test-payment-detail-dialog-layout.ts` — 组件布局源码契约检查。
|
||||
- Modify: `web-admin/src/components/business/sample-payment-detail-dialog/index.vue` — 响应式弹窗宽度及基础信息 Tag 收缩样式。
|
||||
- Update generated graph: `web-admin/graphify-out/*` — 按仓库规则同步前端代码知识图谱。
|
||||
|
||||
### Task 1: 锁定弹窗宽度和 Tag 宽度契约
|
||||
|
||||
**Files:**
|
||||
- Create: `web-admin/scripts/test-payment-detail-dialog-layout.ts`
|
||||
- Test: `web-admin/scripts/test-payment-detail-dialog-layout.ts`
|
||||
|
||||
- [ ] **Step 1: 写入失败的源码契约检查**
|
||||
|
||||
```typescript
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
const componentUrl = new URL(
|
||||
'../src/components/business/sample-payment-detail-dialog/index.vue',
|
||||
import.meta.url
|
||||
)
|
||||
const source = readFileSync(componentUrl, 'utf8')
|
||||
|
||||
assert.match(source, /width="min\(1200px, 90vw\)"/)
|
||||
assert.equal(source.match(/class="detail-tag"/g)?.length, 2)
|
||||
assert.match(
|
||||
source,
|
||||
/\.detail-tag\s*\{[^}]*align-self:\s*flex-start;[^}]*width:\s*fit-content;/s
|
||||
)
|
||||
|
||||
console.log('payment detail dialog layout contract passed')
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行检查并确认按预期失败**
|
||||
|
||||
Run: `cd web-admin && pnpm exec tsx scripts/test-payment-detail-dialog-layout.ts`
|
||||
|
||||
Expected: FAIL,首个断言提示组件仍是 `width="1080px"`,未匹配响应式宽度。
|
||||
|
||||
### Task 2: 实现响应式弹窗和自适应 Tag
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/components/business/sample-payment-detail-dialog/index.vue:3-70`
|
||||
- Modify: `web-admin/src/components/business/sample-payment-detail-dialog/index.vue:666-705`
|
||||
- Test: `web-admin/scripts/test-payment-detail-dialog-layout.ts`
|
||||
|
||||
- [ ] **Step 1: 将弹窗宽度改为响应式且限制最大值**
|
||||
|
||||
将:
|
||||
|
||||
```vue
|
||||
width="1080px"
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```vue
|
||||
width="min(1200px, 90vw)"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 只给两个基础信息 Tag 添加专用类**
|
||||
|
||||
缴费方式和缴费进度的两个 `<el-tag>` 均增加:
|
||||
|
||||
```vue
|
||||
class="detail-tag"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 添加收缩样式**
|
||||
|
||||
在组件 scoped SCSS 中增加:
|
||||
|
||||
```scss
|
||||
.detail-tag {
|
||||
align-self: flex-start;
|
||||
width: fit-content;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 重跑源码契约检查**
|
||||
|
||||
Run: `cd web-admin && pnpm exec tsx scripts/test-payment-detail-dialog-layout.ts`
|
||||
|
||||
Expected: PASS,并输出 `payment detail dialog layout contract passed`。
|
||||
|
||||
- [ ] **Step 5: 运行前端验证**
|
||||
|
||||
Run: `cd web-admin && pnpm exec eslint src/components/business/sample-payment-detail-dialog/index.vue scripts/test-payment-detail-dialog-layout.ts`
|
||||
|
||||
Expected: PASS,无 ESLint 错误。
|
||||
|
||||
Run: `cd web-admin && pnpm build:typecheck`
|
||||
|
||||
Expected: PASS,`vue-tsc --noEmit` 与 Vite production build 均成功。
|
||||
|
||||
### Task 3: 同步知识图谱并检查改动范围
|
||||
|
||||
**Files:**
|
||||
- Update generated graph: `web-admin/graphify-out/*`
|
||||
|
||||
- [ ] **Step 1: 更新前端知识图谱**
|
||||
|
||||
Run: `./scripts/update-graph.sh frontend`
|
||||
|
||||
Expected: 命令成功完成,前端图谱与组件源码一致。
|
||||
|
||||
- [ ] **Step 2: 检查差异与格式**
|
||||
|
||||
Run: `git diff --check -- web-admin/src/components/business/sample-payment-detail-dialog/index.vue web-admin/scripts/test-payment-detail-dialog-layout.ts web-admin/graphify-out`
|
||||
|
||||
Expected: PASS,无空白或补丁格式错误;差异仅包含本次组件、测试和生成图谱变更。
|
||||
|
||||
- [ ] **Step 3: 提交本次实现**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/components/business/sample-payment-detail-dialog/index.vue \
|
||||
web-admin/scripts/test-payment-detail-dialog-layout.ts \
|
||||
web-admin/graphify-out
|
||||
git commit -m "fix(web-admin): widen payment detail dialog responsively"
|
||||
```
|
||||
|
||||
Expected: 仅提交本计划列出的实现、验证与图谱文件。
|
||||
@@ -31,7 +31,7 @@
|
||||
**非目标**
|
||||
|
||||
- 本期不实现「审批人去重」`approverDedup` 的引擎逻辑(UI 开关可继续存在,与本开关独立)。
|
||||
- 不改变 `direct_supervisor`「主管不能是发起人本人」的解析报错。
|
||||
- ~~不改变 `direct_supervisor`「主管不能是发起人本人」的解析报错。~~(已取消:允许主管配置为本人,如部门经理。)
|
||||
- 不新增独立 `event_type=auto_approve`(以任务意见留痕;后续若报表需要再补)。
|
||||
- 发起预览 V1 不增加「将自动通过」旁注。
|
||||
- 小程序端、抄送节点语义变更。
|
||||
@@ -108,7 +108,7 @@
|
||||
- 开关关闭
|
||||
- 抄送节点(保持现有建即 done / 知会)
|
||||
- 本人不在该节点 assignee 列表
|
||||
- `direct_supervisor` 解析到本人仍抛错(不依赖本开关兜底)
|
||||
- ~~`direct_supervisor` 解析到本人仍抛错~~(已取消:主管为本人时正常建待办;是否自动通过由本开关决定)
|
||||
- **加签**:仅追加他人 pending,不因加签对本节点发起人再跑一遍「自动整节点通过」
|
||||
|
||||
### 5.4 与意见必填
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
# 统一样本信息管理 — 实现设计(方案 1)
|
||||
|
||||
> 日期:2026-10-05
|
||||
> 状态:已确认(2026-10-05)
|
||||
> 关联:细胞存储 / 科研捐赠「样本信息管理」;采集协议单「储存证 / 满月待办」收口
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景
|
||||
|
||||
现状把样本进度拆在多处:
|
||||
|
||||
- 细胞存储「样本信息管理」四个 Tab(采集 / 接收 / 质检 / 入库),后三 Tab 还承担短信提取与通知标记。
|
||||
- 科研捐赠「样本信息管理」只有采集列表。
|
||||
- 储存证、满月保险在「采集协议单」另外两个 Tab;保险还绑定保险公司、险种、保单号、投保单、发票。
|
||||
|
||||
市场侧无法在同一张样本列表上看完整进度。保险公司 / 险种菜单已隐藏,产品要求证与保险先做轻量办理。
|
||||
|
||||
---
|
||||
|
||||
## 2. 目标与非目标
|
||||
|
||||
### 2.1 目标
|
||||
|
||||
- 一个「样本信息管理」界面、两个来源 Tab(细胞存储 / 科研捐赠)。
|
||||
- **一条样本一行**;一份协议多个样本即多行。
|
||||
- 列表展示采集、接收、质检、入库进度标识(只读,由现有业务数据派生)。
|
||||
- 细胞存储额外展示储存证、保险「已办理 / 未办理」(协议级,同协议多行相同)。
|
||||
- 详情展示对应进度明细;细胞存储详情可办理储存证、保险(办理时间、办理人员、附件、备注)。
|
||||
- 采集协议单移除「储存证管理」「满月待办管理」Tab;旧路由跳转到样本信息。
|
||||
|
||||
### 2.2 非目标
|
||||
|
||||
- 不在本页改采集 / 接收 / 质检 / 入库状态(仍在质检、生产等菜单办理)。
|
||||
- 不在本页提取短信、标记已通知。
|
||||
- 不采集保险公司、险种、保单号、费用、投保单、发票;不恢复已隐藏的保险公司 / 体检机构菜单。
|
||||
- 不新建「样本进度总表」,不把两套协议样本合成一张混排物理表。
|
||||
- 不删除短信相关 API / 表;本页不再入口即可。
|
||||
- 不删除证 / 保险表上的旧保险字段;界面不展示、新保存不写入这些字段。
|
||||
|
||||
---
|
||||
|
||||
## 3. 菜单与页面
|
||||
|
||||
### 3.1 入口
|
||||
|
||||
| 菜单 | 路由 | 默认 Tab |
|
||||
|------|------|----------|
|
||||
| 细胞存储 → 样本信息管理 | `/market/cell-storage/sample-info` | `cell_storage` |
|
||||
| 科研捐赠 → 样本信息管理 | `/market/research-donation/sample-info` | `research_donation` |
|
||||
|
||||
两路由共用同一页面组件。`query.tab` 为 `cell_storage` | `research_donation`,与菜单默认一致,切换 Tab 时写入 query。
|
||||
|
||||
权限码保持现有:`cell-storage:sample-info:list`、`research-donation:sample-info:list`。用户只能看到有权限的 Tab;从无权限的菜单进入时不展示对应 Tab。
|
||||
|
||||
### 3.2 采集协议单
|
||||
|
||||
`protocol-audit` 仅保留「协议单管理」(审核、状态变更、增删改)。移除储存证、满月待办 Tab。
|
||||
|
||||
已隐藏路由:
|
||||
|
||||
- `/market/cell-storage/storage-cert` → 跳转 `CellStorageSampleInfo?tab=cell_storage`
|
||||
- `/market/cell-storage/fullmoon-todo` → 同上
|
||||
- 旧样本接收 / 入库 / `protocol-audit?tab=storage-cert|fullmoon-todo` → 同样落到统一样本信息页
|
||||
|
||||
### 3.3 UI
|
||||
|
||||
- 列表对齐标准列表(筛选 → 表格 → 分页);进度与办理状态用 `el-tag`。
|
||||
- 操作列 `fixed="right"`,`min-width` ≥ 260px;本页主操作仅为「查看」。
|
||||
- 详情弹窗沿用 `ProtocolViewDialog` / `DonationViewDialog`,`label-position="top"`,无遮罩模糊。
|
||||
- 附件上传卡片化,对齐协议附件(`ProtocolFormDialog`)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 列表
|
||||
|
||||
### 4.1 粒度与排序
|
||||
|
||||
- 数据源:`biz_protocol_sample`(细胞存储)/ `biz_research_donation_sample`(科研捐赠),`del_flag = 0`,当前租户 + 数据权限。
|
||||
- 默认排序与现采集列表一致:预产期空值靠后,其余预产期倒序,再主单 `create_time DESC`,再样本 id 倒序。
|
||||
|
||||
### 4.2 公共列
|
||||
|
||||
协议/捐赠状态、预产期、母亲姓名、新生儿姓名、样本编码(含特殊样本展示)、实验室入库编码、样本类型、采集标识、接收标识、质检标识、入库标识、操作。
|
||||
|
||||
细胞存储 Tab 在入库标识后增加:储存证办理、保险办理。
|
||||
|
||||
### 4.3 进度标识(只读,按租户 + 样本编码关联下游)
|
||||
|
||||
**采集**(`sample_status`)
|
||||
|
||||
| 展示 | 条件 |
|
||||
|------|------|
|
||||
| 未采集 | 空 |
|
||||
| 已采集 | `ycj` / `jcz` / `yrk` |
|
||||
| 已作废 | `yzf` |
|
||||
|
||||
**接收**(该编码、对应来源下 `del_flag=0` 的接收单;多条时取接收时间最晚一条)
|
||||
|
||||
| 展示 | 条件 |
|
||||
|------|------|
|
||||
| 未接收 | 无接收单,或结论为空 / `dqr` |
|
||||
| 已接收 | `js` |
|
||||
| 拒收 | `bj` |
|
||||
|
||||
**质检**(该编码细胞存储或科研捐赠来源的入库质检)
|
||||
|
||||
| 展示 | 条件 |
|
||||
|------|------|
|
||||
| 未完成 | 无工单,或待质检 / 质检中 |
|
||||
| 已完成 | 已有结论:`tg` 或 `btg`(通过、不通过都算完成) |
|
||||
| 已作废 | 工单作废 `yzf` |
|
||||
|
||||
**入库**
|
||||
|
||||
| 展示 | 条件 |
|
||||
|------|------|
|
||||
| 未入库 | 无入库结果,或待确认 `drk` |
|
||||
| 已入库 | `yrk` |
|
||||
| 拒绝入库 | `btg` |
|
||||
|
||||
本页 **不得** 提供改这四类状态的控件。
|
||||
|
||||
### 4.4 储存证 / 保险办理标识(仅细胞存储,协议级)
|
||||
|
||||
同一 `collection_protocol_id` 下所有样本行标识相同。
|
||||
|
||||
**已办理** 当且仅当以下任一成立:
|
||||
|
||||
- 新字段 `handle_time` 非空;或
|
||||
- 兼容旧数据:储存证 `make_status = 1` 或 `make_date` 非空;保险 `todo_status = 1` 或旧 `handle_date` 非空。
|
||||
|
||||
否则 **未办理**。
|
||||
|
||||
不使用保险公司、险种、保单号、`insurance_subscribed` 判断。
|
||||
|
||||
### 4.5 筛选
|
||||
|
||||
- 关键词:姓名 / 样本编码 / 实验室入库编码(与现采集列表一致)。
|
||||
- 采集、接收、质检、入库:与上表展示枚举一致。
|
||||
- 细胞存储:储存证办理、保险办理(已办理 / 未办理)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 详情
|
||||
|
||||
### 5.1 打开方式
|
||||
|
||||
查看 / 双击:细胞存储打开协议详情,科研捐赠打开捐赠详情。仍为整单。打开后滚动到「样本及运输」,高亮当前行样本。
|
||||
|
||||
### 5.2 样本进度(只读)
|
||||
|
||||
每条样本卡片补充四段:采集(状态与采集/母血时间、地区医院医护)、接收(结论、时间、备注)、质检(状态/结论、完成时间)、入库(状态、时间)。无数据展示「—」。
|
||||
|
||||
科研捐赠无缴费、储存证、保险区块。
|
||||
|
||||
### 5.3 储存证 / 保险办理(仅细胞存储,可编辑)
|
||||
|
||||
协议级两块,各含:
|
||||
|
||||
| 字段 | 规则 |
|
||||
|------|------|
|
||||
| 办理时间 | 日期时间;非空则已办理 |
|
||||
| 办理人员 | 用户选择;打开空表单时默认当前登录用户。填写了办理时间则办理人员必填 |
|
||||
| 附件 | 可选;多文件逗号分隔 URL(证:`cert_image_urls`;保险:`insurance_attachments`) |
|
||||
| 备注 | 可选(证:`remark`;保险:`handle_remark`) |
|
||||
|
||||
每块独立「保存」。不校验协议必须为生效中。
|
||||
|
||||
保存时:
|
||||
|
||||
- 有办理时间:写入 `handle_time`、`handler_user_id`、附件、备注;同步 `make_status`/`todo_status` = `1`;同步 `make_date` / 旧 `handle_date` 为该日 `YYYYMMDD`。
|
||||
- 办理时间为空:视为撤销办理——清空 `handle_time`、`handler_user_id`,status = `0`,日期字段清空;附件与备注按本次提交写入(允许一并清空)。
|
||||
- **不更新** `insurance_company_id`、险种、保单号、费用、生效失效、`insurance_subscribed`。
|
||||
|
||||
从样本信息打开的协议详情不再使用旧的只读储存证拼盘 / 满月保险只读区作为办理入口;办理只在上述两块。
|
||||
|
||||
---
|
||||
|
||||
## 6. 数据与接口
|
||||
|
||||
### 6.1 表变更
|
||||
|
||||
脚本:`docs/sql/migrations/2026-10-05-sample-info-handle-fields.sql`(名称以落地为准)。
|
||||
|
||||
`biz_protocol_storage_cert` 在业务字段末、公共字段前增加:
|
||||
|
||||
| 列 | 类型 | 说明 |
|
||||
|----|------|------|
|
||||
| `handle_time` | DATETIME NULL | 办理时间 |
|
||||
| `handler_user_id` | VARCHAR(64) NULL | 办理人 `sys_user.id` |
|
||||
|
||||
`biz_protocol_fullmoon_todo` 同样增加 `handle_time`、`handler_user_id`。
|
||||
|
||||
Model / `_row_to_dict` 与 Create/Update 可写字段对齐(办理接口,非整单协议 CreateBody)。
|
||||
|
||||
### 6.2 列表
|
||||
|
||||
扩展:
|
||||
|
||||
- `GET /web/admin/protocol-sample-collections/list`
|
||||
- `GET /web/admin/research-donation-sample-collections/list`
|
||||
|
||||
响应增加进度字段(code + label + 关键时间)。细胞存储增加 `storageCertHandled`、`insuranceHandled`(boolean)及办理时间(可选,供详情预填不是必须)。
|
||||
|
||||
查询参数增加进度与办理筛选。关联下游一律 `tenant_no` + `sample_code`,来源分别为 `cell_storage` / `research_donation`。
|
||||
|
||||
### 6.3 办理写入
|
||||
|
||||
- `PUT /web/admin/storage-certs/{protocolId}`:body 仅 `handleTime`、`handlerUserId`、`certImageUrls`、`remark`。
|
||||
- `PUT /web/admin/fullmoon-todos/{protocolId}/handle`:body 仅 `handleTime`、`handlerUserId`、`insuranceAttachments`、`handleRemark`。
|
||||
|
||||
租户校验、数据权限与协议更新一致。协议详情 bundle 返回办理字段及办理人姓名。
|
||||
|
||||
现有 `PUT .../fullmoon-todos/{id}/insurance` 可保留但前端样本信息页不调用;协议整单更新不再作为本页证/保险保存路径。
|
||||
|
||||
### 6.4 前端主要改动(落地时按此拆文件)
|
||||
|
||||
- `web-admin/src/views/cell-storage/sample-info/index.vue`:两来源 Tab + 统一列表。
|
||||
- 科研捐赠 `sample-info/index.vue`:复用或重定向到同一实现。
|
||||
- `protocol-audit/index.vue`:去掉证 / 满月 Tab。
|
||||
- `ProtocolViewDialog.vue`:样本进度摘要 + 证/保险办理表单。
|
||||
- `DonationViewDialog.vue`:样本进度摘要。
|
||||
- 可删除或停用:`StorageCertTab.vue`、`FullmoonTodoTab.vue`、四个旧进度 Tab 作为独立工作台(采集列表逻辑并入统一页)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 测试要点
|
||||
|
||||
- 细胞存储 / 科研捐赠列表:无下游 → 未接收 / 质检未完成 / 未入库;有 `js`/`tg`/`yrk` → 对应已完成类标识。
|
||||
- 多样本协议:两行;改证/保险后两行办理标识同时变化。
|
||||
- 办理保存 / 清空办理时间:标识与 status、日期同步字段一致。
|
||||
- 旧 `make_status=1` 且新 `handle_time` 空:列表仍为已办理。
|
||||
- 租户隔离与数据权限:不可见他户协议样本。
|
||||
- 科研捐赠列表响应不含证/保险办理列。
|
||||
|
||||
---
|
||||
|
||||
## 8. 决策摘要
|
||||
|
||||
| 项 | 结论 |
|
||||
|----|------|
|
||||
| 架构 | 方案 1:一页两 Tab,进度派生,不建总表 |
|
||||
| 行粒度 | 样本行 |
|
||||
| 证/保险入口 | 只在样本详情;协议单 Tab 撤销(选项 A) |
|
||||
| 证/保险字段 | 仅时间、人员、附件、备注 |
|
||||
| 短信 | 本页不做 |
|
||||
| 已办理 | 以 `handle_time` 为主,兼容旧制作/办理日期与 status |
|
||||
| 质检「已完成」 | 通过或不通过都算完成 |
|
||||
@@ -0,0 +1,155 @@
|
||||
# 配置运费「关联已有」列表 enrichment — 实现设计
|
||||
|
||||
> 日期:2026-10-08
|
||||
> 状态:已确认(2026-10-08)
|
||||
> 关联:
|
||||
> - `docs/superpowers/specs/2026-10-07-sample-freight-batch-design.md`
|
||||
> - `docs/superpowers/specs/2026-10-08-sample-freight-menu-design.md`
|
||||
> - Demo:`docs/demos/2026-10-08-freight-link-existing-demo.html`
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与问题
|
||||
|
||||
采集协议单(细胞存储 / 科研捐赠)的「配置运费」弹窗中,「关联已有」仅展示:
|
||||
|
||||
- 运输方式、运费(元)、关联样本数、创建时间
|
||||
|
||||
多笔同运输方式、同金额的批次无法区分,用户难以判断应关联哪一笔。
|
||||
|
||||
「运费信息」菜单页(`/#/market/cell-storage/sample-freight`)已用合并单元格 + 样本明细列,信息完整,业务确认希望「关联已有」对齐该列表。
|
||||
|
||||
---
|
||||
|
||||
## 2. 已确认决策
|
||||
|
||||
| 项 | 结论 |
|
||||
|----|------|
|
||||
| 展示形态 | 对齐「运费信息」列表:批次字段 rowspan + 样本明细行 |
|
||||
| 覆盖入口 | ① 协议单 `SampleTransportFormSection`「配置运费」;②「运费信息」页 `FreightConfigDialog`「关联已有」 |
|
||||
| 创建人 | 列表展示创建人(昵称);运费信息主列表已落地 `createBy` / `createByName` |
|
||||
| 后端 | 复用 `GET /web/admin/sample-freights?includeItems=true`;关联已有路径须传 `includeItems=true` |
|
||||
| 新接口 | 不新增 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 目标与非目标
|
||||
|
||||
### 3.1 目标
|
||||
|
||||
- 「关联已有」表格与「运费信息」主列表信息密度一致,便于识别批次。
|
||||
- 按**运费批次**单选一笔;点任一样本明细行即选中该批次。
|
||||
- 两入口 UI 一致(可抽公共表格组件,避免复制)。
|
||||
|
||||
### 3.2 非目标
|
||||
|
||||
- 不改新建运费、link/unlink、编辑运费业务规则。
|
||||
- 不改运费信息主列表筛选/分页/工具栏行为(创建人列已单独落地)。
|
||||
- 不引入快递单号等物流扩展字段。
|
||||
- 不做完整筛选区(弹窗高度有限);仅可选轻量关键词。
|
||||
|
||||
---
|
||||
|
||||
## 4. UI 规格
|
||||
|
||||
### 4.1 弹窗
|
||||
|
||||
- `dialog-form-layout`;无 backdrop blur;`append-to-body`。
|
||||
- 「关联已有」场景建议宽度 **960~1080px**(相对现状 720/860 加宽)。
|
||||
- Tab:新建运费 | 关联已有(行为不变)。
|
||||
|
||||
### 4.2 「关联已有」表格列
|
||||
|
||||
与运费信息主列表对齐(去掉操作列,增加单选):
|
||||
|
||||
| 列 | 合并(按 freightId rowspan) | 说明 |
|
||||
|----|------------------------------|------|
|
||||
| 选择 | ✓ | 单选;点该批次任一行选中整笔 |
|
||||
| 运输方式 | ✓ | `el-tag` |
|
||||
| 运费(元) | ✓ | |
|
||||
| 关联数 | ✓ | |
|
||||
| 来源 | 否 | 细胞存储 / 科研捐赠 Tag |
|
||||
| 协议单号 | 否 | |
|
||||
| 样本编码 | 否 | |
|
||||
| 样本类型 | 否 | |
|
||||
| 创建人 | ✓ | `createByName \|\| createBy \|\| —` |
|
||||
| 创建时间 | ✓ | |
|
||||
|
||||
- **不展示**操作列(编辑 / 取消关联)。
|
||||
- 无 item 的批次:仍展示一行批次摘要,样本列显示 `—`。
|
||||
- 表格 `max-height` 可滚;`stripe` + `list-table`,对齐 Fresh Mint。
|
||||
|
||||
### 4.3 轻量检索(建议)
|
||||
|
||||
弹窗内可选:关键词输入(运输方式 / 协议单号 / 样本编码),调用现有 list `keyword` 参数。
|
||||
不做运输方式/来源/日期完整筛选条。
|
||||
|
||||
### 4.4 协议单内 vs 运费页弹窗差异
|
||||
|
||||
| | 协议单内 | 运费信息页 |
|
||||
|--|----------|------------|
|
||||
| 选中样本来源 | 当前协议单已勾选样本 | 弹窗内样本多选表 |
|
||||
| 关联已有表 | 本规格 | 本规格(同一组件) |
|
||||
| 确定后 | create/link 或草稿(现有逻辑) | create/link 后刷新主列表 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 数据与前端结构
|
||||
|
||||
### 5.1 API
|
||||
|
||||
```
|
||||
GET /web/admin/sample-freights?page=1&pageSize=50&includeItems=true&keyword?=
|
||||
```
|
||||
|
||||
响应批次字段须含(已有或近期已补):
|
||||
|
||||
- `transportMode` / `transportModeLabel` / `freightAmount` / `linkedSampleCount`
|
||||
- `createBy` / `createByName` / `createTime`
|
||||
- `items[]`:`sampleSource`、`sampleId`、`sampleCode`、`protocolId`、`protocolNo`、`sampleTypeName`
|
||||
|
||||
### 5.2 前端 flatten
|
||||
|
||||
复用运费信息页逻辑(可抽到工具或公共组件):
|
||||
|
||||
- `flattenFreights(list)` → 扁平行 + `rowspan` / `isFirst` / `freightId`
|
||||
- `span-method` 合并:选择、运输方式、运费、关联数、创建人、创建时间
|
||||
|
||||
选中态:维护 `selectedFreightId`;`current-change` 或 radio 以 `freightId` 为准(非样本行 key)。
|
||||
|
||||
### 5.3 建议文件
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| 新建(建议)`web-admin/src/components/business/sample-freight-pick-table/index.vue` | 关联已有选批表格 |
|
||||
| `sample-transport-form-section/index.vue` | 协议单弹窗改用公共表;`includeItems: true` |
|
||||
| `sample-freight/FreightConfigDialog.vue` | 同上 |
|
||||
| `sample-freight/index.vue` | 已含创建人;可改为复用 flatten 工具(可选,非必须) |
|
||||
|
||||
若不愿抽组件:允许两处各改表格,但列定义与 flatten **必须一致**。
|
||||
|
||||
---
|
||||
|
||||
## 6. 验收标准
|
||||
|
||||
1. 细胞存储协议单「配置运费 → 关联已有」可见来源、协议单号、样本编码、样本类型、创建人、创建时间,批次列合并正确。
|
||||
2. 科研捐赠协议单同上。
|
||||
3. 运费信息页「配置运费 → 关联已有」同上。
|
||||
4. 点击某批次下任一样本行,确定后将该批次关联到选中样本(协议单内)或弹窗勾选样本(运费页)。
|
||||
5. 同运输方式、同金额的两笔批次可凭协议/样本/创建人区分。
|
||||
6. 新建运费 Tab 与 link API 行为无回归。
|
||||
|
||||
---
|
||||
|
||||
## 7. 已落地前置
|
||||
|
||||
- 运费信息主列表「创建人」列 + 后端 `_row_to_dict` 返回 `createBy` / `createByName`(本会话已改)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 实现顺序(概要)
|
||||
|
||||
1. (可选)抽出 `flatten` + 选批表格组件。
|
||||
2. 改造 `FreightConfigDialog`「关联已有」。
|
||||
3. 改造 `SampleTransportFormSection`「关联已有」(细胞存储 + 科研捐赠共用该组件)。
|
||||
4. 手工验收三入口;必要时补前端类型字段。
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
# 采集协议单:市场专员 / 采集地区 / 地区经理联动
|
||||
|
||||
**日期:** 2026-10-08
|
||||
**状态:** 已确认待实现
|
||||
**范围:** 细胞存储采集协议单 + 科研捐赠采集单(协议审核页两个 Tab 共用的表单/列表/查看)
|
||||
|
||||
## 背景
|
||||
|
||||
采集地区人员配置在「采集地区及医院」模块(`biz_collection_region_staff`):
|
||||
|
||||
| 模块用语 | 业务用语 |
|
||||
|---------|---------|
|
||||
| 工作人员 | 市场专员 |
|
||||
| 负责人(`is_leader=1`) | 地区经理 |
|
||||
|
||||
采集协议单原字段「销售人员」需更名为「市场专员」,并按人员关联地区自动带出采集地区;再按所选地区带出地区经理并落库。
|
||||
|
||||
预签单(`PresignOrderFormDialog`)已有「采集经理 + 按地区负责人默认带出、可改选」模式,本设计对齐该行为。
|
||||
|
||||
## 目标
|
||||
|
||||
1. UI 文案:`销售人员` → `市场专员`(表单、列表列、查看弹窗、协议审核卡片展示)。
|
||||
2. 选中市场专员后,采集地区选项收缩为其在 `biz_collection_region_staff` 中绑定的地区;多地区时默认第一项,可改选。
|
||||
3. 市场专员仍可按姓名 remote 检索**全部系统用户**(不强制必须是已配置工作人员)。
|
||||
4. 市场专员后新增「地区经理」:按当前采集地区负责人默认带出,可改选任意用户;必须写入采集信息表。
|
||||
5. 细胞存储与科研捐赠两套采集单行为一致。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不改采集地区人员配置模块本身。
|
||||
- 不做 HTML Demo。
|
||||
- 不限制市场专员只能选自工作人员列表。
|
||||
- 不重构预签单(仅复用其交互模式)。
|
||||
|
||||
## 数据模型
|
||||
|
||||
### 新增列
|
||||
|
||||
| 表 | 列 | 说明 |
|
||||
|----|-----|------|
|
||||
| `biz_protocol_collection_info` | `region_manager_user_id` VARCHAR(64) NULL | 地区经理用户 ID |
|
||||
| `biz_research_donation_collection` | `region_manager_user_id` VARCHAR(64) NULL | 同上 |
|
||||
|
||||
迁移脚本放在 `docs/sql/migrations/2026-10-08-protocol-region-manager.sql`。
|
||||
|
||||
ORM / CRUD / CreateBody·UpdateBody / `_row_to_dict` / 前端 form 与提交体同步增加 `regionManagerUserId`;详情与列表填充 `regionManagerUserName`(由用户表昵称解析)。
|
||||
|
||||
既有 `sales_user_id` 字段保留,仅前端与注释语义改为「市场专员」。
|
||||
|
||||
## API
|
||||
|
||||
### 新增:按用户查关联采集地区
|
||||
|
||||
`GET /api/admin/collection-regions/by-staff-user/{userId}`
|
||||
|
||||
- 租户隔离:仅当前用户 `tenant_no`。
|
||||
- 查询 `biz_collection_region_staff`(`del_flag=0`)join 地区(`del_flag=0`)。
|
||||
- 排序:`order_num ASC`,再 `create_time DESC`(与地区人员列表习惯一致)。
|
||||
- 响应:`{ list: [{ id, regionName }] }`;无绑定时 `list: []`。
|
||||
|
||||
既有 `GET /collection-regions/{regionId}/staff` 继续用于按地区取负责人(与预签单一致)。
|
||||
|
||||
## 前端联动(`ProtocolFormDialog` + `DonationFormDialog`)
|
||||
|
||||
### 字段顺序(采集信息区)
|
||||
|
||||
市场专员 → 地区经理 → 采集地区 → 采集医院 → 采集医护 → 样本/母血采集时间 → …
|
||||
|
||||
### 市场专员
|
||||
|
||||
- Remote 检索全部启用用户(现状保留)。
|
||||
- 新建默认当前登录人;默认后触发一次「按专员刷新关联地区」。
|
||||
|
||||
### 市场专员变更 → 采集地区
|
||||
|
||||
1. 调用 `by-staff-user` 取关联地区列表 `linkedRegions`。
|
||||
2. **有关联:**
|
||||
- 采集地区下拉选项 = `linkedRegions`(不再用全量 remote 检索,改为本地 filter 即可)。
|
||||
- 若当前已选地区仍在 `linkedRegions` 中则保留,否则默认第一项。
|
||||
- 若地区值发生变化:清空医院并重载;触发地区经理默认带出。地区未变则不强制改医院/经理。
|
||||
3. **无关联:**
|
||||
- 清空采集地区与医院。
|
||||
- 恢复全量地区 remote 检索(与改前一致),用户可手工选任意地区。
|
||||
- 清空地区经理(待用户选地区后再带出)。
|
||||
|
||||
### 采集地区变更 → 地区经理
|
||||
|
||||
- 清空医院并重载该地区医院。
|
||||
- 拉取该地区 staff:优先 `isLeader`,否则取列表第一人;写入 `regionManagerUserId` 并保证 options 中有 stub。
|
||||
- 地区经理控件:remote 检索全部用户、可 clear、可改选(对齐预签单「采集经理」)。
|
||||
|
||||
### 保存
|
||||
|
||||
提交 `collectionInfo`(或等价结构)时包含:
|
||||
|
||||
- `salesUserId`
|
||||
- `regionManagerUserId`
|
||||
- `regionId` / `hospitalId` / …
|
||||
|
||||
## 展示同步
|
||||
|
||||
以下位置文案与回显一并调整(有则改):
|
||||
|
||||
| 位置 | 调整 |
|
||||
|------|------|
|
||||
| `ProtocolFormDialog` / `DonationFormDialog` | label + 地区经理字段 + 联动 |
|
||||
| `ProtocolViewDialog` / `DonationViewDialog` | 销售人员→市场专员;增加地区经理 |
|
||||
| 协议列表 `protocol/index.vue`、科研捐赠 `research-donation/list/index.vue` | 「销售人员」列改名为「市场专员」;列表不加「地区经理」列 |
|
||||
| `protocol-audit` 卡片(`ProtocolAuditTab` / `DonationProtocolTab`) | 销售人员→市场专员;卡片增加地区经理展示 |
|
||||
|
||||
**列表列策略:** 列表只改名「市场专员」;地区经理在查看弹窗与审核卡片展示。
|
||||
|
||||
## 错误与边界
|
||||
|
||||
- 市场专员清空:清空关联地区约束,恢复全量地区检索;清空地区/医院/地区经理(与「无关联」一致)。
|
||||
- 地区无任何 staff:地区经理留空,可手工选。
|
||||
- 编辑回填:以库中已存 `regionId` / `salesUserId` / `regionManagerUserId` 为准;hydrate options stub;进入编辑后若用户改市场专员再走联动逻辑。
|
||||
- 历史数据无 `region_manager_user_id`:展示为空,编辑保存时可补全。
|
||||
|
||||
## 测试
|
||||
|
||||
- 后端:`by-staff-user` 有/无绑定、租户隔离;采集信息 create/update/get 含 `regionManagerUserId`。
|
||||
- 前端手工:新建默认专员→带地区;多地区默认第一可改;改地区→经理变化可改选;科研捐赠与细胞存储一致;编辑回显不丢经理。
|
||||
|
||||
## 参考实现
|
||||
|
||||
- 地区经理默认带出:`web-admin/src/views/cell-storage/presign-order/PresignOrderFormDialog.vue` 中 `applyDefaultRegionManager`
|
||||
- 人员表:`biz_collection_region_staff` / `backend/app/crud/collection_region.py`
|
||||
@@ -0,0 +1,244 @@
|
||||
# 缴费信息 Excel 单列表 + 分改趸 + 催缴信息
|
||||
|
||||
> 状态:已确认
|
||||
|
||||
> 日期:2026-10-09
|
||||
> 界面模式:P02 复杂业务列表(缴费宽表 / 催缴标准列表)
|
||||
> 参考:Excel 缴费台账截图、`docs/demos/2026-10-08-payment-info-excel-list-demo.html`、现 `payment-info` / `SamplePaymentPanel`
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 现状
|
||||
|
||||
【缴费信息】含四个 Pill Tab(已缴费 / 缴费确认中 / 缴费成功 / 按样本),运营扫读成本高,且列表难以对齐财务 Excel「一行一样本 + 多期横排」习惯。
|
||||
|
||||
数据模型已支持:
|
||||
|
||||
```
|
||||
样本缴费方案 1 ──N 应缴批次(期) ──N 转账笔
|
||||
```
|
||||
|
||||
一期多笔在「按样本详情」可见,但流程 Tab 按转账散开,不成组。
|
||||
|
||||
缴费方式目前主要为 `dj`(趸交)、`fq`(分期)、以及双样本预设 `djfq`(趸交+分期),缺少业务上的「分改趸」标识与中途并期能力。
|
||||
|
||||
市场侧缺少「近月应缴 + 已逾期」的催缴工作台。
|
||||
|
||||
### 1.2 目标
|
||||
|
||||
1. 【缴费信息】改为**单列表**(方案 1):基础信息 + 固定第 1~6 期色块 + 汇总;点击某期「实缴金额」弹窗查看转账与财务审核。
|
||||
2. 新增缴费方式 Tag **`fgd`(分改趸)**:建单可选;中途可由分期改来并自动并期(入口仅在采集协议单缴费方案)。
|
||||
3. 新增侧栏菜单 **【催缴信息】**:展示未来 30 天内应缴未清 + 已逾期未清的期次,供市场专员催缴。
|
||||
|
||||
### 1.3 已确认决策
|
||||
|
||||
| 项 | 选择 |
|
||||
|----|------|
|
||||
| 缴费信息行粒度 | 一行 = 一个样本缴费方案 |
|
||||
| 列表形态 | Excel 定宽 6 期列(方案 1) |
|
||||
| 缴费方式语义 | Tag 标识;真实结构以有效期数为准 |
|
||||
| 分期 | 最多 6 期 |
|
||||
| 趸交 | 通常 1 期 |
|
||||
| 分改趸 | 建单可选;中途 `fq→fgd` 并期后常见 2~3 期 |
|
||||
| 中途并期规则 | 软删合并目标期之后的后续期,剩余未缴并入第一期欠缴>0 的期 |
|
||||
| 中途入口 | 仅采集协议单 · 编辑缴费方案 |
|
||||
| 改造范围 | 完整闭环:列表 + 建单 `fgd` + 中途并期 + 催缴菜单 |
|
||||
| 催缴纳入 | 未来 30 天待缴期 ∪ 已逾期未缴清期 |
|
||||
| 催缴行粒度 | 一行 = 一个待催期(样本×期次) |
|
||||
|
||||
## 2. 信息架构与菜单
|
||||
|
||||
```
|
||||
… → 预签约订单 → 【缴费信息】→ 【催缴信息】→ 样本信息管理 → …
|
||||
```
|
||||
|
||||
| 菜单 | 路由建议 | 权限码建议 |
|
||||
|------|----------|------------|
|
||||
| 缴费信息 | `/#/market/cell-storage/payment-info`(沿用现路由) | 沿用 `cellStorage:paymentInfo:list` |
|
||||
| 催缴信息 | `/#/market/cell-storage/payment-chase` | 新增 `cellStorage:paymentChase:list` |
|
||||
|
||||
- 缴费信息:去掉四个 Tab,单一宽表。
|
||||
- 催缴信息:独立页面与权限;默认授权给市场相关角色(具体角色在菜单 SQL / 实施时配置)。
|
||||
- 中途改分改趸:不在缴费信息/催缴信息写 Tag。
|
||||
|
||||
## 3. 缴费信息(Excel 宽表)
|
||||
|
||||
### 3.1 页面形态
|
||||
|
||||
- 筛选:关键词、缴费方式(含分改趸)、缴费进度、应缴日区间(可选)。
|
||||
- 左侧基础列 sticky 冻结;期次区横向滚动。
|
||||
- 操作:查看协议单;有欠缴时可「缴费确认」(复用现有转账确认弹窗)。
|
||||
- Demo:`docs/demos/2026-10-08-payment-info-excel-list-demo.html`。
|
||||
|
||||
### 3.2 列映射
|
||||
|
||||
**基础信息**
|
||||
|
||||
| 列 | 字段 |
|
||||
|----|------|
|
||||
| 序号 | 分页序号 |
|
||||
| 姓名 / 手机号 | `motherName` / `motherPhone` |
|
||||
| 合同编号 | `protocolNo` |
|
||||
| 合同签署日期 | 协议签署日(列表 JOIN 补齐) |
|
||||
| 存储年限 | 样本 `storageYears` |
|
||||
| 存储项目 | 样本类型名 `sampleTypeName` |
|
||||
| 合同总额 | `contractAmount` |
|
||||
| 缴费方式 | `payMode` Tag:`dj` / `fq` / `fgd`(`djfq` 若存在仍展示) |
|
||||
| 优惠金额 | 有则展示;无则 `—`(不强造新业务规则) |
|
||||
| 应收总额 | 未作废期应缴合计(或与现应收口径对齐) |
|
||||
|
||||
**第 N 期(N=1…6)**
|
||||
|
||||
| 子列 | 口径 |
|
||||
|------|------|
|
||||
| 应缴金额 | 该期 `dueAmount`;无期 → `—` |
|
||||
| 实缴金额 | 该期转账展示合计(含 `yjf` + `jfqrz`);**可点击** |
|
||||
| 缴费日期 | 多笔取最近一笔转账日(摘要) |
|
||||
| 支付方式 | 多笔显示「多笔」或最近一笔方式 |
|
||||
| 财务审核 | 期级摘要 Tag(确认中 / 已通过 / 待缴费 / 部分到账) |
|
||||
|
||||
**汇总**
|
||||
|
||||
| 列 | 口径 |
|
||||
|----|------|
|
||||
| 累计实缴 | 已确认 `yjf` 合计(对齐现 `paidAmount`) |
|
||||
| 待缴金额 | `dueRemainAmount` |
|
||||
| 备注 | 方案 `remark` |
|
||||
|
||||
无 schedule 的期位显示 `—`。
|
||||
|
||||
### 3.3 实缴金额弹窗
|
||||
|
||||
- 标题:客户 · 协议号 · 第 N 期。
|
||||
- 摘要:应缴 / 实缴合计 / 欠缴 / 笔数。
|
||||
- 明细:每笔金额、方式、时间、凭证、`payStatus`、财务审核时间线。
|
||||
- 可确认时底部「缴费确认」→ 复用 `PaymentReceiptConfirmDialog`。
|
||||
- 本弹窗不提供改分改趸。
|
||||
|
||||
### 3.4 与旧 Tab
|
||||
|
||||
原流程三状态由行内 Tag + 筛选「缴费进度」替代。旧转账列表接口可保留给其它页面;本页主数据为样本方案 + 期摘要聚合。
|
||||
|
||||
## 4. 催缴信息
|
||||
|
||||
### 4.1 纳入规则
|
||||
|
||||
未作废、未软删、欠缴 > 0 的应缴批次,且:
|
||||
|
||||
1. `dueDate` ∈ `[今天, 今天+30 天]`,或
|
||||
2. `dueDate` < 今天(已逾期)。
|
||||
|
||||
趸交 / 分期 / 分改趸均按期次纳入。
|
||||
|
||||
### 4.2 列表
|
||||
|
||||
- 模式:P02 标准复杂列表(非 6 期宽表)。
|
||||
- 默认排序:逾期优先(逾期天数 DESC)→ 应缴日 ASC → 协议号。
|
||||
- 列:协议号、客户/手机、样本编码、市场专员、缴费方式、期次、应缴日(逾期高亮)、应缴/已缴/欠缴、催缴状态 Tag(已逾期 / N 天内到期)、协议状态、操作(查看协议单;可选深链到缴费信息该样本)。
|
||||
- 本页不做缴费确认、不改分改趸。
|
||||
|
||||
### 4.3 筛选与权限
|
||||
|
||||
- 关键词、催缴类型(全部/仅逾期/仅未来 30 天)、市场专员、缴费方式。
|
||||
- 数据范围:与采集协议单一致(`apply_data_scope`)。
|
||||
|
||||
## 5. 分改趸规则与 API
|
||||
|
||||
### 5.1 枚举
|
||||
|
||||
| 码 | Tag | 说明 |
|
||||
|----|-----|------|
|
||||
| `dj` | 趸交 | 通常 1 期 |
|
||||
| `fq` | 分期 | 最多 6 期 |
|
||||
| `fgd` | 分改趸 | 新建 |
|
||||
| `djfq` | 趸交+分期 | 保留,本需求不改语义 |
|
||||
|
||||
`pay_mode` 仅为 Tag;有效 schedule 条数决定实际期结构。
|
||||
|
||||
### 5.2 建单选分改趸
|
||||
|
||||
- 协议单样本缴费方案增加与趸交/分期同级选项。
|
||||
- 允许配置 2~6 期;生成规则复用分期 schedule 生成能力。
|
||||
- 保存 `pay_mode=fgd`。
|
||||
|
||||
### 5.3 中途 `fq` → `fgd`(仅协议单)
|
||||
|
||||
算法(例:原 6 期,前 2 期已缴清):
|
||||
|
||||
1. 有效期按 `schedule_no` 排序。
|
||||
2. 第一期欠缴 > 0 记为合并目标期 `T`。
|
||||
3. 计算全局剩余欠缴,将未清金额并入 `T` 的应缴,使总欠缴与合同应收关系不变。
|
||||
4. 软删 `schedule_no > T` 的后续期(`del_flag=1`);其下仅 `dfj` 草稿转账一并软删。
|
||||
5. 更新 `pay_mode=fgd`,`installment_count` = 保留有效期数。
|
||||
|
||||
实现上「删除后续期」= 软删除,禁止物理删库。
|
||||
|
||||
### 5.4 约束
|
||||
|
||||
- 存在 `jfqrz` 转账 → 禁止改模式/并期(对齐现可编辑校验)。
|
||||
- 拟软删的后续期若已有 `yjf` → 拒绝并提示。
|
||||
- 本需求明确支持的自动并期路径:`fq` → `fgd`。其它模式切换不承诺自动并期。
|
||||
- 数据权限与协议单编辑一致。
|
||||
|
||||
### 5.5 API
|
||||
|
||||
| 接口 | 变更 |
|
||||
|------|------|
|
||||
| 协议内保存样本缴费方案 | 接受 `fgd`;`fq→fgd` 走并期 service,避免盲目重建抹掉已缴期 |
|
||||
| `GET .../sample-payments` | 宽表字段:签署日、年限、项目、期摘要 1~6、汇总;支持 `fgd` |
|
||||
| `GET .../sample-payments/{id}` | 完整 schedules + transfers(补展示字段) |
|
||||
| `GET .../payment-chase`(名称可微调) | 催缴分页 |
|
||||
| 转账写接口 | 语义不变 |
|
||||
|
||||
并期逻辑放在 `app/services/`(建议 `sample_payment_fgd_convert.py`),附单测。
|
||||
|
||||
### 5.6 前端落点
|
||||
|
||||
| 位置 | 改动 |
|
||||
|------|------|
|
||||
| 协议表单 `SamplePaymentPanel` | 分改趸选项;`fq→fgd` 保存前提示并期 |
|
||||
| `payment-info` | 去 Tab → Excel 宽表 + 实缴弹窗 |
|
||||
| 新页 `payment-chase` | 催缴列表 |
|
||||
| 路由 + 菜单 SQL | 缴费信息下挂催缴信息 |
|
||||
| `status-tag-registry` | `fgd` |
|
||||
|
||||
### 5.7 用户提示
|
||||
|
||||
- 确认中不可改:存在缴费确认中的转账,请先完成财务确认。
|
||||
- 并期成功:已改为分改趸,后续未缴期已合并至第 N 期。
|
||||
|
||||
## 6. 测试要点
|
||||
|
||||
### 6.1 后端
|
||||
|
||||
- `pay_mode=fgd` 可创建 2~6 期方案。
|
||||
- `fq→fgd`:已缴清 2 期时软删 4~6,第 3 期应缴吸收剩余欠缴;总已确认实缴不变。
|
||||
- 存在 `jfqrz` 时 `fq→fgd` 失败。
|
||||
- 后续期已有 `yjf` 时并期失败。
|
||||
- 催缴查询:边界日今天、今天+30 纳入;今天+31 不纳入;逾期未清纳入;欠缴=0 不纳入。
|
||||
- 宽表列表期摘要与详情树金额一致;租户与 data scope 生效。
|
||||
|
||||
### 6.2 前端
|
||||
|
||||
- 缴费信息无 4 Tab;趸交仅第 1 期有值;分期最多 6 期有值。
|
||||
- 点击实缴打开多笔转账弹窗并可发起确认。
|
||||
- 协议单可选分改趸;`fq→fgd` 有确认提示。
|
||||
- 催缴菜单可见、列表排序与筛选符合 §4。
|
||||
|
||||
### 6.3 非目标(本变更不做)
|
||||
|
||||
- 催缴页内直接改分改趸或财务确认。
|
||||
- 物理删除 schedule。
|
||||
- 改造 `djfq` 双样本预设语义。
|
||||
- 财务确认独立页流程大改。
|
||||
|
||||
## 7. 风险与兼容
|
||||
|
||||
- 宽表列多,需冻结列与横向滚动;小屏体验依赖横向滚。
|
||||
- 列表接口需批量补齐协议/样本字段,注意 N+1。
|
||||
- 历史数据无 `fgd`:仅 `dj`/`fq`/`djfq`,展示不受影响。
|
||||
- 旧书签 `?tab=dfj|jfqrz|yjf|by-sample`:进入缴费信息单列表并**忽略 tab**(不再分 Tab);若 `tab` 为进度码,可选地预填「缴费进度」筛选项,无匹配则忽略。
|
||||
|
||||
## 8. 附录:Demo
|
||||
|
||||
- `docs/demos/2026-10-08-payment-info-excel-list-demo.html`
|
||||
@@ -0,0 +1,115 @@
|
||||
# 缴费信息期次列口径 + 协议单应缴批次可编辑
|
||||
|
||||
> 状态:已确认
|
||||
> 日期:2026-10-09
|
||||
> 界面模式:P02 复杂业务列表(缴费宽表);协议表单内嵌按样本缴费面板
|
||||
> 关联:`docs/superpowers/specs/2026-10-09-payment-info-excel-list-and-chase-design.md`
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 现状
|
||||
|
||||
1. 【缴费信息】Excel 宽表每期子列为:应缴、实缴、缴费日期、支付方式、财务审核。
|
||||
- 实缴仅计 `yjf` + `jfqrz`,不含转账笔「已缴费」(`dfj`)。
|
||||
- 缴费日期取多笔中**最晚**一笔。
|
||||
- 未单独展示应缴费时间、欠缴费金额。
|
||||
|
||||
2. 【采集协议单 · 细胞存储】按样本缴费(`SamplePaymentPanel`)的「应缴批次」由期数/间隔/开始日公式生成,每期应缴金额与应缴时间只读展示,无法单期微调。
|
||||
|
||||
### 1.2 目标
|
||||
|
||||
1. 缴费信息列表每一期展示五列业务口径字段(见 §2)。
|
||||
2. 协议单编辑态支持动态调整每一期的应缴费时间、应缴费金额(见 §3)。
|
||||
3. 保存时强制:各期应缴合计 = 该样本合同金额(见 §3.3)。
|
||||
|
||||
## 2. 缴费信息宽表 · 期次列
|
||||
|
||||
### 2.1 列定义(第 N 期,N=1…6)
|
||||
|
||||
| 子列 | 口径 |
|
||||
|------|------|
|
||||
| 应缴费金额(元) | 该期 `dueAmount`;无期 → `—` |
|
||||
| 应缴费时间 | 该期 `dueDate`(展示为 `YYYY-MM-DD`);无期 / 无日期 → `—` |
|
||||
| 已缴费金额 | 该期转账笔中 `pay_status ∈ {dfj, jfqrz, yjf}` 的 `transfer_amount` 合计;无转账 → `0` 或与现展示一致的金额格式;无期 → `—` |
|
||||
| 已缴费时间 | 该期转账记录中**第一条**的 `transferTime` 日期部分;无转账 → `—`;无期 → `—` |
|
||||
| 欠缴费金额 | `max(应缴费金额 − 已缴费金额, 0)`;无期 → `—` |
|
||||
|
||||
说明:
|
||||
|
||||
- 「已缴费 / 确认中 / 缴费完成」对应 UI 文案与码值:`dfj`(已缴费)、`jfqrz`(缴费确认中)、`yjf`(缴费成功/完成)。退费相关状态(`tfqrz` / `ytf`)不计入已缴费金额。
|
||||
- 「第一条」指该期转账列表的持久化/查询顺序中的首条(与详情弹窗列表顺序一致),**不是**按时间最晚。若需稳定顺序:同序下按 `create_time ASC`、`id ASC`。
|
||||
- 点击「已缴费金额」(有金额或有转账时)仍打开现有期次转账明细弹窗;支付方式、财务审核仅在弹窗内展示,不再作为宽表子列。
|
||||
|
||||
### 2.2 后端摘要 `summarize_period`
|
||||
|
||||
调整 / 补充返回字段:
|
||||
|
||||
| 字段 | 规则 |
|
||||
|------|------|
|
||||
| `displayPaidAmount` | `dfj` + `jfqrz` + `yjf` 转账金额合计 |
|
||||
| `payDate` | 首笔转账时间的日期部分(非最近一笔) |
|
||||
| `arrearsAmount` | `max(due − displayPaidAmount, 0)` |
|
||||
| `payTypeSummary` / `financeAudit` | 保留现有逻辑(供弹窗或其它消费方),宽表不再展示 |
|
||||
|
||||
`_build_periods_array` 每期额外输出:
|
||||
|
||||
- `dueDate`(格式与列表约定一致,建议 `YYYY-MM-DD` 或现有 8 位统一前端格式化)
|
||||
- `arrearsAmount`
|
||||
|
||||
前端 `PaymentExcelList.vue` 按上表改列绑定。
|
||||
|
||||
## 3. 协议单 · 应缴批次可编辑
|
||||
|
||||
### 3.1 交互
|
||||
|
||||
在 `SamplePaymentPanel` 每个「第 N 期」区域提供可编辑控件:
|
||||
|
||||
- **应缴费金额(元)**
|
||||
- **应缴费时间**(日期)
|
||||
|
||||
约束:
|
||||
|
||||
- 若该期存在任一转账笔 `pay_status = yjf`(财务已确认),该期应缴金额、应缴时间**只读**。
|
||||
- 仅改某一期的应缴金额/时间时,**不得**触发 `rebuildSchedules` 整表重建。
|
||||
- 变更「缴费模式 / 合同金额 / 期数 / 每期间隔 / 分期开始时间」时,仍可按现有公式重建批次(可能覆盖手工调整,属预期)。
|
||||
- **合同金额不因各期应缴合计自动改写**(决策 A)。
|
||||
|
||||
### 3.2 数据流
|
||||
|
||||
- 编辑写入 `samplePayments[].schedules[].dueAmount` / `dueDate`。
|
||||
- 保存协议时原样提交;后端 `sync_schedules_and_transfers` / `replace_schedules` 已支持写入 `dueAmount`/`dueDate`,无需新表字段。
|
||||
|
||||
### 3.3 保存校验(硬拦截)
|
||||
|
||||
对每个样本缴费方案(未作废期):
|
||||
|
||||
```
|
||||
Σ(dueAmount) 必须等于 contractAmount
|
||||
```
|
||||
|
||||
- 前端:提交前校验;不一致则 `ElMessage` / `ElMessageBox` 提示差额,**阻止保存**。
|
||||
- 后端:保存 `samplePayments` 时同样校验,失败返回业务错误(防绕过前端)。
|
||||
- 比较使用 Decimal,避免浮点误差(允许极小精度差如 `0.01` 元内视为相等,或按项目金额惯例四舍五入到分后比较)。
|
||||
|
||||
## 4. 非目标
|
||||
|
||||
- 催缴信息列表「应缴 / 已缴 / 欠缴」与本节缴费信息期次口径对齐(已缴含 `dfj`+`jfqrz`+`yjf`,欠缴=max(应缴−已缴,0));纳入规则仍用对齐后的欠缴>0。
|
||||
- 不恢复缴费信息四个流程 Tab。
|
||||
- 不改转账笔状态机枚举。
|
||||
|
||||
## 5. 测试要点
|
||||
|
||||
1. `summarize_period`:仅 `dfj` 计入已缴;首笔时间非最晚笔;欠缴计算。
|
||||
2. 列表 periods 含 `dueDate`、`arrearsAmount`,`displayPaidAmount` 含 `dfj`。
|
||||
3. 面板:改单期金额/日期不重建其它期;有 `yjf` 的期只读。
|
||||
4. 保存:合计 ≠ 合同金额前后端均拒绝;相等可保存成功。
|
||||
|
||||
## 6. 已确认决策
|
||||
|
||||
| 项 | 选择 |
|
||||
|----|------|
|
||||
| 合同金额与各期关系 | 各期独立改,不自动改写合同金额 |
|
||||
| 合计不一致 | 必须改到一致才能保存(前后端硬拦) |
|
||||
| 已缴费金额含状态 | `dfj` + `jfqrz` + `yjf` |
|
||||
| 已缴费时间 | 首笔转账时间(非最晚) |
|
||||
| 宽表期次子列 | 五列:应缴金额/应缴时间/已缴金额/已缴时间/欠缴金额 |
|
||||
@@ -0,0 +1,123 @@
|
||||
# 采集协议单 · 优惠价驱动缴费金额 — 设计说明
|
||||
|
||||
**日期**:2026-10-09
|
||||
**状态**:已确认
|
||||
**范围**:`web-admin` 采集协议单新增/编辑(细胞存储)、相关工具函数与保存校验;后端按样本缴费保存校验对齐
|
||||
**取代**:`2026-07-06-protocol-payment-defaults-design.md` 中的「样本数 × 缴费模式」硬编码价目表默认合同金额逻辑
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标
|
||||
|
||||
1. **废除价目表**:不再使用 `22880 / 25880 / 37880 / 41000 / 44000` 及各期写死拆分作为合同/应缴默认值。
|
||||
2. **优惠价为唯一金额源**(新建与变更时):
|
||||
- 样本合同金额 = 该样本优惠价 `price`
|
||||
- 样本合同合计 = Σ 各有效样本合同金额
|
||||
- 协议总金额默认 = 样本合同合计
|
||||
3. **分期不默认金额**:按「期数 + 间隔 + 开始日」生成期次与应缴日;每期金额留空手填;保存时每期金额与应缴时间必填,且各期之和 = 该样本合同金额。
|
||||
4. **历史已核对数据不被重算**:打开编辑以库内缴费为准;仅用户改优惠价时同步该样本合同。
|
||||
|
||||
---
|
||||
|
||||
## 2. 已确认产品决策
|
||||
|
||||
| # | 议题 | 决策 |
|
||||
|---|------|------|
|
||||
| D1 | 价目表 | **全部去掉**(新旧一律不再走价目表代码路径) |
|
||||
| D2 | 协议总金额 | **默认同步合计,可手改**;与合计不一致仅提示,不阻断 |
|
||||
| D3 | 分期期次生成 | 仍用期数 + 间隔 + 开始日;**金额空**;应缴日可预填可改 |
|
||||
| D4 | 历史打开 | **以库内缴费为准**;改优惠价才同步该样本合同 |
|
||||
| D5 | 实现范围 | **前端去价目 + 统一按优惠价;后端保存校验对齐**(方案 B);不做历史金额迁移脚本 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 金额规则
|
||||
|
||||
### 3.1 样本层
|
||||
|
||||
| 字段 | 规则 |
|
||||
|------|------|
|
||||
| 原价格 `originalPrice` | 仍可由样本类型 `storagePrice` 带出;不参与合同默认 |
|
||||
| 优惠价格 `price` | 用户设置;**合同金额来源** |
|
||||
| 样本合同金额 | = `price`(新建方案时;或用户改 `price` 后同步) |
|
||||
| 趸交应缴 | 单期 `dueAmount` = 合同金额 |
|
||||
| 分期各期 | 金额**不默认**;用户填写;Σ = 合同金额;每期须有应缴时间 |
|
||||
|
||||
### 3.2 协议层
|
||||
|
||||
| 字段 | 规则 |
|
||||
|------|------|
|
||||
| 样本合同合计 | Σ 各样本 `contractAmount`(只读展示) |
|
||||
| 协议总金额 | 未手改时跟随合计;手改后可偏离,仅弱提示 |
|
||||
|
||||
### 3.3 优惠价变更
|
||||
|
||||
- 无已财务确认(`yjf`)转账时:将该样本 `contractAmount` 更新为新优惠价。
|
||||
- 趸交:同步唯一期 `dueAmount`。
|
||||
- 分期:不自动改写各期金额;保存前须各期之和仍等于新合同,否则阻断并提示用户调整。
|
||||
|
||||
### 3.4 编辑打开
|
||||
|
||||
- **不**用当前优惠价覆盖库内 `contractAmount` / 各期 `dueAmount`。
|
||||
- 新建样本缴费方案时:合同 = 当时优惠价(无优惠价则合同为空,提示补价后再配缴费)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 校验
|
||||
|
||||
### 4.1 前端保存(v2 按样本)
|
||||
|
||||
| 规则 | 强度 |
|
||||
|------|------|
|
||||
| 有效样本须有样本编码(已有按样本方案时) | 阻断 |
|
||||
| 分期/分改趸:每期 `dueAmount` 有值且 > 0 | 阻断 |
|
||||
| 分期/分改趸:每期 `dueDate` 非空 | 阻断 |
|
||||
| 未作废期应缴合计 = 该样本合同金额 | 阻断 |
|
||||
| 协议总金额 ≠ 样本合同合计 | 仅提示 |
|
||||
| 样本合同与优惠价不一致(可选弱提示) | 仅提示(允许历史核对后手改合同的边界;新建以优惠价写入) |
|
||||
|
||||
### 4.2 后端
|
||||
|
||||
与前端阻断项对齐:`sample_payment_schedule_validate`(或等价)校验各期必填与合计 = 合同;不依赖价目表常量。
|
||||
|
||||
---
|
||||
|
||||
## 5. 删除 / 停用范围
|
||||
|
||||
| 项 | 处理 |
|
||||
|----|------|
|
||||
| `protocol-payment-presets.ts` 中 `PRESETS` 及 `resolvePaymentPreset` | 删除价目;保留 `getActiveSampleCount` / `availablePayModes` / `isInstallmentPayMode` 等非金额工具(若仍被引用) |
|
||||
| `resolvePerSamplePreset` / 硬编码 22880 等 | 删除;改由优惠价解析 |
|
||||
| `ProtocolFormDialog` v1:`applyPaymentPresetForMode`、样本数变化套价目 | 删除套用逻辑;v1 编辑仅展示库内值,不再填默认合同 |
|
||||
| 新建 | 一律 `paymentModelVersion=2`,不出现整单价目填充 |
|
||||
|
||||
**不做**:历史协议金额批量迁移;强制把 v1 明细拆成按样本(本期)。
|
||||
|
||||
---
|
||||
|
||||
## 6. UI 行为摘要(SamplePaymentPanel)
|
||||
|
||||
1. 样本增减:无方案则新建趸交方案,`contractAmount = price`(无 price 则 null)。
|
||||
2. 切换趸交:合同 = 优惠价,一期应缴 = 合同。
|
||||
3. 切换分期/分改趸:保留/写入期数间隔开始日;生成期次行时 **dueAmount 为空**;dueDate 按公式预填。
|
||||
4. 合同金额字段:可展示;与优惠价联动策略见 §3.3;用户仍可手改合同(保存时各期之和须等于合同)。
|
||||
5. 去掉「套用默认分期金额模板」的一切入口。
|
||||
|
||||
---
|
||||
|
||||
## 7. 非目标
|
||||
|
||||
- 意向金自动冲抵样本合同。
|
||||
- 强制协议总金额 = 样本合计。
|
||||
- 历史 v1 → v2 自动拆分迁移。
|
||||
- 价目表配置化(本期直接删除硬编码,不换成配置表)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 验收要点
|
||||
|
||||
1. 两样本优惠价 20000 + 22000 → 样本合同分别为 20000/22000,合计与默认协议总金额均为 42000。
|
||||
2. 分期生成后各期金额为空;不填金额或应缴日无法保存。
|
||||
3. 各期填满且之和 ≠ 合同 → 无法保存。
|
||||
4. 打开历史单金额与打开前一致;改某一优惠价后仅该样本合同(及趸交应缴)更新。
|
||||
5. 代码与测试中不再出现作为默认合同的 22880/25880/37880/41000/44000 套用路径。
|
||||
Reference in new issue
Block a user