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 共同使用的经营协同平台。 + 从预算与资源控制来看,未来的预算管控不再是“年初编预算,月底对账单”的静态割裂状态。智能费控通过建立动态预算池与资金控制链,使预算能够在秒级发生响应。这保证了业务部门在项目推进过程中,能实时感知每一笔花费对项目整体毛利空间、部门现金流余量的动态影响,促使人人关注经营效果。

- 从风险控制看,未来公司的费用风险不是单张票据能解释清楚的,而是多源数据的组合问题。 - 一张票据可能合规,但一个人三个月内的频次、金额、地点、客户、同行人和预算占比可能异常。 - 智能费控要把这些信号拉成一张网,让管理者在支付之前就看到风险轮廓。 + 从业务运营与体验来看,智能费控是消除部门隔阂、降低一线行政摩擦的“润滑剂”。当平台实现高度数字化与免报销消费结算时,员工不再需要贴票垫资,管理者不再需要在审批页面盲目点“同意”,财务不再需要枯燥审单,整个公司的运营效率将得到数量级提升。

-
业务动作
-
出差、采购、招待、培训、项目交付
+
业务场景
+
差旅、招待、采购、项目外包、会务支出
-
智能费控平台
-
预算、单据、制度、票据、风险、智能体
+
费用智能操作系统
+
本体语义 · 实时预算 · 智能体 · 仿真沙盘
-
财务结果
-
成本、现金、核销、支付、账务沉淀
+
经营结果
+
成本优化、合规账套、税务风险减免、现金流稳健
-
组织与预算
-
部门、项目、成本中心、预算池
+
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 @@