diff --git a/document/development/intelligent-expense-control-platform/index.html b/document/development/intelligent-expense-control-platform/index.html
index f98a4bc..dbec979 100644
--- a/document/development/intelligent-expense-control-platform/index.html
+++ b/document/development/intelligent-expense-control-platform/index.html
@@ -850,6 +850,327 @@
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) {
.page {
grid-template-columns: 236px minmax(0, 1fr);
@@ -971,6 +1292,8 @@
为什么要做
未来地位
蓝海空间
+ 竞品对比
+ 痛点解决
开发目的
核心算法
项目模块
@@ -1037,28 +1360,19 @@
为什么要做智能费控平台
智能费控不是把报销审批电子化,而是把企业每一笔支出变成可理解、可预测、可解释的经营信号。
- 当 AI、RAG、智能体、异常检测和预算联动进入财务流程后,费用平台会从“流程系统”升级为“经营控制系统”。
+ 当前,在金税四期深化、数电发票单轨制全面普及的政策背景下,传统的“后置流程型”费控已被逼入死胡同,企业必须构建全链路智能费控平台。
- 第一,费用是企业经营动作最密集、最贴近一线的数据入口。销售拜访客户会产生差旅和招待,
- 项目交付会产生交通、住宿、外包和办公支出,培训、采购、通信、会务也都以费用形式落地。
- 如果企业只在报销完成后做核销,就只能看到“钱已经花了”;如果在申请、预算占用、票据上传、
- 审批和归档全过程做智能分析,就能提前看到“这笔钱为什么要花、是否该花、花完后对预算和风险有什么影响”。
+ 第一,数电票时代倒逼全链路数字化。随着全国数电发票的单轨制落地,发票的真伪验重、多系统抵扣、合规归档以及全生命周期的追踪已无法通过传统人工肉眼或零散的OCR识别来解决。系统必须具备在发票导入瞬间进行结构化解析、关联交易比对、业务事由交叉匹配的自动化能力,实现“开票即采集、采集即验真、入账即归档”。
- 第二,财务工作的重心正在从人工处理转向分析、预测和决策支持。Gartner 2025 年财务 AI 调研显示,
- 财务组织最常见的 AI 用例已经包括知识管理、应付流程自动化、错误与异常检测;McKinsey 的 CFO 调研也显示,
- 绝大多数受访者期待 AI 减少人工分析负担、生成洞察。这意味着费控平台必须具备自动识别、自动解释和自动沉淀的能力,
- 否则它会停留在“录单工具”,无法进入未来财务的核心工作台。
+ 第二,企业从“事后核销”向“事前控制”的主动性跨越。传统ERP与报销系统核心是“记账与流程审批”,当员工提交报销单时,费用早已产生。智能费控利用 RAG 知识检索、预算实时联动,在费用申请阶段甚至消费发生的瞬间(如因公商旅预订)就进行预算额度校验与制度约束,使费控前置到决策端。
- 第三,费用风险越来越隐蔽。传统审批能挡住“缺附件”“超标准”这类显性问题,
- 但很难发现拆单、跨部门合谋、异常频次、预算节奏异常、同组偏离、长期材料质量差等隐性问题。
- ACFE 的职业舞弊报告将虚构或夸大业务费用列为典型报销舞弊形态;随着 AI 生成票据、深度伪造和自动化攻击出现,
- 企业更需要一个能把票据、人员、部门、历史行为、预算和制度放在一起分析的平台。
+ 第三,费用舞弊与异常的隐性化、复杂化。根据 ACFE 2024 年《全球职业舞弊与滥用报告》显示,全球企业中约有 15% 的舞弊案件与费用报销直接相关,且平均潜伏期长达 18 个月。传统的强硬制度配置只能拦截“超标准”、“无附件”等显性异常,但对于拆单报销、异地多点异常消费、同组偏离度过高、长期合作供应商异常开票等隐性风控,需要基于大数据画像和语义关系的图谱检测。
@@ -1122,74 +1436,66 @@
02 / COMPANY POSITION
费控在未来公司的地位
- 未来公司的费控,会从“报销审批入口”变成“经营支出操作系统”。
+ 未来公司的费控,会从“报销审批工具”变成“企业费用智能操作系统”(Expense OS)。
它一端连接员工和部门的真实业务动作,另一端连接预算、现金流、成本、风险、制度和管理决策。
- 从组织分工看,CFO 的角色正在从财务记录者变成业务伙伴和技术治理者。
- Deloitte 的未来财务洞察持续强调,财务团队会把更多时间投入分析、预测和决策支持;
- Deloitte APAC CFO 2025 调研也显示,接近一半 CFO 认为生成式 AI 会在两年内显著改变行业、组织和财务职能。
- 这意味着费控不应再被放在“报销系统”这个狭窄位置,而应该成为 CFO 获取一线经营信号的前置雷达。
+ 从组织分工与决策支持来看,CFO 的角色正加速从“合规记录者”转变为“战略业务伙伴(HR/IT/业务协同的中心)”。Deloitte 2025 年未来财务展望报告指出,财务部门未来的工作精力将有 70% 倾斜于数据分析和未来预测。智能费控则是这一变革的第一步——通过把一线经营活动(如差旅、招待、项目交付)产生的每一笔资金流动转化为语义数据,为 CFO 的“战略驾驶舱”提供实时的一手信号。
- 从业务管理看,预算不再只是年初编制和月底复盘。未来预算需要跟每一次费用申请实时联动:
- 当前预算池还有多少、审批后使用率是多少、是否触达预警线、该部门同类费用是否异常、是否影响项目毛利和现金节奏。
- 因此费控会成为部门经理、项目负责人和财务 BP 共同使用的经营协同平台。
+ 从预算与资源控制来看,未来的预算管控不再是“年初编预算,月底对账单”的静态割裂状态。智能费控通过建立动态预算池与资金控制链,使预算能够在秒级发生响应。这保证了业务部门在项目推进过程中,能实时感知每一笔花费对项目整体毛利空间、部门现金流余量的动态影响,促使人人关注经营效果。
- 从风险控制看,未来公司的费用风险不是单张票据能解释清楚的,而是多源数据的组合问题。
- 一张票据可能合规,但一个人三个月内的频次、金额、地点、客户、同行人和预算占比可能异常。
- 智能费控要把这些信号拉成一张网,让管理者在支付之前就看到风险轮廓。
+ 从业务运营与体验来看,智能费控是消除部门隔阂、降低一线行政摩擦的“润滑剂”。当平台实现高度数字化与免报销消费结算时,员工不再需要贴票垫资,管理者不再需要在审批页面盲目点“同意”,财务不再需要枯燥审单,整个公司的运营效率将得到数量级提升。
- - 经营仪表盘:部门费用、项目消耗、预算占比、执行率、风险热区可以被实时汇总。
- - 预算守门员:费用申请不再只看“能不能报”,而要看“是否符合预算节奏”。
- - 内控前置层:票据、地点、人员、金额、制度条款和历史行为被联动审核。
- - AI 财务工作台:财务人员可以通过自然语言查询制度、解释风险、生成审批建议和费用报告。
- - 规则与知识工厂:制度解释、规则生成、风险复盘和人工反馈进入可持续优化闭环。
- - 管理决策入口:管理者可以询问“哪个部门预算风险最高”“哪些费用正在吞噬毛利”。
+ - 经营支出中枢 (Expense OS): 将多系统的零散流程、差旅出行、第三方消费直接通过本体层收归统一。
+ - 预算守护雷达: 在业务动作发生时实现“秒级预算占用与测算”,而不是事后核销时的“超支警告”。
+ - 合规内控防火墙: 结合发票验真、合同信息、行程轨迹和行为偏差等多模态数据对风险自动拦截。
+ - 战略决策沙盘: 支持 CFO 和高管直接询问“各项目利润健康度”、“政策变动预算消耗预测”等深度命题。
+ - 自进化规则工厂: 依据历史人工审批意见和申诉结果,系统能自动推荐规则优化方案,实现制度灰度升级。
-
业务动作
-
出差、采购、招待、培训、项目交付
+
业务场景
+
差旅、招待、采购、项目外包、会务支出
-
智能费控平台
-
预算、单据、制度、票据、风险、智能体
+
费用智能操作系统
+
本体语义 · 实时预算 · 智能体 · 仿真沙盘
-
财务结果
-
成本、现金、核销、支付、账务沉淀
+
经营结果
+
成本优化、合规账套、税务风险减免、现金流稳健
-
组织与预算
-
部门、项目、成本中心、预算池
+
CFO 战略看板
+
业财融合数据、动态毛利预警、资金利用率
-
数据与画像
-
费用画像、行为画像、同组基准
+
数字孪生仿真
+
制度变动测算、预算优化预测、行为偏差度
-
管理决策
-
预警、报告、经营洞察、规则优化
+
协同平台
+
免贴票、即时审批、智能助手交互
- 费控位于经营动作和财务结果之间,未来承担预算守门、规则解释、风险识别、数据画像和管理洞察的中枢角色。
+ 智能费控平台向上承接公司战略与预算规则,向下沉入业务一线动作,最终演化为企业支出的全局操作系统。
@@ -1200,45 +1506,203 @@
03 / BLUE OCEAN
智能分析费控平台的蓝海
- 传统 ERP、OA、报销系统和财务共享平台已经覆盖了流程,但对“语义理解、自动洞察、隐性风险识别、
- 预算占比解释、部门费用经营分析”的覆盖仍然不足。蓝海空间正来自这个空白:
- 市场正在从单点报销工具转向 AI 驱动的差旅、费用、发票、预算和风险一体化平台。
+ 传统的报销系统、OA 工作流和财务共享软件已是一片红海,产品竞争停留在表单设计与流程配置层面。而真正能为企业带来颠覆性价值的“智能费控蓝海”,则存在于从流程处理到智能化决策预测的跃迁中。
- Grand View Research 2025 年报告预计,全球差旅与费用管理软件市场到 2030 年将达到 106.9 亿美元,
- 2024 至 2030 年复合增速为 16.9%。更重要的是,它特别提到 AI 可用于审批与支付自动化、
- 员工差旅历史和支出模式分析、OCR 票据字段识别以及基于自然语言的交互。
- 这说明市场真正的增长点不只是“报销线上化”,而是“费用智能化”。
+ 根据全球著名调研机构 Grand View Research 2025 发布的市场预测报告,全球差旅与费用管理(T&E)软件市场规模在 2030 年将达到 106.9 亿美元,2024 至 2030 年的复合年增长率(CAGR)高达 16.9%。报告中特别强调,市场增长的核心引擎已不再是单纯的“线上化审批”,而是引入 AI Agent 进行决策自动化、基于 OCR/LLM 的隐性欺诈检测、以及基于企业历史支出行为的可持续 ESG 碳足迹追踪。这表明,“费用智能化与经营沙盘仿真”是蕴含巨大商业溢价的崭新蓝海。
- 非结构化数据智能化
- 票据、制度 PDF、Excel 规则、审批意见、用户自然语言都能进入同一个语义管道。
+ 1. 认知与决策型 AI Agent
+ 从“机械填表和固定规则校验”跨越到“能基于上下文自主决策、异常判定、并提供人机协同解释”的财务数字员工。
- 部门费用经营化
- 费用从“报销明细”升级为“部门预算执行、费用结构、异常趋势、经营压力”的管理对象。
+ 2. 财务数字孪生与政策仿真
+ 基于公司历史发票、人员组织、预算规则建立仿真模型,模拟差旅标准或预算政策调整后,对企业资金流与利润的实际影响。
- 风险识别前置化
- 拆单、超预算、重复票据、地点不一致、附件缺失、异常频次都可以更早被发现。
+ 3. 多源异构数据语义对齐
+ 打破各子系统(CRM、ERP、OA、商旅聚合商)接口壁垒,利用 Semantic Ontology 本体语义层实现业务数据的天然降维对齐。
- 财务智能体运营化
- 后台数字员工不再等待人点击,而是按周期巡检数据、生成报告和沉淀优化候选。
+ 4. 行为偏离与隐性欺诈识别
+ 基于知识图谱与图机器学习技术,识别跨期拆单、虚假报销、同组偏离异常、跨部门关联交易等人工难以察觉的系统性风险。
- 规则治理产品化
- 自然语言规则、风险规则、Excel 规则和制度知识可以逐步变成可测试、可版本化资产。
+ 5. 自进化规则治理闭环
+ 支持自然语言输入制度,由 LLM 自动翻译成系统执行规则,并通过人工申诉与退回反馈,在后台自动完成规则库的校准 and 修正。
- 财务知识即时化
- RAG 与知识库让制度解释从人工翻文档,转为“问一句,返回依据和建议”。
+ 6. 绿色财务与 ESG 碳足迹
+ 在费控和商旅端自动提取交通工具、酒店的排放系数,帮助企业在进行费用管控的同时,同步测算并生成企业 ESG 碳足迹报告。
+
+
+
+
+
+
+
03.1 / COMPETITORS
+
主流费控系统竞品分析
+
+ 要实现上述蓝海构想,必须客观分析当前市场上主流的费控软件。各大软件定位明确,但普遍在“智能化决策”、“跨系统语义融合”及“政策仿真”方面存在显著痛点。
+
+
+
+
+
+
+
+ | 竞品名称 |
+ 核心定位与主要优势 |
+ 核心痛点与局限性 |
+ X-Financial 的创新解决与差异化 |
+
+
+
+
+ | SAP Concur |
+
+
+ - 全球化费控天花板,支持多币种、多税制、全球财税合规。
+ - 商旅生态极其成熟,能够与 SAP ERP 体系深度咬合。
+
+ |
+
+
+ - 本土化体验被国内用户诟病(如数电发票处理缓慢)。
+ - 系统界面臃肿,配置与二次开发极为繁琐。
+ - 实施周期漫长,运维与实施费用极其高昂。
+
+ |
+
+
+ - 原生适配数电发票单轨制,具备开箱即用的 AI 智能验真归档。
+ - 基于本地化轻量级大模型架构,大幅降低实施门槛和部署成本。
+
+ |
+
+
+ | 分贝通 |
+
+
+ - 以商旅消费支付为切入点,打造“商旅+支付+费控”模式。
+ - 倡导“无需报销”体验,员工体验极佳。
+
+ |
+
+
+ - 强依赖平台内置的消费商城生态,在面对大量零星线下费用、外包采购时灵活性不足。
+ - 对于复杂的多级跨账簿预算、动态共享额度的精细化管控能力相对偏弱。
+
+ |
+
+
+ - 提供基于本体层屏蔽异构商城壁垒的通用解析能力,线下报销补件与线上免报销消费实现语义对齐。
+ - 支持复杂层级的预算池(部门/项目/成本中心)秒级动态关联管控。
+
+ |
+
+
+ | 合思 (易快报) |
+
+
+ - 一站式业财收支管理,国内市占率居前。
+ - 生态系统对接广泛,能与国内主流 ERP 无缝打通,配置灵活。
+
+ |
+
+
+ - 系统逻辑依然以“传统工作流表单”为主,交互较为传统,缺乏智能助理的深度参与。
+ - 缺乏中长周期的员工行为偏差分析、规则误报自适应纠偏机制。
+
+ |
+
+
+ - 前台 User Agent 支持自然语言直接进行报销提问、草稿生成、异常核对与智能交互。
+ - 通过共享本体与规则中心,让财务人员可以用自然语言配置和灰度升级风控规则。
+
+ |
+
+
+ | 每刻报销 |
+
+
+ - 专注于大中型企业的智能财务共享,规则引擎非常强大。
+ - AI 智能审单能力强,支持复杂的集团化多层级架构。
+
+ |
+
+
+ - 系统的学习曲线陡峭,对业务人员和普通员工的交互不够平滑。
+ - 侧重于既定流程的合规审计,对于预算和费控政策调整带来的前瞻性影响缺乏模拟仿真能力。
+
+ |
+
+
+ - 前台大模型辅助,降低普通用户填单学习曲线。
+ - 核心差异化:首创“财务数字孪生与仿真沙盘”,在系统级测试和决策前对费控变动进行量化分析预测。
+
+ |
+
+
+
+
+
+
+
+
+
03.2 / SOLUTIONS
+
X-Financial 痛点解决方案
+
+ 针对以上竞品普遍面临的系统孤岛、缺乏智能决策、政策调整靠经验等痛点,X-Financial 提出了以 **“AI + 数字化 + 财务数字孪生”** 为核心的下一代费控解决方案。
+
+
+
+
+
+ 一、 解决多系统孤岛:Semantic Ontology 本体语义层
+
+ 传统软件通过 API 强行硬编码对接 CRM、ERP 和商旅系统,一旦系统升级,接口极易断裂,导致业财数据“语义断层”。
+
+
+ X-Financial 方案: 我们设计了包含 8 个关键因子的本体语义协议(domain, scenario, intent 等)。无论是口语化描述、页面动作还是三方消费接口,都先被解析为统一的“本体状态”,再分发至下层服务。这实现了跨系统的语义级天然对齐,让数据真正融为一体。
+
+
+
+
+ 二、 解决财务假智能:User Agent & Hermes 双智能体闭环
+
+ 市面软件仅使用 OCR 识别将文字填入表单,人工审核工作量并没有显著降低,也无法识别隐性舞弊。
+
+
+ X-Financial 方案: 我们构建了双 Agent 循环。前台 User Agent 帮员工和财务进行友好交互,自动查漏补正,解释退回原因;后台 Hermes 数字员工 定时在数据底座扫描,分析员工近90/180天的费用画像和行为画像,依据同组偏差算法自动判定隐性风险,出具报告。
+
+
+
+
+ 三、 解决规则配置难:LLM 驱动的规则与知识工厂
+
+ 传统系统的规则引擎配置需要 IT 人员编写繁杂的条件表达式,难以响应公司制度的频繁变动。
+
+
+ X-Financial 方案: 支持财务直接上传自然语言描述的制度文档。系统通过大模型将文本转化为可测试、可执行的规则库资产;当发生审批争议或误报时,支持财务通过“反馈池”对规则 and 知识库进行一键校正,进入自进化闭环。
+
+
+
+
+ 首创蓝海技术
+ 四、 解决政策盲目调整:财务数字孪生与仿真沙盘(Financial Digital Twin)
+
+ 很多企业在遇到经营波动时,会盲目调低差旅标准(如酒店降低20%),这容易导致员工满意度下降、灰色报销增多或合规偏离率升高,甚至因行政摩擦上升造成更大的隐性成本。目前市场没有任何一款软件能评估规则调整带来的实际后果。
+
+
+ X-Financial 方案: 我们通过融合组织架构树、员工费用画像、预算控制规则与历史发票数据库,为企业构建了一个“财务数字孪生体”。当管理层想要修改政策(如“下调销售部酒店标准 15%”)时,可在系统仿真沙盘(Simulation Sandbox)中运行模拟。沙盘会模拟历史真实或合成出的数万个出差场景进行回归测算,量化预测此次政策调整对“未来费用降幅(预估降低11%)”、“员工合规偏离率(预估提升8%)”、“预计审批流程摩擦时间(预估提升12%)”的多维影响,为企业提供真正科学的决策辅助。
+
@@ -1811,6 +2275,8 @@ budget_control =
为什么要做
未来地位
蓝海空间
+ 竞品对比
+ 痛点解决
开发目的
算法能力
模块清单
diff --git a/document/development/receipt-folder/CONCEPT.md b/document/development/receipt-folder/CONCEPT.md
new file mode 100644
index 0000000..63c142f
--- /dev/null
+++ b/document/development/receipt-folder/CONCEPT.md
@@ -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///`。
+- 每个票据目录包含:
+ - 原始文件:`source.`
+ - 预览文件:`preview.`,可为空
+ - 元数据:`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` 建立更强绑定。
+- 多票据关联时,如果用户中途取消对话,本轮仍保留为未关联,避免误标。
diff --git a/document/development/receipt-folder/TODO.md b/document/development/receipt-folder/TODO.md
new file mode 100644
index 0000000..d9d21d1
--- /dev/null
+++ b/document/development/receipt-folder/TODO.md
@@ -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: 测试方案]
diff --git a/server/src/app/api/pagination.py b/server/src/app/api/pagination.py
new file mode 100644
index 0000000..4904b9b
--- /dev/null
+++ b/server/src/app/api/pagination.py
@@ -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,
+ }
diff --git a/server/src/app/api/v1/endpoints/agent_assets.py b/server/src/app/api/v1/endpoints/agent_assets.py
index ee376a0..723a3c6 100644
--- a/server/src/app/api/v1/endpoints/agent_assets.py
+++ b/server/src/app/api/v1/endpoints/agent_assets.py
@@ -14,6 +14,7 @@ from app.api.deps import (
require_rule_editor_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.schemas.agent_asset import (
AgentAssetCreate,
@@ -43,7 +44,7 @@ from app.schemas.agent_asset import (
AgentAssetVersionRead,
AgentAssetVersionTimelineItemRead,
)
-from app.schemas.common import ErrorResponse
+from app.schemas.common import ErrorResponse, PaginatedResponse
from app.services.agent_assets import AgentAssetService
from app.services.risk_rule_generation_jobs import RiskRuleGenerationJobService
@@ -94,7 +95,7 @@ def _complete_risk_rule_generation_task(
@router.get(
"",
- response_model=list[AgentAssetListItem],
+ response_model=list[AgentAssetListItem] | PaginatedResponse[AgentAssetListItem],
summary="查询 Agent 资产列表",
description="按资产类型、状态、领域和关键字筛选规则、技能、MCP 与任务资产。",
)
@@ -116,8 +117,22 @@ def list_agent_assets(
str | None,
Query(description="资产编码、名称关键字模糊查询。"),
] = None,
-) -> list[AgentAssetListItem]:
- return AgentAssetService(db).list_assets(
+ page: PageNumber = None,
+ 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,
status=status_value,
domain=domain,
diff --git a/server/src/app/api/v1/endpoints/budgets.py b/server/src/app/api/v1/endpoints/budgets.py
index c91ed2e..49dd580 100644
--- a/server/src/app/api/v1/endpoints/budgets.py
+++ b/server/src/app/api/v1/endpoints/budgets.py
@@ -4,8 +4,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, or_, select
-from sqlalchemy.orm import selectinload
-from sqlalchemy.orm import Session
+from sqlalchemy.orm import Session, selectinload
from app.api.deps import (
CurrentUserContext,
@@ -15,8 +14,9 @@ from app.api.deps import (
require_budget_editor_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.employee import Employee
from app.schemas.budget import (
BudgetAllocationCreate,
BudgetAllocationRead,
@@ -27,7 +27,7 @@ from app.schemas.budget import (
BudgetSummaryRead,
BudgetTransactionRead,
)
-from app.schemas.common import ErrorResponse
+from app.schemas.common import ErrorResponse, PaginatedResponse
from app.services.budget import BudgetControlError, BudgetService
router = APIRouter(prefix="/budgets")
@@ -67,7 +67,7 @@ def get_budget_summary(
@router.get(
"/allocations",
- response_model=list[BudgetAllocationRead],
+ response_model=list[BudgetAllocationRead] | PaginatedResponse[BudgetAllocationRead],
summary="查询预算额度列表",
)
def list_budget_allocations(
@@ -78,7 +78,9 @@ def list_budget_allocations(
department_id: str | None = None,
department_name: 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(
db,
current_user,
@@ -86,7 +88,18 @@ def list_budget_allocations(
department_name=department_name,
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,
period_key=period_key,
**scope,
@@ -119,20 +132,30 @@ def create_budget_allocation(
@router.get(
"/allocations/{allocation_id}/transactions",
- response_model=list[BudgetTransactionRead],
+ response_model=list[BudgetTransactionRead] | PaginatedResponse[BudgetTransactionRead],
summary="读取预算交易台账",
)
def list_budget_transactions(
allocation_id: str,
db: DbSession,
current_user: BudgetViewer,
-) -> list[BudgetTransactionRead]:
- allocation = BudgetService(db).get_allocation_row(allocation_id)
+ page: PageNumber = None,
+ page_size: PageSize = None,
+) -> list[BudgetTransactionRead] | PaginatedResponse[BudgetTransactionRead]:
+ service = BudgetService(db)
+ allocation = service.get_allocation_row(allocation_id)
if allocation is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="预算额度不存在。")
if not _allocation_visible_to_user(db, current_user, allocation):
- raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能查看其他部门预算流水。")
- return BudgetService(db).list_transactions(allocation_id)
+ raise HTTPException(
+ 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(
diff --git a/server/src/app/api/v1/endpoints/employees.py b/server/src/app/api/v1/endpoints/employees.py
index 7e834be..0adca17 100644
--- a/server/src/app/api/v1/endpoints/employees.py
+++ b/server/src/app/api/v1/endpoints/employees.py
@@ -7,7 +7,8 @@ from fastapi.responses import Response
from sqlalchemy.orm import Session
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 (
EmployeeCreate,
EmployeeImportResultRead,
@@ -16,6 +17,7 @@ from app.schemas.employee import (
EmployeeUpdate,
)
from app.services.employee import EmployeeService
+from app.services.employee_pagination import EmployeePaginationService
router = APIRouter()
DbSession = Annotated[Session, Depends(get_db)]
@@ -33,7 +35,7 @@ def get_employee_meta(db: DbSession) -> EmployeeMetaRead:
@router.get(
"",
- response_model=list[EmployeeRead],
+ response_model=list[EmployeeRead] | PaginatedResponse[EmployeeRead],
summary="查询员工列表",
description="按状态和关键字筛选员工目录。",
)
@@ -47,7 +49,18 @@ def list_employees(
str | None,
Query(description="姓名、工号、邮箱等关键字模糊查询。"),
] = 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)
diff --git a/server/src/app/api/v1/endpoints/reimbursements.py b/server/src/app/api/v1/endpoints/reimbursements.py
index d76e54b..6f52629 100644
--- a/server/src/app/api/v1/endpoints/reimbursements.py
+++ b/server/src/app/api/v1/endpoints/reimbursements.py
@@ -7,8 +7,9 @@ from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
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.common import ErrorResponse
+from app.schemas.common import ErrorResponse, PaginatedResponse
from app.schemas.reimbursement import (
ExpenseClaimAttachmentActionResponse,
ExpenseClaimActionResponse,
@@ -25,8 +26,8 @@ from app.schemas.reimbursement import (
TravelReimbursementCalculatorRequest,
TravelReimbursementCalculatorResponse,
)
-from app.services.expense_claims import ExpenseClaimService
from app.services.budget import BudgetService
+from app.services.expense_claims import ExpenseClaimService
from app.services.reimbursement import ReimbursementService
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
@@ -37,12 +38,19 @@ CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
@router.get(
"",
- response_model=list[ReimbursementRead],
+ response_model=list[ReimbursementRead] | PaginatedResponse[ReimbursementRead],
summary="查询报销申请列表",
description="返回当前系统中的报销申请列表。",
)
-def list_reimbursements(db: DbSession) -> list[ReimbursementRead]:
- return ReimbursementService(db).list_reimbursements()
+def 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(
@@ -81,32 +89,60 @@ def calculate_travel_reimbursement(
@router.get(
"/claims",
- response_model=list[ExpenseClaimRead],
+ response_model=list[ExpenseClaimRead] | PaginatedResponse[ExpenseClaimRead],
summary="查询个人报销单列表",
description="返回当前登录用户可见的真实个人报销单据列表。",
)
-def list_expense_claims(db: DbSession, current_user: CurrentUser) -> list[ExpenseClaimRead]:
- return ExpenseClaimService(db).list_claims(current_user)
+def list_expense_claims(
+ 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(
"/claims/approvals",
- response_model=list[ExpenseClaimRead],
+ response_model=list[ExpenseClaimRead] | PaginatedResponse[ExpenseClaimRead],
summary="查询当前用户审批待办报销单列表",
description="返回当前登录用户有权处理的待审批报销单据,不混入个人报销列表。",
)
-def list_expense_claim_approvals(db: DbSession, current_user: CurrentUser) -> list[ExpenseClaimRead]:
- return ExpenseClaimService(db).list_approval_claims(current_user)
+def list_expense_claim_approvals(
+ 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(
"/claims/archives",
- response_model=list[ExpenseClaimRead],
+ response_model=list[ExpenseClaimRead] | PaginatedResponse[ExpenseClaimRead],
summary="查询归档中心报销单列表",
description="返回公司已归档入账的报销单据,供财务与审计角色集中查阅。",
)
-def list_archived_expense_claims(db: DbSession, current_user: CurrentUser) -> list[ExpenseClaimRead]:
- return ExpenseClaimService(db).list_archived_claims(current_user)
+def list_archived_expense_claims(
+ 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(
diff --git a/server/src/app/repositories/agent_asset.py b/server/src/app/repositories/agent_asset.py
index 2237884..75b4921 100644
--- a/server/src/app/repositories/agent_asset.py
+++ b/server/src/app/repositories/agent_asset.py
@@ -9,20 +9,21 @@ from app.models.agent_asset import (
AgentAssetTestRun,
AgentAssetVersion,
)
+from app.services.pagination import PageResult, paginate_select
class AgentAssetRepository:
def __init__(self, db: Session) -> None:
self.db = db
- def list(
+ def _list_stmt(
self,
*,
asset_type: str | None = None,
status: str | None = None,
domain: str | None = None,
keyword: str | None = None,
- ) -> list[AgentAsset]:
+ ):
stmt = select(AgentAsset)
if asset_type:
@@ -42,8 +43,42 @@ class AgentAssetRepository:
)
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())
+ 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:
return self.db.get(AgentAsset, asset_id)
diff --git a/server/src/app/repositories/employee.py b/server/src/app/repositories/employee.py
index a26ad31..62e7291 100644
--- a/server/src/app/repositories/employee.py
+++ b/server/src/app/repositories/employee.py
@@ -6,13 +6,14 @@ from sqlalchemy.orm import Session, selectinload
from app.models.employee import Employee
from app.models.organization import OrganizationUnit
from app.models.role import Role
+from app.services.pagination import PageResult, paginate_select
class EmployeeRepository:
def __init__(self, db: Session) -> None:
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 = (
select(Employee)
.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())
+ 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:
stmt = (
select(Employee)
diff --git a/server/src/app/repositories/reimbursement.py b/server/src/app/repositories/reimbursement.py
index f29f4a8..9b9516a 100644
--- a/server/src/app/repositories/reimbursement.py
+++ b/server/src/app/repositories/reimbursement.py
@@ -1,14 +1,27 @@
+from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.reimbursement import ReimbursementRequest
+from app.services.pagination import PageResult, paginate_select
class ReimbursementRepository:
def __init__(self, db: Session) -> None:
self.db = db
+ def _list_stmt(self):
+ return select(ReimbursementRequest).order_by(ReimbursementRequest.created_at.desc())
+
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:
return self.db.query(ReimbursementRequest).filter(ReimbursementRequest.id == request_id).first()
diff --git a/server/src/app/schemas/common.py b/server/src/app/schemas/common.py
index 965fc77..e9da737 100644
--- a/server/src/app/schemas/common.py
+++ b/server/src/app/schemas/common.py
@@ -1,12 +1,26 @@
from __future__ import annotations
+from typing import Generic, TypeVar
+
from pydantic import BaseModel
+T = TypeVar("T")
+
class ErrorResponse(BaseModel):
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):
message: str
diff --git a/server/src/app/services/agent_assets.py b/server/src/app/services/agent_assets.py
index fd17494..a1c93c0 100644
--- a/server/src/app/services/agent_assets.py
+++ b/server/src/app/services/agent_assets.py
@@ -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_foundation import AgentFoundationService
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
logger = get_logger("app.services.agent_assets")
@@ -75,6 +76,32 @@ class AgentAssetService(
version_stats = self._collect_version_stats(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:
self._ensure_ready()
asset = self.repository.get(asset_id)
diff --git a/server/src/app/services/budget.py b/server/src/app/services/budget.py
index 934a07a..2af453a 100644
--- a/server/src/app/services/budget.py
+++ b/server/src/app/services/budget.py
@@ -21,11 +21,12 @@ from app.schemas.budget import (
BudgetTransactionRead,
)
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_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:
self.db = db
@@ -46,22 +47,13 @@ class BudgetService(BudgetSupportMixin):
cost_center: str | None = None,
) -> list[BudgetAllocationRead]:
self.ensure_budget_ready()
- stmt = select(BudgetAllocation).order_by(
- BudgetAllocation.fiscal_year.desc(),
- BudgetAllocation.period_key.asc(),
- BudgetAllocation.department_name.asc(),
- BudgetAllocation.subject_code.asc(),
- ).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)
+ stmt = self.build_allocation_stmt(
+ fiscal_year=fiscal_year,
+ period_key=period_key,
+ department_id=department_id,
+ department_name=department_name,
+ cost_center=cost_center,
+ )
return [self.serialize_allocation(row) for row in self.db.scalars(stmt).all()]
def get_summary(
diff --git a/server/src/app/services/budget_pagination.py b/server/src/app/services/budget_pagination.py
new file mode 100644
index 0000000..a0c3a53
--- /dev/null
+++ b/server/src/app/services/budget_pagination.py
@@ -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)
diff --git a/server/src/app/services/employee_pagination.py b/server/src/app/services/employee_pagination.py
new file mode 100644
index 0000000..0bfbd1c
--- /dev/null
+++ b/server/src/app/services/employee_pagination.py
@@ -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)
diff --git a/server/src/app/services/expense_claim_pagination.py b/server/src/app/services/expense_claim_pagination.py
new file mode 100644
index 0000000..2d75cf3
--- /dev/null
+++ b/server/src/app/services/expense_claim_pagination.py
@@ -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)
diff --git a/server/src/app/services/expense_claims.py b/server/src/app/services/expense_claims.py
index 850f5e6..8b70ca9 100644
--- a/server/src/app/services/expense_claims.py
+++ b/server/src/app/services/expense_claims.py
@@ -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_persistence import ExpenseClaimDraftPersistenceMixin
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_read_model import ExpenseClaimReadModelMixin
from app.services.expense_claim_review_preview import ExpenseClaimReviewPreviewMixin
@@ -128,6 +129,7 @@ from app.services.ocr import OcrService
class ExpenseClaimService(
+ ExpenseClaimPaginationMixin,
ExpenseClaimApprovalFlowMixin,
ExpenseClaimApplicationHandoffMixin,
ExpenseClaimBudgetFlowMixin,
diff --git a/server/src/app/services/pagination.py b/server/src/app/services/pagination.py
new file mode 100644
index 0000000..6631e7e
--- /dev/null
+++ b/server/src/app/services/pagination.py
@@ -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,
+ )
diff --git a/server/src/app/services/reimbursement.py b/server/src/app/services/reimbursement.py
index 284a367..5aaa1bd 100644
--- a/server/src/app/services/reimbursement.py
+++ b/server/src/app/services/reimbursement.py
@@ -4,6 +4,7 @@ from app.core.logging import get_logger
from app.models.reimbursement import ReimbursementRequest
from app.repositories.reimbursement import ReimbursementRepository
from app.schemas.reimbursement import ReimbursementCreate
+from app.services.pagination import PageResult
logger = get_logger("app.services.reimbursement")
@@ -17,6 +18,22 @@ class ReimbursementService:
logger.info("Listed reimbursements (count=%d)", len(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:
request = self.repository.get(request_id)
if request:
diff --git a/server/tests/test_agent_asset_service.py b/server/tests/test_agent_asset_service.py
index 6a41ae7..7bc0533 100644
--- a/server/tests/test_agent_asset_service.py
+++ b/server/tests/test_agent_asset_service.py
@@ -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
+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:
with build_session() as db:
service = AgentAssetService(db)
diff --git a/server/tests/test_backend_pagination.py b/server/tests/test_backend_pagination.py
new file mode 100644
index 0000000..c01ec15
--- /dev/null
+++ b/server/tests/test_backend_pagination.py
@@ -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
diff --git a/web/index.html b/web/index.html
index 4b1509f..0c8cf8c 100644
--- a/web/index.html
+++ b/web/index.html
@@ -4,7 +4,6 @@
-
ReimburseOps - 企业报销智能运营台
diff --git a/web/package-lock.json b/web/package-lock.json
index 9afab4f..9f5222d 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -12,14 +12,12 @@
"@element-plus/icons-vue": "^2.3.2",
"@vitejs/plugin-vue": "^5.2.4",
"@vueuse/motion": "^3.0.3",
- "chart.js": "^4.5.1",
"echarts": "^6.1.0",
"element-plus": "^2.14.0",
"markdown-it": "^14.1.1",
"pg": "^8.13.1",
"vite": "^5.4.19",
"vue": "^3.5.13",
- "vue-chartjs": "^5.3.3",
"vue-router": "^4.5.1"
}
},
@@ -803,12 +801,6 @@
"@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": {
"version": "3.21.2",
"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": {
"version": "5.0.0",
"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": {
"version": "3.3.2",
"resolved": "https://registry.npmmirror.com/vue-component-type-helpers/-/vue-component-type-helpers-3.3.2.tgz",
diff --git a/web/package.json b/web/package.json
index 64d8ee3..9797d8d 100644
--- a/web/package.json
+++ b/web/package.json
@@ -14,14 +14,12 @@
"@element-plus/icons-vue": "^2.3.2",
"@vitejs/plugin-vue": "^5.2.4",
"@vueuse/motion": "^3.0.3",
- "chart.js": "^4.5.1",
"echarts": "^6.1.0",
"element-plus": "^2.14.0",
"markdown-it": "^14.1.1",
"pg": "^8.13.1",
"vite": "^5.4.19",
"vue": "^3.5.13",
- "vue-chartjs": "^5.3.3",
"vue-router": "^4.5.1"
}
}
diff --git a/web/src/App.vue b/web/src/App.vue
index 6d7a47f..d05744c 100644
--- a/web/src/App.vue
+++ b/web/src/App.vue
@@ -1,8 +1,10 @@
@@ -10,6 +12,8 @@