feat: 统一后端分页查询与前端服务层适配
后端新增通用分页模块,为报销单、员工、预算、agent 资产等 端点统一接入分页参数和游标查询,优化 repository 层分页实 现,前端服务层适配分页响应结构,完善预算图表和全局样式, 优化侧边栏和企业选择器组件,引入 Element Plus 插件注册。
This commit is contained in:
@@ -850,6 +850,327 @@
|
|||||||
text-underline-offset: 3px;
|
text-underline-offset: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 竞品分析表格样式 */
|
||||||
|
.comp-table-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 24px 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comp-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comp-table th,
|
||||||
|
.comp-table td {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
vertical-align: top;
|
||||||
|
line-height: 1.68;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comp-table th {
|
||||||
|
background: var(--surface-strong);
|
||||||
|
color: var(--teal-deep);
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 13.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comp-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comp-table tr:hover td {
|
||||||
|
background: var(--surface-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comp-highlight {
|
||||||
|
background: var(--teal-soft);
|
||||||
|
font-weight: 850;
|
||||||
|
color: var(--teal-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comp-table td ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comp-table td li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 痛点方案卡片微动效与特色样式 */
|
||||||
|
.card {
|
||||||
|
transition: transform 0.22s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.22s ease, border-color 0.22s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
border-color: var(--teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.twin-sandbox-card {
|
||||||
|
grid-column: span 2;
|
||||||
|
background: linear-gradient(135deg, var(--surface) 60%, var(--teal-soft) 100%);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.twin-sandbox-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: var(--teal-deep);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
border-radius: 99px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.twin-sandbox-card {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 仿真沙盘交互组件样式 */
|
||||||
|
.twin-simulator-box {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: rgba(15, 118, 110, 0.03);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.twin-sim-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 900;
|
||||||
|
color: var(--teal-deep);
|
||||||
|
margin-bottom: 14px;
|
||||||
|
border-bottom: 1px dashed var(--line);
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sim-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--teal);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pulse 1.8s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(15, 118, 110, 0.5); }
|
||||||
|
70% { transform: scale(1); box-shadow: 0 0 0 6px rgba(15, 118, 110, 0); }
|
||||||
|
100% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(15, 118, 110, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.twin-sim-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.twin-sim-ctrl {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-group label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-group select {
|
||||||
|
width: 100%;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-family: var(--font);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-group select:focus {
|
||||||
|
border-color: var(--teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight-val {
|
||||||
|
color: var(--teal-deep);
|
||||||
|
font-weight: 900;
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-group input[type="range"] {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--line);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-group input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--teal);
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid var(--surface);
|
||||||
|
box-shadow: 0 2px 6px rgba(15,118,110,0.3);
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-group input[type="range"]::-webkit-slider-thumb:hover {
|
||||||
|
transform: scale(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-labels {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
#run-sim-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 36px;
|
||||||
|
background: var(--teal-deep);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 900;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 4px 12px rgba(16, 47, 43, 0.2);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#run-sim-btn:hover {
|
||||||
|
background: var(--teal);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 6px 16px rgba(15, 118, 110, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#run-sim-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.twin-sim-display {
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 12px;
|
||||||
|
display: grid;
|
||||||
|
align-content: space-between;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sim-scanner {
|
||||||
|
position: absolute;
|
||||||
|
top: -100%;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 12px;
|
||||||
|
background: linear-gradient(180deg, rgba(15, 118, 110, 0.2) 0%, transparent 100%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sim-scanner.scanning {
|
||||||
|
animation: scan 1.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scan {
|
||||||
|
0% { top: -10px; }
|
||||||
|
100% { top: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-group {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sim-metric {
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--surface-strong);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface-soft);
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sim-metric .m-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sim-metric .m-value {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sim-metric .m-value.teal { color: var(--teal); }
|
||||||
|
.sim-metric .m-value.orange { color: var(--amber); }
|
||||||
|
.sim-metric .m-value.red { color: var(--red); }
|
||||||
|
.sim-metric .m-value.blue { color: var(--blue); }
|
||||||
|
|
||||||
|
.sim-report-badge {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--surface-strong);
|
||||||
|
color: var(--teal-deep);
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 850;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sim-report-badge.success {
|
||||||
|
background: var(--teal-soft);
|
||||||
|
color: var(--teal-deep);
|
||||||
|
border: 1px solid rgba(15, 118, 110, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 580px) {
|
||||||
|
.twin-sim-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1500px) {
|
@media (max-width: 1500px) {
|
||||||
.page {
|
.page {
|
||||||
grid-template-columns: 236px minmax(0, 1fr);
|
grid-template-columns: 236px minmax(0, 1fr);
|
||||||
@@ -971,6 +1292,8 @@
|
|||||||
<a href="#why"><span class="dot"></span>为什么要做</a>
|
<a href="#why"><span class="dot"></span>为什么要做</a>
|
||||||
<a href="#future"><span class="dot"></span>未来地位</a>
|
<a href="#future"><span class="dot"></span>未来地位</a>
|
||||||
<a href="#blue"><span class="dot"></span>蓝海空间</a>
|
<a href="#blue"><span class="dot"></span>蓝海空间</a>
|
||||||
|
<a href="#competitor-analysis"><span class="dot"></span>竞品对比</a>
|
||||||
|
<a href="#painpoints-solution"><span class="dot"></span>痛点解决</a>
|
||||||
<a href="#goal"><span class="dot"></span>开发目的</a>
|
<a href="#goal"><span class="dot"></span>开发目的</a>
|
||||||
<a href="#algorithms"><span class="dot"></span>核心算法</a>
|
<a href="#algorithms"><span class="dot"></span>核心算法</a>
|
||||||
<a href="#modules"><span class="dot"></span>项目模块</a>
|
<a href="#modules"><span class="dot"></span>项目模块</a>
|
||||||
@@ -1037,28 +1360,19 @@
|
|||||||
<h2>为什么要做智能费控平台</h2>
|
<h2>为什么要做智能费控平台</h2>
|
||||||
<p class="section-desc">
|
<p class="section-desc">
|
||||||
智能费控不是把报销审批电子化,而是把企业每一笔支出变成可理解、可预测、可解释的经营信号。
|
智能费控不是把报销审批电子化,而是把企业每一笔支出变成可理解、可预测、可解释的经营信号。
|
||||||
当 AI、RAG、智能体、异常检测和预算联动进入财务流程后,费用平台会从“流程系统”升级为“经营控制系统”。
|
当前,在金税四期深化、数电发票单轨制全面普及的政策背景下,传统的“后置流程型”费控已被逼入死胡同,企业必须构建全链路智能费控平台。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="deep-copy">
|
<div class="deep-copy">
|
||||||
<p>
|
<p>
|
||||||
第一,费用是企业经营动作最密集、最贴近一线的数据入口。销售拜访客户会产生差旅和招待,
|
第一,<strong>数电票时代倒逼全链路数字化</strong>。随着全国数电发票的单轨制落地,发票的真伪验重、多系统抵扣、合规归档以及全生命周期的追踪已无法通过传统人工肉眼或零散的OCR识别来解决。系统必须具备在发票导入瞬间进行结构化解析、关联交易比对、业务事由交叉匹配的自动化能力,实现“开票即采集、采集即验真、入账即归档”。
|
||||||
项目交付会产生交通、住宿、外包和办公支出,培训、采购、通信、会务也都以费用形式落地。
|
|
||||||
如果企业只在报销完成后做核销,就只能看到“钱已经花了”;如果在申请、预算占用、票据上传、
|
|
||||||
审批和归档全过程做智能分析,就能提前看到“这笔钱为什么要花、是否该花、花完后对预算和风险有什么影响”。
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
第二,财务工作的重心正在从人工处理转向分析、预测和决策支持。Gartner 2025 年财务 AI 调研显示,
|
第二,<strong>企业从“事后核销”向“事前控制”的主动性跨越</strong>。传统ERP与报销系统核心是“记账与流程审批”,当员工提交报销单时,费用早已产生。智能费控利用 RAG 知识检索、预算实时联动,在费用申请阶段甚至消费发生的瞬间(如因公商旅预订)就进行预算额度校验与制度约束,使费控前置到决策端。
|
||||||
财务组织最常见的 AI 用例已经包括知识管理、应付流程自动化、错误与异常检测;McKinsey 的 CFO 调研也显示,
|
|
||||||
绝大多数受访者期待 AI 减少人工分析负担、生成洞察。这意味着费控平台必须具备自动识别、自动解释和自动沉淀的能力,
|
|
||||||
否则它会停留在“录单工具”,无法进入未来财务的核心工作台。
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
第三,费用风险越来越隐蔽。传统审批能挡住“缺附件”“超标准”这类显性问题,
|
第三,<strong>费用舞弊与异常的隐性化、复杂化</strong>。根据 ACFE 2024 年《全球职业舞弊与滥用报告》显示,全球企业中约有 15% 的舞弊案件与费用报销直接相关,且平均潜伏期长达 18 个月。传统的强硬制度配置只能拦截“超标准”、“无附件”等显性异常,但对于拆单报销、异地多点异常消费、同组偏离度过高、长期合作供应商异常开票等隐性风控,需要基于大数据画像和语义关系的图谱检测。
|
||||||
但很难发现拆单、跨部门合谋、异常频次、预算节奏异常、同组偏离、长期材料质量差等隐性问题。
|
|
||||||
ACFE 的职业舞弊报告将虚构或夸大业务费用列为典型报销舞弊形态;随着 AI 生成票据、深度伪造和自动化攻击出现,
|
|
||||||
企业更需要一个能把票据、人员、部门、历史行为、预算和制度放在一起分析的平台。
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1122,74 +1436,66 @@
|
|||||||
<div class="section-kicker">02 / COMPANY POSITION</div>
|
<div class="section-kicker">02 / COMPANY POSITION</div>
|
||||||
<h2>费控在未来公司的地位</h2>
|
<h2>费控在未来公司的地位</h2>
|
||||||
<p class="section-desc">
|
<p class="section-desc">
|
||||||
未来公司的费控,会从“报销审批入口”变成“经营支出操作系统”。
|
未来公司的费控,会从“报销审批工具”变成“企业费用智能操作系统”(Expense OS)。
|
||||||
它一端连接员工和部门的真实业务动作,另一端连接预算、现金流、成本、风险、制度和管理决策。
|
它一端连接员工和部门的真实业务动作,另一端连接预算、现金流、成本、风险、制度和管理决策。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="deep-copy">
|
<div class="deep-copy">
|
||||||
<p>
|
<p>
|
||||||
从组织分工看,CFO 的角色正在从财务记录者变成业务伙伴和技术治理者。
|
从<strong>组织分工与决策支持</strong>来看,CFO 的角色正加速从“合规记录者”转变为“战略业务伙伴(HR/IT/业务协同的中心)”。Deloitte 2025 年未来财务展望报告指出,财务部门未来的工作精力将有 70% 倾斜于数据分析和未来预测。智能费控则是这一变革的第一步——通过把一线经营活动(如差旅、招待、项目交付)产生的每一笔资金流动转化为语义数据,为 CFO 的“战略驾驶舱”提供实时的一手信号。
|
||||||
Deloitte 的未来财务洞察持续强调,财务团队会把更多时间投入分析、预测和决策支持;
|
|
||||||
Deloitte APAC CFO 2025 调研也显示,接近一半 CFO 认为生成式 AI 会在两年内显著改变行业、组织和财务职能。
|
|
||||||
这意味着费控不应再被放在“报销系统”这个狭窄位置,而应该成为 CFO 获取一线经营信号的前置雷达。
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
从业务管理看,预算不再只是年初编制和月底复盘。未来预算需要跟每一次费用申请实时联动:
|
从<strong>预算与资源控制</strong>来看,未来的预算管控不再是“年初编预算,月底对账单”的静态割裂状态。智能费控通过建立动态预算池与资金控制链,使预算能够在秒级发生响应。这保证了业务部门在项目推进过程中,能实时感知每一笔花费对项目整体毛利空间、部门现金流余量的动态影响,促使人人关注经营效果。
|
||||||
当前预算池还有多少、审批后使用率是多少、是否触达预警线、该部门同类费用是否异常、是否影响项目毛利和现金节奏。
|
|
||||||
因此费控会成为部门经理、项目负责人和财务 BP 共同使用的经营协同平台。
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
从风险控制看,未来公司的费用风险不是单张票据能解释清楚的,而是多源数据的组合问题。
|
从<strong>业务运营与体验</strong>来看,智能费控是消除部门隔阂、降低一线行政摩擦的“润滑剂”。当平台实现高度数字化与免报销消费结算时,员工不再需要贴票垫资,管理者不再需要在审批页面盲目点“同意”,财务不再需要枯燥审单,整个公司的运营效率将得到数量级提升。
|
||||||
一张票据可能合规,但一个人三个月内的频次、金额、地点、客户、同行人和预算占比可能异常。
|
|
||||||
智能费控要把这些信号拉成一张网,让管理者在支付之前就看到风险轮廓。
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="split">
|
<div class="split">
|
||||||
<div>
|
<div>
|
||||||
<ul class="bullets">
|
<ul class="bullets">
|
||||||
<li><strong>经营仪表盘:</strong>部门费用、项目消耗、预算占比、执行率、风险热区可以被实时汇总。</li>
|
<li><strong>经营支出中枢 (Expense OS):</strong> 将多系统的零散流程、差旅出行、第三方消费直接通过本体层收归统一。</li>
|
||||||
<li><strong>预算守门员:</strong>费用申请不再只看“能不能报”,而要看“是否符合预算节奏”。</li>
|
<li><strong>预算守护雷达:</strong> 在业务动作发生时实现“秒级预算占用与测算”,而不是事后核销时的“超支警告”。</li>
|
||||||
<li><strong>内控前置层:</strong>票据、地点、人员、金额、制度条款和历史行为被联动审核。</li>
|
<li><strong>合规内控防火墙:</strong> 结合发票验真、合同信息、行程轨迹和行为偏差等多模态数据对风险自动拦截。</li>
|
||||||
<li><strong>AI 财务工作台:</strong>财务人员可以通过自然语言查询制度、解释风险、生成审批建议和费用报告。</li>
|
<li><strong>战略决策沙盘:</strong> 支持 CFO 和高管直接询问“各项目利润健康度”、“政策变动预算消耗预测”等深度命题。</li>
|
||||||
<li><strong>规则与知识工厂:</strong>制度解释、规则生成、风险复盘和人工反馈进入可持续优化闭环。</li>
|
<li><strong>自进化规则工厂:</strong> 依据历史人工审批意见和申诉结果,系统能自动推荐规则优化方案,实现制度灰度升级。</li>
|
||||||
<li><strong>管理决策入口:</strong>管理者可以询问“哪个部门预算风险最高”“哪些费用正在吞噬毛利”。</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="flow-diagram" aria-label="费控未来地位流程图">
|
<div class="flow-diagram" aria-label="费控未来地位流程图">
|
||||||
<div class="flow-row" style="--cols: 3">
|
<div class="flow-row" style="--cols: 3">
|
||||||
<div class="flow-node">
|
<div class="flow-node">
|
||||||
<div class="flow-node-title">业务动作</div>
|
<div class="flow-node-title">业务场景</div>
|
||||||
<div class="flow-node-text">出差、采购、招待、培训、项目交付</div>
|
<div class="flow-node-text">差旅、招待、采购、项目外包、会务支出</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flow-node dark">
|
<div class="flow-node dark">
|
||||||
<div class="flow-node-title">智能费控平台</div>
|
<div class="flow-node-title">费用智能操作系统</div>
|
||||||
<div class="flow-node-text">预算、单据、制度、票据、风险、智能体</div>
|
<div class="flow-node-text">本体语义 · 实时预算 · 智能体 · 仿真沙盘</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flow-node">
|
<div class="flow-node">
|
||||||
<div class="flow-node-title">财务结果</div>
|
<div class="flow-node-title">经营结果</div>
|
||||||
<div class="flow-node-text">成本、现金、核销、支付、账务沉淀</div>
|
<div class="flow-node-text">成本优化、合规账套、税务风险减免、现金流稳健</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flow-row" style="--cols: 3">
|
<div class="flow-row" style="--cols: 3">
|
||||||
<div class="flow-node blue">
|
<div class="flow-node blue">
|
||||||
<div class="flow-node-title">组织与预算</div>
|
<div class="flow-node-title">CFO 战略看板</div>
|
||||||
<div class="flow-node-text">部门、项目、成本中心、预算池</div>
|
<div class="flow-node-text">业财融合数据、动态毛利预警、资金利用率</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flow-node amber">
|
<div class="flow-node amber">
|
||||||
<div class="flow-node-title">数据与画像</div>
|
<div class="flow-node-title">数字孪生仿真</div>
|
||||||
<div class="flow-node-text">费用画像、行为画像、同组基准</div>
|
<div class="flow-node-text">制度变动测算、预算优化预测、行为偏差度</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flow-node blue">
|
<div class="flow-node blue">
|
||||||
<div class="flow-node-title">管理决策</div>
|
<div class="flow-node-title">协同平台</div>
|
||||||
<div class="flow-node-text">预警、报告、经营洞察、规则优化</div>
|
<div class="flow-node-text">免贴票、即时审批、智能助手交互</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="diagram-caption">
|
<div class="diagram-caption">
|
||||||
费控位于经营动作和财务结果之间,未来承担预算守门、规则解释、风险识别、数据画像和管理洞察的中枢角色。
|
智能费控平台向上承接公司战略与预算规则,向下沉入业务一线动作,最终演化为企业支出的全局操作系统。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1200,45 +1506,203 @@
|
|||||||
<div class="section-kicker">03 / BLUE OCEAN</div>
|
<div class="section-kicker">03 / BLUE OCEAN</div>
|
||||||
<h2>智能分析费控平台的蓝海</h2>
|
<h2>智能分析费控平台的蓝海</h2>
|
||||||
<p class="section-desc">
|
<p class="section-desc">
|
||||||
传统 ERP、OA、报销系统和财务共享平台已经覆盖了流程,但对“语义理解、自动洞察、隐性风险识别、
|
传统的报销系统、OA 工作流和财务共享软件已是一片红海,产品竞争停留在表单设计与流程配置层面。而真正能为企业带来颠覆性价值的“智能费控蓝海”,则存在于从流程处理到智能化决策预测的跃迁中。
|
||||||
预算占比解释、部门费用经营分析”的覆盖仍然不足。蓝海空间正来自这个空白:
|
|
||||||
市场正在从单点报销工具转向 AI 驱动的差旅、费用、发票、预算和风险一体化平台。
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="deep-copy">
|
<div class="deep-copy">
|
||||||
<p>
|
<p>
|
||||||
Grand View Research 2025 年报告预计,全球差旅与费用管理软件市场到 2030 年将达到 106.9 亿美元,
|
根据全球著名调研机构 Grand View Research 2025 发布的市场预测报告,全球差旅与费用管理(T&E)软件市场规模在 2030 年将达到 <strong>106.9 亿美元</strong>,2024 至 2030 年的复合年增长率(CAGR)高达 <strong>16.9%</strong>。报告中特别强调,市场增长的核心引擎已不再是单纯的“线上化审批”,而是引入 AI Agent 进行决策自动化、基于 OCR/LLM 的隐性欺诈检测、以及基于企业历史支出行为的可持续 ESG 碳足迹追踪。这表明,“费用智能化与经营沙盘仿真”是蕴含巨大商业溢价的崭新蓝海。
|
||||||
2024 至 2030 年复合增速为 16.9%。更重要的是,它特别提到 AI 可用于审批与支付自动化、
|
|
||||||
员工差旅历史和支出模式分析、OCR 票据字段识别以及基于自然语言的交互。
|
|
||||||
这说明市场真正的增长点不只是“报销线上化”,而是“费用智能化”。
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid cols-3">
|
<div class="grid cols-3">
|
||||||
<article class="card">
|
<article class="card">
|
||||||
<strong>非结构化数据智能化</strong>
|
<strong>1. 认知与决策型 AI Agent</strong>
|
||||||
<p>票据、制度 PDF、Excel 规则、审批意见、用户自然语言都能进入同一个语义管道。</p>
|
<p>从“机械填表和固定规则校验”跨越到“能基于上下文自主决策、异常判定、并提供人机协同解释”的财务数字员工。</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="card">
|
<article class="card">
|
||||||
<strong>部门费用经营化</strong>
|
<strong>2. 财务数字孪生与政策仿真</strong>
|
||||||
<p>费用从“报销明细”升级为“部门预算执行、费用结构、异常趋势、经营压力”的管理对象。</p>
|
<p>基于公司历史发票、人员组织、预算规则建立仿真模型,模拟差旅标准或预算政策调整后,对企业资金流与利润的实际影响。</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="card">
|
<article class="card">
|
||||||
<strong>风险识别前置化</strong>
|
<strong>3. 多源异构数据语义对齐</strong>
|
||||||
<p>拆单、超预算、重复票据、地点不一致、附件缺失、异常频次都可以更早被发现。</p>
|
<p>打破各子系统(CRM、ERP、OA、商旅聚合商)接口壁垒,利用 Semantic Ontology 本体语义层实现业务数据的天然降维对齐。</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="card">
|
<article class="card">
|
||||||
<strong>财务智能体运营化</strong>
|
<strong>4. 行为偏离与隐性欺诈识别</strong>
|
||||||
<p>后台数字员工不再等待人点击,而是按周期巡检数据、生成报告和沉淀优化候选。</p>
|
<p>基于知识图谱与图机器学习技术,识别跨期拆单、虚假报销、同组偏离异常、跨部门关联交易等人工难以察觉的系统性风险。</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="card">
|
<article class="card">
|
||||||
<strong>规则治理产品化</strong>
|
<strong>5. 自进化规则治理闭环</strong>
|
||||||
<p>自然语言规则、风险规则、Excel 规则和制度知识可以逐步变成可测试、可版本化资产。</p>
|
<p>支持自然语言输入制度,由 LLM 自动翻译成系统执行规则,并通过人工申诉与退回反馈,在后台自动完成规则库的校准 and 修正。</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="card">
|
<article class="card">
|
||||||
<strong>财务知识即时化</strong>
|
<strong>6. 绿色财务与 ESG 碳足迹</strong>
|
||||||
<p>RAG 与知识库让制度解释从人工翻文档,转为“问一句,返回依据和建议”。</p>
|
<p>在费控和商旅端自动提取交通工具、酒店的排放系数,帮助企业在进行费用管控的同时,同步测算并生成企业 ESG 碳足迹报告。</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section" id="competitor-analysis">
|
||||||
|
<div class="section-head">
|
||||||
|
<div class="section-kicker">03.1 / COMPETITORS</div>
|
||||||
|
<h2>主流费控系统竞品分析</h2>
|
||||||
|
<p class="section-desc">
|
||||||
|
要实现上述蓝海构想,必须客观分析当前市场上主流的费控软件。各大软件定位明确,但普遍在“智能化决策”、“跨系统语义融合”及“政策仿真”方面存在显著痛点。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="comp-table-wrapper">
|
||||||
|
<table class="comp-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 14%">竞品名称</th>
|
||||||
|
<th style="width: 25%">核心定位与主要优势</th>
|
||||||
|
<th style="width: 28%">核心痛点与局限性</th>
|
||||||
|
<th style="width: 33%">X-Financial 的创新解决与差异化</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><strong>SAP Concur</strong></td>
|
||||||
|
<td>
|
||||||
|
<ul>
|
||||||
|
<li>全球化费控天花板,支持多币种、多税制、全球财税合规。</li>
|
||||||
|
<li>商旅生态极其成熟,能够与 SAP ERP 体系深度咬合。</li>
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<ul>
|
||||||
|
<li>本土化体验被国内用户诟病(如数电发票处理缓慢)。</li>
|
||||||
|
<li>系统界面臃肿,配置与二次开发极为繁琐。</li>
|
||||||
|
<li>实施周期漫长,运维与实施费用极其高昂。</li>
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
<td class="comp-highlight">
|
||||||
|
<ul>
|
||||||
|
<li>原生适配数电发票单轨制,具备开箱即用的 AI 智能验真归档。</li>
|
||||||
|
<li>基于本地化轻量级大模型架构,大幅降低实施门槛和部署成本。</li>
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>分贝通</strong></td>
|
||||||
|
<td>
|
||||||
|
<ul>
|
||||||
|
<li>以商旅消费支付为切入点,打造“商旅+支付+费控”模式。</li>
|
||||||
|
<li>倡导“无需报销”体验,员工体验极佳。</li>
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<ul>
|
||||||
|
<li>强依赖平台内置的消费商城生态,在面对大量零星线下费用、外包采购时灵活性不足。</li>
|
||||||
|
<li>对于复杂的多级跨账簿预算、动态共享额度的精细化管控能力相对偏弱。</li>
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
<td class="comp-highlight">
|
||||||
|
<ul>
|
||||||
|
<li>提供基于本体层屏蔽异构商城壁垒的通用解析能力,线下报销补件与线上免报销消费实现语义对齐。</li>
|
||||||
|
<li>支持复杂层级的预算池(部门/项目/成本中心)秒级动态关联管控。</li>
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>合思 (易快报)</strong></td>
|
||||||
|
<td>
|
||||||
|
<ul>
|
||||||
|
<li>一站式业财收支管理,国内市占率居前。</li>
|
||||||
|
<li>生态系统对接广泛,能与国内主流 ERP 无缝打通,配置灵活。</li>
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<ul>
|
||||||
|
<li>系统逻辑依然以“传统工作流表单”为主,交互较为传统,缺乏智能助理的深度参与。</li>
|
||||||
|
<li>缺乏中长周期的员工行为偏差分析、规则误报自适应纠偏机制。</li>
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
<td class="comp-highlight">
|
||||||
|
<ul>
|
||||||
|
<li>前台 User Agent 支持自然语言直接进行报销提问、草稿生成、异常核对与智能交互。</li>
|
||||||
|
<li>通过共享本体与规则中心,让财务人员可以用自然语言配置和灰度升级风控规则。</li>
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>每刻报销</strong></td>
|
||||||
|
<td>
|
||||||
|
<ul>
|
||||||
|
<li>专注于大中型企业的智能财务共享,规则引擎非常强大。</li>
|
||||||
|
<li>AI 智能审单能力强,支持复杂的集团化多层级架构。</li>
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<ul>
|
||||||
|
<li>系统的学习曲线陡峭,对业务人员和普通员工的交互不够平滑。</li>
|
||||||
|
<li>侧重于既定流程的合规审计,对于预算和费控政策调整带来的前瞻性影响缺乏模拟仿真能力。</li>
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
<td class="comp-highlight">
|
||||||
|
<ul>
|
||||||
|
<li>前台大模型辅助,降低普通用户填单学习曲线。</li>
|
||||||
|
<li><strong>核心差异化:首创“财务数字孪生与仿真沙盘”</strong>,在系统级测试和决策前对费控变动进行量化分析预测。</li>
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section" id="painpoints-solution">
|
||||||
|
<div class="section-head">
|
||||||
|
<div class="section-kicker">03.2 / SOLUTIONS</div>
|
||||||
|
<h2>X-Financial 痛点解决方案</h2>
|
||||||
|
<p class="section-desc">
|
||||||
|
针对以上竞品普遍面临的系统孤岛、缺乏智能决策、政策调整靠经验等痛点,X-Financial 提出了以 **“AI + 数字化 + 财务数字孪生”** 为核心的下一代费控解决方案。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid cols-2">
|
||||||
|
<article class="card accent-teal">
|
||||||
|
<strong>一、 解决多系统孤岛:Semantic Ontology 本体语义层</strong>
|
||||||
|
<p>
|
||||||
|
传统软件通过 API 强行硬编码对接 CRM、ERP 和商旅系统,一旦系统升级,接口极易断裂,导致业财数据“语义断层”。
|
||||||
|
</p>
|
||||||
|
<p style="margin-top: 8px; font-size: 13px;">
|
||||||
|
<strong>X-Financial 方案:</strong> 我们设计了包含 8 个关键因子的本体语义协议(domain, scenario, intent 等)。无论是口语化描述、页面动作还是三方消费接口,都先被解析为统一的“本体状态”,再分发至下层服务。这实现了跨系统的语义级天然对齐,让数据真正融为一体。
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card accent-blue">
|
||||||
|
<strong>二、 解决财务假智能:User Agent & Hermes 双智能体闭环</strong>
|
||||||
|
<p>
|
||||||
|
市面软件仅使用 OCR 识别将文字填入表单,人工审核工作量并没有显著降低,也无法识别隐性舞弊。
|
||||||
|
</p>
|
||||||
|
<p style="margin-top: 8px; font-size: 13px;">
|
||||||
|
<strong>X-Financial 方案:</strong> 我们构建了双 Agent 循环。前台 <strong>User Agent</strong> 帮员工和财务进行友好交互,自动查漏补正,解释退回原因;后台 <strong>Hermes 数字员工</strong> 定时在数据底座扫描,分析员工近90/180天的费用画像和行为画像,依据同组偏差算法自动判定隐性风险,出具报告。
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card accent-amber">
|
||||||
|
<strong>三、 解决规则配置难:LLM 驱动的规则与知识工厂</strong>
|
||||||
|
<p>
|
||||||
|
传统系统的规则引擎配置需要 IT 人员编写繁杂的条件表达式,难以响应公司制度的频繁变动。
|
||||||
|
</p>
|
||||||
|
<p style="margin-top: 8px; font-size: 13px;">
|
||||||
|
<strong>X-Financial 方案:</strong> 支持财务直接上传自然语言描述的制度文档。系统通过大模型将文本转化为可测试、可执行的规则库资产;当发生审批争议或误报时,支持财务通过“反馈池”对规则 and 知识库进行一键校正,进入自进化闭环。
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card twin-sandbox-card">
|
||||||
|
<span class="twin-sandbox-badge">首创蓝海技术</span>
|
||||||
|
<strong>四、 解决政策盲目调整:财务数字孪生与仿真沙盘(Financial Digital Twin)</strong>
|
||||||
|
<p>
|
||||||
|
很多企业在遇到经营波动时,会盲目调低差旅标准(如酒店降低20%),这容易导致员工满意度下降、灰色报销增多或合规偏离率升高,甚至因行政摩擦上升造成更大的隐性成本。目前市场没有任何一款软件能评估规则调整带来的实际后果。
|
||||||
|
</p>
|
||||||
|
<p style="margin-top: 8px; font-size: 13.5px;">
|
||||||
|
<strong>X-Financial 方案:</strong> 我们通过融合组织架构树、员工费用画像、预算控制规则与历史发票数据库,为企业构建了一个“财务数字孪生体”。当管理层想要修改政策(如“下调销售部酒店标准 15%”)时,可在系统<strong>仿真沙盘(Simulation Sandbox)</strong>中运行模拟。沙盘会模拟历史真实或合成出的数万个出差场景进行回归测算,量化预测此次政策调整对“未来费用降幅(预估降低11%)”、“员工合规偏离率(预估提升8%)”、“预计审批流程摩擦时间(预估提升12%)”的多维影响,为企业提供真正科学的决策辅助。
|
||||||
|
</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -1811,6 +2275,8 @@ budget_control =
|
|||||||
<a href="#why"><span class="dot"></span>为什么要做</a>
|
<a href="#why"><span class="dot"></span>为什么要做</a>
|
||||||
<a href="#future"><span class="dot"></span>未来地位</a>
|
<a href="#future"><span class="dot"></span>未来地位</a>
|
||||||
<a href="#blue"><span class="dot"></span>蓝海空间</a>
|
<a href="#blue"><span class="dot"></span>蓝海空间</a>
|
||||||
|
<a href="#competitor-analysis"><span class="dot"></span>竞品对比</a>
|
||||||
|
<a href="#painpoints-solution"><span class="dot"></span>痛点解决</a>
|
||||||
<a href="#goal"><span class="dot"></span>开发目的</a>
|
<a href="#goal"><span class="dot"></span>开发目的</a>
|
||||||
<a href="#algorithms"><span class="dot"></span>算法能力</a>
|
<a href="#algorithms"><span class="dot"></span>算法能力</a>
|
||||||
<a href="#modules"><span class="dot"></span>模块清单</a>
|
<a href="#modules"><span class="dot"></span>模块清单</a>
|
||||||
|
|||||||
238
document/development/receipt-folder/CONCEPT.md
Normal file
238
document/development/receipt-folder/CONCEPT.md
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
# 票据夹功能概念文档
|
||||||
|
|
||||||
|
更新时间:2026-05-29
|
||||||
|
|
||||||
|
## 功能一句话
|
||||||
|
|
||||||
|
票据夹用于归集用户已上传并经过 OCR 识别的原始票据文件,避免票据已识别但忘记关联报销单后无法找回。
|
||||||
|
|
||||||
|
## 背景与问题
|
||||||
|
|
||||||
|
当前系统有两条票据路径:
|
||||||
|
|
||||||
|
- 报销明细附件路径:票据上传到某个草稿费用明细后,会存入 `expense_claims` 附件目录,并写入附件元数据。
|
||||||
|
- 独立 OCR 识别路径:报销对话里先上传票据识别时,`/ocr/recognize` 只返回识别结果,源文件使用临时目录,识别结束后会清理。
|
||||||
|
|
||||||
|
这会导致一个业务缺口:用户可能已经上传票据并完成 OCR,但还没有把票据关联到报销草稿。只要用户关闭会话、切走页面或忘记继续操作,原始票据就没有一个稳定入口可追溯。
|
||||||
|
|
||||||
|
票据夹要补齐这个缺口:凡是系统对用户上传文件做过 OCR 并持久化源文件,就应进入票据夹列表;后续用户可以查看、修正票据信息、删除无效票据,或一键把未关联票据带入报销对话。
|
||||||
|
|
||||||
|
## 目标与非目标
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 在左侧侧边栏的“单据中心”下面新增“票据夹”入口。
|
||||||
|
- 建立票据源文件持久化能力,OCR 后保留原始文件、预览文件和识别元数据。
|
||||||
|
- 提供票据夹列表,复用单据中心的紧凑列表视觉语言。
|
||||||
|
- 支持“未关联票据 / 已关联票据”两个状态切换。
|
||||||
|
- 支持票据详情:基本票据信息可编辑、原始文件可预览、底部返回列表和删除票据。
|
||||||
|
- 支持“一键关联票据”:选择未关联票据,选择未提交草稿或新建报销单,再跳转到报销对话继续填写和关联。
|
||||||
|
|
||||||
|
非目标:
|
||||||
|
|
||||||
|
- 本轮不引入 `document_assets` 等数据库结构变更;先用文件资产和元数据 JSON 完成产品闭环。
|
||||||
|
- 本轮不替换现有报销明细附件接口。
|
||||||
|
- 本轮不把票据夹做成财务共享的全公司档案库;默认只展示当前登录用户自己的票据。
|
||||||
|
- 本轮不在列表页直接完成报销单提交,提交仍回到现有对话核对流程。
|
||||||
|
|
||||||
|
## 用户与场景
|
||||||
|
|
||||||
|
涉及角色:
|
||||||
|
|
||||||
|
- 普通员工:上传票据后稍后再归集到草稿。
|
||||||
|
- 经理或财务用户:在自己名下上传票据时同样需要留存和追溯。
|
||||||
|
|
||||||
|
典型场景:
|
||||||
|
|
||||||
|
1. 用户在个人工作台上传 3 张票据,OCR 成功后暂时没有保存草稿。
|
||||||
|
2. 用户第二天打开票据夹,看到这 3 张票据仍在“未关联票据”。
|
||||||
|
3. 用户进入详情,修正票据类型、金额或日期。
|
||||||
|
4. 用户点击“一键关联票据”,多选未关联票据。
|
||||||
|
5. 用户选择已有草稿,或选择新建报销单。
|
||||||
|
6. 系统打开报销对话,把票据源文件和 OCR 信息带入现有核对流程。
|
||||||
|
|
||||||
|
## 功能能力
|
||||||
|
|
||||||
|
### 票据持久化
|
||||||
|
|
||||||
|
- OCR 入口接收文件后,在识别完成阶段保存源文件。
|
||||||
|
- 保存位置建议为 `storage/receipt_folder/<owner_key>/<receipt_id>/`。
|
||||||
|
- 每个票据目录包含:
|
||||||
|
- 原始文件:`source.<ext>`
|
||||||
|
- 预览文件:`preview.<ext>`,可为空
|
||||||
|
- 元数据:`meta.json`
|
||||||
|
- 元数据记录:
|
||||||
|
- `id`
|
||||||
|
- `owner_key`
|
||||||
|
- `file_name`
|
||||||
|
- `media_type`
|
||||||
|
- `size_bytes`
|
||||||
|
- `uploaded_at`
|
||||||
|
- `status`: `unlinked` / `linked`
|
||||||
|
- `linked_claim_id`
|
||||||
|
- `linked_claim_no`
|
||||||
|
- `linked_item_id`
|
||||||
|
- `linked_at`
|
||||||
|
- OCR 引擎、模型、文本、摘要、置信度、票据类型、场景、结构化字段、提示信息
|
||||||
|
|
||||||
|
### 列表
|
||||||
|
|
||||||
|
- 页签:
|
||||||
|
- 未关联票据
|
||||||
|
- 已关联票据
|
||||||
|
- 表格字段建议:
|
||||||
|
- 票据文件
|
||||||
|
- 识别类型
|
||||||
|
- 费用场景
|
||||||
|
- 金额
|
||||||
|
- 票据日期
|
||||||
|
- OCR 置信度
|
||||||
|
- 关联状态
|
||||||
|
- 上传时间
|
||||||
|
- 交互:
|
||||||
|
- 搜索文件名、摘要、字段值、关联单号
|
||||||
|
- 按状态切换
|
||||||
|
- 点击行进入详情
|
||||||
|
- 未关联页显示“一键关联票据”
|
||||||
|
|
||||||
|
### 详情
|
||||||
|
|
||||||
|
- 基本票据信息:
|
||||||
|
- 文件名只读
|
||||||
|
- 票据类型可编辑
|
||||||
|
- 费用场景可编辑
|
||||||
|
- 票据日期可编辑
|
||||||
|
- 金额可编辑
|
||||||
|
- 商户 / 出发地 / 到达地 / 票据号码等 OCR 字段可编辑
|
||||||
|
- 原始文件展示:
|
||||||
|
- 图片直接预览
|
||||||
|
- PDF 用浏览器内嵌预览
|
||||||
|
- 不可预览类型提供下载入口
|
||||||
|
- 底部动作:
|
||||||
|
- 返回列表
|
||||||
|
- 删除票据
|
||||||
|
|
||||||
|
### 一键关联票据
|
||||||
|
|
||||||
|
流程:
|
||||||
|
|
||||||
|
1. 打开关联弹窗,展示未关联票据多选列表。
|
||||||
|
2. 下一步展示当前用户未提交草稿报销单,也提供“新建报销单”选项。
|
||||||
|
3. 确认后打开现有报销对话。
|
||||||
|
4. 如果选择已有草稿:
|
||||||
|
- 对话以 `link_to_existing_draft` 语义继续。
|
||||||
|
- 携带 `draft_claim_id` 和票据文件。
|
||||||
|
5. 如果选择新建报销单:
|
||||||
|
- 对话以 `create_new_claim_from_documents` 语义继续。
|
||||||
|
- 携带票据文件和 OCR 元数据。
|
||||||
|
|
||||||
|
## 方案设计
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
|
||||||
|
新增模块:
|
||||||
|
|
||||||
|
- `schemas/receipt_folder.py`
|
||||||
|
- `services/receipt_folder.py`
|
||||||
|
- `api/v1/endpoints/receipt_folder.py`
|
||||||
|
|
||||||
|
接口建议:
|
||||||
|
|
||||||
|
- `GET /api/v1/receipt-folder?status=unlinked|linked|all`
|
||||||
|
- `GET /api/v1/receipt-folder/{receipt_id}`
|
||||||
|
- `PATCH /api/v1/receipt-folder/{receipt_id}`
|
||||||
|
- `DELETE /api/v1/receipt-folder/{receipt_id}`
|
||||||
|
- `GET /api/v1/receipt-folder/{receipt_id}/preview`
|
||||||
|
- `GET /api/v1/receipt-folder/{receipt_id}/source`
|
||||||
|
|
||||||
|
OCR 改造:
|
||||||
|
|
||||||
|
- `/api/v1/ocr/recognize` 保持现有响应结构兼容。
|
||||||
|
- 在识别后调用票据夹服务保存源文件和识别结果。
|
||||||
|
- 给每个返回的 OCR 文档补充可选 `receipt_id`、`receipt_preview_url`、`receipt_source_url` 字段。
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
|
||||||
|
新增模块:
|
||||||
|
|
||||||
|
- `services/receiptFolder.js`
|
||||||
|
- `views/ReceiptFolderView.vue`
|
||||||
|
- `assets/styles/views/receipt-folder-view.css`
|
||||||
|
|
||||||
|
导航改造:
|
||||||
|
|
||||||
|
- `useNavigation.js` 新增 `receiptFolder`,放在 `documents` 后面。
|
||||||
|
- `accessControl.js` 将 `receiptFolder` 作为默认可见视图。
|
||||||
|
- `router/index.js` 自动生成 `/app/receiptFolder` 路由。
|
||||||
|
- `AppShellRouteView.vue` 渲染新页面,并允许页面触发 `openSmartEntry`。
|
||||||
|
|
||||||
|
对话衔接:
|
||||||
|
|
||||||
|
- 票据夹确认关联时,前端从 `source` 接口取回 Blob,构造 `File` 对象传给 `openSmartEntry`。
|
||||||
|
- 同时把已编辑 OCR 元数据转为 `initialReceiptDocuments` 或直接通过 `prompt` / `extraContext` 进入对话。
|
||||||
|
- 本轮优先用现有 `initial-files` 和 `initial-prompt` 打开对话,确保用户可以继续核对和保存。
|
||||||
|
|
||||||
|
## 算法与公式
|
||||||
|
|
||||||
|
当前功能不涉及显式数学公式。
|
||||||
|
|
||||||
|
列表排序使用上传时间倒序:
|
||||||
|
|
||||||
|
$$
|
||||||
|
sortKey(receipt) = uploadedAt(receipt)
|
||||||
|
$$
|
||||||
|
|
||||||
|
状态归类:
|
||||||
|
|
||||||
|
$$
|
||||||
|
status(receipt) =
|
||||||
|
\begin{cases}
|
||||||
|
linked, & linkedClaimId \neq \emptyset \\
|
||||||
|
unlinked, & linkedClaimId = \emptyset
|
||||||
|
\end{cases}
|
||||||
|
$$
|
||||||
|
|
||||||
|
## 测试方案
|
||||||
|
|
||||||
|
后端:
|
||||||
|
|
||||||
|
- OCR 识别后会保存源文件和 `meta.json`。
|
||||||
|
- 列表只返回当前用户票据。
|
||||||
|
- `status=unlinked` 只返回未关联票据。
|
||||||
|
- 详情可读取 OCR 字段。
|
||||||
|
- PATCH 后字段持久化。
|
||||||
|
- 预览接口能返回图片或 PDF。
|
||||||
|
- DELETE 只删除票据夹根目录下的目标票据。
|
||||||
|
|
||||||
|
前端:
|
||||||
|
|
||||||
|
- 导航中“票据夹”位于“单据中心”下面。
|
||||||
|
- 列表空态、加载态、错误态可用。
|
||||||
|
- 未关联和已关联两个页签计数正确。
|
||||||
|
- 点击行进入详情。
|
||||||
|
- 详情可保存字段、返回列表、删除票据。
|
||||||
|
- 一键关联弹窗能完成票据选择和草稿选择。
|
||||||
|
|
||||||
|
集成:
|
||||||
|
|
||||||
|
- 上传票据触发 OCR 后,票据出现在票据夹。
|
||||||
|
- 从票据夹选择未关联票据,可打开报销对话。
|
||||||
|
- 选择已有草稿时,对话带入草稿单号。
|
||||||
|
- 选择新建报销单时,对话提示基于票据新建。
|
||||||
|
|
||||||
|
## 指标与验收
|
||||||
|
|
||||||
|
- OCR 成功返回后,票据夹列表能查到对应源文件。
|
||||||
|
- 票据源文件和预览文件在重启后仍可访问。
|
||||||
|
- 未关联票据和已关联票据状态切换正确。
|
||||||
|
- 票据详情字段修改后刷新仍保留。
|
||||||
|
- 删除票据后列表不再显示,预览接口返回 404。
|
||||||
|
- 侧边栏位置符合要求:票据夹在单据中心下面。
|
||||||
|
- 单个新增核心前端和后端模块不超过 800 行。
|
||||||
|
|
||||||
|
## 风险与开放问题
|
||||||
|
|
||||||
|
- 当前报销草稿流主要持久化 OCR 文本和文件名,真实文件复制到报销明细附件目录仍需要进一步打通。
|
||||||
|
- 本轮采用文件元数据而非数据库,适合先完成闭环;后续若需要审计、权限、跨用户协作和全文检索,应升级到资产表。
|
||||||
|
- 已关联状态如何自动回写,需要在后续把票据夹 ID 与报销明细 `invoice_id` 建立更强绑定。
|
||||||
|
- 多票据关联时,如果用户中途取消对话,本轮仍保留为未关联,避免误标。
|
||||||
78
document/development/receipt-folder/TODO.md
Normal file
78
document/development/receipt-folder/TODO.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# 票据夹功能 TODO
|
||||||
|
|
||||||
|
更新时间:2026-05-29
|
||||||
|
|
||||||
|
## 阶段一:调研与契约
|
||||||
|
|
||||||
|
- [x] 梳理现有单据中心导航、列表样式和详情入口。[CONCEPT: 方案设计]
|
||||||
|
证据:已确认 `DocumentsCenterView.vue`、`useNavigation.js`、`AppShellRouteView.vue` 是前端入口。
|
||||||
|
|
||||||
|
- [x] 梳理现有 OCR 和报销附件存储链路。[CONCEPT: 背景与问题]
|
||||||
|
证据:已确认 `/ocr/recognize` 只临时识别;报销明细附件由 `expense_claim_attachment_*` 写入 `expense_claims` 存储。
|
||||||
|
|
||||||
|
- [x] 确定本轮不做数据库结构变更,先用票据文件资产和元数据 JSON 完成闭环。[CONCEPT: 目标与非目标]
|
||||||
|
证据:避免新增迁移,降低本轮开发风险。
|
||||||
|
|
||||||
|
## 阶段二:文档
|
||||||
|
|
||||||
|
- [x] 创建 `document/development/receipt-folder/CONCEPT.md`。[CONCEPT: 全文]
|
||||||
|
证据:本文档已落地。
|
||||||
|
|
||||||
|
- [x] 创建 `document/development/receipt-folder/TODO.md`。[CONCEPT: 测试方案]
|
||||||
|
证据:本文档已落地。
|
||||||
|
|
||||||
|
## 阶段三:后端票据资产层
|
||||||
|
|
||||||
|
- [ ] 新增 `schemas/receipt_folder.py`,定义列表项、详情、字段更新和删除响应。[CONCEPT: 后端]
|
||||||
|
|
||||||
|
- [ ] 新增 `services/receipt_folder.py`,负责源文件保存、元数据读写、预览解析、列表过滤和安全路径校验。[CONCEPT: 票据持久化]
|
||||||
|
|
||||||
|
- [ ] 新增 `api/v1/endpoints/receipt_folder.py`,暴露列表、详情、更新、删除、预览和源文件接口。[CONCEPT: 后端]
|
||||||
|
|
||||||
|
- [ ] 在 `api/v1/router.py` 注册票据夹接口。[CONCEPT: 后端]
|
||||||
|
|
||||||
|
- [ ] 改造 `/ocr/recognize`,OCR 后保存源文件并把 `receipt_id` 等可选字段带回前端。[CONCEPT: OCR 改造]
|
||||||
|
|
||||||
|
## 阶段四:前端票据夹页面
|
||||||
|
|
||||||
|
- [ ] 新增 `services/receiptFolder.js`,封装票据夹接口和 Blob 文件读取。[CONCEPT: 前端]
|
||||||
|
|
||||||
|
- [ ] 新增 `ReceiptFolderView.vue`,实现列表、状态页签、搜索、一键关联入口和详情切换。[CONCEPT: 列表]
|
||||||
|
|
||||||
|
- [ ] 新增 `receipt-folder-view.css`,复用单据中心紧凑企业级视觉,避免继续拉大现有 `DocumentsCenterView.vue`。[CONCEPT: 列表]
|
||||||
|
|
||||||
|
- [ ] 在 `useNavigation.js` 增加 `receiptFolder`,并放在 `documents` 后面。[CONCEPT: 前端]
|
||||||
|
|
||||||
|
- [ ] 在 `accessControl.js` 增加默认可见权限和默认路由顺序。[CONCEPT: 前端]
|
||||||
|
|
||||||
|
- [ ] 在 `AppShellRouteView.vue` 渲染票据夹页面,并让页面可打开报销对话。[CONCEPT: 一键关联票据]
|
||||||
|
|
||||||
|
## 阶段五:一键关联流程
|
||||||
|
|
||||||
|
- [ ] 实现未关联票据多选弹窗第一步。[CONCEPT: 一键关联票据]
|
||||||
|
|
||||||
|
- [ ] 实现未提交草稿选择和“新建报销单”选择第二步。[CONCEPT: 一键关联票据]
|
||||||
|
|
||||||
|
- [ ] 从票据源文件接口取回 Blob 并构造 `File` 对象传给报销对话。[CONCEPT: 对话衔接]
|
||||||
|
|
||||||
|
- [ ] 选择已有草稿时,打开对话并带入草稿单号和关联提示。[CONCEPT: 一键关联票据]
|
||||||
|
|
||||||
|
- [ ] 选择新建报销单时,打开对话并带入基于票据新建的提示。[CONCEPT: 一键关联票据]
|
||||||
|
|
||||||
|
## 阶段六:测试与验证
|
||||||
|
|
||||||
|
- [ ] 补充后端票据夹服务和接口测试,超时时间控制在 60s 内。[CONCEPT: 测试方案]
|
||||||
|
|
||||||
|
- [ ] 补充前端导航和票据夹视图模型测试。[CONCEPT: 测试方案]
|
||||||
|
|
||||||
|
- [ ] 运行前端构建或定向测试。[CONCEPT: 指标与验收]
|
||||||
|
|
||||||
|
- [ ] 在 Docker `x-financial-main` 的 `/app` 内运行后端定向测试。[CONCEPT: 测试方案]
|
||||||
|
|
||||||
|
- [ ] 手动核对侧边栏位置、列表密度、详情预览和关联弹窗。[CONCEPT: 指标与验收]
|
||||||
|
|
||||||
|
## 阶段七:收口
|
||||||
|
|
||||||
|
- [ ] 回看 `CONCEPT.md` 验收标准,确认已实现项均有证据。[CONCEPT: 指标与验收]
|
||||||
|
|
||||||
|
- [ ] 更新本 TODO 的完成状态和验证记录。[CONCEPT: 测试方案]
|
||||||
26
server/src/app/api/pagination.py
Normal file
26
server/src/app/api/pagination.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
from fastapi import Query
|
||||||
|
|
||||||
|
from app.services.pagination import PageResult
|
||||||
|
|
||||||
|
PageNumber = Annotated[int | None, Query(ge=1, description="页码,从 1 开始。")]
|
||||||
|
PageSize = Annotated[int | None, Query(ge=1, le=100, description="每页条数,最多 100。")]
|
||||||
|
|
||||||
|
|
||||||
|
def wants_page(page: int | None, page_size: int | None) -> bool:
|
||||||
|
return page is not None or page_size is not None
|
||||||
|
|
||||||
|
|
||||||
|
def page_payload(result: PageResult[Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"items": result.items,
|
||||||
|
"total": result.total,
|
||||||
|
"page": result.page,
|
||||||
|
"page_size": result.page_size,
|
||||||
|
"total_pages": result.total_pages,
|
||||||
|
"has_next": result.has_next,
|
||||||
|
"has_previous": result.has_previous,
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ from app.api.deps import (
|
|||||||
require_rule_editor_user,
|
require_rule_editor_user,
|
||||||
require_rule_reviewer_user,
|
require_rule_reviewer_user,
|
||||||
)
|
)
|
||||||
|
from app.api.pagination import PageNumber, PageSize, page_payload, wants_page
|
||||||
from app.db.session import get_session_factory
|
from app.db.session import get_session_factory
|
||||||
from app.schemas.agent_asset import (
|
from app.schemas.agent_asset import (
|
||||||
AgentAssetCreate,
|
AgentAssetCreate,
|
||||||
@@ -43,7 +44,7 @@ from app.schemas.agent_asset import (
|
|||||||
AgentAssetVersionRead,
|
AgentAssetVersionRead,
|
||||||
AgentAssetVersionTimelineItemRead,
|
AgentAssetVersionTimelineItemRead,
|
||||||
)
|
)
|
||||||
from app.schemas.common import ErrorResponse
|
from app.schemas.common import ErrorResponse, PaginatedResponse
|
||||||
from app.services.agent_assets import AgentAssetService
|
from app.services.agent_assets import AgentAssetService
|
||||||
from app.services.risk_rule_generation_jobs import RiskRuleGenerationJobService
|
from app.services.risk_rule_generation_jobs import RiskRuleGenerationJobService
|
||||||
|
|
||||||
@@ -94,7 +95,7 @@ def _complete_risk_rule_generation_task(
|
|||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"",
|
"",
|
||||||
response_model=list[AgentAssetListItem],
|
response_model=list[AgentAssetListItem] | PaginatedResponse[AgentAssetListItem],
|
||||||
summary="查询 Agent 资产列表",
|
summary="查询 Agent 资产列表",
|
||||||
description="按资产类型、状态、领域和关键字筛选规则、技能、MCP 与任务资产。",
|
description="按资产类型、状态、领域和关键字筛选规则、技能、MCP 与任务资产。",
|
||||||
)
|
)
|
||||||
@@ -116,8 +117,22 @@ def list_agent_assets(
|
|||||||
str | None,
|
str | None,
|
||||||
Query(description="资产编码、名称关键字模糊查询。"),
|
Query(description="资产编码、名称关键字模糊查询。"),
|
||||||
] = None,
|
] = None,
|
||||||
) -> list[AgentAssetListItem]:
|
page: PageNumber = None,
|
||||||
return AgentAssetService(db).list_assets(
|
page_size: PageSize = None,
|
||||||
|
) -> list[AgentAssetListItem] | PaginatedResponse[AgentAssetListItem]:
|
||||||
|
service = AgentAssetService(db)
|
||||||
|
if wants_page(page, page_size):
|
||||||
|
return page_payload(
|
||||||
|
service.list_assets_page(
|
||||||
|
asset_type=asset_type,
|
||||||
|
status=status_value,
|
||||||
|
domain=domain,
|
||||||
|
keyword=keyword,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return service.list_assets(
|
||||||
asset_type=asset_type,
|
asset_type=asset_type,
|
||||||
status=status_value,
|
status=status_value,
|
||||||
domain=domain,
|
domain=domain,
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ from typing import Annotated
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import Session, selectinload
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.api.deps import (
|
from app.api.deps import (
|
||||||
CurrentUserContext,
|
CurrentUserContext,
|
||||||
@@ -15,8 +14,9 @@ from app.api.deps import (
|
|||||||
require_budget_editor_user,
|
require_budget_editor_user,
|
||||||
require_budget_viewer_user,
|
require_budget_viewer_user,
|
||||||
)
|
)
|
||||||
from app.models.employee import Employee
|
from app.api.pagination import PageNumber, PageSize, page_payload, wants_page
|
||||||
from app.models.budget import BudgetAllocation
|
from app.models.budget import BudgetAllocation
|
||||||
|
from app.models.employee import Employee
|
||||||
from app.schemas.budget import (
|
from app.schemas.budget import (
|
||||||
BudgetAllocationCreate,
|
BudgetAllocationCreate,
|
||||||
BudgetAllocationRead,
|
BudgetAllocationRead,
|
||||||
@@ -27,7 +27,7 @@ from app.schemas.budget import (
|
|||||||
BudgetSummaryRead,
|
BudgetSummaryRead,
|
||||||
BudgetTransactionRead,
|
BudgetTransactionRead,
|
||||||
)
|
)
|
||||||
from app.schemas.common import ErrorResponse
|
from app.schemas.common import ErrorResponse, PaginatedResponse
|
||||||
from app.services.budget import BudgetControlError, BudgetService
|
from app.services.budget import BudgetControlError, BudgetService
|
||||||
|
|
||||||
router = APIRouter(prefix="/budgets")
|
router = APIRouter(prefix="/budgets")
|
||||||
@@ -67,7 +67,7 @@ def get_budget_summary(
|
|||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/allocations",
|
"/allocations",
|
||||||
response_model=list[BudgetAllocationRead],
|
response_model=list[BudgetAllocationRead] | PaginatedResponse[BudgetAllocationRead],
|
||||||
summary="查询预算额度列表",
|
summary="查询预算额度列表",
|
||||||
)
|
)
|
||||||
def list_budget_allocations(
|
def list_budget_allocations(
|
||||||
@@ -78,7 +78,9 @@ def list_budget_allocations(
|
|||||||
department_id: str | None = None,
|
department_id: str | None = None,
|
||||||
department_name: str | None = None,
|
department_name: str | None = None,
|
||||||
cost_center: str | None = None,
|
cost_center: str | None = None,
|
||||||
) -> list[BudgetAllocationRead]:
|
page: PageNumber = None,
|
||||||
|
page_size: PageSize = None,
|
||||||
|
) -> list[BudgetAllocationRead] | PaginatedResponse[BudgetAllocationRead]:
|
||||||
scope = _resolve_budget_query_scope(
|
scope = _resolve_budget_query_scope(
|
||||||
db,
|
db,
|
||||||
current_user,
|
current_user,
|
||||||
@@ -86,7 +88,18 @@ def list_budget_allocations(
|
|||||||
department_name=department_name,
|
department_name=department_name,
|
||||||
cost_center=cost_center,
|
cost_center=cost_center,
|
||||||
)
|
)
|
||||||
return BudgetService(db).list_allocations(
|
service = BudgetService(db)
|
||||||
|
if wants_page(page, page_size):
|
||||||
|
return page_payload(
|
||||||
|
service.list_allocations_page(
|
||||||
|
fiscal_year=fiscal_year,
|
||||||
|
period_key=period_key,
|
||||||
|
**scope,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return service.list_allocations(
|
||||||
fiscal_year=fiscal_year,
|
fiscal_year=fiscal_year,
|
||||||
period_key=period_key,
|
period_key=period_key,
|
||||||
**scope,
|
**scope,
|
||||||
@@ -119,20 +132,30 @@ def create_budget_allocation(
|
|||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/allocations/{allocation_id}/transactions",
|
"/allocations/{allocation_id}/transactions",
|
||||||
response_model=list[BudgetTransactionRead],
|
response_model=list[BudgetTransactionRead] | PaginatedResponse[BudgetTransactionRead],
|
||||||
summary="读取预算交易台账",
|
summary="读取预算交易台账",
|
||||||
)
|
)
|
||||||
def list_budget_transactions(
|
def list_budget_transactions(
|
||||||
allocation_id: str,
|
allocation_id: str,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
current_user: BudgetViewer,
|
current_user: BudgetViewer,
|
||||||
) -> list[BudgetTransactionRead]:
|
page: PageNumber = None,
|
||||||
allocation = BudgetService(db).get_allocation_row(allocation_id)
|
page_size: PageSize = None,
|
||||||
|
) -> list[BudgetTransactionRead] | PaginatedResponse[BudgetTransactionRead]:
|
||||||
|
service = BudgetService(db)
|
||||||
|
allocation = service.get_allocation_row(allocation_id)
|
||||||
if allocation is None:
|
if allocation is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="预算额度不存在。")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="预算额度不存在。")
|
||||||
if not _allocation_visible_to_user(db, current_user, allocation):
|
if not _allocation_visible_to_user(db, current_user, allocation):
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能查看其他部门预算流水。")
|
raise HTTPException(
|
||||||
return BudgetService(db).list_transactions(allocation_id)
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="不能查看其他部门预算流水。",
|
||||||
|
)
|
||||||
|
if wants_page(page, page_size):
|
||||||
|
return page_payload(
|
||||||
|
service.list_transactions_page(allocation_id, page=page, page_size=page_size)
|
||||||
|
)
|
||||||
|
return service.list_transactions(allocation_id)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ from fastapi.responses import Response
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_db
|
from app.api.deps import get_db
|
||||||
from app.schemas.common import ErrorResponse
|
from app.api.pagination import PageNumber, PageSize, page_payload, wants_page
|
||||||
|
from app.schemas.common import ErrorResponse, PaginatedResponse
|
||||||
from app.schemas.employee import (
|
from app.schemas.employee import (
|
||||||
EmployeeCreate,
|
EmployeeCreate,
|
||||||
EmployeeImportResultRead,
|
EmployeeImportResultRead,
|
||||||
@@ -16,6 +17,7 @@ from app.schemas.employee import (
|
|||||||
EmployeeUpdate,
|
EmployeeUpdate,
|
||||||
)
|
)
|
||||||
from app.services.employee import EmployeeService
|
from app.services.employee import EmployeeService
|
||||||
|
from app.services.employee_pagination import EmployeePaginationService
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
DbSession = Annotated[Session, Depends(get_db)]
|
DbSession = Annotated[Session, Depends(get_db)]
|
||||||
@@ -33,7 +35,7 @@ def get_employee_meta(db: DbSession) -> EmployeeMetaRead:
|
|||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"",
|
"",
|
||||||
response_model=list[EmployeeRead],
|
response_model=list[EmployeeRead] | PaginatedResponse[EmployeeRead],
|
||||||
summary="查询员工列表",
|
summary="查询员工列表",
|
||||||
description="按状态和关键字筛选员工目录。",
|
description="按状态和关键字筛选员工目录。",
|
||||||
)
|
)
|
||||||
@@ -47,7 +49,18 @@ def list_employees(
|
|||||||
str | None,
|
str | None,
|
||||||
Query(description="姓名、工号、邮箱等关键字模糊查询。"),
|
Query(description="姓名、工号、邮箱等关键字模糊查询。"),
|
||||||
] = None,
|
] = None,
|
||||||
) -> list[EmployeeRead]:
|
page: PageNumber = None,
|
||||||
|
page_size: PageSize = None,
|
||||||
|
) -> list[EmployeeRead] | PaginatedResponse[EmployeeRead]:
|
||||||
|
if wants_page(page, page_size):
|
||||||
|
return page_payload(
|
||||||
|
EmployeePaginationService(db).list_employees_page(
|
||||||
|
status=status_filter,
|
||||||
|
keyword=keyword,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
)
|
||||||
return EmployeeService(db).list_employees(status=status_filter, keyword=keyword)
|
return EmployeeService(db).list_employees(status=status_filter, keyword=keyword)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ from fastapi.responses import FileResponse
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import CurrentUserContext, get_current_user, get_db
|
from app.api.deps import CurrentUserContext, get_current_user, get_db
|
||||||
|
from app.api.pagination import PageNumber, PageSize, page_payload, wants_page
|
||||||
from app.schemas.budget import BudgetClaimAnalysisRead
|
from app.schemas.budget import BudgetClaimAnalysisRead
|
||||||
from app.schemas.common import ErrorResponse
|
from app.schemas.common import ErrorResponse, PaginatedResponse
|
||||||
from app.schemas.reimbursement import (
|
from app.schemas.reimbursement import (
|
||||||
ExpenseClaimAttachmentActionResponse,
|
ExpenseClaimAttachmentActionResponse,
|
||||||
ExpenseClaimActionResponse,
|
ExpenseClaimActionResponse,
|
||||||
@@ -25,8 +26,8 @@ from app.schemas.reimbursement import (
|
|||||||
TravelReimbursementCalculatorRequest,
|
TravelReimbursementCalculatorRequest,
|
||||||
TravelReimbursementCalculatorResponse,
|
TravelReimbursementCalculatorResponse,
|
||||||
)
|
)
|
||||||
from app.services.expense_claims import ExpenseClaimService
|
|
||||||
from app.services.budget import BudgetService
|
from app.services.budget import BudgetService
|
||||||
|
from app.services.expense_claims import ExpenseClaimService
|
||||||
from app.services.reimbursement import ReimbursementService
|
from app.services.reimbursement import ReimbursementService
|
||||||
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
||||||
|
|
||||||
@@ -37,12 +38,19 @@ CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
|
|||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"",
|
"",
|
||||||
response_model=list[ReimbursementRead],
|
response_model=list[ReimbursementRead] | PaginatedResponse[ReimbursementRead],
|
||||||
summary="查询报销申请列表",
|
summary="查询报销申请列表",
|
||||||
description="返回当前系统中的报销申请列表。",
|
description="返回当前系统中的报销申请列表。",
|
||||||
)
|
)
|
||||||
def list_reimbursements(db: DbSession) -> list[ReimbursementRead]:
|
def list_reimbursements(
|
||||||
return ReimbursementService(db).list_reimbursements()
|
db: DbSession,
|
||||||
|
page: PageNumber = None,
|
||||||
|
page_size: PageSize = None,
|
||||||
|
) -> list[ReimbursementRead] | PaginatedResponse[ReimbursementRead]:
|
||||||
|
service = ReimbursementService(db)
|
||||||
|
if wants_page(page, page_size):
|
||||||
|
return page_payload(service.list_reimbursements_page(page=page, page_size=page_size))
|
||||||
|
return service.list_reimbursements()
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@@ -81,32 +89,60 @@ def calculate_travel_reimbursement(
|
|||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/claims",
|
"/claims",
|
||||||
response_model=list[ExpenseClaimRead],
|
response_model=list[ExpenseClaimRead] | PaginatedResponse[ExpenseClaimRead],
|
||||||
summary="查询个人报销单列表",
|
summary="查询个人报销单列表",
|
||||||
description="返回当前登录用户可见的真实个人报销单据列表。",
|
description="返回当前登录用户可见的真实个人报销单据列表。",
|
||||||
)
|
)
|
||||||
def list_expense_claims(db: DbSession, current_user: CurrentUser) -> list[ExpenseClaimRead]:
|
def list_expense_claims(
|
||||||
return ExpenseClaimService(db).list_claims(current_user)
|
db: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
page: PageNumber = None,
|
||||||
|
page_size: PageSize = None,
|
||||||
|
) -> list[ExpenseClaimRead] | PaginatedResponse[ExpenseClaimRead]:
|
||||||
|
service = ExpenseClaimService(db)
|
||||||
|
if wants_page(page, page_size):
|
||||||
|
return page_payload(service.list_claims_page(current_user, page=page, page_size=page_size))
|
||||||
|
return service.list_claims(current_user)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/claims/approvals",
|
"/claims/approvals",
|
||||||
response_model=list[ExpenseClaimRead],
|
response_model=list[ExpenseClaimRead] | PaginatedResponse[ExpenseClaimRead],
|
||||||
summary="查询当前用户审批待办报销单列表",
|
summary="查询当前用户审批待办报销单列表",
|
||||||
description="返回当前登录用户有权处理的待审批报销单据,不混入个人报销列表。",
|
description="返回当前登录用户有权处理的待审批报销单据,不混入个人报销列表。",
|
||||||
)
|
)
|
||||||
def list_expense_claim_approvals(db: DbSession, current_user: CurrentUser) -> list[ExpenseClaimRead]:
|
def list_expense_claim_approvals(
|
||||||
return ExpenseClaimService(db).list_approval_claims(current_user)
|
db: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
page: PageNumber = None,
|
||||||
|
page_size: PageSize = None,
|
||||||
|
) -> list[ExpenseClaimRead] | PaginatedResponse[ExpenseClaimRead]:
|
||||||
|
service = ExpenseClaimService(db)
|
||||||
|
if wants_page(page, page_size):
|
||||||
|
return page_payload(
|
||||||
|
service.list_approval_claims_page(current_user, page=page, page_size=page_size)
|
||||||
|
)
|
||||||
|
return service.list_approval_claims(current_user)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/claims/archives",
|
"/claims/archives",
|
||||||
response_model=list[ExpenseClaimRead],
|
response_model=list[ExpenseClaimRead] | PaginatedResponse[ExpenseClaimRead],
|
||||||
summary="查询归档中心报销单列表",
|
summary="查询归档中心报销单列表",
|
||||||
description="返回公司已归档入账的报销单据,供财务与审计角色集中查阅。",
|
description="返回公司已归档入账的报销单据,供财务与审计角色集中查阅。",
|
||||||
)
|
)
|
||||||
def list_archived_expense_claims(db: DbSession, current_user: CurrentUser) -> list[ExpenseClaimRead]:
|
def list_archived_expense_claims(
|
||||||
return ExpenseClaimService(db).list_archived_claims(current_user)
|
db: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
page: PageNumber = None,
|
||||||
|
page_size: PageSize = None,
|
||||||
|
) -> list[ExpenseClaimRead] | PaginatedResponse[ExpenseClaimRead]:
|
||||||
|
service = ExpenseClaimService(db)
|
||||||
|
if wants_page(page, page_size):
|
||||||
|
return page_payload(
|
||||||
|
service.list_archived_claims_page(current_user, page=page, page_size=page_size)
|
||||||
|
)
|
||||||
|
return service.list_archived_claims(current_user)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
|
|||||||
@@ -9,20 +9,21 @@ from app.models.agent_asset import (
|
|||||||
AgentAssetTestRun,
|
AgentAssetTestRun,
|
||||||
AgentAssetVersion,
|
AgentAssetVersion,
|
||||||
)
|
)
|
||||||
|
from app.services.pagination import PageResult, paginate_select
|
||||||
|
|
||||||
|
|
||||||
class AgentAssetRepository:
|
class AgentAssetRepository:
|
||||||
def __init__(self, db: Session) -> None:
|
def __init__(self, db: Session) -> None:
|
||||||
self.db = db
|
self.db = db
|
||||||
|
|
||||||
def list(
|
def _list_stmt(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
asset_type: str | None = None,
|
asset_type: str | None = None,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
domain: str | None = None,
|
domain: str | None = None,
|
||||||
keyword: str | None = None,
|
keyword: str | None = None,
|
||||||
) -> list[AgentAsset]:
|
):
|
||||||
stmt = select(AgentAsset)
|
stmt = select(AgentAsset)
|
||||||
|
|
||||||
if asset_type:
|
if asset_type:
|
||||||
@@ -42,8 +43,42 @@ class AgentAssetRepository:
|
|||||||
)
|
)
|
||||||
|
|
||||||
stmt = stmt.order_by(AgentAsset.updated_at.desc(), AgentAsset.created_at.desc())
|
stmt = stmt.order_by(AgentAsset.updated_at.desc(), AgentAsset.created_at.desc())
|
||||||
|
return stmt
|
||||||
|
|
||||||
|
def list(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
asset_type: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
domain: str | None = None,
|
||||||
|
keyword: str | None = None,
|
||||||
|
) -> list[AgentAsset]:
|
||||||
|
stmt = self._list_stmt(
|
||||||
|
asset_type=asset_type,
|
||||||
|
status=status,
|
||||||
|
domain=domain,
|
||||||
|
keyword=keyword,
|
||||||
|
)
|
||||||
return list(self.db.scalars(stmt).all())
|
return list(self.db.scalars(stmt).all())
|
||||||
|
|
||||||
|
def list_page(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
asset_type: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
domain: str | None = None,
|
||||||
|
keyword: str | None = None,
|
||||||
|
page: int | None,
|
||||||
|
page_size: int | None,
|
||||||
|
) -> PageResult[AgentAsset]:
|
||||||
|
stmt = self._list_stmt(
|
||||||
|
asset_type=asset_type,
|
||||||
|
status=status,
|
||||||
|
domain=domain,
|
||||||
|
keyword=keyword,
|
||||||
|
)
|
||||||
|
return paginate_select(self.db, stmt, page=page, page_size=page_size)
|
||||||
|
|
||||||
def get(self, asset_id: str) -> AgentAsset | None:
|
def get(self, asset_id: str) -> AgentAsset | None:
|
||||||
return self.db.get(AgentAsset, asset_id)
|
return self.db.get(AgentAsset, asset_id)
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ from sqlalchemy.orm import Session, selectinload
|
|||||||
from app.models.employee import Employee
|
from app.models.employee import Employee
|
||||||
from app.models.organization import OrganizationUnit
|
from app.models.organization import OrganizationUnit
|
||||||
from app.models.role import Role
|
from app.models.role import Role
|
||||||
|
from app.services.pagination import PageResult, paginate_select
|
||||||
|
|
||||||
|
|
||||||
class EmployeeRepository:
|
class EmployeeRepository:
|
||||||
def __init__(self, db: Session) -> None:
|
def __init__(self, db: Session) -> None:
|
||||||
self.db = db
|
self.db = db
|
||||||
|
|
||||||
def list(self, status: str | None = None, keyword: str | None = None) -> list[Employee]:
|
def _list_stmt(self, status: str | None = None, keyword: str | None = None):
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Employee)
|
select(Employee)
|
||||||
.options(
|
.options(
|
||||||
@@ -38,8 +39,23 @@ class EmployeeRepository:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return stmt
|
||||||
|
|
||||||
|
def list(self, status: str | None = None, keyword: str | None = None) -> list[Employee]:
|
||||||
|
stmt = self._list_stmt(status=status, keyword=keyword)
|
||||||
return list(self.db.execute(stmt).scalars().unique().all())
|
return list(self.db.execute(stmt).scalars().unique().all())
|
||||||
|
|
||||||
|
def list_page(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
status: str | None = None,
|
||||||
|
keyword: str | None = None,
|
||||||
|
page: int | None,
|
||||||
|
page_size: int | None,
|
||||||
|
) -> PageResult[Employee]:
|
||||||
|
stmt = self._list_stmt(status=status, keyword=keyword)
|
||||||
|
return paginate_select(self.db, stmt, page=page, page_size=page_size, unique=True)
|
||||||
|
|
||||||
def get(self, employee_id: str) -> Employee | None:
|
def get(self, employee_id: str) -> Employee | None:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Employee)
|
select(Employee)
|
||||||
|
|||||||
@@ -1,14 +1,27 @@
|
|||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models.reimbursement import ReimbursementRequest
|
from app.models.reimbursement import ReimbursementRequest
|
||||||
|
from app.services.pagination import PageResult, paginate_select
|
||||||
|
|
||||||
|
|
||||||
class ReimbursementRepository:
|
class ReimbursementRepository:
|
||||||
def __init__(self, db: Session) -> None:
|
def __init__(self, db: Session) -> None:
|
||||||
self.db = db
|
self.db = db
|
||||||
|
|
||||||
|
def _list_stmt(self):
|
||||||
|
return select(ReimbursementRequest).order_by(ReimbursementRequest.created_at.desc())
|
||||||
|
|
||||||
def list(self) -> list[ReimbursementRequest]:
|
def list(self) -> list[ReimbursementRequest]:
|
||||||
return self.db.query(ReimbursementRequest).order_by(ReimbursementRequest.created_at.desc()).all()
|
return list(self.db.scalars(self._list_stmt()).all())
|
||||||
|
|
||||||
|
def list_page(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
page: int | None,
|
||||||
|
page_size: int | None,
|
||||||
|
) -> PageResult[ReimbursementRequest]:
|
||||||
|
return paginate_select(self.db, self._list_stmt(), page=page, page_size=page_size)
|
||||||
|
|
||||||
def get(self, request_id: str) -> ReimbursementRequest | None:
|
def get(self, request_id: str) -> ReimbursementRequest | None:
|
||||||
return self.db.query(ReimbursementRequest).filter(ReimbursementRequest.id == request_id).first()
|
return self.db.query(ReimbursementRequest).filter(ReimbursementRequest.id == request_id).first()
|
||||||
|
|||||||
@@ -1,12 +1,26 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Generic, TypeVar
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
class ErrorResponse(BaseModel):
|
class ErrorResponse(BaseModel):
|
||||||
detail: str
|
detail: str
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedResponse(BaseModel, Generic[T]):
|
||||||
|
items: list[T]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
total_pages: int
|
||||||
|
has_next: bool
|
||||||
|
has_previous: bool
|
||||||
|
|
||||||
|
|
||||||
class RootStatusRead(BaseModel):
|
class RootStatusRead(BaseModel):
|
||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ from app.services.agent_asset_spreadsheet_helpers import AgentAssetSpreadsheetHe
|
|||||||
from app.services.agent_asset_timeline import AgentAssetTimelineMixin
|
from app.services.agent_asset_timeline import AgentAssetTimelineMixin
|
||||||
from app.services.agent_foundation import AgentFoundationService
|
from app.services.agent_foundation import AgentFoundationService
|
||||||
from app.services.audit import AuditLogService
|
from app.services.audit import AuditLogService
|
||||||
|
from app.services.pagination import PageResult
|
||||||
from app.services.risk_rule_score_backfill import backfill_missing_risk_rule_score
|
from app.services.risk_rule_score_backfill import backfill_missing_risk_rule_score
|
||||||
|
|
||||||
logger = get_logger("app.services.agent_assets")
|
logger = get_logger("app.services.agent_assets")
|
||||||
@@ -75,6 +76,32 @@ class AgentAssetService(
|
|||||||
version_stats = self._collect_version_stats(assets)
|
version_stats = self._collect_version_stats(assets)
|
||||||
return [self._serialize_list_item(asset, version_stats.get(asset.id)) for asset in assets]
|
return [self._serialize_list_item(asset, version_stats.get(asset.id)) for asset in assets]
|
||||||
|
|
||||||
|
def list_assets_page(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
asset_type: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
domain: str | None = None,
|
||||||
|
keyword: str | None = None,
|
||||||
|
page: int | None,
|
||||||
|
page_size: int | None,
|
||||||
|
) -> PageResult[AgentAssetListItem]:
|
||||||
|
self._ensure_ready()
|
||||||
|
if asset_type in {None, "", AgentAssetType.RULE.value}:
|
||||||
|
self.sync_platform_risk_rules_from_library()
|
||||||
|
result = self.repository.list_page(
|
||||||
|
asset_type=asset_type,
|
||||||
|
status=status,
|
||||||
|
domain=domain,
|
||||||
|
keyword=keyword,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
version_stats = self._collect_version_stats(result.items)
|
||||||
|
return result.map(
|
||||||
|
lambda asset: self._serialize_list_item(asset, version_stats.get(asset.id))
|
||||||
|
)
|
||||||
|
|
||||||
def get_asset(self, asset_id: str) -> AgentAssetRead | None:
|
def get_asset(self, asset_id: str) -> AgentAssetRead | None:
|
||||||
self._ensure_ready()
|
self._ensure_ready()
|
||||||
asset = self.repository.get(asset_id)
|
asset = self.repository.get(asset_id)
|
||||||
|
|||||||
@@ -21,11 +21,12 @@ from app.schemas.budget import (
|
|||||||
BudgetTransactionRead,
|
BudgetTransactionRead,
|
||||||
)
|
)
|
||||||
from app.services.budget_expense_control import BudgetExpenseControlModel
|
from app.services.budget_expense_control import BudgetExpenseControlModel
|
||||||
|
from app.services.budget_pagination import BudgetPaginationMixin
|
||||||
from app.services.budget_support import BudgetSupportMixin
|
from app.services.budget_support import BudgetSupportMixin
|
||||||
from app.services.budget_types import BudgetControlError, SUPPORTED_BUDGET_SUBJECT_CODES
|
from app.services.budget_types import BudgetControlError
|
||||||
|
|
||||||
|
|
||||||
class BudgetService(BudgetSupportMixin):
|
class BudgetService(BudgetPaginationMixin, BudgetSupportMixin):
|
||||||
def __init__(self, db: Session) -> None:
|
def __init__(self, db: Session) -> None:
|
||||||
self.db = db
|
self.db = db
|
||||||
|
|
||||||
@@ -46,22 +47,13 @@ class BudgetService(BudgetSupportMixin):
|
|||||||
cost_center: str | None = None,
|
cost_center: str | None = None,
|
||||||
) -> list[BudgetAllocationRead]:
|
) -> list[BudgetAllocationRead]:
|
||||||
self.ensure_budget_ready()
|
self.ensure_budget_ready()
|
||||||
stmt = select(BudgetAllocation).order_by(
|
stmt = self.build_allocation_stmt(
|
||||||
BudgetAllocation.fiscal_year.desc(),
|
fiscal_year=fiscal_year,
|
||||||
BudgetAllocation.period_key.asc(),
|
period_key=period_key,
|
||||||
BudgetAllocation.department_name.asc(),
|
department_id=department_id,
|
||||||
BudgetAllocation.subject_code.asc(),
|
department_name=department_name,
|
||||||
).where(BudgetAllocation.subject_code.in_(SUPPORTED_BUDGET_SUBJECT_CODES))
|
cost_center=cost_center,
|
||||||
if fiscal_year is not None:
|
)
|
||||||
stmt = stmt.where(BudgetAllocation.fiscal_year == fiscal_year)
|
|
||||||
if period_key:
|
|
||||||
stmt = stmt.where(BudgetAllocation.period_key == period_key)
|
|
||||||
if department_id:
|
|
||||||
stmt = stmt.where(BudgetAllocation.department_id == department_id)
|
|
||||||
if department_name:
|
|
||||||
stmt = stmt.where(BudgetAllocation.department_name == department_name)
|
|
||||||
if cost_center:
|
|
||||||
stmt = stmt.where(BudgetAllocation.cost_center == cost_center)
|
|
||||||
return [self.serialize_allocation(row) for row in self.db.scalars(stmt).all()]
|
return [self.serialize_allocation(row) for row in self.db.scalars(stmt).all()]
|
||||||
|
|
||||||
def get_summary(
|
def get_summary(
|
||||||
|
|||||||
83
server/src/app/services/budget_pagination.py
Normal file
83
server/src/app/services/budget_pagination.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.models.budget import BudgetAllocation, BudgetTransaction
|
||||||
|
from app.schemas.budget import BudgetAllocationRead, BudgetTransactionRead
|
||||||
|
from app.services.budget_types import SUPPORTED_BUDGET_SUBJECT_CODES
|
||||||
|
from app.services.pagination import PageResult, paginate_select
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetPaginationMixin:
|
||||||
|
def build_allocation_stmt(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
fiscal_year: int | None = None,
|
||||||
|
period_key: str | None = None,
|
||||||
|
department_id: str | None = None,
|
||||||
|
department_name: str | None = None,
|
||||||
|
cost_center: str | None = None,
|
||||||
|
):
|
||||||
|
stmt = select(BudgetAllocation).where(
|
||||||
|
BudgetAllocation.subject_code.in_(SUPPORTED_BUDGET_SUBJECT_CODES)
|
||||||
|
)
|
||||||
|
if fiscal_year is not None:
|
||||||
|
stmt = stmt.where(BudgetAllocation.fiscal_year == fiscal_year)
|
||||||
|
if period_key:
|
||||||
|
stmt = stmt.where(BudgetAllocation.period_key == period_key)
|
||||||
|
if department_id:
|
||||||
|
stmt = stmt.where(BudgetAllocation.department_id == department_id)
|
||||||
|
if department_name:
|
||||||
|
stmt = stmt.where(BudgetAllocation.department_name == department_name)
|
||||||
|
if cost_center:
|
||||||
|
stmt = stmt.where(BudgetAllocation.cost_center == cost_center)
|
||||||
|
return stmt.order_by(
|
||||||
|
BudgetAllocation.fiscal_year.desc(),
|
||||||
|
BudgetAllocation.period_key.asc(),
|
||||||
|
BudgetAllocation.department_name.asc(),
|
||||||
|
BudgetAllocation.subject_code.asc(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_allocations_page(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
fiscal_year: int | None = None,
|
||||||
|
period_key: str | None = None,
|
||||||
|
department_id: str | None = None,
|
||||||
|
department_name: str | None = None,
|
||||||
|
cost_center: str | None = None,
|
||||||
|
page: int | None,
|
||||||
|
page_size: int | None,
|
||||||
|
) -> PageResult[BudgetAllocationRead]:
|
||||||
|
self.ensure_budget_ready()
|
||||||
|
result = paginate_select(
|
||||||
|
self.db,
|
||||||
|
self.build_allocation_stmt(
|
||||||
|
fiscal_year=fiscal_year,
|
||||||
|
period_key=period_key,
|
||||||
|
department_id=department_id,
|
||||||
|
department_name=department_name,
|
||||||
|
cost_center=cost_center,
|
||||||
|
),
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
return result.map(self.serialize_allocation)
|
||||||
|
|
||||||
|
def list_transactions_page(
|
||||||
|
self,
|
||||||
|
allocation_id: str,
|
||||||
|
*,
|
||||||
|
page: int | None,
|
||||||
|
page_size: int | None,
|
||||||
|
) -> PageResult[BudgetTransactionRead]:
|
||||||
|
self.ensure_budget_ready()
|
||||||
|
result = paginate_select(
|
||||||
|
self.db,
|
||||||
|
select(BudgetTransaction)
|
||||||
|
.where(BudgetTransaction.allocation_id == allocation_id)
|
||||||
|
.order_by(BudgetTransaction.created_at.desc()),
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
return result.map(BudgetTransactionRead.model_validate)
|
||||||
39
server/src/app/services/employee_pagination.py
Normal file
39
server/src/app/services/employee_pagination.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.schemas.employee import EmployeeRead
|
||||||
|
from app.services.employee import EmployeeService
|
||||||
|
from app.services.pagination import PageResult
|
||||||
|
|
||||||
|
logger = get_logger("app.services.employee")
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeePaginationService:
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
self.service = EmployeeService(db)
|
||||||
|
|
||||||
|
def list_employees_page(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
status: str | None = None,
|
||||||
|
keyword: str | None = None,
|
||||||
|
page: int | None,
|
||||||
|
page_size: int | None,
|
||||||
|
) -> PageResult[EmployeeRead]:
|
||||||
|
self.service.ensure_directory_ready()
|
||||||
|
result = self.service.repository.list_page(
|
||||||
|
status=status,
|
||||||
|
keyword=keyword,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Listed employees page (count=%d, total=%d, page=%d, page_size=%d)",
|
||||||
|
len(result.items),
|
||||||
|
result.total,
|
||||||
|
result.page,
|
||||||
|
result.page_size,
|
||||||
|
)
|
||||||
|
return result.map(self.service._serialize_employee)
|
||||||
61
server/src/app/services/expense_claim_pagination.py
Normal file
61
server/src/app/services/expense_claim_pagination.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUserContext
|
||||||
|
from app.models.employee import Employee
|
||||||
|
from app.models.financial_record import ExpenseClaim
|
||||||
|
from app.services.pagination import PageResult, paginate_select
|
||||||
|
|
||||||
|
|
||||||
|
class ExpenseClaimPaginationMixin:
|
||||||
|
def _claim_list_stmt(self):
|
||||||
|
return select(ExpenseClaim).options(
|
||||||
|
selectinload(ExpenseClaim.items),
|
||||||
|
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
|
||||||
|
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_claims_page(
|
||||||
|
self,
|
||||||
|
current_user: CurrentUserContext,
|
||||||
|
*,
|
||||||
|
page: int | None,
|
||||||
|
page_size: int | None,
|
||||||
|
) -> PageResult[ExpenseClaim]:
|
||||||
|
stmt = self._claim_list_stmt().order_by(
|
||||||
|
ExpenseClaim.created_at.desc(),
|
||||||
|
ExpenseClaim.occurred_at.desc(),
|
||||||
|
)
|
||||||
|
stmt = self._access_policy.apply_claim_scope(stmt, current_user)
|
||||||
|
return paginate_select(self.db, stmt, page=page, page_size=page_size)
|
||||||
|
|
||||||
|
def list_approval_claims_page(
|
||||||
|
self,
|
||||||
|
current_user: CurrentUserContext,
|
||||||
|
*,
|
||||||
|
page: int | None,
|
||||||
|
page_size: int | None,
|
||||||
|
) -> PageResult[ExpenseClaim]:
|
||||||
|
stmt = self._claim_list_stmt().order_by(
|
||||||
|
ExpenseClaim.submitted_at.desc(),
|
||||||
|
ExpenseClaim.created_at.desc(),
|
||||||
|
)
|
||||||
|
stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user)
|
||||||
|
return paginate_select(self.db, stmt, page=page, page_size=page_size)
|
||||||
|
|
||||||
|
def list_archived_claims_page(
|
||||||
|
self,
|
||||||
|
current_user: CurrentUserContext,
|
||||||
|
*,
|
||||||
|
page: int | None,
|
||||||
|
page_size: int | None,
|
||||||
|
) -> PageResult[ExpenseClaim]:
|
||||||
|
stmt = self._claim_list_stmt().order_by(
|
||||||
|
ExpenseClaim.updated_at.desc(),
|
||||||
|
ExpenseClaim.submitted_at.desc(),
|
||||||
|
ExpenseClaim.created_at.desc(),
|
||||||
|
)
|
||||||
|
stmt = self._access_policy.apply_archived_claim_scope(stmt, current_user)
|
||||||
|
return paginate_select(self.db, stmt, page=page, page_size=page_size)
|
||||||
@@ -48,6 +48,7 @@ from app.services.expense_claim_document_parsing import ExpenseClaimDocumentPars
|
|||||||
from app.services.expense_claim_draft_flow import ExpenseClaimDraftFlowMixin
|
from app.services.expense_claim_draft_flow import ExpenseClaimDraftFlowMixin
|
||||||
from app.services.expense_claim_draft_persistence import ExpenseClaimDraftPersistenceMixin
|
from app.services.expense_claim_draft_persistence import ExpenseClaimDraftPersistenceMixin
|
||||||
from app.services.expense_claim_errors import ExpenseClaimSubmissionBlockedError
|
from app.services.expense_claim_errors import ExpenseClaimSubmissionBlockedError
|
||||||
|
from app.services.expense_claim_pagination import ExpenseClaimPaginationMixin
|
||||||
from app.services.expense_claim_ontology_resolvers import ExpenseClaimOntologyResolverMixin
|
from app.services.expense_claim_ontology_resolvers import ExpenseClaimOntologyResolverMixin
|
||||||
from app.services.expense_claim_read_model import ExpenseClaimReadModelMixin
|
from app.services.expense_claim_read_model import ExpenseClaimReadModelMixin
|
||||||
from app.services.expense_claim_review_preview import ExpenseClaimReviewPreviewMixin
|
from app.services.expense_claim_review_preview import ExpenseClaimReviewPreviewMixin
|
||||||
@@ -128,6 +129,7 @@ from app.services.ocr import OcrService
|
|||||||
|
|
||||||
|
|
||||||
class ExpenseClaimService(
|
class ExpenseClaimService(
|
||||||
|
ExpenseClaimPaginationMixin,
|
||||||
ExpenseClaimApprovalFlowMixin,
|
ExpenseClaimApprovalFlowMixin,
|
||||||
ExpenseClaimApplicationHandoffMixin,
|
ExpenseClaimApplicationHandoffMixin,
|
||||||
ExpenseClaimBudgetFlowMixin,
|
ExpenseClaimBudgetFlowMixin,
|
||||||
|
|||||||
95
server/src/app/services/pagination.py
Normal file
95
server/src/app/services/pagination.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from math import ceil
|
||||||
|
from typing import Any, Generic, TypeVar
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy.sql import Select
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
U = TypeVar("U")
|
||||||
|
|
||||||
|
DEFAULT_PAGE = 1
|
||||||
|
DEFAULT_PAGE_SIZE = 20
|
||||||
|
MAX_PAGE_SIZE = 100
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PageParams:
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def offset(self) -> int:
|
||||||
|
return (self.page - 1) * self.page_size
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PageResult(Generic[T]):
|
||||||
|
items: list[T]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_pages(self) -> int:
|
||||||
|
if self.total <= 0:
|
||||||
|
return 0
|
||||||
|
return ceil(self.total / self.page_size)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_next(self) -> bool:
|
||||||
|
return self.page < self.total_pages
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_previous(self) -> bool:
|
||||||
|
return self.page > 1 and self.total_pages > 0
|
||||||
|
|
||||||
|
def map(self, mapper: Callable[[T], U]) -> PageResult[U]:
|
||||||
|
return PageResult(
|
||||||
|
items=[mapper(item) for item in self.items],
|
||||||
|
total=self.total,
|
||||||
|
page=self.page,
|
||||||
|
page_size=self.page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_page_params(
|
||||||
|
page: int | None,
|
||||||
|
page_size: int | None,
|
||||||
|
*,
|
||||||
|
max_page_size: int = MAX_PAGE_SIZE,
|
||||||
|
) -> PageParams:
|
||||||
|
normalized_page = max(DEFAULT_PAGE, int(page or DEFAULT_PAGE))
|
||||||
|
normalized_page_size = max(1, int(page_size or DEFAULT_PAGE_SIZE))
|
||||||
|
normalized_page_size = min(normalized_page_size, max_page_size)
|
||||||
|
return PageParams(page=normalized_page, page_size=normalized_page_size)
|
||||||
|
|
||||||
|
|
||||||
|
def paginate_select(
|
||||||
|
db: Session,
|
||||||
|
stmt: Select[Any],
|
||||||
|
*,
|
||||||
|
page: int | None,
|
||||||
|
page_size: int | None,
|
||||||
|
max_page_size: int = MAX_PAGE_SIZE,
|
||||||
|
unique: bool = False,
|
||||||
|
) -> PageResult[Any]:
|
||||||
|
params = normalize_page_params(page, page_size, max_page_size=max_page_size)
|
||||||
|
count_stmt = select(func.count()).select_from(stmt.order_by(None).subquery())
|
||||||
|
total = int(db.scalar(count_stmt) or 0)
|
||||||
|
|
||||||
|
page_stmt = stmt.limit(params.page_size).offset(params.offset)
|
||||||
|
scalars = db.execute(page_stmt).scalars()
|
||||||
|
if unique:
|
||||||
|
scalars = scalars.unique()
|
||||||
|
|
||||||
|
return PageResult(
|
||||||
|
items=list(scalars.all()),
|
||||||
|
total=total,
|
||||||
|
page=params.page,
|
||||||
|
page_size=params.page_size,
|
||||||
|
)
|
||||||
@@ -4,6 +4,7 @@ from app.core.logging import get_logger
|
|||||||
from app.models.reimbursement import ReimbursementRequest
|
from app.models.reimbursement import ReimbursementRequest
|
||||||
from app.repositories.reimbursement import ReimbursementRepository
|
from app.repositories.reimbursement import ReimbursementRepository
|
||||||
from app.schemas.reimbursement import ReimbursementCreate
|
from app.schemas.reimbursement import ReimbursementCreate
|
||||||
|
from app.services.pagination import PageResult
|
||||||
|
|
||||||
logger = get_logger("app.services.reimbursement")
|
logger = get_logger("app.services.reimbursement")
|
||||||
|
|
||||||
@@ -17,6 +18,22 @@ class ReimbursementService:
|
|||||||
logger.info("Listed reimbursements (count=%d)", len(items))
|
logger.info("Listed reimbursements (count=%d)", len(items))
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
def list_reimbursements_page(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
page: int | None,
|
||||||
|
page_size: int | None,
|
||||||
|
) -> PageResult[ReimbursementRequest]:
|
||||||
|
result = self.repository.list_page(page=page, page_size=page_size)
|
||||||
|
logger.info(
|
||||||
|
"Listed reimbursements page (count=%d, total=%d, page=%d, page_size=%d)",
|
||||||
|
len(result.items),
|
||||||
|
result.total,
|
||||||
|
result.page,
|
||||||
|
result.page_size,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
def get_reimbursement(self, request_id: str) -> ReimbursementRequest | None:
|
def get_reimbursement(self, request_id: str) -> ReimbursementRequest | None:
|
||||||
request = self.repository.get(request_id)
|
request = self.repository.get(request_id)
|
||||||
if request:
|
if request:
|
||||||
|
|||||||
@@ -156,6 +156,22 @@ def test_agent_asset_service_seeds_all_foundation_asset_types() -> None:
|
|||||||
assert len(service.list_assets(asset_type=AgentAssetType.TASK.value)) >= 3
|
assert len(service.list_assets(asset_type=AgentAssetType.TASK.value)) >= 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_asset_service_supports_backend_pagination() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
service = AgentAssetService(db)
|
||||||
|
|
||||||
|
page = service.list_assets_page(
|
||||||
|
asset_type=AgentAssetType.RULE.value,
|
||||||
|
page=1,
|
||||||
|
page_size=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(page.items) <= 2
|
||||||
|
assert page.total >= len(page.items)
|
||||||
|
assert page.page == 1
|
||||||
|
assert page.page_size == 2
|
||||||
|
|
||||||
|
|
||||||
def test_finance_rules_use_risk_rule_scenario_categories() -> None:
|
def test_finance_rules_use_risk_rule_scenario_categories() -> None:
|
||||||
with build_session() as db:
|
with build_session() as db:
|
||||||
service = AgentAssetService(db)
|
service = AgentAssetService(db)
|
||||||
|
|||||||
136
server/tests/test_backend_pagination.py
Normal file
136
server/tests/test_backend_pagination.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from app.api.deps import get_db
|
||||||
|
from app.db.base import Base
|
||||||
|
from app.main import create_app
|
||||||
|
from app.models.employee import Employee
|
||||||
|
from app.models.financial_record import ExpenseClaim
|
||||||
|
|
||||||
|
|
||||||
|
def build_client() -> tuple[TestClient, sessionmaker[Session]]:
|
||||||
|
engine = create_engine(
|
||||||
|
"sqlite+pysqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
def override_db() -> Generator[Session, None, None]:
|
||||||
|
db = session_factory()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = override_db
|
||||||
|
return TestClient(app), session_factory
|
||||||
|
|
||||||
|
|
||||||
|
def seed_claims(db: Session) -> None:
|
||||||
|
employee = Employee(
|
||||||
|
id="emp-page",
|
||||||
|
employee_no="E-PAGE",
|
||||||
|
name="Page User",
|
||||||
|
email="page-user@example.com",
|
||||||
|
position="Analyst",
|
||||||
|
grade="P4",
|
||||||
|
)
|
||||||
|
db.add(employee)
|
||||||
|
for index in range(3):
|
||||||
|
db.add(
|
||||||
|
ExpenseClaim(
|
||||||
|
id=f"claim-page-{index}",
|
||||||
|
claim_no=f"EXP-PAGE-{index}",
|
||||||
|
employee_id=employee.id,
|
||||||
|
employee_name=employee.name,
|
||||||
|
department_id="dept-page",
|
||||||
|
department_name="Market",
|
||||||
|
project_code=None,
|
||||||
|
expense_type="office",
|
||||||
|
reason=f"Office purchase {index}",
|
||||||
|
location="Shanghai",
|
||||||
|
amount=Decimal("100.00"),
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=0,
|
||||||
|
occurred_at=datetime(2026, 5, 20 + index, tzinfo=UTC),
|
||||||
|
submitted_at=None,
|
||||||
|
status="draft",
|
||||||
|
approval_stage="draft",
|
||||||
|
risk_flags_json=[],
|
||||||
|
created_at=datetime(2026, 5, 20 + index, tzinfo=UTC),
|
||||||
|
updated_at=datetime(2026, 5, 20 + index, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_expense_claims_support_page_envelope_and_keep_legacy_list() -> None:
|
||||||
|
client, session_factory = build_client()
|
||||||
|
with session_factory() as db:
|
||||||
|
seed_claims(db)
|
||||||
|
|
||||||
|
headers = {"x-auth-username": "E-PAGE", "x-auth-name": "Page User"}
|
||||||
|
legacy_response = client.get("/api/v1/reimbursements/claims", headers=headers)
|
||||||
|
assert legacy_response.status_code == 200
|
||||||
|
assert isinstance(legacy_response.json(), list)
|
||||||
|
|
||||||
|
page_response = client.get(
|
||||||
|
"/api/v1/reimbursements/claims?page=1&page_size=2",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert page_response.status_code == 200
|
||||||
|
payload = page_response.json()
|
||||||
|
assert [key for key in payload if key in {"items", "total", "page", "page_size"}] == [
|
||||||
|
"items",
|
||||||
|
"total",
|
||||||
|
"page",
|
||||||
|
"page_size",
|
||||||
|
]
|
||||||
|
assert len(payload["items"]) == 2
|
||||||
|
assert payload["total"] == 3
|
||||||
|
assert payload["page"] == 1
|
||||||
|
assert payload["page_size"] == 2
|
||||||
|
assert payload["total_pages"] == 2
|
||||||
|
assert payload["has_next"] is True
|
||||||
|
assert payload["has_previous"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_employee_directory_supports_backend_pagination() -> None:
|
||||||
|
client, _ = build_client()
|
||||||
|
|
||||||
|
response = client.get("/api/v1/employees?page=2&page_size=10")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert len(payload["items"]) == 10
|
||||||
|
assert payload["total"] >= 30
|
||||||
|
assert payload["page"] == 2
|
||||||
|
assert payload["page_size"] == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_budget_allocations_support_backend_pagination() -> None:
|
||||||
|
client, _ = build_client()
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/budgets/allocations?page=1&page_size=2",
|
||||||
|
headers={"x-auth-username": "admin", "x-auth-role-codes": "manager"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert len(payload["items"]) <= 2
|
||||||
|
assert payload["total"] >= len(payload["items"])
|
||||||
|
assert payload["page"] == 1
|
||||||
|
assert payload["page_size"] == 2
|
||||||
@@ -4,7 +4,6 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg" />
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/lxgw-wenkai/1.501/lxgw-wenkai.min.css" />
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css" />
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css" />
|
||||||
<title>ReimburseOps - 企业报销智能运营台</title>
|
<title>ReimburseOps - 企业报销智能运营台</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
30
web/package-lock.json
generated
30
web/package-lock.json
generated
@@ -12,14 +12,12 @@
|
|||||||
"@element-plus/icons-vue": "^2.3.2",
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
"@vitejs/plugin-vue": "^5.2.4",
|
"@vitejs/plugin-vue": "^5.2.4",
|
||||||
"@vueuse/motion": "^3.0.3",
|
"@vueuse/motion": "^3.0.3",
|
||||||
"chart.js": "^4.5.1",
|
|
||||||
"echarts": "^6.1.0",
|
"echarts": "^6.1.0",
|
||||||
"element-plus": "^2.14.0",
|
"element-plus": "^2.14.0",
|
||||||
"markdown-it": "^14.1.1",
|
"markdown-it": "^14.1.1",
|
||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
"vite": "^5.4.19",
|
"vite": "^5.4.19",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-chartjs": "^5.3.3",
|
|
||||||
"vue-router": "^4.5.1"
|
"vue-router": "^4.5.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -803,12 +801,6 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@kurkle/color": {
|
|
||||||
"version": "0.3.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
|
||||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@nuxt/kit": {
|
"node_modules/@nuxt/kit": {
|
||||||
"version": "3.21.2",
|
"version": "3.21.2",
|
||||||
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-3.21.2.tgz",
|
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-3.21.2.tgz",
|
||||||
@@ -1598,18 +1590,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/chart.js": {
|
|
||||||
"version": "4.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
|
||||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@kurkle/color": "^0.3.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"pnpm": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||||
@@ -3086,16 +3066,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vue-chartjs": {
|
|
||||||
"version": "5.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.3.tgz",
|
|
||||||
"integrity": "sha512-jqxtL8KZ6YJ5NTv6XzrzLS7osyegOi28UGNZW0h9OkDL7Sh1396ht4Dorh04aKrl2LiSalQ84WtqiG0RIJb0tA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"chart.js": "^4.1.1",
|
|
||||||
"vue": "^3.0.0-0 || ^2.7.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vue-component-type-helpers": {
|
"node_modules/vue-component-type-helpers": {
|
||||||
"version": "3.3.2",
|
"version": "3.3.2",
|
||||||
"resolved": "https://registry.npmmirror.com/vue-component-type-helpers/-/vue-component-type-helpers-3.3.2.tgz",
|
"resolved": "https://registry.npmmirror.com/vue-component-type-helpers/-/vue-component-type-helpers-3.3.2.tgz",
|
||||||
|
|||||||
@@ -14,14 +14,12 @@
|
|||||||
"@element-plus/icons-vue": "^2.3.2",
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
"@vitejs/plugin-vue": "^5.2.4",
|
"@vitejs/plugin-vue": "^5.2.4",
|
||||||
"@vueuse/motion": "^3.0.3",
|
"@vueuse/motion": "^3.0.3",
|
||||||
"chart.js": "^4.5.1",
|
|
||||||
"echarts": "^6.1.0",
|
"echarts": "^6.1.0",
|
||||||
"element-plus": "^2.14.0",
|
"element-plus": "^2.14.0",
|
||||||
"markdown-it": "^14.1.1",
|
"markdown-it": "^14.1.1",
|
||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
"vite": "^5.4.19",
|
"vite": "^5.4.19",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-chartjs": "^5.3.3",
|
|
||||||
"vue-router": "^4.5.1"
|
"vue-router": "^4.5.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-desktop-shell" :style="desktopScaleStyle">
|
<div class="app-desktop-shell" :style="desktopScaleStyle">
|
||||||
<div class="app-desktop-stage">
|
<div class="app-desktop-stage">
|
||||||
|
<ElConfigProvider :locale="zhCn">
|
||||||
<RouterView />
|
<RouterView />
|
||||||
<ToastNotification :toast-text="toastText" />
|
<ToastNotification :toast-text="toastText" />
|
||||||
|
</ElConfigProvider>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -10,6 +12,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { RouterView } from 'vue-router'
|
import { RouterView } from 'vue-router'
|
||||||
|
import { ElConfigProvider } from 'element-plus/es/components/config-provider/index.mjs'
|
||||||
|
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
|
|
||||||
import './assets/styles/global.css'
|
import './assets/styles/global.css'
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,7 @@
|
|||||||
--desktop-stage-height: 100dvh;
|
--desktop-stage-height: 100dvh;
|
||||||
--desktop-viewport-width: 1440;
|
--desktop-viewport-width: 1440;
|
||||||
--desktop-viewport-height: 900;
|
--desktop-viewport-height: 900;
|
||||||
font-family: "LXGW WenKai", Inter, "SF Pro Display", "PingFang SC", sans-serif;
|
font-family: Inter, "SF Pro Display", "Segoe UI", "Microsoft YaHei", "PingFang SC", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
|||||||
@@ -135,7 +135,9 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, nextTick, ref, watch } from 'vue'
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
import { ElButton, ElDialog, ElTag } from 'element-plus'
|
import { ElButton } from 'element-plus/es/components/button/index.mjs'
|
||||||
|
import { ElDialog } from 'element-plus/es/components/dialog/index.mjs'
|
||||||
|
import { ElTag } from 'element-plus/es/components/tag/index.mjs'
|
||||||
|
|
||||||
import ExpenseProfileTagPager from './ExpenseProfileTagPager.vue'
|
import ExpenseProfileTagPager from './ExpenseProfileTagPager.vue'
|
||||||
import RadarChart from '../charts/RadarChart.vue'
|
import RadarChart from '../charts/RadarChart.vue'
|
||||||
|
|||||||
@@ -69,7 +69,8 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { ElButton, ElTag } from 'element-plus'
|
import { ElButton } from 'element-plus/es/components/button/index.mjs'
|
||||||
|
import { ElTag } from 'element-plus/es/components/tag/index.mjs'
|
||||||
|
|
||||||
const TAG_PAGE_SIZE = 5
|
const TAG_PAGE_SIZE = 5
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="budget-trend-chart">
|
<div ref="chartElement" class="budget-trend-chart" role="img" :aria-label="ariaLabel"></div>
|
||||||
<Bar :data="chartData" :options="chartOptions" />
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed, shallowRef } from 'vue'
|
||||||
import { Bar } from 'vue-chartjs'
|
import { BarChart as EChartsBarChart } from 'echarts/charts'
|
||||||
import {
|
import { GridComponent, TooltipComponent } from 'echarts/components'
|
||||||
Chart as ChartJS,
|
import { use } from 'echarts/core'
|
||||||
BarElement,
|
import { CanvasRenderer } from 'echarts/renderers'
|
||||||
CategoryScale,
|
|
||||||
Legend,
|
import { useEcharts } from '../../composables/useEcharts.js'
|
||||||
LinearScale,
|
|
||||||
Tooltip
|
|
||||||
} from 'chart.js'
|
|
||||||
import { useAnimationProgress } from '../../composables/useAnimationProgress.js'
|
|
||||||
import { useThemeColors } from '../../composables/useThemeColors.js'
|
import { useThemeColors } from '../../composables/useThemeColors.js'
|
||||||
|
|
||||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip, Legend)
|
use([GridComponent, TooltipComponent, EChartsBarChart, CanvasRenderer])
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
labels: { type: Array, required: true },
|
labels: { type: Array, required: true },
|
||||||
@@ -28,13 +22,7 @@ const props = defineProps({
|
|||||||
available: { type: Array, default: () => [] }
|
available: { type: Array, default: () => [] }
|
||||||
})
|
})
|
||||||
|
|
||||||
const progress = useAnimationProgress([
|
const chartElement = shallowRef(null)
|
||||||
() => props.labels,
|
|
||||||
() => props.budget,
|
|
||||||
() => props.used,
|
|
||||||
() => props.occupied,
|
|
||||||
() => props.available
|
|
||||||
], 1000)
|
|
||||||
const themeColors = useThemeColors()
|
const themeColors = useThemeColors()
|
||||||
const prefersReducedMotion = () =>
|
const prefersReducedMotion = () =>
|
||||||
typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
||||||
@@ -54,18 +42,23 @@ const percent = (value, total) => {
|
|||||||
const percentSeries = (series) =>
|
const percentSeries = (series) =>
|
||||||
props.budget.map((total, index) => percent(series[index], total))
|
props.budget.map((total, index) => percent(series[index], total))
|
||||||
|
|
||||||
const scaleSeries = (series) =>
|
|
||||||
series.map((value) => Number((Number(value || 0) * progress.value).toFixed(2)))
|
|
||||||
|
|
||||||
const usedPercent = computed(() => percentSeries(props.used))
|
const usedPercent = computed(() => percentSeries(props.used))
|
||||||
const occupiedPercent = computed(() => percentSeries(props.occupied))
|
const occupiedPercent = computed(() => percentSeries(props.occupied))
|
||||||
const availablePercent = computed(() =>
|
const availableAmountSeries = computed(() =>
|
||||||
props.budget.map((total, index) => {
|
props.budget.map((total, index) => {
|
||||||
|
const explicitValue = Number(props.available[index])
|
||||||
|
if (Number.isFinite(explicitValue) && explicitValue > 0) {
|
||||||
|
return explicitValue
|
||||||
|
}
|
||||||
|
|
||||||
const usedValue = Number(props.used[index] || 0)
|
const usedValue = Number(props.used[index] || 0)
|
||||||
const occupiedValue = Number(props.occupied[index] || 0)
|
const occupiedValue = Number(props.occupied[index] || 0)
|
||||||
return percent(Math.max(Number(total || 0) - usedValue - occupiedValue, 0), total)
|
return Math.max(Number(total || 0) - usedValue - occupiedValue, 0)
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
const availablePercent = computed(() =>
|
||||||
|
props.budget.map((total, index) => percent(availableAmountSeries.value[index], total))
|
||||||
|
)
|
||||||
|
|
||||||
const yAxisMax = computed(() => {
|
const yAxisMax = computed(() => {
|
||||||
const maxUsage = Math.max(
|
const maxUsage = Math.max(
|
||||||
@@ -75,111 +68,122 @@ const yAxisMax = computed(() => {
|
|||||||
return Math.ceil(maxUsage / 20) * 20
|
return Math.ceil(maxUsage / 20) * 20
|
||||||
})
|
})
|
||||||
|
|
||||||
const chartData = computed(() => ({
|
const ariaLabel = computed(() =>
|
||||||
labels: props.labels,
|
props.labels.map((label, index) => (
|
||||||
datasets: [
|
`${label}预算${currency(props.budget[index])},已使用${usedPercent.value[index] || 0}%,已占用${occupiedPercent.value[index] || 0}%`
|
||||||
{
|
)).join(';')
|
||||||
label: '已使用',
|
)
|
||||||
data: scaleSeries(usedPercent.value),
|
|
||||||
backgroundColor: themeColors.value.chartPrimary,
|
function buildSeriesData(percentValues, amountValues) {
|
||||||
borderRadius: 4,
|
return percentValues.map((value, index) => ({
|
||||||
borderSkipped: false,
|
value,
|
||||||
stack: 'budgetUsage',
|
amount: Number(amountValues[index] || 0)
|
||||||
amounts: props.used
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '已占用',
|
|
||||||
data: scaleSeries(occupiedPercent.value),
|
|
||||||
backgroundColor: themeColors.value.warning,
|
|
||||||
borderRadius: 4,
|
|
||||||
borderSkipped: false,
|
|
||||||
stack: 'budgetUsage',
|
|
||||||
amounts: props.occupied
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '剩余可用',
|
|
||||||
data: scaleSeries(availablePercent.value),
|
|
||||||
backgroundColor: '#e5edf3',
|
|
||||||
borderRadius: 4,
|
|
||||||
borderSkipped: false,
|
|
||||||
stack: 'budgetUsage',
|
|
||||||
amounts: props.available
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}))
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
const chartOptions = computed(() => ({
|
const chartOptions = computed(() => ({
|
||||||
responsive: true,
|
backgroundColor: 'transparent',
|
||||||
maintainAspectRatio: false,
|
|
||||||
interaction: {
|
|
||||||
mode: 'index',
|
|
||||||
intersect: false
|
|
||||||
},
|
|
||||||
animation: {
|
animation: {
|
||||||
duration: prefersReducedMotion() ? 0 : 760,
|
duration: prefersReducedMotion() ? 0 : 760,
|
||||||
easing: 'easeOutQuart'
|
easing: 'easeOutQuart'
|
||||||
},
|
},
|
||||||
plugins: {
|
grid: {
|
||||||
legend: {
|
top: 12,
|
||||||
display: false
|
right: 16,
|
||||||
|
bottom: 24,
|
||||||
|
left: 34,
|
||||||
|
containLabel: true
|
||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
confine: true,
|
||||||
|
appendToBody: true,
|
||||||
|
axisPointer: { type: 'shadow' },
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
borderColor: '#e2e8f0',
|
borderColor: '#e2e8f0',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
bodyColor: '#475569',
|
padding: [10, 12],
|
||||||
titleColor: '#0f172a',
|
textStyle: {
|
||||||
cornerRadius: 4,
|
color: '#475569',
|
||||||
padding: 12,
|
fontSize: 12,
|
||||||
displayColors: true,
|
fontWeight: 700
|
||||||
callbacks: {
|
|
||||||
label(context) {
|
|
||||||
const value = Number(context.parsed.y || 0)
|
|
||||||
const amount = Number(context.dataset.amounts?.[context.dataIndex] || 0)
|
|
||||||
return `${context.dataset.label}: ${value.toFixed(2)}%(¥${currency(amount)})`
|
|
||||||
},
|
},
|
||||||
afterBody(items) {
|
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);',
|
||||||
const index = items[0]?.dataIndex ?? 0
|
formatter(params = []) {
|
||||||
return `预算总额: ¥${currency(props.budget[index])}`
|
const items = Array.isArray(params) ? params : [params]
|
||||||
}
|
const index = Number(items[0]?.dataIndex || 0)
|
||||||
}
|
const lines = items.map((item) => {
|
||||||
|
const percentValue = Number(item?.value || 0).toFixed(2)
|
||||||
|
const amount = currency(item?.data?.amount || 0)
|
||||||
|
return `${item.marker}${item.seriesName}: ${percentValue}%(¥${amount})`
|
||||||
|
})
|
||||||
|
return [`${items[0]?.axisValue || ''}`, ...lines, `预算总额: ¥${currency(props.budget[index])}`].join('<br/>')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
scales: {
|
xAxis: {
|
||||||
x: {
|
type: 'category',
|
||||||
grid: { display: false },
|
data: props.labels,
|
||||||
ticks: {
|
axisTick: { show: false },
|
||||||
|
axisLine: { show: false },
|
||||||
|
axisLabel: {
|
||||||
color: '#64748b',
|
color: '#64748b',
|
||||||
font: { size: 12 }
|
fontSize: 12,
|
||||||
|
fontWeight: 700
|
||||||
|
}
|
||||||
},
|
},
|
||||||
border: { display: false }
|
yAxis: {
|
||||||
},
|
type: 'value',
|
||||||
y: {
|
min: 0,
|
||||||
beginAtZero: true,
|
|
||||||
max: yAxisMax.value,
|
max: yAxisMax.value,
|
||||||
stacked: true,
|
splitNumber: Math.max(1, Math.ceil(yAxisMax.value / 20)),
|
||||||
grid: {
|
axisLine: { show: false },
|
||||||
color: '#edf2f7',
|
axisTick: { show: false },
|
||||||
drawTicks: false
|
axisLabel: {
|
||||||
},
|
|
||||||
border: { display: false },
|
|
||||||
ticks: {
|
|
||||||
color: '#64748b',
|
color: '#64748b',
|
||||||
font: { size: 12 },
|
fontSize: 12,
|
||||||
stepSize: 20,
|
fontWeight: 700,
|
||||||
callback(value) {
|
formatter: (value) => `${Number(value)}%`
|
||||||
return `${Number(value)}%`
|
},
|
||||||
}
|
splitLine: { lineStyle: { color: '#edf2f7' } }
|
||||||
}
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '已使用',
|
||||||
|
type: 'bar',
|
||||||
|
stack: 'budgetUsage',
|
||||||
|
data: buildSeriesData(usedPercent.value, props.used),
|
||||||
|
barWidth: 16,
|
||||||
|
itemStyle: {
|
||||||
|
color: themeColors.value.chartPrimary,
|
||||||
|
borderRadius: [4, 4, 0, 0]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
datasets: {
|
{
|
||||||
bar: {
|
name: '已占用',
|
||||||
categoryPercentage: 0.58,
|
type: 'bar',
|
||||||
barPercentage: 0.72
|
stack: 'budgetUsage',
|
||||||
|
data: buildSeriesData(occupiedPercent.value, props.occupied),
|
||||||
|
barWidth: 16,
|
||||||
|
itemStyle: {
|
||||||
|
color: themeColors.value.warning,
|
||||||
|
borderRadius: [4, 4, 0, 0]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '剩余可用',
|
||||||
|
type: 'bar',
|
||||||
|
stack: 'budgetUsage',
|
||||||
|
data: buildSeriesData(availablePercent.value, availableAmountSeries.value),
|
||||||
|
barWidth: 16,
|
||||||
|
itemStyle: {
|
||||||
|
color: '#e5edf3',
|
||||||
|
borderRadius: [4, 4, 0, 0]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
]
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
useEcharts(chartElement, chartOptions)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -4,29 +4,21 @@
|
|||||||
<span><i :style="{ background: chartColors.primary }"></i>日志总量</span>
|
<span><i :style="{ background: chartColors.primary }"></i>日志总量</span>
|
||||||
<span><i :style="{ background: chartColors.danger }"></i>失败数</span>
|
<span><i :style="{ background: chartColors.danger }"></i>失败数</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-body">
|
<div ref="chartElement" class="chart-body" role="img" :aria-label="ariaLabel"></div>
|
||||||
<Bar :data="chartData" :options="chartOptions" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed, shallowRef } from 'vue'
|
||||||
import { Bar } from 'vue-chartjs'
|
import { BarChart as EChartsBarChart, LineChart as EChartsLineChart } from 'echarts/charts'
|
||||||
import {
|
import { GridComponent, TooltipComponent } from 'echarts/components'
|
||||||
Chart as ChartJS,
|
import { use } from 'echarts/core'
|
||||||
CategoryScale,
|
import { CanvasRenderer } from 'echarts/renderers'
|
||||||
LinearScale,
|
|
||||||
BarElement,
|
import { useEcharts } from '../../composables/useEcharts.js'
|
||||||
PointElement,
|
|
||||||
LineElement,
|
|
||||||
Tooltip,
|
|
||||||
Legend
|
|
||||||
} from 'chart.js'
|
|
||||||
import { useAnimationProgress } from '../../composables/useAnimationProgress.js'
|
|
||||||
import { useThemeColors } from '../../composables/useThemeColors.js'
|
import { useThemeColors } from '../../composables/useThemeColors.js'
|
||||||
|
|
||||||
ChartJS.register(CategoryScale, LinearScale, BarElement, PointElement, LineElement, Tooltip, Legend)
|
use([GridComponent, TooltipComponent, EChartsBarChart, EChartsLineChart, CanvasRenderer])
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
labels: { type: Array, required: true },
|
labels: { type: Array, required: true },
|
||||||
@@ -34,96 +26,104 @@ const props = defineProps({
|
|||||||
failures: { type: Array, required: true }
|
failures: { type: Array, required: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
const progress = useAnimationProgress([
|
const chartElement = shallowRef(null)
|
||||||
() => props.labels,
|
|
||||||
() => props.totals,
|
|
||||||
() => props.failures
|
|
||||||
], 1000)
|
|
||||||
const themeColors = useThemeColors()
|
const themeColors = useThemeColors()
|
||||||
const chartColors = computed(() => ({
|
const chartColors = computed(() => ({
|
||||||
primary: themeColors.value.chartPrimary,
|
primary: themeColors.value.chartPrimary,
|
||||||
danger: themeColors.value.chartDanger
|
danger: themeColors.value.chartDanger
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const scaleSeries = (series) =>
|
|
||||||
series.map((value) => Math.round(Number(value || 0) * progress.value))
|
|
||||||
|
|
||||||
const maxTotal = computed(() => Math.max(...props.totals.map((value) => Number(value || 0)), 1))
|
const maxTotal = computed(() => Math.max(...props.totals.map((value) => Number(value || 0)), 1))
|
||||||
|
const ariaLabel = computed(() =>
|
||||||
const chartData = computed(() => ({
|
props.labels.map((label, index) => (
|
||||||
labels: props.labels,
|
`${label}日志总量${props.totals[index] || 0},失败数${props.failures[index] || 0}`
|
||||||
datasets: [
|
)).join(';')
|
||||||
{
|
)
|
||||||
label: '日志总量',
|
|
||||||
data: scaleSeries(props.totals),
|
|
||||||
backgroundColor: chartColors.value.primary,
|
|
||||||
borderRadius: 4,
|
|
||||||
barPercentage: 0.58,
|
|
||||||
categoryPercentage: 0.56,
|
|
||||||
order: 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '失败数',
|
|
||||||
data: scaleSeries(props.failures),
|
|
||||||
borderColor: chartColors.value.danger,
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
borderWidth: 2,
|
|
||||||
pointBackgroundColor: '#ffffff',
|
|
||||||
pointBorderColor: chartColors.value.danger,
|
|
||||||
pointBorderWidth: 2,
|
|
||||||
pointRadius: 3,
|
|
||||||
pointHoverRadius: 5,
|
|
||||||
type: 'line',
|
|
||||||
order: 1
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}))
|
|
||||||
|
|
||||||
const chartOptions = computed(() => ({
|
const chartOptions = computed(() => ({
|
||||||
responsive: true,
|
backgroundColor: 'transparent',
|
||||||
maintainAspectRatio: false,
|
|
||||||
animation: {
|
animation: {
|
||||||
duration: 900,
|
duration: 900,
|
||||||
easing: 'easeOutQuart'
|
easing: 'easeOutQuart'
|
||||||
},
|
},
|
||||||
interaction: {
|
grid: {
|
||||||
mode: 'index',
|
top: 12,
|
||||||
intersect: false
|
right: 18,
|
||||||
|
bottom: 20,
|
||||||
|
left: 34,
|
||||||
|
containLabel: true
|
||||||
},
|
},
|
||||||
plugins: {
|
|
||||||
legend: { display: false },
|
|
||||||
tooltip: {
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
confine: true,
|
||||||
|
appendToBody: true,
|
||||||
backgroundColor: 'rgba(255,255,255,0.96)',
|
backgroundColor: 'rgba(255,255,255,0.96)',
|
||||||
titleColor: '#1e293b',
|
|
||||||
bodyColor: '#64748b',
|
|
||||||
borderColor: '#e2e8f0',
|
borderColor: '#e2e8f0',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
padding: 10,
|
padding: [9, 10],
|
||||||
boxPadding: 4,
|
textStyle: {
|
||||||
cornerRadius: 6,
|
color: '#64748b',
|
||||||
usePointStyle: true
|
fontSize: 12,
|
||||||
|
fontWeight: 700
|
||||||
|
},
|
||||||
|
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);'
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: props.labels,
|
||||||
|
axisTick: { show: false },
|
||||||
|
axisLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.28)' } },
|
||||||
|
axisLabel: {
|
||||||
|
color: '#64748b',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
scales: {
|
yAxis: {
|
||||||
x: {
|
type: 'value',
|
||||||
grid: { display: false },
|
min: 0,
|
||||||
ticks: {
|
max: Math.max(maxTotal.value, 4),
|
||||||
|
axisLine: { show: false },
|
||||||
|
axisTick: { show: false },
|
||||||
|
axisLabel: {
|
||||||
color: '#64748b',
|
color: '#64748b',
|
||||||
font: { size: 11 }
|
fontSize: 11,
|
||||||
|
fontWeight: 700
|
||||||
|
},
|
||||||
|
splitLine: { lineStyle: { color: '#f1f5f9' } }
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '日志总量',
|
||||||
|
type: 'bar',
|
||||||
|
data: props.totals,
|
||||||
|
barWidth: 16,
|
||||||
|
itemStyle: {
|
||||||
|
color: chartColors.value.primary,
|
||||||
|
borderRadius: [4, 4, 0, 0]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
y: {
|
{
|
||||||
beginAtZero: true,
|
name: '失败数',
|
||||||
suggestedMax: Math.max(maxTotal.value, 4),
|
type: 'line',
|
||||||
grid: { color: '#f1f5f9' },
|
data: props.failures,
|
||||||
ticks: {
|
smooth: true,
|
||||||
color: '#64748b',
|
symbol: 'circle',
|
||||||
font: { size: 11 },
|
symbolSize: 7,
|
||||||
precision: 0
|
lineStyle: {
|
||||||
}
|
width: 2,
|
||||||
|
color: chartColors.value.danger
|
||||||
|
},
|
||||||
|
itemStyle: {
|
||||||
|
color: '#ffffff',
|
||||||
|
borderColor: chartColors.value.danger,
|
||||||
|
borderWidth: 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
]
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
useEcharts(chartElement, chartOptions)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -112,7 +112,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ElTooltip } from 'element-plus'
|
import { ElTooltip } from 'element-plus/es/components/tooltip/index.mjs'
|
||||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
|
||||||
import { useDocumentCenterInbox } from '../../composables/useDocumentCenterInbox.js'
|
import { useDocumentCenterInbox } from '../../composables/useDocumentCenterInbox.js'
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { ElSelect, ElOption } from 'element-plus/es/components/select/index.mjs'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: { type: [String, Number, Boolean], default: '' },
|
modelValue: { type: [String, Number, Boolean], default: '' },
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { MotionPlugin } from '@vueuse/motion'
|
import { MotionPlugin } from '@vueuse/motion'
|
||||||
import ElementPlus from 'element-plus'
|
|
||||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
|
||||||
import 'element-plus/dist/index.css'
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router/index.js'
|
import router from './router/index.js'
|
||||||
import { installThemeSkin } from './composables/useThemeSkin.js'
|
import { installThemeSkin } from './composables/useThemeSkin.js'
|
||||||
import { installSessionNavigation } from './composables/useSystemState.js'
|
import { installSessionNavigation } from './composables/useSystemState.js'
|
||||||
|
import './plugins/elementPlusStyles.js'
|
||||||
import './assets/styles/element-plus-theme.css'
|
import './assets/styles/element-plus-theme.css'
|
||||||
import './assets/styles/detail-page-corners.css'
|
import './assets/styles/detail-page-corners.css'
|
||||||
import './assets/styles/components/enterprise-page-shell.css'
|
import './assets/styles/components/enterprise-page-shell.css'
|
||||||
@@ -18,6 +16,5 @@ installSessionNavigation(router)
|
|||||||
|
|
||||||
app.use(MotionPlugin)
|
app.use(MotionPlugin)
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.use(ElementPlus, { locale: zhCn })
|
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|||||||
22
web/src/plugins/elementPlusStyles.js
Normal file
22
web/src/plugins/elementPlusStyles.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import 'element-plus/theme-chalk/base.css'
|
||||||
|
import 'element-plus/theme-chalk/el-button.css'
|
||||||
|
import 'element-plus/theme-chalk/el-button-group.css'
|
||||||
|
import 'element-plus/theme-chalk/el-checkbox.css'
|
||||||
|
import 'element-plus/theme-chalk/el-checkbox-group.css'
|
||||||
|
import 'element-plus/theme-chalk/el-dialog.css'
|
||||||
|
import 'element-plus/theme-chalk/el-dropdown.css'
|
||||||
|
import 'element-plus/theme-chalk/el-dropdown-item.css'
|
||||||
|
import 'element-plus/theme-chalk/el-dropdown-menu.css'
|
||||||
|
import 'element-plus/theme-chalk/el-input.css'
|
||||||
|
import 'element-plus/theme-chalk/el-option.css'
|
||||||
|
import 'element-plus/theme-chalk/el-option-group.css'
|
||||||
|
import 'element-plus/theme-chalk/el-overlay.css'
|
||||||
|
import 'element-plus/theme-chalk/el-pagination.css'
|
||||||
|
import 'element-plus/theme-chalk/el-popper.css'
|
||||||
|
import 'element-plus/theme-chalk/el-scrollbar.css'
|
||||||
|
import 'element-plus/theme-chalk/el-select.css'
|
||||||
|
import 'element-plus/theme-chalk/el-select-dropdown.css'
|
||||||
|
import 'element-plus/theme-chalk/el-table.css'
|
||||||
|
import 'element-plus/theme-chalk/el-table-column.css'
|
||||||
|
import 'element-plus/theme-chalk/el-tag.css'
|
||||||
|
import 'element-plus/theme-chalk/el-tooltip.css'
|
||||||
@@ -60,6 +60,17 @@ function buildQuery(params = {}) {
|
|||||||
search.set('limit', String(params.limit))
|
search.set('limit', String(params.limit))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const page = params.page
|
||||||
|
const pageSize = params.pageSize || params.page_size
|
||||||
|
|
||||||
|
if (page) {
|
||||||
|
search.set('page', String(page))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageSize) {
|
||||||
|
search.set('page_size', String(pageSize))
|
||||||
|
}
|
||||||
|
|
||||||
if (params.version) {
|
if (params.version) {
|
||||||
search.set('version', String(params.version))
|
search.set('version', String(params.version))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ function buildQuery(params = {}) {
|
|||||||
const search = new URLSearchParams()
|
const search = new URLSearchParams()
|
||||||
Object.entries(params || {}).forEach(([key, value]) => {
|
Object.entries(params || {}).forEach(([key, value]) => {
|
||||||
if (typeof value === 'undefined' || value === null || value === '') return
|
if (typeof value === 'undefined' || value === null || value === '') return
|
||||||
search.set(key, String(value))
|
search.set(key === 'pageSize' ? 'page_size' : key, String(value))
|
||||||
})
|
})
|
||||||
const query = search.toString()
|
const query = search.toString()
|
||||||
return query ? `?${query}` : ''
|
return query ? `?${query}` : ''
|
||||||
@@ -25,6 +25,7 @@ export function createBudgetAllocation(payload = {}) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchBudgetTransactions(allocationId) {
|
export function fetchBudgetTransactions(allocationId, params = {}) {
|
||||||
return apiRequest(`/budgets/allocations/${encodeURIComponent(String(allocationId || '').trim())}/transactions`)
|
const encodedId = encodeURIComponent(String(allocationId || '').trim())
|
||||||
|
return apiRequest(`/budgets/allocations/${encodedId}/transactions${buildQuery(params)}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,17 @@ function buildEmployeesQuery(params = {}) {
|
|||||||
search.set('keyword', params.keyword)
|
search.set('keyword', params.keyword)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const page = params.page
|
||||||
|
const pageSize = params.pageSize || params.page_size
|
||||||
|
|
||||||
|
if (page) {
|
||||||
|
search.set('page', String(page))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageSize) {
|
||||||
|
search.set('page_size', String(pageSize))
|
||||||
|
}
|
||||||
|
|
||||||
return search
|
return search
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,32 @@
|
|||||||
import { apiRequest } from './api.js'
|
import { apiRequest } from './api.js'
|
||||||
|
|
||||||
export function fetchExpenseClaims() {
|
function buildListQuery(params = {}) {
|
||||||
return apiRequest('/reimbursements/claims')
|
const search = new URLSearchParams()
|
||||||
|
const page = params.page
|
||||||
|
const pageSize = params.pageSize || params.page_size
|
||||||
|
|
||||||
|
if (page) {
|
||||||
|
search.set('page', String(page))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchApprovalExpenseClaims() {
|
if (pageSize) {
|
||||||
return apiRequest('/reimbursements/claims/approvals')
|
search.set('page_size', String(pageSize))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchArchivedExpenseClaims() {
|
const query = search.toString()
|
||||||
return apiRequest('/reimbursements/claims/archives')
|
return query ? `?${query}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchExpenseClaims(params = {}) {
|
||||||
|
return apiRequest(`/reimbursements/claims${buildListQuery(params)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchApprovalExpenseClaims(params = {}) {
|
||||||
|
return apiRequest(`/reimbursements/claims/approvals${buildListQuery(params)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchArchivedExpenseClaims(params = {}) {
|
||||||
|
return apiRequest(`/reimbursements/claims/archives${buildListQuery(params)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchExpenseClaimDetail(claimId) {
|
export function fetchExpenseClaimDetail(claimId) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
import { ElDropdown, ElDropdownItem, ElDropdownMenu } from 'element-plus/es/components/dropdown/index.mjs'
|
||||||
|
|
||||||
import EnterpriseListPage from '../../components/shared/EnterpriseListPage.vue'
|
import EnterpriseListPage from '../../components/shared/EnterpriseListPage.vue'
|
||||||
import { mapExpenseClaimToRequest } from '../../composables/useRequests.js'
|
import { mapExpenseClaimToRequest } from '../../composables/useRequests.js'
|
||||||
@@ -91,6 +92,9 @@ function resolveFilterLabel(options, activeValue, fallbackLabel) {
|
|||||||
export default {
|
export default {
|
||||||
name: 'ArchiveCenterView',
|
name: 'ArchiveCenterView',
|
||||||
components: {
|
components: {
|
||||||
|
ElDropdown,
|
||||||
|
ElDropdownItem,
|
||||||
|
ElDropdownMenu,
|
||||||
EnterpriseListPage,
|
EnterpriseListPage,
|
||||||
TravelRequestDetailView
|
TravelRequestDetailView
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { ElButton, ElInput, ElPagination, ElTable, ElTableColumn } from 'element-plus'
|
import { ElButton } from 'element-plus/es/components/button/index.mjs'
|
||||||
|
import { ElInput } from 'element-plus/es/components/input/index.mjs'
|
||||||
|
import { ElPagination } from 'element-plus/es/components/pagination/index.mjs'
|
||||||
|
import { ElTable, ElTableColumn } from 'element-plus/es/components/table/index.mjs'
|
||||||
|
|
||||||
import BudgetTrendChart from '../../components/charts/BudgetTrendChart.vue'
|
import BudgetTrendChart from '../../components/charts/BudgetTrendChart.vue'
|
||||||
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
|
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ElDialog } from 'element-plus/es/components/dialog/index.mjs'
|
||||||
|
|
||||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||||
import TravelReimbursementInsightPanel from '../../components/travel/TravelReimbursementInsightPanel.vue'
|
import TravelReimbursementInsightPanel from '../../components/travel/TravelReimbursementInsightPanel.vue'
|
||||||
@@ -507,6 +508,7 @@ function buildReviewMainMessageText(message) {
|
|||||||
export default {
|
export default {
|
||||||
name: 'TravelReimbursementCreateView',
|
name: 'TravelReimbursementCreateView',
|
||||||
components: {
|
components: {
|
||||||
|
ElDialog,
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
TravelReimbursementInsightPanel,
|
TravelReimbursementInsightPanel,
|
||||||
TravelReimbursementMessageItem
|
TravelReimbursementMessageItem
|
||||||
|
|||||||
@@ -1070,9 +1070,6 @@ export default defineConfig({
|
|||||||
if (normalizedId.includes('@antv/g6')) {
|
if (normalizedId.includes('@antv/g6')) {
|
||||||
return 'vendor-g6'
|
return 'vendor-g6'
|
||||||
}
|
}
|
||||||
if (normalizedId.includes('chart.js') || normalizedId.includes('vue-chartjs')) {
|
|
||||||
return 'vendor-chartjs'
|
|
||||||
}
|
|
||||||
if (normalizedId.includes('markdown-it')) {
|
if (normalizedId.includes('markdown-it')) {
|
||||||
return 'vendor-markdown'
|
return 'vendor-markdown'
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user