feat: 新增归档中心页面并完善知识库与报销查询能力

新增前端归档中心视图及相关工具函数,扩充知识库文档分类和
提取器支持多种格式,增强编排器报销查询的多维度检索,优
化本体规则和用户代理审核消息,前端完善报销创建和审批详
情交互细节,补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-22 16:00:19 +08:00
parent 1f15699013
commit 88ff04bef8
120 changed files with 6236 additions and 643 deletions

View File

@@ -0,0 +1,896 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>X-Financial 轻量知识库归集与问答优化开发文档</title>
<style>
:root {
--green-900: #064e3b;
--green-700: #047857;
--green-600: #10b981;
--green-100: #dff8ec;
--green-50: #effcf6;
--blue-700: #1d4ed8;
--blue-50: #eff6ff;
--amber-600: #d97706;
--amber-50: #fffbeb;
--red-600: #dc2626;
--red-50: #fef2f2;
--ink-900: #071124;
--ink-700: #24324a;
--ink-600: #58677f;
--ink-500: #728098;
--line: #dbe5ef;
--line-strong: #c6d4e2;
--surface: #ffffff;
--surface-soft: #f7fafc;
--shadow: 0 10px 26px rgba(15, 23, 42, 0.08);
--radius: 8px;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
background:
linear-gradient(180deg, rgba(239, 252, 246, 0.78), rgba(247, 250, 252, 0.96) 360px),
var(--surface-soft);
color: var(--ink-900);
font-family: "IBM Plex Sans", "Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC", sans-serif;
line-height: 1.62;
letter-spacing: 0;
}
a {
color: inherit;
text-decoration: none;
}
code {
padding: 2px 6px;
border-radius: 6px;
background: rgba(15, 23, 42, 0.06);
color: var(--ink-700);
font-family: "JetBrains Mono", "Cascadia Code", Consolas, monospace;
font-size: 0.92em;
}
.shell {
display: grid;
grid-template-columns: 276px minmax(0, 1fr);
min-height: 100dvh;
}
.sidebar {
position: sticky;
top: 0;
align-self: start;
height: 100dvh;
padding: 28px 22px;
border-right: 1px solid var(--line);
background: rgba(255, 255, 255, 0.86);
backdrop-filter: blur(18px);
overflow: auto;
}
.brand {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 30px;
}
.logo-mark {
display: grid;
width: 42px;
height: 42px;
place-items: center;
border-radius: 8px;
background: linear-gradient(145deg, var(--green-700), var(--green-600));
color: #fff;
font-weight: 800;
box-shadow: 0 10px 18px rgba(16, 185, 129, 0.26);
}
.brand-title {
font-size: 15px;
font-weight: 800;
line-height: 1.2;
}
.brand-subtitle {
margin-top: 4px;
color: var(--ink-500);
font-size: 12px;
}
.nav-label {
margin: 22px 0 8px;
color: var(--ink-500);
font-size: 12px;
font-weight: 700;
}
.nav {
display: grid;
gap: 6px;
}
.nav a {
display: flex;
align-items: center;
min-height: 38px;
padding: 8px 10px;
border-radius: 8px;
color: var(--ink-700);
font-size: 13px;
font-weight: 650;
}
.nav a:hover {
background: var(--green-50);
color: var(--green-900);
}
.nav-dot {
width: 7px;
height: 7px;
margin-right: 10px;
border-radius: 999px;
background: var(--line-strong);
}
main {
min-width: 0;
padding: 34px 42px 56px;
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1.14fr) minmax(300px, 0.86fr);
gap: 24px;
align-items: stretch;
margin-bottom: 24px;
}
.hero-panel,
.metric-panel,
section,
.card {
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
box-shadow: var(--shadow);
}
.hero-panel {
padding: 30px;
border-color: rgba(16, 185, 129, 0.28);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.96), rgba(239, 252, 246, 0.9)),
var(--surface);
}
.kicker {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 4px 10px;
border: 1px solid rgba(16, 185, 129, 0.24);
border-radius: 999px;
background: rgba(16, 185, 129, 0.1);
color: var(--green-900);
font-size: 12px;
font-weight: 800;
}
h1 {
max-width: 780px;
margin: 16px 0 14px;
font-size: 34px;
line-height: 1.18;
letter-spacing: 0;
}
.hero-copy,
.section-desc,
.card p,
.phase span,
.footnote {
color: var(--ink-600);
font-size: 14px;
}
.hero-copy {
max-width: 800px;
margin: 0;
font-size: 15px;
}
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 22px;
}
.pill {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 6px 11px;
border-radius: 999px;
background: #fff;
border: 1px solid var(--line);
color: var(--ink-700);
font-size: 12px;
font-weight: 750;
}
.pill.primary {
background: var(--green-700);
border-color: var(--green-700);
color: #fff;
}
.metric-panel {
display: grid;
gap: 12px;
padding: 18px;
}
.metric {
padding: 14px;
border-radius: 8px;
background: var(--surface-soft);
border: 1px solid var(--line);
}
.metric-label {
color: var(--ink-500);
font-size: 12px;
font-weight: 700;
}
.metric-value {
margin-top: 5px;
font-size: 18px;
line-height: 1.28;
font-weight: 800;
}
section {
margin-top: 18px;
padding: 24px;
}
.section-head {
display: flex;
gap: 18px;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 16px;
}
.section-title {
margin: 0 0 8px;
font-size: 22px;
line-height: 1.25;
}
.section-desc {
max-width: 920px;
margin: 0;
}
.tag {
flex: 0 0 auto;
min-height: 28px;
padding: 4px 10px;
border-radius: 999px;
background: var(--green-50);
color: var(--green-900);
font-size: 12px;
font-weight: 800;
border: 1px solid rgba(16, 185, 129, 0.22);
}
.grid-2,
.grid-3,
.grid-4 {
display: grid;
gap: 14px;
}
.grid-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.grid-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.card {
padding: 17px;
box-shadow: none;
}
.card h3 {
margin: 0 0 10px;
font-size: 16px;
line-height: 1.3;
}
.card p {
margin: 0;
}
.card ul,
.card ol {
margin: 0;
padding-left: 18px;
color: var(--ink-600);
font-size: 14px;
}
.card li + li {
margin-top: 7px;
}
.tone-green {
background: var(--green-50);
border-color: rgba(16, 185, 129, 0.26);
}
.tone-blue {
background: var(--blue-50);
border-color: rgba(37, 99, 235, 0.18);
}
.tone-amber {
background: var(--amber-50);
border-color: rgba(217, 119, 6, 0.2);
}
.tone-red {
background: var(--red-50);
border-color: rgba(220, 38, 38, 0.16);
}
.flow {
display: grid;
gap: 10px;
margin-top: 6px;
}
.flow-row {
display: grid;
grid-template-columns: 132px minmax(0, 1fr);
gap: 12px;
align-items: start;
padding: 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface-soft);
}
.flow-key {
color: var(--green-900);
font-size: 13px;
font-weight: 800;
}
.flow-body {
color: var(--ink-600);
font-size: 14px;
}
.diagram {
padding: 16px;
border: 1px solid var(--line);
border-radius: 8px;
background: #0f172a;
color: #e5eef8;
overflow-x: auto;
font-family: "JetBrains Mono", "Cascadia Code", Consolas, monospace;
font-size: 13px;
line-height: 1.65;
white-space: pre;
}
.timeline {
display: grid;
gap: 12px;
}
.phase {
display: grid;
grid-template-columns: 150px minmax(0, 1fr);
gap: 14px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface-soft);
}
.phase-key {
color: var(--green-900);
font-size: 13px;
font-weight: 850;
}
.phase-body strong {
display: block;
margin-bottom: 4px;
color: var(--ink-900);
font-size: 15px;
}
.checklist {
display: grid;
gap: 10px;
}
.check {
position: relative;
padding: 12px 12px 12px 34px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface-soft);
color: var(--ink-700);
font-size: 14px;
}
.check::before {
content: "";
position: absolute;
left: 13px;
top: 17px;
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--green-600);
}
.link-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 14px;
}
.link-chip {
display: inline-flex;
align-items: center;
min-height: 30px;
padding: 5px 10px;
border: 1px solid var(--line);
border-radius: 999px;
background: #fff;
color: var(--ink-700);
font-size: 12px;
font-weight: 750;
}
.link-chip:hover {
border-color: rgba(16, 185, 129, 0.5);
color: var(--green-900);
}
footer {
margin-top: 22px;
color: var(--ink-500);
font-size: 12px;
text-align: center;
}
@media (max-width: 1080px) {
.shell {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
height: auto;
border-right: 0;
border-bottom: 1px solid var(--line);
}
.hero,
.grid-2,
.grid-3,
.grid-4 {
grid-template-columns: 1fr;
}
main {
padding: 24px 18px 42px;
}
}
@media (max-width: 680px) {
h1 {
font-size: 28px;
}
.section-head,
.phase,
.flow-row {
grid-template-columns: 1fr;
}
.section-head {
display: grid;
}
}
</style>
</head>
<body>
<div class="shell">
<aside class="sidebar">
<div class="brand">
<div class="logo-mark">KB</div>
<div>
<div class="brand-title">轻量知识库归集</div>
<div class="brand-subtitle">LightRAG + Hermes 优化方案</div>
</div>
</div>
<div class="nav-label">文档导航</div>
<nav class="nav">
<a href="#position"><span class="nav-dot"></span>定位与边界</a>
<a href="#architecture"><span class="nav-dot"></span>轻量架构</a>
<a href="#borrow"><span class="nav-dot"></span>Yuxi 借鉴点</a>
<a href="#modules"><span class="nav-dot"></span>模块设计</a>
<a href="#retrieval"><span class="nav-dot"></span>召回与回答</a>
<a href="#delivery"><span class="nav-dot"></span>实施路线</a>
<a href="#quality"><span class="nav-dot"></span>验收标准</a>
</nav>
<div class="nav-label">硬约束</div>
<div class="nav">
<a href="#quality"><span class="nav-dot"></span>不做重平台</a>
<a href="#quality"><span class="nav-dot"></span>证据优先回答</a>
<a href="#quality"><span class="nav-dot"></span>增量任务可追踪</a>
</div>
</aside>
<main>
<div class="hero">
<div class="hero-panel">
<span class="kicker">开发文档 · 先定边界再实现</span>
<h1>X-Financial 轻量知识库归集与问答优化方案</h1>
<p class="hero-copy">
本方案不把 X-Financial 改造成专业知识库平台,而是在现有
<code>LightRAG</code><code>Hermes</code><code>AgentRun</code>
和知识库 UI 上补齐最薄弱的归集、分块、召回和证据回答能力。
Yuxi 只作为成熟设计参考,借鉴其统一解析、分块预设和评估思想。
</p>
<div class="hero-actions">
<span class="pill primary">保留现有 LightRAG</span>
<span class="pill">轻量 Parser</span>
<span class="pill">条款级分块</span>
<span class="pill">混合召回</span>
<span class="pill">证据化回答</span>
</div>
</div>
<div class="metric-panel">
<div class="metric">
<div class="metric-label">核心目标</div>
<div class="metric-value">准、快、可解释</div>
</div>
<div class="metric">
<div class="metric-label">改造范围</div>
<div class="metric-value">归集与召回链路,不重做平台</div>
</div>
<div class="metric">
<div class="metric-label">并发预期</div>
<div class="metric-value">5-10 用户查询可降级、有上限</div>
</div>
</div>
</div>
<section id="position">
<div class="section-head">
<div>
<h2 class="section-title">定位与边界</h2>
<p class="section-desc">
知识库在 X-Financial 中是业务辅助能力,不是独立知识管理产品。
因此实现必须克制:不引入重型多租户平台,不替换现有业务数据模型,
不把知识库 UI 做成复杂后台,只补齐影响问答质量的关键薄层。
</p>
</div>
<span class="tag">轻量优先</span>
</div>
<div class="grid-3">
<div class="card tone-green">
<h3>要解决的问题</h3>
<ul>
<li>Word、PDF、Excel 等文件进入 RAG 前缺少统一结构。</li>
<li>制度类文档如果按普通 chunk 切分,条款容易被切散。</li>
<li>问答质量依赖向量召回,缺少关键词、标题、条款补召回。</li>
<li>效果优化缺少固定评测集,容易靠体感判断。</li>
</ul>
</div>
<div class="card tone-blue">
<h3>保留的现有能力</h3>
<ul>
<li><code>KnowledgeService</code> 继续负责文件库和状态入口。</li>
<li><code>KnowledgeRagService</code> 继续封装 LightRAG 查询和入库。</li>
<li><code>KnowledgeIndexTaskManager</code> 继续承接 Hermes 增量任务。</li>
<li>前端知识管理继续保持简单文件夹与文件列表形态。</li>
</ul>
</div>
<div class="card tone-amber">
<h3>明确不做</h3>
<ul>
<li>不整体引入 Yuxi 平台。</li>
<li>不把存储改成 Milvus + Neo4j。</li>
<li>不一次性接入全量 OCR 引擎。</li>
<li>不新增复杂多租户知识库后台。</li>
</ul>
</div>
</div>
</section>
<section id="architecture">
<div class="section-head">
<div>
<h2 class="section-title">轻量架构</h2>
<p class="section-desc">
新增能力只放在 LightRAG 前后两侧:前侧负责把文件变成稳定 Markdown 和业务友好 chunk
后侧负责混合召回、证据重排和可靠回答。LightRAG 仍是主召回核心。
</p>
</div>
<span class="tag">薄层增强</span>
</div>
<div class="diagram">原始文件
├── docx / pdf / xlsx / pptx / csv / txt
轻量 Parser
├── 统一 Markdown
├── 表格上下文
└── 页码 / sheet / 条款路径
Chunk Preset
├── laws制度条款
├── qa常见问答
└── table表格行组
现有 LightRAG / Qdrant
混合召回
├── LightRAG 语义召回
├── 标题与条款关键词召回
└── 轻量重排 top 3-5
证据化回答
├── 命中证据
├── 直接结论
└── 缺失信息说明</div>
</section>
<section id="borrow">
<div class="section-head">
<div>
<h2 class="section-title">Yuxi 借鉴点</h2>
<p class="section-desc">
Yuxi 的价值不在于整套平台,而在于成熟的归集分层思想:
文件先解析成 Markdown再按场景分块再索引再评估。
这些思想可以小规模落地到现有服务内。
</p>
</div>
<span class="tag">借鉴而非搬运</span>
</div>
<div class="grid-4">
<div class="card">
<h3>统一 Parser</h3>
<p>学习 Yuxi 把多格式文件统一转 Markdown 的入口设计,但只实现 X-Financial 当前需要的格式。</p>
</div>
<div class="card">
<h3>分块 Preset</h3>
<p>借鉴 RAGFlow-like preset。先做 <code>laws</code><code>qa</code><code>table</code> 三类。</p>
</div>
<div class="card">
<h3>两阶段状态</h3>
<p>内部区分解析和索引。UI 仍可显示简单归纳状态,后台记录真实失败点。</p>
</div>
<div class="card">
<h3>轻量评测</h3>
<p>不做评估平台,只维护 JSON 用例和脚本,持续检查召回与回答质量。</p>
</div>
</div>
<div class="link-list">
<a class="link-chip" href="https://github.com/xerrors/Yuxi">Yuxi README</a>
<a class="link-chip" href="https://github.com/xerrors/Yuxi/blob/main/backend/package/yuxi/plugins/parser/unified.py">Yuxi unified parser</a>
<a class="link-chip" href="https://github.com/xerrors/Yuxi/blob/main/backend/package/yuxi/knowledge/chunking/ragflow_like/presets.py">Yuxi chunk presets</a>
<a class="link-chip" href="https://github.com/xerrors/Yuxi/blob/main/backend/package/yuxi/knowledge/chunking/ragflow_like/parsers/laws.py">Yuxi laws parser</a>
</div>
</section>
<section id="modules">
<div class="section-head">
<div>
<h2 class="section-title">模块设计</h2>
<p class="section-desc">
新增模块必须小而清楚,避免把逻辑继续堆进单个 Service。
单个核心文件控制在 800 行以内,优先按解析、分块、召回、评测拆分。
</p>
</div>
<span class="tag">职责拆分</span>
</div>
<div class="flow">
<div class="flow-row">
<div class="flow-key">knowledge_parser.py</div>
<div class="flow-body">
负责把 docx、pdf、xlsx、csv、txt 等文件转成 Markdown。
输出正文、标题路径、页码、sheet、表头、解析告警。
</div>
</div>
<div class="flow-row">
<div class="flow-key">knowledge_chunking.py</div>
<div class="flow-body">
根据文件夹、文件类型和文档特征选择分块策略。
第一批只实现制度、问答、表格三类。
</div>
</div>
<div class="flow-row">
<div class="flow-key">knowledge_retrieval.py</div>
<div class="flow-body">
在 LightRAG 命中结果外补充关键词、条款标题和文件名召回。
最终输出小而准的证据块。
</div>
</div>
<div class="flow-row">
<div class="flow-key">knowledge_eval.py</div>
<div class="flow-body">
读取轻量评测用例,检查 expected 文件、关键词、证据和答案约束。
用于每次调整参数后的回归验证。
</div>
</div>
</div>
</section>
<section id="retrieval">
<div class="section-head">
<div>
<h2 class="section-title">召回与回答策略</h2>
<p class="section-desc">
目标不是让模型更会猜,而是让系统给模型更可靠的证据。
制度问题优先命中条款,表格问题保留表头与行上下文,回答必须暴露依据和缺失信息。
</p>
</div>
<span class="tag">证据优先</span>
</div>
<div class="grid-3">
<div class="card tone-green">
<h3>召回层</h3>
<ul>
<li>LightRAG 继续提供语义召回。</li>
<li>条款号、标题、文件名、关键词做补召回。</li>
<li>召回候选数量有上限,避免并发下无限扩张。</li>
</ul>
</div>
<div class="card tone-blue">
<h3>重排层</h3>
<ul>
<li>优先保留含问题关键词、标题路径和条款语义的块。</li>
<li>制度类按条款完整度加权。</li>
<li>最终给回答链路 3-5 条高质量证据。</li>
</ul>
</div>
<div class="card tone-amber">
<h3>回答层</h3>
<ul>
<li>能直接基于证据回答时,不强制二次模型整理。</li>
<li>模型只做压缩表达,不凭空补事实。</li>
<li>证据不足时明确说明缺什么。</li>
</ul>
</div>
</div>
</section>
<section id="delivery">
<div class="section-head">
<div>
<h2 class="section-title">实施路线</h2>
<p class="section-desc">
分四步小步交付。每一步都能单独验证,不把解析、索引、召回和评测揉成一次大改。
</p>
</div>
<span class="tag">渐进落地</span>
</div>
<div class="timeline">
<div class="phase">
<div class="phase-key">P0 / 文档落地</div>
<div class="phase-body">
<strong>先明确轻量边界</strong>
<span>完成本文档,确认不做重平台、不替换存储、不一次性引入复杂 OCR。</span>
</div>
</div>
<div class="phase">
<div class="phase-key">P1 / 统一解析</div>
<div class="phase-body">
<strong>补齐文件归集质量</strong>
<span>新增 Parser把 Word、PDF、Excel、CSV、TXT 稳定转为 Markdown并保存解析产物供索引复用。</span>
</div>
</div>
<div class="phase">
<div class="phase-key">P2 / 场景分块</div>
<div class="phase-body">
<strong>提升制度与表格命中率</strong>
<span>实现 laws、qa、table 三类分块。制度按章、节、条、款保留完整语义,表格保留 sheet、表头和行上下文。</span>
</div>
</div>
<div class="phase">
<div class="phase-key">P3 / 混合召回</div>
<div class="phase-body">
<strong>减少答偏和漏召回</strong>
<span>在 LightRAG 命中外补充关键词、条款标题、文件名召回,输出可控数量的证据块。</span>
</div>
</div>
<div class="phase">
<div class="phase-key">P4 / 轻量评测</div>
<div class="phase-body">
<strong>把效果优化变成可回归</strong>
<span>建设 30-50 条远光软件制度风格问答用例,覆盖报销、差旅、发票、预算、税务等高频问题。</span>
</div>
</div>
</div>
</section>
<section id="quality">
<div class="section-head">
<div>
<h2 class="section-title">验收标准</h2>
<p class="section-desc">
验收不只看页面状态,而要看文件是否真实入库、召回是否命中文档依据、
回答是否引用证据,以及并发访问时是否能稳定降级。
</p>
</div>
<span class="tag">真实验证</span>
</div>
<div class="checklist">
<div class="check">Word、PDF、Excel、CSV、TXT 文件能生成可读 Markdown且解析产物可复用。</div>
<div class="check">制度类文件能按章、节、条、款形成相对完整的证据块。</div>
<div class="check">Excel 表格问答能保留 sheet、表头、关键列和业务行上下文。</div>
<div class="check">Hermes 增量任务能区分解析失败、索引失败和归纳失败。</div>
<div class="check">常见制度问答优先返回证据化直接答案,模型超时时仍有可读降级答案。</div>
<div class="check">5-10 个用户同时访问时,查询候选数、重排数、模型调用数都有明确上限。</div>
<div class="check">轻量评测集覆盖至少 30 条问题,并记录命中文件、关键词和答案约束。</div>
<div class="check">不引入 Yuxi 平台级依赖,不改变现有知识库 UI 的主体交互。</div>
</div>
<p class="footnote">
后续实现时,优先在现有定向测试基础上补充 Parser、Chunking、Retrieval 和 Knowledge Eval 的小测试。
后端验证优先在 Docker 容器 <code>x-financial-main</code> 中运行。
</p>
</section>
<footer>
X-Financial 轻量知识库归集与问答优化开发文档 · 放置位置document/development/knowledge-answers/lightweight-knowledge-ingestion-design.html
</footer>
</main>
</div>
</body>
</html>

View File

