feat: 统一后端分页查询与前端服务层适配

后端新增通用分页模块,为报销单、员工、预算、agent 资产等
端点统一接入分页参数和游标查询,优化 repository 层分页实
现,前端服务层适配分页响应结构,完善预算图表和全局样式,
优化侧边栏和企业选择器组件,引入 Element Plus 插件注册。
This commit is contained in:
caoxiaozhu
2026-05-29 14:11:06 +08:00
parent e080105f9f
commit 678f64d772
43 changed files with 1863 additions and 378 deletions

View File

@@ -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>

View 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` 建立更强绑定。
- 多票据关联时,如果用户中途取消对话,本轮仍保留为未关联,避免误标。

View 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: 测试方案]

View 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,
}

View File

@@ -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,

View File

@@ -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(

View File

@@ -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)

View File

@@ -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(

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()

View File

@@ -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

View File

@@ -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)

View File

@@ -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(

View 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)

View 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)

View 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)

View File

@@ -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,

View 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,
)

View File

@@ -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:

View File

@@ -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)

View 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

View File

@@ -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
View File

@@ -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",

View File

@@ -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"
} }
} }

View File

@@ -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">
<RouterView /> <ElConfigProvider :locale="zhCn">
<ToastNotification :toast-text="toastText" /> <RouterView />
<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'

View File

@@ -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; }

View File

@@ -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'

View File

@@ -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

View File

@@ -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: {
trigger: 'axis',
confine: true,
appendToBody: true,
axisPointer: { type: 'shadow' },
backgroundColor: '#ffffff',
borderColor: '#e2e8f0',
borderWidth: 1,
padding: [10, 12],
textStyle: {
color: '#475569',
fontSize: 12,
fontWeight: 700
}, },
tooltip: { extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);',
backgroundColor: '#ffffff', formatter(params = []) {
borderColor: '#e2e8f0', const items = Array.isArray(params) ? params : [params]
borderWidth: 1, const index = Number(items[0]?.dataIndex || 0)
bodyColor: '#475569', const lines = items.map((item) => {
titleColor: '#0f172a', const percentValue = Number(item?.value || 0).toFixed(2)
cornerRadius: 4, const amount = currency(item?.data?.amount || 0)
padding: 12, return `${item.marker}${item.seriesName}: ${percentValue}%(¥${amount}`
displayColors: true, })
callbacks: { return [`${items[0]?.axisValue || ''}`, ...lines, `预算总额: ¥${currency(props.budget[index])}`].join('<br/>')
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) {
const index = items[0]?.dataIndex ?? 0
return `预算总额: ¥${currency(props.budget[index])}`
}
}
} }
}, },
scales: { xAxis: {
x: { type: 'category',
grid: { display: false }, data: props.labels,
ticks: { axisTick: { show: false },
color: '#64748b', axisLine: { show: false },
font: { size: 12 } axisLabel: {
}, color: '#64748b',
border: { display: false } fontSize: 12,
}, fontWeight: 700
y: {
beginAtZero: true,
max: yAxisMax.value,
stacked: true,
grid: {
color: '#edf2f7',
drawTicks: false
},
border: { display: false },
ticks: {
color: '#64748b',
font: { size: 12 },
stepSize: 20,
callback(value) {
return `${Number(value)}%`
}
}
} }
}, },
datasets: { yAxis: {
bar: { type: 'value',
categoryPercentage: 0.58, min: 0,
barPercentage: 0.72 max: yAxisMax.value,
splitNumber: Math.max(1, Math.ceil(yAxisMax.value / 20)),
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
color: '#64748b',
fontSize: 12,
fontWeight: 700,
formatter: (value) => `${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]
}
},
{
name: '已占用',
type: 'bar',
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>

View File

@@ -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: { tooltip: {
legend: { display: false }, trigger: 'axis',
tooltip: { confine: true,
backgroundColor: 'rgba(255,255,255,0.96)', appendToBody: true,
titleColor: '#1e293b', backgroundColor: 'rgba(255,255,255,0.96)',
bodyColor: '#64748b', borderColor: '#e2e8f0',
borderColor: '#e2e8f0', borderWidth: 1,
borderWidth: 1, padding: [9, 10],
padding: 10, textStyle: {
boxPadding: 4, color: '#64748b',
cornerRadius: 6, fontSize: 12,
usePointStyle: true 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),
color: '#64748b', axisLine: { show: false },
font: { size: 11 } axisTick: { show: false },
axisLabel: {
color: '#64748b',
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>

View File

@@ -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'

View File

@@ -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: '' },

View File

@@ -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')

View 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'

View File

@@ -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))
} }

View File

@@ -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)}`)
} }

View File

@@ -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
} }

View File

@@ -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))
}
if (pageSize) {
search.set('page_size', String(pageSize))
}
const query = search.toString()
return query ? `?${query}` : ''
} }
export function fetchApprovalExpenseClaims() { export function fetchExpenseClaims(params = {}) {
return apiRequest('/reimbursements/claims/approvals') return apiRequest(`/reimbursements/claims${buildListQuery(params)}`)
} }
export function fetchArchivedExpenseClaims() { export function fetchApprovalExpenseClaims(params = {}) {
return apiRequest('/reimbursements/claims/archives') 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) {

View File

@@ -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
}, },

View File

@@ -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'

View File

@@ -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

View File

@@ -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'
} }