feat: 新增归档中心页面并完善知识库与报销查询能力
新增前端归档中心视图及相关工具函数,扩充知识库文档分类和 提取器支持多种格式,增强编排器报销查询的多维度检索,优 化本体规则和用户代理审核消息,前端完善报销创建和审批详 情交互细节,补充单元测试覆盖。
This commit is contained in:
@@ -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>
|
||||
@@ -97,6 +97,16 @@ def list_expense_claim_approvals(db: DbSession, current_user: CurrentUser) -> li
|
||||
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(
|
||||
"/claims/{claim_id}",
|
||||
response_model=ExpenseClaimRead,
|
||||
|
||||
@@ -58,10 +58,12 @@ class UserAgentExpenseQueryRecord(BaseModel):
|
||||
occurred_at: str = Field(default="", description="业务发生日期。")
|
||||
reason: str = Field(default="", description="事由。")
|
||||
location: str = Field(default="", description="地点。")
|
||||
risk_flags: list[dict[str, Any]] = Field(default_factory=list, description="该单据当前风险项。")
|
||||
|
||||
|
||||
class UserAgentQueryPayload(BaseModel):
|
||||
result_type: str = Field(default="expense_claim_list", description="结构化查询结果类型。")
|
||||
title: str = Field(default="", description="查询结果标题。")
|
||||
scope_label: str = Field(default="报销单", description="当前查询范围名。")
|
||||
recent_window_applied: bool = Field(default=False, description="是否应用了近 10 日窗口。")
|
||||
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 日窗口结束日期。")
|
||||
record_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 日窗口的单据数。")
|
||||
has_more_in_window: bool = Field(default=False, 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 平均得分。")
|
||||
preview_kind: str = Field(default="", description="票据预览类型,例如 image。")
|
||||
preview_data_url: str = Field(default="", description="票据预览图片 data URL。")
|
||||
preview_url: str = Field(default="", description="票据预览图片地址。")
|
||||
warnings: list[str] = Field(default_factory=list, description="该票据的识别提示。")
|
||||
fields: list[UserAgentReviewDocumentField] = Field(
|
||||
default_factory=list,
|
||||
|
||||
@@ -93,6 +93,12 @@ class AgentConversationService:
|
||||
if existing_session_type != incoming_session_type:
|
||||
normalized_id = ""
|
||||
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:
|
||||
conversation = AgentConversation(
|
||||
@@ -241,6 +247,10 @@ class AgentConversationService:
|
||||
history_limit: int = 8,
|
||||
) -> dict[str, Any]:
|
||||
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 {})
|
||||
should_hydrate_review_flow = self._should_hydrate_review_flow_context(
|
||||
context_json=merged,
|
||||
@@ -641,6 +651,26 @@ class AgentConversationService:
|
||||
).strip()
|
||||
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
|
||||
def _merge_state_json(
|
||||
current_state: dict[str, Any] | None,
|
||||
|
||||
@@ -13,8 +13,10 @@ from app.models.organization import OrganizationUnit
|
||||
|
||||
|
||||
PRIVILEGED_CLAIM_ROLE_CODES = {"finance", "executive"}
|
||||
ARCHIVE_CENTER_ROLE_CODES = {"finance", "executive", "auditor"}
|
||||
APPROVAL_VISIBLE_CLAIM_ROLE_CODES = {"manager", "approver"}
|
||||
CLAIM_DELETE_ROLE_CODES = {"executive"}
|
||||
ARCHIVED_CLAIM_STATUSES = ("approved", "completed", "paid")
|
||||
|
||||
|
||||
class ExpenseClaimAccessPolicy:
|
||||
@@ -27,6 +29,30 @@ class ExpenseClaimAccessPolicy:
|
||||
return True
|
||||
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
|
||||
def has_claim_delete_access(current_user: CurrentUserContext) -> bool:
|
||||
if current_user.is_admin:
|
||||
@@ -374,7 +400,16 @@ class ExpenseClaimAccessPolicy:
|
||||
include_approval_scope: bool = False,
|
||||
) -> Any:
|
||||
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)
|
||||
|
||||
@@ -386,6 +421,12 @@ class ExpenseClaimAccessPolicy:
|
||||
|
||||
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
|
||||
def resolve_claim_manager_name(claim: ExpenseClaim) -> str:
|
||||
if claim.employee is not None:
|
||||
|
||||
@@ -615,7 +615,7 @@ class ExpenseClaimAttachmentAnalysisMixin:
|
||||
severity = "high"
|
||||
label = "高风险"
|
||||
headline = "AI提示:住宿金额超出报销标准"
|
||||
summary = "当前住宿票据金额超过规则中心差旅住宿标准,强行提交前需补充超标原因。"
|
||||
summary = "当前住宿票据金额超过规则中心差旅住宿标准,已作为风险项保留在单据中;如需按特殊情况提交,请补充超标原因。"
|
||||
elif (
|
||||
line_count == 0
|
||||
or not compact_text
|
||||
|
||||
@@ -169,6 +169,19 @@ class ExpenseClaimService(
|
||||
stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user)
|
||||
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:
|
||||
stmt = (
|
||||
select(ExpenseClaim)
|
||||
|
||||
@@ -106,7 +106,7 @@ class KnowledgeService:
|
||||
KnowledgeFolderRead(
|
||||
name=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
|
||||
]
|
||||
|
||||
@@ -10,6 +10,12 @@ from zipfile import BadZipFile, ZipFile
|
||||
from app.services.knowledge_constants import IMAGE_EXTENSIONS, TEXT_EXTENSIONS
|
||||
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:
|
||||
encodings = ("utf-8", "utf-8-sig", "gbk")
|
||||
for encoding in encodings:
|
||||
@@ -19,6 +25,7 @@ def _read_text_preview(file_path: Path) -> str:
|
||||
continue
|
||||
return "当前文本文件编码暂不支持在线解析。"
|
||||
|
||||
|
||||
def _extract_docx_text(file_path: Path) -> str:
|
||||
try:
|
||||
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]
|
||||
return "\n".join(texts)
|
||||
|
||||
|
||||
def _extract_document_text_from_path(
|
||||
*,
|
||||
file_path: Path,
|
||||
@@ -41,6 +49,20 @@ def _extract_document_text_from_path(
|
||||
return _normalize_extracted_text(_read_text_preview(file_path))
|
||||
if extension == "docx":
|
||||
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":
|
||||
text = _normalize_extracted_text(_extract_pdf_text(file_path))
|
||||
if text:
|
||||
@@ -62,11 +84,13 @@ def _extract_document_text_from_path(
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
def _normalize_extracted_text(text: str) -> str:
|
||||
normalized = str(text or "").replace("\r\n", "\n").replace("\r", "\n")
|
||||
normalized = re.sub(r"\n{3,}", "\n\n", normalized)
|
||||
return normalized.strip()
|
||||
|
||||
|
||||
def _extract_pdf_text(file_path: Path) -> str:
|
||||
pdftotext_bin = shutil.which("pdftotext")
|
||||
if not pdftotext_bin:
|
||||
@@ -83,6 +107,7 @@ def _extract_pdf_text(file_path: Path) -> str:
|
||||
return ""
|
||||
return str(completed.stdout or "")
|
||||
|
||||
|
||||
def _extract_text_with_ocr(
|
||||
*,
|
||||
file_path: Path,
|
||||
@@ -92,9 +117,7 @@ def _extract_text_with_ocr(
|
||||
try:
|
||||
from app.services.ocr import OcrService
|
||||
|
||||
result = OcrService().recognize_files(
|
||||
[(original_name, file_path.read_bytes(), mime_type)]
|
||||
)
|
||||
result = OcrService().recognize_files([(original_name, file_path.read_bytes(), mime_type)])
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
@@ -108,6 +131,7 @@ def _extract_text_with_ocr(
|
||||
parts.append(summary)
|
||||
return "\n\n".join(part for part in parts if part)
|
||||
|
||||
|
||||
def _extract_xlsx_sheets(file_path: Path) -> list[tuple[str, list[list[str]]]]:
|
||||
try:
|
||||
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)
|
||||
|
||||
if cell_type == "inlineStr":
|
||||
text_node = next((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 "")
|
||||
text_node = next(
|
||||
(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
|
||||
|
||||
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()
|
||||
if cell_type == "s" and raw_value.isdigit():
|
||||
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:
|
||||
row_values.append(raw_value)
|
||||
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):
|
||||
return []
|
||||
|
||||
|
||||
def _extract_pptx_slides(file_path: Path) -> list[list[str]]:
|
||||
try:
|
||||
with ZipFile(file_path) as archive:
|
||||
@@ -216,8 +248,91 @@ def _extract_pptx_slides(file_path: Path) -> list[list[str]]:
|
||||
slides: list[list[str]] = []
|
||||
for slide_name in slide_names:
|
||||
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)
|
||||
return slides
|
||||
except (BadZipFile, ElementTree.ParseError, KeyError):
|
||||
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("|", "\\|")
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.services.knowledge_document_extractors import (
|
||||
)
|
||||
from app.services.knowledge_file_utils import extract_extension, format_size
|
||||
|
||||
|
||||
def build_preview(
|
||||
entry: dict[str, Any],
|
||||
*,
|
||||
@@ -52,7 +53,9 @@ def build_preview(
|
||||
subtitle="当前格式暂不支持在线解析预览。",
|
||||
stats=[
|
||||
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="下载后查看"),
|
||||
],
|
||||
blocks=[
|
||||
@@ -68,9 +71,8 @@ def build_preview(
|
||||
],
|
||||
)
|
||||
|
||||
def _build_text_preview_page(
|
||||
entry: dict[str, Any], text: str
|
||||
) -> KnowledgePreviewPageRead:
|
||||
|
||||
def _build_text_preview_page(entry: dict[str, Any], text: str) -> KnowledgePreviewPageRead:
|
||||
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
||||
if not lines:
|
||||
lines = ["文件内容为空,或当前文档未提取到可展示文本。"]
|
||||
@@ -92,10 +94,9 @@ def _build_text_preview_page(
|
||||
blocks=blocks,
|
||||
)
|
||||
|
||||
def _build_xlsx_preview_pages(
|
||||
entry: dict[str, Any], file_path
|
||||
) -> list[KnowledgePreviewPageRead]:
|
||||
sheets = self._extract_xlsx_sheets(file_path)
|
||||
|
||||
def _build_xlsx_preview_pages(entry: dict[str, Any], file_path) -> list[KnowledgePreviewPageRead]:
|
||||
sheets = _extract_xlsx_sheets(file_path)
|
||||
if not sheets:
|
||||
sheets = [("Sheet 1", [["未提取到表格内容。"]])]
|
||||
|
||||
@@ -118,7 +119,9 @@ def _build_xlsx_preview_pages(
|
||||
stats=[
|
||||
KnowledgePreviewStatRead(label="工作表数量", value=str(sheet_count)),
|
||||
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,
|
||||
)
|
||||
@@ -126,10 +129,9 @@ def _build_xlsx_preview_pages(
|
||||
|
||||
return preview_pages
|
||||
|
||||
def _build_pptx_preview_pages(
|
||||
entry: dict[str, Any], file_path
|
||||
) -> list[KnowledgePreviewPageRead]:
|
||||
slides = self._extract_pptx_slides(file_path)
|
||||
|
||||
def _build_pptx_preview_pages(entry: dict[str, Any], file_path) -> list[KnowledgePreviewPageRead]:
|
||||
slides = _extract_pptx_slides(file_path)
|
||||
if not slides:
|
||||
slides = [["未提取到幻灯片文本。"]]
|
||||
|
||||
@@ -154,4 +156,3 @@ def _build_pptx_preview_pages(
|
||||
)
|
||||
|
||||
return pages
|
||||
|
||||
|
||||
@@ -114,6 +114,20 @@ class OntologyDetectionMixin:
|
||||
return "query", 0.24
|
||||
if any(keyword in compact_query for keyword in DRAFT_KEYWORDS):
|
||||
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):
|
||||
return "draft", 0.24
|
||||
if any(keyword in compact_query for keyword in COMPARE_KEYWORDS):
|
||||
@@ -220,7 +234,11 @@ class OntologyDetectionMixin:
|
||||
has_expense_signal = any(
|
||||
keyword in compact_query for keyword in EXPENSE_NARRATIVE_KEYWORDS
|
||||
) 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
|
||||
|
||||
|
||||
@@ -186,7 +186,21 @@ class OntologyExtractionMixin:
|
||||
|
||||
if any(
|
||||
keyword in query
|
||||
for keyword in ("打车", "网约车", "出租车", "车费", "乘车", "用车", "叫车", "车资", "停车费", "过路费")
|
||||
for keyword in (
|
||||
"打车",
|
||||
"网约车",
|
||||
"出租车",
|
||||
"出租车票",
|
||||
"车费",
|
||||
"乘车",
|
||||
"用车",
|
||||
"叫车",
|
||||
"车资",
|
||||
"的士",
|
||||
"的士票",
|
||||
"停车费",
|
||||
"过路费",
|
||||
)
|
||||
):
|
||||
upsert(self._make_entity("expense_type", "交通", "transport", role="filter", confidence=0.9))
|
||||
|
||||
|
||||
@@ -137,11 +137,14 @@ EXPENSE_TYPE_KEYWORDS = {
|
||||
"打车": "transport",
|
||||
"网约车": "transport",
|
||||
"出租车": "transport",
|
||||
"出租车票": "transport",
|
||||
"乘车": "transport",
|
||||
"乘车费": "transport",
|
||||
"用车": "transport",
|
||||
"叫车": "transport",
|
||||
"车资": "transport",
|
||||
"的士": "transport",
|
||||
"的士票": "transport",
|
||||
"停车费": "transport",
|
||||
"餐费": "meal",
|
||||
"用餐": "meal",
|
||||
@@ -180,6 +183,9 @@ EXPENSE_NARRATIVE_KEYWORDS = (
|
||||
"用车",
|
||||
"叫车",
|
||||
"车资",
|
||||
"的士",
|
||||
"的士票",
|
||||
"出租车票",
|
||||
"餐费",
|
||||
"吃饭",
|
||||
"用餐",
|
||||
@@ -232,6 +238,9 @@ STATUS_KEYWORDS = {
|
||||
"已审批": "approved",
|
||||
"已通过": "approved",
|
||||
"已审核": "approved",
|
||||
"归档": "archived",
|
||||
"已归档": "archived",
|
||||
"入账": "archived",
|
||||
"已入账": "paid",
|
||||
"已付款": "paid",
|
||||
"未付款": "unpaid",
|
||||
|
||||
@@ -17,14 +17,50 @@ from app.schemas.ontology import OntologyParseResult
|
||||
PRIVILEGED_EXPENSE_QUERY_ROLE_CODES = {"finance"}
|
||||
SELF_REFERENCE_KEYWORDS = ("我的", "我自己", "本人", "我名下", "给我查", "我提交", "我申请")
|
||||
EXPENSE_QUERY_RECENT_WINDOW_DAYS = 10
|
||||
EXPENSE_QUERY_PREVIEW_LIMIT = 20
|
||||
EXPENSE_QUERY_PREVIEW_LIMIT = 5
|
||||
EXPENSE_STATUS_LABELS = {
|
||||
"archived": "归档",
|
||||
"draft": "草稿",
|
||||
"supplement": "待补充",
|
||||
"returned": "已退回",
|
||||
"submitted": "已提交",
|
||||
"review": "审核中",
|
||||
"approved": "已通过",
|
||||
"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 = {
|
||||
"draft": "草稿",
|
||||
@@ -33,6 +69,13 @@ EXPENSE_STATUS_GROUP_LABELS = {
|
||||
"other": "其他状态",
|
||||
}
|
||||
EXPENSE_STATUS_GROUP_ORDER = ("draft", "in_progress", "completed", "other")
|
||||
EXPENSE_RISK_LEVEL_LABELS = {
|
||||
"high": "高风险",
|
||||
"medium": "中风险",
|
||||
"warning": "中风险",
|
||||
"low": "低风险",
|
||||
"info": "低风险",
|
||||
}
|
||||
EXPENSE_TYPE_LABELS = {
|
||||
"travel": "差旅费",
|
||||
"hotel": "住宿费",
|
||||
@@ -95,7 +138,7 @@ class OrchestratorDatabaseQueryBuilder:
|
||||
total_count = int(self.db.scalar(count_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_amount = total_amount
|
||||
older_record_count = 0
|
||||
@@ -146,12 +189,14 @@ class OrchestratorDatabaseQueryBuilder:
|
||||
"record_count": display_count,
|
||||
"total_amount": round(display_amount, 2),
|
||||
"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,
|
||||
"recent_window_applied": recent_window_applied,
|
||||
"window_days": EXPENSE_QUERY_RECENT_WINDOW_DAYS if recent_window_applied else None,
|
||||
"window_start_date": window_start_date,
|
||||
"window_end_date": window_end_date,
|
||||
"preview_count": len(preview_claims),
|
||||
"preview_limit": EXPENSE_QUERY_PREVIEW_LIMIT,
|
||||
"older_record_count": older_record_count,
|
||||
"records": [
|
||||
self._build_expense_query_record(claim)
|
||||
@@ -199,6 +244,7 @@ class OrchestratorDatabaseQueryBuilder:
|
||||
@staticmethod
|
||||
def _should_limit_expense_query_to_recent_window(
|
||||
ontology: OntologyParseResult,
|
||||
message: str = "",
|
||||
) -> bool:
|
||||
has_explicit_claim_no = any(
|
||||
item.type == "expense_claim"
|
||||
@@ -208,7 +254,12 @@ class OrchestratorDatabaseQueryBuilder:
|
||||
has_explicit_time_range = bool(
|
||||
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
|
||||
def _resolve_reference_now(context_json: dict[str, Any]) -> datetime:
|
||||
@@ -294,6 +345,12 @@ class OrchestratorDatabaseQueryBuilder:
|
||||
) -> dict[str, Any]:
|
||||
status_group, status_group_label = self._resolve_expense_status_group(claim.status)
|
||||
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 {
|
||||
"claim_id": claim.id,
|
||||
"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 "报销"),
|
||||
"amount": round(float(claim.amount), 2),
|
||||
"status": claim.status,
|
||||
"status_label": EXPENSE_STATUS_LABELS.get(claim.status, claim.status or "处理中"),
|
||||
"status_label": status_label,
|
||||
"status_group": status_group,
|
||||
"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 "",
|
||||
"occurred_at": claim.occurred_at.date().isoformat() if claim.occurred_at else "",
|
||||
"reason": claim.reason,
|
||||
"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(
|
||||
self,
|
||||
*,
|
||||
@@ -344,12 +448,13 @@ class OrchestratorDatabaseQueryBuilder:
|
||||
)
|
||||
project_values = self._collect_expense_query_filter_values(ontology, "project")
|
||||
location_values = self._collect_expense_query_filter_values(ontology, "location")
|
||||
status_values = list(
|
||||
dict.fromkeys(
|
||||
status_values = self._resolve_expense_query_status_values(
|
||||
[
|
||||
str(item.value).strip()
|
||||
for item in ontology.constraints
|
||||
if item.field == "status" and item.operator == "=" and str(item.value).strip()
|
||||
)
|
||||
],
|
||||
message,
|
||||
)
|
||||
amount_constraints = [
|
||||
item
|
||||
@@ -363,8 +468,16 @@ class OrchestratorDatabaseQueryBuilder:
|
||||
conditions.append(ExpenseClaim.claim_no.in_(expense_claim_nos))
|
||||
if expense_types:
|
||||
conditions.append(ExpenseClaim.expense_type.in_(expense_types))
|
||||
if status_values:
|
||||
conditions.append(ExpenseClaim.status.in_(status_values))
|
||||
direct_status_values = [status for status in status_values if status != "archived"]
|
||||
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:
|
||||
project_conditions = []
|
||||
for value in project_values:
|
||||
@@ -438,7 +551,49 @@ class OrchestratorDatabaseQueryBuilder:
|
||||
else:
|
||||
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
|
||||
def _collect_expense_query_filter_values(
|
||||
|
||||
@@ -365,25 +365,13 @@ class UserAgentResponseMixin:
|
||||
)
|
||||
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 = [
|
||||
f"我先为你列出{window_prefix}的{query_payload.scope_label},"
|
||||
f"共 {query_payload.record_count} 笔,金额合计 {query_payload.total_amount:.2f} 元。"
|
||||
f"已按你的筛选条件查询{query_payload.scope_label}。",
|
||||
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] = []
|
||||
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:
|
||||
hint_parts.append(
|
||||
@@ -448,6 +436,11 @@ class UserAgentResponseMixin:
|
||||
occurred_at=str(item.get("occurred_at") or "").strip(),
|
||||
reason=str(item.get("reason") 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(
|
||||
result_type="expense_claim_list",
|
||||
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")),
|
||||
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)),
|
||||
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)),
|
||||
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),
|
||||
@@ -670,18 +665,7 @@ class UserAgentResponseMixin:
|
||||
]
|
||||
|
||||
if payload.ontology.intent in {"query", "compare"}:
|
||||
return [
|
||||
UserAgentSuggestedAction(
|
||||
label="查看明细",
|
||||
action_type="open_detail",
|
||||
description="继续查看命中记录和过滤条件。",
|
||||
),
|
||||
UserAgentSuggestedAction(
|
||||
label="生成处理意见",
|
||||
action_type="create_draft",
|
||||
description="把当前查询结果整理成可确认草稿。",
|
||||
),
|
||||
]
|
||||
return []
|
||||
|
||||
if payload.ontology.intent == "risk_check":
|
||||
return [
|
||||
|
||||
@@ -322,6 +322,7 @@ class UserAgentReviewCoreMixin:
|
||||
avg_score=float(item.get("avg_score") or 0.0),
|
||||
preview_kind=str(item.get("preview_kind") 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()],
|
||||
fields=[
|
||||
UserAgentReviewDocumentField(
|
||||
@@ -411,16 +412,26 @@ class UserAgentReviewCoreMixin:
|
||||
) -> list[UserAgentReviewRiskBrief]:
|
||||
briefs: list[UserAgentReviewRiskBrief] = []
|
||||
for reason in self._resolve_submission_blocked_reasons(payload):
|
||||
needs_exception_explanation = self._is_submission_exception_explanation_reason(reason)
|
||||
briefs.append(
|
||||
UserAgentReviewRiskBrief(
|
||||
title="提交风险提示",
|
||||
level=self._resolve_submission_blocked_risk_level(reason),
|
||||
content=reason,
|
||||
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"
|
||||
|
||||
|
||||
@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
|
||||
def _filter_deprecated_review_risk_briefs(
|
||||
briefs: list[UserAgentReviewRiskBrief],
|
||||
|
||||
@@ -183,9 +183,9 @@ class UserAgentReviewMessageMixin:
|
||||
if draft_payload is not None and draft_payload.claim_no:
|
||||
return (
|
||||
f"已按您当前确认的信息保存为草稿 {draft_payload.claim_no}。"
|
||||
"后续您可以继续补充缺失项,或修改识别结果后再继续提交。"
|
||||
"后续上传附件或补充票据信息时,请关联这张草稿;补齐缺失项后再继续提交。"
|
||||
)
|
||||
return "已按您当前确认的信息保存为草稿。后续您可以继续补充缺失项,或修改识别结果后再继续提交。"
|
||||
return "已按您当前确认的信息保存为草稿。后续上传附件或补充票据信息时,请关联这张草稿;补齐缺失项后再继续提交。"
|
||||
if review_action == "link_to_existing_draft":
|
||||
document_count = self._resolve_review_document_count(payload)
|
||||
followup_copy = self._build_review_action_followup_copy(review_payload)
|
||||
@@ -214,6 +214,12 @@ class UserAgentReviewMessageMixin:
|
||||
reason_lines = "\n".join(
|
||||
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 (
|
||||
"AI预审暂未通过,所以还没有提交到审批人。\n"
|
||||
f"{reason_lines}\n"
|
||||
@@ -253,6 +259,12 @@ class UserAgentReviewMessageMixin:
|
||||
blocked_reasons = self._resolve_submission_blocked_reasons(payload)
|
||||
if blocked_reasons:
|
||||
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 (
|
||||
f"AI预审未通过:{reason_text}。"
|
||||
"请先根据风险提示补充原因、调整金额或更换附件,整改后再继续提交。"
|
||||
@@ -670,4 +682,3 @@ class UserAgentReviewMessageMixin:
|
||||
if not claim_groups:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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": []
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 150 KiB |
@@ -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.退房时间为中午12:00,超时退房将按酒店规定收取相关费用。\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": []
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 150 KiB |
Binary file not shown.
@@ -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": []
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
@@ -14,8 +14,8 @@
|
||||
"updated_at": "2026-05-17T09:28:28.999515+00:00",
|
||||
"uploaded_by": "admin",
|
||||
"version_number": 1,
|
||||
"ingest_status": 3,
|
||||
"ingest_status_updated_at": "2026-05-17T10:01:33.272539+00:00",
|
||||
"ingest_status": 1,
|
||||
"ingest_status_updated_at": "2026-05-22T07:04:12.388160+00:00",
|
||||
"ingest_completed_at": "2026-05-17T10:01:33.272539+00:00",
|
||||
"ingest_document_name": "远光《公司支出管理办法(2024)》.pdf",
|
||||
"ingest_document_updated_at": "2026-05-17T09:28:28.999515+00:00",
|
||||
@@ -23,25 +23,739 @@
|
||||
"ingest_agent_run_id": "run_3a0b0ecb941b4c8e"
|
||||
},
|
||||
{
|
||||
"id": "a8f8465df08e455ebe133351721d49f8",
|
||||
"folder": "报销制度",
|
||||
"original_name": "无单需求文档0506.docx",
|
||||
"stored_name": "a8f8465df08e455ebe133351721d49f8__无单需求文档0506.docx",
|
||||
"id": "c7601043d9944ef2bcf4d3f67ed253f7",
|
||||
"folder": "财务知识库",
|
||||
"original_name": "远光软件会计科目使用说明.xlsx",
|
||||
"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",
|
||||
"extension": "docx",
|
||||
"size_bytes": 454307,
|
||||
"sha256": "00985ec85a8163be9c9ffc5eb522df18ed52d4b131ceed12102c2d75e4df85a9",
|
||||
"created_at": "2026-05-17T13:00:09.485818+00:00",
|
||||
"updated_at": "2026-05-17T13:00:09.485818+00:00",
|
||||
"uploaded_by": "admin",
|
||||
"size_bytes": 36653,
|
||||
"sha256": "",
|
||||
"created_at": "2026-05-22T07:00:22.011016+00:00",
|
||||
"updated_at": "2026-05-22T07:00:22.011016+00:00",
|
||||
"uploaded_by": "系统导入",
|
||||
"version_number": 1,
|
||||
"ingest_status": 3,
|
||||
"ingest_status_updated_at": "2026-05-21T15:56:58.286585+00:00",
|
||||
"ingest_completed_at": "2026-05-21T15:56:58.286585+00:00",
|
||||
"ingest_document_name": "无单需求文档0506.docx",
|
||||
"ingest_document_updated_at": "2026-05-17T13:00:09.485818+00:00",
|
||||
"ingest_document_sha256": "00985ec85a8163be9c9ffc5eb522df18ed52d4b131ceed12102c2d75e4df85a9",
|
||||
"ingest_agent_run_id": "run_9f4f60cf545c470f"
|
||||
"ingest_status": 1,
|
||||
"ingest_status_updated_at": "2026-05-22T07:03:57.861469+00:00",
|
||||
"ingest_completed_at": "",
|
||||
"ingest_document_name": "",
|
||||
"ingest_document_updated_at": "",
|
||||
"ingest_document_sha256": "",
|
||||
"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": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
server/storage/knowledge/制度政策/远光软件公司内部控制基本规范.pdf
Normal file
BIN
server/storage/knowledge/制度政策/远光软件公司内部控制基本规范.pdf
Normal file
Binary file not shown.
BIN
server/storage/knowledge/制度政策/远光软件公司合同管理制度.docx
Normal file
BIN
server/storage/knowledge/制度政策/远光软件公司合同管理制度.docx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/制度政策/远光软件公司财务管理制度总则.docx
Normal file
BIN
server/storage/knowledge/制度政策/远光软件公司财务管理制度总则.docx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/制度政策/远光软件公司资产管理制度.pdf
Normal file
BIN
server/storage/knowledge/制度政策/远光软件公司资产管理制度.pdf
Normal file
Binary file not shown.
BIN
server/storage/knowledge/制度政策/远光软件公司采购管理办法.xlsx
Normal file
BIN
server/storage/knowledge/制度政策/远光软件公司采购管理办法.xlsx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/发票管理/远光软件公司发票审核标准.xlsx
Normal file
BIN
server/storage/knowledge/发票管理/远光软件公司发票审核标准.xlsx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/发票管理/远光软件公司发票管理规范.docx
Normal file
BIN
server/storage/knowledge/发票管理/远光软件公司发票管理规范.docx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/发票管理/远光软件公司增值税发票操作指南.pdf
Normal file
BIN
server/storage/knowledge/发票管理/远光软件公司增值税发票操作指南.pdf
Normal file
Binary file not shown.
BIN
server/storage/knowledge/发票管理/远光软件公司电子发票管理办法.docx
Normal file
BIN
server/storage/knowledge/发票管理/远光软件公司电子发票管理办法.docx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/培训资料/远光软件报销流程培训手册.pdf
Normal file
BIN
server/storage/knowledge/培训资料/远光软件报销流程培训手册.pdf
Normal file
Binary file not shown.
BIN
server/storage/knowledge/培训资料/远光软件新员工财务培训课件.pdf
Normal file
BIN
server/storage/knowledge/培训资料/远光软件新员工财务培训课件.pdf
Normal file
Binary file not shown.
BIN
server/storage/knowledge/培训资料/远光软件财务制度培训手册.docx
Normal file
BIN
server/storage/knowledge/培训资料/远光软件财务制度培训手册.docx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/培训资料/远光软件财务培训课程安排.xlsx
Normal file
BIN
server/storage/knowledge/培训资料/远光软件财务培训课程安排.xlsx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/差旅规范/远光软件公司差旅费管理办法.docx
Normal file
BIN
server/storage/knowledge/差旅规范/远光软件公司差旅费管理办法.docx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/差旅规范/远光软件出差审批流程说明.pdf
Normal file
BIN
server/storage/knowledge/差旅规范/远光软件出差审批流程说明.pdf
Normal file
Binary file not shown.
BIN
server/storage/knowledge/差旅规范/远光软件国际出差管理规定.docx
Normal file
BIN
server/storage/knowledge/差旅规范/远光软件国际出差管理规定.docx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/差旅规范/远光软件差旅费标准速查表.xlsx
Normal file
BIN
server/storage/knowledge/差旅规范/远光软件差旅费标准速查表.xlsx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/常见问答/远光软件报销问题处理指引.xlsx
Normal file
BIN
server/storage/knowledge/常见问答/远光软件报销问题处理指引.xlsx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/常见问答/远光软件财务制度问答汇总.pdf
Normal file
BIN
server/storage/knowledge/常见问答/远光软件财务制度问答汇总.pdf
Normal file
Binary file not shown.
BIN
server/storage/knowledge/常见问答/远光软件财务报销常见问题解答.docx
Normal file
BIN
server/storage/knowledge/常见问答/远光软件财务报销常见问题解答.docx
Normal file
Binary file not shown.
Binary file not shown.
BIN
server/storage/knowledge/税务合规/远光软件企业所得税汇算清缴操作手册.pdf
Normal file
BIN
server/storage/knowledge/税务合规/远光软件企业所得税汇算清缴操作手册.pdf
Normal file
Binary file not shown.
BIN
server/storage/knowledge/税务合规/远光软件公司税务管理制度.docx
Normal file
BIN
server/storage/knowledge/税务合规/远光软件公司税务管理制度.docx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/税务合规/远光软件研发费用加计扣除管理办法.xlsx
Normal file
BIN
server/storage/knowledge/税务合规/远光软件研发费用加计扣除管理办法.xlsx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/税务合规/远光软件软件产品增值税即征即退操作指南.pdf
Normal file
BIN
server/storage/knowledge/税务合规/远光软件软件产品增值税即征即退操作指南.pdf
Normal file
Binary file not shown.
BIN
server/storage/knowledge/财务共享/远光软件财务共享服务SLA标准.xlsx
Normal file
BIN
server/storage/knowledge/财务共享/远光软件财务共享服务SLA标准.xlsx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/财务共享/远光软件财务共享服务中心运营管理办法.docx
Normal file
BIN
server/storage/knowledge/财务共享/远光软件财务共享服务中心运营管理办法.docx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/财务共享/远光软件财务共享服务操作手册.pdf
Normal file
BIN
server/storage/knowledge/财务共享/远光软件财务共享服务操作手册.pdf
Normal file
Binary file not shown.
BIN
server/storage/knowledge/财务知识库/远光软件会计科目使用说明.xlsx
Normal file
BIN
server/storage/knowledge/财务知识库/远光软件会计科目使用说明.xlsx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/财务知识库/远光软件财务基础知识手册.docx
Normal file
BIN
server/storage/knowledge/财务知识库/远光软件财务基础知识手册.docx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/财务知识库/远光软件财务术语解释手册.docx
Normal file
BIN
server/storage/knowledge/财务知识库/远光软件财务术语解释手册.docx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/财务知识库/远光软件高新技术企业税收优惠政策汇总.pdf
Normal file
BIN
server/storage/knowledge/财务知识库/远光软件高新技术企业税收优惠政策汇总.pdf
Normal file
Binary file not shown.
BIN
server/storage/knowledge/预算管理/远光软件公司预算管理制度.docx
Normal file
BIN
server/storage/knowledge/预算管理/远光软件公司预算管理制度.docx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/预算管理/远光软件年度预算编制指南.pdf
Normal file
BIN
server/storage/knowledge/预算管理/远光软件年度预算编制指南.pdf
Normal file
Binary file not shown.
BIN
server/storage/knowledge/预算管理/远光软件预算执行分析报告模板.docx
Normal file
BIN
server/storage/knowledge/预算管理/远光软件预算执行分析报告模板.docx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/预算管理/远光软件预算编制模板.xlsx
Normal file
BIN
server/storage/knowledge/预算管理/远光软件预算编制模板.xlsx
Normal file
Binary file not shown.
@@ -1516,8 +1516,21 @@ def test_upload_hotel_attachment_flags_amount_over_travel_policy(monkeypatch, tm
|
||||
analysis = uploaded_meta["analysis"]
|
||||
assert analysis["severity"] == "high"
|
||||
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 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:
|
||||
@@ -2433,8 +2446,8 @@ def test_list_claims_allows_finance_to_view_all_records() -> None:
|
||||
|
||||
claims = ExpenseClaimService(db).list_claims(current_user)
|
||||
|
||||
assert len(claims) == 2
|
||||
assert {claim.claim_no for claim in claims} == {"EXP-FIN-101", "EXP-FIN-102"}
|
||||
assert len(claims) == 1
|
||||
assert claims[0].claim_no == "EXP-FIN-101"
|
||||
|
||||
|
||||
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)
|
||||
|
||||
assert len(claims) == 2
|
||||
assert {claim.claim_no for claim in claims} == {"EXP-EXE-101", "EXP-EXE-102"}
|
||||
assert len(claims) == 1
|
||||
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:
|
||||
|
||||
96
server/tests/test_knowledge_document_extractors.py
Normal file
96
server/tests/test_knowledge_document_extractors.py
Normal 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)
|
||||
@@ -28,6 +28,15 @@ def build_session() -> Session:
|
||||
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(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
|
||||
@@ -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:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
|
||||
@@ -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"] == "差旅费"
|
||||
|
||||
|
||||
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(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
@@ -322,6 +376,89 @@ def test_orchestrator_history_query_filters_location_time_and_returns_real_amoun
|
||||
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(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
|
||||
@@ -700,6 +700,37 @@ def test_user_agent_guides_riding_fare_as_transport_expense() -> None:
|
||||
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:
|
||||
session_factory = build_session_factory()
|
||||
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:
|
||||
session_factory = build_session_factory()
|
||||
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:
|
||||
assert UserAgentService._resolve_submission_blocked_risk_level("住宿金额超出当前职级差标") == "high"
|
||||
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:
|
||||
@@ -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.answer == response.review_payload.body_message
|
||||
assert response.answer.startswith("AI预审未通过:住宿金额超出当前职级差标")
|
||||
assert "整改后再继续提交" in response.answer
|
||||
assert response.answer.startswith("检测到当前单据存在需要说明的超标风险")
|
||||
assert "票据会先正常归集到单据中" in response.answer
|
||||
assert "附加说明" in response.answer
|
||||
assert response.review_payload.can_proceed is False
|
||||
blocked_brief = next(item for item in response.review_payload.risk_briefs if item.title == "提交风险提示")
|
||||
assert blocked_brief.level == "high"
|
||||
assert "不是票据归集阻断条件" in blocked_brief.detail
|
||||
assert not any(item.title == "AI预审未通过" for item in response.review_payload.risk_briefs)
|
||||
|
||||
|
||||
|
||||
@@ -96,6 +96,7 @@
|
||||
}
|
||||
.main.requests-main,
|
||||
.main.approval-main,
|
||||
.main.archive-main,
|
||||
.main.policies-main,
|
||||
.main.audit-main,
|
||||
.main.logs-main,
|
||||
@@ -114,6 +115,7 @@
|
||||
.workarea { min-height: 0; overflow: auto; padding: 24px; }
|
||||
.workarea.requests-workarea,
|
||||
.workarea.approval-workarea,
|
||||
.workarea.archive-workarea,
|
||||
.workarea.policies-workarea,
|
||||
.workarea.audit-workarea,
|
||||
.workarea.logs-workarea,
|
||||
|
||||
54
web/src/assets/styles/views/archive-center-view.css
Normal file
54
web/src/assets/styles/views/archive-center-view.css
Normal 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;
|
||||
}
|
||||
@@ -1089,23 +1089,3 @@
|
||||
gap: 12px;
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -453,10 +453,6 @@
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.review-upload-decision-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.primary-dialog-btn,
|
||||
.secondary-dialog-btn,
|
||||
.danger-dialog-btn {
|
||||
|
||||
@@ -740,6 +740,38 @@
|
||||
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) {
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
@@ -766,6 +798,22 @@
|
||||
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) {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
@@ -1237,6 +1285,71 @@
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1513,4 +1626,3 @@
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
|
||||
@@ -606,54 +606,6 @@
|
||||
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 {
|
||||
border-color: rgba(5, 150, 105, .18);
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f7fdfb 100%);
|
||||
|
||||
@@ -79,8 +79,9 @@ const {
|
||||
const sidebarMeta = {
|
||||
overview: { label: '财务总览' },
|
||||
workbench: { label: '个人工作台' },
|
||||
requests: { label: '个人报销' },
|
||||
requests: { label: '报销中心' },
|
||||
approval: { label: '审批中心' },
|
||||
archive: { label: '归档中心' },
|
||||
policies: { label: '知识管理' },
|
||||
audit: { label: '任务规则中心' },
|
||||
logs: { label: '日志管理' },
|
||||
|
||||
@@ -6,92 +6,29 @@ import { useNavigation, navItems } from './useNavigation.js'
|
||||
import { useRequests } from './useRequests.js'
|
||||
import { useSystemState } from './useSystemState.js'
|
||||
import { useToast } from './useToast.js'
|
||||
import { fetchLatestConversation } from '../services/orchestrator.js'
|
||||
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
|
||||
import { buildWorkbenchSummary } from '../utils/workbenchSummary.js'
|
||||
|
||||
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() {
|
||||
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 { buildWorkbenchSummary } from '../utils/workbenchSummary.js'
|
||||
|
||||
const SESSION_TYPE_EXPENSE = 'expense'
|
||||
|
||||
export function useAppShell() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
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 smartEntryInvalidatedDraftClaimId = ref('')
|
||||
|
||||
const { activeView, currentView, setView } = useNavigation()
|
||||
const {
|
||||
@@ -208,25 +145,56 @@ export function useAppShell() {
|
||||
setView(view)
|
||||
}
|
||||
|
||||
function openTravelCreate() {
|
||||
smartEntryOpen.value = true
|
||||
smartEntryContext.value = { prompt: '', source: 'topbar', request: null, files: [], conversation: null }
|
||||
smartEntrySessionId.value += 1
|
||||
}
|
||||
function openTravelCreate() {
|
||||
smartEntryOpen.value = true
|
||||
smartEntryContext.value = {
|
||||
prompt: '',
|
||||
source: 'topbar',
|
||||
request: null,
|
||||
files: [],
|
||||
conversation: null,
|
||||
scope: null
|
||||
}
|
||||
smartEntrySessionId.value += 1
|
||||
}
|
||||
|
||||
function resolveCurrentUserId() {
|
||||
const user = currentUser.value || {}
|
||||
return String(user.username || user.name || 'anonymous').trim() || 'anonymous'
|
||||
}
|
||||
|
||||
async function resolveSmartEntryConversation(payload = {}) {
|
||||
if (payload.conversation) {
|
||||
return payload.conversation
|
||||
}
|
||||
|
||||
if (!payload.restoreLatestConversation) {
|
||||
return null
|
||||
}
|
||||
function resolveCurrentUserId() {
|
||||
const user = currentUser.value || {}
|
||||
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 = {}) {
|
||||
if (payload.conversation) {
|
||||
return payload.conversation
|
||||
}
|
||||
|
||||
if (isDetailClaimScopedPayload(payload)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!payload.restoreLatestConversation) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const latestPayload = await fetchLatestConversation(resolveCurrentUserId(), SESSION_TYPE_EXPENSE, {
|
||||
@@ -240,17 +208,19 @@ export function useAppShell() {
|
||||
}
|
||||
}
|
||||
|
||||
async function openSmartEntry(payload = {}) {
|
||||
const conversation = await resolveSmartEntryConversation(payload)
|
||||
smartEntryOpen.value = true
|
||||
|
||||
smartEntryContext.value = {
|
||||
prompt: payload.prompt ?? '',
|
||||
source: payload.source ?? 'workbench',
|
||||
request: payload.request ?? selectedRequest.value,
|
||||
files: Array.isArray(payload.files) ? payload.files : [],
|
||||
conversation
|
||||
}
|
||||
async function openSmartEntry(payload = {}) {
|
||||
const conversation = await resolveSmartEntryConversation(payload)
|
||||
const scope = resolveSmartEntryClaimScope(payload)
|
||||
smartEntryOpen.value = true
|
||||
|
||||
smartEntryContext.value = {
|
||||
prompt: payload.prompt ?? '',
|
||||
source: payload.source ?? 'workbench',
|
||||
request: payload.request ?? selectedRequest.value,
|
||||
files: Array.isArray(payload.files) ? payload.files : [],
|
||||
conversation,
|
||||
scope
|
||||
}
|
||||
smartEntrySessionId.value += 1
|
||||
}
|
||||
|
||||
@@ -262,15 +232,15 @@ export function useAppShell() {
|
||||
const claimNo = String(payload.claimNo || payload.claim_no || '').trim()
|
||||
const status = String(payload.status || payload.claimStatus || '').trim()
|
||||
const approvalStage = String(payload.approvalStage || payload.approval_stage || '').trim()
|
||||
smartEntryOpen.value = false
|
||||
await reloadRequests()
|
||||
void refreshApprovalInbox()
|
||||
if (status === 'submitted') {
|
||||
smartEntryOpen.value = false
|
||||
void refreshApprovalInbox()
|
||||
toast(`${claimNo || '该'}单据已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}。`)
|
||||
} else {
|
||||
toast(`${claimNo || '该'}单据已保存到草稿,请到报销页面查看。`)
|
||||
router.push({ name: 'app-requests' })
|
||||
return
|
||||
}
|
||||
router.push({ name: 'app-requests' })
|
||||
toast(`${claimNo || '该'}单据已保存为草稿,可继续上传票据或补充信息。`)
|
||||
}
|
||||
|
||||
function openRequestDetail(request) {
|
||||
@@ -289,7 +259,13 @@ export function useAppShell() {
|
||||
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()
|
||||
void refreshApprovalInbox()
|
||||
router.push({ name: 'app-requests' })
|
||||
@@ -327,6 +303,7 @@ export function useAppShell() {
|
||||
selectedRequest,
|
||||
setView,
|
||||
smartEntryContext,
|
||||
smartEntryInvalidatedDraftClaimId,
|
||||
smartEntryOpen,
|
||||
smartEntrySessionId,
|
||||
detailAlerts,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
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 = [
|
||||
{
|
||||
@@ -24,10 +24,10 @@ export const navItems = [
|
||||
},
|
||||
{
|
||||
id: 'requests',
|
||||
label: '个人报销',
|
||||
navHint: '查看和管理个人报销',
|
||||
label: '报销中心',
|
||||
navHint: '查看和管理报销单据',
|
||||
icon: icons.list,
|
||||
title: '个人报销',
|
||||
title: '报销中心',
|
||||
desc: '集中查看草稿、审批进度、票据状态与风险提示。'
|
||||
},
|
||||
{
|
||||
@@ -38,6 +38,14 @@ export const navItems = [
|
||||
title: '审批中心',
|
||||
desc: '按优先级处理待审批事项,控制时效与风险。'
|
||||
},
|
||||
{
|
||||
id: 'archive',
|
||||
label: '归档中心',
|
||||
navHint: '查阅公司已归档财务数据',
|
||||
icon: icons.archive,
|
||||
title: '归档中心',
|
||||
desc: '集中保存公司已归档入账的报销单据,形成完整财务归档库。'
|
||||
},
|
||||
{
|
||||
id: 'policies',
|
||||
label: '制度知识',
|
||||
@@ -85,6 +93,7 @@ const viewRouteNames = {
|
||||
workbench: 'app-workbench',
|
||||
requests: 'app-requests',
|
||||
approval: 'app-approval',
|
||||
archive: 'app-archive',
|
||||
policies: 'app-policies',
|
||||
audit: 'app-audit',
|
||||
logs: 'app-logs',
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
|
||||
import { fetchExpenseClaims } from '../services/reimbursements.js'
|
||||
import { filterActionableRiskFlags } from '../utils/riskFlags.js'
|
||||
|
||||
const EXPENSE_TYPE_LABELS = {
|
||||
travel: '差旅费',
|
||||
train_ticket: '火车票',
|
||||
flight_ticket: '机票',
|
||||
ship_ticket: '轮船票',
|
||||
ferry_ticket: '轮船票',
|
||||
hotel_ticket: '住宿票',
|
||||
ride_ticket: '乘车',
|
||||
travel_allowance: '出差补贴',
|
||||
@@ -31,6 +34,8 @@ const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
|
||||
|
||||
const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
|
||||
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 = [
|
||||
'创建单据',
|
||||
@@ -135,6 +140,17 @@ function resolveLocationDisplay(location, 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) {
|
||||
return String(item?.id || `${claim?.id || 'claim'}-item-${index}`)
|
||||
}
|
||||
@@ -273,7 +289,7 @@ function buildRiskSummary(riskFlags) {
|
||||
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(';') : '无'
|
||||
}
|
||||
|
||||
@@ -602,7 +618,7 @@ function buildExpenseItems(claim, riskSummary) {
|
||||
name: itemTypeLabel,
|
||||
category: itemTypeLabel,
|
||||
desc: itemReason || '待补充',
|
||||
detail: resolveLocationDisplay(itemLocation, itemType),
|
||||
detail: resolveExpenseDescriptionDetail(itemType, itemLocation),
|
||||
amount: itemAmountDisplay,
|
||||
status: isSystemGenerated ? '系统计算' : attachments.length ? '已识别' : '待补充',
|
||||
tone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'bad',
|
||||
@@ -654,6 +670,7 @@ export function mapExpenseClaimToRequest(claim) {
|
||||
applyTime: formatDateTime(applyDateTime) || '待补充',
|
||||
submittedAt: applyDateTime || '',
|
||||
createdAt: claim?.created_at || '',
|
||||
updatedAt: claim?.updated_at || '',
|
||||
amount: parseNumber(claim?.amount),
|
||||
riskFlags: Array.isArray(claim?.risk_flags_json) ? claim.risk_flags_json : [],
|
||||
invoiceCount,
|
||||
|
||||
@@ -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"/>'),
|
||||
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"/>'),
|
||||
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"/>'),
|
||||
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"/>'),
|
||||
|
||||
@@ -8,6 +8,10 @@ export function fetchApprovalExpenseClaims() {
|
||||
return apiRequest('/reimbursements/claims/approvals')
|
||||
}
|
||||
|
||||
export function fetchArchivedExpenseClaims() {
|
||||
return apiRequest('/reimbursements/claims/archives')
|
||||
}
|
||||
|
||||
export function fetchExpenseClaimDetail(claimId) {
|
||||
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}`)
|
||||
}
|
||||
|
||||
@@ -1,75 +1,77 @@
|
||||
export const DEFAULT_APP_VIEW_ORDER = [
|
||||
'overview',
|
||||
'workbench',
|
||||
'requests',
|
||||
'approval',
|
||||
'policies',
|
||||
'audit',
|
||||
'logs',
|
||||
'employees',
|
||||
'settings'
|
||||
]
|
||||
|
||||
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'requests', 'policies'])
|
||||
const VIEW_ROLE_RULES = {
|
||||
overview: ['finance', 'executive'],
|
||||
approval: ['approver', 'finance', 'executive'],
|
||||
audit: ['auditor', 'finance'],
|
||||
logs: ['manager'],
|
||||
employees: ['manager'],
|
||||
settings: ['manager']
|
||||
}
|
||||
const CLAIM_MANAGER_ROLE_CODES = new Set(['executive'])
|
||||
const CLAIM_RETURN_ROLE_CODES = new Set(['finance', 'executive', 'manager', 'approver'])
|
||||
const CLAIM_LEADER_APPROVAL_ROLE_CODES = new Set(['manager', 'approver'])
|
||||
export const DEFAULT_APP_VIEW_ORDER = [
|
||||
'overview',
|
||||
'workbench',
|
||||
'requests',
|
||||
'approval',
|
||||
'archive',
|
||||
'policies',
|
||||
'audit',
|
||||
'logs',
|
||||
'employees',
|
||||
'settings'
|
||||
]
|
||||
|
||||
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'requests', 'policies'])
|
||||
const VIEW_ROLE_RULES = {
|
||||
overview: ['finance', 'executive'],
|
||||
approval: ['approver', 'finance', 'executive'],
|
||||
archive: ['finance', 'executive', 'auditor'],
|
||||
audit: ['auditor', 'finance'],
|
||||
logs: ['manager'],
|
||||
employees: ['manager'],
|
||||
settings: ['manager']
|
||||
}
|
||||
const CLAIM_MANAGER_ROLE_CODES = new Set(['executive'])
|
||||
const CLAIM_RETURN_ROLE_CODES = new Set(['finance', 'executive', 'manager', 'approver'])
|
||||
const CLAIM_LEADER_APPROVAL_ROLE_CODES = new Set(['manager', 'approver'])
|
||||
|
||||
function normalizedRoleCodes(user) {
|
||||
if (!user) {
|
||||
return []
|
||||
}
|
||||
|
||||
return Array.isArray(user.roleCodes)
|
||||
? user.roleCodes.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)
|
||||
: []
|
||||
}
|
||||
return Array.isArray(user.roleCodes)
|
||||
? user.roleCodes.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)
|
||||
: []
|
||||
}
|
||||
|
||||
export function isManagerUser(user) {
|
||||
return Boolean(user?.isAdmin) || normalizedRoleCodes(user).includes('manager')
|
||||
}
|
||||
|
||||
export function isFinanceUser(user) {
|
||||
return normalizedRoleCodes(user).includes('finance')
|
||||
}
|
||||
|
||||
export function isExecutiveUser(user) {
|
||||
return normalizedRoleCodes(user).includes('executive')
|
||||
}
|
||||
|
||||
export function canManageExpenseClaims(user) {
|
||||
if (Boolean(user?.isAdmin)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return normalizedRoleCodes(user).some((roleCode) => CLAIM_MANAGER_ROLE_CODES.has(roleCode))
|
||||
}
|
||||
|
||||
export function canReturnExpenseClaims(user) {
|
||||
if (Boolean(user?.isAdmin)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return normalizedRoleCodes(user).some((roleCode) => CLAIM_RETURN_ROLE_CODES.has(roleCode))
|
||||
}
|
||||
|
||||
export function canApproveLeaderExpenseClaims(user) {
|
||||
if (Boolean(user?.isAdmin)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return normalizedRoleCodes(user).some((roleCode) => CLAIM_LEADER_APPROVAL_ROLE_CODES.has(roleCode))
|
||||
}
|
||||
|
||||
export function canAccessAppView(user, viewId) {
|
||||
export function isManagerUser(user) {
|
||||
return Boolean(user?.isAdmin) || normalizedRoleCodes(user).includes('manager')
|
||||
}
|
||||
|
||||
export function isFinanceUser(user) {
|
||||
return normalizedRoleCodes(user).includes('finance')
|
||||
}
|
||||
|
||||
export function isExecutiveUser(user) {
|
||||
return normalizedRoleCodes(user).includes('executive')
|
||||
}
|
||||
|
||||
export function canManageExpenseClaims(user) {
|
||||
if (Boolean(user?.isAdmin)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return normalizedRoleCodes(user).some((roleCode) => CLAIM_MANAGER_ROLE_CODES.has(roleCode))
|
||||
}
|
||||
|
||||
export function canReturnExpenseClaims(user) {
|
||||
if (Boolean(user?.isAdmin)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return normalizedRoleCodes(user).some((roleCode) => CLAIM_RETURN_ROLE_CODES.has(roleCode))
|
||||
}
|
||||
|
||||
export function canApproveLeaderExpenseClaims(user) {
|
||||
if (Boolean(user?.isAdmin)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return normalizedRoleCodes(user).some((roleCode) => CLAIM_LEADER_APPROVAL_ROLE_CODES.has(roleCode))
|
||||
}
|
||||
|
||||
export function canAccessAppView(user, viewId) {
|
||||
if (!viewId || !user) {
|
||||
return false
|
||||
}
|
||||
|
||||
216
web/src/utils/archiveCenterListFilters.js
Normal file
216
web/src/utils/archiveCenterListFilters.js
Normal 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()
|
||||
)
|
||||
}
|
||||
@@ -22,12 +22,12 @@ function getStorage() {
|
||||
return window.localStorage
|
||||
}
|
||||
|
||||
function emitSnapshotChange(sessionType) {
|
||||
function emitSnapshotChange(sessionType, detail = {}) {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
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') {
|
||||
const storage = getStorage()
|
||||
if (!storage) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
const normalizedSessionType = normalizeSessionType(sessionType)
|
||||
try {
|
||||
storage.removeItem(resolveAssistantSessionSnapshotKey(userId, normalizedSessionType))
|
||||
emitSnapshotChange(normalizedSessionType)
|
||||
emitSnapshotChange(normalizedSessionType, { action: 'clear' })
|
||||
return true
|
||||
} catch (error) {
|
||||
console.warn('Failed to clear assistant session snapshot:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function hasAssistantSessionSnapshot(userId, sessionType = 'expense') {
|
||||
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)
|
||||
}
|
||||
|
||||
114
web/src/utils/detailAlerts.js
Normal file
114
web/src/utils/detailAlerts.js
Normal 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)
|
||||
}
|
||||
14
web/src/utils/expenseClaimArchive.js
Normal file
14
web/src/utils/expenseClaimArchive.js
Normal 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'
|
||||
}
|
||||
@@ -8,6 +8,76 @@ const markdown = new MarkdownIt({
|
||||
|
||||
const defaultTableOpen = markdown.renderer.rules.table_open
|
||||
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) => (
|
||||
`<div class="markdown-table-wrap">${defaultTableOpen ? defaultTableOpen(tokens, idx, options, env, self) : '<table>'}`
|
||||
|
||||
107
web/src/utils/riskFlags.js
Normal file
107
web/src/utils/riskFlags.js
Normal 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
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
'workbench-main': activeView === 'workbench',
|
||||
'requests-main': activeView === 'requests',
|
||||
'approval-main': activeView === 'approval',
|
||||
'archive-main': activeView === 'archive',
|
||||
'policies-main': activeView === 'policies',
|
||||
'audit-main': activeView === 'audit',
|
||||
'audit-detail-main': activeView === 'audit' && auditDetailOpen,
|
||||
@@ -49,7 +50,7 @@
|
||||
/>
|
||||
|
||||
<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'"
|
||||
:filters="filters"
|
||||
:ranges="ranges"
|
||||
@@ -62,6 +63,7 @@
|
||||
:class="{
|
||||
'requests-workarea': activeView === 'requests',
|
||||
'approval-workarea': activeView === 'approval',
|
||||
'archive-workarea': activeView === 'archive',
|
||||
'policies-workarea': activeView === 'policies',
|
||||
'audit-workarea': activeView === 'audit',
|
||||
'logs-workarea': activeView === 'logs',
|
||||
@@ -105,6 +107,7 @@
|
||||
/>
|
||||
|
||||
<ApprovalCenterView v-else-if="activeView === 'approval'" />
|
||||
<ArchiveCenterView v-else-if="activeView === 'archive'" />
|
||||
<PoliciesView v-else-if="activeView === 'policies'" @summary-change="knowledgeSummary = $event" />
|
||||
<AuditView v-else-if="activeView === 'audit'" @detail-open-change="auditDetailOpen = $event" />
|
||||
<LogDetailView v-else-if="activeView === 'logs' && logDetailMode" />
|
||||
@@ -122,6 +125,7 @@
|
||||
:initial-conversation="smartEntryContext.conversation"
|
||||
:entry-source="smartEntryContext.source"
|
||||
:request-context="smartEntryContext.request"
|
||||
:invalidated-draft-claim-id="smartEntryInvalidatedDraftClaimId"
|
||||
@close="closeSmartEntry"
|
||||
@draft-saved="handleDraftSaved"
|
||||
/>
|
||||
@@ -140,6 +144,7 @@ import TravelReimbursementCreateView from './TravelReimbursementCreateView.vue'
|
||||
import TravelRequestDetailView from './TravelRequestDetailView.vue'
|
||||
import RequestsView from './RequestsView.vue'
|
||||
import ApprovalCenterView from './ApprovalCenterView.vue'
|
||||
import ArchiveCenterView from './ArchiveCenterView.vue'
|
||||
import PoliciesView from './PoliciesView.vue'
|
||||
import AuditView from './AuditView.vue'
|
||||
import LogsView from './LogsView.vue'
|
||||
@@ -187,6 +192,7 @@ const {
|
||||
search,
|
||||
selectedRequest,
|
||||
smartEntryContext,
|
||||
smartEntryInvalidatedDraftClaimId,
|
||||
smartEntryOpen,
|
||||
smartEntrySessionId,
|
||||
toast,
|
||||
|
||||
141
web/src/views/ArchiveCenterView.vue
Normal file
141
web/src/views/ArchiveCenterView.vue
Normal 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>
|
||||
@@ -24,7 +24,7 @@
|
||||
:class="{ active: activeFolder === folder.name }"
|
||||
@click="activeFolder = folder.name"
|
||||
>
|
||||
<i :class="folder.icon"></i>
|
||||
<i :class="resolveKnowledgeFolderIcon(folder, activeFolder)"></i>
|
||||
<span>{{ folder.name }}</span>
|
||||
<b>{{ folder.count }}</b>
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<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">
|
||||
<section class="assistant-modal">
|
||||
<div class="assistant-header-actions">
|
||||
@@ -127,7 +127,7 @@
|
||||
</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
|
||||
v-for="item in message.meta"
|
||||
:key="item"
|
||||
@@ -139,7 +139,7 @@
|
||||
</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"
|
||||
>
|
||||
<button
|
||||
@@ -173,7 +173,7 @@
|
||||
</div>
|
||||
|
||||
<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"
|
||||
>
|
||||
<summary>
|
||||
@@ -197,7 +197,7 @@
|
||||
class="message-detail-block expense-query-block"
|
||||
>
|
||||
<strong>
|
||||
{{ message.queryPayload.title || (message.queryPayload.selectionMode === 'draft_association' ? '选择关联草稿' : (message.queryPayload.recentWindowApplied ? '近 10 日单据' : '单据明细')) }}
|
||||
{{ message.queryPayload.title || (message.queryPayload.selectionMode === 'draft_association' ? '选择关联草稿' : '最近 5 条筛选结果') }}
|
||||
</strong>
|
||||
|
||||
<p v-if="buildExpenseQueryWindowLabel(message.queryPayload)" class="expense-query-window-label">
|
||||
@@ -242,6 +242,20 @@
|
||||
<span>{{ record.dateDisplay }}</span>
|
||||
<span>{{ record.amountDisplay }}</span>
|
||||
</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>
|
||||
<i class="mdi mdi-chevron-right"></i>
|
||||
</button>
|
||||
@@ -289,15 +303,19 @@
|
||||
<span>{{ message.queryPayload.emptyText || '当前没有可直接展开的近期待办单据。' }}</span>
|
||||
</div>
|
||||
|
||||
<p v-if="buildExpenseQueryHint(message.queryPayload)" class="expense-query-hint">
|
||||
{{ buildExpenseQueryHint(message.queryPayload) }}
|
||||
<p
|
||||
v-if="buildExpenseQueryHint(message.queryPayload)"
|
||||
class="expense-query-hint message-answer-markdown"
|
||||
v-html="renderMarkdown(buildExpenseQueryHint(message.queryPayload))"
|
||||
@click="handleAssistantMarkdownClick($event, message)"
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="message.role === 'assistant' && message.reviewPayload" class="message-detail-block review-message-block">
|
||||
<div class="review-plain-followup">
|
||||
<template
|
||||
v-for="followup in [buildReviewPlainFollowupCopy(message.reviewPayload)]"
|
||||
v-for="followup in [buildReviewPlainFollowupForMessage(message)]"
|
||||
:key="`${message.id}-review-followup`"
|
||||
>
|
||||
<h3
|
||||
@@ -684,7 +702,7 @@
|
||||
|
||||
<div v-if="activeReviewPayload || isReviewFlowDrawer" class="review-insight-tools">
|
||||
<button
|
||||
v-if="activeReviewPayload"
|
||||
v-if="activeReviewPayload && reviewOverviewDrawerAvailable"
|
||||
type="button"
|
||||
class="review-insight-switch-icon-btn"
|
||||
:class="{
|
||||
@@ -836,7 +854,7 @@
|
||||
|
||||
<template v-else-if="currentInsight.intent === 'agent' && currentInsight.agent">
|
||||
<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">
|
||||
<div class="review-side-intent-row">
|
||||
<i class="mdi mdi-account-outline"></i>
|
||||
@@ -1221,7 +1239,7 @@
|
||||
</button>
|
||||
</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">
|
||||
<h4>制度依据</h4>
|
||||
</div>
|
||||
@@ -1284,30 +1302,6 @@
|
||||
@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">
|
||||
<div v-if="documentPreviewDialog.open" class="assistant-overlay review-overlay">
|
||||
<section class="review-preview-modal">
|
||||
|
||||
@@ -97,20 +97,11 @@
|
||||
</div>
|
||||
<div v-if="canEditDetailNote" class="detail-note-editor">
|
||||
<textarea
|
||||
v-model="detailNoteEditor"
|
||||
v-model="detailNoteEditorView"
|
||||
maxlength="500"
|
||||
placeholder="例如:去北京客户现场出差,拜访 XX 客户并处理项目验收事项"
|
||||
aria-label="附加说明"
|
||||
></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">
|
||||
<span>仅草稿待提交状态可编辑,提交后将作为明确说明展示。</span>
|
||||
<div class="detail-note-actions">
|
||||
@@ -136,15 +127,6 @@
|
||||
</div>
|
||||
<div v-else class="detail-note readonly">
|
||||
<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>
|
||||
</article>
|
||||
|
||||
@@ -178,8 +160,8 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-time">时间</th>
|
||||
<th class="col-filled-at">填写时间</th>
|
||||
<th class="col-time">发生时间</th>
|
||||
<th class="col-type">费用项目</th>
|
||||
<th class="col-desc">说明</th>
|
||||
<th class="col-amount">金额</th>
|
||||
@@ -190,6 +172,10 @@
|
||||
<tbody>
|
||||
<template v-for="item in expenseItems" :key="item.id">
|
||||
<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) }]">
|
||||
<i
|
||||
v-if="isMajorExpenseRisk(item)"
|
||||
@@ -208,10 +194,6 @@
|
||||
<span>{{ item.dayLabel }}</span>
|
||||
</template>
|
||||
</td>
|
||||
<td class="expense-filled-at col-filled-at">
|
||||
<strong>{{ item.filledAt }}</strong>
|
||||
<span>条款填写时间</span>
|
||||
</td>
|
||||
<td class="expense-type col-type">
|
||||
<template v-if="editingExpenseId === item.id">
|
||||
<div class="cell-editor">
|
||||
@@ -405,11 +387,11 @@
|
||||
</div>
|
||||
</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>
|
||||
<h3>AI建议</h3>
|
||||
<p>按建议顺序补齐信息或处理风险后,再发起审批。</p>
|
||||
<h3>{{ aiAdviceTitle }}</h3>
|
||||
<p>{{ aiAdviceHint }}</p>
|
||||
</div>
|
||||
<span :class="['validation-pill', aiAdvice.tone]">{{ aiAdvice.badge }}</span>
|
||||
</div>
|
||||
@@ -434,15 +416,6 @@
|
||||
<span>{{ card.label }}</span>
|
||||
<strong>{{ card.title }}</strong>
|
||||
</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>
|
||||
<div class="risk-advice-meta">
|
||||
<div>
|
||||
@@ -733,15 +706,6 @@
|
||||
<strong>{{ currentSubmitRiskWarning.title }}</strong>
|
||||
</div>
|
||||
<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
|
||||
v-model="riskOverrideReasons[currentSubmitRiskWarning.id]"
|
||||
maxlength="160"
|
||||
|
||||
@@ -6,6 +6,11 @@ import { useApprovalInbox } from '../../composables/useApprovalInbox.js'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { fetchApprovalExpenseClaims } from '../../services/reimbursements.js'
|
||||
import { listPendingApprovalRequests } from '../../utils/approvalInbox.js'
|
||||
import {
|
||||
filterActionableRiskFlags,
|
||||
isRiskSummaryWithRisk,
|
||||
normalizeRiskFlagTone
|
||||
} from '../../utils/riskFlags.js'
|
||||
import TravelRequestDetailView from '../TravelRequestDetailView.vue'
|
||||
|
||||
const DEFAULT_SLA_HOURS = 24
|
||||
@@ -37,10 +42,9 @@ function formatCurrency(value) {
|
||||
}
|
||||
|
||||
function resolveRiskTone(riskFlags, riskSummary) {
|
||||
if (Array.isArray(riskFlags)) {
|
||||
const severities = riskFlags
|
||||
.map((item) => String(item?.severity || '').trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
const actionableFlags = filterActionableRiskFlags(riskFlags)
|
||||
if (actionableFlags.length) {
|
||||
const severities = actionableFlags.map((item) => normalizeRiskFlagTone(item)).filter(Boolean)
|
||||
|
||||
if (severities.includes('high')) {
|
||||
return 'high'
|
||||
@@ -53,7 +57,7 @@ function resolveRiskTone(riskFlags, riskSummary) {
|
||||
}
|
||||
}
|
||||
|
||||
if (String(riskSummary || '').trim() && String(riskSummary || '').trim() !== '无') {
|
||||
if (isRiskSummaryWithRisk(riskSummary)) {
|
||||
return 'medium'
|
||||
}
|
||||
|
||||
|
||||
313
web/src/views/scripts/ArchiveCenterView.js
Normal file
313
web/src/views/scripts/ArchiveCenterView.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,10 @@ import {
|
||||
shouldRenderOnlyOfficePreview
|
||||
} from './knowledgePreviewMode.js'
|
||||
import { resolveKnowledgePreviewLayoutState } from './knowledgePreviewLayout.js'
|
||||
import { resolveInitialKnowledgeFolder } from './knowledgeFolderSelection.js'
|
||||
import {
|
||||
resolveInitialKnowledgeFolder,
|
||||
resolveKnowledgeFolderIcon
|
||||
} from './knowledgeFolderSelection.js'
|
||||
import { buildOnlyOfficePreviewConfig } from './onlyOfficePreviewConfig.js'
|
||||
|
||||
const KNOWLEDGE_POLL_INTERVAL_MS = 5000
|
||||
@@ -663,11 +666,12 @@ export default {
|
||||
previewLoading,
|
||||
shouldRenderOnlyOffice,
|
||||
shouldRenderOnlyOfficeHostNode,
|
||||
selectDocument,
|
||||
selectPreviewPage,
|
||||
selectedDocument,
|
||||
syncingFolder,
|
||||
totalCount,
|
||||
selectDocument,
|
||||
selectPreviewPage,
|
||||
selectedDocument,
|
||||
resolveKnowledgeFolderIcon,
|
||||
syncingFolder,
|
||||
totalCount,
|
||||
totalPages,
|
||||
triggerUpload,
|
||||
uploadHint,
|
||||
|
||||
@@ -181,12 +181,26 @@ const REVIEW_DRAWER_MODE_REVIEW = 'review'
|
||||
const REVIEW_DRAWER_MODE_DOCUMENTS = 'documents'
|
||||
const REVIEW_DRAWER_MODE_RISK = 'risk'
|
||||
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_RUNNING = 'running'
|
||||
const FLOW_STEP_STATUS_COMPLETED = 'completed'
|
||||
const FLOW_STEP_STATUS_FAILED = 'failed'
|
||||
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 = {}) {
|
||||
return buildBusinessTimeContextFromReviewValuesModel(values)
|
||||
}
|
||||
@@ -413,11 +427,13 @@ function buildReviewRiskItems(reviewPayload) {
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function buildReviewRiskConversationText(item) {
|
||||
function buildReviewRiskConversationText(item, detailTarget = {}) {
|
||||
const title = String(item?.title || '风险提示').trim()
|
||||
const summary = String(item?.summary || '').trim()
|
||||
const detail = String(item?.detail || '').trim()
|
||||
const suggestion = String(item?.suggestion || '').trim()
|
||||
const detailHref = String(detailTarget?.href || '').trim()
|
||||
const detailLabel = String(detailTarget?.label || '').trim() || '进入该单据详情重新填写'
|
||||
const lines = [`${title}`]
|
||||
|
||||
if (summary) {
|
||||
@@ -429,6 +445,9 @@ function buildReviewRiskConversationText(item) {
|
||||
if (suggestion) {
|
||||
lines.push('', `修改建议:${suggestion}`)
|
||||
}
|
||||
if (detailHref) {
|
||||
lines.push('', `[${detailLabel}](${detailHref})`)
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
@@ -470,6 +489,10 @@ export default {
|
||||
requestContext: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
invalidatedDraftClaimId: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
emits: ['close', 'draft-saved'],
|
||||
@@ -484,10 +507,10 @@ export default {
|
||||
const composerDraft = ref('')
|
||||
const submitting = ref(false)
|
||||
const workbenchVisible = ref(false)
|
||||
const closeAfterBusy = ref(false)
|
||||
const linkedRequest = computed(() => sanitizeRequest(props.requestContext))
|
||||
const hotKnowledgeQuestions = HOT_KNOWLEDGE_QUESTIONS
|
||||
let sessionRuntimeRefs = {}
|
||||
const uploadDecisionDialogOpen = ref(false)
|
||||
const {
|
||||
activeSessionType,
|
||||
messages,
|
||||
@@ -511,7 +534,6 @@ export default {
|
||||
linkedRequest,
|
||||
toast,
|
||||
composerDraft,
|
||||
uploadDecisionDialogOpen,
|
||||
adjustComposerTextareaHeight,
|
||||
scrollToBottom,
|
||||
getSessionRuntimeRefs: () => sessionRuntimeRefs
|
||||
@@ -568,8 +590,21 @@ export default {
|
||||
FLOW_STEP_STATUS_COMPLETED,
|
||||
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(
|
||||
() => 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 insightPanelToggleLabel = computed(() =>
|
||||
@@ -604,11 +639,31 @@ export default {
|
||||
() => Boolean(conversationId.value) || messages.value.some((item) => item.role === 'user')
|
||||
)
|
||||
const latestReviewMessage = computed(() =>
|
||||
[...messages.value].reverse().find((item) => item.role === 'assistant' && item.reviewPayload) ?? null
|
||||
)
|
||||
const activeReviewPayload = computed(
|
||||
() => currentInsight.value.agent?.reviewPayload || latestReviewMessage.value?.reviewPayload || null
|
||||
[...messages.value].reverse().find((item) =>
|
||||
item.role === 'assistant' && item.reviewPayload && canExposeReviewPanelScope(item.reviewPanelScope)
|
||||
) ?? 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 buildReviewRiskSummary = (payload) => buildReviewRiskSummaryModel(payload, reviewRiskBriefResolver)
|
||||
const {
|
||||
@@ -634,6 +689,7 @@ export default {
|
||||
reviewRiskSummary,
|
||||
reviewRiskItems,
|
||||
reviewRiskEmpty,
|
||||
reviewOverviewDrawerAvailable,
|
||||
reviewDocumentDrawerAvailable,
|
||||
reviewRiskDrawerAvailable,
|
||||
reviewFlowDrawerAvailable,
|
||||
@@ -671,6 +727,7 @@ export default {
|
||||
closeDocumentPreview
|
||||
} = useTravelReimbursementReviewDrawer({
|
||||
activeReviewPayload,
|
||||
activeReviewPanelScope,
|
||||
reviewFilePreviews,
|
||||
flowSteps,
|
||||
submitting,
|
||||
@@ -709,6 +766,7 @@ export default {
|
||||
mergeBusinessTimeIntoExtraContext,
|
||||
syncComposerBusinessTimeToReviewCard,
|
||||
resolveComposerSubmitText,
|
||||
resolveComposerDisplaySubmitText,
|
||||
toggleComposerDatePicker,
|
||||
closeComposerDatePicker,
|
||||
setComposerDateMode,
|
||||
@@ -853,6 +911,7 @@ export default {
|
||||
refreshFlowRunDetail,
|
||||
rememberFilePreviews,
|
||||
replaceMessage,
|
||||
resolveComposerDisplaySubmitText,
|
||||
resetFlowRun,
|
||||
resolveComposerSubmitText,
|
||||
reviewInlineForm,
|
||||
@@ -868,7 +927,6 @@ export default {
|
||||
startSemanticFlowPreview,
|
||||
submitting,
|
||||
syncComposerFilesToDraft,
|
||||
uploadDecisionDialogOpen,
|
||||
toast
|
||||
})
|
||||
const canSubmit = computed(
|
||||
@@ -906,8 +964,8 @@ export default {
|
||||
}
|
||||
])
|
||||
watch(
|
||||
() => activeReviewPayload.value,
|
||||
(payload) => {
|
||||
() => [activeReviewPayload.value, activeReviewPanelScope.value],
|
||||
([payload]) => {
|
||||
rememberFilePreviews(buildReviewFilePreviewsFromReviewPayload(payload))
|
||||
// reviewDrawerMode.value = resolveReviewRiskBriefs(payload).length
|
||||
// ? REVIEW_DRAWER_MODE_RISK
|
||||
@@ -989,11 +1047,51 @@ export default {
|
||||
{ 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(() => {
|
||||
document.addEventListener('click', handleComposerDatePickerOutside)
|
||||
startFlowTick()
|
||||
nextTick(() => {
|
||||
workbenchVisible.value = true
|
||||
nextTick(() => {
|
||||
adjustComposerTextareaHeight()
|
||||
scrollToBottom()
|
||||
})
|
||||
})
|
||||
void clearKnowledgeSessionOnEntry()
|
||||
currentInsight.value =
|
||||
@@ -1008,11 +1106,6 @@ export default {
|
||||
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`)
|
||||
}
|
||||
submitComposer()
|
||||
} else {
|
||||
nextTick(() => {
|
||||
adjustComposerTextareaHeight()
|
||||
scrollToBottom()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1023,8 +1116,31 @@ export default {
|
||||
})
|
||||
|
||||
function scrollToBottom() {
|
||||
if (!messageListRef.value) return
|
||||
messageListRef.value.scrollTop = messageListRef.value.scrollHeight
|
||||
const scrollOnce = () => {
|
||||
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() {
|
||||
@@ -1034,6 +1150,31 @@ export default {
|
||||
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() {
|
||||
if (!composerTextareaRef.value) return
|
||||
|
||||
@@ -1071,31 +1212,6 @@ export default {
|
||||
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) {
|
||||
if (shortcut?.action === 'switch_view' && shortcut?.targetSessionType) {
|
||||
await switchSessionType(shortcut.targetSessionType)
|
||||
@@ -1218,6 +1334,9 @@ export default {
|
||||
}
|
||||
|
||||
function switchToReviewOverviewDrawer() {
|
||||
if (!reviewOverviewDrawerAvailable.value) {
|
||||
return
|
||||
}
|
||||
switchReviewDrawerMode(REVIEW_DRAWER_MODE_REVIEW)
|
||||
}
|
||||
|
||||
@@ -1255,20 +1374,98 @@ export default {
|
||||
|
||||
function appendReviewRiskBriefToConversation(item) {
|
||||
if (!item) return
|
||||
messages.value.push(createMessage('assistant', buildReviewRiskConversationText(item), [], {
|
||||
messages.value.push(createMessage('assistant', buildReviewRiskConversationText(item, resolveReviewRiskDetailTarget()), [], {
|
||||
meta: [item.sourceLabel || item.levelLabel || '风险提示'],
|
||||
metaTone: item.level || 'low'
|
||||
}))
|
||||
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() {
|
||||
persistSessionState()
|
||||
closeAfterBusy.value = isWorkbenchBusy()
|
||||
workbenchVisible.value = false
|
||||
}
|
||||
|
||||
function emitCloseAfterLeave() {
|
||||
if (closeAfterBusy.value && isWorkbenchBusy()) {
|
||||
return
|
||||
}
|
||||
closeAfterBusy.value = false
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@@ -1317,7 +1514,6 @@ export default {
|
||||
pendingText: `已选择草稿 ${record.claimNo},正在识别并归集附件...`,
|
||||
files,
|
||||
uploadDisposition: 'continue_existing',
|
||||
skipUploadDecisionPrompt: true,
|
||||
extraContext: {
|
||||
draft_claim_id: claimId,
|
||||
selected_claim_id: claimId,
|
||||
@@ -1469,6 +1665,12 @@ export default {
|
||||
}
|
||||
|
||||
const href = String(anchor.getAttribute('href') || '').trim()
|
||||
if (href.startsWith('/app/')) {
|
||||
event.preventDefault()
|
||||
router.push(href)
|
||||
return
|
||||
}
|
||||
|
||||
if (href !== ATTACHMENT_ASSOCIATION_CONFIRM_HREF) {
|
||||
return
|
||||
}
|
||||
@@ -1492,8 +1694,25 @@ export default {
|
||||
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) {
|
||||
if (!message?.reviewPayload || message?.draftPayload?.claim_no) {
|
||||
if (!message?.reviewPayload || isDraftSavedReviewMessage(message)) {
|
||||
return false
|
||||
}
|
||||
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,
|
||||
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,
|
||||
hasInsightPanelContent, showInsightPanel, insightPanelToggleLabel, composerPlaceholder, currentIntentLabel, canDeleteCurrentSession, latestReviewMessage, activeReviewPayload, activeReviewFilePreviews, reviewDrawerMode, isReviewOverviewDrawer, isReviewDocumentDrawer, isReviewRiskDrawer, isReviewFlowDrawer,
|
||||
reviewDrawerTitle, reviewDocumentDrawerAvailable, reviewRiskDrawerAvailable, reviewFlowDrawerAvailable, reviewDocumentDrawerLabel, reviewDocumentDrawerIcon, reviewRiskDrawerLabel, reviewRiskDrawerIcon, reviewFlowDrawerLabel, reviewFlowDrawerIcon, activeReviewDocument, activeReviewDocumentIndex, activeReviewDocumentPreview, canPreviewActiveReviewDocument,
|
||||
hasInsightPanelContent, showInsightPanel, insightPanelToggleLabel, composerPlaceholder, currentIntentLabel, canDeleteCurrentSession, latestReviewMessage, activeReviewPayload, activeReviewPanelScope, activeReviewFilePreviews, reviewDrawerMode, isReviewOverviewDrawer, isReviewDocumentDrawer, isReviewRiskDrawer, isReviewFlowDrawer,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
queryDraftByClaimNo, appendReviewRiskBriefToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, canUseInlineSaveDraft, handleInlineSaveDraft
|
||||
requestCloseWorkbench, emitCloseAfterLeave, handleAssistantModalAfterEnter, openExpenseQueryRecord, handleExpenseQueryRecordClick, setExpenseQueryPage, shiftExpenseQueryPage, openDeleteSessionDialog, closeDeleteSessionDialog, confirmDeleteCurrentSession, openInlineReviewEditor, closeInlineReviewEditor, commitInlineReviewEditor, clearInlineReviewFieldError, selectInlineScene, selectReviewCategory, selectReviewOtherCategory,
|
||||
queryDraftByClaimNo, appendReviewRiskBriefToConversation, appendExpenseQueryRiskToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, isDraftSavedReviewMessage, canUseInlineSaveDraft, handleInlineSaveDraft
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,10 +29,11 @@ import {
|
||||
buildAiAdviceViewModel,
|
||||
buildAttachmentInsightViewModel,
|
||||
buildAttachmentRiskCards,
|
||||
buildClaimSummaryRiskCards,
|
||||
buildItemClaimRiskState,
|
||||
extractRiskTagsFromText,
|
||||
normalizeRiskTone,
|
||||
resolveRiskTags,
|
||||
resolveRiskTagTone
|
||||
resolveRiskTags
|
||||
} from './travelRequestDetailInsights.js'
|
||||
import {
|
||||
EXPENSE_TYPE_OPTIONS,
|
||||
@@ -95,6 +96,26 @@ function normalizeDetailNoteDraftValue(value) {
|
||||
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) {
|
||||
const travelItems = items
|
||||
.map((item, index) => {
|
||||
@@ -612,13 +633,36 @@ export default {
|
||||
() => 6 + (isEditableRequest.value ? 1 : 0)
|
||||
)
|
||||
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 detailNote = computed(() => {
|
||||
if (detailNoteSource.value) {
|
||||
return detailNoteSource.value
|
||||
return stripDetailNoteRiskTags(detailNoteSource.value)
|
||||
}
|
||||
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 detailNoteTags = computed(() =>
|
||||
extractRiskTagsFromText(canEditDetailNote.value ? detailNoteEditor.value : detailNoteSource.value)
|
||||
@@ -689,6 +733,11 @@ export default {
|
||||
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) {
|
||||
const metadata = resolveAttachmentMeta(item)
|
||||
return String(metadata?.file_name || item.attachmentHint || '').trim()
|
||||
@@ -790,10 +839,6 @@ export default {
|
||||
}
|
||||
|
||||
function resolveExpenseRiskState(item) {
|
||||
if (!item.invoiceId) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (uploadingExpenseId.value === item.id) {
|
||||
return {
|
||||
label: 'AI识别中',
|
||||
@@ -818,6 +863,15 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
const claimRiskState = buildItemClaimRiskState(item, resolveClaimRiskFlags())
|
||||
if (claimRiskState) {
|
||||
return claimRiskState
|
||||
}
|
||||
|
||||
if (!item.invoiceId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
label: '已上传',
|
||||
tone: 'low',
|
||||
@@ -843,13 +897,20 @@ export default {
|
||||
}
|
||||
|
||||
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 = [
|
||||
...buildAttachmentRiskCards({
|
||||
expenseItems: expenseItems.value,
|
||||
attachmentMetaByItemId: expenseAttachmentMeta,
|
||||
claimRiskFlags: request.value.riskFlags || request.value.risk_flags_json || []
|
||||
}),
|
||||
...(hasActionableRiskCards ? [] : buildClaimSummaryRiskCards(request.value)),
|
||||
...directRiskCards,
|
||||
...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(() =>
|
||||
aiAdvice.value.riskCards
|
||||
.filter((card) => normalizeRiskTone(card?.tone) === 'high')
|
||||
@@ -904,10 +973,6 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRiskTagClass(tag) {
|
||||
return resolveRiskTagTone(tag)
|
||||
}
|
||||
|
||||
function openRiskOverrideDialog() {
|
||||
const warnings = submitRiskWarnings.value
|
||||
if (!warnings.length) {
|
||||
@@ -1619,11 +1684,18 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
const claimId = String(request.value?.claimId || '').trim()
|
||||
emit('openAssistant', {
|
||||
source: 'detail',
|
||||
prompt: '',
|
||||
request: request.value,
|
||||
restoreLatestConversation: true
|
||||
restoreLatestConversation: false,
|
||||
scope: claimId
|
||||
? {
|
||||
type: 'claim',
|
||||
claimId
|
||||
}
|
||||
: null
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1632,7 +1704,7 @@ export default {
|
||||
})
|
||||
|
||||
return {
|
||||
emit, actionBusy, aiAdvice, attachmentPreviewError, attachmentPreviewIndexLabel,
|
||||
emit, actionBusy, aiAdvice, aiAdviceHint, aiAdviceTitle, attachmentPreviewError, attachmentPreviewIndexLabel,
|
||||
attachmentPreviewLoading, attachmentPreviewMediaType, attachmentPreviewName, attachmentPreviewOpen,
|
||||
attachmentPreviewUrl, approveBusy, approveConfirmDialogOpen, approvalConfirmBadge,
|
||||
approvalConfirmDescription, approvalNextStage, approvalOpinionHint, approvalOpinionPlaceholder,
|
||||
@@ -1646,7 +1718,7 @@ export default {
|
||||
currentSubmitRiskWarning,
|
||||
canEditDetailNote, deleteActionLabel, deleteBusy, deleteDialogDescription, deleteDialogOpen,
|
||||
deleteDialogTitle, deletingAttachmentId, deletingExpenseId, detailNote, detailNoteDirty,
|
||||
detailNoteEditor, detailNoteTags, draftBlockingIssues, editingExpenseId, creatingExpense, expenseEditor,
|
||||
detailNoteEditor, detailNoteEditorView, detailNoteTags, draftBlockingIssues, editingExpenseId, creatingExpense, expenseEditor,
|
||||
expenseItems, expenseTableColumnCount, expenseTotal, expenseUploadInput,
|
||||
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
|
||||
goToNextSubmitRisk, goToPreviousSubmitRisk,
|
||||
@@ -1655,12 +1727,12 @@ export default {
|
||||
isMajorExpenseRisk,
|
||||
openAiEntry, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview,
|
||||
profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
|
||||
resolveExpenseRiskIndicatorTitle, resolveRiskTagClass,
|
||||
resolveExpenseRiskIndicatorTitle,
|
||||
resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
|
||||
resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues,
|
||||
returnBusy, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel,
|
||||
riskOverrideReasons, saveDetailNote, savingDetailNote, savingExpenseId,
|
||||
showLeaderApprovalPanel, showExpenseRisk, startExpenseEdit, submitBusy, submitConfirmDialogOpen,
|
||||
showAiAdvicePanel, showLeaderApprovalPanel, showExpenseRisk, startExpenseEdit, submitBusy, submitConfirmDialogOpen,
|
||||
submitRiskWarnings,
|
||||
triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit
|
||||
}
|
||||
|
||||
@@ -8,3 +8,12 @@ export function resolveInitialKnowledgeFolder(folders, currentFolder = '') {
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
@@ -6,26 +6,26 @@ import {
|
||||
import { normalizeExpenseQueryPayload } from './travelReimbursementExpenseQueryModel.js'
|
||||
|
||||
const SCENARIO_LABELS = {
|
||||
expense: '??',
|
||||
accounts_receivable: '??',
|
||||
accounts_payable: '??',
|
||||
knowledge: '??',
|
||||
unknown: '??'
|
||||
expense: '报销',
|
||||
accounts_receivable: '应收',
|
||||
accounts_payable: '应付',
|
||||
knowledge: '知识',
|
||||
unknown: '通用'
|
||||
}
|
||||
|
||||
const INTENT_LABELS = {
|
||||
query: '??',
|
||||
explain: '??',
|
||||
compare: '??',
|
||||
risk_check: '????',
|
||||
draft: '????',
|
||||
operate: '????'
|
||||
query: '查询',
|
||||
explain: '解释',
|
||||
compare: '对比',
|
||||
risk_check: '风险检查',
|
||||
draft: '信息核对',
|
||||
operate: '动作请求'
|
||||
}
|
||||
|
||||
function resolveStatusLabel(status) {
|
||||
if (status === 'succeeded') return '???'
|
||||
if (status === 'blocked') return '???'
|
||||
return '??'
|
||||
if (status === 'succeeded') return '已完成'
|
||||
if (status === 'blocked') return '已阻断'
|
||||
return '处理中'
|
||||
}
|
||||
|
||||
function resolveStatusTone(status) {
|
||||
@@ -123,6 +123,12 @@ function buildAssociationDocumentContentLines(document) {
|
||||
return ['- 识别内容:暂未提取到结构化字段,请以票据原件为准。']
|
||||
}
|
||||
|
||||
function buildAssociationDocumentCard(lines) {
|
||||
return (Array.isArray(lines) ? lines : [])
|
||||
.map((line) => String(line || '').trim() ? `> ${line}` : '>')
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export function buildAttachmentAssociationConfirmationMessage({
|
||||
claimNo = '',
|
||||
claimTitle = '',
|
||||
@@ -144,13 +150,14 @@ export function buildAttachmentAssociationConfirmationMessage({
|
||||
const filename = String(document?.filename || '').trim() || `附件 ${index + 1}`
|
||||
const typeLabel = resolveAssociationDocumentTypeLabel(document)
|
||||
const contentLines = buildAssociationDocumentContentLines(document)
|
||||
return [
|
||||
`附件 ${index + 1}:${filename}`,
|
||||
.map((line) => String(line || '').replace(/^-\s*/, ''))
|
||||
return buildAssociationDocumentCard([
|
||||
`**附件 ${index + 1}:${filename}**`,
|
||||
'',
|
||||
`附件类型:${typeLabel}`,
|
||||
'',
|
||||
...contentLines
|
||||
].join('\n')
|
||||
])
|
||||
})
|
||||
|
||||
return [
|
||||
@@ -158,14 +165,17 @@ export function buildAttachmentAssociationConfirmationMessage({
|
||||
'',
|
||||
documentBlocks.join('\n\n'),
|
||||
'',
|
||||
'',
|
||||
'请问是否确定将票据信息归集到单据:',
|
||||
'',
|
||||
targetLines.join('\n'),
|
||||
'',
|
||||
`如果 [确认](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF}) 该信息,我将直接将票据进行归集。`
|
||||
'',
|
||||
`如果 **[确认](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF})** 该信息,我将直接将票据进行归集。`
|
||||
]
|
||||
.filter((part) => String(part || '').trim())
|
||||
.join('\n')
|
||||
.replace(/\n{4,}/g, '\n\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function normalizeReviewDocumentFieldKey(label) {
|
||||
@@ -235,6 +245,9 @@ export function buildOcrDocumentsFromReviewPayload(reviewPayload) {
|
||||
document_type_label: resolveDocumentTypeLabel(item?.document_type),
|
||||
scene_code: resolveExpenseTypeCode(item?.suggested_expense_type),
|
||||
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,
|
||||
warnings: Array.isArray(item?.warnings) ? item.warnings : []
|
||||
}
|
||||
@@ -373,12 +386,32 @@ export function mergeFilePreviews(existingPreviews, incomingPreviews) {
|
||||
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) {
|
||||
const documents = Array.isArray(payload?.documents) ? payload.documents : []
|
||||
return documents
|
||||
.map((item) => ({
|
||||
filename: String(item?.filename || '').trim(),
|
||||
kind: String(item?.preview_kind || '').trim(),
|
||||
kind: resolveDocumentPreviewKind(item),
|
||||
url: String(item?.preview_url || item?.preview_data_url || '').trim()
|
||||
}))
|
||||
.filter((item) => item.filename && item.kind === 'image' && item.url)
|
||||
@@ -389,7 +422,7 @@ export function buildReviewFilePreviewsFromReviewPayload(reviewPayload) {
|
||||
return documents
|
||||
.map((item) => ({
|
||||
filename: String(item?.filename || '').trim(),
|
||||
kind: String(item?.preview_kind || '').trim(),
|
||||
kind: resolveDocumentPreviewKind(item),
|
||||
url: String(item?.preview_url || item?.preview_data_url || '').trim()
|
||||
}))
|
||||
.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
Reference in New Issue
Block a user