@@ -97,6 +97,16 @@ def list_expense_claim_approvals(db: DbSession, current_user: CurrentUser) -> li
return ExpenseClaimService(db).list_approval_claims(current_user) return ExpenseClaimService(db).list_approval_claims(current_user)
@router.get(
"/claims/archives",
response_model=list[ExpenseClaimRead],
summary="查询归档中心报销单列表",
description="返回公司已归档入账的报销单据,供财务与审计角色集中查阅。",
)
def list_archived_expense_claims(db: DbSession, current_user: CurrentUser) -> list[ExpenseClaimRead]:
return ExpenseClaimService(db).list_archived_claims(current_user)
@router.get( @router.get(
"/claims/{claim_id}", "/claims/{claim_id}",
response_model=ExpenseClaimRead, response_model=ExpenseClaimRead,

View File

@@ -58,10 +58,12 @@ class UserAgentExpenseQueryRecord(BaseModel):
occurred_at: str = Field(default="", description="业务发生日期。") occurred_at: str = Field(default="", description="业务发生日期。")
reason: str = Field(default="", description="事由。") reason: str = Field(default="", description="事由。")
location: str = Field(default="", description="地点。") location: str = Field(default="", description="地点。")
risk_flags: list[dict[str, Any]] = Field(default_factory=list, description="该单据当前风险项。")
class UserAgentQueryPayload(BaseModel): class UserAgentQueryPayload(BaseModel):
result_type: str = Field(default="expense_claim_list", description="结构化查询结果类型。") result_type: str = Field(default="expense_claim_list", description="结构化查询结果类型。")
title: str = Field(default="", description="查询结果标题。")
scope_label: str = Field(default="报销单", description="当前查询范围名。") scope_label: str = Field(default="报销单", description="当前查询范围名。")
recent_window_applied: bool = Field(default=False, description="是否应用了近 10 日窗口。") recent_window_applied: bool = Field(default=False, description="是否应用了近 10 日窗口。")
window_days: int | None = Field(default=None, ge=1, description="近 N 日窗口天数。") window_days: int | None = Field(default=None, ge=1, description="近 N 日窗口天数。")
@@ -69,6 +71,7 @@ class UserAgentQueryPayload(BaseModel):
window_end_date: str | None = Field(default=None, description="近 N 日窗口结束日期。") window_end_date: str | None = Field(default=None, description="近 N 日窗口结束日期。")
record_count: int = Field(default=0, ge=0, description="当前展示范围内的单据数。") record_count: int = Field(default=0, ge=0, description="当前展示范围内的单据数。")
preview_count: int = Field(default=0, ge=0, description="当前返回的单据数。") preview_count: int = Field(default=0, ge=0, description="当前返回的单据数。")
preview_limit: int = Field(default=5, ge=1, description="默认展示条数上限。")
older_record_count: int = Field(default=0, ge=0, description="超出近 10 日窗口的单据数。") older_record_count: int = Field(default=0, ge=0, description="超出近 10 日窗口的单据数。")
has_more_in_window: bool = Field(default=False, description="当前展示范围内是否还有更多单据未返回。") has_more_in_window: bool = Field(default=False, description="当前展示范围内是否还有更多单据未返回。")
total_amount: float = Field(default=0.0, ge=0.0, description="当前展示范围内金额合计。") total_amount: float = Field(default=0.0, ge=0.0, description="当前展示范围内金额合计。")
@@ -122,6 +125,7 @@ class UserAgentReviewDocumentCard(BaseModel):
avg_score: float = Field(default=0.0, ge=0.0, le=1.0, description="OCR 平均得分。") avg_score: float = Field(default=0.0, ge=0.0, le=1.0, description="OCR 平均得分。")
preview_kind: str = Field(default="", description="票据预览类型,例如 image。") preview_kind: str = Field(default="", description="票据预览类型,例如 image。")
preview_data_url: str = Field(default="", description="票据预览图片 data URL。") preview_data_url: str = Field(default="", description="票据预览图片 data URL。")
preview_url: str = Field(default="", description="票据预览图片地址。")
warnings: list[str] = Field(default_factory=list, description="该票据的识别提示。") warnings: list[str] = Field(default_factory=list, description="该票据的识别提示。")
fields: list[UserAgentReviewDocumentField] = Field( fields: list[UserAgentReviewDocumentField] = Field(
default_factory=list, default_factory=list,

View File

@@ -93,6 +93,12 @@ class AgentConversationService:
if existing_session_type != incoming_session_type: if existing_session_type != incoming_session_type:
normalized_id = "" normalized_id = ""
conversation = None conversation = None
if conversation is not None and self._has_draft_claim_scope_conflict(
conversation,
incoming_draft_claim_id,
):
normalized_id = ""
conversation = None
if conversation is None: if conversation is None:
conversation = AgentConversation( conversation = AgentConversation(
@@ -241,6 +247,10 @@ class AgentConversationService:
history_limit: int = 8, history_limit: int = 8,
) -> dict[str, Any]: ) -> dict[str, Any]:
merged = dict(context_json or {}) merged = dict(context_json or {})
incoming_draft_claim_id = self._resolve_draft_claim_id(merged)
if self._has_draft_claim_scope_conflict(conversation, incoming_draft_claim_id):
return merged
state_json = dict(conversation.state_json or {}) state_json = dict(conversation.state_json or {})
should_hydrate_review_flow = self._should_hydrate_review_flow_context( should_hydrate_review_flow = self._should_hydrate_review_flow_context(
context_json=merged, context_json=merged,
@@ -641,6 +651,26 @@ class AgentConversationService:
).strip() ).strip()
return "" return ""
@staticmethod
def _resolve_conversation_draft_claim_id(conversation: AgentConversation) -> str:
state_json = dict(conversation.state_json or {})
return str(
conversation.draft_claim_id
or state_json.get("draft_claim_id")
or ""
).strip()
@staticmethod
def _has_draft_claim_scope_conflict(
conversation: AgentConversation,
incoming_draft_claim_id: str | None,
) -> bool:
incoming_claim_id = str(incoming_draft_claim_id or "").strip()
if not incoming_claim_id:
return False
existing_claim_id = AgentConversationService._resolve_conversation_draft_claim_id(conversation)
return bool(existing_claim_id and existing_claim_id != incoming_claim_id)
@staticmethod @staticmethod
def _merge_state_json( def _merge_state_json(
current_state: dict[str, Any] | None, current_state: dict[str, Any] | None,

View File

@@ -13,8 +13,10 @@ from app.models.organization import OrganizationUnit
PRIVILEGED_CLAIM_ROLE_CODES = {"finance", "executive"} PRIVILEGED_CLAIM_ROLE_CODES = {"finance", "executive"}
ARCHIVE_CENTER_ROLE_CODES = {"finance", "executive", "auditor"}
APPROVAL_VISIBLE_CLAIM_ROLE_CODES = {"manager", "approver"} APPROVAL_VISIBLE_CLAIM_ROLE_CODES = {"manager", "approver"}
CLAIM_DELETE_ROLE_CODES = {"executive"} CLAIM_DELETE_ROLE_CODES = {"executive"}
ARCHIVED_CLAIM_STATUSES = ("approved", "completed", "paid")
class ExpenseClaimAccessPolicy: class ExpenseClaimAccessPolicy:
@@ -27,6 +29,30 @@ class ExpenseClaimAccessPolicy:
return True return True
return bool(ExpenseClaimAccessPolicy.normalize_role_codes(current_user) & PRIVILEGED_CLAIM_ROLE_CODES) return bool(ExpenseClaimAccessPolicy.normalize_role_codes(current_user) & PRIVILEGED_CLAIM_ROLE_CODES)
@staticmethod
def has_archive_center_access(current_user: CurrentUserContext) -> bool:
if current_user.is_admin:
return True
return bool(ExpenseClaimAccessPolicy.normalize_role_codes(current_user) & ARCHIVE_CENTER_ROLE_CODES)
@staticmethod
def build_archived_claim_condition() -> Any:
normalized_status = func.lower(func.coalesce(ExpenseClaim.status, ""))
stage = func.coalesce(ExpenseClaim.approval_stage, "")
return or_(
stage == "归档入账",
stage == "completed",
and_(
normalized_status.in_(ARCHIVED_CLAIM_STATUSES),
or_(
stage == "",
stage.is_(None),
stage == "归档入账",
stage == "completed",
),
),
)
@staticmethod @staticmethod
def has_claim_delete_access(current_user: CurrentUserContext) -> bool: def has_claim_delete_access(current_user: CurrentUserContext) -> bool:
if current_user.is_admin: if current_user.is_admin:
@@ -374,7 +400,16 @@ class ExpenseClaimAccessPolicy:
include_approval_scope: bool = False, include_approval_scope: bool = False,
) -> Any: ) -> Any:
if self.has_privileged_claim_access(current_user): if self.has_privileged_claim_access(current_user):
return stmt owned_conditions = self.build_personal_claim_conditions(current_user)
archived_condition = self.build_archived_claim_condition()
if owned_conditions:
return stmt.where(
or_(
~archived_condition,
and_(archived_condition, or_(*owned_conditions)),
)
)
return stmt.where(~archived_condition)
conditions = self.build_personal_claim_conditions(current_user) conditions = self.build_personal_claim_conditions(current_user)
@@ -386,6 +421,12 @@ class ExpenseClaimAccessPolicy:
return stmt.where(or_(*conditions)) return stmt.where(or_(*conditions))
def apply_archived_claim_scope(self, stmt: Any, current_user: CurrentUserContext) -> Any:
if not self.has_archive_center_access(current_user):
return stmt.where(ExpenseClaim.id == "__no_visible_claim__")
return stmt.where(self.build_archived_claim_condition())
@staticmethod @staticmethod
def resolve_claim_manager_name(claim: ExpenseClaim) -> str: def resolve_claim_manager_name(claim: ExpenseClaim) -> str:
if claim.employee is not None: if claim.employee is not None:

View File

@@ -615,7 +615,7 @@ class ExpenseClaimAttachmentAnalysisMixin:
severity = "high" severity = "high"
label = "高风险" label = "高风险"
headline = "AI提示住宿金额超出报销标准" headline = "AI提示住宿金额超出报销标准"
summary = "当前住宿票据金额超过规则中心差旅住宿标准,强行提交前需补充超标原因。" summary = "当前住宿票据金额超过规则中心差旅住宿标准,已作为风险项保留在单据中;如需按特殊情况提交,请补充超标原因。"
elif ( elif (
line_count == 0 line_count == 0
or not compact_text or not compact_text

View File

@@ -169,6 +169,19 @@ class ExpenseClaimService(
stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user) stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user)
return list(self.db.scalars(stmt).all()) return list(self.db.scalars(stmt).all())
def list_archived_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
stmt = (
select(ExpenseClaim)
.options(
selectinload(ExpenseClaim.items),
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
)
.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 list(self.db.scalars(stmt).all())
def get_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None: def get_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None:
stmt = ( stmt = (
select(ExpenseClaim) select(ExpenseClaim)

View File

@@ -106,7 +106,7 @@ class KnowledgeService:
KnowledgeFolderRead( KnowledgeFolderRead(
name=folder_name, name=folder_name,
count=sum(1 for item in documents if item.folder == folder_name), count=sum(1 for item in documents if item.folder == folder_name),
icon="mdi mdi-folder-open" if folder_name == "差旅规范" else "mdi mdi-folder", icon="mdi mdi-folder",
) )
for folder_name in FIXED_KNOWLEDGE_FOLDERS for folder_name in FIXED_KNOWLEDGE_FOLDERS
] ]

View File

@@ -10,6 +10,12 @@ from zipfile import BadZipFile, ZipFile
from app.services.knowledge_constants import IMAGE_EXTENSIONS, TEXT_EXTENSIONS from app.services.knowledge_constants import IMAGE_EXTENSIONS, TEXT_EXTENSIONS
from app.services.knowledge_file_utils import extract_extension from app.services.knowledge_file_utils import extract_extension
MAX_EXTRACTED_XLSX_SHEETS = 12
MAX_EXTRACTED_XLSX_ROWS_PER_SHEET = 300
MAX_EXTRACTED_XLSX_COLUMNS = 40
MAX_EXTRACTED_PPTX_SLIDES = 80
def _read_text_preview(file_path: Path) -> str: def _read_text_preview(file_path: Path) -> str:
encodings = ("utf-8", "utf-8-sig", "gbk") encodings = ("utf-8", "utf-8-sig", "gbk")
for encoding in encodings: for encoding in encodings:
@@ -19,6 +25,7 @@ def _read_text_preview(file_path: Path) -> str:
continue continue
return "当前文本文件编码暂不支持在线解析。" return "当前文本文件编码暂不支持在线解析。"
def _extract_docx_text(file_path: Path) -> str: def _extract_docx_text(file_path: Path) -> str:
try: try:
with ZipFile(file_path) as archive: with ZipFile(file_path) as archive:
@@ -30,6 +37,7 @@ def _extract_docx_text(file_path: Path) -> str:
texts = [node.text.strip() for node in root.iter() if node.tag.endswith("}t") and node.text] texts = [node.text.strip() for node in root.iter() if node.tag.endswith("}t") and node.text]
return "\n".join(texts) return "\n".join(texts)
def _extract_document_text_from_path( def _extract_document_text_from_path(
*, *,
file_path: Path, file_path: Path,
@@ -41,6 +49,20 @@ def _extract_document_text_from_path(
return _normalize_extracted_text(_read_text_preview(file_path)) return _normalize_extracted_text(_read_text_preview(file_path))
if extension == "docx": if extension == "docx":
return _normalize_extracted_text(_extract_docx_text(file_path)) return _normalize_extracted_text(_extract_docx_text(file_path))
if extension == "xlsx":
return _normalize_extracted_text(
_build_xlsx_markdown(
original_name=original_name,
sheets=_extract_xlsx_sheets(file_path),
)
)
if extension == "pptx":
return _normalize_extracted_text(
_build_pptx_markdown(
original_name=original_name,
slides=_extract_pptx_slides(file_path),
)
)
if extension == "pdf": if extension == "pdf":
text = _normalize_extracted_text(_extract_pdf_text(file_path)) text = _normalize_extracted_text(_extract_pdf_text(file_path))
if text: if text:
@@ -62,11 +84,13 @@ def _extract_document_text_from_path(
) )
return "" return ""
def _normalize_extracted_text(text: str) -> str: def _normalize_extracted_text(text: str) -> str:
normalized = str(text or "").replace("\r\n", "\n").replace("\r", "\n") normalized = str(text or "").replace("\r\n", "\n").replace("\r", "\n")
normalized = re.sub(r"\n{3,}", "\n\n", normalized) normalized = re.sub(r"\n{3,}", "\n\n", normalized)
return normalized.strip() return normalized.strip()
def _extract_pdf_text(file_path: Path) -> str: def _extract_pdf_text(file_path: Path) -> str:
pdftotext_bin = shutil.which("pdftotext") pdftotext_bin = shutil.which("pdftotext")
if not pdftotext_bin: if not pdftotext_bin:
@@ -83,6 +107,7 @@ def _extract_pdf_text(file_path: Path) -> str:
return "" return ""
return str(completed.stdout or "") return str(completed.stdout or "")
def _extract_text_with_ocr( def _extract_text_with_ocr(
*, *,
file_path: Path, file_path: Path,
@@ -92,9 +117,7 @@ def _extract_text_with_ocr(
try: try:
from app.services.ocr import OcrService from app.services.ocr import OcrService
result = OcrService().recognize_files( result = OcrService().recognize_files([(original_name, file_path.read_bytes(), mime_type)])
[(original_name, file_path.read_bytes(), mime_type)]
)
except Exception: except Exception:
return "" return ""
@@ -108,6 +131,7 @@ def _extract_text_with_ocr(
parts.append(summary) parts.append(summary)
return "\n\n".join(part for part in parts if part) return "\n\n".join(part for part in parts if part)
def _extract_xlsx_sheets(file_path: Path) -> list[tuple[str, list[list[str]]]]: def _extract_xlsx_sheets(file_path: Path) -> list[tuple[str, list[list[str]]]]:
try: try:
with ZipFile(file_path) as archive: with ZipFile(file_path) as archive:
@@ -182,8 +206,13 @@ def _extract_xlsx_sheets(file_path: Path) -> list[tuple[str, list[list[str]]]]:
value_node = next((item for item in cell if item.tag.endswith("}v")), None) value_node = next((item for item in cell if item.tag.endswith("}v")), None)
if cell_type == "inlineStr": if cell_type == "inlineStr":
text_node = next((item for item in cell.iter() if item.tag.endswith("}t")), None) text_node = next(
row_values.append((text_node.text or "").strip() if text_node is not None else "") (item for item in cell.iter() if item.tag.endswith("}t")),
None,
)
row_values.append(
(text_node.text or "").strip() if text_node is not None else ""
)
continue continue
if value_node is None or value_node.text is None: if value_node is None or value_node.text is None:
@@ -193,7 +222,9 @@ def _extract_xlsx_sheets(file_path: Path) -> list[tuple[str, list[list[str]]]]:
raw_value = value_node.text.strip() raw_value = value_node.text.strip()
if cell_type == "s" and raw_value.isdigit(): if cell_type == "s" and raw_value.isdigit():
index = int(raw_value) index = int(raw_value)
row_values.append(shared_strings[index] if index < len(shared_strings) else raw_value) row_values.append(
shared_strings[index] if index < len(shared_strings) else raw_value
)
else: else:
row_values.append(raw_value) row_values.append(raw_value)
if row_values: if row_values:
@@ -205,6 +236,7 @@ def _extract_xlsx_sheets(file_path: Path) -> list[tuple[str, list[list[str]]]]:
except (BadZipFile, ElementTree.ParseError, KeyError, ValueError): except (BadZipFile, ElementTree.ParseError, KeyError, ValueError):
return [] return []
def _extract_pptx_slides(file_path: Path) -> list[list[str]]: def _extract_pptx_slides(file_path: Path) -> list[list[str]]:
try: try:
with ZipFile(file_path) as archive: with ZipFile(file_path) as archive:
@@ -216,8 +248,91 @@ def _extract_pptx_slides(file_path: Path) -> list[list[str]]:
slides: list[list[str]] = [] slides: list[list[str]] = []
for slide_name in slide_names: for slide_name in slide_names:
root = ElementTree.fromstring(archive.read(slide_name)) root = ElementTree.fromstring(archive.read(slide_name))
texts = [node.text.strip() for node in root.iter() if node.tag.endswith("}t") and node.text] texts = [
node.text.strip()
for node in root.iter()
if node.tag.endswith("}t") and node.text
]
slides.append(texts) slides.append(texts)
return slides return slides
except (BadZipFile, ElementTree.ParseError, KeyError): except (BadZipFile, ElementTree.ParseError, KeyError):
return [] return []
def _build_xlsx_markdown(
*,
original_name: str,
sheets: list[tuple[str, list[list[str]]]],
) -> str:
if not sheets:
return ""
parts = [f"# Excel 工作簿:{original_name}"]
for sheet_index, (sheet_name, rows) in enumerate(sheets[:MAX_EXTRACTED_XLSX_SHEETS], start=1):
visible_rows = [
[_escape_markdown_cell(cell) for cell in row[:MAX_EXTRACTED_XLSX_COLUMNS]]
for row in rows[:MAX_EXTRACTED_XLSX_ROWS_PER_SHEET]
if any(str(cell or "").strip() for cell in row)
]
if not visible_rows:
continue
column_count = max(len(row) for row in visible_rows)
normalized_rows = [row + [""] * (column_count - len(row)) for row in visible_rows]
header = [
cell or f"{column_index + 1}" for column_index, cell in enumerate(normalized_rows[0])
]
parts.append(f"## 工作表 {sheet_index}{sheet_name}")
parts.append(_format_markdown_table(header, normalized_rows[1:]))
parts.append("### 行级检索线索")
for row_number, row in enumerate(normalized_rows[1:], start=2):
pairs = [
f"{header[column_index]}={value}" for column_index, value in enumerate(row) if value
]
if pairs:
parts.append(f"- {sheet_name}{row_number} 行:" + "".join(pairs))
if len(rows) > MAX_EXTRACTED_XLSX_ROWS_PER_SHEET:
parts.append(
f"- {sheet_name} 还有 {len(rows) - MAX_EXTRACTED_XLSX_ROWS_PER_SHEET} 行未展开。"
)
return "\n\n".join(part for part in parts if part).strip()
def _build_pptx_markdown(
*,
original_name: str,
slides: list[list[str]],
) -> str:
if not slides:
return ""
parts = [f"# PowerPoint 演示文稿:{original_name}"]
for slide_index, slide_lines in enumerate(slides[:MAX_EXTRACTED_PPTX_SLIDES], start=1):
lines = [line.strip() for line in slide_lines if str(line or "").strip()]
if not lines:
continue
parts.append(f"## 幻灯片 {slide_index}")
parts.extend(f"- {line}" for line in lines)
if len(slides) > MAX_EXTRACTED_PPTX_SLIDES:
parts.append(f"- 还有 {len(slides) - MAX_EXTRACTED_PPTX_SLIDES} 页未展开。")
return "\n\n".join(part for part in parts if part).strip()
def _format_markdown_table(header: list[str], rows: list[list[str]]) -> str:
table_rows = [header] + rows
separator = ["---"] * len(header)
lines = [
"| " + " | ".join(table_rows[0]) + " |",
"| " + " | ".join(separator) + " |",
]
lines.extend("| " + " | ".join(row) + " |" for row in table_rows[1:])
return "\n".join(lines)
def _escape_markdown_cell(value: str) -> str:
text = str(value or "").replace("\r\n", " ").replace("\r", " ").replace("\n", " ")
text = re.sub(r"\s+", " ", text).strip()
return text.replace("|", "\\|")

View File

@@ -16,6 +16,7 @@ from app.services.knowledge_document_extractors import (
) )
from app.services.knowledge_file_utils import extract_extension, format_size from app.services.knowledge_file_utils import extract_extension, format_size
def build_preview( def build_preview(
entry: dict[str, Any], entry: dict[str, Any],
*, *,
@@ -52,7 +53,9 @@ def build_preview(
subtitle="当前格式暂不支持在线解析预览。", subtitle="当前格式暂不支持在线解析预览。",
stats=[ stats=[
KnowledgePreviewStatRead(label="文件格式", value=extension.upper() or "FILE"), KnowledgePreviewStatRead(label="文件格式", value=extension.upper() or "FILE"),
KnowledgePreviewStatRead(label="文件大小", value=format_size(entry["size_bytes"])), KnowledgePreviewStatRead(
label="文件大小", value=format_size(entry["size_bytes"])
),
KnowledgePreviewStatRead(label="建议操作", value="下载后查看"), KnowledgePreviewStatRead(label="建议操作", value="下载后查看"),
], ],
blocks=[ blocks=[
@@ -68,9 +71,8 @@ def build_preview(
], ],
) )
def _build_text_preview_page(
entry: dict[str, Any], text: str def _build_text_preview_page(entry: dict[str, Any], text: str) -> KnowledgePreviewPageRead:
) -> KnowledgePreviewPageRead:
lines = [line.strip() for line in text.splitlines() if line.strip()] lines = [line.strip() for line in text.splitlines() if line.strip()]
if not lines: if not lines:
lines = ["文件内容为空,或当前文档未提取到可展示文本。"] lines = ["文件内容为空,或当前文档未提取到可展示文本。"]
@@ -92,10 +94,9 @@ def _build_text_preview_page(
blocks=blocks, blocks=blocks,
) )
def _build_xlsx_preview_pages(
entry: dict[str, Any], file_path def _build_xlsx_preview_pages(entry: dict[str, Any], file_path) -> list[KnowledgePreviewPageRead]:
) -> list[KnowledgePreviewPageRead]: sheets = _extract_xlsx_sheets(file_path)
sheets = self._extract_xlsx_sheets(file_path)
if not sheets: if not sheets:
sheets = [("Sheet 1", [["未提取到表格内容。"]])] sheets = [("Sheet 1", [["未提取到表格内容。"]])]
@@ -118,7 +119,9 @@ def _build_xlsx_preview_pages(
stats=[ stats=[
KnowledgePreviewStatRead(label="工作表数量", value=str(sheet_count)), KnowledgePreviewStatRead(label="工作表数量", value=str(sheet_count)),
KnowledgePreviewStatRead(label="预览行数", value=str(len(visible_rows))), KnowledgePreviewStatRead(label="预览行数", value=str(len(visible_rows))),
KnowledgePreviewStatRead(label="文件大小", value=format_size(entry["size_bytes"])), KnowledgePreviewStatRead(
label="文件大小", value=format_size(entry["size_bytes"])
),
], ],
blocks=blocks, blocks=blocks,
) )
@@ -126,10 +129,9 @@ def _build_xlsx_preview_pages(
return preview_pages return preview_pages
def _build_pptx_preview_pages(
entry: dict[str, Any], file_path def _build_pptx_preview_pages(entry: dict[str, Any], file_path) -> list[KnowledgePreviewPageRead]:
) -> list[KnowledgePreviewPageRead]: slides = _extract_pptx_slides(file_path)
slides = self._extract_pptx_slides(file_path)
if not slides: if not slides:
slides = [["未提取到幻灯片文本。"]] slides = [["未提取到幻灯片文本。"]]
@@ -154,4 +156,3 @@ def _build_pptx_preview_pages(
) )
return pages return pages

View File

@@ -114,6 +114,20 @@ class OntologyDetectionMixin:
return "query", 0.24 return "query", 0.24
if any(keyword in compact_query for keyword in DRAFT_KEYWORDS): if any(keyword in compact_query for keyword in DRAFT_KEYWORDS):
return "draft", 0.26 return "draft", 0.26
if scenario == "expense" and "报销" in compact_query and any(
item.type == "expense_type"
and str(item.normalized_value or item.value or "").strip()
for item in entities
) and not any(
keyword in compact_query
for keyword in (
*QUERY_KEYWORDS,
*COMPARE_KEYWORDS,
*EXPLAIN_KEYWORDS,
*RISK_KEYWORDS,
)
):
return "draft", 0.25
if scenario == "expense" and self._is_generic_expense_prompt(compact_query): if scenario == "expense" and self._is_generic_expense_prompt(compact_query):
return "draft", 0.24 return "draft", 0.24
if any(keyword in compact_query for keyword in COMPARE_KEYWORDS): if any(keyword in compact_query for keyword in COMPARE_KEYWORDS):
@@ -220,7 +234,11 @@ class OntologyDetectionMixin:
has_expense_signal = any( has_expense_signal = any(
keyword in compact_query for keyword in EXPENSE_NARRATIVE_KEYWORDS keyword in compact_query for keyword in EXPENSE_NARRATIVE_KEYWORDS
) or "expense_type" in entity_types ) or "expense_type" in entity_types
has_context_signal = bool(time_range.start_date) or "amount" in entity_types has_context_signal = (
bool(time_range.start_date)
or "amount" in entity_types
or ("报销" in compact_query and "expense_type" in entity_types)
)
return has_expense_signal and has_context_signal return has_expense_signal and has_context_signal

View File

@@ -186,7 +186,21 @@ class OntologyExtractionMixin:
if any( if any(
keyword in query keyword in query
for keyword in ("打车", "网约车", "出租车", "车费", "乘车", "用车", "叫车", "车资", "停车费", "过路费") for keyword in (
"打车",
"网约车",
"出租车",
"出租车票",
"车费",
"乘车",
"用车",
"叫车",
"车资",
"的士",
"的士票",
"停车费",
"过路费",
)
): ):
upsert(self._make_entity("expense_type", "交通", "transport", role="filter", confidence=0.9)) upsert(self._make_entity("expense_type", "交通", "transport", role="filter", confidence=0.9))

View File

@@ -137,11 +137,14 @@ EXPENSE_TYPE_KEYWORDS = {
"打车": "transport", "打车": "transport",
"网约车": "transport", "网约车": "transport",
"出租车": "transport", "出租车": "transport",
"出租车票": "transport",
"乘车": "transport", "乘车": "transport",
"乘车费": "transport", "乘车费": "transport",
"用车": "transport", "用车": "transport",
"叫车": "transport", "叫车": "transport",
"车资": "transport", "车资": "transport",
"的士": "transport",
"的士票": "transport",
"停车费": "transport", "停车费": "transport",
"餐费": "meal", "餐费": "meal",
"用餐": "meal", "用餐": "meal",
@@ -180,6 +183,9 @@ EXPENSE_NARRATIVE_KEYWORDS = (
"用车", "用车",
"叫车", "叫车",
"车资", "车资",
"的士",
"的士票",
"出租车票",
"餐费", "餐费",
"吃饭", "吃饭",
"用餐", "用餐",
@@ -232,6 +238,9 @@ STATUS_KEYWORDS = {
"已审批": "approved", "已审批": "approved",
"已通过": "approved", "已通过": "approved",
"已审核": "approved", "已审核": "approved",
"归档": "archived",
"已归档": "archived",
"入账": "archived",
"已入账": "paid", "已入账": "paid",
"已付款": "paid", "已付款": "paid",
"未付款": "unpaid", "未付款": "unpaid",

View File

@@ -17,14 +17,50 @@ from app.schemas.ontology import OntologyParseResult
PRIVILEGED_EXPENSE_QUERY_ROLE_CODES = {"finance"} PRIVILEGED_EXPENSE_QUERY_ROLE_CODES = {"finance"}
SELF_REFERENCE_KEYWORDS = ("我的", "我自己", "本人", "我名下", "给我查", "我提交", "我申请") SELF_REFERENCE_KEYWORDS = ("我的", "我自己", "本人", "我名下", "给我查", "我提交", "我申请")
EXPENSE_QUERY_RECENT_WINDOW_DAYS = 10 EXPENSE_QUERY_RECENT_WINDOW_DAYS = 10
EXPENSE_QUERY_PREVIEW_LIMIT = 20 EXPENSE_QUERY_PREVIEW_LIMIT = 5
EXPENSE_STATUS_LABELS = { EXPENSE_STATUS_LABELS = {
"archived": "归档",
"draft": "草稿", "draft": "草稿",
"supplement": "待补充",
"returned": "已退回",
"submitted": "已提交", "submitted": "已提交",
"review": "审核中", "review": "审核中",
"approved": "已通过", "approved": "已通过",
"rejected": "已驳回", "rejected": "已驳回",
"paid": "已付款", "paid": "归档",
}
EXPENSE_QUERY_STATUS_KEYWORDS = (
(("归档", "已归档", "入账", "已入账", "已付款"), ("archived",)),
(("审批通过", "审核通过", "已通过", "已审核"), ("approved",)),
(("审批中", "审核中", "进行中", "流程中"), ("submitted", "review")),
(("已提交", "提交了"), ("submitted",)),
(("草稿", "待报销", "待提交"), ("draft",)),
(("待补充", "待完善", "退回", "已退回"), ("supplement", "returned")),
(("驳回", "已驳回", "拒绝"), ("rejected",)),
)
EXPENSE_STATUS_ALIASES = {
"归档": "archived",
"已归档": "archived",
"入账": "archived",
"已入账": "archived",
"已付款": "archived",
"已通过": "approved",
"审批通过": "approved",
"审核通过": "approved",
"已审核": "approved",
"审批中": "review",
"审核中": "review",
"进行中": "review",
"已提交": "submitted",
"草稿": "draft",
"待报销": "draft",
"待提交": "draft",
"待补充": "supplement",
"待完善": "supplement",
"已退回": "returned",
"退回": "returned",
"驳回": "rejected",
"已驳回": "rejected",
} }
EXPENSE_STATUS_GROUP_LABELS = { EXPENSE_STATUS_GROUP_LABELS = {
"draft": "草稿", "draft": "草稿",
@@ -33,6 +69,13 @@ EXPENSE_STATUS_GROUP_LABELS = {
"other": "其他状态", "other": "其他状态",
} }
EXPENSE_STATUS_GROUP_ORDER = ("draft", "in_progress", "completed", "other") EXPENSE_STATUS_GROUP_ORDER = ("draft", "in_progress", "completed", "other")
EXPENSE_RISK_LEVEL_LABELS = {
"high": "高风险",
"medium": "中风险",
"warning": "中风险",
"low": "低风险",
"info": "低风险",
}
EXPENSE_TYPE_LABELS = { EXPENSE_TYPE_LABELS = {
"travel": "差旅费", "travel": "差旅费",
"hotel": "住宿费", "hotel": "住宿费",
@@ -95,7 +138,7 @@ class OrchestratorDatabaseQueryBuilder:
total_count = int(self.db.scalar(count_stmt) or 0) total_count = int(self.db.scalar(count_stmt) or 0)
total_amount = float(self.db.scalar(amount_stmt) or 0) total_amount = float(self.db.scalar(amount_stmt) or 0)
recent_window_applied = self._should_limit_expense_query_to_recent_window(ontology) recent_window_applied = self._should_limit_expense_query_to_recent_window(ontology, message)
display_count = total_count display_count = total_count
display_amount = total_amount display_amount = total_amount
older_record_count = 0 older_record_count = 0
@@ -146,12 +189,14 @@ class OrchestratorDatabaseQueryBuilder:
"record_count": display_count, "record_count": display_count,
"total_amount": round(display_amount, 2), "total_amount": round(display_amount, 2),
"scope_label": scope_label, "scope_label": scope_label,
"title": f"最近 {len(preview_claims)}{scope_label}" if preview_claims else f"{scope_label}筛选结果",
"scoped_to_current_user": scoped_to_current_user, "scoped_to_current_user": scoped_to_current_user,
"recent_window_applied": recent_window_applied, "recent_window_applied": recent_window_applied,
"window_days": EXPENSE_QUERY_RECENT_WINDOW_DAYS if recent_window_applied else None, "window_days": EXPENSE_QUERY_RECENT_WINDOW_DAYS if recent_window_applied else None,
"window_start_date": window_start_date, "window_start_date": window_start_date,
"window_end_date": window_end_date, "window_end_date": window_end_date,
"preview_count": len(preview_claims), "preview_count": len(preview_claims),
"preview_limit": EXPENSE_QUERY_PREVIEW_LIMIT,
"older_record_count": older_record_count, "older_record_count": older_record_count,
"records": [ "records": [
self._build_expense_query_record(claim) self._build_expense_query_record(claim)
@@ -199,6 +244,7 @@ class OrchestratorDatabaseQueryBuilder:
@staticmethod @staticmethod
def _should_limit_expense_query_to_recent_window( def _should_limit_expense_query_to_recent_window(
ontology: OntologyParseResult, ontology: OntologyParseResult,
message: str = "",
) -> bool: ) -> bool:
has_explicit_claim_no = any( has_explicit_claim_no = any(
item.type == "expense_claim" item.type == "expense_claim"
@@ -208,7 +254,12 @@ class OrchestratorDatabaseQueryBuilder:
has_explicit_time_range = bool( has_explicit_time_range = bool(
ontology.time_range.start_date or ontology.time_range.end_date ontology.time_range.start_date or ontology.time_range.end_date
) )
return not has_explicit_claim_no and not has_explicit_time_range compact_message = str(message or "").replace(" ", "")
asks_recent_window = any(
keyword in compact_message
for keyword in ("", "最近", "本周", "上周", "过去", "前几天", "这几天")
)
return asks_recent_window and not has_explicit_claim_no and not has_explicit_time_range
@staticmethod @staticmethod
def _resolve_reference_now(context_json: dict[str, Any]) -> datetime: def _resolve_reference_now(context_json: dict[str, Any]) -> datetime:
@@ -294,6 +345,12 @@ class OrchestratorDatabaseQueryBuilder:
) -> dict[str, Any]: ) -> dict[str, Any]:
status_group, status_group_label = self._resolve_expense_status_group(claim.status) status_group, status_group_label = self._resolve_expense_status_group(claim.status)
document_datetime = self._resolve_expense_query_document_datetime(claim) document_datetime = self._resolve_expense_query_document_datetime(claim)
approval_stage = str(claim.approval_stage or "").strip()
status_label = (
"已归档"
if "归档" in approval_stage
else EXPENSE_STATUS_LABELS.get(claim.status, claim.status or "处理中")
)
return { return {
"claim_id": claim.id, "claim_id": claim.id,
"claim_no": claim.claim_no, "claim_no": claim.claim_no,
@@ -302,16 +359,63 @@ class OrchestratorDatabaseQueryBuilder:
"expense_type_label": EXPENSE_TYPE_LABELS.get(claim.expense_type, claim.expense_type or "报销"), "expense_type_label": EXPENSE_TYPE_LABELS.get(claim.expense_type, claim.expense_type or "报销"),
"amount": round(float(claim.amount), 2), "amount": round(float(claim.amount), 2),
"status": claim.status, "status": claim.status,
"status_label": EXPENSE_STATUS_LABELS.get(claim.status, claim.status or "处理中"), "status_label": status_label,
"status_group": status_group, "status_group": status_group,
"status_group_label": status_group_label, "status_group_label": status_group_label,
"approval_stage": claim.approval_stage, "approval_stage": approval_stage,
"document_date": document_datetime.date().isoformat() if document_datetime else "", "document_date": document_datetime.date().isoformat() if document_datetime else "",
"occurred_at": claim.occurred_at.date().isoformat() if claim.occurred_at else "", "occurred_at": claim.occurred_at.date().isoformat() if claim.occurred_at else "",
"reason": claim.reason, "reason": claim.reason,
"location": claim.location, "location": claim.location,
"risk_flags": self._normalize_expense_query_risk_flags(claim.risk_flags_json),
} }
@staticmethod
def _normalize_expense_query_risk_flags(raw_flags: Any) -> list[dict[str, str]]:
if not isinstance(raw_flags, list):
return []
normalized_flags: list[dict[str, str]] = []
for index, raw_flag in enumerate(raw_flags, start=1):
if isinstance(raw_flag, dict):
raw_level = str(raw_flag.get("severity") or raw_flag.get("level") or "").strip().lower()
level = raw_level if raw_level in EXPENSE_RISK_LEVEL_LABELS else "medium"
summary = str(
raw_flag.get("message")
or raw_flag.get("summary")
or raw_flag.get("title")
or raw_flag.get("label")
or ""
).strip()
detail = "".join(
str(point or "").strip()
for point in list(raw_flag.get("points") or [])
if str(point or "").strip()
)
title = str(raw_flag.get("label") or EXPENSE_RISK_LEVEL_LABELS[level]).strip()
else:
raw_text = str(raw_flag or "").strip()
if not raw_text:
continue
level = "high" if any(keyword in raw_text for keyword in ("高风险", "超标", "重复", "异常")) else "medium"
summary = raw_text
detail = raw_text
title = EXPENSE_RISK_LEVEL_LABELS[level]
if not summary:
continue
normalized_flags.append(
{
"key": f"risk-{index}",
"level": level,
"level_label": EXPENSE_RISK_LEVEL_LABELS.get(level, "中风险"),
"title": title or EXPENSE_RISK_LEVEL_LABELS.get(level, "中风险"),
"summary": summary,
"detail": detail or summary,
}
)
return normalized_flags
def _build_expense_query_scope( def _build_expense_query_scope(
self, self,
*, *,
@@ -344,12 +448,13 @@ class OrchestratorDatabaseQueryBuilder:
) )
project_values = self._collect_expense_query_filter_values(ontology, "project") project_values = self._collect_expense_query_filter_values(ontology, "project")
location_values = self._collect_expense_query_filter_values(ontology, "location") location_values = self._collect_expense_query_filter_values(ontology, "location")
status_values = list( status_values = self._resolve_expense_query_status_values(
dict.fromkeys( [
str(item.value).strip() str(item.value).strip()
for item in ontology.constraints for item in ontology.constraints
if item.field == "status" and item.operator == "=" and str(item.value).strip() if item.field == "status" and item.operator == "=" and str(item.value).strip()
) ],
message,
) )
amount_constraints = [ amount_constraints = [
item item
@@ -363,8 +468,16 @@ class OrchestratorDatabaseQueryBuilder:
conditions.append(ExpenseClaim.claim_no.in_(expense_claim_nos)) conditions.append(ExpenseClaim.claim_no.in_(expense_claim_nos))
if expense_types: if expense_types:
conditions.append(ExpenseClaim.expense_type.in_(expense_types)) conditions.append(ExpenseClaim.expense_type.in_(expense_types))
if status_values: direct_status_values = [status for status in status_values if status != "archived"]
conditions.append(ExpenseClaim.status.in_(status_values)) if "archived" in status_values:
conditions.append(
or_(
ExpenseClaim.approval_stage.ilike("%归档%"),
ExpenseClaim.status.in_(["approved", "paid"]),
)
)
if direct_status_values:
conditions.append(ExpenseClaim.status.in_(direct_status_values))
if project_values: if project_values:
project_conditions = [] project_conditions = []
for value in project_values: for value in project_values:
@@ -438,7 +551,49 @@ class OrchestratorDatabaseQueryBuilder:
else: else:
scope_label = "全部报销单" scope_label = "全部报销单"
return conditions, scope_label, scoped_to_current_user return conditions, self._compose_expense_scope_label(scope_label, status_values), scoped_to_current_user
@staticmethod
def _resolve_expense_query_status_values(
raw_values: list[str],
message: str,
) -> list[str]:
values: list[str] = []
for raw_value in raw_values:
normalized = str(raw_value or "").strip()
if not normalized:
continue
values.append(EXPENSE_STATUS_ALIASES.get(normalized, normalized))
compact_message = str(message or "").replace(" ", "")
for keywords, statuses in EXPENSE_QUERY_STATUS_KEYWORDS:
if any(keyword in compact_message for keyword in keywords):
values.extend(statuses)
return [
status
for status in dict.fromkeys(values)
if status in EXPENSE_STATUS_LABELS
]
@staticmethod
def _compose_expense_scope_label(scope_label: str, status_values: list[str]) -> str:
normalized_scope = str(scope_label or "").strip() or "报销单"
if not status_values:
return normalized_scope
status_labels = [
EXPENSE_STATUS_LABELS.get(status, status)
for status in status_values
if status in EXPENSE_STATUS_LABELS
]
if not status_labels:
return normalized_scope
status_text = "".join(dict.fromkeys(status_labels))
if "报销单" in normalized_scope:
return normalized_scope.replace("报销单", f"{status_text}报销单")
return f"{normalized_scope}{status_text}"
@staticmethod @staticmethod
def _collect_expense_query_filter_values( def _collect_expense_query_filter_values(

View File

@@ -365,25 +365,13 @@ class UserAgentResponseMixin:
) )
return f"{window_prefix}没有查到{query_payload.scope_label}。你可以补充时间范围、单号或状态继续筛选。" return f"{window_prefix}没有查到{query_payload.scope_label}。你可以补充时间范围、单号或状态继续筛选。"
group_lines = [
f"{item.label} {item.count}"
for item in query_payload.status_groups
if item.count > 0
]
answer_parts = [ answer_parts = [
f"我先为你列出{window_prefix}{query_payload.scope_label}" f"已按你的筛选条件查询{query_payload.scope_label}",
f" {query_payload.record_count} 笔,金额合计 {query_payload.total_amount:.2f}" f"下面先列出最近 {query_payload.preview_count} 条记录,点击任一单据即可查看详情",
f"本次共命中 {query_payload.record_count} 笔,金额合计 {query_payload.total_amount:.2f} 元。",
] ]
if group_lines:
answer_parts.append(f"其中包括:{''.join(group_lines)}")
hint_parts: list[str] = [] hint_parts: list[str] = []
if query_payload.has_more_in_window and query_payload.preview_count < query_payload.record_count:
hint_parts.append(
f"下方先展示最近 {query_payload.preview_count} 笔,你可以直接点击单据查看详情。"
)
elif query_payload.records:
hint_parts.append("下方已列出本次命中的真实单据,可直接点击查看详情。")
if query_payload.older_record_count > 0 and query_payload.window_days: if query_payload.older_record_count > 0 and query_payload.window_days:
hint_parts.append( hint_parts.append(
@@ -448,6 +436,11 @@ class UserAgentResponseMixin:
occurred_at=str(item.get("occurred_at") or "").strip(), occurred_at=str(item.get("occurred_at") or "").strip(),
reason=str(item.get("reason") or "").strip(), reason=str(item.get("reason") or "").strip(),
location=str(item.get("location") or "").strip(), location=str(item.get("location") or "").strip(),
risk_flags=[
flag
for flag in list(item.get("risk_flags") or [])
if isinstance(flag, dict)
],
) )
) )
@@ -466,6 +459,7 @@ class UserAgentResponseMixin:
return UserAgentQueryPayload( return UserAgentQueryPayload(
result_type="expense_claim_list", result_type="expense_claim_list",
scope_label=str(payload.tool_payload.get("scope_label") or self._resolve_subject(payload)).strip() or "报销单", scope_label=str(payload.tool_payload.get("scope_label") or self._resolve_subject(payload)).strip() or "报销单",
title=str(payload.tool_payload.get("title") or "").strip(),
recent_window_applied=bool(payload.tool_payload.get("recent_window_applied")), recent_window_applied=bool(payload.tool_payload.get("recent_window_applied")),
window_days=( window_days=(
int(payload.tool_payload["window_days"]) int(payload.tool_payload["window_days"])
@@ -480,6 +474,7 @@ class UserAgentResponseMixin:
), ),
record_count=max(0, int(payload.tool_payload.get("record_count") or 0)), record_count=max(0, int(payload.tool_payload.get("record_count") or 0)),
preview_count=max(0, int(payload.tool_payload.get("preview_count") or len(records))), preview_count=max(0, int(payload.tool_payload.get("preview_count") or len(records))),
preview_limit=max(1, int(payload.tool_payload.get("preview_limit") or 5)),
older_record_count=max(0, int(payload.tool_payload.get("older_record_count") or 0)), older_record_count=max(0, int(payload.tool_payload.get("older_record_count") or 0)),
has_more_in_window=bool(payload.tool_payload.get("has_more_in_window") or payload.tool_payload.get("has_more")), has_more_in_window=bool(payload.tool_payload.get("has_more_in_window") or payload.tool_payload.get("has_more")),
total_amount=round(float(payload.tool_payload.get("total_amount") or 0), 2), total_amount=round(float(payload.tool_payload.get("total_amount") or 0), 2),
@@ -670,18 +665,7 @@ class UserAgentResponseMixin:
] ]
if payload.ontology.intent in {"query", "compare"}: if payload.ontology.intent in {"query", "compare"}:
return [ return []
UserAgentSuggestedAction(
label="查看明细",
action_type="open_detail",
description="继续查看命中记录和过滤条件。",
),
UserAgentSuggestedAction(
label="生成处理意见",
action_type="create_draft",
description="把当前查询结果整理成可确认草稿。",
),
]
if payload.ontology.intent == "risk_check": if payload.ontology.intent == "risk_check":
return [ return [

View File

@@ -322,6 +322,7 @@ class UserAgentReviewCoreMixin:
avg_score=float(item.get("avg_score") or 0.0), avg_score=float(item.get("avg_score") or 0.0),
preview_kind=str(item.get("preview_kind") or "").strip(), preview_kind=str(item.get("preview_kind") or "").strip(),
preview_data_url=str(item.get("preview_data_url") or "").strip(), preview_data_url=str(item.get("preview_data_url") or "").strip(),
preview_url=str(item.get("preview_url") or "").strip(),
warnings=[str(warning) for warning in item.get("warnings", []) if str(warning).strip()], warnings=[str(warning) for warning in item.get("warnings", []) if str(warning).strip()],
fields=[ fields=[
UserAgentReviewDocumentField( UserAgentReviewDocumentField(
@@ -411,16 +412,26 @@ class UserAgentReviewCoreMixin:
) -> list[UserAgentReviewRiskBrief]: ) -> list[UserAgentReviewRiskBrief]:
briefs: list[UserAgentReviewRiskBrief] = [] briefs: list[UserAgentReviewRiskBrief] = []
for reason in self._resolve_submission_blocked_reasons(payload): for reason in self._resolve_submission_blocked_reasons(payload):
needs_exception_explanation = self._is_submission_exception_explanation_reason(reason)
briefs.append( briefs.append(
UserAgentReviewRiskBrief( UserAgentReviewRiskBrief(
title="提交风险提示", title="提交风险提示",
level=self._resolve_submission_blocked_risk_level(reason), level=self._resolve_submission_blocked_risk_level(reason),
content=reason, content=reason,
detail=( detail=(
"该项属于提交审批前的阻断条件系统会先要求补齐基础字段、附件或业务说明," "该项不是票据归集阻断条件系统会保留费用明细并在详情中标记高风险;"
"否则审批人无法判断成本归属、业务真实性或票据有效性" "继续提交前需要补充特殊情况说明,便于审批人判断例外原因"
if needs_exception_explanation
else (
"该项属于提交审批前的阻断条件。系统会先要求补齐基础字段、附件或业务说明,"
"否则审批人无法判断成本归属、业务真实性或票据有效性。"
)
),
suggestion=(
"请在附加说明中写清超标或例外原因;确认业务真实后可继续提交给审批人重点复核。"
if needs_exception_explanation
else "按提示补齐对应信息;如果业务场景本身合理,请补充说明或佐证附件后再提交。"
), ),
suggestion="按提示补齐对应信息;如果业务场景本身合理,请补充说明或佐证附件后再提交。",
) )
) )
@@ -514,6 +525,16 @@ class UserAgentReviewCoreMixin:
return "high" if any(keyword in normalized for keyword in amount_keywords) else "warning" return "high" if any(keyword in normalized for keyword in amount_keywords) else "warning"
@staticmethod
def _is_submission_exception_explanation_reason(reason: str) -> bool:
normalized = re.sub(r"\s+", "", str(reason or ""))
if not normalized:
return False
has_over_standard = any(keyword in normalized for keyword in ("超标", "超出", "超标准", "差标"))
has_explanation = any(keyword in normalized for keyword in ("说明", "原因", "例外", "特殊"))
return has_over_standard and has_explanation
@staticmethod @staticmethod
def _filter_deprecated_review_risk_briefs( def _filter_deprecated_review_risk_briefs(
briefs: list[UserAgentReviewRiskBrief], briefs: list[UserAgentReviewRiskBrief],

View File

@@ -183,9 +183,9 @@ class UserAgentReviewMessageMixin:
if draft_payload is not None and draft_payload.claim_no: if draft_payload is not None and draft_payload.claim_no:
return ( return (
f"已按您当前确认的信息保存为草稿 {draft_payload.claim_no}" f"已按您当前确认的信息保存为草稿 {draft_payload.claim_no}"
"后续您可以继续补充缺失项,或修改识别结果后再继续提交。" "后续上传附件或补充票据信息时,请关联这张草稿;补齐缺失项后再继续提交。"
) )
return "已按您当前确认的信息保存为草稿。后续您可以继续补充缺失项,或修改识别结果后再继续提交。" return "已按您当前确认的信息保存为草稿。后续上传附件或补充票据信息时,请关联这张草稿;补齐缺失项后再继续提交。"
if review_action == "link_to_existing_draft": if review_action == "link_to_existing_draft":
document_count = self._resolve_review_document_count(payload) document_count = self._resolve_review_document_count(payload)
followup_copy = self._build_review_action_followup_copy(review_payload) followup_copy = self._build_review_action_followup_copy(review_payload)
@@ -214,6 +214,12 @@ class UserAgentReviewMessageMixin:
reason_lines = "\n".join( reason_lines = "\n".join(
f"{index}. {reason}" for index, reason in enumerate(reasons, start=1) f"{index}. {reason}" for index, reason in enumerate(reasons, start=1)
) )
if all(self._is_submission_exception_explanation_reason(reason) for reason in reasons):
return (
"检测到当前单据存在需要说明的超标风险,但票据和费用明细会继续保留在单据中。\n"
f"{reason_lines}\n"
"如果确有特殊情况,请先在附加说明中补充原因;补充后可以继续提交给审批人重点复核。"
)
return ( return (
"AI预审暂未通过所以还没有提交到审批人。\n" "AI预审暂未通过所以还没有提交到审批人。\n"
f"{reason_lines}\n" f"{reason_lines}\n"
@@ -253,6 +259,12 @@ class UserAgentReviewMessageMixin:
blocked_reasons = self._resolve_submission_blocked_reasons(payload) blocked_reasons = self._resolve_submission_blocked_reasons(payload)
if blocked_reasons: if blocked_reasons:
reason_text = "".join(dict.fromkeys(reason.strip("。;;") for reason in blocked_reasons if reason)) reason_text = "".join(dict.fromkeys(reason.strip("。;;") for reason in blocked_reasons if reason))
if all(self._is_submission_exception_explanation_reason(reason) for reason in blocked_reasons):
return (
f"检测到当前单据存在需要说明的超标风险:{reason_text}"
"票据会先正常归集到单据中,并在费用明细前标记风险;"
"如确有特殊情况,请在附加说明中补充原因后继续提交审批。"
)
return ( return (
f"AI预审未通过{reason_text}" f"AI预审未通过{reason_text}"
"请先根据风险提示补充原因、调整金额或更换附件,整改后再继续提交。" "请先根据风险提示补充原因、调整金额或更换附件,整改后再继续提交。"
@@ -670,4 +682,3 @@ class UserAgentReviewMessageMixin:
if not claim_groups: if not claim_groups:
return False return False
return True return True

View File

@@ -0,0 +1,88 @@
{
"file_name": "2月20_武汉-上海.pdf",
"storage_key": "635e6e0f-d4ae-4913-a046-c2340643ea1c/0df374d2-0eeb-4c54-be94-53589dbf0d65/2月20_武汉-上海.pdf",
"media_type": "application/pdf",
"size_bytes": 24995,
"uploaded_at": "2026-05-22T05:00:39.043901+00:00",
"previewable": true,
"preview_kind": "image",
"preview_storage_key": "635e6e0f-d4ae-4913-a046-c2340643ea1c/0df374d2-0eeb-4c54-be94-53589dbf0d65/2月20_武汉-上海.preview.png",
"preview_media_type": "image/png",
"preview_file_name": "2月20_武汉-上海.preview.png",
"analysis": {
"severity": "pass",
"label": "AI提示符合条件",
"headline": "AI提示附件符合基础校验条件",
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
"points": [
"票据类型:已识别为火车/高铁票。",
"附件类型要求:当前费用项目为火车票,已识别为火车/高铁票。",
"金额字段:已识别到与当前明细接近的金额 354.00 元。"
],
"rule_basis": [],
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
},
"document_info": {
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-20 07:55"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26429165800002785705"
},
{
"key": "route",
"label": "行程",
"value": "武汉-上海"
}
]
},
"requirement_check": {
"matches": true,
"current_expense_type": "train_ticket",
"current_expense_type_label": "火车票",
"allowed_scene_labels": [],
"allowed_document_type_labels": [],
"recognized_scene_code": "travel",
"recognized_scene_label": "差旅票据",
"recognized_document_type": "train_ticket",
"recognized_document_type_label": "火车/高铁票",
"mismatch_severity": "high",
"rule_code": "rule.expense.scene_submission_standard",
"rule_name": "报销场景提交与附件标准",
"message": "当前费用项目为火车票,已识别为火车/高铁票。"
},
"ocr_status": "recognized",
"ocr_error": "",
"ocr_text": "电子发票\n铁路电子客票)\n州\n国家税务总局\n发票号码26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"ocr_summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9580968717734019,
"ocr_line_count": 24,
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"ocr_warnings": []
}

View File

@@ -0,0 +1,79 @@
{
"file_name": "酒店3.jpg",
"storage_key": "635e6e0f-d4ae-4913-a046-c2340643ea1c/12aab9cc-fa31-4cde-8935-16ef24fb94a4/酒店3.jpg",
"media_type": "image/jpeg",
"size_bytes": 153582,
"uploaded_at": "2026-05-22T06:05:17.947049+00:00",
"previewable": true,
"preview_kind": "image",
"preview_storage_key": "635e6e0f-d4ae-4913-a046-c2340643ea1c/12aab9cc-fa31-4cde-8935-16ef24fb94a4/酒店3.preview.jpg",
"preview_media_type": "image/jpeg",
"preview_file_name": "酒店3.preview.jpg",
"analysis": {
"severity": "pass",
"label": "AI提示符合条件",
"headline": "AI提示附件符合基础校验条件",
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
"points": [
"票据类型:已识别为酒店住宿票据。",
"附件类型要求:当前费用项目为住宿票,已识别为酒店住宿票据。",
"金额字段:已识别到与当前明细接近的金额 1086.00 元。"
],
"rule_basis": [
"依据《公司差旅费报销规则》v1.0.17),住宿费按员工职级、出差城市和每晚金额进行差标核算。"
],
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
},
"document_info": {
"document_type": "hotel_invoice",
"document_type_label": "酒店住宿票据",
"scene_code": "hotel",
"scene_label": "住宿票据",
"fields": [
{
"key": "amount",
"label": "金额",
"value": "1086元"
},
{
"key": "date",
"label": "日期",
"value": "2026-02-20"
},
{
"key": "merchant_name",
"label": "商户",
"value": "上海喜来登酒店"
}
]
},
"requirement_check": {
"matches": true,
"current_expense_type": "hotel_ticket",
"current_expense_type_label": "住宿票",
"allowed_scene_labels": [],
"allowed_document_type_labels": [],
"recognized_scene_code": "hotel",
"recognized_scene_label": "住宿票据",
"recognized_document_type": "hotel_invoice",
"recognized_document_type_label": "酒店住宿票据",
"mismatch_severity": "high",
"rule_code": "rule.expense.scene_submission_standard",
"rule_name": "报销场景提交与附件标准",
"message": "当前费用项目为住宿票,已识别为酒店住宿票据。"
},
"ocr_status": "recognized",
"ocr_error": "",
"ocr_text": "上海喜来登酒店(样例)\n住宿费用单\n单据编号SH-SAMPLE-20260223-001\n开单期2026年223\n宾客姓名曹笑\n住期2026年220\n离店期2026年223\n住晚数3晚\n房型豪华床房\n房号1808\n项目\n日期\n数量\n单价\n金额\n备注\n住宿费\n2026-02-20至2026-02-22\n3晚\n¥362/晚\n¥1086\n豪华大床房\n金额大写壹仟零捌拾陆元整\n合计¥1086\n备注\n1.如有疑问请致电前台021-28958888。\n2.退房时间为中午1200超时退房将按酒店规定收取相关费用。\n3.感谢您的下榻,期待您的再次光临!\n酒店地址上海市浦东新区银城中路88号 邮编200120\n样例票据|仅供系统测试|无效凭证",
"ocr_summary": "上海喜来登酒店样例住宿费用单单据编号SH-SAMPLE-20260223-001",
"ocr_avg_score": 0.988790222009023,
"ocr_line_count": 30,
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.71,
"ocr_classification_evidence": [
"住宿",
"离店",
"酒店"
],
"ocr_warnings": []
}

View File

@@ -0,0 +1,88 @@
{
"file_name": "2月23_上海-武汉.pdf",
"storage_key": "635e6e0f-d4ae-4913-a046-c2340643ea1c/2014d004-4ef9-4655-8ef0-8f3ad64df2f7/2月23_上海-武汉.pdf",
"media_type": "application/pdf",
"size_bytes": 24940,
"uploaded_at": "2026-05-22T05:01:02.605504+00:00",
"previewable": true,
"preview_kind": "image",
"preview_storage_key": "635e6e0f-d4ae-4913-a046-c2340643ea1c/2014d004-4ef9-4655-8ef0-8f3ad64df2f7/2月23_上海-武汉.preview.png",
"preview_media_type": "image/png",
"preview_file_name": "2月23_上海-武汉.preview.png",
"analysis": {
"severity": "pass",
"label": "AI提示符合条件",
"headline": "AI提示附件符合基础校验条件",
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
"points": [
"票据类型:已识别为火车/高铁票。",
"附件类型要求:当前费用项目为火车票,已识别为火车/高铁票。",
"金额字段:已识别到与当前明细接近的金额 354.00 元。"
],
"rule_basis": [],
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
},
"document_info": {
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-23 13:54"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26319166100006175398"
},
{
"key": "route",
"label": "行程",
"value": "上海-武汉"
}
]
},
"requirement_check": {
"matches": true,
"current_expense_type": "train_ticket",
"current_expense_type_label": "火车票",
"allowed_scene_labels": [],
"allowed_document_type_labels": [],
"recognized_scene_code": "travel",
"recognized_scene_label": "差旅票据",
"recognized_document_type": "train_ticket",
"recognized_document_type_label": "火车/高铁票",
"mismatch_severity": "high",
"rule_code": "rule.expense.scene_submission_standard",
"rule_name": "报销场景提交与附件标准",
"message": "当前费用项目为火车票,已识别为火车/高铁票。"
},
"ocr_status": "recognized",
"ocr_error": "",
"ocr_text": "电子发票\n铁路电子客票\n州\n国家税务总局\n发票号码26319166100006175398\n开票日期:2026年05月18日\n上海市税务局\n上海虹桥站\n武汉站\nG456\nShanghaihongqiao\nWuhan\n2026年02月23日\n13:54开\n12车08B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6610061086021394837402026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"ocr_summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9620026834309101,
"ocr_line_count": 24,
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"ocr_warnings": []
}

View File

@@ -14,8 +14,8 @@
"updated_at": "2026-05-17T09:28:28.999515+00:00", "updated_at": "2026-05-17T09:28:28.999515+00:00",
"uploaded_by": "admin", "uploaded_by": "admin",
"version_number": 1, "version_number": 1,
"ingest_status": 3, "ingest_status": 1,
"ingest_status_updated_at": "2026-05-17T10:01:33.272539+00:00", "ingest_status_updated_at": "2026-05-22T07:04:12.388160+00:00",
"ingest_completed_at": "2026-05-17T10:01:33.272539+00:00", "ingest_completed_at": "2026-05-17T10:01:33.272539+00:00",
"ingest_document_name": "远光《公司支出管理办法2024》.pdf", "ingest_document_name": "远光《公司支出管理办法2024》.pdf",
"ingest_document_updated_at": "2026-05-17T09:28:28.999515+00:00", "ingest_document_updated_at": "2026-05-17T09:28:28.999515+00:00",
@@ -23,25 +23,739 @@
"ingest_agent_run_id": "run_3a0b0ecb941b4c8e" "ingest_agent_run_id": "run_3a0b0ecb941b4c8e"
}, },
{ {
"id": "a8f8465df08e455ebe133351721d49f8", "id": "c7601043d9944ef2bcf4d3f67ed253f7",
"folder": "报销制度", "folder": "财务知识库",
"original_name": "无单需求文档0506.docx", "original_name": "远光软件会计科目使用说明.xlsx",
"stored_name": "a8f8465df08e455ebe133351721d49f8__无单需求文档0506.docx", "stored_name": "远光软件会计科目使用说明.xlsx",
"mime_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"extension": "xlsx",
"size_bytes": 6336,
"sha256": "",
"created_at": "2026-05-22T07:00:22.328877+00:00",
"updated_at": "2026-05-22T07:00:22.328877+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:57.851719+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
},
{
"id": "b0277cd76034437997fbf5219662725a",
"folder": "财务知识库",
"original_name": "远光软件财务基础知识手册.docx",
"stored_name": "远光软件财务基础知识手册.docx",
"mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"extension": "docx", "extension": "docx",
"size_bytes": 454307, "size_bytes": 36653,
"sha256": "00985ec85a8163be9c9ffc5eb522df18ed52d4b131ceed12102c2d75e4df85a9", "sha256": "",
"created_at": "2026-05-17T13:00:09.485818+00:00", "created_at": "2026-05-22T07:00:22.011016+00:00",
"updated_at": "2026-05-17T13:00:09.485818+00:00", "updated_at": "2026-05-22T07:00:22.011016+00:00",
"uploaded_by": "admin", "uploaded_by": "系统导入",
"version_number": 1, "version_number": 1,
"ingest_status": 3, "ingest_status": 1,
"ingest_status_updated_at": "2026-05-21T15:56:58.286585+00:00", "ingest_status_updated_at": "2026-05-22T07:03:57.861469+00:00",
"ingest_completed_at": "2026-05-21T15:56:58.286585+00:00", "ingest_completed_at": "",
"ingest_document_name": "无单需求文档0506.docx", "ingest_document_name": "",
"ingest_document_updated_at": "2026-05-17T13:00:09.485818+00:00", "ingest_document_updated_at": "",
"ingest_document_sha256": "00985ec85a8163be9c9ffc5eb522df18ed52d4b131ceed12102c2d75e4df85a9", "ingest_document_sha256": "",
"ingest_agent_run_id": "run_9f4f60cf545c470f" "ingest_agent_run_id": ""
},
{
"id": "23f56f159a3e4bc3b2338056544120dd",
"folder": "财务知识库",
"original_name": "远光软件财务术语解释手册.docx",
"stored_name": "远光软件财务术语解释手册.docx",
"mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"extension": "docx",
"size_bytes": 36490,
"sha256": "",
"created_at": "2026-05-22T07:00:22.352133+00:00",
"updated_at": "2026-05-22T07:00:22.352133+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:57.870777+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
},
{
"id": "09fbcae74d3b41e498a47e05b45262cb",
"folder": "财务知识库",
"original_name": "远光软件高新技术企业税收优惠政策汇总.pdf",
"stored_name": "远光软件高新技术企业税收优惠政策汇总.pdf",
"mime_type": "application/pdf",
"extension": "pdf",
"size_bytes": 37682,
"sha256": "",
"created_at": "2026-05-22T07:00:22.304623+00:00",
"updated_at": "2026-05-22T07:00:22.304623+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:57.879239+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
},
{
"id": "5fb3c63fbfe244a280cf3316a20150cd",
"folder": "制度政策",
"original_name": "远光软件公司内部控制基本规范.pdf",
"stored_name": "远光软件公司内部控制基本规范.pdf",
"mime_type": "application/pdf",
"extension": "pdf",
"size_bytes": 49972,
"sha256": "",
"created_at": "2026-05-22T07:00:18.153373+00:00",
"updated_at": "2026-05-22T07:00:18.153373+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:57.893729+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
},
{
"id": "f4ae48231a974240bbaf6c9f3bfd4160",
"folder": "制度政策",
"original_name": "远光软件公司合同管理制度.docx",
"stored_name": "远光软件公司合同管理制度.docx",
"mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"extension": "docx",
"size_bytes": 36764,
"sha256": "",
"created_at": "2026-05-22T07:00:18.190399+00:00",
"updated_at": "2026-05-22T07:00:18.190399+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:57.902022+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
},
{
"id": "b1d08d6a9dc6404aba9098f3b7287353",
"folder": "制度政策",
"original_name": "远光软件公司财务管理制度总则.docx",
"stored_name": "远光软件公司财务管理制度总则.docx",
"mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"extension": "docx",
"size_bytes": 36865,
"sha256": "",
"created_at": "2026-05-22T07:00:17.798679+00:00",
"updated_at": "2026-05-22T07:00:17.798679+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:57.907591+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
},
{
"id": "c87fc4aabe524c6c81862c20aabe434c",
"folder": "制度政策",
"original_name": "远光软件公司资产管理制度.pdf",
"stored_name": "远光软件公司资产管理制度.pdf",
"mime_type": "application/pdf",
"extension": "pdf",
"size_bytes": 44978,
"sha256": "",
"created_at": "2026-05-22T07:00:18.531598+00:00",
"updated_at": "2026-05-22T07:00:18.531598+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:57.913293+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
},
{
"id": "13181df0179a4bacb12a2f65e3772d9b",
"folder": "制度政策",
"original_name": "远光软件公司采购管理办法.xlsx",
"stored_name": "远光软件公司采购管理办法.xlsx",
"mime_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"extension": "xlsx",
"size_bytes": 7011,
"sha256": "",
"created_at": "2026-05-22T07:00:18.221073+00:00",
"updated_at": "2026-05-22T07:00:18.221073+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:57.918790+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
},
{
"id": "396588b0cdd04c86a61ae0b9bd04e06c",
"folder": "差旅规范",
"original_name": "远光软件公司差旅费管理办法.docx",
"stored_name": "远光软件公司差旅费管理办法.docx",
"mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"extension": "docx",
"size_bytes": 37028,
"sha256": "",
"created_at": "2026-05-22T07:00:19.734422+00:00",
"updated_at": "2026-05-22T07:00:19.734422+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:57.933936+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
},
{
"id": "fe5f834f94244b77bb62171d580ecee3",
"folder": "差旅规范",
"original_name": "远光软件出差审批流程说明.pdf",
"stored_name": "远光软件出差审批流程说明.pdf",
"mime_type": "application/pdf",
"extension": "pdf",
"size_bytes": 39208,
"sha256": "",
"created_at": "2026-05-22T07:00:20.095824+00:00",
"updated_at": "2026-05-22T07:00:20.095824+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:57.939406+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
},
{
"id": "be3fca61e2be421896405082c93cf86c",
"folder": "差旅规范",
"original_name": "远光软件国际出差管理规定.docx",
"stored_name": "远光软件国际出差管理规定.docx",
"mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"extension": "docx",
"size_bytes": 36502,
"sha256": "",
"created_at": "2026-05-22T07:00:20.128471+00:00",
"updated_at": "2026-05-22T07:00:20.128471+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:57.945004+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
},
{
"id": "c4421b3049b244a8a92cc53d502e530f",
"folder": "差旅规范",
"original_name": "远光软件差旅费标准速查表.xlsx",
"stored_name": "远光软件差旅费标准速查表.xlsx",
"mime_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"extension": "xlsx",
"size_bytes": 6199,
"sha256": "",
"created_at": "2026-05-22T07:00:19.759954+00:00",
"updated_at": "2026-05-22T07:00:19.759954+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:57.950298+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
},
{
"id": "e13cc0a8d6474b6caeeedc49c4304558",
"folder": "发票管理",
"original_name": "远光软件公司发票审核标准.xlsx",
"stored_name": "远光软件公司发票审核标准.xlsx",
"mime_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"extension": "xlsx",
"size_bytes": 7235,
"sha256": "",
"created_at": "2026-05-22T07:00:18.922298+00:00",
"updated_at": "2026-05-22T07:00:18.922298+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:57.958758+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
},
{
"id": "7170abfdde6f4e6abad2fc987564c2cf",
"folder": "发票管理",
"original_name": "远光软件公司发票管理规范.docx",
"stored_name": "远光软件公司发票管理规范.docx",
"mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"extension": "docx",
"size_bytes": 36870,
"sha256": "",
"created_at": "2026-05-22T07:00:18.560177+00:00",
"updated_at": "2026-05-22T07:00:18.560177+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:57.963796+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
},
{
"id": "dd0d7b32e832446e8ce9caa06c442685",
"folder": "发票管理",
"original_name": "远光软件公司增值税发票操作指南.pdf",
"stored_name": "远光软件公司增值税发票操作指南.pdf",
"mime_type": "application/pdf",
"extension": "pdf",
"size_bytes": 45772,
"sha256": "",
"created_at": "2026-05-22T07:00:18.888128+00:00",
"updated_at": "2026-05-22T07:00:18.888128+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:57.968988+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
},
{
"id": "f268a54ee05e4dfca33fd86bcc077216",
"folder": "发票管理",
"original_name": "远光软件公司电子发票管理办法.docx",
"stored_name": "远光软件公司电子发票管理办法.docx",
"mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"extension": "docx",
"size_bytes": 36403,
"sha256": "",
"created_at": "2026-05-22T07:00:18.953110+00:00",
"updated_at": "2026-05-22T07:00:18.953110+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:57.974057+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
},
{
"id": "f3f74cb65a9a4a16933368218c5e25de",
"folder": "税务合规",
"original_name": "远光软件企业所得税汇算清缴操作手册.pdf",
"stored_name": "远光软件企业所得税汇算清缴操作手册.pdf",
"mime_type": "application/pdf",
"extension": "pdf",
"size_bytes": 41933,
"sha256": "",
"created_at": "2026-05-22T07:00:21.585718+00:00",
"updated_at": "2026-05-22T07:00:21.585718+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:57.983136+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
},
{
"id": "56721ca1904b437486a609b85e3d9362",
"folder": "税务合规",
"original_name": "远光软件公司税务管理制度.docx",
"stored_name": "远光软件公司税务管理制度.docx",
"mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"extension": "docx",
"size_bytes": 36753,
"sha256": "",
"created_at": "2026-05-22T07:00:20.881351+00:00",
"updated_at": "2026-05-22T07:00:20.881351+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:57.988449+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
},
{
"id": "2460661167ef456699ab259321db4156",
"folder": "税务合规",
"original_name": "远光软件研发费用加计扣除管理办法.xlsx",
"stored_name": "远光软件研发费用加计扣除管理办法.xlsx",
"mime_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"extension": "xlsx",
"size_bytes": 6027,
"sha256": "",
"created_at": "2026-05-22T07:00:21.606227+00:00",
"updated_at": "2026-05-22T07:00:21.606227+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:57.993925+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
},
{
"id": "e30f54ea32704fbd9701cc931b447a06",
"folder": "税务合规",
"original_name": "远光软件软件产品增值税即征即退操作指南.pdf",
"stored_name": "远光软件软件产品增值税即征即退操作指南.pdf",
"mime_type": "application/pdf",
"extension": "pdf",
"size_bytes": 41919,
"sha256": "",
"created_at": "2026-05-22T07:00:21.202633+00:00",
"updated_at": "2026-05-22T07:00:21.202633+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:57.999215+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
},
{
"id": "2d1cd10154e84cb38640dce31f33b529",
"folder": "预算管理",
"original_name": "远光软件公司预算管理制度.docx",
"stored_name": "远光软件公司预算管理制度.docx",
"mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"extension": "docx",
"size_bytes": 36667,
"sha256": "",
"created_at": "2026-05-22T07:00:22.379307+00:00",
"updated_at": "2026-05-22T07:00:22.379307+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:58.007947+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
},
{
"id": "229b3a79fef14360ba3cbd0a55e5e20c",
"folder": "预算管理",
"original_name": "远光软件年度预算编制指南.pdf",
"stored_name": "远光软件年度预算编制指南.pdf",
"mime_type": "application/pdf",
"extension": "pdf",
"size_bytes": 44848,
"sha256": "",
"created_at": "2026-05-22T07:00:22.760169+00:00",
"updated_at": "2026-05-22T07:00:22.760169+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:58.013550+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
},
{
"id": "a40da5544dea4efcade070274b84a54e",
"folder": "预算管理",
"original_name": "远光软件预算执行分析报告模板.docx",
"stored_name": "远光软件预算执行分析报告模板.docx",
"mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"extension": "docx",
"size_bytes": 36502,
"sha256": "",
"created_at": "2026-05-22T07:00:22.848272+00:00",
"updated_at": "2026-05-22T07:00:22.848272+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:58.019078+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
},
{
"id": "dcd982e40ce94105824e59ecbbae75cb",
"folder": "预算管理",
"original_name": "远光软件预算编制模板.xlsx",
"stored_name": "远光软件预算编制模板.xlsx",
"mime_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"extension": "xlsx",
"size_bytes": 7819,
"sha256": "",
"created_at": "2026-05-22T07:00:22.803708+00:00",
"updated_at": "2026-05-22T07:00:22.803708+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:58.024507+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
},
{
"id": "79cb9276398b4216ba17d5623aadf75f",
"folder": "财务共享",
"original_name": "远光软件财务共享服务SLA标准.xlsx",
"stored_name": "远光软件财务共享服务SLA标准.xlsx",
"mime_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"extension": "xlsx",
"size_bytes": 6007,
"sha256": "",
"created_at": "2026-05-22T07:00:21.971983+00:00",
"updated_at": "2026-05-22T07:00:21.971983+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:58.037116+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
},
{
"id": "f841ca416b5d404994a7c4a310e35569",
"folder": "财务共享",
"original_name": "远光软件财务共享服务中心运营管理办法.docx",
"stored_name": "远光软件财务共享服务中心运营管理办法.docx",
"mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"extension": "docx",
"size_bytes": 36572,
"sha256": "",
"created_at": "2026-05-22T07:00:21.634300+00:00",
"updated_at": "2026-05-22T07:00:21.634300+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:58.045292+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
},
{
"id": "d1ad784de58a4c4a802a0b9fbce29f62",
"folder": "财务共享",
"original_name": "远光软件财务共享服务操作手册.pdf",
"stored_name": "远光软件财务共享服务操作手册.pdf",
"mime_type": "application/pdf",
"extension": "pdf",
"size_bytes": 38186,
"sha256": "",
"created_at": "2026-05-22T07:00:21.945868+00:00",
"updated_at": "2026-05-22T07:00:21.945868+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:58.053890+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
},
{
"id": "ce50d015861f4633a634a2eae416fa2e",
"folder": "培训资料",
"original_name": "远光软件报销流程培训手册.pdf",
"stored_name": "远光软件报销流程培训手册.pdf",
"mime_type": "application/pdf",
"extension": "pdf",
"size_bytes": 47218,
"sha256": "",
"created_at": "2026-05-22T07:00:19.662743+00:00",
"updated_at": "2026-05-22T07:00:19.662743+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:58.066031+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
},
{
"id": "56a0e13b705e49468d46629f3b5f691a",
"folder": "培训资料",
"original_name": "远光软件新员工财务培训课件.pdf",
"stored_name": "远光软件新员工财务培训课件.pdf",
"mime_type": "application/pdf",
"extension": "pdf",
"size_bytes": 50347,
"sha256": "",
"created_at": "2026-05-22T07:00:19.323921+00:00",
"updated_at": "2026-05-22T07:00:19.323921+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:58.073977+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
},
{
"id": "22ef5d13bb5e4307a8097628eaa3d398",
"folder": "培训资料",
"original_name": "远光软件财务制度培训手册.docx",
"stored_name": "远光软件财务制度培训手册.docx",
"mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"extension": "docx",
"size_bytes": 36802,
"sha256": "",
"created_at": "2026-05-22T07:00:18.988700+00:00",
"updated_at": "2026-05-22T07:00:18.988700+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:58.082287+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
},
{
"id": "78d1a28f1c934f46b762fb1466d4be32",
"folder": "培训资料",
"original_name": "远光软件财务培训课程安排.xlsx",
"stored_name": "远光软件财务培训课程安排.xlsx",
"mime_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"extension": "xlsx",
"size_bytes": 6187,
"sha256": "",
"created_at": "2026-05-22T07:00:19.686485+00:00",
"updated_at": "2026-05-22T07:00:19.686485+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:58.089670+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
},
{
"id": "91fbf156593a4dcc956780962195ffd7",
"folder": "常见问答",
"original_name": "远光软件报销问题处理指引.xlsx",
"stored_name": "远光软件报销问题处理指引.xlsx",
"mime_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"extension": "xlsx",
"size_bytes": 6096,
"sha256": "",
"created_at": "2026-05-22T07:00:20.476077+00:00",
"updated_at": "2026-05-22T07:00:20.476077+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:58.101732+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
},
{
"id": "a24793b7f7de4749a7c531d1713a4a2b",
"folder": "常见问答",
"original_name": "远光软件财务制度问答汇总.pdf",
"stored_name": "远光软件财务制度问答汇总.pdf",
"mime_type": "application/pdf",
"extension": "pdf",
"size_bytes": 47165,
"sha256": "",
"created_at": "2026-05-22T07:00:20.453567+00:00",
"updated_at": "2026-05-22T07:00:20.453567+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:58.109771+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
},
{
"id": "3acd9c2df63b4a438c7eab876269b25d",
"folder": "常见问答",
"original_name": "远光软件财务报销常见问题解答.docx",
"stored_name": "远光软件财务报销常见问题解答.docx",
"mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"extension": "docx",
"size_bytes": 36671,
"sha256": "",
"created_at": "2026-05-22T07:00:20.158497+00:00",
"updated_at": "2026-05-22T07:00:20.158497+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:58.117797+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
} }
] ]
} }

View File

@@ -1516,8 +1516,21 @@ def test_upload_hotel_attachment_flags_amount_over_travel_policy(monkeypatch, tm
analysis = uploaded_meta["analysis"] analysis = uploaded_meta["analysis"]
assert analysis["severity"] == "high" assert analysis["severity"] == "high"
assert analysis["headline"] == "AI提示住宿金额超出报销标准" assert analysis["headline"] == "AI提示住宿金额超出报销标准"
assert "保留在单据中" in analysis["summary"]
assert "特殊情况" in analysis["summary"]
assert any("住宿标准" in point and "800.00 元" in point for point in analysis["points"]) assert any("住宿标准" in point and "800.00 元" in point for point in analysis["points"])
assert any("住宿费按员工职级" in basis for basis in analysis["rule_basis"]) assert any("住宿费按员工职级" in basis for basis in analysis["rule_basis"])
db.refresh(claim)
hotel_item = next(item for item in claim.items if str(item.invoice_id or "").strip())
assert hotel_item.item_amount == Decimal("800.00")
assert claim.invoice_count == 1
assert any(
isinstance(flag, dict)
and str(flag.get("source") or "").strip() == "attachment_analysis"
and flag.get("item_id") == hotel_item.id
and str(flag.get("severity") or "").strip() == "high"
for flag in list(claim.risk_flags_json or [])
)
def test_attachment_analysis_does_not_compare_business_purpose_with_ticket_scene() -> None: def test_attachment_analysis_does_not_compare_business_purpose_with_ticket_scene() -> None:
@@ -2433,8 +2446,8 @@ def test_list_claims_allows_finance_to_view_all_records() -> None:
claims = ExpenseClaimService(db).list_claims(current_user) claims = ExpenseClaimService(db).list_claims(current_user)
assert len(claims) == 2 assert len(claims) == 1
assert {claim.claim_no for claim in claims} == {"EXP-FIN-101", "EXP-FIN-102"} assert claims[0].claim_no == "EXP-FIN-101"
def test_list_claims_allows_executive_to_view_all_records() -> None: def test_list_claims_allows_executive_to_view_all_records() -> None:
@@ -2488,8 +2501,134 @@ def test_list_claims_allows_executive_to_view_all_records() -> None:
claims = ExpenseClaimService(db).list_claims(current_user) claims = ExpenseClaimService(db).list_claims(current_user)
assert len(claims) == 2 assert len(claims) == 1
assert {claim.claim_no for claim in claims} == {"EXP-EXE-101", "EXP-EXE-102"} assert claims[0].claim_no == "EXP-EXE-101"
def test_list_claims_keeps_own_archived_claim_for_finance_applicant() -> None:
current_user = CurrentUserContext(
username="finance@example.com",
name="财务",
role_codes=["finance"],
is_admin=False,
)
with build_session() as db:
db.add(
ExpenseClaim(
claim_no="EXP-FIN-OWN-ARCH",
employee_name="财务",
department_name="财务部",
project_code="PRJ-FIN",
expense_type="meal",
reason="本人报销",
location="上海",
amount=Decimal("88.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
status="approved",
approval_stage="归档入账",
risk_flags_json=[],
)
)
db.commit()
claims = ExpenseClaimService(db).list_claims(current_user)
assert len(claims) == 1
assert claims[0].claim_no == "EXP-FIN-OWN-ARCH"
def test_list_archived_claims_returns_company_archived_records_for_finance() -> None:
current_user = CurrentUserContext(
username="finance@example.com",
name="财务",
role_codes=["finance"],
is_admin=False,
)
with build_session() as db:
db.add_all(
[
ExpenseClaim(
claim_no="EXP-ARCH-101",
employee_name="",
department_name="A部",
project_code="PRJ-A",
expense_type="travel",
reason="A 报销",
location="上海",
amount=Decimal("120.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
status="approved",
approval_stage="归档入账",
risk_flags_json=[],
),
ExpenseClaim(
claim_no="EXP-ARCH-102",
employee_name="",
department_name="B部",
project_code="PRJ-B",
expense_type="meal",
reason="B 报销",
location="杭州",
amount=Decimal("300.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 11, 12, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 11, 13, 0, tzinfo=UTC),
status="submitted",
approval_stage="财务审批",
risk_flags_json=[],
),
]
)
db.commit()
claims = ExpenseClaimService(db).list_archived_claims(current_user)
assert len(claims) == 1
assert claims[0].claim_no == "EXP-ARCH-101"
def test_list_archived_claims_is_empty_for_regular_employee() -> None:
current_user = CurrentUserContext(
username="zhangsan@example.com",
name="张三",
role_codes=["employee"],
is_admin=False,
)
with build_session() as db:
db.add(
ExpenseClaim(
claim_no="EXP-ARCH-EMP",
employee_name="张三",
department_name="研发部",
project_code="PRJ-EMP",
expense_type="travel",
reason="本人报销",
location="北京",
amount=Decimal("200.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 10, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 10, 10, 0, tzinfo=UTC),
status="approved",
approval_stage="归档入账",
risk_flags_json=[],
)
)
db.commit()
claims = ExpenseClaimService(db).list_archived_claims(current_user)
assert claims == []
def test_finance_can_return_but_cannot_delete_submitted_claim() -> None: def test_finance_can_return_but_cannot_delete_submitted_claim() -> None:

View File

@@ -0,0 +1,96 @@
from __future__ import annotations
from zipfile import ZipFile
from app.services.knowledge_document_extractors import _extract_document_text_from_path
def test_extract_xlsx_document_text_builds_markdown_with_row_clues(tmp_path) -> None:
file_path = tmp_path / "company-expense-rules.xlsx"
_write_minimal_xlsx(
file_path,
sheet_name="报销标准",
rows=[
["费用类型", "标准", "说明"],
["住宿费", "500", "超标准需事前审批"],
["交通费", "据实", "保留发票"],
],
)
text = _extract_document_text_from_path(
file_path=file_path,
original_name="公司支出管理办法.xlsx",
mime_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
assert "# Excel 工作簿:公司支出管理办法.xlsx" in text
assert "## 工作表 1报销标准" in text
assert "| 费用类型 | 标准 | 说明 |" in text
assert "费用类型=住宿费;标准=500说明=超标准需事前审批" in text
assert "费用类型=交通费;标准=据实;说明=保留发票" in text
def test_extract_pptx_document_text_builds_markdown_slides(tmp_path) -> None:
file_path = tmp_path / "training.pptx"
slide_xml = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld>
<p:spTree>
<p:sp><p:txBody><a:p><a:r><a:t>差旅报销培训</a:t></a:r></a:p></p:txBody></p:sp>
<p:sp><p:txBody><a:p><a:r><a:t>发票、审批、预算三项要素必须齐全</a:t></a:r></a:p></p:txBody></p:sp>
</p:spTree>
</p:cSld>
</p:sld>
"""
with ZipFile(file_path, "w") as archive:
archive.writestr("ppt/slides/slide1.xml", slide_xml)
text = _extract_document_text_from_path(
file_path=file_path,
original_name="报销培训.pptx",
mime_type="application/vnd.openxmlformats-officedocument.presentationml.presentation",
)
assert "# PowerPoint 演示文稿:报销培训.pptx" in text
assert "## 幻灯片 1" in text
assert "- 差旅报销培训" in text
assert "- 发票、审批、预算三项要素必须齐全" in text
def _write_minimal_xlsx(file_path, *, sheet_name: str, rows: list[list[str]]) -> None:
workbook_xml = f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets>
<sheet name="{sheet_name}" sheetId="1" r:id="rId1"/>
</sheets>
</workbook>
"""
rels_xml = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"
Target="worksheets/sheet1.xml"/>
</Relationships>
"""
row_xml = "\n".join(
f'<row r="{row_index}">'
+ "".join(
f'<c r="{chr(65 + column_index)}{row_index}" t="inlineStr"><is><t>{cell}</t></is></c>'
for column_index, cell in enumerate(row)
)
+ "</row>"
for row_index, row in enumerate(rows, start=1)
)
sheet_xml = f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<sheetData>
{row_xml}
</sheetData>
</worksheet>
"""
with ZipFile(file_path, "w") as archive:
archive.writestr("xl/workbook.xml", workbook_xml)
archive.writestr("xl/_rels/workbook.xml.rels", rels_xml)
archive.writestr("xl/worksheets/sheet1.xml", sheet_xml)

View File

@@ -28,6 +28,15 @@ def build_session() -> Session:
return session_factory() return session_factory()
def test_list_library_returns_closed_folder_icons_by_default(tmp_path) -> None:
service = KnowledgeService(storage_root=tmp_path)
library = service.list_library()
assert library.folders
assert {folder.icon for folder in library.folders} == {"mdi mdi-folder"}
def test_reconcile_document_ingest_status_keeps_failed_when_linked_run_failed( def test_reconcile_document_ingest_status_keeps_failed_when_linked_run_failed(
tmp_path, tmp_path,
monkeypatch, monkeypatch,

View File

@@ -534,6 +534,28 @@ def test_semantic_ontology_service_maps_riding_fare_to_transport_expense_type()
) )
def test_semantic_ontology_service_maps_taxi_ticket_reimbursement_to_transport_draft() -> None:
session_factory = build_session_factory()
with session_factory() as db:
result = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="送客户去机场,报销的士票",
user_id="pytest",
)
)
assert result.scenario == "expense"
assert result.intent == "draft"
assert any(
item.type == "expense_type" and item.normalized_value == "transport"
for item in result.entities
)
assert not any(
item.type == "expense_type" and item.normalized_value == "entertainment"
for item in result.entities
)
def test_semantic_ontology_service_uses_model_parse_when_available(monkeypatch) -> None: def test_semantic_ontology_service_uses_model_parse_when_available(monkeypatch) -> None:
session_factory = build_session_factory() session_factory = build_session_factory()
with session_factory() as db: with session_factory() as db:

View File

@@ -228,6 +228,60 @@ def test_conversation_hydration_does_not_reuse_review_type_for_fresh_expense_pro
assert continued_context["review_form_values"]["expense_type"] == "差旅费" assert continued_context["review_form_values"]["expense_type"] == "差旅费"
def test_conversation_scope_creates_new_session_for_different_claim() -> None:
session_factory = build_session_factory()
with session_factory() as db:
service = AgentConversationService(db)
old_conversation = service.get_or_create_conversation(
conversation_id="conv-old-claim-scope",
user_id="emp-scope@example.com",
source="user_message",
context_json={
"session_type": "expense",
"draft_claim_id": "claim-old",
"attachment_names": ["old-hotel.pdf"],
"attachment_count": 1,
"review_form_values": {
"expense_type": "住宿票",
"merchant_name": "旧酒店",
},
},
)
service.append_message(
conversation_id=old_conversation.conversation_id,
role="user",
content="继续补充旧酒店发票",
)
scoped_conversation = service.get_or_create_conversation(
conversation_id=old_conversation.conversation_id,
user_id="emp-scope@example.com",
source="user_message",
context_json={
"session_type": "expense",
"draft_claim_id": "claim-current",
},
)
conflict_context = service.hydrate_context_json(
conversation=old_conversation,
context_json={"draft_claim_id": "claim-current"},
message="继续补充当前单据的火车票",
)
scoped_context = service.hydrate_context_json(
conversation=scoped_conversation,
context_json={"draft_claim_id": "claim-current"},
message="继续补充当前单据的火车票",
)
db.refresh(old_conversation)
assert scoped_conversation.conversation_id != old_conversation.conversation_id
assert scoped_conversation.draft_claim_id == "claim-current"
assert old_conversation.draft_claim_id == "claim-old"
assert conflict_context == {"draft_claim_id": "claim-current"}
assert scoped_context["draft_claim_id"] == "claim-current"
assert scoped_context["conversation_history"] == []
def test_orchestrator_history_query_filters_location_time_and_returns_real_amount( def test_orchestrator_history_query_filters_location_time_and_returns_real_amount(
monkeypatch, monkeypatch,
) -> None: ) -> None:
@@ -322,6 +376,89 @@ def test_orchestrator_history_query_filters_location_time_and_returns_real_amoun
assert "321.45" in response.result["answer"] assert "321.45" in response.result["answer"]
def test_orchestrator_archive_query_filters_archived_claims_and_limits_preview(
monkeypatch,
) -> None:
monkeypatch.setattr(
"app.services.runtime_chat.RuntimeChatService.complete",
lambda *_args, **_kwargs: None,
)
session_factory = build_session_factory()
with session_factory() as db:
employee = Employee(
id="emp-archive-query",
employee_no="E9021",
name="归档员工",
email="archive-query@example.com",
)
claims = []
for index in range(6):
claims.append(
ExpenseClaim(
id=f"claim-archive-query-{index}",
claim_no=f"EXP-ARCHIVE-{index + 1:03d}",
employee=employee,
employee_id=employee.id,
employee_name="归档员工",
department_name="交付部",
expense_type="travel",
reason=f"归档差旅 {index + 1}",
location="上海",
amount=Decimal("100.00") + Decimal(index),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 2, index + 1, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 2, index + 2, 10, 0, tzinfo=UTC),
status="approved",
approval_stage="归档入账",
)
)
draft_claim = ExpenseClaim(
id="claim-archive-query-draft",
claim_no="EXP-ARCHIVE-DRAFT",
employee=employee,
employee_id=employee.id,
employee_name="归档员工",
department_name="交付部",
expense_type="travel",
reason="未归档草稿",
location="上海",
amount=Decimal("999.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 3, 1, 9, 0, tzinfo=UTC),
submitted_at=None,
status="draft",
approval_stage="待提交",
)
db.add_all([employee, *claims, draft_claim])
db.commit()
response = OrchestratorService(db).run(
OrchestratorRequest(
source="user_message",
user_id="archive-query@example.com",
message="帮我查询一下我的归档的单据有哪些?",
context_json={
"client_now_iso": "2026-05-21T04:00:00.000Z",
"client_timezone_offset_minutes": -480,
},
)
)
query_payload = response.result["query_payload"]
assert response.status == "succeeded"
assert response.trace_summary.intent == "query"
assert query_payload["record_count"] == 6
assert query_payload["preview_count"] == 5
assert query_payload["preview_limit"] == 5
assert query_payload["title"] == "最近 5 条你的归档报销单"
assert all(record["status"] == "approved" for record in query_payload["records"])
assert "EXP-ARCHIVE-DRAFT" not in [record["claim_no"] for record in query_payload["records"]]
assert response.result["suggested_actions"] == []
assert "下面先列出最近 5 条记录" in response.result["answer"]
def test_orchestrator_expense_preview_does_not_persist_claim_before_user_action( def test_orchestrator_expense_preview_does_not_persist_claim_before_user_action(
monkeypatch, monkeypatch,
) -> None: ) -> None:

View File

@@ -700,6 +700,37 @@ def test_user_agent_guides_riding_fare_as_transport_expense() -> None:
assert "“交通费”" in response.review_payload.intent_summary assert "“交通费”" in response.review_payload.intent_summary
def test_user_agent_keeps_taxi_ticket_for_customer_dropoff_as_transport_expense() -> None:
session_factory = build_session_factory()
with session_factory() as db:
message = "送客户去机场,报销的士票"
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id="pytest",
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message=message,
ontology=ontology,
tool_payload={"draft_only": True},
)
)
assert ontology.intent == "draft"
assert response.review_payload is not None
slot_map = {item.key: item for item in response.review_payload.slot_cards}
assert slot_map["expense_type"].value == "交通费"
assert slot_map["expense_type"].normalized_value == "transport"
assert slot_map["reason"].value == "送客户去机场,报销的士票"
assert "业务招待费" not in response.review_payload.intent_summary
assert "客户名称" not in response.review_payload.missing_slots
assert "参与人员" not in response.review_payload.missing_slots
def test_user_agent_keeps_travel_range_when_user_adds_receipts_after_text_context() -> None: def test_user_agent_keeps_travel_range_when_user_adds_receipts_after_text_context() -> None:
session_factory = build_session_factory() session_factory = build_session_factory()
with session_factory() as db: with session_factory() as db:
@@ -1060,6 +1091,40 @@ def test_user_agent_returns_draft_limit_message_when_save_is_blocked() -> None:
) )
def test_user_agent_save_draft_answer_guides_followup_to_existing_draft() -> None:
session_factory = build_session_factory()
with session_factory() as db:
context_json = {"review_action": "save_draft"}
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="请按当前识别信息保存报销草稿",
user_id="pytest",
context_json=context_json,
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message="请按当前识别信息保存报销草稿",
ontology=ontology,
context_json=context_json,
tool_payload={
"claim_id": "claim-1",
"claim_no": "BX202605220001",
"status": "draft",
"approval_stage": "待提交",
},
)
)
assert response.draft_payload is not None
assert response.draft_payload.claim_no == "BX202605220001"
assert "已按您当前确认的信息保存为草稿 BX202605220001" in response.answer
assert "请关联这张草稿" in response.answer
assert "继续保存草稿" not in response.answer
def test_user_agent_returns_submitted_draft_payload_for_review_next_step() -> None: def test_user_agent_returns_submitted_draft_payload_for_review_next_step() -> None:
session_factory = build_session_factory() session_factory = build_session_factory()
with session_factory() as db: with session_factory() as db:
@@ -2022,6 +2087,8 @@ def test_user_agent_filters_deprecated_review_risk_briefs() -> None:
def test_user_agent_submission_blocked_risk_level_only_marks_amount_reasons_high() -> None: def test_user_agent_submission_blocked_risk_level_only_marks_amount_reasons_high() -> None:
assert UserAgentService._resolve_submission_blocked_risk_level("住宿金额超出当前职级差标") == "high" assert UserAgentService._resolve_submission_blocked_risk_level("住宿金额超出当前职级差标") == "high"
assert UserAgentService._resolve_submission_blocked_risk_level("缺少直属领导或参与人员信息") == "warning" assert UserAgentService._resolve_submission_blocked_risk_level("缺少直属领导或参与人员信息") == "warning"
assert UserAgentService._is_submission_exception_explanation_reason("住宿金额超出当前职级差标,且未补充超标说明。")
assert not UserAgentService._is_submission_exception_explanation_reason("缺少直属领导或参与人员信息")
def test_user_agent_review_payload_shows_ai_precheck_failure_in_main_message() -> None: def test_user_agent_review_payload_shows_ai_precheck_failure_in_main_message() -> None:
@@ -2066,11 +2133,13 @@ def test_user_agent_review_payload_shows_ai_precheck_failure_in_main_message() -
assert response.review_payload is not None assert response.review_payload is not None
assert response.answer == response.review_payload.body_message assert response.answer == response.review_payload.body_message
assert response.answer.startswith("AI预审未通过住宿金额超出当前职级差标") assert response.answer.startswith("检测到当前单据存在需要说明的超标风险")
assert "整改后再继续提交" in response.answer assert "票据会先正常归集到单据中" in response.answer
assert "附加说明" in response.answer
assert response.review_payload.can_proceed is False assert response.review_payload.can_proceed is False
blocked_brief = next(item for item in response.review_payload.risk_briefs if item.title == "提交风险提示") blocked_brief = next(item for item in response.review_payload.risk_briefs if item.title == "提交风险提示")
assert blocked_brief.level == "high" assert blocked_brief.level == "high"
assert "不是票据归集阻断条件" in blocked_brief.detail
assert not any(item.title == "AI预审未通过" for item in response.review_payload.risk_briefs) assert not any(item.title == "AI预审未通过" for item in response.review_payload.risk_briefs)

View File

@@ -96,6 +96,7 @@
} }
.main.requests-main, .main.requests-main,
.main.approval-main, .main.approval-main,
.main.archive-main,
.main.policies-main, .main.policies-main,
.main.audit-main, .main.audit-main,
.main.logs-main, .main.logs-main,
@@ -114,6 +115,7 @@
.workarea { min-height: 0; overflow: auto; padding: 24px; } .workarea { min-height: 0; overflow: auto; padding: 24px; }
.workarea.requests-workarea, .workarea.requests-workarea,
.workarea.approval-workarea, .workarea.approval-workarea,
.workarea.archive-workarea,
.workarea.policies-workarea, .workarea.policies-workarea,
.workarea.audit-workarea, .workarea.audit-workarea,
.workarea.logs-workarea, .workarea.logs-workarea,

View File

@@ -0,0 +1,54 @@
.archive-page .status-tag.archived {
color: #0f766e;
background: rgba(16, 185, 129, 0.12);
border: 1px solid rgba(16, 185, 129, 0.22);
}
.archive-page .risk-tag.none {
background: #f1f5f9;
color: #64748b;
}
.archive-dropdown-filter {
position: relative;
}
.archive-dropdown-menu {
position: absolute;
top: calc(100% + 8px);
left: 0;
z-index: 12;
min-width: 148px;
max-height: 280px;
padding: 6px;
border: 1px solid #d7e0ea;
border-radius: 10px;
background: #fff;
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.12);
overflow-y: auto;
}
.archive-dropdown-option {
display: block;
width: 100%;
min-height: 36px;
padding: 0 12px;
border: 0;
border-radius: 8px;
background: transparent;
color: #334155;
font-size: 13px;
font-weight: 600;
text-align: left;
cursor: pointer;
}
.archive-dropdown-option:hover,
.archive-dropdown-option.active {
background: rgba(16, 185, 129, 0.1);
color: #047857;
}
.archive-page .hint {
color: #475569;
}

View File

@@ -1089,23 +1089,3 @@
gap: 12px; gap: 12px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.review-upload-decision-modal {
display: grid;
gap: 18px;
}
.review-upload-decision-copy {
display: grid;
gap: 10px;
}
.review-upload-decision-actions {
justify-content: stretch;
}
.review-upload-decision-actions .primary-dialog-btn,
.review-upload-decision-actions .secondary-dialog-btn {
flex: 1 1 168px;
}

View File

@@ -453,10 +453,6 @@
justify-content: stretch; justify-content: stretch;
} }
.review-upload-decision-actions {
width: 100%;
}
.primary-dialog-btn, .primary-dialog-btn,
.secondary-dialog-btn, .secondary-dialog-btn,
.danger-dialog-btn { .danger-dialog-btn {

View File

@@ -740,6 +740,38 @@
color: #475569; color: #475569;
} }
.message-answer-markdown :deep(.markdown-attachment-card) {
margin: 10px 0 12px;
padding: 12px 14px;
border: 1px solid #dbe4ee;
border-left: 4px solid #2563eb;
border-radius: 8px;
background: #f8fafc;
color: #334155;
}
.message-answer-markdown :deep(.markdown-attachment-card + .markdown-attachment-card) {
margin-top: 12px;
}
.message-answer-markdown :deep(.markdown-attachment-card p) {
margin: 0;
}
.message-answer-markdown :deep(.markdown-attachment-card p:first-child) {
color: #0f172a;
font-weight: 820;
}
.message-answer-markdown :deep(.markdown-attachment-card ul) {
margin-top: 8px;
padding-left: 18px;
}
.message-answer-markdown :deep(.markdown-attachment-card li + li) {
margin-top: 4px;
}
.message-answer-markdown :deep(code) { .message-answer-markdown :deep(code) {
padding: 2px 6px; padding: 2px 6px;
border-radius: 6px; border-radius: 6px;
@@ -766,6 +798,22 @@
text-decoration: underline; text-decoration: underline;
} }
.message-answer-markdown :deep(.markdown-action-paragraph) {
margin-top: 34px;
color: #475569;
}
.message-answer-markdown :deep(.markdown-action-link) {
color: #2563eb;
font-weight: 850;
text-decoration-thickness: 1.5px;
text-underline-offset: 3px;
}
.message-answer-markdown :deep(.markdown-action-link:hover) {
color: #1d4ed8;
}
.message-answer-markdown :deep(.markdown-table-wrap) { .message-answer-markdown :deep(.markdown-table-wrap) {
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
@@ -1237,6 +1285,71 @@
font-weight: 700; font-weight: 700;
} }
.expense-query-risk-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
.expense-query-risk-chip {
max-width: 100%;
min-height: 24px;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 0 8px;
border: 1px solid #fecaca;
border-radius: 999px;
background: #fff7ed;
color: #9a3412;
font: inherit;
font-size: 11px;
cursor: pointer;
}
.expense-query-risk-chip span,
.expense-query-risk-chip em {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.expense-query-risk-chip span {
max-width: 86px;
color: #7c2d12;
}
.expense-query-risk-chip strong {
flex-shrink: 0;
font-weight: 850;
}
.expense-query-risk-chip em {
max-width: 120px;
font-style: normal;
}
.expense-query-risk-chip.high {
border-color: #fecaca;
background: #fef2f2;
color: #b91c1c;
}
.expense-query-risk-chip.medium,
.expense-query-risk-chip.warning {
border-color: #fed7aa;
background: #fff7ed;
color: #c2410c;
}
.expense-query-risk-chip.low,
.expense-query-risk-chip.info {
border-color: #bfdbfe;
background: #eff6ff;
color: #1d4ed8;
}
.expense-query-pager { .expense-query-pager {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1513,4 +1626,3 @@
font-size: 13px; font-size: 13px;
font-weight: 900; font-weight: 900;
} }

View File

@@ -606,54 +606,6 @@
gap: 8px; gap: 8px;
} }
.detail-note-tag-list,
.risk-card-tag-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.risk-note-tag {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 0 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 850;
line-height: 1;
}
.risk-note-tag.high {
background: #fef2f2;
color: #dc2626;
}
.risk-note-tag.medium {
background: #fff7ed;
color: #c2410c;
}
.risk-note-tag.low {
background: #eff6ff;
color: #2563eb;
}
.risk-note-tag.hotel {
background: #fdf2f8;
color: #be185d;
}
.risk-note-tag.traffic {
background: #ecfeff;
color: #0e7490;
}
.risk-note-tag.neutral {
background: #f1f5f9;
color: #475569;
}
.leader-approval-card { .leader-approval-card {
border-color: rgba(5, 150, 105, .18); border-color: rgba(5, 150, 105, .18);
background: linear-gradient(180deg, #ffffff 0%, #f7fdfb 100%); background: linear-gradient(180deg, #ffffff 0%, #f7fdfb 100%);

View File

@@ -79,8 +79,9 @@ const {
const sidebarMeta = { const sidebarMeta = {
overview: { label: '财务总览' }, overview: { label: '财务总览' },
workbench: { label: '个人工作台' }, workbench: { label: '个人工作台' },
requests: { label: '个人报销' }, requests: { label: '报销中心' },
approval: { label: '审批中心' }, approval: { label: '审批中心' },
archive: { label: '归档中心' },
policies: { label: '知识管理' }, policies: { label: '知识管理' },
audit: { label: '任务规则中心' }, audit: { label: '任务规则中心' },
logs: { label: '日志管理' }, logs: { label: '日志管理' },

View File

@@ -7,91 +7,28 @@ import { useRequests } from './useRequests.js'
import { useSystemState } from './useSystemState.js' import { useSystemState } from './useSystemState.js'
import { useToast } from './useToast.js' import { useToast } from './useToast.js'
import { fetchLatestConversation } from '../services/orchestrator.js' import { fetchLatestConversation } from '../services/orchestrator.js'
import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.js'
import { buildDetailAlerts } from '../utils/detailAlerts.js'
import { normalizeRequestForUi } from '../utils/requestViewModel.js' import { normalizeRequestForUi } from '../utils/requestViewModel.js'
import { buildWorkbenchSummary } from '../utils/workbenchSummary.js' import { buildWorkbenchSummary } from '../utils/workbenchSummary.js'
const SESSION_TYPE_EXPENSE = 'expense' const SESSION_TYPE_EXPENSE = 'expense'
function isPlaceholderValue(value) {
const text = String(value || '').trim()
if (!text) {
return true
}
return ['待补充', '暂无', '无', '未知', '处理中'].includes(text.replace(/\s+/g, ''))
}
function hasMissingAttachment(request) {
const expenseItems = Array.isArray(request?.expenseItems) ? request.expenseItems : []
if (expenseItems.length) {
return expenseItems.some((item) => !String(item?.invoiceId || item?.invoice_id || '').trim())
}
const attachmentSummary = String(request?.attachmentSummary || '').trim()
const secondaryStatusValue = String(request?.secondaryStatusValue || '').trim()
return /待|缺|未/.test(attachmentSummary) || /待|缺|未/.test(secondaryStatusValue)
}
function hasPendingInfo(request) {
if (!request) {
return false
}
if (request.approvalKey === 'draft' || request.approvalKey === 'supplement') {
return true
}
if (!Number.isFinite(Number(request.amountValue)) || Number(request.amountValue) <= 0) {
return true
}
return [
request.profileDepartment,
request.profilePosition,
request.profileGrade,
request.profileManager,
request.reason,
request.occurredDisplay
].some(isPlaceholderValue)
}
function resolveDetailAlertTone(request) {
if (request?.approvalKey === 'completed') return 'success'
if (request?.approvalKey === 'rejected') return 'danger'
return 'warning'
}
function buildDetailAlerts(request) {
if (!request) {
return []
}
const alerts = []
const nodeLabel = String(request.node || request.approval || '').trim()
if (nodeLabel) {
alerts.push({ label: nodeLabel, tone: resolveDetailAlertTone(request) })
}
if (hasMissingAttachment(request)) {
alerts.push({ label: '缺少票据', tone: 'warning' })
}
if (hasPendingInfo(request)) {
alerts.push({ label: '待补信息', tone: 'warning' })
}
return alerts.filter((item, index, list) => list.findIndex((entry) => entry.label === item.label) === index).slice(0, 3)
}
export function useAppShell() { export function useAppShell() {
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const smartEntryOpen = ref(false) const smartEntryOpen = ref(false)
const smartEntryContext = ref({ prompt: '', source: 'requests', request: null, files: [], conversation: null }) const smartEntryContext = ref({
prompt: '',
source: 'requests',
request: null,
files: [],
conversation: null,
scope: null
})
const smartEntrySessionId = ref(0) const smartEntrySessionId = ref(0)
const smartEntryInvalidatedDraftClaimId = ref('')
const { activeView, currentView, setView } = useNavigation() const { activeView, currentView, setView } = useNavigation()
const { const {
@@ -210,7 +147,14 @@ export function useAppShell() {
function openTravelCreate() { function openTravelCreate() {
smartEntryOpen.value = true smartEntryOpen.value = true
smartEntryContext.value = { prompt: '', source: 'topbar', request: null, files: [], conversation: null } smartEntryContext.value = {
prompt: '',
source: 'topbar',
request: null,
files: [],
conversation: null,
scope: null
}
smartEntrySessionId.value += 1 smartEntrySessionId.value += 1
} }
@@ -219,11 +163,35 @@ export function useAppShell() {
return String(user.username || user.name || 'anonymous').trim() || 'anonymous' return String(user.username || user.name || 'anonymous').trim() || 'anonymous'
} }
function resolveSmartEntryClaimScope(payload = {}) {
const request = payload.request && typeof payload.request === 'object' ? payload.request : null
const payloadScope = payload.scope && typeof payload.scope === 'object' ? payload.scope : null
const claimId = String(
payloadScope?.claimId ||
payloadScope?.claim_id ||
request?.claimId ||
request?.claim_id ||
''
).trim()
if (!claimId) {
return null
}
return { type: 'claim', claimId }
}
function isDetailClaimScopedPayload(payload = {}) {
return String(payload.source || '').trim() === 'detail' && Boolean(resolveSmartEntryClaimScope(payload))
}
async function resolveSmartEntryConversation(payload = {}) { async function resolveSmartEntryConversation(payload = {}) {
if (payload.conversation) { if (payload.conversation) {
return payload.conversation return payload.conversation
} }
if (isDetailClaimScopedPayload(payload)) {
return null
}
if (!payload.restoreLatestConversation) { if (!payload.restoreLatestConversation) {
return null return null
} }
@@ -242,6 +210,7 @@ export function useAppShell() {
async function openSmartEntry(payload = {}) { async function openSmartEntry(payload = {}) {
const conversation = await resolveSmartEntryConversation(payload) const conversation = await resolveSmartEntryConversation(payload)
const scope = resolveSmartEntryClaimScope(payload)
smartEntryOpen.value = true smartEntryOpen.value = true
smartEntryContext.value = { smartEntryContext.value = {
@@ -249,7 +218,8 @@ export function useAppShell() {
source: payload.source ?? 'workbench', source: payload.source ?? 'workbench',
request: payload.request ?? selectedRequest.value, request: payload.request ?? selectedRequest.value,
files: Array.isArray(payload.files) ? payload.files : [], files: Array.isArray(payload.files) ? payload.files : [],
conversation conversation,
scope
} }
smartEntrySessionId.value += 1 smartEntrySessionId.value += 1
} }
@@ -262,15 +232,15 @@ export function useAppShell() {
const claimNo = String(payload.claimNo || payload.claim_no || '').trim() const claimNo = String(payload.claimNo || payload.claim_no || '').trim()
const status = String(payload.status || payload.claimStatus || '').trim() const status = String(payload.status || payload.claimStatus || '').trim()
const approvalStage = String(payload.approvalStage || payload.approval_stage || '').trim() const approvalStage = String(payload.approvalStage || payload.approval_stage || '').trim()
smartEntryOpen.value = false
await reloadRequests() await reloadRequests()
void refreshApprovalInbox()
if (status === 'submitted') { if (status === 'submitted') {
smartEntryOpen.value = false
void refreshApprovalInbox()
toast(`${claimNo || '该'}单据已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}`) toast(`${claimNo || '该'}单据已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}`)
} else { router.push({ name: 'app-requests' })
toast(`${claimNo || '该'}单据已保存到草稿,请到报销页面查看。`) return
} }
router.push({ name: 'app-requests' }) toast(`${claimNo || '该'}单据已保存为草稿,可继续上传票据或补充信息。`)
} }
function openRequestDetail(request) { function openRequestDetail(request) {
@@ -289,7 +259,13 @@ export function useAppShell() {
void refreshApprovalInbox() void refreshApprovalInbox()
} }
async function handleRequestDeleted() { async function handleRequestDeleted(payload = {}) {
const deletedClaimId = String(payload.claimId || payload.claim_id || '').trim()
if (deletedClaimId) {
clearAssistantSessionSnapshotForDraftClaim(resolveCurrentUserId(), deletedClaimId, SESSION_TYPE_EXPENSE)
smartEntryInvalidatedDraftClaimId.value = deletedClaimId
}
await reloadRequests() await reloadRequests()
void refreshApprovalInbox() void refreshApprovalInbox()
router.push({ name: 'app-requests' }) router.push({ name: 'app-requests' })
@@ -327,6 +303,7 @@ export function useAppShell() {
selectedRequest, selectedRequest,
setView, setView,
smartEntryContext, smartEntryContext,
smartEntryInvalidatedDraftClaimId,
smartEntryOpen, smartEntryOpen,
smartEntrySessionId, smartEntrySessionId,
detailAlerts, detailAlerts,

View File

@@ -3,7 +3,7 @@ import { useRoute, useRouter } from 'vue-router'
import { icons } from '../data/icons.js' import { icons } from '../data/icons.js'
export const appViews = ['overview', 'workbench', 'requests', 'approval', 'policies', 'audit', 'logs', 'employees', 'settings'] export const appViews = ['overview', 'workbench', 'requests', 'approval', 'archive', 'policies', 'audit', 'logs', 'employees', 'settings']
export const navItems = [ export const navItems = [
{ {
@@ -24,10 +24,10 @@ export const navItems = [
}, },
{ {
id: 'requests', id: 'requests',
label: '个人报销', label: '报销中心',
navHint: '查看和管理个人报销', navHint: '查看和管理报销单据',
icon: icons.list, icon: icons.list,
title: '个人报销', title: '报销中心',
desc: '集中查看草稿、审批进度、票据状态与风险提示。' desc: '集中查看草稿、审批进度、票据状态与风险提示。'
}, },
{ {
@@ -38,6 +38,14 @@ export const navItems = [
title: '审批中心', title: '审批中心',
desc: '按优先级处理待审批事项,控制时效与风险。' desc: '按优先级处理待审批事项,控制时效与风险。'
}, },
{
id: 'archive',
label: '归档中心',
navHint: '查阅公司已归档财务数据',
icon: icons.archive,
title: '归档中心',
desc: '集中保存公司已归档入账的报销单据,形成完整财务归档库。'
},
{ {
id: 'policies', id: 'policies',
label: '制度知识', label: '制度知识',
@@ -85,6 +93,7 @@ const viewRouteNames = {
workbench: 'app-workbench', workbench: 'app-workbench',
requests: 'app-requests', requests: 'app-requests',
approval: 'app-approval', approval: 'app-approval',
archive: 'app-archive',
policies: 'app-policies', policies: 'app-policies',
audit: 'app-audit', audit: 'app-audit',
logs: 'app-logs', logs: 'app-logs',

View File

@@ -1,11 +1,14 @@
import { computed, reactive, ref } from 'vue' import { computed, reactive, ref } from 'vue'
import { fetchExpenseClaims } from '../services/reimbursements.js' import { fetchExpenseClaims } from '../services/reimbursements.js'
import { filterActionableRiskFlags } from '../utils/riskFlags.js'
const EXPENSE_TYPE_LABELS = { const EXPENSE_TYPE_LABELS = {
travel: '差旅费', travel: '差旅费',
train_ticket: '火车票', train_ticket: '火车票',
flight_ticket: '机票', flight_ticket: '机票',
ship_ticket: '轮船票',
ferry_ticket: '轮船票',
hotel_ticket: '住宿票', hotel_ticket: '住宿票',
ride_ticket: '乘车', ride_ticket: '乘车',
travel_allowance: '出差补贴', travel_allowance: '出差补贴',
@@ -31,6 +34,8 @@ const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance']) const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket']) const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket'])
const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket'])
const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket'])
const REIMBURSEMENT_PROGRESS_LABELS = [ const REIMBURSEMENT_PROGRESS_LABELS = [
'创建单据', '创建单据',
@@ -135,6 +140,17 @@ function resolveLocationDisplay(location, typeCode) {
return isLocationRequiredExpenseType(typeCode) ? '待补充' : '非必填' return isLocationRequiredExpenseType(typeCode) ? '待补充' : '非必填'
} }
function resolveExpenseDescriptionDetail(itemType, itemLocation) {
const normalizedType = normalizeExpenseType(itemType)
if (ROUTE_DESCRIPTION_EXPENSE_TYPES.has(normalizedType)) {
return '起始地-目的地'
}
if (HOTEL_DESCRIPTION_EXPENSE_TYPES.has(normalizedType)) {
return '目的地酒店'
}
return resolveLocationDisplay(itemLocation, normalizedType)
}
function resolveExpenseItemViewId(item, index, claim) { function resolveExpenseItemViewId(item, index, claim) {
return String(item?.id || `${claim?.id || 'claim'}-item-${index}`) return String(item?.id || `${claim?.id || 'claim'}-item-${index}`)
} }
@@ -273,7 +289,7 @@ function buildRiskSummary(riskFlags) {
return '无' return '无'
} }
const items = riskFlags.map((item) => stringifyRiskFlag(item)).filter(Boolean) const items = filterActionableRiskFlags(riskFlags).map((item) => stringifyRiskFlag(item)).filter(Boolean)
return items.length ? items.join('') : '无' return items.length ? items.join('') : '无'
} }
@@ -602,7 +618,7 @@ function buildExpenseItems(claim, riskSummary) {
name: itemTypeLabel, name: itemTypeLabel,
category: itemTypeLabel, category: itemTypeLabel,
desc: itemReason || '待补充', desc: itemReason || '待补充',
detail: resolveLocationDisplay(itemLocation, itemType), detail: resolveExpenseDescriptionDetail(itemType, itemLocation),
amount: itemAmountDisplay, amount: itemAmountDisplay,
status: isSystemGenerated ? '系统计算' : attachments.length ? '已识别' : '待补充', status: isSystemGenerated ? '系统计算' : attachments.length ? '已识别' : '待补充',
tone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'bad', tone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'bad',
@@ -654,6 +670,7 @@ export function mapExpenseClaimToRequest(claim) {
applyTime: formatDateTime(applyDateTime) || '待补充', applyTime: formatDateTime(applyDateTime) || '待补充',
submittedAt: applyDateTime || '', submittedAt: applyDateTime || '',
createdAt: claim?.created_at || '', createdAt: claim?.created_at || '',
updatedAt: claim?.updated_at || '',
amount: parseNumber(claim?.amount), amount: parseNumber(claim?.amount),
riskFlags: Array.isArray(claim?.risk_flags_json) ? claim.risk_flags_json : [], riskFlags: Array.isArray(claim?.risk_flags_json) ? claim.risk_flags_json : [],
invoiceCount, invoiceCount,

View File

@@ -5,6 +5,7 @@ export const icons = {
workspace: iconPath('<path d="M4 20h16"/><path d="M6 20V8a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v12"/><path d="M9 10h6"/><path d="M9 14h6"/><path d="M12 3v3"/>'), workspace: iconPath('<path d="M4 20h16"/><path d="M6 20V8a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v12"/><path d="M9 10h6"/><path d="M9 14h6"/><path d="M12 3v3"/>'),
list: iconPath('<path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/>'), list: iconPath('<path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/>'),
approval: iconPath('<path d="M9 11l2 2 4-5"/><path d="M20 12v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h8"/><path d="M17 3h4v4"/>'), approval: iconPath('<path d="M9 11l2 2 4-5"/><path d="M20 12v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h8"/><path d="M17 3h4v4"/>'),
archive: iconPath('<path d="M3 7h18v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M3 7l2-3h14l2 3"/><path d="M10 12h4"/>'),
file: iconPath('<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h5"/>'), file: iconPath('<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h5"/>'),
skill: iconPath('<path d="M12 3 9.5 8.5 3 11l6.5 2.5L12 19l2.5-5.5L21 11l-6.5-2.5z"/><path d="M19 19l.9 2 .9-2 2-.9-2-.9-.9-2-.9 2-2 .9z"/><path d="M5 5l.6 1.4L7 7l-1.4.6L5 9l-.6-1.4L3 7l1.4-.6z"/>'), skill: iconPath('<path d="M12 3 9.5 8.5 3 11l6.5 2.5L12 19l2.5-5.5L21 11l-6.5-2.5z"/><path d="M19 19l.9 2 .9-2 2-.9-2-.9-.9-2-.9 2-2 .9z"/><path d="M5 5l.6 1.4L7 7l-1.4.6L5 9l-.6-1.4L3 7l1.4-.6z"/>'),
users: iconPath('<path d="M16 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2"/><circle cx="9.5" cy="7" r="4"/><path d="M20 8v6"/><path d="M23 11h-6"/>'), users: iconPath('<path d="M16 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2"/><circle cx="9.5" cy="7" r="4"/><path d="M20 8v6"/><path d="M23 11h-6"/>'),

View File

@@ -8,6 +8,10 @@ export function fetchApprovalExpenseClaims() {
return apiRequest('/reimbursements/claims/approvals') return apiRequest('/reimbursements/claims/approvals')
} }
export function fetchArchivedExpenseClaims() {
return apiRequest('/reimbursements/claims/archives')
}
export function fetchExpenseClaimDetail(claimId) { export function fetchExpenseClaimDetail(claimId) {
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}`) return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}`)
} }

View File

@@ -3,6 +3,7 @@ export const DEFAULT_APP_VIEW_ORDER = [
'workbench', 'workbench',
'requests', 'requests',
'approval', 'approval',
'archive',
'policies', 'policies',
'audit', 'audit',
'logs', 'logs',
@@ -14,6 +15,7 @@ const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'requests', 'policies'])
const VIEW_ROLE_RULES = { const VIEW_ROLE_RULES = {
overview: ['finance', 'executive'], overview: ['finance', 'executive'],
approval: ['approver', 'finance', 'executive'], approval: ['approver', 'finance', 'executive'],
archive: ['finance', 'executive', 'auditor'],
audit: ['auditor', 'finance'], audit: ['auditor', 'finance'],
logs: ['manager'], logs: ['manager'],
employees: ['manager'], employees: ['manager'],

View File

@@ -0,0 +1,216 @@
import {
isActionableRiskFlag,
isRiskSummaryWithRisk,
normalizeRiskFlagTone
} from './riskFlags.js'
export const ARCHIVE_FILTER_ALL = 'all'
export function countClaimRisks(riskFlags, riskSummary) {
let count = 0
for (const flag of Array.isArray(riskFlags) ? riskFlags : []) {
if (!isActionableRiskFlag(flag)) {
continue
}
if (!flag || typeof flag !== 'object') {
count += 1
continue
}
const points = Array.isArray(flag.points)
? flag.points.map((point) => String(point || '').trim()).filter(Boolean)
: []
if (points.length) {
count += points.length
continue
}
const message = String(
flag.message || flag.reason || flag.summary || flag.label || flag.description || flag.title || ''
).trim()
if (message) {
count += 1
}
}
if (!count && isRiskSummaryWithRisk(riskSummary)) {
return 1
}
return count
}
export function resolveArchiveRiskTone(riskFlags, riskSummary) {
let tone = 'low'
for (const flag of Array.isArray(riskFlags) ? riskFlags : []) {
if (!isActionableRiskFlag(flag)) {
continue
}
const flagTone = normalizeRiskFlagTone(flag)
if (flagTone === 'high') {
return 'high'
}
if (flagTone === 'medium') {
tone = 'medium'
}
}
if (tone === 'low' && isRiskSummaryWithRisk(riskSummary)) {
return 'medium'
}
return tone
}
export function formatArchiveRiskCountLabel(riskCount) {
const count = Math.max(0, Number(riskCount) || 0)
return `${count}`
}
export function extractArchiveMonth(...values) {
for (const value of values) {
const text = String(value || '').trim()
if (!text) {
continue
}
const parsedDate = new Date(text)
if (!Number.isNaN(parsedDate.getTime())) {
const year = parsedDate.getFullYear()
const month = String(parsedDate.getMonth() + 1).padStart(2, '0')
return `${year}-${month}`
}
const matched = text.match(/(\d{4})-(\d{2})/)
if (matched) {
return `${matched[1]}-${matched[2]}`
}
}
return ''
}
export function formatArchiveMonthLabel(monthKey) {
const normalized = String(monthKey || '').trim()
const matched = normalized.match(/^(\d{4})-(\d{2})$/)
if (!matched) {
return normalized || '未知月份'
}
return `${matched[1]}${matched[2]}`
}
export function buildTypeFilterOptions(rows) {
const typeMap = new Map()
for (const row of rows) {
const value = String(row?.typeCode || 'other').trim() || 'other'
if (!typeMap.has(value)) {
typeMap.set(value, String(row?.type || row?.typeLabel || value).trim() || value)
}
}
return [
{ value: ARCHIVE_FILTER_ALL, label: '全部类型' },
...Array.from(typeMap.entries())
.sort((left, right) => left[1].localeCompare(right[1], 'zh-CN'))
.map(([value, label]) => ({ value, label }))
]
}
export function buildDepartmentFilterOptions(rows) {
const departments = new Set()
for (const row of rows) {
const department = String(row?.department || row?.dept || '').trim()
if (department) {
departments.add(department)
}
}
return [
{ value: ARCHIVE_FILTER_ALL, label: '全部部门' },
...Array.from(departments)
.sort((left, right) => left.localeCompare(right, 'zh-CN'))
.map((value) => ({ value, label: value }))
]
}
export function buildArchiveMonthFilterOptions(rows) {
const months = new Set()
for (const row of rows) {
const month = String(row?.archiveMonth || '').trim()
if (month) {
months.add(month)
}
}
return [
{ value: ARCHIVE_FILTER_ALL, label: '全部月份' },
...Array.from(months)
.sort((left, right) => right.localeCompare(left))
.map((value) => ({ value, label: formatArchiveMonthLabel(value) }))
]
}
export function applyArchiveListFilters(rows, filters) {
let filteredRows = Array.isArray(rows) ? [...rows] : []
if (filters.tab && filters.tab !== '全部归档') {
filteredRows = filteredRows.filter((row) => row.archiveTab === filters.tab)
}
if (filters.risk === 'has') {
filteredRows = filteredRows.filter((row) => row.hasRisk)
} else if (filters.risk === 'none') {
filteredRows = filteredRows.filter((row) => !row.hasRisk)
} else if (filters.risk && filters.risk !== ARCHIVE_FILTER_ALL) {
filteredRows = filteredRows.filter((row) => row.hasRisk && row.riskTone === filters.risk)
}
if (filters.type && filters.type !== ARCHIVE_FILTER_ALL) {
filteredRows = filteredRows.filter((row) => String(row.typeCode || '').trim() === filters.type)
}
if (filters.department && filters.department !== ARCHIVE_FILTER_ALL) {
filteredRows = filteredRows.filter((row) => String(row.department || '').trim() === filters.department)
}
if (filters.archiveMonth && filters.archiveMonth !== ARCHIVE_FILTER_ALL) {
filteredRows = filteredRows.filter((row) => String(row.archiveMonth || '').trim() === filters.archiveMonth)
}
const keyword = String(filters.keyword || '').trim().toLowerCase()
if (keyword) {
filteredRows = filteredRows.filter((row) => (
String(row.id || '').toLowerCase().includes(keyword)
|| String(row.applicant || '').toLowerCase().includes(keyword)
|| String(row.department || '').toLowerCase().includes(keyword)
|| String(row.type || '').toLowerCase().includes(keyword)
|| String(row.amount || '').toLowerCase().includes(keyword)
|| String(row.risk || '').toLowerCase().includes(keyword)
|| String(row.riskCount ?? '').includes(keyword)
|| String(row.archiveMonthLabel || '').toLowerCase().includes(keyword)
))
}
return filteredRows
}
export function hasActiveArchiveListFilters(filters) {
return Boolean(
(filters.tab && filters.tab !== '全部归档')
|| (filters.risk && filters.risk !== ARCHIVE_FILTER_ALL)
|| (filters.type && filters.type !== ARCHIVE_FILTER_ALL)
|| (filters.department && filters.department !== ARCHIVE_FILTER_ALL)
|| (filters.archiveMonth && filters.archiveMonth !== ARCHIVE_FILTER_ALL)
|| String(filters.keyword || '').trim()
)
}

View File

@@ -22,12 +22,12 @@ function getStorage() {
return window.localStorage return window.localStorage
} }
function emitSnapshotChange(sessionType) { function emitSnapshotChange(sessionType, detail = {}) {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return return
} }
window.dispatchEvent(new CustomEvent(ASSISTANT_SESSION_SNAPSHOT_EVENT, { window.dispatchEvent(new CustomEvent(ASSISTANT_SESSION_SNAPSHOT_EVENT, {
detail: { sessionType: normalizeSessionType(sessionType) } detail: { sessionType: normalizeSessionType(sessionType), ...detail }
})) }))
} }
@@ -82,18 +82,39 @@ export function writeAssistantSessionSnapshot(userId, sessionType = 'expense', s
export function clearAssistantSessionSnapshot(userId, sessionType = 'expense') { export function clearAssistantSessionSnapshot(userId, sessionType = 'expense') {
const storage = getStorage() const storage = getStorage()
if (!storage) { if (!storage) {
return return false
} }
const normalizedSessionType = normalizeSessionType(sessionType) const normalizedSessionType = normalizeSessionType(sessionType)
try { try {
storage.removeItem(resolveAssistantSessionSnapshotKey(userId, normalizedSessionType)) storage.removeItem(resolveAssistantSessionSnapshotKey(userId, normalizedSessionType))
emitSnapshotChange(normalizedSessionType) emitSnapshotChange(normalizedSessionType, { action: 'clear' })
return true
} catch (error) { } catch (error) {
console.warn('Failed to clear assistant session snapshot:', error) console.warn('Failed to clear assistant session snapshot:', error)
return false
} }
} }
export function hasAssistantSessionSnapshot(userId, sessionType = 'expense') { export function hasAssistantSessionSnapshot(userId, sessionType = 'expense') {
return Boolean(readAssistantSessionSnapshot(userId, sessionType)?.state) return Boolean(readAssistantSessionSnapshot(userId, sessionType)?.state)
} }
function resolveSnapshotDraftClaimId(snapshot) {
const state = snapshot?.state && typeof snapshot.state === 'object' ? snapshot.state : {}
return String(state.draftClaimId || state.draft_claim_id || '').trim()
}
export function clearAssistantSessionSnapshotForDraftClaim(userId, claimId, sessionType = 'expense') {
const normalizedClaimId = String(claimId || '').trim()
if (!normalizedClaimId) {
return false
}
const snapshot = readAssistantSessionSnapshot(userId, sessionType)
if (resolveSnapshotDraftClaimId(snapshot) !== normalizedClaimId) {
return false
}
return clearAssistantSessionSnapshot(userId, sessionType)
}

View File

@@ -0,0 +1,114 @@
const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
const LOCATION_REQUIRED_EXPENSE_TYPES = new Set(['travel', 'meeting', 'entertainment'])
function isPlaceholderValue(value) {
const text = String(value || '').trim()
if (!text) {
return true
}
return ['待补充', '暂无', '无', '未知', '处理中'].includes(text.replace(/\s+/g, ''))
}
function normalizeExpenseType(value) {
return String(value || '').trim() || 'other'
}
function isSystemGeneratedExpenseItem(item) {
const itemType = normalizeExpenseType(item?.itemType || item?.item_type)
return Boolean(item?.isSystemGenerated || item?.is_system_generated || SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType))
}
function hasPositiveAmount(value) {
const amount = Number(value)
return Number.isFinite(amount) && amount > 0
}
function getExpenseItems(request) {
return Array.isArray(request?.expenseItems) ? request.expenseItems : []
}
export function hasMissingAttachment(request) {
const expenseItems = getExpenseItems(request)
if (expenseItems.length) {
return expenseItems.some((item) => {
if (isSystemGeneratedExpenseItem(item)) {
return false
}
return !String(item?.invoiceId || item?.invoice_id || '').trim()
})
}
const attachmentSummary = String(request?.attachmentSummary || '').trim()
const secondaryStatusValue = String(request?.secondaryStatusValue || '').trim()
return /待|缺|未/.test(attachmentSummary) || /待|缺|未/.test(secondaryStatusValue)
}
export function hasPendingInfo(request) {
if (!request) {
return false
}
const expenseItems = getExpenseItems(request).filter((item) => !isSystemGeneratedExpenseItem(item))
const hasItemValue = (resolver) => expenseItems.some((item) => !isPlaceholderValue(resolver(item)))
const hasItemAmount = expenseItems.some((item) => hasPositiveAmount(item?.itemAmount || item?.item_amount))
const requestType = normalizeExpenseType(request.typeCode || request.expense_type)
const locationRequired = LOCATION_REQUIRED_EXPENSE_TYPES.has(requestType)
if (!hasPositiveAmount(request.amountValue) && !hasItemAmount) {
return true
}
if (isPlaceholderValue(request.typeLabel) && !hasItemValue((item) => item?.itemType || item?.item_type)) {
return true
}
if (isPlaceholderValue(request.reason) && !hasItemValue((item) => item?.itemReason || item?.item_reason || item?.desc)) {
return true
}
if (isPlaceholderValue(request.occurredDisplay) && !hasItemValue((item) => item?.itemDate || item?.item_date || item?.time)) {
return true
}
if (
locationRequired
&& isPlaceholderValue(request.location)
&& isPlaceholderValue(request.city)
&& !hasItemValue((item) => item?.itemLocation || item?.item_location)
) {
return true
}
return false
}
function resolveDetailAlertTone(request) {
if (request?.approvalKey === 'completed') return 'success'
if (request?.approvalKey === 'rejected') return 'danger'
return 'warning'
}
export function buildDetailAlerts(request) {
if (!request) {
return []
}
const alerts = []
const nodeLabel = String(request.node || request.approval || '').trim()
if (nodeLabel) {
alerts.push({ label: nodeLabel, tone: resolveDetailAlertTone(request) })
}
if (hasMissingAttachment(request)) {
alerts.push({ label: '缺少票据', tone: 'warning' })
}
if (hasPendingInfo(request)) {
alerts.push({ label: '待补信息', tone: 'warning' })
}
return alerts.filter((item, index, list) => list.findIndex((entry) => entry.label === item.label) === index).slice(0, 3)
}

View File

@@ -0,0 +1,14 @@
export function isArchivedExpenseClaim(claim) {
const stage = String(claim?.approval_stage || claim?.approvalStage || '').trim()
const status = String(claim?.status || '').trim().toLowerCase()
if (stage === '归档入账' || stage === 'completed' || stage.includes('归档')) {
return true
}
if (!['approved', 'completed', 'paid'].includes(status)) {
return false
}
return !stage || stage === '归档入账' || stage === 'completed'
}

View File

@@ -8,6 +8,76 @@ const markdown = new MarkdownIt({
const defaultTableOpen = markdown.renderer.rules.table_open const defaultTableOpen = markdown.renderer.rules.table_open
const defaultTableClose = markdown.renderer.rules.table_close const defaultTableClose = markdown.renderer.rules.table_close
const defaultParagraphOpen = markdown.renderer.rules.paragraph_open
const defaultLinkOpen = markdown.renderer.rules.link_open
const defaultBlockquoteOpen = markdown.renderer.rules.blockquote_open
const ACTION_LINK_CLASS_BY_HREF = {
'#confirm-attachment-association': 'markdown-action-link-confirm'
}
function resolveActionLinkClass(href) {
const normalizedHref = String(href || '').trim()
return ACTION_LINK_CLASS_BY_HREF[normalizedHref] || ''
}
function inlineTokenHasActionLink(token) {
const children = Array.isArray(token?.children) ? token.children : []
return children.some((child) => (
child?.type === 'link_open' && resolveActionLinkClass(child.attrGet?.('href'))
))
}
function resolveInlineTokenPlainText(token) {
const children = Array.isArray(token?.children) ? token.children : []
const childText = children
.filter((child) => ['text', 'code_inline'].includes(String(child?.type || '')))
.map((child) => String(child?.content || ''))
.join('')
.trim()
return childText || String(token?.content || '').replace(/[*_`]+/g, '').trim()
}
function blockquoteHasAttachmentHeading(tokens, idx) {
for (let i = idx + 1; i < tokens.length; i += 1) {
const token = tokens[i]
if (token?.type === 'blockquote_close') {
return false
}
if (token?.type === 'inline') {
return /^附件\s*\d+\s*[:]/.test(resolveInlineTokenPlainText(token))
}
}
return false
}
markdown.renderer.rules.paragraph_open = (tokens, idx, options, env, self) => {
if (inlineTokenHasActionLink(tokens[idx + 1])) {
tokens[idx].attrJoin('class', 'markdown-action-paragraph')
}
return defaultParagraphOpen
? defaultParagraphOpen(tokens, idx, options, env, self)
: self.renderToken(tokens, idx, options)
}
markdown.renderer.rules.link_open = (tokens, idx, options, env, self) => {
const actionClass = resolveActionLinkClass(tokens[idx].attrGet('href'))
if (actionClass) {
tokens[idx].attrJoin('class', `markdown-action-link ${actionClass}`)
}
return defaultLinkOpen
? defaultLinkOpen(tokens, idx, options, env, self)
: self.renderToken(tokens, idx, options)
}
markdown.renderer.rules.blockquote_open = (tokens, idx, options, env, self) => {
if (blockquoteHasAttachmentHeading(tokens, idx)) {
tokens[idx].attrJoin('class', 'markdown-attachment-card')
}
return defaultBlockquoteOpen
? defaultBlockquoteOpen(tokens, idx, options, env, self)
: self.renderToken(tokens, idx, options)
}
markdown.renderer.rules.table_open = (tokens, idx, options, env, self) => ( markdown.renderer.rules.table_open = (tokens, idx, options, env, self) => (
`<div class="markdown-table-wrap">${defaultTableOpen ? defaultTableOpen(tokens, idx, options, env, self) : '<table>'}` `<div class="markdown-table-wrap">${defaultTableOpen ? defaultTableOpen(tokens, idx, options, env, self) : '<table>'}`

107
web/src/utils/riskFlags.js Normal file
View File

@@ -0,0 +1,107 @@
const NO_RISK_SUMMARY_VALUES = new Set(['无', '暂无异常', '无异常', '暂无风险'])
const NON_RISK_SOURCES = new Set([
'manual_approval',
'finance_approval',
'approval',
'approval_log',
'expense_claim_approval',
'expense_claim_finance_approval'
])
const NON_RISK_EVENTS = new Set([
'expense_claim_approval',
'expense_claim_finance_approval'
])
const NON_RISK_TONES = new Set(['info', 'pass', 'success', 'approved', 'ok', 'none'])
const RISK_SOURCES = new Set([
'attachment_analysis',
'submission_review',
'manual_return',
'platform_risk',
'policy_review',
'scene_policy_review'
])
function normalizeText(value) {
return String(value || '').trim()
}
function normalizeKey(value) {
return normalizeText(value).toLowerCase()
}
function isApprovalOnlyText(value) {
const text = normalizeText(value)
if (!text) {
return true
}
return (
/^(同意|通过|审批通过|审核通过|已同意|无意见)$/.test(text)
|| /已审批通过/.test(text)
|| /已完成财务审核/.test(text)
|| /进入归档入账/.test(text)
|| /流转至/.test(text)
)
}
export function normalizeRiskFlagTone(flag) {
if (!flag || typeof flag !== 'object') {
return normalizeText(flag) ? 'medium' : 'none'
}
const tone = normalizeKey(flag.severity || flag.tone || flag.level || flag.riskTone || flag.risk_tone)
if (['high', 'medium', 'low'].includes(tone)) {
return tone
}
if (NON_RISK_TONES.has(tone)) {
return 'none'
}
const source = normalizeKey(flag.source)
if (source === 'manual_return') {
return 'medium'
}
if (RISK_SOURCES.has(source)) {
return 'medium'
}
const riskText = normalizeText(flag.message || flag.reason || flag.summary || flag.label || flag.description || flag.title)
if (riskText && !isApprovalOnlyText(riskText)) {
return 'medium'
}
return 'none'
}
export function isActionableRiskFlag(flag) {
if (!flag || typeof flag !== 'object') {
const text = normalizeText(flag)
return Boolean(text && !isApprovalOnlyText(text))
}
const source = normalizeKey(flag.source)
const eventType = normalizeKey(flag.event_type || flag.eventType)
if (NON_RISK_SOURCES.has(source) || NON_RISK_EVENTS.has(eventType)) {
return false
}
const tone = normalizeRiskFlagTone(flag)
if (tone === 'high' || tone === 'medium' || tone === 'low') {
return true
}
return false
}
export function filterActionableRiskFlags(riskFlags) {
return (Array.isArray(riskFlags) ? riskFlags : []).filter((flag) => isActionableRiskFlag(flag))
}
export function isRiskSummaryWithRisk(riskSummary) {
const summary = normalizeText(riskSummary)
if (!summary || NO_RISK_SUMMARY_VALUES.has(summary) || isApprovalOnlyText(summary)) {
return false
}
return true
}

View File

@@ -17,6 +17,7 @@
'workbench-main': activeView === 'workbench', 'workbench-main': activeView === 'workbench',
'requests-main': activeView === 'requests', 'requests-main': activeView === 'requests',
'approval-main': activeView === 'approval', 'approval-main': activeView === 'approval',
'archive-main': activeView === 'archive',
'policies-main': activeView === 'policies', 'policies-main': activeView === 'policies',
'audit-main': activeView === 'audit', 'audit-main': activeView === 'audit',
'audit-detail-main': activeView === 'audit' && auditDetailOpen, 'audit-detail-main': activeView === 'audit' && auditDetailOpen,
@@ -49,7 +50,7 @@
/> />
<FilterBar <FilterBar
v-if="activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'requests' && activeView !== 'approval' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'logs' && activeView !== 'employees' && activeView !== 'settings'" v-if="activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'requests' && activeView !== 'approval' && activeView !== 'archive' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'logs' && activeView !== 'employees' && activeView !== 'settings'"
:compact="activeView === 'overview'" :compact="activeView === 'overview'"
:filters="filters" :filters="filters"
:ranges="ranges" :ranges="ranges"
@@ -62,6 +63,7 @@
:class="{ :class="{
'requests-workarea': activeView === 'requests', 'requests-workarea': activeView === 'requests',
'approval-workarea': activeView === 'approval', 'approval-workarea': activeView === 'approval',
'archive-workarea': activeView === 'archive',
'policies-workarea': activeView === 'policies', 'policies-workarea': activeView === 'policies',
'audit-workarea': activeView === 'audit', 'audit-workarea': activeView === 'audit',
'logs-workarea': activeView === 'logs', 'logs-workarea': activeView === 'logs',
@@ -105,6 +107,7 @@
/> />
<ApprovalCenterView v-else-if="activeView === 'approval'" /> <ApprovalCenterView v-else-if="activeView === 'approval'" />
<ArchiveCenterView v-else-if="activeView === 'archive'" />
<PoliciesView v-else-if="activeView === 'policies'" @summary-change="knowledgeSummary = $event" /> <PoliciesView v-else-if="activeView === 'policies'" @summary-change="knowledgeSummary = $event" />
<AuditView v-else-if="activeView === 'audit'" @detail-open-change="auditDetailOpen = $event" /> <AuditView v-else-if="activeView === 'audit'" @detail-open-change="auditDetailOpen = $event" />
<LogDetailView v-else-if="activeView === 'logs' && logDetailMode" /> <LogDetailView v-else-if="activeView === 'logs' && logDetailMode" />
@@ -122,6 +125,7 @@
:initial-conversation="smartEntryContext.conversation" :initial-conversation="smartEntryContext.conversation"
:entry-source="smartEntryContext.source" :entry-source="smartEntryContext.source"
:request-context="smartEntryContext.request" :request-context="smartEntryContext.request"
:invalidated-draft-claim-id="smartEntryInvalidatedDraftClaimId"
@close="closeSmartEntry" @close="closeSmartEntry"
@draft-saved="handleDraftSaved" @draft-saved="handleDraftSaved"
/> />
@@ -140,6 +144,7 @@ import TravelReimbursementCreateView from './TravelReimbursementCreateView.vue'
import TravelRequestDetailView from './TravelRequestDetailView.vue' import TravelRequestDetailView from './TravelRequestDetailView.vue'
import RequestsView from './RequestsView.vue' import RequestsView from './RequestsView.vue'
import ApprovalCenterView from './ApprovalCenterView.vue' import ApprovalCenterView from './ApprovalCenterView.vue'
import ArchiveCenterView from './ArchiveCenterView.vue'
import PoliciesView from './PoliciesView.vue' import PoliciesView from './PoliciesView.vue'
import AuditView from './AuditView.vue' import AuditView from './AuditView.vue'
import LogsView from './LogsView.vue' import LogsView from './LogsView.vue'
@@ -187,6 +192,7 @@ const {
search, search,
selectedRequest, selectedRequest,
smartEntryContext, smartEntryContext,
smartEntryInvalidatedDraftClaimId,
smartEntryOpen, smartEntryOpen,
smartEntrySessionId, smartEntrySessionId,
toast, toast,

View File

@@ -0,0 +1,141 @@
<template>
<section class="approval-page archive-page">
<TravelRequestDetailView
v-if="selectedRow"
:request="selectedRow"
back-label="返回归档列表"
@back-to-requests="closeSelectedDetail"
@request-updated="reload"
@request-deleted="reload"
/>
<article v-else class="approval-list panel">
<nav class="status-tabs" aria-label="归档分类">
<button
v-for="tab in tabs"
:key="tab"
type="button"
:class="{ active: activeTab === tab }"
@click="activeTab = tab"
>
{{ tab }}
</button>
</nav>
<div class="list-toolbar">
<div class="filter-set">
<div class="list-search">
<i class="mdi mdi-magnify"></i>
<input v-model="listKeyword" type="search" placeholder="搜索单号、申请人、部门、报销类型..." />
</div>
<div
v-for="dropdown in filterDropdowns"
:key="dropdown.key"
class="archive-dropdown-filter"
:class="{ open: openFilterKey === dropdown.key }"
>
<button class="filter-btn" type="button" @click="toggleFilterDropdown(dropdown.key)">
<span>{{ dropdown.label }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="openFilterKey === dropdown.key"
class="archive-dropdown-menu"
role="menu"
:aria-label="`${dropdown.label}筛选`"
>
<button
v-for="option in dropdown.options"
:key="`${dropdown.key}-${option.value}`"
type="button"
class="archive-dropdown-option"
:class="{ active: dropdown.activeValue === option.value }"
role="menuitem"
@click="selectFilterValue(dropdown.key, option.value)"
>
{{ option.label }}
</button>
</div>
</div>
</div>
</div>
<p class="hint"><i class="mdi mdi-information-outline"></i> 归档中心保存公司已归档入账的报销数据点击单据行查看详情</p>
<div class="table-wrap" :class="{ 'is-empty': showEmpty }">
<div v-if="loading" class="table-state">
<TableLoadingState
title="归档数据同步中"
message="正在加载公司已归档的报销单据"
icon="mdi mdi-archive-check-outline"
/>
</div>
<div v-else-if="error" class="table-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<strong>归档列表加载失败</strong>
<p>{{ error }}</p>
<button class="state-action" type="button" @click="reload">重新加载</button>
</div>
<TableEmptyState
v-else-if="showEmpty"
:eyebrow="archiveEmptyState.eyebrow"
:title="archiveEmptyState.title"
:description="archiveEmptyState.desc"
:icon="archiveEmptyState.icon"
:action-label="archiveEmptyState.actionLabel"
:action-icon="archiveEmptyState.actionIcon"
:tone="archiveEmptyState.tone"
:art-label="archiveEmptyState.artLabel"
:tips="archiveEmptyState.tips"
@action="handleEmptyAction"
/>
<table v-else>
<colgroup>
<col><col><col><col><col><col><col><col><col>
</colgroup>
<thead>
<tr>
<th>单号</th>
<th>申请人</th>
<th>申请部门</th>
<th>报销类型</th>
<th>金额</th>
<th>提交时间 <i class="mdi mdi-sort"></i></th>
<th>归档节点</th>
<th>风险</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr v-for="row in visibleRows" :key="row.id" @click="selectedRow = row">
<td><strong class="doc-id">{{ row.id }}</strong></td>
<td>
<span class="person">
<span class="avatar">{{ row.avatar }}</span>
{{ row.applicant }}
</span>
</td>
<td>{{ row.department }}</td>
<td>{{ row.type }}</td>
<td>{{ row.amount }}</td>
<td>{{ row.time }}</td>
<td>{{ row.node }}</td>
<td><span class="risk-tag" :class="row.riskTone">{{ row.risk }}</span></td>
<td><span class="status-tag archived">{{ row.status }}</span></td>
</tr>
</tbody>
</table>
</div>
</article>
</section>
</template>
<script src="./scripts/ArchiveCenterView.js"></script>
<style scoped src="../assets/styles/views/approval-center-view.css"></style>
<style scoped src="../assets/styles/views/approval-center-view-part2.css"></style>
<style scoped src="../assets/styles/views/archive-center-view.css"></style>

View File

@@ -24,7 +24,7 @@
:class="{ active: activeFolder === folder.name }" :class="{ active: activeFolder === folder.name }"
@click="activeFolder = folder.name" @click="activeFolder = folder.name"
> >
<i :class="folder.icon"></i> <i :class="resolveKnowledgeFolderIcon(folder, activeFolder)"></i>
<span>{{ folder.name }}</span> <span>{{ folder.name }}</span>
<b>{{ folder.count }}</b> <b>{{ folder.count }}</b>
</button> </button>

View File

@@ -1,6 +1,6 @@
<template> <template>
<Teleport to="body"> <Teleport to="body">
<Transition name="assistant-modal" @after-leave="emitCloseAfterLeave"> <Transition name="assistant-modal" @after-enter="handleAssistantModalAfterEnter" @after-leave="emitCloseAfterLeave">
<div v-if="workbenchVisible" class="assistant-overlay"> <div v-if="workbenchVisible" class="assistant-overlay">
<section class="assistant-modal"> <section class="assistant-modal">
<div class="assistant-header-actions"> <div class="assistant-header-actions">
@@ -127,7 +127,7 @@
</div> </div>
</div> </div>
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.meta?.length" class="message-meta-row"> <div v-if="message.role === 'assistant' && !message.reviewPayload && !message.queryPayload && message.meta?.length" class="message-meta-row">
<span <span
v-for="item in message.meta" v-for="item in message.meta"
:key="item" :key="item"
@@ -139,7 +139,7 @@
</div> </div>
<div <div
v-if="message.role === 'assistant' && !message.reviewPayload && message.suggestedActions?.length" v-if="message.role === 'assistant' && !message.reviewPayload && !message.queryPayload && message.suggestedActions?.length"
class="message-suggested-actions" class="message-suggested-actions"
> >
<button <button
@@ -173,7 +173,7 @@
</div> </div>
<details <details
v-if="message.role === 'assistant' && !message.reviewPayload && message.citations?.length" v-if="message.role === 'assistant' && !message.reviewPayload && !message.queryPayload && message.citations?.length"
class="message-detail-block message-citation-disclosure" class="message-detail-block message-citation-disclosure"
> >
<summary> <summary>
@@ -197,7 +197,7 @@
class="message-detail-block expense-query-block" class="message-detail-block expense-query-block"
> >
<strong> <strong>
{{ message.queryPayload.title || (message.queryPayload.selectionMode === 'draft_association' ? '选择关联草稿' : (message.queryPayload.recentWindowApplied ? '近 10 日单据' : '单据明细')) }} {{ message.queryPayload.title || (message.queryPayload.selectionMode === 'draft_association' ? '选择关联草稿' : '5 条筛选结果') }}
</strong> </strong>
<p v-if="buildExpenseQueryWindowLabel(message.queryPayload)" class="expense-query-window-label"> <p v-if="buildExpenseQueryWindowLabel(message.queryPayload)" class="expense-query-window-label">
@@ -242,6 +242,20 @@
<span>{{ record.dateDisplay }}</span> <span>{{ record.dateDisplay }}</span>
<span>{{ record.amountDisplay }}</span> <span>{{ record.amountDisplay }}</span>
</div> </div>
<div v-if="record.riskItems?.length" class="expense-query-risk-row">
<button
v-for="risk in record.riskItems"
:key="`${message.id}-${record.claimId}-${risk.key}`"
type="button"
class="expense-query-risk-chip"
:class="risk.level"
@click.stop="appendExpenseQueryRiskToConversation(record, risk)"
>
<span>{{ record.claimNo }}</span>
<strong>{{ risk.levelLabel }}</strong>
<em>{{ risk.title }}</em>
</button>
</div>
</div> </div>
<i class="mdi mdi-chevron-right"></i> <i class="mdi mdi-chevron-right"></i>
</button> </button>
@@ -289,15 +303,19 @@
<span>{{ message.queryPayload.emptyText || '当前没有可直接展开的近期待办单据。' }}</span> <span>{{ message.queryPayload.emptyText || '当前没有可直接展开的近期待办单据。' }}</span>
</div> </div>
<p v-if="buildExpenseQueryHint(message.queryPayload)" class="expense-query-hint"> <p
{{ buildExpenseQueryHint(message.queryPayload) }} v-if="buildExpenseQueryHint(message.queryPayload)"
class="expense-query-hint message-answer-markdown"
v-html="renderMarkdown(buildExpenseQueryHint(message.queryPayload))"
@click="handleAssistantMarkdownClick($event, message)"
>
</p> </p>
</div> </div>
<div v-if="message.role === 'assistant' && message.reviewPayload" class="message-detail-block review-message-block"> <div v-if="message.role === 'assistant' && message.reviewPayload" class="message-detail-block review-message-block">
<div class="review-plain-followup"> <div class="review-plain-followup">
<template <template
v-for="followup in [buildReviewPlainFollowupCopy(message.reviewPayload)]" v-for="followup in [buildReviewPlainFollowupForMessage(message)]"
:key="`${message.id}-review-followup`" :key="`${message.id}-review-followup`"
> >
<h3 <h3
@@ -684,7 +702,7 @@
<div v-if="activeReviewPayload || isReviewFlowDrawer" class="review-insight-tools"> <div v-if="activeReviewPayload || isReviewFlowDrawer" class="review-insight-tools">
<button <button
v-if="activeReviewPayload" v-if="activeReviewPayload && reviewOverviewDrawerAvailable"
type="button" type="button"
class="review-insight-switch-icon-btn" class="review-insight-switch-icon-btn"
:class="{ :class="{
@@ -836,7 +854,7 @@
<template v-else-if="currentInsight.intent === 'agent' && currentInsight.agent"> <template v-else-if="currentInsight.intent === 'agent' && currentInsight.agent">
<template v-if="activeReviewPayload"> <template v-if="activeReviewPayload">
<template v-if="!isReviewDocumentDrawer && !isReviewRiskDrawer && !isReviewFlowDrawer"> <template v-if="reviewOverviewDrawerAvailable && !isReviewDocumentDrawer && !isReviewRiskDrawer && !isReviewFlowDrawer">
<section class="review-side-card review-side-overview-card"> <section class="review-side-card review-side-overview-card">
<div class="review-side-intent-row"> <div class="review-side-intent-row">
<i class="mdi mdi-account-outline"></i> <i class="mdi mdi-account-outline"></i>
@@ -1221,7 +1239,7 @@
</button> </button>
</template> </template>
<section v-if="currentInsight.agent.citations?.length && !activeReviewPayload" class="insight-card"> <section v-if="currentInsight.agent.citations?.length && !currentInsight.agent.queryPayload && !activeReviewPayload" class="insight-card">
<div class="card-head"> <div class="card-head">
<h4>制度依据</h4> <h4>制度依据</h4>
</div> </div>
@@ -1284,30 +1302,6 @@
@confirm="confirmDeleteCurrentSession" @confirm="confirmDeleteCurrentSession"
/> />
<Transition name="assistant-modal">
<div v-if="uploadDecisionDialogOpen" class="assistant-overlay review-overlay">
<section class="review-confirm-modal review-upload-decision-modal">
<div class="review-upload-decision-copy">
<span class="assistant-badge">上传票据</span>
<h3>检测到你已有单据事件</h3>
<p>这次新上传的附件需要先确认处理方式你可以继续归集到上一笔单据也可以重新开启一张新单据</p>
</div>
<div class="review-confirm-actions review-upload-decision-actions">
<button type="button" class="primary-dialog-btn" :disabled="submitting || reviewActionBusy" @click="continueExistingUpload">
继续
</button>
<button type="button" class="secondary-dialog-btn" :disabled="submitting || reviewActionBusy" @click="createNewUploadDocument">
新单据
</button>
<button type="button" class="secondary-dialog-btn" :disabled="submitting || reviewActionBusy" @click="closeUploadDecisionDialog">
取消
</button>
</div>
</section>
</div>
</Transition>
<Transition name="assistant-modal"> <Transition name="assistant-modal">
<div v-if="documentPreviewDialog.open" class="assistant-overlay review-overlay"> <div v-if="documentPreviewDialog.open" class="assistant-overlay review-overlay">
<section class="review-preview-modal"> <section class="review-preview-modal">

View File

@@ -97,20 +97,11 @@
</div> </div>
<div v-if="canEditDetailNote" class="detail-note-editor"> <div v-if="canEditDetailNote" class="detail-note-editor">
<textarea <textarea
v-model="detailNoteEditor" v-model="detailNoteEditorView"
maxlength="500" maxlength="500"
placeholder="例如:去北京客户现场出差,拜访 XX 客户并处理项目验收事项" placeholder="例如:去北京客户现场出差,拜访 XX 客户并处理项目验收事项"
aria-label="附加说明" aria-label="附加说明"
></textarea> ></textarea>
<div v-if="detailNoteTags.length" class="detail-note-tag-list" aria-label="附加说明风险标签">
<span
v-for="tag in detailNoteTags"
:key="tag"
:class="['risk-note-tag', resolveRiskTagClass(tag)]"
>
{{ tag }}
</span>
</div>
<div class="detail-note-editor-meta"> <div class="detail-note-editor-meta">
<span>仅草稿待提交状态可编辑提交后将作为明确说明展示</span> <span>仅草稿待提交状态可编辑提交后将作为明确说明展示</span>
<div class="detail-note-actions"> <div class="detail-note-actions">
@@ -136,15 +127,6 @@
</div> </div>
<div v-else class="detail-note readonly"> <div v-else class="detail-note readonly">
<p>{{ detailNote }}</p> <p>{{ detailNote }}</p>
<div v-if="detailNoteTags.length" class="detail-note-tag-list" aria-label="附加说明风险标签">
<span
v-for="tag in detailNoteTags"
:key="tag"
:class="['risk-note-tag', resolveRiskTagClass(tag)]"
>
{{ tag }}
</span>
</div>
</div> </div>
</article> </article>
@@ -178,8 +160,8 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th class="col-time">时间</th>
<th class="col-filled-at">填写时间</th> <th class="col-filled-at">填写时间</th>
<th class="col-time">发生时间</th>
<th class="col-type">费用项目</th> <th class="col-type">费用项目</th>
<th class="col-desc">说明</th> <th class="col-desc">说明</th>
<th class="col-amount">金额</th> <th class="col-amount">金额</th>
@@ -190,6 +172,10 @@
<tbody> <tbody>
<template v-for="item in expenseItems" :key="item.id"> <template v-for="item in expenseItems" :key="item.id">
<tr :class="{ 'system-generated-row': item.isSystemGenerated }"> <tr :class="{ 'system-generated-row': item.isSystemGenerated }">
<td class="expense-filled-at col-filled-at">
<strong>{{ item.filledAt }}</strong>
<span>条款填写时间</span>
</td>
<td :class="['expense-time col-time', { 'has-major-risk': isMajorExpenseRisk(item) }]"> <td :class="['expense-time col-time', { 'has-major-risk': isMajorExpenseRisk(item) }]">
<i <i
v-if="isMajorExpenseRisk(item)" v-if="isMajorExpenseRisk(item)"
@@ -208,10 +194,6 @@
<span>{{ item.dayLabel }}</span> <span>{{ item.dayLabel }}</span>
</template> </template>
</td> </td>
<td class="expense-filled-at col-filled-at">
<strong>{{ item.filledAt }}</strong>
<span>条款填写时间</span>
</td>
<td class="expense-type col-type"> <td class="expense-type col-type">
<template v-if="editingExpenseId === item.id"> <template v-if="editingExpenseId === item.id">
<div class="cell-editor"> <div class="cell-editor">
@@ -405,11 +387,11 @@
</div> </div>
</article> </article>
<article v-if="isEditableRequest" class="detail-card panel validation-card"> <article v-if="showAiAdvicePanel" class="detail-card panel validation-card">
<div class="validation-head"> <div class="validation-head">
<div> <div>
<h3>AI建议</h3> <h3>{{ aiAdviceTitle }}</h3>
<p>按建议顺序补齐信息或处理风险后再发起审批</p> <p>{{ aiAdviceHint }}</p>
</div> </div>
<span :class="['validation-pill', aiAdvice.tone]">{{ aiAdvice.badge }}</span> <span :class="['validation-pill', aiAdvice.tone]">{{ aiAdvice.badge }}</span>
</div> </div>
@@ -434,15 +416,6 @@
<span>{{ card.label }}</span> <span>{{ card.label }}</span>
<strong>{{ card.title }}</strong> <strong>{{ card.title }}</strong>
</div> </div>
<div v-if="card.tags?.length" class="risk-card-tag-list" aria-label="风险标签">
<span
v-for="tag in card.tags"
:key="tag"
:class="['risk-note-tag', resolveRiskTagClass(tag)]"
>
{{ tag }}
</span>
</div>
<p class="risk-advice-point">{{ card.risk }}</p> <p class="risk-advice-point">{{ card.risk }}</p>
<div class="risk-advice-meta"> <div class="risk-advice-meta">
<div> <div>
@@ -733,15 +706,6 @@
<strong>{{ currentSubmitRiskWarning.title }}</strong> <strong>{{ currentSubmitRiskWarning.title }}</strong>
</div> </div>
<p>{{ currentSubmitRiskWarning.risk }}</p> <p>{{ currentSubmitRiskWarning.risk }}</p>
<div class="risk-card-tag-list" aria-label="风险标签">
<span
v-for="tag in currentSubmitRiskWarning.tags"
:key="tag"
:class="['risk-note-tag', resolveRiskTagClass(tag)]"
>
{{ tag }}
</span>
</div>
<textarea <textarea
v-model="riskOverrideReasons[currentSubmitRiskWarning.id]" v-model="riskOverrideReasons[currentSubmitRiskWarning.id]"
maxlength="160" maxlength="160"

View File

@@ -6,6 +6,11 @@ import { useApprovalInbox } from '../../composables/useApprovalInbox.js'
import { useSystemState } from '../../composables/useSystemState.js' import { useSystemState } from '../../composables/useSystemState.js'
import { fetchApprovalExpenseClaims } from '../../services/reimbursements.js' import { fetchApprovalExpenseClaims } from '../../services/reimbursements.js'
import { listPendingApprovalRequests } from '../../utils/approvalInbox.js' import { listPendingApprovalRequests } from '../../utils/approvalInbox.js'
import {
filterActionableRiskFlags,
isRiskSummaryWithRisk,
normalizeRiskFlagTone
} from '../../utils/riskFlags.js'
import TravelRequestDetailView from '../TravelRequestDetailView.vue' import TravelRequestDetailView from '../TravelRequestDetailView.vue'
const DEFAULT_SLA_HOURS = 24 const DEFAULT_SLA_HOURS = 24
@@ -37,10 +42,9 @@ function formatCurrency(value) {
} }
function resolveRiskTone(riskFlags, riskSummary) { function resolveRiskTone(riskFlags, riskSummary) {
if (Array.isArray(riskFlags)) { const actionableFlags = filterActionableRiskFlags(riskFlags)
const severities = riskFlags if (actionableFlags.length) {
.map((item) => String(item?.severity || '').trim().toLowerCase()) const severities = actionableFlags.map((item) => normalizeRiskFlagTone(item)).filter(Boolean)
.filter(Boolean)
if (severities.includes('high')) { if (severities.includes('high')) {
return 'high' return 'high'
@@ -53,7 +57,7 @@ function resolveRiskTone(riskFlags, riskSummary) {
} }
} }
if (String(riskSummary || '').trim() && String(riskSummary || '').trim() !== '无') { if (isRiskSummaryWithRisk(riskSummary)) {
return 'medium' return 'medium'
} }

View File

@@ -0,0 +1,313 @@
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
import { mapExpenseClaimToRequest } from '../../composables/useRequests.js'
import { fetchArchivedExpenseClaims } from '../../services/reimbursements.js'
import {
ARCHIVE_FILTER_ALL,
applyArchiveListFilters,
buildArchiveMonthFilterOptions,
buildDepartmentFilterOptions,
buildTypeFilterOptions,
countClaimRisks,
extractArchiveMonth,
formatArchiveMonthLabel,
formatArchiveRiskCountLabel,
hasActiveArchiveListFilters,
resolveArchiveRiskTone
} from '../../utils/archiveCenterListFilters.js'
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
import TravelRequestDetailView from '../TravelRequestDetailView.vue'
const tabs = ['全部归档', '差旅报销', '招待报销', '其他费用']
const RISK_FILTER_OPTIONS = [
{ value: ARCHIVE_FILTER_ALL, label: '全部风险' },
{ value: 'has', label: '有风险' },
{ value: 'none', label: '无风险' },
{ value: 'high', label: '高风险' },
{ value: 'medium', label: '中风险' },
{ value: 'low', label: '低风险' }
]
function formatCurrency(value) {
const amount = Number(value)
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
minimumFractionDigits: 0,
maximumFractionDigits: Number.isInteger(amount) ? 0 : 2
}).format(Number.isFinite(amount) ? amount : 0)
}
function resolveArchiveTypeTab(request) {
const expenseType = String(request?.typeCode || request?.expenseType || '').trim().toLowerCase()
if (expenseType === 'travel') {
return '差旅报销'
}
if (expenseType === 'entertainment') {
return '招待报销'
}
return '其他费用'
}
function buildArchiveRow(request) {
const normalized = normalizeRequestForUi(request)
const riskCount = countClaimRisks(normalized.riskFlags, normalized.riskSummary)
const riskTone = riskCount > 0 ? resolveArchiveRiskTone(normalized.riskFlags, normalized.riskSummary) : 'none'
const hasRisk = riskCount > 0
const archiveMonth = extractArchiveMonth(
normalized.updatedAt,
normalized.submittedAt,
normalized.createdAt,
normalized.occurredAt,
normalized.applyTime
)
return {
...normalized,
applicant: normalized.person,
avatar: String(normalized.person || '?').trim().slice(0, 1) || '?',
department: normalized.dept,
type: normalized.typeLabel,
amount: formatCurrency(normalized.amountValue),
time: normalized.applyTime,
archivedAt: normalized.updatedAt || normalized.applyTime,
archiveMonth,
archiveMonthLabel: formatArchiveMonthLabel(archiveMonth),
node: normalized.workflowNode || '归档入账',
hasRisk,
riskCount,
risk: formatArchiveRiskCountLabel(riskCount),
riskTone,
status: '已归档',
statusTone: 'archived',
archiveTab: resolveArchiveTypeTab(normalized)
}
}
function resolveFilterLabel(options, activeValue, fallbackLabel) {
return options.find((item) => item.value === activeValue)?.label || fallbackLabel
}
export default {
name: 'ArchiveCenterView',
components: {
TravelRequestDetailView,
TableLoadingState,
TableEmptyState
},
setup() {
const activeTab = ref('全部归档')
const activeRiskFilter = ref(ARCHIVE_FILTER_ALL)
const activeTypeFilter = ref(ARCHIVE_FILTER_ALL)
const activeDepartmentFilter = ref(ARCHIVE_FILTER_ALL)
const activeArchiveMonthFilter = ref(ARCHIVE_FILTER_ALL)
const openFilterKey = ref('')
const selectedClaimId = ref('')
const listKeyword = ref('')
const rows = ref([])
const loading = ref(false)
const error = ref('')
const typeFilterOptions = computed(() => buildTypeFilterOptions(rows.value))
const departmentFilterOptions = computed(() => buildDepartmentFilterOptions(rows.value))
const archiveMonthFilterOptions = computed(() => buildArchiveMonthFilterOptions(rows.value))
const riskFilterLabel = computed(() => resolveFilterLabel(RISK_FILTER_OPTIONS, activeRiskFilter.value, '全部风险'))
const typeFilterLabel = computed(() => resolveFilterLabel(typeFilterOptions.value, activeTypeFilter.value, '费用类型'))
const departmentFilterLabel = computed(() => resolveFilterLabel(departmentFilterOptions.value, activeDepartmentFilter.value, '所属部门'))
const archiveMonthFilterLabel = computed(() => resolveFilterLabel(archiveMonthFilterOptions.value, activeArchiveMonthFilter.value, '归档月份'))
const filterDropdowns = computed(() => [
{
key: 'risk',
label: riskFilterLabel.value,
options: RISK_FILTER_OPTIONS,
activeValue: activeRiskFilter.value
},
{
key: 'type',
label: typeFilterLabel.value,
options: typeFilterOptions.value,
activeValue: activeTypeFilter.value
},
{
key: 'department',
label: departmentFilterLabel.value,
options: departmentFilterOptions.value,
activeValue: activeDepartmentFilter.value
},
{
key: 'archiveMonth',
label: archiveMonthFilterLabel.value,
options: archiveMonthFilterOptions.value,
activeValue: activeArchiveMonthFilter.value
}
])
const selectedRow = computed({
get() {
return rows.value.find((row) => row.claimId === selectedClaimId.value) || null
},
set(value) {
selectedClaimId.value = value?.claimId || ''
}
})
const visibleRows = computed(() => applyArchiveListFilters(rows.value, {
tab: activeTab.value,
risk: activeRiskFilter.value,
type: activeTypeFilter.value,
department: activeDepartmentFilter.value,
archiveMonth: activeArchiveMonthFilter.value,
keyword: listKeyword.value
}))
const showEmpty = computed(() => !loading.value && !error.value && visibleRows.value.length === 0)
const archiveEmptyState = computed(() => {
if (!rows.value.length) {
return {
eyebrow: '归档中心',
title: '当前还没有已归档单据',
desc: '财务终审通过并进入「归档入账」节点的报销单会自动汇总到这里,形成公司级财务归档库。',
icon: 'mdi mdi-archive-check-outline',
actionLabel: null,
actionIcon: null,
tone: 'slate',
artLabel: 'ARCHIVE',
tips: ['仅展示已归档入账的单据', '申请人仍可在报销中心查看自己的归档记录']
}
}
const filtersActive = hasActiveArchiveListFilters({
tab: activeTab.value,
risk: activeRiskFilter.value,
type: activeTypeFilter.value,
department: activeDepartmentFilter.value,
archiveMonth: activeArchiveMonthFilter.value,
keyword: listKeyword.value
})
return {
eyebrow: filtersActive ? '筛选结果为空' : '归档中心',
title: filtersActive ? '没有符合当前筛选条件的归档单据' : `${activeTab.value}”里暂时没有归档单据`,
desc: filtersActive
? '可以调整风险、费用类型、部门或归档月份筛选,也可以修改搜索关键词后重试。'
: '可以切换到其他分类查看,或调整筛选条件后重新检索。',
icon: 'mdi mdi-archive-outline',
actionLabel: null,
actionIcon: null,
tone: 'sky',
artLabel: filtersActive ? 'FILTER' : 'ARCHIVE',
tips: ['归档中心保存全公司归档数据', '非申请人无法在报销中心查看他人归档单']
}
})
function resetListFilters() {
activeTab.value = '全部归档'
activeRiskFilter.value = ARCHIVE_FILTER_ALL
activeTypeFilter.value = ARCHIVE_FILTER_ALL
activeDepartmentFilter.value = ARCHIVE_FILTER_ALL
activeArchiveMonthFilter.value = ARCHIVE_FILTER_ALL
listKeyword.value = ''
openFilterKey.value = ''
}
function handleEmptyAction() {
if (!rows.value.length) {
void reload()
return
}
resetListFilters()
}
function toggleFilterDropdown(key) {
openFilterKey.value = openFilterKey.value === key ? '' : key
}
function selectFilterValue(key, value) {
if (key === 'risk') {
activeRiskFilter.value = value
} else if (key === 'type') {
activeTypeFilter.value = value
} else if (key === 'department') {
activeDepartmentFilter.value = value
} else if (key === 'archiveMonth') {
activeArchiveMonthFilter.value = value
}
openFilterKey.value = ''
}
function handleDocumentClick(event) {
const target = event.target
if (!(target instanceof Element)) {
return
}
if (!target.closest('.archive-dropdown-filter')) {
openFilterKey.value = ''
}
}
function closeSelectedDetail() {
selectedClaimId.value = ''
}
async function reload() {
loading.value = true
error.value = ''
try {
const payload = await fetchArchivedExpenseClaims()
const mappedRows = (Array.isArray(payload) ? payload : [])
.map((item) => mapExpenseClaimToRequest(item))
.filter(Boolean)
.map((item) => buildArchiveRow(item))
rows.value = mappedRows
if (!mappedRows.some((item) => item.claimId === selectedClaimId.value)) {
selectedClaimId.value = ''
}
} catch (nextError) {
rows.value = []
selectedClaimId.value = ''
error.value = nextError instanceof Error ? nextError.message : '归档中心加载失败。'
} finally {
loading.value = false
}
}
onMounted(() => {
document.addEventListener('click', handleDocumentClick)
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleDocumentClick)
})
void reload()
return {
activeTab,
archiveEmptyState,
closeSelectedDetail,
error,
filterDropdowns,
handleEmptyAction,
listKeyword,
loading,
openFilterKey,
reload,
resetListFilters,
rows,
selectFilterValue,
selectedRow,
showEmpty,
tabs,
toggleFilterDropdown,
visibleRows
}
}
}

View File

@@ -27,7 +27,10 @@ import {
shouldRenderOnlyOfficePreview shouldRenderOnlyOfficePreview
} from './knowledgePreviewMode.js' } from './knowledgePreviewMode.js'
import { resolveKnowledgePreviewLayoutState } from './knowledgePreviewLayout.js' import { resolveKnowledgePreviewLayoutState } from './knowledgePreviewLayout.js'
import { resolveInitialKnowledgeFolder } from './knowledgeFolderSelection.js' import {
resolveInitialKnowledgeFolder,
resolveKnowledgeFolderIcon
} from './knowledgeFolderSelection.js'
import { buildOnlyOfficePreviewConfig } from './onlyOfficePreviewConfig.js' import { buildOnlyOfficePreviewConfig } from './onlyOfficePreviewConfig.js'
const KNOWLEDGE_POLL_INTERVAL_MS = 5000 const KNOWLEDGE_POLL_INTERVAL_MS = 5000
@@ -666,6 +669,7 @@ export default {
selectDocument, selectDocument,
selectPreviewPage, selectPreviewPage,
selectedDocument, selectedDocument,
resolveKnowledgeFolderIcon,
syncingFolder, syncingFolder,
totalCount, totalCount,
totalPages, totalPages,

View File

@@ -181,12 +181,26 @@ const REVIEW_DRAWER_MODE_REVIEW = 'review'
const REVIEW_DRAWER_MODE_DOCUMENTS = 'documents' const REVIEW_DRAWER_MODE_DOCUMENTS = 'documents'
const REVIEW_DRAWER_MODE_RISK = 'risk' const REVIEW_DRAWER_MODE_RISK = 'risk'
const REVIEW_DRAWER_MODE_FLOW = 'flow' const REVIEW_DRAWER_MODE_FLOW = 'flow'
const REVIEW_PANEL_SCOPE_OVERVIEW = 'overview'
const REVIEW_PANEL_SCOPE_DOCUMENTS = 'documents'
const REVIEW_PANEL_SCOPE_RISK = 'risk'
const FLOW_STEP_STATUS_PENDING = 'pending' const FLOW_STEP_STATUS_PENDING = 'pending'
const FLOW_STEP_STATUS_RUNNING = 'running' const FLOW_STEP_STATUS_RUNNING = 'running'
const FLOW_STEP_STATUS_COMPLETED = 'completed' const FLOW_STEP_STATUS_COMPLETED = 'completed'
const FLOW_STEP_STATUS_FAILED = 'failed' const FLOW_STEP_STATUS_FAILED = 'failed'
const DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = ['历史报销画像', '用户画像', '制度注意事项', '制度注意'] const DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = ['历史报销画像', '用户画像', '制度注意事项', '制度注意']
function normalizeReviewPanelScope(scope) {
const normalized = String(scope || '').trim()
return [REVIEW_PANEL_SCOPE_OVERVIEW, REVIEW_PANEL_SCOPE_DOCUMENTS, REVIEW_PANEL_SCOPE_RISK].includes(normalized)
? normalized
: ''
}
function canExposeReviewPanelScope(scope) {
return Boolean(normalizeReviewPanelScope(scope))
}
function buildBusinessTimeContextFromReviewValues(values = {}) { function buildBusinessTimeContextFromReviewValues(values = {}) {
return buildBusinessTimeContextFromReviewValuesModel(values) return buildBusinessTimeContextFromReviewValuesModel(values)
} }
@@ -413,11 +427,13 @@ function buildReviewRiskItems(reviewPayload) {
.filter(Boolean) .filter(Boolean)
} }
function buildReviewRiskConversationText(item) { function buildReviewRiskConversationText(item, detailTarget = {}) {
const title = String(item?.title || '风险提示').trim() const title = String(item?.title || '风险提示').trim()
const summary = String(item?.summary || '').trim() const summary = String(item?.summary || '').trim()
const detail = String(item?.detail || '').trim() const detail = String(item?.detail || '').trim()
const suggestion = String(item?.suggestion || '').trim() const suggestion = String(item?.suggestion || '').trim()
const detailHref = String(detailTarget?.href || '').trim()
const detailLabel = String(detailTarget?.label || '').trim() || '进入该单据详情重新填写'
const lines = [`${title}`] const lines = [`${title}`]
if (summary) { if (summary) {
@@ -429,6 +445,9 @@ function buildReviewRiskConversationText(item) {
if (suggestion) { if (suggestion) {
lines.push('', `修改建议:${suggestion}`) lines.push('', `修改建议:${suggestion}`)
} }
if (detailHref) {
lines.push('', `[${detailLabel}](${detailHref})`)
}
return lines.join('\n') return lines.join('\n')
} }
@@ -470,6 +489,10 @@ export default {
requestContext: { requestContext: {
type: Object, type: Object,
default: null default: null
},
invalidatedDraftClaimId: {
type: String,
default: ''
} }
}, },
emits: ['close', 'draft-saved'], emits: ['close', 'draft-saved'],
@@ -484,10 +507,10 @@ export default {
const composerDraft = ref('') const composerDraft = ref('')
const submitting = ref(false) const submitting = ref(false)
const workbenchVisible = ref(false) const workbenchVisible = ref(false)
const closeAfterBusy = ref(false)
const linkedRequest = computed(() => sanitizeRequest(props.requestContext)) const linkedRequest = computed(() => sanitizeRequest(props.requestContext))
const hotKnowledgeQuestions = HOT_KNOWLEDGE_QUESTIONS const hotKnowledgeQuestions = HOT_KNOWLEDGE_QUESTIONS
let sessionRuntimeRefs = {} let sessionRuntimeRefs = {}
const uploadDecisionDialogOpen = ref(false)
const { const {
activeSessionType, activeSessionType,
messages, messages,
@@ -511,7 +534,6 @@ export default {
linkedRequest, linkedRequest,
toast, toast,
composerDraft, composerDraft,
uploadDecisionDialogOpen,
adjustComposerTextareaHeight, adjustComposerTextareaHeight,
scrollToBottom, scrollToBottom,
getSessionRuntimeRefs: () => sessionRuntimeRefs getSessionRuntimeRefs: () => sessionRuntimeRefs
@@ -568,8 +590,21 @@ export default {
FLOW_STEP_STATUS_COMPLETED, FLOW_STEP_STATUS_COMPLETED,
FLOW_STEP_STATUS_FAILED FLOW_STEP_STATUS_FAILED
}) })
const hasScopedReviewPayload = computed(() => {
const agent = currentInsight.value.agent || null
if (agent?.reviewPayload && canExposeReviewPanelScope(agent.reviewPanelScope)) {
return true
}
if (currentInsight.value.intent === 'agent' && agent) {
return false
}
return messages.value.some((item) =>
item.role === 'assistant' && item.reviewPayload && canExposeReviewPanelScope(item.reviewPanelScope)
)
})
const hasQueryInsight = computed(() => Boolean(currentInsight.value.agent?.queryPayload))
const hasInsightPanelContent = computed( const hasInsightPanelContent = computed(
() => isKnowledgeSession.value || currentInsight.value.intent !== 'welcome' || flowSteps.value.length > 0 () => isKnowledgeSession.value || hasScopedReviewPayload.value || hasQueryInsight.value || flowSteps.value.length > 0
) )
const showInsightPanel = computed(() => hasInsightPanelContent.value && !insightPanelCollapsed.value) const showInsightPanel = computed(() => hasInsightPanelContent.value && !insightPanelCollapsed.value)
const insightPanelToggleLabel = computed(() => const insightPanelToggleLabel = computed(() =>
@@ -604,11 +639,31 @@ export default {
() => Boolean(conversationId.value) || messages.value.some((item) => item.role === 'user') () => Boolean(conversationId.value) || messages.value.some((item) => item.role === 'user')
) )
const latestReviewMessage = computed(() => const latestReviewMessage = computed(() =>
[...messages.value].reverse().find((item) => item.role === 'assistant' && item.reviewPayload) ?? null [...messages.value].reverse().find((item) =>
) item.role === 'assistant' && item.reviewPayload && canExposeReviewPanelScope(item.reviewPanelScope)
const activeReviewPayload = computed( ) ?? null
() => currentInsight.value.agent?.reviewPayload || latestReviewMessage.value?.reviewPayload || null
) )
const activeReviewPanelScope = computed(() => {
const agent = currentInsight.value.agent || null
const agentScope = normalizeReviewPanelScope(agent?.reviewPanelScope)
if (agent?.reviewPayload && agentScope) {
return agentScope
}
if (currentInsight.value.intent === 'agent' && agent) {
return ''
}
return normalizeReviewPanelScope(latestReviewMessage.value?.reviewPanelScope)
})
const activeReviewPayload = computed(() => {
const agent = currentInsight.value.agent || null
if (agent?.reviewPayload && normalizeReviewPanelScope(agent.reviewPanelScope)) {
return agent.reviewPayload
}
if (currentInsight.value.intent === 'agent' && agent) {
return null
}
return latestReviewMessage.value?.reviewPayload || null
})
const reviewRiskBriefResolver = (payload) => resolveReviewRiskBriefs(payload) const reviewRiskBriefResolver = (payload) => resolveReviewRiskBriefs(payload)
const buildReviewRiskSummary = (payload) => buildReviewRiskSummaryModel(payload, reviewRiskBriefResolver) const buildReviewRiskSummary = (payload) => buildReviewRiskSummaryModel(payload, reviewRiskBriefResolver)
const { const {
@@ -634,6 +689,7 @@ export default {
reviewRiskSummary, reviewRiskSummary,
reviewRiskItems, reviewRiskItems,
reviewRiskEmpty, reviewRiskEmpty,
reviewOverviewDrawerAvailable,
reviewDocumentDrawerAvailable, reviewDocumentDrawerAvailable,
reviewRiskDrawerAvailable, reviewRiskDrawerAvailable,
reviewFlowDrawerAvailable, reviewFlowDrawerAvailable,
@@ -671,6 +727,7 @@ export default {
closeDocumentPreview closeDocumentPreview
} = useTravelReimbursementReviewDrawer({ } = useTravelReimbursementReviewDrawer({
activeReviewPayload, activeReviewPayload,
activeReviewPanelScope,
reviewFilePreviews, reviewFilePreviews,
flowSteps, flowSteps,
submitting, submitting,
@@ -709,6 +766,7 @@ export default {
mergeBusinessTimeIntoExtraContext, mergeBusinessTimeIntoExtraContext,
syncComposerBusinessTimeToReviewCard, syncComposerBusinessTimeToReviewCard,
resolveComposerSubmitText, resolveComposerSubmitText,
resolveComposerDisplaySubmitText,
toggleComposerDatePicker, toggleComposerDatePicker,
closeComposerDatePicker, closeComposerDatePicker,
setComposerDateMode, setComposerDateMode,
@@ -853,6 +911,7 @@ export default {
refreshFlowRunDetail, refreshFlowRunDetail,
rememberFilePreviews, rememberFilePreviews,
replaceMessage, replaceMessage,
resolveComposerDisplaySubmitText,
resetFlowRun, resetFlowRun,
resolveComposerSubmitText, resolveComposerSubmitText,
reviewInlineForm, reviewInlineForm,
@@ -868,7 +927,6 @@ export default {
startSemanticFlowPreview, startSemanticFlowPreview,
submitting, submitting,
syncComposerFilesToDraft, syncComposerFilesToDraft,
uploadDecisionDialogOpen,
toast toast
}) })
const canSubmit = computed( const canSubmit = computed(
@@ -906,8 +964,8 @@ export default {
} }
]) ])
watch( watch(
() => activeReviewPayload.value, () => [activeReviewPayload.value, activeReviewPanelScope.value],
(payload) => { ([payload]) => {
rememberFilePreviews(buildReviewFilePreviewsFromReviewPayload(payload)) rememberFilePreviews(buildReviewFilePreviewsFromReviewPayload(payload))
// reviewDrawerMode.value = resolveReviewRiskBriefs(payload).length // reviewDrawerMode.value = resolveReviewRiskBriefs(payload).length
// ? REVIEW_DRAWER_MODE_RISK // ? REVIEW_DRAWER_MODE_RISK
@@ -989,11 +1047,51 @@ export default {
{ immediate: true } { immediate: true }
) )
watch(
() => props.invalidatedDraftClaimId,
(claimId) => {
clearExpenseSessionForDeletedClaim(claimId)
},
{ immediate: true }
)
watch(
() => workbenchVisible.value,
(visible) => {
if (visible) {
scrollToBottom()
} else {
maybeFinalizeDeferredClose()
}
}
)
watch(
() => [submitting.value, reviewActionBusy.value, sessionSwitchBusy.value, workbenchVisible.value],
() => {
maybeFinalizeDeferredClose()
}
)
watch(
() => messages.value.length,
() => {
if (!workbenchVisible.value) {
return
}
scrollToBottom()
}
)
onMounted(() => { onMounted(() => {
document.addEventListener('click', handleComposerDatePickerOutside) document.addEventListener('click', handleComposerDatePickerOutside)
startFlowTick() startFlowTick()
nextTick(() => { nextTick(() => {
workbenchVisible.value = true workbenchVisible.value = true
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
}) })
void clearKnowledgeSessionOnEntry() void clearKnowledgeSessionOnEntry()
currentInsight.value = currentInsight.value =
@@ -1008,11 +1106,6 @@ export default {
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`) toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`)
} }
submitComposer() submitComposer()
} else {
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
} }
}) })
@@ -1023,8 +1116,31 @@ export default {
}) })
function scrollToBottom() { function scrollToBottom() {
if (!messageListRef.value) return const scrollOnce = () => {
messageListRef.value.scrollTop = messageListRef.value.scrollHeight const list = messageListRef.value
if (!list) {
return false
}
list.scrollTop = list.scrollHeight
return true
}
nextTick(() => {
if (scrollOnce()) {
return
}
requestAnimationFrame(() => {
scrollOnce()
requestAnimationFrame(scrollOnce)
})
})
}
function handleAssistantModalAfterEnter() {
scrollToBottom()
requestAnimationFrame(() => {
scrollToBottom()
})
} }
function resetCurrentSessionState() { function resetCurrentSessionState() {
@@ -1034,6 +1150,31 @@ export default {
resetFlowRun({ startedAt: 0, openDrawer: false }) resetFlowRun({ startedAt: 0, openDrawer: false })
} }
function clearExpenseSessionForDeletedClaim(claimId) {
const normalizedClaimId = String(claimId || '').trim()
if (!normalizedClaimId) {
return
}
const expenseSnapshot = sessionSnapshots.value[SESSION_TYPE_EXPENSE]
const snapshotMatchesDeletedClaim = String(expenseSnapshot?.draftClaimId || '').trim() === normalizedClaimId
const currentMatchesDeletedClaim =
activeSessionType.value === SESSION_TYPE_EXPENSE
&& String(resolveActiveClaimId() || '').trim() === normalizedClaimId
if (!snapshotMatchesDeletedClaim && !currentMatchesDeletedClaim) {
return
}
clearAssistantSessionSnapshot(resolveCurrentUserId(), SESSION_TYPE_EXPENSE)
if (currentMatchesDeletedClaim) {
resetCurrentSessionState()
toast('该草稿单据已删除,相关财务助手会话已清空。')
return
}
sessionSnapshots.value[SESSION_TYPE_EXPENSE] = buildEmptySessionState(SESSION_TYPE_EXPENSE)
}
function adjustComposerTextareaHeight() { function adjustComposerTextareaHeight() {
if (!composerTextareaRef.value) return if (!composerTextareaRef.value) return
@@ -1071,31 +1212,6 @@ export default {
messages.value.splice(index, 1, nextMessage) messages.value.splice(index, 1, nextMessage)
} }
function closeUploadDecisionDialog() {
if (submitting.value || reviewActionBusy.value) return
uploadDecisionDialogOpen.value = false
}
async function continueExistingUpload() {
if (submitting.value || reviewActionBusy.value) return
uploadDecisionDialogOpen.value = false
composerUploadIntent.value = 'continue_existing'
await submitComposer({
uploadDisposition: 'continue_existing',
skipUploadDecisionPrompt: true
})
}
async function createNewUploadDocument() {
if (submitting.value || reviewActionBusy.value) return
uploadDecisionDialogOpen.value = false
composerUploadIntent.value = ''
await submitComposer({
uploadDisposition: 'new_document',
skipUploadDecisionPrompt: true
})
}
async function runShortcut(shortcut) { async function runShortcut(shortcut) {
if (shortcut?.action === 'switch_view' && shortcut?.targetSessionType) { if (shortcut?.action === 'switch_view' && shortcut?.targetSessionType) {
await switchSessionType(shortcut.targetSessionType) await switchSessionType(shortcut.targetSessionType)
@@ -1218,6 +1334,9 @@ export default {
} }
function switchToReviewOverviewDrawer() { function switchToReviewOverviewDrawer() {
if (!reviewOverviewDrawerAvailable.value) {
return
}
switchReviewDrawerMode(REVIEW_DRAWER_MODE_REVIEW) switchReviewDrawerMode(REVIEW_DRAWER_MODE_REVIEW)
} }
@@ -1255,20 +1374,98 @@ export default {
function appendReviewRiskBriefToConversation(item) { function appendReviewRiskBriefToConversation(item) {
if (!item) return if (!item) return
messages.value.push(createMessage('assistant', buildReviewRiskConversationText(item), [], { messages.value.push(createMessage('assistant', buildReviewRiskConversationText(item, resolveReviewRiskDetailTarget()), [], {
meta: [item.sourceLabel || item.levelLabel || '风险提示'], meta: [item.sourceLabel || item.levelLabel || '风险提示'],
metaTone: item.level || 'low' metaTone: item.level || 'low'
})) }))
nextTick(scrollToBottom) nextTick(scrollToBottom)
} }
function appendExpenseQueryRiskToConversation(record, risk) {
if (!record || !risk) return
const claimId = String(record.claimId || '').trim()
const claimNo = String(record.claimNo || '该单据').trim()
const route = claimId
? router.resolve({
name: 'app-request-detail',
params: { requestId: claimId }
})
: null
messages.value.push(createMessage(
'assistant',
buildReviewRiskConversationText(
{
title: `${claimNo} ${risk.levelLabel || '风险提示'}${risk.title || '风险提示'}`,
summary: risk.summary,
detail: risk.detail,
suggestion: '请进入单据详情核对费用明细、票据附件和附加说明;如属于合理例外,请补充业务说明后再继续流程。',
sourceLabel: risk.levelLabel,
level: risk.level
},
route?.href
? {
href: route.href,
label: `进入 ${claimNo} 详情重新填写`
}
: {}
),
[],
{
meta: [`${claimNo} 风险详情`],
metaTone: risk.level || 'medium'
}
))
nextTick(scrollToBottom)
}
function resolveReviewRiskDetailTarget() {
const latestDraftMessage = [...messages.value].reverse().find((item) => item?.draftPayload)
const candidates = [
currentInsight.value.agent?.draftPayload,
latestReviewMessage.value?.draftPayload,
latestDraftMessage?.draftPayload,
linkedRequest.value
].filter(Boolean)
const claimTarget = candidates.find((item) => String(item?.claim_id || item?.claimId || item?.id || '').trim())
const claimId = String(claimTarget?.claim_id || claimTarget?.claimId || claimTarget?.id || draftClaimId.value || resolveActiveClaimId() || '').trim()
if (!claimId) {
return {}
}
const claimNoTarget = candidates.find((item) => String(item?.claim_no || item?.claimNo || item?.documentNo || '').trim())
const claimNo = String(claimNoTarget?.claim_no || claimNoTarget?.claimNo || claimNoTarget?.documentNo || '').trim()
const route = router.resolve({
name: 'app-request-detail',
params: { requestId: claimId }
})
return {
href: route.href,
label: claimNo ? `进入 ${claimNo} 详情重新填写` : '进入该单据详情重新填写'
}
}
function isWorkbenchBusy() {
return submitting.value || reviewActionBusy.value || sessionSwitchBusy.value
}
function maybeFinalizeDeferredClose() {
if (!closeAfterBusy.value || workbenchVisible.value || isWorkbenchBusy()) {
return
}
closeAfterBusy.value = false
emit('close')
}
function requestCloseWorkbench() { function requestCloseWorkbench() {
persistSessionState() persistSessionState()
closeAfterBusy.value = isWorkbenchBusy()
workbenchVisible.value = false workbenchVisible.value = false
} }
function emitCloseAfterLeave() { function emitCloseAfterLeave() {
if (closeAfterBusy.value && isWorkbenchBusy()) {
return
}
closeAfterBusy.value = false
emit('close') emit('close')
} }
@@ -1317,7 +1514,6 @@ export default {
pendingText: `已选择草稿 ${record.claimNo},正在识别并归集附件...`, pendingText: `已选择草稿 ${record.claimNo},正在识别并归集附件...`,
files, files,
uploadDisposition: 'continue_existing', uploadDisposition: 'continue_existing',
skipUploadDecisionPrompt: true,
extraContext: { extraContext: {
draft_claim_id: claimId, draft_claim_id: claimId,
selected_claim_id: claimId, selected_claim_id: claimId,
@@ -1469,6 +1665,12 @@ export default {
} }
const href = String(anchor.getAttribute('href') || '').trim() const href = String(anchor.getAttribute('href') || '').trim()
if (href.startsWith('/app/')) {
event.preventDefault()
router.push(href)
return
}
if (href !== ATTACHMENT_ASSOCIATION_CONFIRM_HREF) { if (href !== ATTACHMENT_ASSOCIATION_CONFIRM_HREF) {
return return
} }
@@ -1492,8 +1694,25 @@ export default {
return handleSaveDraftDirectlyInternal(message, actionType) return handleSaveDraftDirectlyInternal(message, actionType)
} }
function isDraftSavedReviewMessage(message) {
if (!message?.reviewPayload) {
return false
}
return Boolean(
String(message?.draftPayload?.claim_no || message?.draftPayload?.claim_id || '').trim()
|| String(draftClaimId.value || '').trim()
|| String(resolveActiveClaimId() || '').trim()
)
}
function buildReviewPlainFollowupForMessage(message) {
return buildReviewPlainFollowupCopy(message?.reviewPayload, {
savedDraft: isDraftSavedReviewMessage(message)
})
}
function canUseInlineSaveDraft(message) { function canUseInlineSaveDraft(message) {
if (!message?.reviewPayload || message?.draftPayload?.claim_no) { if (!message?.reviewPayload || isDraftSavedReviewMessage(message)) {
return false return false
} }
return Boolean(resolveReviewSaveDraftAction(message.reviewPayload)) return Boolean(resolveReviewSaveDraftAction(message.reviewPayload))
@@ -1515,16 +1734,16 @@ export default {
emit, ASSISTANT_DISPLAY_NAME, aiAvatar, userAvatar, fileInputRef, composerTextareaRef, messageListRef, composerDraft, composerDatePickerOpen, composerDateMode, composerSingleDate, composerRangeStartDate, composerRangeEndDate, composerBusinessTimeTags, composerCanApplyDateSelection, emit, ASSISTANT_DISPLAY_NAME, aiAvatar, userAvatar, fileInputRef, composerTextareaRef, messageListRef, composerDraft, composerDatePickerOpen, composerDateMode, composerSingleDate, composerRangeStartDate, composerRangeEndDate, composerBusinessTimeTags, composerCanApplyDateSelection,
toggleComposerDatePicker, closeComposerDatePicker, setComposerDateMode, handleComposerDateInputChange, removeComposerBusinessTimeTag, flowSteps, flowRunId, flowRefreshBusy, completedFlowStepCount, flowOverallStatusTone, flowOverallStatusText, flowTotalDurationText, toggleComposerDatePicker, closeComposerDatePicker, setComposerDateMode, handleComposerDateInputChange, removeComposerBusinessTimeTag, flowSteps, flowRunId, flowRefreshBusy, completedFlowStepCount, flowOverallStatusTone, flowOverallStatusText, flowTotalDurationText,
attachedFiles, composerFilesExpanded, visibleAttachedFiles, hiddenAttachedFileCount, submitting, sessionSwitchBusy, messages, currentInsight, linkedRequest, canSubmit, activeSessionType, isKnowledgeSession, hotKnowledgeQuestions, attachedFiles, composerFilesExpanded, visibleAttachedFiles, hiddenAttachedFileCount, submitting, sessionSwitchBusy, messages, currentInsight, linkedRequest, canSubmit, activeSessionType, isKnowledgeSession, hotKnowledgeQuestions,
hasInsightPanelContent, showInsightPanel, insightPanelToggleLabel, composerPlaceholder, currentIntentLabel, canDeleteCurrentSession, latestReviewMessage, activeReviewPayload, activeReviewFilePreviews, reviewDrawerMode, isReviewOverviewDrawer, isReviewDocumentDrawer, isReviewRiskDrawer, isReviewFlowDrawer, hasInsightPanelContent, showInsightPanel, insightPanelToggleLabel, composerPlaceholder, currentIntentLabel, canDeleteCurrentSession, latestReviewMessage, activeReviewPayload, activeReviewPanelScope, activeReviewFilePreviews, reviewDrawerMode, isReviewOverviewDrawer, isReviewDocumentDrawer, isReviewRiskDrawer, isReviewFlowDrawer,
reviewDrawerTitle, reviewDocumentDrawerAvailable, reviewRiskDrawerAvailable, reviewFlowDrawerAvailable, reviewDocumentDrawerLabel, reviewDocumentDrawerIcon, reviewRiskDrawerLabel, reviewRiskDrawerIcon, reviewFlowDrawerLabel, reviewFlowDrawerIcon, activeReviewDocument, activeReviewDocumentIndex, activeReviewDocumentPreview, canPreviewActiveReviewDocument, reviewDrawerTitle, reviewOverviewDrawerAvailable, reviewDocumentDrawerAvailable, reviewRiskDrawerAvailable, reviewFlowDrawerAvailable, reviewDocumentDrawerLabel, reviewDocumentDrawerIcon, reviewRiskDrawerLabel, reviewRiskDrawerIcon, reviewFlowDrawerLabel, reviewFlowDrawerIcon, activeReviewDocument, activeReviewDocumentIndex, activeReviewDocumentPreview, canPreviewActiveReviewDocument,
reviewIntentText, reviewFactCards, reviewCategoryOptions, reviewOtherCategoryOptions, reviewSelectedOtherCategory, reviewInlineDirty, reviewInlineForm, reviewInlineEditorKey, reviewInlineErrors, reviewOtherCategoryOpen, reviewInlinePendingFiles, DATE_INPUT_FORMAT, REVIEW_SCENE_OTHER_OPTION, REVIEW_SCENE_OPTIONS, REVIEW_OTHER_CATEGORY_OPTIONS, reviewIntentText, reviewFactCards, reviewCategoryOptions, reviewOtherCategoryOptions, reviewSelectedOtherCategory, reviewInlineDirty, reviewInlineForm, reviewInlineEditorKey, reviewInlineErrors, reviewOtherCategoryOpen, reviewInlinePendingFiles, DATE_INPUT_FORMAT, REVIEW_SCENE_OTHER_OPTION, REVIEW_SCENE_OPTIONS, REVIEW_OTHER_CATEGORY_OPTIONS,
workbenchVisible, reviewPanelConfidence, reviewRiskSummary, reviewRiskItems, reviewRiskEmpty, recognizedNarratives, reviewRecognitionNotes, reviewDocumentSummaries, reviewDocumentCount, reviewDocumentDirty, reviewHasUnsavedChanges, uploadDecisionDialogOpen, workbenchVisible, reviewPanelConfidence, reviewRiskSummary, reviewRiskItems, reviewRiskEmpty, recognizedNarratives, reviewRecognitionNotes, reviewDocumentSummaries, reviewDocumentCount, reviewDocumentDirty, reviewHasUnsavedChanges,
travelCalculatorOpen, travelCalculatorBusy, travelCalculatorError, travelCalculatorResult, travelCalculatorForm, travelCalculatorCanSubmit, deleteSessionDialogOpen, reviewActionBusy, deleteSessionBusy, documentPreviewDialog, shortcuts, travelCalculatorOpen, travelCalculatorBusy, travelCalculatorError, travelCalculatorResult, travelCalculatorForm, travelCalculatorCanSubmit, deleteSessionDialogOpen, reviewActionBusy, deleteSessionBusy, documentPreviewDialog, shortcuts,
resolveReviewMissingSlotCards, resolveReviewRiskBriefs, buildReviewHeadline, buildReviewSubline, buildReviewStateLabel, buildReviewStateTone, buildReviewPlainFollowupCopy, resolveReviewFooterActions, resolveReviewSaveDraftAction, buildReviewPrimaryButtonLabel, buildReviewMainMessageText, resolveReviewMissingSlotCards, resolveReviewRiskBriefs, buildReviewHeadline, buildReviewSubline, buildReviewStateLabel, buildReviewStateTone, buildReviewPlainFollowupCopy, buildReviewPlainFollowupForMessage, resolveReviewFooterActions, resolveReviewSaveDraftAction, buildReviewPrimaryButtonLabel, buildReviewMainMessageText,
renderMarkdown, buildExpenseQueryWindowLabel, buildExpenseQueryHint, getExpenseQueryActivePage, getExpenseQueryTotalPages, getExpenseQueryVisibleRecords, resolveDocumentPreview, triggerFileUpload, applyComposerDateSelection, handleFilesChange, handleComposerInput, handleComposerEnter, runShortcut, runWelcomeQuickAction: runShortcut, handleSuggestedAction, isSuggestedActionSelected, askHotKnowledgeQuestion, resolveKnowledgeRankLabel, resolveKnowledgeRankTone, renderMarkdown, buildExpenseQueryWindowLabel, buildExpenseQueryHint, getExpenseQueryActivePage, getExpenseQueryTotalPages, getExpenseQueryVisibleRecords, resolveDocumentPreview, triggerFileUpload, applyComposerDateSelection, handleFilesChange, handleComposerInput, handleComposerEnter, runShortcut, runWelcomeQuickAction: runShortcut, handleSuggestedAction, isSuggestedActionSelected, askHotKnowledgeQuestion, resolveKnowledgeRankLabel, resolveKnowledgeRankTone,
refreshFlowRunDetail, formatFlowStepDuration, resolveFlowStepStatusLabel, resolveFlowStepDetail, toggleInsightPanel, openTravelCalculator, toggleTravelCalculator, closeTravelCalculator, submitTravelCalculator, switchToReviewOverviewDrawer, toggleReviewDocumentDrawer, toggleReviewRiskDrawer, toggleReviewFlowDrawer, toggleAttachedFilesExpanded, removeAttachedFile, clearAttachedFiles, refreshFlowRunDetail, formatFlowStepDuration, resolveFlowStepStatusLabel, resolveFlowStepDetail, toggleInsightPanel, openTravelCalculator, toggleTravelCalculator, closeTravelCalculator, submitTravelCalculator, switchToReviewOverviewDrawer, toggleReviewDocumentDrawer, toggleReviewRiskDrawer, toggleReviewFlowDrawer, toggleAttachedFilesExpanded, removeAttachedFile, clearAttachedFiles,
requestCloseWorkbench, emitCloseAfterLeave, openExpenseQueryRecord, handleExpenseQueryRecordClick, setExpenseQueryPage, shiftExpenseQueryPage, openDeleteSessionDialog, closeDeleteSessionDialog, confirmDeleteCurrentSession, closeUploadDecisionDialog, continueExistingUpload, createNewUploadDocument, openInlineReviewEditor, closeInlineReviewEditor, commitInlineReviewEditor, clearInlineReviewFieldError, selectInlineScene, selectReviewCategory, selectReviewOtherCategory, requestCloseWorkbench, emitCloseAfterLeave, handleAssistantModalAfterEnter, openExpenseQueryRecord, handleExpenseQueryRecordClick, setExpenseQueryPage, shiftExpenseQueryPage, openDeleteSessionDialog, closeDeleteSessionDialog, confirmDeleteCurrentSession, openInlineReviewEditor, closeInlineReviewEditor, commitInlineReviewEditor, clearInlineReviewFieldError, selectInlineScene, selectReviewCategory, selectReviewOtherCategory,
queryDraftByClaimNo, appendReviewRiskBriefToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, canUseInlineSaveDraft, handleInlineSaveDraft queryDraftByClaimNo, appendReviewRiskBriefToConversation, appendExpenseQueryRiskToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, isDraftSavedReviewMessage, canUseInlineSaveDraft, handleInlineSaveDraft
} }
} }
} }

View File

@@ -29,10 +29,11 @@ import {
buildAiAdviceViewModel, buildAiAdviceViewModel,
buildAttachmentInsightViewModel, buildAttachmentInsightViewModel,
buildAttachmentRiskCards, buildAttachmentRiskCards,
buildClaimSummaryRiskCards,
buildItemClaimRiskState,
extractRiskTagsFromText, extractRiskTagsFromText,
normalizeRiskTone, normalizeRiskTone,
resolveRiskTags, resolveRiskTags
resolveRiskTagTone
} from './travelRequestDetailInsights.js' } from './travelRequestDetailInsights.js'
import { import {
EXPENSE_TYPE_OPTIONS, EXPENSE_TYPE_OPTIONS,
@@ -95,6 +96,26 @@ function normalizeDetailNoteDraftValue(value) {
return isPlaceholderValue(text) ? '' : text return isPlaceholderValue(text) ? '' : text
} }
function stripRiskTagsForDisplay(value) {
return String(value || '')
.split('\n')
.map((line) =>
line
.replace(/(?:^|\s)#[A-Za-z_]+(?=\s|$)/g, ' ')
.replace(/[ \t]{2,}/g, ' ')
.replace(/\s+第/g, ':第')
.trim()
)
.join('\n')
.trim()
}
function mergeVisibleNoteWithHiddenTags(visibleText, rawText) {
const cleanText = normalizeDetailNoteDraftValue(visibleText)
const tags = extractRiskTagsFromText(rawText).join(' ')
return [cleanText, tags].map((item) => String(item || '').trim()).filter(Boolean).join('\n')
}
function buildTravelTimeLabelMap(items, requestModel) { function buildTravelTimeLabelMap(items, requestModel) {
const travelItems = items const travelItems = items
.map((item, index) => { .map((item, index) => {
@@ -612,13 +633,36 @@ export default {
() => 6 + (isEditableRequest.value ? 1 : 0) () => 6 + (isEditableRequest.value ? 1 : 0)
) )
const canEditDetailNote = computed(() => isDraftRequest.value) const canEditDetailNote = computed(() => isDraftRequest.value)
const stripDetailNoteRiskTags = (value) =>
String(value || '')
.split('\n')
.map((line) =>
line
.replace(/(?:^|\s)#[A-Za-z_]+(?=\s|$)/g, ' ')
.replace(/[ \t]{2,}/g, ' ')
.replace(/:\s+第/g, ':第')
.trim()
)
.join('\n')
.trim()
const mergeDetailNoteVisibleTextWithTags = (visibleText, rawText) => {
const cleanText = normalizeDetailNoteDraftValue(visibleText)
const tags = extractRiskTagsFromText(rawText).join(' ')
return [cleanText, tags].map((item) => String(item || '').trim()).filter(Boolean).join('\n')
}
const detailNoteSource = computed(() => normalizeDetailNoteDraftValue(request.value.note)) const detailNoteSource = computed(() => normalizeDetailNoteDraftValue(request.value.note))
const detailNote = computed(() => { const detailNote = computed(() => {
if (detailNoteSource.value) { if (detailNoteSource.value) {
return detailNoteSource.value return stripDetailNoteRiskTags(detailNoteSource.value)
} }
return '暂无附加说明。请补充本次出差或办事事由,例如“去北京客户现场出差,拜访 XX 客户并处理项目验收事项”。' return '暂无附加说明。请补充本次出差或办事事由,例如“去北京客户现场出差,拜访 XX 客户并处理项目验收事项”。'
}) })
const detailNoteEditorView = computed({
get: () => stripDetailNoteRiskTags(detailNoteEditor.value),
set: (value) => {
detailNoteEditor.value = mergeDetailNoteVisibleTextWithTags(value, detailNoteEditor.value)
}
})
const detailNoteDirty = computed(() => detailNoteEditor.value.trim() !== detailNoteSource.value) const detailNoteDirty = computed(() => detailNoteEditor.value.trim() !== detailNoteSource.value)
const detailNoteTags = computed(() => const detailNoteTags = computed(() =>
extractRiskTagsFromText(canEditDetailNote.value ? detailNoteEditor.value : detailNoteSource.value) extractRiskTagsFromText(canEditDetailNote.value ? detailNoteEditor.value : detailNoteSource.value)
@@ -689,6 +733,11 @@ export default {
return expenseAttachmentMeta[item.id] || null return expenseAttachmentMeta[item.id] || null
} }
function resolveClaimRiskFlags() {
const flags = request.value?.riskFlags || request.value?.risk_flags_json || []
return Array.isArray(flags) ? flags : []
}
function resolveAttachmentDisplayName(item) { function resolveAttachmentDisplayName(item) {
const metadata = resolveAttachmentMeta(item) const metadata = resolveAttachmentMeta(item)
return String(metadata?.file_name || item.attachmentHint || '').trim() return String(metadata?.file_name || item.attachmentHint || '').trim()
@@ -790,10 +839,6 @@ export default {
} }
function resolveExpenseRiskState(item) { function resolveExpenseRiskState(item) {
if (!item.invoiceId) {
return null
}
if (uploadingExpenseId.value === item.id) { if (uploadingExpenseId.value === item.id) {
return { return {
label: 'AI识别中', label: 'AI识别中',
@@ -818,6 +863,15 @@ export default {
} }
} }
const claimRiskState = buildItemClaimRiskState(item, resolveClaimRiskFlags())
if (claimRiskState) {
return claimRiskState
}
if (!item.invoiceId) {
return null
}
return { return {
label: '已上传', label: '已上传',
tone: 'low', tone: 'low',
@@ -843,13 +897,20 @@ export default {
} }
const aiAdvice = computed(() => { const aiAdvice = computed(() => {
const completionItems = draftBlockingIssues.value.map(mapIssueToAdvice).filter(Boolean) const completionItems = isEditableRequest.value
? draftBlockingIssues.value.map(mapIssueToAdvice).filter(Boolean)
: []
const directRiskCards = buildAttachmentRiskCards({
expenseItems: expenseItems.value,
attachmentMetaByItemId: expenseAttachmentMeta,
claimRiskFlags: resolveClaimRiskFlags()
})
const hasActionableRiskCards = directRiskCards.some(
(card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone))
)
const riskCards = [ const riskCards = [
...buildAttachmentRiskCards({ ...(hasActionableRiskCards ? [] : buildClaimSummaryRiskCards(request.value)),
expenseItems: expenseItems.value, ...directRiskCards,
attachmentMetaByItemId: expenseAttachmentMeta,
claimRiskFlags: request.value.riskFlags || request.value.risk_flags_json || []
}),
...buildOptionalTravelReceiptRiskCards(request.value, expenseItems.value) ...buildOptionalTravelReceiptRiskCards(request.value, expenseItems.value)
] ]
@@ -859,6 +920,14 @@ export default {
}) })
}) })
const showAiAdvicePanel = computed(() => isEditableRequest.value || aiAdvice.value.riskCards.length > 0)
const aiAdviceTitle = computed(() => (isEditableRequest.value ? 'AI建议' : 'AI提示'))
const aiAdviceHint = computed(() => (
isEditableRequest.value
? '按建议顺序补齐信息或处理风险后,再发起审批。'
: '展示系统已识别的风险点,便于审批和后续整改。'
))
const submitRiskWarnings = computed(() => const submitRiskWarnings = computed(() =>
aiAdvice.value.riskCards aiAdvice.value.riskCards
.filter((card) => normalizeRiskTone(card?.tone) === 'high') .filter((card) => normalizeRiskTone(card?.tone) === 'high')
@@ -904,10 +973,6 @@ export default {
} }
} }
function resolveRiskTagClass(tag) {
return resolveRiskTagTone(tag)
}
function openRiskOverrideDialog() { function openRiskOverrideDialog() {
const warnings = submitRiskWarnings.value const warnings = submitRiskWarnings.value
if (!warnings.length) { if (!warnings.length) {
@@ -1619,11 +1684,18 @@ export default {
return return
} }
const claimId = String(request.value?.claimId || '').trim()
emit('openAssistant', { emit('openAssistant', {
source: 'detail', source: 'detail',
prompt: '', prompt: '',
request: request.value, request: request.value,
restoreLatestConversation: true restoreLatestConversation: false,
scope: claimId
? {
type: 'claim',
claimId
}
: null
}) })
} }
@@ -1632,7 +1704,7 @@ export default {
}) })
return { return {
emit, actionBusy, aiAdvice, attachmentPreviewError, attachmentPreviewIndexLabel, emit, actionBusy, aiAdvice, aiAdviceHint, aiAdviceTitle, attachmentPreviewError, attachmentPreviewIndexLabel,
attachmentPreviewLoading, attachmentPreviewMediaType, attachmentPreviewName, attachmentPreviewOpen, attachmentPreviewLoading, attachmentPreviewMediaType, attachmentPreviewName, attachmentPreviewOpen,
attachmentPreviewUrl, approveBusy, approveConfirmDialogOpen, approvalConfirmBadge, attachmentPreviewUrl, approveBusy, approveConfirmDialogOpen, approvalConfirmBadge,
approvalConfirmDescription, approvalNextStage, approvalOpinionHint, approvalOpinionPlaceholder, approvalConfirmDescription, approvalNextStage, approvalOpinionHint, approvalOpinionPlaceholder,
@@ -1646,7 +1718,7 @@ export default {
currentSubmitRiskWarning, currentSubmitRiskWarning,
canEditDetailNote, deleteActionLabel, deleteBusy, deleteDialogDescription, deleteDialogOpen, canEditDetailNote, deleteActionLabel, deleteBusy, deleteDialogDescription, deleteDialogOpen,
deleteDialogTitle, deletingAttachmentId, deletingExpenseId, detailNote, detailNoteDirty, deleteDialogTitle, deletingAttachmentId, deletingExpenseId, detailNote, detailNoteDirty,
detailNoteEditor, detailNoteTags, draftBlockingIssues, editingExpenseId, creatingExpense, expenseEditor, detailNoteEditor, detailNoteEditorView, detailNoteTags, draftBlockingIssues, editingExpenseId, creatingExpense, expenseEditor,
expenseItems, expenseTableColumnCount, expenseTotal, expenseUploadInput, expenseItems, expenseTableColumnCount, expenseTotal, expenseUploadInput,
expenseTypeOptions: EXPENSE_TYPE_OPTIONS, expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
goToNextSubmitRisk, goToPreviousSubmitRisk, goToNextSubmitRisk, goToPreviousSubmitRisk,
@@ -1655,12 +1727,12 @@ export default {
isMajorExpenseRisk, isMajorExpenseRisk,
openAiEntry, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview, openAiEntry, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview,
profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem, profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
resolveExpenseRiskIndicatorTitle, resolveRiskTagClass, resolveExpenseRiskIndicatorTitle,
resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition, resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues, resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues,
returnBusy, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel, returnBusy, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel,
riskOverrideReasons, saveDetailNote, savingDetailNote, savingExpenseId, riskOverrideReasons, saveDetailNote, savingDetailNote, savingExpenseId,
showLeaderApprovalPanel, showExpenseRisk, startExpenseEdit, submitBusy, submitConfirmDialogOpen, showAiAdvicePanel, showLeaderApprovalPanel, showExpenseRisk, startExpenseEdit, submitBusy, submitConfirmDialogOpen,
submitRiskWarnings, submitRiskWarnings,
triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit
} }

View File

@@ -8,3 +8,12 @@ export function resolveInitialKnowledgeFolder(folders, currentFolder = '') {
return normalizedFolders[0]?.name || '' return normalizedFolders[0]?.name || ''
} }
export function resolveKnowledgeFolderIcon(folder, activeFolder = '') {
const folderName = String(folder?.name || folder || '').trim()
const normalizedActiveFolder = String(activeFolder || '').trim()
return folderName && folderName === normalizedActiveFolder
? 'mdi mdi-folder-open'
: 'mdi mdi-folder'
}

View File

@@ -6,26 +6,26 @@ import {
import { normalizeExpenseQueryPayload } from './travelReimbursementExpenseQueryModel.js' import { normalizeExpenseQueryPayload } from './travelReimbursementExpenseQueryModel.js'
const SCENARIO_LABELS = { const SCENARIO_LABELS = {
expense: '??', expense: '报销',
accounts_receivable: '??', accounts_receivable: '应收',
accounts_payable: '??', accounts_payable: '应付',
knowledge: '??', knowledge: '知识',
unknown: '??' unknown: '通用'
} }
const INTENT_LABELS = { const INTENT_LABELS = {
query: '??', query: '查询',
explain: '??', explain: '解释',
compare: '??', compare: '对比',
risk_check: '????', risk_check: '风险检查',
draft: '????', draft: '信息核对',
operate: '????' operate: '动作请求'
} }
function resolveStatusLabel(status) { function resolveStatusLabel(status) {
if (status === 'succeeded') return '???' if (status === 'succeeded') return '已完成'
if (status === 'blocked') return '???' if (status === 'blocked') return '已阻断'
return '??' return '处理中'
} }
function resolveStatusTone(status) { function resolveStatusTone(status) {
@@ -123,6 +123,12 @@ function buildAssociationDocumentContentLines(document) {
return ['- 识别内容:暂未提取到结构化字段,请以票据原件为准。'] return ['- 识别内容:暂未提取到结构化字段,请以票据原件为准。']
} }
function buildAssociationDocumentCard(lines) {
return (Array.isArray(lines) ? lines : [])
.map((line) => String(line || '').trim() ? `> ${line}` : '>')
.join('\n')
}
export function buildAttachmentAssociationConfirmationMessage({ export function buildAttachmentAssociationConfirmationMessage({
claimNo = '', claimNo = '',
claimTitle = '', claimTitle = '',
@@ -144,13 +150,14 @@ export function buildAttachmentAssociationConfirmationMessage({
const filename = String(document?.filename || '').trim() || `附件 ${index + 1}` const filename = String(document?.filename || '').trim() || `附件 ${index + 1}`
const typeLabel = resolveAssociationDocumentTypeLabel(document) const typeLabel = resolveAssociationDocumentTypeLabel(document)
const contentLines = buildAssociationDocumentContentLines(document) const contentLines = buildAssociationDocumentContentLines(document)
return [ .map((line) => String(line || '').replace(/^-\s*/, ''))
`附件 ${index + 1}${filename}`, return buildAssociationDocumentCard([
`**附件 ${index + 1}${filename}**`,
'', '',
`附件类型:${typeLabel}`, `附件类型:${typeLabel}`,
'', '',
...contentLines ...contentLines
].join('\n') ])
}) })
return [ return [
@@ -158,14 +165,17 @@ export function buildAttachmentAssociationConfirmationMessage({
'', '',
documentBlocks.join('\n\n'), documentBlocks.join('\n\n'),
'', '',
'',
'请问是否确定将票据信息归集到单据:', '请问是否确定将票据信息归集到单据:',
'', '',
targetLines.join('\n'), targetLines.join('\n'),
'', '',
`如果 [确认](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF}) 该信息,我将直接将票据进行归集。` '',
`如果 **[确认](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF})** 该信息,我将直接将票据进行归集。`
] ]
.filter((part) => String(part || '').trim())
.join('\n') .join('\n')
.replace(/\n{4,}/g, '\n\n\n')
.trim()
} }
export function normalizeReviewDocumentFieldKey(label) { export function normalizeReviewDocumentFieldKey(label) {
@@ -235,6 +245,9 @@ export function buildOcrDocumentsFromReviewPayload(reviewPayload) {
document_type_label: resolveDocumentTypeLabel(item?.document_type), document_type_label: resolveDocumentTypeLabel(item?.document_type),
scene_code: resolveExpenseTypeCode(item?.suggested_expense_type), scene_code: resolveExpenseTypeCode(item?.suggested_expense_type),
scene_label: String(item?.scene_label || '').trim(), scene_label: String(item?.scene_label || '').trim(),
preview_kind: String(item?.preview_kind || '').trim(),
preview_data_url: String(item?.preview_data_url || '').trim(),
preview_url: String(item?.preview_url || '').trim(),
document_fields: fields, document_fields: fields,
warnings: Array.isArray(item?.warnings) ? item.warnings : [] warnings: Array.isArray(item?.warnings) ? item.warnings : []
} }
@@ -373,12 +386,32 @@ export function mergeFilePreviews(existingPreviews, incomingPreviews) {
return result return result
} }
function inferPreviewKindFromUrl(url) {
const normalized = String(url || '').trim().toLowerCase()
if (!normalized) return ''
if (normalized.startsWith('data:image/') || /\.(png|jpg|jpeg|webp|bmp)(?:[?#].*)?$/i.test(normalized)) {
return 'image'
}
if (normalized.startsWith('data:application/pdf') || /\.pdf(?:[?#].*)?$/i.test(normalized)) {
return 'pdf'
}
return ''
}
function resolveDocumentPreviewKind(item) {
const explicit = String(item?.preview_kind || '').trim()
if (explicit) {
return explicit
}
return inferPreviewKindFromUrl(String(item?.preview_url || item?.preview_data_url || '').trim())
}
export function buildOcrFilePreviews(payload) { export function buildOcrFilePreviews(payload) {
const documents = Array.isArray(payload?.documents) ? payload.documents : [] const documents = Array.isArray(payload?.documents) ? payload.documents : []
return documents return documents
.map((item) => ({ .map((item) => ({
filename: String(item?.filename || '').trim(), filename: String(item?.filename || '').trim(),
kind: String(item?.preview_kind || '').trim(), kind: resolveDocumentPreviewKind(item),
url: String(item?.preview_url || item?.preview_data_url || '').trim() url: String(item?.preview_url || item?.preview_data_url || '').trim()
})) }))
.filter((item) => item.filename && item.kind === 'image' && item.url) .filter((item) => item.filename && item.kind === 'image' && item.url)
@@ -389,7 +422,7 @@ export function buildReviewFilePreviewsFromReviewPayload(reviewPayload) {
return documents return documents
.map((item) => ({ .map((item) => ({
filename: String(item?.filename || '').trim(), filename: String(item?.filename || '').trim(),
kind: String(item?.preview_kind || '').trim(), kind: resolveDocumentPreviewKind(item),
url: String(item?.preview_url || item?.preview_data_url || '').trim() url: String(item?.preview_url || item?.preview_data_url || '').trim()
})) }))
.filter((item) => item.filename && item.kind === 'image' && item.url) .filter((item) => item.filename && item.kind === 'image' && item.url)

Some files were not shown because too many files have changed in this diff Show More