提交
This commit is contained in:
1 parent
c3bd2c00ae
commit
a52fe77e34
39 files changed
+609647
-9238
No files matched your search
@@ -0,0 +1,947 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>列头过滤 Demo · 文本 / 数字 / 时间</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #10b981;
|
||||
--primary-dark: #059669;
|
||||
--primary-bg: #ecfdf5;
|
||||
--text: #1e293b;
|
||||
--text-muted: #64748b;
|
||||
--border: #e2e8f0;
|
||||
--table-head: #f8fafc;
|
||||
--danger: #dc2626;
|
||||
--warning: #d97706;
|
||||
--radius: 8px;
|
||||
--shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
|
||||
font-family: Inter, system-ui, -apple-system, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
color: var(--text);
|
||||
background: #f1f5f9;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.desc {
|
||||
margin: 0 0 16px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.chip strong { color: var(--text); font-weight: 600; }
|
||||
.dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: var(--primary);
|
||||
}
|
||||
.dot.num { background: #3b82f6; }
|
||||
.dot.date { background: var(--warning); }
|
||||
|
||||
.panel {
|
||||
background: #fff;
|
||||
border: 1px solid #d9e8e2;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
background: var(--table-head);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.toolbar .hint { color: var(--text-muted); font-size: 12px; flex: 1; min-width: 200px; }
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid #d9e8e2;
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
button:hover { border-color: #a7f3d0; background: var(--primary-bg); }
|
||||
button.primary {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
button.primary:hover { background: var(--primary-dark); border-color: var(--primary-dark); }
|
||||
button.ghost-danger { color: var(--danger); border-color: #fecaca; }
|
||||
button.ghost-danger:hover { background: #fef2f2; }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.table-wrap { overflow: auto; }
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 860px;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: var(--table-head);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
th .th-inner {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
th.align-right, td.align-right { text-align: right; }
|
||||
th.align-center, td.align-center { text-align: center; }
|
||||
|
||||
tbody tr:nth-child(even) { background: #fafbfc; }
|
||||
tbody tr:hover { background: #f0fdf4; }
|
||||
tbody tr.is-hidden { display: none; }
|
||||
|
||||
.filter-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.filter-btn:hover { background: #e2e8f0; color: var(--text); }
|
||||
.filter-btn.active {
|
||||
color: #fff;
|
||||
background: var(--primary);
|
||||
}
|
||||
.filter-btn svg { width: 14px; height: 14px; }
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
.tag.draft { background: #64748b; }
|
||||
.tag.pending { background: var(--warning); }
|
||||
.tag.approved { background: var(--primary-dark); }
|
||||
.tag.rejected { background: var(--danger); }
|
||||
|
||||
/* Popover panel */
|
||||
.popover {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
width: 280px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow);
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
max-height: min(420px, calc(100vh - 24px));
|
||||
}
|
||||
.popover.open { display: flex; }
|
||||
|
||||
.popover-hd {
|
||||
padding: 10px 12px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.popover-hd .title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.popover-hd .sub {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.popover-bd {
|
||||
padding: 10px 12px;
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.popover-ft {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--table-head);
|
||||
}
|
||||
|
||||
.field { margin-bottom: 10px; }
|
||||
.field:last-child { margin-bottom: 0; }
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
input[type="date"],
|
||||
input[type="datetime-local"],
|
||||
select {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
background: #fff;
|
||||
}
|
||||
input:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
|
||||
.range-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.range-row .sep { color: var(--text-muted); font-size: 12px; text-align: center; }
|
||||
|
||||
.ops {
|
||||
display: grid;
|
||||
grid-template-columns: 110px 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.value-list {
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 4px;
|
||||
}
|
||||
.value-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.value-item:hover { background: var(--primary-bg); }
|
||||
.value-item input { width: auto; margin: 0; }
|
||||
.value-item .count {
|
||||
margin-left: auto;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.select-all-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.select-all-row button {
|
||||
height: 26px;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
.select-all-row button:hover { background: var(--primary-bg); }
|
||||
|
||||
.empty-tip {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.footer-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.notes {
|
||||
margin-top: 20px;
|
||||
padding: 14px 16px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.notes h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
}
|
||||
.notes ul { margin: 0; padding-left: 18px; }
|
||||
.notes li { margin-bottom: 4px; }
|
||||
.notes code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
background: #f1f5f9;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<h1>Excel 风格列头过滤 Demo</h1>
|
||||
<p class="desc">
|
||||
模拟报销申请列表:点击表头漏斗图标过滤<strong>当前页数据</strong>。
|
||||
已覆盖 <strong>文本</strong>、<strong>数字</strong>、<strong>日期/时间</strong>三类控件。
|
||||
</p>
|
||||
|
||||
<div class="legend">
|
||||
<span class="chip"><span class="dot"></span><strong>文本</strong>:搜索 + 勾选本页去重值</span>
|
||||
<span class="chip"><span class="dot num"></span><strong>数字</strong>:运算符 + 单值 / 区间</span>
|
||||
<span class="chip"><span class="dot date"></span><strong>时间</strong>:日期区间(可扩到时分)</span>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<div class="toolbar">
|
||||
<span class="hint" id="activeFilterHint">当前无列过滤</span>
|
||||
<button type="button" id="btnClearAll" class="ghost-danger" disabled>清空全部过滤</button>
|
||||
<button type="button" id="btnResetData">恢复演示数据</button>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table id="demoTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="align-center" style="width:56px">序号</th>
|
||||
<th data-col="applyCode" data-type="text">
|
||||
<span class="th-inner">报销单号<button type="button" class="filter-btn" data-col="applyCode" aria-label="过滤报销单号"></button></span>
|
||||
</th>
|
||||
<th data-col="title" data-type="text">
|
||||
<span class="th-inner">报销标题<button type="button" class="filter-btn" data-col="title" aria-label="过滤报销标题"></button></span>
|
||||
</th>
|
||||
<th data-col="expenseName" data-type="text">
|
||||
<span class="th-inner">费用项目<button type="button" class="filter-btn" data-col="expenseName" aria-label="过滤费用项目"></button></span>
|
||||
</th>
|
||||
<th class="align-right" data-col="amount" data-type="number">
|
||||
<span class="th-inner">报销金额(元)<button type="button" class="filter-btn" data-col="amount" aria-label="过滤金额"></button></span>
|
||||
</th>
|
||||
<th class="align-center" data-col="status" data-type="enum">
|
||||
<span class="th-inner">状态<button type="button" class="filter-btn" data-col="status" aria-label="过滤状态"></button></span>
|
||||
</th>
|
||||
<th class="align-center" data-col="applyTime" data-type="datetime">
|
||||
<span class="th-inner">申请时间<button type="button" class="filter-btn" data-col="applyTime" aria-label="过滤申请时间"></button></span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="footer-bar">
|
||||
<span id="pageStat">显示 0 / 0 条(当前页)</span>
|
||||
<span>说明:本 Demo 仅过滤当前表格内数据,不请求后端</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="notes">
|
||||
<h2>各数据类型过滤建议</h2>
|
||||
<ul>
|
||||
<li><strong>文本 / 枚举</strong>:漏斗内「关键词搜索」缩小可选值,再多选勾选;枚举列(状态)可固定选项,不必依赖本页去重。</li>
|
||||
<li><strong>数字</strong>:提供运算符(等于 / 大于 / 小于 / 介于);「介于」用最小值~最大值;注意金额展示与比较用原始数值。</li>
|
||||
<li><strong>日期 / 时间</strong>:默认「开始日期~结束日期」闭区间;若业务要精确到时分,用 <code>datetime-local</code> 或拆成日期+时间两段。</li>
|
||||
<li><strong>组合</strong>:多列过滤取交集(AND);任一列清空后只影响该列。</li>
|
||||
<li><strong>与顶部筛选并存时</strong>:顶部筛选 = 查库;列头过滤 = 当前页二次过滤,文案上要区分,避免用户误以为筛了全库。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shared popover -->
|
||||
<div class="popover" id="popover" role="dialog" aria-modal="false">
|
||||
<div class="popover-hd">
|
||||
<div class="title" id="popTitle">过滤</div>
|
||||
<div class="sub" id="popSub">按本列筛选当前页</div>
|
||||
</div>
|
||||
<div class="popover-bd" id="popBody"></div>
|
||||
<div class="popover-ft">
|
||||
<button type="button" id="popClear">清空本列</button>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button type="button" id="popCancel">取消</button>
|
||||
<button type="button" class="primary" id="popOk">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const STATUS_MAP = {
|
||||
draft: { label: '待提交', cls: 'draft' },
|
||||
pending: { label: '审批中', cls: 'pending' },
|
||||
approved: { label: '审批通过', cls: 'approved' },
|
||||
rejected: { label: '审批驳回', cls: 'rejected' }
|
||||
}
|
||||
|
||||
const COLUMNS = {
|
||||
applyCode: { label: '报销单号', type: 'text' },
|
||||
title: { label: '报销标题', type: 'text' },
|
||||
expenseName: { label: '费用项目', type: 'text' },
|
||||
amount: { label: '报销金额(元)', type: 'number' },
|
||||
status: { label: '状态', type: 'enum', enumOptions: Object.keys(STATUS_MAP) },
|
||||
applyTime: { label: '申请时间', type: 'datetime' }
|
||||
}
|
||||
|
||||
/** Demo rows — mixed types */
|
||||
const DEMO_ROWS = [
|
||||
{ applyCode: 'BX20260818001', title: '差旅报销-上海客户拜访', expenseName: '差旅费 / 交通费', amount: 1280.5, status: 'pending', applyTime: '2026-08-10 09:20:00' },
|
||||
{ applyCode: 'BX20260818002', title: '办公用品采购', expenseName: '办公费 / 耗材', amount: 356, status: 'draft', applyTime: '2026-08-11 14:05:00' },
|
||||
{ applyCode: 'BX20260818003', title: '团队团建餐费', expenseName: '业务招待费', amount: 980, status: 'approved', applyTime: '2026-08-12 18:40:00' },
|
||||
{ applyCode: 'BX20260818004', title: '快递运费报销', expenseName: '物流费', amount: 86.2, status: 'rejected', applyTime: '2026-08-13 10:12:00' },
|
||||
{ applyCode: 'BX20260818005', title: '培训报名费', expenseName: '培训费', amount: 2500, status: 'pending', applyTime: '2026-08-14 11:30:00' },
|
||||
{ applyCode: 'BX20260818006', title: '差旅报销-广州展会', expenseName: '差旅费 / 住宿费', amount: 1860, status: 'approved', applyTime: '2026-08-15 08:55:00' },
|
||||
{ applyCode: 'BX20260818007', title: '加油站加油费', expenseName: '车辆使用费', amount: 420.8, status: 'draft', applyTime: '2026-08-16 16:22:00' },
|
||||
{ applyCode: 'BX20260818008', title: '软件订阅年费', expenseName: '信息化费用', amount: 3999, status: 'pending', applyTime: '2026-08-17 09:08:00' },
|
||||
{ applyCode: 'BX20260818009', title: '客户招待午餐', expenseName: '业务招待费', amount: 680, status: 'approved', applyTime: '2026-08-17 13:45:00' },
|
||||
{ applyCode: 'BX20260818010', title: '打印机维修', expenseName: '办公费 / 维修', amount: 230, status: 'draft', applyTime: '2026-08-18 10:01:00' }
|
||||
]
|
||||
|
||||
/** Active filters: { colKey: filterState } */
|
||||
const filters = {}
|
||||
|
||||
let draft = null // { col, type, ... } while popover open
|
||||
let openCol = null
|
||||
|
||||
const funnelSvg = `
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M3 5a1 1 0 0 1 1-1h16a1 1 0 0 1 .8 1.6L15 13v5.2a1 1 0 0 1-1.45.9l-3-1.5A1 1 0 0 1 10 16.7V13L3.2 5.6A1 1 0 0 1 3 5z"/>
|
||||
</svg>`
|
||||
|
||||
document.querySelectorAll('.filter-btn').forEach((btn) => {
|
||||
btn.innerHTML = funnelSvg
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
openFilter(btn.dataset.col, btn)
|
||||
})
|
||||
})
|
||||
|
||||
function formatMoney(n) {
|
||||
return Number(n).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
function formatDateOnly(s) {
|
||||
return String(s || '').slice(0, 10)
|
||||
}
|
||||
|
||||
function parseDate(s) {
|
||||
// Treat as local date for range compare (date part or full datetime)
|
||||
const t = Date.parse(String(s).replace(/-/g, '/'))
|
||||
return Number.isNaN(t) ? null : t
|
||||
}
|
||||
|
||||
function startOfDay(yyyyMmDd) {
|
||||
return parseDate(yyyyMmDd + ' 00:00:00')
|
||||
}
|
||||
|
||||
function endOfDay(yyyyMmDd) {
|
||||
return parseDate(yyyyMmDd + ' 23:59:59')
|
||||
}
|
||||
|
||||
function displayValue(col, row) {
|
||||
if (col === 'status') return STATUS_MAP[row.status]?.label || row.status
|
||||
if (col === 'amount') return formatMoney(row.amount)
|
||||
if (col === 'applyTime') return formatDateOnly(row.applyTime)
|
||||
return String(row[col] ?? '')
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const tbody = document.getElementById('tbody')
|
||||
tbody.innerHTML = ''
|
||||
let visible = 0
|
||||
|
||||
DEMO_ROWS.forEach((row, idx) => {
|
||||
const pass = matchAllFilters(row)
|
||||
if (pass) visible += 1
|
||||
|
||||
const tr = document.createElement('tr')
|
||||
if (!pass) tr.classList.add('is-hidden')
|
||||
|
||||
const st = STATUS_MAP[row.status]
|
||||
tr.innerHTML = `
|
||||
<td class="align-center">${idx + 1}</td>
|
||||
<td>${escapeHtml(row.applyCode)}</td>
|
||||
<td>${escapeHtml(row.title)}</td>
|
||||
<td>${escapeHtml(row.expenseName)}</td>
|
||||
<td class="align-right">${formatMoney(row.amount)}</td>
|
||||
<td class="align-center"><span class="tag ${st.cls}">${st.label}</span></td>
|
||||
<td class="align-center">${formatDateOnly(row.applyTime)}</td>
|
||||
`
|
||||
tbody.appendChild(tr)
|
||||
})
|
||||
|
||||
document.getElementById('pageStat').textContent =
|
||||
`显示 ${visible} / ${DEMO_ROWS.length} 条(当前页)`
|
||||
updateFilterUi()
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function matchAllFilters(row) {
|
||||
return Object.keys(filters).every((col) => matchOne(col, filters[col], row))
|
||||
}
|
||||
|
||||
function matchOne(col, f, row) {
|
||||
if (!f) return true
|
||||
const meta = COLUMNS[col]
|
||||
const type = meta.type
|
||||
|
||||
if (type === 'text' || type === 'enum') {
|
||||
const selected = f.selected
|
||||
if (!selected || selected.length === 0) return true
|
||||
const raw = type === 'enum' ? row[col] : String(row[col] ?? '')
|
||||
return selected.includes(raw)
|
||||
}
|
||||
|
||||
if (type === 'number') {
|
||||
const val = Number(row[col])
|
||||
const op = f.op || 'between'
|
||||
if (op === 'eq') return f.value !== '' && val === Number(f.value)
|
||||
if (op === 'gt') return f.value !== '' && val > Number(f.value)
|
||||
if (op === 'lt') return f.value !== '' && val < Number(f.value)
|
||||
if (op === 'gte') return f.value !== '' && val >= Number(f.value)
|
||||
if (op === 'lte') return f.value !== '' && val <= Number(f.value)
|
||||
// between
|
||||
const minOk = f.min === '' || f.min == null || val >= Number(f.min)
|
||||
const maxOk = f.max === '' || f.max == null || val <= Number(f.max)
|
||||
if ((f.min === '' || f.min == null) && (f.max === '' || f.max == null)) return true
|
||||
return minOk && maxOk
|
||||
}
|
||||
|
||||
if (type === 'datetime') {
|
||||
const t = parseDate(row[col])
|
||||
if (t == null) return false
|
||||
const fromOk = !f.from || t >= startOfDay(f.from)
|
||||
const toOk = !f.to || t <= endOfDay(f.to)
|
||||
if (!f.from && !f.to) return true
|
||||
return fromOk && toOk
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function updateFilterUi() {
|
||||
const activeCols = Object.keys(filters).filter((k) => isFilterActive(k, filters[k]))
|
||||
document.querySelectorAll('.filter-btn').forEach((btn) => {
|
||||
btn.classList.toggle('active', activeCols.includes(btn.dataset.col))
|
||||
})
|
||||
const hint = document.getElementById('activeFilterHint')
|
||||
const clearBtn = document.getElementById('btnClearAll')
|
||||
if (activeCols.length === 0) {
|
||||
hint.textContent = '当前无列过滤'
|
||||
clearBtn.disabled = true
|
||||
} else {
|
||||
hint.textContent = '已过滤列:' + activeCols.map((c) => COLUMNS[c].label).join('、')
|
||||
clearBtn.disabled = false
|
||||
}
|
||||
}
|
||||
|
||||
function isFilterActive(col, f) {
|
||||
if (!f) return false
|
||||
const type = COLUMNS[col].type
|
||||
if (type === 'text' || type === 'enum') return Array.isArray(f.selected) && f.selected.length > 0
|
||||
if (type === 'number') {
|
||||
if (f.op === 'between') return (f.min !== '' && f.min != null) || (f.max !== '' && f.max != null)
|
||||
return f.value !== '' && f.value != null
|
||||
}
|
||||
if (type === 'datetime') return !!(f.from || f.to)
|
||||
return false
|
||||
}
|
||||
|
||||
function uniqueValues(col) {
|
||||
const map = new Map()
|
||||
DEMO_ROWS.forEach((row) => {
|
||||
const key = COLUMNS[col].type === 'enum' ? row[col] : String(row[col] ?? '')
|
||||
map.set(key, (map.get(key) || 0) + 1)
|
||||
})
|
||||
return [...map.entries()].sort((a, b) => String(a[0]).localeCompare(String(b[0]), 'zh-CN'))
|
||||
}
|
||||
|
||||
function openFilter(col, anchorBtn) {
|
||||
const meta = COLUMNS[col]
|
||||
openCol = col
|
||||
draft = cloneFilter(col, filters[col])
|
||||
|
||||
const pop = document.getElementById('popover')
|
||||
document.getElementById('popTitle').textContent = '过滤 · ' + meta.label
|
||||
document.getElementById('popSub').textContent =
|
||||
meta.type === 'text' ? '文本:搜索并勾选本页出现的值'
|
||||
: meta.type === 'enum' ? '枚举:勾选状态(固定选项)'
|
||||
: meta.type === 'number' ? '数字:运算符 + 单值或区间'
|
||||
: '时间:按日期闭区间过滤(含起止当天)'
|
||||
|
||||
document.getElementById('popBody').innerHTML = buildBodyHtml(col, draft)
|
||||
wireBodyEvents(col)
|
||||
|
||||
positionPopover(pop, anchorBtn)
|
||||
pop.classList.add('open')
|
||||
}
|
||||
|
||||
function cloneFilter(col, f) {
|
||||
const type = COLUMNS[col].type
|
||||
if (!f) {
|
||||
if (type === 'text' || type === 'enum') return { selected: [], keyword: '' }
|
||||
if (type === 'number') return { op: 'between', value: '', min: '', max: '' }
|
||||
if (type === 'datetime') return { from: '', to: '' }
|
||||
}
|
||||
return JSON.parse(JSON.stringify(f))
|
||||
}
|
||||
|
||||
function buildBodyHtml(col, f) {
|
||||
const type = COLUMNS[col].type
|
||||
|
||||
if (type === 'text' || type === 'enum') {
|
||||
const options = type === 'enum'
|
||||
? COLUMNS[col].enumOptions.map((k) => [k, DEMO_ROWS.filter((r) => r.status === k).length])
|
||||
: uniqueValues(col)
|
||||
|
||||
const items = options.map(([key, count]) => {
|
||||
const checked = f.selected.includes(key) ? 'checked' : ''
|
||||
const label = type === 'enum' ? STATUS_MAP[key].label : key
|
||||
return `
|
||||
<label class="value-item" data-key="${escapeHtml(key)}">
|
||||
<input type="checkbox" value="${escapeHtml(key)}" ${checked} />
|
||||
<span>${escapeHtml(label)}</span>
|
||||
<span class="count">${count}</span>
|
||||
</label>`
|
||||
}).join('')
|
||||
|
||||
return `
|
||||
<div class="field">
|
||||
<label>搜索可选值</label>
|
||||
<input type="text" id="kwInput" placeholder="输入关键字过滤下方列表" value="${escapeHtml(f.keyword || '')}" />
|
||||
</div>
|
||||
<div class="select-all-row">
|
||||
<span>本页可选值</span>
|
||||
<span>
|
||||
<button type="button" id="btnSelectAll">全选</button>
|
||||
<button type="button" id="btnSelectNone">全不选</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="value-list" id="valueList">${items || '<div class="empty-tip">无数据</div>'}</div>
|
||||
`
|
||||
}
|
||||
|
||||
if (type === 'number') {
|
||||
const showSingle = f.op !== 'between'
|
||||
return `
|
||||
<div class="field">
|
||||
<label>条件</label>
|
||||
<div class="ops">
|
||||
<select id="numOp">
|
||||
<option value="between" ${f.op === 'between' ? 'selected' : ''}>介于</option>
|
||||
<option value="eq" ${f.op === 'eq' ? 'selected' : ''}>等于</option>
|
||||
<option value="gt" ${f.op === 'gt' ? 'selected' : ''}>大于</option>
|
||||
<option value="gte" ${f.op === 'gte' ? 'selected' : ''}>大于等于</option>
|
||||
<option value="lt" ${f.op === 'lt' ? 'selected' : ''}>小于</option>
|
||||
<option value="lte" ${f.op === 'lte' ? 'selected' : ''}>小于等于</option>
|
||||
</select>
|
||||
<div id="numSingleWrap" style="${showSingle ? '' : 'display:none'}">
|
||||
<input type="number" id="numValue" step="0.01" placeholder="输入金额" value="${escapeHtml(f.value ?? '')}" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field" id="numRangeWrap" style="${showSingle ? 'display:none' : ''}">
|
||||
<label>金额区间(元)</label>
|
||||
<div class="range-row">
|
||||
<input type="number" id="numMin" step="0.01" placeholder="最小值" value="${escapeHtml(f.min ?? '')}" />
|
||||
<span class="sep">至</span>
|
||||
<input type="number" id="numMax" step="0.01" placeholder="最大值" value="${escapeHtml(f.max ?? '')}" />
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin:8px 0 0;font-size:11px;color:var(--text-muted)">
|
||||
提示:比较使用原始数值;本页金额范围约 ${formatMoney(Math.min(...DEMO_ROWS.map(r => r.amount)))} ~ ${formatMoney(Math.max(...DEMO_ROWS.map(r => r.amount)))}
|
||||
</p>
|
||||
`
|
||||
}
|
||||
|
||||
if (type === 'datetime') {
|
||||
return `
|
||||
<div class="field">
|
||||
<label>开始日期</label>
|
||||
<input type="date" id="dateFrom" value="${escapeHtml(f.from || '')}" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>结束日期</label>
|
||||
<input type="date" id="dateTo" value="${escapeHtml(f.to || '')}" />
|
||||
</div>
|
||||
<p style="margin:8px 0 0;font-size:11px;color:var(--text-muted)">
|
||||
闭区间:含起止当天 00:00:00~23:59:59。若需时分秒,可把控件换成 <code>datetime-local</code>。
|
||||
</p>
|
||||
`
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function wireBodyEvents(col) {
|
||||
const type = COLUMNS[col].type
|
||||
|
||||
if (type === 'text' || type === 'enum') {
|
||||
const kw = document.getElementById('kwInput')
|
||||
const list = document.getElementById('valueList')
|
||||
|
||||
kw.addEventListener('input', () => {
|
||||
draft.keyword = kw.value.trim()
|
||||
const q = draft.keyword.toLowerCase()
|
||||
list.querySelectorAll('.value-item').forEach((el) => {
|
||||
const key = el.dataset.key
|
||||
const label = COLUMNS[col].type === 'enum' ? STATUS_MAP[key].label : key
|
||||
const hit = !q || label.toLowerCase().includes(q) || String(key).toLowerCase().includes(q)
|
||||
el.style.display = hit ? '' : 'none'
|
||||
})
|
||||
})
|
||||
|
||||
list.addEventListener('change', (e) => {
|
||||
if (e.target.type !== 'checkbox') return
|
||||
const val = e.target.value
|
||||
if (e.target.checked) {
|
||||
if (!draft.selected.includes(val)) draft.selected.push(val)
|
||||
} else {
|
||||
draft.selected = draft.selected.filter((x) => x !== val)
|
||||
}
|
||||
})
|
||||
|
||||
document.getElementById('btnSelectAll').addEventListener('click', () => {
|
||||
list.querySelectorAll('.value-item').forEach((el) => {
|
||||
if (el.style.display === 'none') return
|
||||
const cb = el.querySelector('input')
|
||||
cb.checked = true
|
||||
if (!draft.selected.includes(cb.value)) draft.selected.push(cb.value)
|
||||
})
|
||||
})
|
||||
|
||||
document.getElementById('btnSelectNone').addEventListener('click', () => {
|
||||
list.querySelectorAll('.value-item').forEach((el) => {
|
||||
if (el.style.display === 'none') return
|
||||
const cb = el.querySelector('input')
|
||||
cb.checked = false
|
||||
draft.selected = draft.selected.filter((x) => x !== cb.value)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (type === 'number') {
|
||||
const op = document.getElementById('numOp')
|
||||
const sync = () => {
|
||||
draft.op = op.value
|
||||
const single = draft.op !== 'between'
|
||||
document.getElementById('numSingleWrap').style.display = single ? '' : 'none'
|
||||
document.getElementById('numRangeWrap').style.display = single ? 'none' : ''
|
||||
}
|
||||
op.addEventListener('change', sync)
|
||||
;['numValue', 'numMin', 'numMax'].forEach((id) => {
|
||||
const el = document.getElementById(id)
|
||||
if (!el) return
|
||||
el.addEventListener('input', () => {
|
||||
if (id === 'numValue') draft.value = el.value
|
||||
if (id === 'numMin') draft.min = el.value
|
||||
if (id === 'numMax') draft.max = el.value
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (type === 'datetime') {
|
||||
document.getElementById('dateFrom').addEventListener('change', (e) => { draft.from = e.target.value })
|
||||
document.getElementById('dateTo').addEventListener('change', (e) => { draft.to = e.target.value })
|
||||
}
|
||||
}
|
||||
|
||||
function readDraftFromDom(col) {
|
||||
const type = COLUMNS[col].type
|
||||
if (type === 'number') {
|
||||
draft.op = document.getElementById('numOp').value
|
||||
draft.value = document.getElementById('numValue')?.value ?? ''
|
||||
draft.min = document.getElementById('numMin')?.value ?? ''
|
||||
draft.max = document.getElementById('numMax')?.value ?? ''
|
||||
}
|
||||
if (type === 'datetime') {
|
||||
draft.from = document.getElementById('dateFrom').value
|
||||
draft.to = document.getElementById('dateTo').value
|
||||
}
|
||||
}
|
||||
|
||||
function applyDraft() {
|
||||
if (!openCol) return
|
||||
readDraftFromDom(openCol)
|
||||
|
||||
// Validate number range
|
||||
if (COLUMNS[openCol].type === 'number' && draft.op === 'between') {
|
||||
if (draft.min !== '' && draft.max !== '' && Number(draft.min) > Number(draft.max)) {
|
||||
alert('最小值不能大于最大值')
|
||||
return
|
||||
}
|
||||
}
|
||||
if (COLUMNS[openCol].type === 'datetime') {
|
||||
if (draft.from && draft.to && draft.from > draft.to) {
|
||||
alert('开始日期不能晚于结束日期')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (isFilterActive(openCol, draft)) {
|
||||
filters[openCol] = JSON.parse(JSON.stringify(draft))
|
||||
} else {
|
||||
delete filters[openCol]
|
||||
}
|
||||
closePopover()
|
||||
renderTable()
|
||||
}
|
||||
|
||||
function clearCurrentCol() {
|
||||
if (!openCol) return
|
||||
delete filters[openCol]
|
||||
closePopover()
|
||||
renderTable()
|
||||
}
|
||||
|
||||
function clearAllFilters() {
|
||||
Object.keys(filters).forEach((k) => delete filters[k])
|
||||
closePopover()
|
||||
renderTable()
|
||||
}
|
||||
|
||||
function closePopover() {
|
||||
document.getElementById('popover').classList.remove('open')
|
||||
openCol = null
|
||||
draft = null
|
||||
}
|
||||
|
||||
function positionPopover(pop, anchor) {
|
||||
const rect = anchor.getBoundingClientRect()
|
||||
const pad = 8
|
||||
let left = rect.left
|
||||
let top = rect.bottom + 6
|
||||
|
||||
pop.style.left = '0px'
|
||||
pop.style.top = '0px'
|
||||
pop.classList.add('open')
|
||||
const pw = pop.offsetWidth
|
||||
const ph = pop.offsetHeight
|
||||
|
||||
if (left + pw > window.innerWidth - pad) left = window.innerWidth - pw - pad
|
||||
if (left < pad) left = pad
|
||||
if (top + ph > window.innerHeight - pad) top = rect.top - ph - 6
|
||||
if (top < pad) top = pad
|
||||
|
||||
pop.style.left = left + 'px'
|
||||
pop.style.top = top + 'px'
|
||||
}
|
||||
|
||||
document.getElementById('popOk').addEventListener('click', applyDraft)
|
||||
document.getElementById('popCancel').addEventListener('click', closePopover)
|
||||
document.getElementById('popClear').addEventListener('click', clearCurrentCol)
|
||||
document.getElementById('btnClearAll').addEventListener('click', clearAllFilters)
|
||||
document.getElementById('btnResetData').addEventListener('click', () => {
|
||||
clearAllFilters()
|
||||
})
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
const pop = document.getElementById('popover')
|
||||
if (!pop.classList.contains('open')) return
|
||||
if (pop.contains(e.target)) return
|
||||
if (e.target.closest('.filter-btn')) return
|
||||
closePopover()
|
||||
})
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closePopover()
|
||||
})
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
if (document.getElementById('popover').classList.contains('open')) closePopover()
|
||||
})
|
||||
|
||||
renderTable()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,897 @@
|
||||
# 报销申请批量导入(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 下拉、填写说明与示例行;导入按分组号合成多明细草稿,支持部分成功。
|
||||
|
||||
**Architecture:** 后端用 openpyxl 动态生成含隐藏选项表 + DataValidation 的 xlsx;导入服务解析行、按分组号分桶、校验单头一致后复用 `create_with_details` 写 `draft`。纯函数(路径文案、申请人文案、分组、示例识别)单独可测;API 薄封装。前端工具栏 + 导入结果弹窗,blob/FormData 走已有 `bxRequest`(`/api/reimburses` 已在加密排除列表)。
|
||||
|
||||
**Tech Stack:** FastAPI、SQLAlchemy、openpyxl、pytest、Vue 3、Element Plus、`bxRequest`
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-08-18-reimburse-apply-batch-import-design.md`
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 操作 | 文件 | 职责 |
|
||||
|------|------|------|
|
||||
| Create | `backend/app/utils/excel_list_validation.py` | 隐藏选项表 + list DataValidation 辅助 |
|
||||
| Create | `backend/app/services/bx/reimburse_import.py` | 文案/分组纯函数、建模板、导入写库 |
|
||||
| Create | `backend/tests/test_bx_reimburse_import.py` | 纯函数与模板/解析单测 |
|
||||
| Modify | `backend/app/api/bx/reimburse.py` | `GET import-template`、`POST import` |
|
||||
| Modify | `web-admin/src/api/bx/index.ts` | `downloadImportTemplate`、`importExcel` |
|
||||
| Modify | `web-admin/src/views/bx/reimburse/apply/index.vue` | 工具栏按钮 + 导入弹窗 |
|
||||
|
||||
**不变:** 发票关联、自动提交、bx-mobile、CRM 导入、加密排除列表(已含 `/api/reimburses`)。
|
||||
|
||||
**常量约定(实现时写在 `reimburse_import.py` 顶部):**
|
||||
|
||||
```python
|
||||
IMPORT_SHEET = "报销导入"
|
||||
GUIDE_SHEET = "填写说明"
|
||||
EXPENSE_OPT_SHEET = "_选项_费用项目"
|
||||
APPLICANT_OPT_SHEET = "_选项_申请人"
|
||||
EXAMPLE_REMARK_PREFIX = "【示例】"
|
||||
MAX_IMPORT_ROWS = 500
|
||||
PATH_SEP = " / "
|
||||
HEADERS = [
|
||||
"分组号", "申请人", "报销日期", "报销标题",
|
||||
"费用项目", "金额(元)", "报销事由", "发生时间",
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 纯函数 — 费用路径 / 申请人文案 / 示例行 / 分组
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/services/bx/reimburse_import.py`(先只放纯函数)
|
||||
- Create: `backend/tests/test_bx_reimburse_import.py`
|
||||
|
||||
- [ ] **Step 1: 写失败单测**
|
||||
|
||||
```python
|
||||
# backend/tests/test_bx_reimburse_import.py
|
||||
from app.services.bx.reimburse_import import (
|
||||
build_applicant_label,
|
||||
build_expense_path_labels,
|
||||
is_example_row,
|
||||
partition_import_groups,
|
||||
validate_group_headers,
|
||||
)
|
||||
|
||||
|
||||
def test_build_expense_path_labels_unique_and_enabled_only():
|
||||
# flat nodes: id, parent_id, name, status, item_code
|
||||
nodes = [
|
||||
{"id": "1", "parent_id": "0", "item_name": "差旅费", "status": 1, "item_code": "CL"},
|
||||
{"id": "2", "parent_id": "1", "item_name": "市内交通", "status": 1, "item_code": "JT"},
|
||||
{"id": "3", "parent_id": "1", "item_name": "住宿费", "status": 0, "item_code": "ZS"},
|
||||
{"id": "4", "parent_id": "0", "item_name": "办公费", "status": 1, "item_code": "BG"},
|
||||
]
|
||||
labels = build_expense_path_labels(nodes)
|
||||
assert labels["2"] == "差旅费 / 市内交通"
|
||||
assert labels["1"] == "差旅费"
|
||||
assert labels["4"] == "办公费"
|
||||
assert "3" not in labels # 停用
|
||||
|
||||
|
||||
def test_build_expense_path_labels_disambiguate_collision():
|
||||
nodes = [
|
||||
{"id": "a", "parent_id": "0", "item_name": "同名", "status": 1, "item_code": "A1"},
|
||||
{"id": "b", "parent_id": "0", "item_name": "同名", "status": 1, "item_code": "B1"},
|
||||
]
|
||||
labels = build_expense_path_labels(nodes)
|
||||
assert labels["a"] != labels["b"]
|
||||
assert "编码" in labels["a"] or "编码" in labels["b"] or labels["a"].endswith(")")
|
||||
|
||||
|
||||
def test_build_applicant_label():
|
||||
assert build_applicant_label("张三", "z001", "uid-abcdef") == "张三(z001)"
|
||||
assert build_applicant_label("李四", "", "abcdefghij") == "李四(efghij)"
|
||||
|
||||
|
||||
def test_is_example_row():
|
||||
assert is_example_row({"报销事由": "【示例】地铁"}) is True
|
||||
assert is_example_row({"报销事由": "正常事由"}) is False
|
||||
|
||||
|
||||
def test_partition_empty_group_is_solo():
|
||||
rows = [
|
||||
{"_row": 2, "分组号": "G1", "申请人": "A", "报销日期": "2026-08-18", "报销标题": "", "费用项目": "x", "金额(元)": 1, "报销事由": "r1", "发生时间": ""},
|
||||
{"_row": 3, "分组号": "G1", "申请人": "A", "报销日期": "2026-08-18", "报销标题": "", "费用项目": "y", "金额(元)": 2, "报销事由": "r2", "发生时间": ""},
|
||||
{"_row": 4, "分组号": "", "申请人": "B", "报销日期": "2026-08-18", "报销标题": "", "费用项目": "z", "金额(元)": 3, "报销事由": "r3", "发生时间": ""},
|
||||
]
|
||||
groups = partition_import_groups(rows)
|
||||
assert len(groups) == 2 # G1 一组 + 空分组号一行一组
|
||||
g1 = next(g for g in groups if g["group_no"] == "G1")
|
||||
assert len(g1["rows"]) == 2
|
||||
solo = next(g for g in groups if g["group_no"] == "")
|
||||
assert len(solo["rows"]) == 1
|
||||
|
||||
|
||||
def test_validate_group_headers_mismatch():
|
||||
rows = [
|
||||
{"_row": 2, "申请人": "A", "报销日期": "2026-08-18", "报销标题": ""},
|
||||
{"_row": 3, "申请人": "B", "报销日期": "2026-08-18", "报销标题": ""},
|
||||
]
|
||||
err = validate_group_headers(rows)
|
||||
assert err and "申请人" in err
|
||||
|
||||
|
||||
def test_validate_group_headers_title_partial_empty():
|
||||
rows = [
|
||||
{"_row": 2, "申请人": "A", "报销日期": "2026-08-18", "报销标题": "有标题"},
|
||||
{"_row": 3, "申请人": "A", "报销日期": "2026-08-18", "报销标题": ""},
|
||||
]
|
||||
err = validate_group_headers(rows)
|
||||
assert err and "标题" in err
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测确认失败**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && pytest tests/test_bx_reimburse_import.py -v
|
||||
```
|
||||
|
||||
Expected: FAIL(模块/函数不存在)
|
||||
|
||||
- [ ] **Step 3: 实现纯函数**
|
||||
|
||||
在 `backend/app/services/bx/reimburse_import.py` 实现:
|
||||
|
||||
```python
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
EXAMPLE_REMARK_PREFIX = "【示例】"
|
||||
PATH_SEP = " / "
|
||||
MAX_IMPORT_ROWS = 500
|
||||
|
||||
HEADERS = [
|
||||
"分组号", "申请人", "报销日期", "报销标题",
|
||||
"费用项目", "金额(元)", "报销事由", "发生时间",
|
||||
]
|
||||
|
||||
|
||||
def build_applicant_label(nick_name: str, user_no: str, user_id: str) -> str:
|
||||
name = (nick_name or user_no or user_id or "").strip() or user_id
|
||||
suffix = (user_no or "").strip() or str(user_id)[-6:]
|
||||
return f"{name}({suffix})"
|
||||
|
||||
|
||||
def build_expense_path_labels(nodes: List[Dict[str, Any]]) -> Dict[str, str]:
|
||||
"""返回 {id: 展示文案},仅 status==1;冲突时追加 (编码:xxx)。"""
|
||||
by_id = {str(n["id"]): n for n in nodes}
|
||||
enabled = [n for n in nodes if int(n.get("status") or 0) == 1]
|
||||
|
||||
def path_of(nid: str) -> str:
|
||||
parts: List[str] = []
|
||||
cur = by_id.get(nid)
|
||||
guard = 0
|
||||
while cur and guard < 20:
|
||||
parts.append(str(cur.get("item_name") or "").strip())
|
||||
pid = str(cur.get("parent_id") or "0")
|
||||
if not pid or pid == "0":
|
||||
break
|
||||
cur = by_id.get(pid)
|
||||
guard += 1
|
||||
return PATH_SEP.join(reversed([p for p in parts if p]))
|
||||
|
||||
raw: Dict[str, str] = {str(n["id"]): path_of(str(n["id"])) for n in enabled}
|
||||
# 冲突消歧
|
||||
path_to_ids: Dict[str, List[str]] = {}
|
||||
for iid, p in raw.items():
|
||||
path_to_ids.setdefault(p, []).append(iid)
|
||||
out: Dict[str, str] = {}
|
||||
for p, ids in path_to_ids.items():
|
||||
if len(ids) == 1:
|
||||
out[ids[0]] = p
|
||||
continue
|
||||
for iid in ids:
|
||||
code = (by_id[iid].get("item_code") or "").strip() or iid[-6:]
|
||||
out[iid] = f"{p}(编码:{code})"
|
||||
return out
|
||||
|
||||
|
||||
def is_example_row(row: Dict[str, Any]) -> bool:
|
||||
remark = str(row.get("报销事由") or "").strip()
|
||||
return remark.startswith(EXAMPLE_REMARK_PREFIX)
|
||||
|
||||
|
||||
def partition_import_groups(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""空分组号:每行独立一组(group_no=\"\");非空:同号合并。保持首次出现顺序。"""
|
||||
ordered: List[Dict[str, Any]] = []
|
||||
index: Dict[str, int] = {}
|
||||
for row in rows:
|
||||
g = str(row.get("分组号") or "").strip()
|
||||
if not g:
|
||||
ordered.append({"group_no": "", "rows": [row]})
|
||||
continue
|
||||
if g not in index:
|
||||
index[g] = len(ordered)
|
||||
ordered.append({"group_no": g, "rows": [row]})
|
||||
else:
|
||||
ordered[index[g]]["rows"].append(row)
|
||||
return ordered
|
||||
|
||||
|
||||
def validate_group_headers(rows: List[Dict[str, Any]]) -> Optional[str]:
|
||||
if not rows:
|
||||
return "分组无有效行"
|
||||
apps = {str(r.get("申请人") or "").strip() for r in rows}
|
||||
dates = {str(r.get("报销日期") or "").strip() for r in rows}
|
||||
titles = [str(r.get("报销标题") or "").strip() for r in rows]
|
||||
if len(apps) != 1 or not next(iter(apps)):
|
||||
return "同组申请人不一致或为空"
|
||||
if len(dates) != 1 or not next(iter(dates)):
|
||||
return "同组报销日期不一致或为空"
|
||||
empty_n = sum(1 for t in titles if not t)
|
||||
if 0 < empty_n < len(titles):
|
||||
return "同组报销标题不能部分为空"
|
||||
if empty_n == 0 and len(set(titles)) != 1:
|
||||
return "同组报销标题不一致"
|
||||
return None
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑测通过**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && pytest tests/test_bx_reimburse_import.py -v
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit(若用户要求提交时再执行)**
|
||||
|
||||
```bash
|
||||
git add backend/app/services/bx/reimburse_import.py backend/tests/test_bx_reimburse_import.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(bx): add reimburse import pure helpers for labels and grouping
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Excel list validation 工具
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/utils/excel_list_validation.py`
|
||||
- Modify: `backend/tests/test_bx_reimburse_import.py`(追加模板下拉存在性测)
|
||||
|
||||
- [ ] **Step 1: 实现工具**
|
||||
|
||||
```python
|
||||
# backend/app/utils/excel_list_validation.py
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable, Sequence
|
||||
|
||||
from openpyxl.utils import get_column_letter
|
||||
from openpyxl.workbook.workbook import Workbook
|
||||
from openpyxl.worksheet.datavalidation import DataValidation
|
||||
from openpyxl.worksheet.worksheet import Worksheet
|
||||
|
||||
|
||||
def write_option_sheet(
|
||||
wb: Workbook,
|
||||
sheet_title: str,
|
||||
labels: Sequence[str],
|
||||
ids: Sequence[str] | None = None,
|
||||
*,
|
||||
hidden: bool = True,
|
||||
) -> str:
|
||||
"""写入选项表:A=label,可选 B=id。返回 sheet_title。"""
|
||||
if sheet_title in wb.sheetnames:
|
||||
ws = wb[sheet_title]
|
||||
wb.remove(ws)
|
||||
ws = wb.create_sheet(sheet_title)
|
||||
ws.append(["label", "id"] if ids is not None else ["label"])
|
||||
for i, lab in enumerate(labels):
|
||||
if ids is not None:
|
||||
ws.append([lab, ids[i] if i < len(ids) else ""])
|
||||
else:
|
||||
ws.append([lab])
|
||||
if hidden:
|
||||
ws.sheet_state = "hidden"
|
||||
return sheet_title
|
||||
|
||||
|
||||
def add_list_validation(
|
||||
ws: Worksheet,
|
||||
*,
|
||||
option_sheet: str,
|
||||
option_rows: int,
|
||||
target_col: int,
|
||||
start_row: int,
|
||||
end_row: int,
|
||||
allow_blank: bool = True,
|
||||
) -> None:
|
||||
"""option_rows 为选项数据行数(不含表头)。option 在 A2:A{1+option_rows}。"""
|
||||
if option_rows <= 0:
|
||||
return
|
||||
last = 1 + option_rows
|
||||
formula = f"='{option_sheet}'!$A$2:$A${last}"
|
||||
col = get_column_letter(target_col)
|
||||
dv = DataValidation(
|
||||
type="list",
|
||||
formula1=formula,
|
||||
allow_blank=allow_blank,
|
||||
showErrorMessage=True,
|
||||
showInputMessage=True,
|
||||
)
|
||||
dv.error = "请从下拉列表选择"
|
||||
dv.errorTitle = "无效值"
|
||||
dv.add(f"{col}{start_row}:{col}{end_row}")
|
||||
ws.add_data_validation(dv)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 最小冒烟测(内存 workbook)**
|
||||
|
||||
在 `test_bx_reimburse_import.py` 追加:
|
||||
|
||||
```python
|
||||
from openpyxl import Workbook
|
||||
from app.utils.excel_list_validation import add_list_validation, write_option_sheet
|
||||
|
||||
|
||||
def test_write_option_sheet_and_validation():
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "报销导入"
|
||||
ws.append(["申请人"])
|
||||
write_option_sheet(wb, "_选项_申请人", ["张三(z001)"], ["u1"], hidden=True)
|
||||
add_list_validation(
|
||||
ws,
|
||||
option_sheet="_选项_申请人",
|
||||
option_rows=1,
|
||||
target_col=1,
|
||||
start_row=2,
|
||||
end_row=100,
|
||||
)
|
||||
assert wb["_选项_申请人"].sheet_state == "hidden"
|
||||
assert len(ws.data_validations.dataValidation) == 1
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 跑测**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && pytest tests/test_bx_reimburse_import.py::test_write_option_sheet_and_validation -v
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 生成导入模板(含说明 + 示例 + 下拉)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/services/bx/reimburse_import.py`
|
||||
- Modify: `backend/tests/test_bx_reimburse_import.py`
|
||||
|
||||
- [ ] **Step 1: 实现 `build_import_template_bytes`**
|
||||
|
||||
签名与要点:
|
||||
|
||||
```python
|
||||
def build_import_template_bytes(
|
||||
*,
|
||||
expense_nodes: List[Dict[str, Any]],
|
||||
applicants: List[Dict[str, Any]], # id, nick_name, user_no
|
||||
current_applicant_label: str,
|
||||
today: date,
|
||||
) -> bytes:
|
||||
```
|
||||
|
||||
行为:
|
||||
|
||||
1. `Workbook()`;先建 `填写说明`,写入 spec §2.5 九条说明(每条一行)。
|
||||
2. 建 `报销导入`,`append(HEADERS)`。
|
||||
3. `expense_labels = build_expense_path_labels(expense_nodes)`;按 path 排序写入 `_选项_费用项目`(A=label B=id)。
|
||||
4. 申请人 labels:`build_applicant_label(...)`,写入 `_选项_申请人`。
|
||||
5. 示例 3 行:事由以 `【示例】` 开头;申请人=`current_applicant_label`;日期=`today.isoformat()`;费用取选项前 1~2 个 label(无选项则费用列留空并在说明中提示先配置费用项);分组号 G1/G1/空;浅灰底 `PatternFill(start_color="F1F5F9", ...)`。
|
||||
6. 对「申请人」列(B)、「费用项目」列(E)从行 2 到行 501 加 `add_list_validation`。
|
||||
7. `wb.save(BytesIO)` 返回 bytes。
|
||||
|
||||
费用节点加载(供 API 调用,可同文件):
|
||||
|
||||
```python
|
||||
def load_expense_nodes_flat(db: Session, tenant_no: str) -> List[Dict[str, Any]]:
|
||||
rows = (
|
||||
db.query(BxExpenseItem)
|
||||
.filter(BxExpenseItem.del_flag == 0, BxExpenseItem.tenant_no == tenant_no)
|
||||
.order_by(BxExpenseItem.sort_order.asc(), BxExpenseItem.create_time.desc())
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"parent_id": r.parent_id or "0",
|
||||
"item_name": r.item_name,
|
||||
"status": r.status,
|
||||
"item_code": r.item_code,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
```
|
||||
|
||||
申请人列表:查询 `SysUser`(同 `/users/options` 过滤);若 `not ReimburseService.can_change_applicant(...)` 则仅当前 user。
|
||||
|
||||
- [ ] **Step 2: 单测模板结构(不连库)**
|
||||
|
||||
```python
|
||||
from datetime import date
|
||||
from io import BytesIO
|
||||
from openpyxl import load_workbook
|
||||
from app.services.bx.reimburse_import import build_import_template_bytes
|
||||
|
||||
|
||||
def test_build_import_template_has_guide_examples_and_dv():
|
||||
raw = build_import_template_bytes(
|
||||
expense_nodes=[
|
||||
{"id": "1", "parent_id": "0", "item_name": "差旅费", "status": 1, "item_code": "CL"},
|
||||
{"id": "2", "parent_id": "1", "item_name": "市内交通", "status": 1, "item_code": "JT"},
|
||||
],
|
||||
applicants=[{"id": "u1", "nick_name": "张三", "user_no": "z001"}],
|
||||
current_applicant_label="张三(z001)",
|
||||
today=date(2026, 8, 18),
|
||||
)
|
||||
wb = load_workbook(BytesIO(raw))
|
||||
assert "填写说明" in wb.sheetnames
|
||||
assert "报销导入" in wb.sheetnames
|
||||
assert wb["_选项_费用项目"].sheet_state == "hidden"
|
||||
ws = wb["报销导入"]
|
||||
assert [c.value for c in ws[1]][:8] == [
|
||||
"分组号", "申请人", "报销日期", "报销标题",
|
||||
"费用项目", "金额(元)", "报销事由", "发生时间",
|
||||
]
|
||||
assert str(ws["G2"].value).startswith("【示例】")
|
||||
assert len(ws.data_validations.dataValidation) >= 1
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 跑测**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && pytest tests/test_bx_reimburse_import.py -v
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 导入解析与按组写库
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/services/bx/reimburse_import.py`
|
||||
- Modify: `backend/tests/test_bx_reimburse_import.py`
|
||||
|
||||
- [ ] **Step 1: 实现解析辅助**
|
||||
|
||||
```python
|
||||
def cell_str(v: Any) -> str:
|
||||
if v is None:
|
||||
return ""
|
||||
if isinstance(v, datetime):
|
||||
return v.date().isoformat()
|
||||
if isinstance(v, date):
|
||||
return v.isoformat()
|
||||
return str(v).strip()
|
||||
|
||||
|
||||
def parse_amount(v: Any) -> Tuple[Optional[Decimal], Optional[str]]:
|
||||
if v is None or cell_str(v) == "":
|
||||
return None, "金额不能为空"
|
||||
try:
|
||||
amt = Decimal(str(v).strip())
|
||||
except Exception:
|
||||
return None, "金额格式无效"
|
||||
if amt <= 0:
|
||||
return None, "金额必须大于 0"
|
||||
return amt, None
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 实现 `import_reimburse_workbook`**
|
||||
|
||||
签名:
|
||||
|
||||
```python
|
||||
def import_reimburse_workbook(
|
||||
db: Session,
|
||||
*,
|
||||
file_bytes: bytes,
|
||||
user: BxUserContext,
|
||||
) -> Dict[str, Any]:
|
||||
```
|
||||
|
||||
逻辑:
|
||||
|
||||
1. `load_workbook(BytesIO(file_bytes), data_only=True)`;无 `报销导入` → `raise ValueError("缺少工作表「报销导入」")`。
|
||||
2. 读第 1 行表头,必须包含 `HEADERS` 全部列名(允许顺序与生成模板一致;缺列 raise)。
|
||||
3. 从第 2 行起读到空行结束;转 dict;跳过全空行;`is_example_row` 跳过。
|
||||
4. 有效行数 > `MAX_IMPORT_ROWS` → `raise ValueError(...)`。
|
||||
5. `tenant_no = ReimburseService._tenant_no(db, user)`。
|
||||
6. 构建匹配表:`label→expense_id`(`build_expense_path_labels` + 反查)、`label→user_id`(按权限加载申请人后 `build_applicant_label`)。
|
||||
7. `partition_import_groups`。
|
||||
8. 对每个 group:
|
||||
- `validate_group_headers` 失败 → errors,continue。
|
||||
- 解析每行费用/金额/事由/发生时间;任一行失败 → 整组 errors。
|
||||
- 申请人 label → id;`resolve_applicant`;日期用 `parse_reimburse_date`(注意:组内已保证非空字符串;Excel datetime 先 `cell_str`)。
|
||||
- 发生时间:`parse_optional_date`(先把 cell 规范成 date/str)。
|
||||
- `details_data` 与 API `_build_details_data` 同形:查 `ExpenseItemService.get_by_id`,写 `expense_id/name/amount/remark/occur_date`。
|
||||
- `data = {apply_code, status:"draft", reimburse_date, remark:""}`;若标题非空则 `title=...`;挂 `wf_id` 同 `create_reimburse`。
|
||||
- `create_with_details(...)`;**立即 `db.commit()`**(部分成功);`created` 追加 `{groupNo, applyId, applyCode}`。
|
||||
- 单组异常:`db.rollback()`,记入 errors,继续下一组。
|
||||
9. 返回:
|
||||
|
||||
```python
|
||||
{
|
||||
"successGroups": ...,
|
||||
"successRows": ...,
|
||||
"failGroups": ...,
|
||||
"failRows": ...,
|
||||
"created": [...],
|
||||
"errors": [{"groupNo": ..., "rows": [...], "message": ...}],
|
||||
}
|
||||
```
|
||||
|
||||
空分组号在结果里 `groupNo` 用 `""`。
|
||||
|
||||
- [ ] **Step 3: 纯解析测(不写库)— 可选抽出 `parse_sheet_rows`**
|
||||
|
||||
为便于测,先抽:
|
||||
|
||||
```python
|
||||
def iter_data_rows(ws) -> List[Dict[str, Any]]:
|
||||
...
|
||||
```
|
||||
|
||||
单测:内存 workbook 含示例行 + 数据行,断言示例被跳过、分组正确。
|
||||
|
||||
- [ ] **Step 4: 跑测**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && pytest tests/test_bx_reimburse_import.py -v
|
||||
```
|
||||
|
||||
Expected: PASS(写库可用 mock/`MagicMock` 测分组失败不调用 create,或跳过集成测;至少保证解析/跳过示例/表头校验有测)
|
||||
|
||||
---
|
||||
|
||||
### Task 5: API 路由
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/api/bx/reimburse.py`
|
||||
|
||||
- [ ] **Step 1: 增加依赖导入**
|
||||
|
||||
```python
|
||||
import io
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import File, UploadFile
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.services.bx.reimburse_import import (
|
||||
build_applicant_label,
|
||||
build_import_template_bytes,
|
||||
import_reimburse_workbook,
|
||||
load_expense_nodes_flat,
|
||||
)
|
||||
from app.models.models import SysUser
|
||||
from app.services.bx.reimburse import ReimburseService
|
||||
```
|
||||
|
||||
(若 `ReimburseService` 已导入则勿重复。)
|
||||
|
||||
- [ ] **Step 2: 实现 `GET /import-template`**
|
||||
|
||||
注意:放在 `/{reimburse_id}` 类路径**之前**,避免被当成 id。建议紧接在 `generate-title` 附近。
|
||||
|
||||
```python
|
||||
@router.get("/import-template")
|
||||
def download_reimburse_import_template(
|
||||
db: Session = Depends(get_db),
|
||||
user: BxUserContext = Depends(get_bx_current_user),
|
||||
):
|
||||
tenant_no = _tenant(db, user)
|
||||
nodes = load_expense_nodes_flat(db, tenant_no)
|
||||
q = db.query(SysUser).filter(
|
||||
SysUser.del_flag == 0, SysUser.status == "1", SysUser.tenant_no == tenant_no
|
||||
)
|
||||
if not ReimburseService.can_change_applicant(db, user):
|
||||
q = q.filter(SysUser.id == user.id)
|
||||
users = q.order_by(SysUser.create_time.desc()).all()
|
||||
applicants = [
|
||||
{"id": u.id, "nick_name": u.nick_name or "", "user_no": u.user_no or ""}
|
||||
for u in users
|
||||
]
|
||||
# 当前用户展示名
|
||||
me = next((a for a in applicants if a["id"] == user.id), None)
|
||||
if me:
|
||||
cur_label = build_applicant_label(me["nick_name"], me["user_no"], me["id"])
|
||||
else:
|
||||
cur_label = build_applicant_label(user.name or "", "", user.id)
|
||||
applicants = [{"id": user.id, "nick_name": user.name or "", "user_no": ""}] + applicants
|
||||
from datetime import date as date_cls
|
||||
raw = build_import_template_bytes(
|
||||
expense_nodes=nodes,
|
||||
applicants=applicants,
|
||||
current_applicant_label=cur_label,
|
||||
today=date_cls.today(),
|
||||
)
|
||||
filename = quote("报销申请导入模板.xlsx")
|
||||
return StreamingResponse(
|
||||
io.BytesIO(raw),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={filename}; filename*=UTF-8''{filename}"
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 实现 `POST /import`**
|
||||
|
||||
```python
|
||||
@router.post("/import")
|
||||
def import_reimburses(
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
user: BxUserContext = Depends(get_bx_current_user),
|
||||
):
|
||||
content = file.file.read()
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail="文件为空")
|
||||
try:
|
||||
result = import_reimburse_workbook(db, file_bytes=content, user=user)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return ApiResponse(data=result)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 手工冒烟(本地服务已启动时)**
|
||||
|
||||
```bash
|
||||
# 登录后带 token
|
||||
curl -o /tmp/bx-tpl.xlsx -H "Authorization: Bearer $TOKEN" \
|
||||
http://127.0.0.1:8000/api/reimburses/import-template
|
||||
file /tmp/bx-tpl.xlsx
|
||||
```
|
||||
|
||||
Expected: Excel 文件;用 Excel/WPS 打开可见下拉与说明。
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 前端 API
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/api/bx/index.ts`
|
||||
|
||||
- [ ] **Step 1: 在 `reimburseApi` 增加方法**
|
||||
|
||||
```typescript
|
||||
downloadImportTemplate() {
|
||||
return bxRequest.get('/reimburses/import-template', { responseType: 'blob' })
|
||||
},
|
||||
importExcel(file: File) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return bxRequest.post('/reimburses/import', formData)
|
||||
},
|
||||
```
|
||||
|
||||
(`bx.ts` 已对 FormData 去掉 Content-Type,勿强制 `multipart/form-data` 字符串以免丢 boundary。)
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 前端工具栏 + 导入弹窗
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/bx/reimburse/apply/index.vue`
|
||||
|
||||
- [ ] **Step 1: 工具栏按钮**
|
||||
|
||||
在「新增报销申请」按钮后插入(`btn-ghost`,带图标):
|
||||
|
||||
```vue
|
||||
<el-button class="btn-ghost" :loading="templateLoading" @click="downloadImportTemplate">
|
||||
<el-icon><Download /></el-icon>下载模板
|
||||
</el-button>
|
||||
<el-button class="btn-ghost" @click="openImportDialog">
|
||||
<el-icon><Upload /></el-icon>批量导入
|
||||
</el-button>
|
||||
```
|
||||
|
||||
注意:页面已有「提交审批」用 `Upload` 图标;批量导入可改用 `UploadFilled` 或 `DocumentAdd`,从 `@element-plus/icons-vue` 显式 import,避免两按钮同图标。推荐:`Download` + `FolderAdd`。
|
||||
|
||||
- [ ] **Step 2: 导入弹窗模板**
|
||||
|
||||
```vue
|
||||
<el-dialog
|
||||
v-model="importDialogVisible"
|
||||
title="批量导入报销申请"
|
||||
width="560px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<p class="import-hint">
|
||||
请先下载最新模板填写。灰色【示例】行导入时自动跳过。导入结果为「待提交」草稿,发票请事后在系统中关联。
|
||||
</p>
|
||||
<el-upload
|
||||
drag
|
||||
:auto-upload="false"
|
||||
:limit="1"
|
||||
accept=".xlsx,.xls"
|
||||
:file-list="importFileList"
|
||||
:on-change="onImportFileChange"
|
||||
:on-remove="onImportFileRemove"
|
||||
>
|
||||
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
|
||||
<div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
|
||||
</el-upload>
|
||||
<div v-if="importResult" class="import-result">
|
||||
<p>
|
||||
成功 {{ importResult.successGroups }} 组({{ importResult.successRows }} 行),
|
||||
失败 {{ importResult.failGroups }} 组({{ importResult.failRows }} 行)
|
||||
</p>
|
||||
<el-table v-if="importResult.errors?.length" :data="importResult.errors" size="small" max-height="240">
|
||||
<el-table-column prop="groupNo" label="分组号" min-width="80" />
|
||||
<el-table-column label="行号" min-width="100">
|
||||
<template #default="{ row }">{{ (row.rows || []).join(', ') }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="message" label="原因" min-width="200" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="importDialogVisible = false">
|
||||
<el-icon><Close /></el-icon>关闭
|
||||
</el-button>
|
||||
<el-button
|
||||
class="btn-main"
|
||||
type="primary"
|
||||
:loading="importLoading"
|
||||
:disabled="!importFileList.length"
|
||||
@click="submitImport"
|
||||
>
|
||||
<el-icon><Check /></el-icon>开始导入
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: script 逻辑**
|
||||
|
||||
```typescript
|
||||
const templateLoading = ref(false)
|
||||
const importDialogVisible = ref(false)
|
||||
const importLoading = ref(false)
|
||||
const importFileList = ref<UploadFile[]>([])
|
||||
const importResult = ref<any>(null)
|
||||
|
||||
async function downloadImportTemplate() {
|
||||
templateLoading.value = true
|
||||
try {
|
||||
const res: any = await reimburseApi.downloadImportTemplate()
|
||||
const blob =
|
||||
res instanceof Blob
|
||||
? res
|
||||
: new Blob([res as BlobPart], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = '报销申请导入模板.xlsx'
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
ElMessage.error('下载模板失败')
|
||||
} finally {
|
||||
templateLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openImportDialog() {
|
||||
importFileList.value = []
|
||||
importResult.value = null
|
||||
importDialogVisible.value = true
|
||||
}
|
||||
|
||||
function onImportFileChange(file: UploadFile, files: UploadFile[]) {
|
||||
importFileList.value = files.slice(-1)
|
||||
}
|
||||
|
||||
function onImportFileRemove() {
|
||||
importFileList.value = []
|
||||
}
|
||||
|
||||
async function submitImport() {
|
||||
const raw = importFileList.value[0]?.raw
|
||||
if (!raw) {
|
||||
ElMessage.warning('请选择要导入的文件')
|
||||
return
|
||||
}
|
||||
importLoading.value = true
|
||||
importResult.value = null
|
||||
try {
|
||||
const res: any = await reimburseApi.importExcel(raw)
|
||||
const data = pickBxData(res) || res?.data || res
|
||||
importResult.value = data
|
||||
const ok = Number(data?.successGroups || 0)
|
||||
const fail = Number(data?.failGroups || 0)
|
||||
if (ok > 0 && fail === 0) {
|
||||
ElMessage.success(`成功导入 ${ok} 组`)
|
||||
} else if (ok > 0) {
|
||||
ElMessage.warning(`部分成功:成功 ${ok} 组,失败 ${fail} 组`)
|
||||
} else {
|
||||
ElMessage.error('导入失败,请查看错误清单')
|
||||
}
|
||||
if (ok > 0) await refreshList()
|
||||
} finally {
|
||||
importLoading.value = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
补全 `UploadFile`、`UploadFilled`、`Download`、`FolderAdd`、`Close`、`Check` 的 import;`refreshList` 用页面已有 `refresh`/`search`。
|
||||
|
||||
- [ ] **Step 4: 手工验收(对照 spec §5.2)**
|
||||
|
||||
1. 打开 `/#/bx/reimburse/apply`,下载模板 → 有说明、示例、下拉。
|
||||
2. 不改示例直接导入 → 0 成功(或仅跳过),无脏数据。
|
||||
3. 复制示例改事由去掉【示例】、金额合理 → 成功草稿。
|
||||
4. G1 两行同头不同费用 → 一单两明细。
|
||||
5. 同组申请人故意不一致 → 该组失败,其它组仍成功。
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 收尾
|
||||
|
||||
- [ ] **Step 1: 后端全量相关测**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && pytest tests/test_bx_reimburse_import.py -v
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 更新图谱(改代码后)**
|
||||
|
||||
```bash
|
||||
./scripts/update-graph.sh backend
|
||||
# 若改了 web-admin:
|
||||
./scripts/update-graph.sh frontend
|
||||
```
|
||||
|
||||
或按仓库规则:`graphify update .`(在对应目录)。
|
||||
|
||||
- [ ] **Step 3: 将 spec 状态改为「已实现」**(实现全部验收后)
|
||||
|
||||
修改 `docs/superpowers/specs/2026-08-18-reimburse-apply-batch-import-design.md` 顶部状态行。
|
||||
|
||||
---
|
||||
|
||||
## Spec 覆盖自检
|
||||
|
||||
| Spec 项 | Task |
|
||||
|---------|------|
|
||||
| D1 后端模板+导入 | 2–5 |
|
||||
| D2 分组号行模型 | 1, 4 |
|
||||
| D3 单头一致 | 1 `validate_group_headers`, 4 |
|
||||
| D4 空分组号 | 1 `partition_import_groups` |
|
||||
| D5 仅草稿 | 4 `status: draft` |
|
||||
| D6 标题可空 | 1, 4 |
|
||||
| D7 启用费用项含父级 | 1 `build_expense_path_labels` |
|
||||
| D8 部分成功 | 4 按组 commit |
|
||||
| D9 无发票 | 4 不传 invoice |
|
||||
| D10 说明+示例 | 3 |
|
||||
| API GET/POST | 5 |
|
||||
| 前端按钮+弹窗 | 6–7 |
|
||||
| 申请人权限 | 3 模板选项 + 4 `resolve_applicant` |
|
||||
|
||||
**占位符扫描:** 无 TBD;Vue 需与页面现有 `refresh` 函数名对齐(实现时搜 `refresh`/`handleSearch`)。
|
||||
|
||||
**类型一致:** `groupNo` / `successGroups` / `errors[].rows` 前后端统一驼峰(Pydantic/`ApiResponse` 若默认 camelCase,service 返回可用 dict 驼峰键,与现有 bx 出参习惯一致)。
|
||||
@@ -0,0 +1,297 @@
|
||||
# 报销申请批量导入(Excel 下拉模板)— 设计文档
|
||||
|
||||
> **日期**: 2026-08-18
|
||||
> **状态**: 已实现(待联调验收)
|
||||
> **仓库**: shanxia2.0(`backend/` + `web-admin/`)
|
||||
> **入口页**: `http://localhost:3006/#/bx/reimburse/apply`
|
||||
> **界面模式**: P01 标准列表扩展(工具栏 + 导入弹窗);参考 Fresh Mint 工具栏按钮规则
|
||||
> **实现路径**: 方案 A — 后端生成模板 + 后端导入
|
||||
|
||||
## 背景与问题
|
||||
|
||||
报销申请页已支持新增 / 编辑 / 删除。用户希望增加**批量导入**,并特别要求:
|
||||
|
||||
1. **费用项目**、**申请人**在 Excel 中为下拉框(非自由文本)。
|
||||
2. 可**下载模板**,模板内**内置下拉数据**,并带**填写说明与示例行**,便于理解。
|
||||
|
||||
现状难点:
|
||||
|
||||
- 费用项目在页面是树形选择;Excel 只能做扁平下拉。
|
||||
- 申请人选项接口 label 仅为昵称,易重名。
|
||||
- 一单多明细;导入行模型需显式分组。
|
||||
- 仓库内 CRM「下载模板」仅有表头,**无** DataValidation 下拉;本需求需新增能力。
|
||||
|
||||
## 已确认决策
|
||||
|
||||
| # | 决策 | 说明 |
|
||||
|---|------|------|
|
||||
| D1 | **实现路径 A** | 后端 `openpyxl` 动态生成模板(隐藏选项表 + DataValidation);后端解析导入;前端只做下载/上传与结果展示 |
|
||||
| D2 | **行模型 B** | Excel **一行 = 一条明细**;相同「分组号」合成一张报销单 |
|
||||
| D3 | **单头一致性** | 同组每一行都须填写申请人 / 报销日期 / 标题(标题可空),且**必须完全一致**;不一致则**整组失败** |
|
||||
| D4 | **空分组号** | 允许为空;空则该行**单独成一张单** |
|
||||
| D5 | **仅草稿** | 导入成功写入「待提交」草稿;不自动提交审批 |
|
||||
| D6 | **标题可空** | 同组标题都空 → 按现有规则自动生成;若填写则同组必须相同 |
|
||||
| D7 | **费用项范围** | 下拉含当前租户全部**启用**节点(含一级父节点),与页面 `check-strictly` 一致 |
|
||||
| D8 | **部分成功** | 合法分组写入;失败组/行返回错误清单;不因单组失败回滚整文件 |
|
||||
| D9 | **不做发票/附件** | v1 不导入关联发票与附件;用户可事后编辑草稿补票 |
|
||||
| D10 | **模板含说明+示例** | 「填写说明」工作表 + 数据表内示例行(导入时跳过) |
|
||||
|
||||
---
|
||||
|
||||
## 一、目标与范围
|
||||
|
||||
### 目标
|
||||
|
||||
在报销申请列表提供「下载模板」「批量导入」,使用户能通过带下拉的 Excel 批量创建多张(含多明细)草稿报销单。
|
||||
|
||||
### 范围内
|
||||
|
||||
- 后端:导入模板生成、导入解析/分组/校验/写库、结果结构
|
||||
- Web:`#/bx/reimburse/apply` 工具栏按钮 + 导入结果弹窗
|
||||
- 费用项目全路径下拉、申请人唯一展示名下拉
|
||||
- 与现有 `create_with_details` / `resolve_applicant` 对齐
|
||||
|
||||
### 范围外(v1)
|
||||
|
||||
- 发票、附件导入
|
||||
- 导入后自动提交审批
|
||||
- 按单号更新已有单据(仅新建)
|
||||
- Excel 级联下拉(`INDIRECT`)
|
||||
- 前端本地解析后循环调用单条创建 API
|
||||
- `bx-mobile` / 小程序导入
|
||||
|
||||
---
|
||||
|
||||
## 二、Excel 模板形态
|
||||
|
||||
### 2.1 工作表
|
||||
|
||||
| 工作表 | 可见性 | 用途 |
|
||||
|--------|--------|------|
|
||||
| `报销导入` | 可见 | 用户填写;含表头、示例行、数据验证下拉 |
|
||||
| `填写说明` | 可见 | 规则说明、列说明、常见错误 |
|
||||
| `_选项_费用项目` | 隐藏 | A 列:下拉展示文案;B 列:id(内部) |
|
||||
| `_选项_申请人` | 隐藏 | A 列:下拉展示文案;B 列:userId(内部) |
|
||||
|
||||
生成时按**当前登录用户**与租户实时查询选项,不使用静态 xlsx 文件。
|
||||
|
||||
### 2.2 `报销导入` 列定义
|
||||
|
||||
| 列名 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| 分组号 | 否 | 相同非空值合成一张单;空 = 该行单独成单 |
|
||||
| 申请人 | 是 | 下拉;展示 `昵称(工号)`(见 2.4) |
|
||||
| 报销日期 | 是 | `YYYY-MM-DD`;同组必须一致 |
|
||||
| 报销标题 | 否 | 同组都空则自动生成;填写则同组必须相同 |
|
||||
| 费用项目 | 是 | 下拉;全路径,分隔符 ` / ` |
|
||||
| 金额(元) | 是 | 数值且 > 0 |
|
||||
| 报销事由 | 是 | 对应明细 `remark` |
|
||||
| 发生时间 | 否 | `YYYY-MM-DD`;对应明细 `occur_date` |
|
||||
|
||||
### 2.3 下拉实现(openpyxl)
|
||||
|
||||
- 使用 `DataValidation(type="list")`,`formula1` 引用隐藏表列区域(如 `=_选项_费用项目!$A$2:$A$N`)。
|
||||
- **禁止**把选项拼进公式字符串(255 字符限制)。
|
||||
- `showErrorMessage=True`,提示「请从下拉列表选择」。
|
||||
- 下拉仅为 UX;导入以服务端当前库精确匹配为准。
|
||||
|
||||
### 2.4 展示文案与匹配键
|
||||
|
||||
**费用项目**
|
||||
|
||||
- 展示:祖先全路径,分隔符为 ` / `(半角斜杠两侧各一空格),例如 `差旅费 / 市内交通`;一级节点仅本级名称。
|
||||
- 仅 `status=启用` 的节点进入选项表。
|
||||
- 若规范化后全路径冲突:在文案末尾追加 `(编码:xxx)` 或短 id 后缀保证唯一;导入匹配同一套规则。
|
||||
- 匹配:导入单元格文本 trim 后与选项 A 列精确相等 → 得到 id;失败则该行错误。
|
||||
|
||||
**申请人**
|
||||
|
||||
- 展示:`{nickName}({userNo})`;`userNo` 为空时用稳定后缀(如用户 id 后 6 位)保证唯一。
|
||||
- 有 `bx:reimburseApply:changeApplicant` 或 `super_admin`:选项 = 本租户全部有效用户。
|
||||
- 无权限:选项仅当前用户;导入仍走 `ReimburseService.resolve_applicant`(无权限强制本人)。
|
||||
- 匹配:精确匹配展示文案 → userId。
|
||||
|
||||
### 2.5 填写说明与示例(D10)
|
||||
|
||||
**`填写说明` 工作表**(要点,实现时可微调文案):
|
||||
|
||||
1. 请使用本系统「下载模板」获取最新文件;费用项/人员变更后请重新下载。
|
||||
2. 推荐 Excel 或 WPS **桌面版**;在线表格可能丢失下拉。
|
||||
3. **一行一条费用明细**;相同「分组号」合成一张报销单。
|
||||
4. 分组号可空:空则该行单独成单。
|
||||
5. 同一分组内:申请人、报销日期、报销标题必须每行都填且完全一致(标题可都留空以自动生成)。
|
||||
6. 费用项目、申请人请用下拉选择,勿手改名称。
|
||||
7. 灰色**示例行仅供参考,导入时自动跳过**,请从示例下方空行开始填写。
|
||||
8. 导入结果为「待提交」草稿;发票请导入后在系统内关联。
|
||||
9. 请勿修改或删除隐藏的 `_选项_*` 工作表。
|
||||
|
||||
**`报销导入` 示例行**(建议 2~3 行,浅灰底 +「示例」标识,导入跳过):
|
||||
|
||||
| 分组号 | 申请人 | 报销日期 | 报销标题 | 费用项目 | 金额 | 报销事由 | 发生时间 |
|
||||
|--------|--------|----------|----------|----------|------|----------|----------|
|
||||
| G1 | (当前用户展示名) | (今日) | | (选项表第一项路径) | 50.00 | 【示例】地铁通勤,请删除本行 | |
|
||||
| G1 | (同上) | (同上) | | (选项表第二项或同第一项) | 300.00 | 【示例】住宿,请删除本行 | (昨日) |
|
||||
| | (同上) | (同上) | | (任选项) | 20.00 | 【示例】空分组号单独成单 | |
|
||||
|
||||
示例行识别规则(按优先级):
|
||||
|
||||
1. 首列或隐藏标记列含 `__EXAMPLE__` / 约定标记;或
|
||||
2. 「报销事由」以 `【示例】` 开头;或
|
||||
3. 生成时写入的固定行号范围(模板内注释说明)。
|
||||
|
||||
推荐采用 **事由前缀 `【示例】` + 行底色**,解析简单、用户可见。
|
||||
|
||||
---
|
||||
|
||||
## 三、导入校验与写库
|
||||
|
||||
### 3.1 流程
|
||||
|
||||
```
|
||||
上传 xlsx
|
||||
→ 读「报销导入」表,校验表头
|
||||
→ 跳过空行、示例行
|
||||
→ 按分组号分桶(空分组号 → 每行独立桶)
|
||||
→ 对每个分组:
|
||||
校验单头一致(申请人/日期/标题)
|
||||
解析申请人、费用项、金额、事由、发生时间
|
||||
任一行失败 → 整组失败,不写库
|
||||
全部通过 → create_with_details(draft)
|
||||
→ 汇总 success / errors 返回
|
||||
```
|
||||
|
||||
### 3.2 分组单头规则(D3 / D6)
|
||||
|
||||
对分组内所有数据行:
|
||||
|
||||
- `申请人`、`报销日期` 字符串 trim 后必须完全一致。
|
||||
- `报销标题`:要么全部为空(自动生成),要么全部非空且文本完全一致;**不允许**组内「有的空有的不空」。
|
||||
- 不一致 → 整组错误,例如:`同组申请人不一致` / `同组报销标题不能部分为空`。
|
||||
|
||||
### 3.3 字段校验
|
||||
|
||||
| 字段 | 规则 |
|
||||
|------|------|
|
||||
| 申请人 | 选项匹配成功;再 `resolve_applicant` |
|
||||
| 报销日期 | 可解析为日期;建议规范为 `YYYY-MM-DD` |
|
||||
| 费用项目 | 选项匹配且仍为启用 |
|
||||
| 金额 | 数值且 > 0 |
|
||||
| 报销事由 | 非空 trim |
|
||||
| 发生时间 | 空或合法日期 |
|
||||
|
||||
### 3.4 写库
|
||||
|
||||
- 复用 `ReimburseService.create_with_details`(及 API 层 `_build_details_data` 同类逻辑)。
|
||||
- 状态:待提交草稿(与页面「保存草稿」一致)。
|
||||
- 不调用 submit;不写 `invoice_ids` / `invoice_links`。
|
||||
- `create_by` = 当前操作人;`applicant_*` = 解析后的申请人。
|
||||
- **部分成功(D8)**:成功组 commit(或按组 flush+commit 策略保证已成功组不因后续失败回滚);失败组仅进 `errors`。
|
||||
- 建议上限:单次最多 **500** 行有效数据(可常量配置);超出返回 400 提示拆分。
|
||||
|
||||
### 3.5 错误与结果结构
|
||||
|
||||
整文件无法解析(非 xlsx、缺必要表头)→ HTTP 400。
|
||||
|
||||
可解析时业务成功响应(示意):
|
||||
|
||||
```json
|
||||
{
|
||||
"successGroups": 2,
|
||||
"successRows": 3,
|
||||
"failGroups": 1,
|
||||
"failRows": 2,
|
||||
"created": [
|
||||
{ "groupNo": "G1", "applyId": "...", "applyCode": "..." }
|
||||
],
|
||||
"errors": [
|
||||
{ "groupNo": "G2", "rows": [5, 6], "message": "同组申请人不一致" },
|
||||
{ "groupNo": "", "rows": [8], "message": "费用项目「xxx」不存在或已停用" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`rows` 为 Excel 行号(含表头时从 1 计,与用户所见一致;实现时在说明中写明)。
|
||||
|
||||
---
|
||||
|
||||
## 四、API 与前端
|
||||
|
||||
### 4.1 后端 API
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/reimburses/import-template` | 下载模板(StreamingResponse xlsx) |
|
||||
| POST | `/api/reimburses/import` | `multipart` 文件字段(如 `file`) |
|
||||
|
||||
- 认证:与报销申请其它接口相同(`get_bx_current_user`)。
|
||||
- 菜单权限:与报销申请列表入口一致;不新开「导入」权限码(若产品后续要单独控权可再加)。
|
||||
- 改申请人:不新增权限码,沿用 `bx:reimburseApply:changeApplicant`。
|
||||
|
||||
落点建议:
|
||||
|
||||
- 路由:`backend/app/api/bx/reimburse.py`
|
||||
- 服务:`backend/app/services/bx/reimburse_import.py`(或同目录等价命名)
|
||||
- Excel 工具:可抽 `backend/app/utils/excel_data_validation.py`(隐藏表 + list 验证),供后续其它导入复用
|
||||
|
||||
### 4.2 前端
|
||||
|
||||
页面:`web-admin/src/views/bx/reimburse/apply/index.vue`
|
||||
|
||||
工具栏(「新增报销申请」之后,**中性描边** `btn-ghost`):
|
||||
|
||||
1. **下载模板**
|
||||
2. **批量导入**
|
||||
|
||||
导入弹窗:
|
||||
|
||||
- 选择 `.xlsx` / `.xls`
|
||||
- 文案提示:请先下载最新模板;示例行自动跳过;结果为待提交草稿
|
||||
- 上传后展示成功组数/行数、失败清单(分组号、行号、原因)
|
||||
- 关闭后刷新列表
|
||||
|
||||
API 封装:`web-admin/src/api/bx`(或现有 `reimburseApi`)增加 `downloadImportTemplate`、`importExcel`。
|
||||
|
||||
**不做**:用 `ArtExcelImport` 前端解析后循环 `create`。
|
||||
|
||||
UI 约束:无遮罩模糊;按钮带图标;符合 Fresh Mint「仅首主按钮用主色」。
|
||||
|
||||
---
|
||||
|
||||
## 五、风险与验收
|
||||
|
||||
### 5.1 风险对策
|
||||
|
||||
| 风险 | 对策 |
|
||||
|------|------|
|
||||
| 下拉被绕过 | 服务端精确匹配当前库 |
|
||||
| 昵称重名 | `昵称(工号)` 唯一展示 |
|
||||
| 费用项同名 | 全路径;冲突加后缀 |
|
||||
| 旧模板 | 说明要求重新下载;导入以当前主数据为准 |
|
||||
| 无权限改申请人 | `resolve_applicant` 强制本人 |
|
||||
| 示例入库 | `【示例】` 前缀跳过 |
|
||||
| WPS/在线表丢失验证 | 说明推荐桌面版;服务端仍校验 |
|
||||
|
||||
### 5.2 验收标准
|
||||
|
||||
1. 可下载模板:含填写说明、灰色示例行、费用项与申请人下拉。
|
||||
2. 示例行不入库。
|
||||
3. 相同分组号且单头一致 → 一张多明细草稿。
|
||||
4. 空分组号 → 每行一张单。
|
||||
5. 同组单头不一致或主数据无法匹配 → 该组失败,其它组合法写入。
|
||||
6. 无改申请人权限时,入库申请人仍为操作人本人。
|
||||
7. 状态为待提交;不关联发票、不自动提交。
|
||||
8. 工具栏按钮样式符合 Fresh Mint。
|
||||
|
||||
---
|
||||
|
||||
## 六、实现落点预览
|
||||
|
||||
| 层 | 路径 |
|
||||
|----|------|
|
||||
| API | `backend/app/api/bx/reimburse.py` |
|
||||
| Service | `backend/app/services/bx/reimburse_import.py` |
|
||||
| Excel 工具 | `backend/app/utils/`(DataValidation 辅助) |
|
||||
| 前端页 | `web-admin/src/views/bx/reimburse/apply/index.vue` |
|
||||
| 前端 API | `web-admin/src/api/bx`(reimburse 相关) |
|
||||
|
||||
下一步:经用户确认本 spec 后,使用 writing-plans 产出实现计划(`docs/superpowers/plans/`)。
|
||||
Reference in new issue
Block a user