feat: 完善文档中心与报销申请交互及侧边栏重构
后端优化编排器报销查询和本体检测精度,增强报销单草稿保 存和附件回填逻辑,前端重构侧边栏组件支持折叠和图标导 航,完善文档中心状态筛选和详情提示,报销创建和审批详情 页优化会话管理和费用明细交互,新增助手应用服务和预设动 作工具函数,补充单元测试覆盖。
This commit is contained in:
746
document/development/ui/personal-workbench-home-reference.html
Normal file
746
document/development/ui/personal-workbench-home-reference.html
Normal file
@@ -0,0 +1,746 @@
|
|||||||
|
<!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 {
|
||||||
|
--bg: #f4f7fb;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--line: #e4ebf3;
|
||||||
|
--line-strong: #d6e1ed;
|
||||||
|
--text: #0f172a;
|
||||||
|
--muted: #64748b;
|
||||||
|
--soft: #f8fbff;
|
||||||
|
--green: #0f9f6e;
|
||||||
|
--green-dark: #047857;
|
||||||
|
--blue: #2563eb;
|
||||||
|
--amber: #b7791f;
|
||||||
|
--red: #d93025;
|
||||||
|
--shadow: 0 18px 44px rgba(15, 23, 42, 0.08);
|
||||||
|
font-family:
|
||||||
|
"Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC",
|
||||||
|
"Noto Sans CJK SC", "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 18% 0%, rgba(16, 185, 129, 0.13), transparent 29%),
|
||||||
|
radial-gradient(circle at 86% 12%, rgba(37, 99, 235, 0.10), transparent 24%),
|
||||||
|
var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
width: 1440px;
|
||||||
|
min-height: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 84px 1fr;
|
||||||
|
background: rgba(255, 255, 255, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail {
|
||||||
|
padding: 20px 12px;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #cbd5e1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr auto;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: 14px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: linear-gradient(135deg, #10b981, #2563eb);
|
||||||
|
color: white;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0;
|
||||||
|
box-shadow: 0 12px 26px rgba(16, 185, 129, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 16px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
color: #ffffff;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
padding: 22px 30px 30px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
height: 58px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumb strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1.2;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumb span {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-pill {
|
||||||
|
height: 38px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 12px 0 6px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.74);
|
||||||
|
color: #334155;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: #e8f7f0;
|
||||||
|
color: var(--green-dark);
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 254px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 230px minmax(0, 1fr);
|
||||||
|
gap: 22px;
|
||||||
|
padding: 24px 28px 22px 20px;
|
||||||
|
border: 1px solid rgba(15, 159, 110, 0.16);
|
||||||
|
border-radius: 18px;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(247, 255, 251, 0.98), rgba(255, 255, 255, 0.98) 55%, rgba(244, 249, 255, 0.96)),
|
||||||
|
var(--panel);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
right: -110px;
|
||||||
|
bottom: -138px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(16, 185, 129, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-wrap {
|
||||||
|
position: relative;
|
||||||
|
min-height: 206px;
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-wrap::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 18px;
|
||||||
|
right: 18px;
|
||||||
|
bottom: 4px;
|
||||||
|
height: 58px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(16, 185, 129, 0.13);
|
||||||
|
filter: blur(13px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-wrap img {
|
||||||
|
position: relative;
|
||||||
|
width: 176px;
|
||||||
|
height: auto;
|
||||||
|
filter: drop-shadow(0 24px 26px rgba(15, 23, 42, 0.15));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
align-content: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 860px;
|
||||||
|
font-size: 27px;
|
||||||
|
line-height: 1.32;
|
||||||
|
font-weight: 850;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-subtitle {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 880px;
|
||||||
|
color: #53637a;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.68;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer {
|
||||||
|
min-height: 58px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||||
|
border-radius: 15px;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.74);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-text {
|
||||||
|
padding: 0 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
height: 42px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 11px;
|
||||||
|
padding: 0 16px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.secondary {
|
||||||
|
border: 1px solid rgba(15, 118, 110, 0.18);
|
||||||
|
background: linear-gradient(180deg, #ffffff, #f2faf7);
|
||||||
|
color: #0f766e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary {
|
||||||
|
background: linear-gradient(135deg, #10b981, #059669);
|
||||||
|
color: #ffffff;
|
||||||
|
box-shadow: 0 12px 24px rgba(16, 185, 129, 0.20);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intent-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intent-card {
|
||||||
|
min-height: 116px;
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.055);
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intent-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intent-icon {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intent-icon.green {
|
||||||
|
background: #e8f7f0;
|
||||||
|
color: var(--green-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intent-icon.blue {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intent-icon.amber {
|
||||||
|
background: #fff7e6;
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intent-icon.slate {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intent-arrow {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intent-card h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.35;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intent-card p {
|
||||||
|
margin: -6px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lower-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
box-shadow: 0 10px 26px rgba(15, 23, 42, 0.045);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head {
|
||||||
|
min-height: 58px;
|
||||||
|
padding: 0 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.2;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head span {
|
||||||
|
color: var(--green-dark);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rows {
|
||||||
|
padding: 2px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
min-height: 70px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 42px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
border-top: 1px solid #edf2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:first-child {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-icon {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 13px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: #eefaf4;
|
||||||
|
color: var(--green-dark);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-copy {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-copy strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.35;
|
||||||
|
font-weight: 800;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-copy small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 5px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-action {
|
||||||
|
min-width: 78px;
|
||||||
|
height: 34px;
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.26);
|
||||||
|
border-radius: 999px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--green-dark);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
background: #f6fffb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 900;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin-left: 10px;
|
||||||
|
min-width: 92px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: var(--blue);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wide-panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.25fr 1fr;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-list {
|
||||||
|
border-left: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-section {
|
||||||
|
min-height: 178px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-section .panel-head {
|
||||||
|
border-bottom: 0;
|
||||||
|
min-height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-strip {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 20px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
min-height: 88px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--soft);
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric span {
|
||||||
|
display: block;
|
||||||
|
margin-top: 9px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.app {
|
||||||
|
width: 100%;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero,
|
||||||
|
.lower-grid,
|
||||||
|
.wide-panel {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intent-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-list {
|
||||||
|
border-left: 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="app">
|
||||||
|
<aside class="rail" aria-label="侧边导航">
|
||||||
|
<div class="brand-mark">XF</div>
|
||||||
|
<nav class="nav">
|
||||||
|
<div class="nav-item active">工作台</div>
|
||||||
|
<div class="nav-item">申请</div>
|
||||||
|
<div class="nav-item">审批</div>
|
||||||
|
<div class="nav-item">规则</div>
|
||||||
|
<div class="nav-item">知识</div>
|
||||||
|
</nav>
|
||||||
|
<div class="nav-item">设置</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="main">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="crumb">
|
||||||
|
<strong>个人工作台</strong>
|
||||||
|
<span>把费用申请、报销处理、进度查询和制度问答集中到一个入口。</span>
|
||||||
|
</div>
|
||||||
|
<div class="user-pill">
|
||||||
|
<span class="avatar">A</span>
|
||||||
|
<span>admin</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<article class="hero">
|
||||||
|
<div class="bot-wrap">
|
||||||
|
<img src="../../../web/src/assets/robot-helper.png" alt="" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hero-copy">
|
||||||
|
<h1 class="hero-title">嗨,admin,描述您想做的事,AI 会直接帮您处理</h1>
|
||||||
|
<p class="hero-subtitle">
|
||||||
|
我会自动识别您的意图,协助完成费用申请、报销、查询和制度问答等业务工作,
|
||||||
|
并把事情推进到可执行的下一步。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="composer">
|
||||||
|
<div class="composer-text">例如:帮我查一下上周提交的差旅报销到哪一步了</div>
|
||||||
|
<button class="btn secondary">上传票据</button>
|
||||||
|
<button class="btn primary">开始处理</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<section class="intent-grid" aria-label="业务入口">
|
||||||
|
<article class="intent-card">
|
||||||
|
<div class="intent-top">
|
||||||
|
<span class="intent-icon green">申</span>
|
||||||
|
<span class="intent-arrow">→</span>
|
||||||
|
</div>
|
||||||
|
<h3>费用申请</h3>
|
||||||
|
<p>发起招待、差旅、采购等费用事项</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="intent-card">
|
||||||
|
<div class="intent-top">
|
||||||
|
<span class="intent-icon blue">报</span>
|
||||||
|
<span class="intent-arrow">→</span>
|
||||||
|
</div>
|
||||||
|
<h3>报销处理</h3>
|
||||||
|
<p>上传票据,生成草稿并核对材料</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="intent-card">
|
||||||
|
<div class="intent-top">
|
||||||
|
<span class="intent-icon amber">查</span>
|
||||||
|
<span class="intent-arrow">→</span>
|
||||||
|
</div>
|
||||||
|
<h3>进度查询</h3>
|
||||||
|
<p>查询单据状态、审批节点和到账情况</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="intent-card">
|
||||||
|
<div class="intent-top">
|
||||||
|
<span class="intent-icon slate">问</span>
|
||||||
|
<span class="intent-arrow">→</span>
|
||||||
|
</div>
|
||||||
|
<h3>制度问答</h3>
|
||||||
|
<p>咨询标准、附件要求和可报销边界</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="lower-grid">
|
||||||
|
<article class="panel">
|
||||||
|
<header class="panel-head">
|
||||||
|
<h3>报销待办</h3>
|
||||||
|
<span>查看全部</span>
|
||||||
|
</header>
|
||||||
|
<div class="rows">
|
||||||
|
<div class="row">
|
||||||
|
<span class="row-icon">招</span>
|
||||||
|
<div class="row-copy">
|
||||||
|
<strong>业务招待报销建议补参与人员</strong>
|
||||||
|
<small>AI 建议:补充客户单位、客户人数、我方陪同人员</small>
|
||||||
|
</div>
|
||||||
|
<span class="row-action">去补充</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="row-icon">旅</span>
|
||||||
|
<div class="row-copy">
|
||||||
|
<strong>差旅报销单待提交</strong>
|
||||||
|
<small>补齐出发交通,可直接生成报销单</small>
|
||||||
|
</div>
|
||||||
|
<span class="row-action">继续填</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="row-icon">票</span>
|
||||||
|
<div class="row-copy">
|
||||||
|
<strong>有 5 张票据未关联报销单</strong>
|
||||||
|
<small>其中 3 张疑似交通费,可合并生成交通报销</small>
|
||||||
|
</div>
|
||||||
|
<span class="row-action">去整理</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel">
|
||||||
|
<header class="panel-head">
|
||||||
|
<h3>报销进度</h3>
|
||||||
|
<span>查看全部</span>
|
||||||
|
</header>
|
||||||
|
<div class="rows">
|
||||||
|
<div class="row">
|
||||||
|
<span class="row-icon">差</span>
|
||||||
|
<div class="row-copy">
|
||||||
|
<strong>差旅报销</strong>
|
||||||
|
<small>提交时间:2026-05-03</small>
|
||||||
|
</div>
|
||||||
|
<div><span class="amount">¥3,280</span><span class="status">主管审批中</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="row-icon">交</span>
|
||||||
|
<div class="row-copy">
|
||||||
|
<strong>交通报销</strong>
|
||||||
|
<small>提交时间:2026-05-02</small>
|
||||||
|
</div>
|
||||||
|
<div><span class="amount">¥126</span><span class="status">财务复核中</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="row-icon">采</span>
|
||||||
|
<div class="row-copy">
|
||||||
|
<strong>办公采购</strong>
|
||||||
|
<small>提交时间:2026-05-01</small>
|
||||||
|
</div>
|
||||||
|
<div><span class="amount">¥458</span><span class="status">已到账</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel wide-panel">
|
||||||
|
<article class="mini-section">
|
||||||
|
<header class="panel-head">
|
||||||
|
<h3>智能概览</h3>
|
||||||
|
<span>本月</span>
|
||||||
|
</header>
|
||||||
|
<div class="metric-strip">
|
||||||
|
<div class="metric"><strong>12</strong><span>待处理事项</span></div>
|
||||||
|
<div class="metric"><strong>86%</strong><span>材料完整率</span></div>
|
||||||
|
<div class="metric"><strong>2.4天</strong><span>平均审批时长</span></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="mini-section policy-list">
|
||||||
|
<header class="panel-head">
|
||||||
|
<h3>最新报销制度</h3>
|
||||||
|
<span>查看全部</span>
|
||||||
|
</header>
|
||||||
|
<div class="rows">
|
||||||
|
<div class="row">
|
||||||
|
<span class="row-icon">规</span>
|
||||||
|
<div class="row-copy">
|
||||||
|
<strong>差旅报销管理办法(2026版)</strong>
|
||||||
|
<small>更新住宿标准与交通等级规则</small>
|
||||||
|
</div>
|
||||||
|
<span class="row-action">查看</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
document/development/ui/personal-workbench-home-reference.png
Normal file
BIN
document/development/ui/personal-workbench-home-reference.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 123 KiB |
@@ -537,12 +537,12 @@ def return_expense_claim(
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/claims/{claim_id}/approve",
|
"/claims/{claim_id}/approve",
|
||||||
response_model=ExpenseClaimRead,
|
response_model=ExpenseClaimRead,
|
||||||
summary="审批通过报销单",
|
summary="审批通过单据",
|
||||||
description="直属领导审批通过后流转到财务审批;财务终审通过后进入归档入账。",
|
description="费用申请由直属领导审批通过后完成;报销单直属领导审批后流转到财务审批,财务终审通过后进入归档入账。",
|
||||||
responses={
|
responses={
|
||||||
status.HTTP_404_NOT_FOUND: {
|
status.HTTP_404_NOT_FOUND: {
|
||||||
"model": ErrorResponse,
|
"model": ErrorResponse,
|
||||||
"description": "报销单不存在。",
|
"description": "单据不存在。",
|
||||||
},
|
},
|
||||||
status.HTTP_400_BAD_REQUEST: {
|
status.HTTP_400_BAD_REQUEST: {
|
||||||
"model": ErrorResponse,
|
"model": ErrorResponse,
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import jwt
|
|||||||
|
|
||||||
from app.api.deps import CurrentUserContext
|
from app.api.deps import CurrentUserContext
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.schemas.agent_asset import AgentAssetOnlyOfficeConfigRead
|
from app.models.agent_asset import AgentAsset
|
||||||
|
from app.schemas.agent_asset import AgentAssetOnlyOfficeConfigRead, AgentAssetRead
|
||||||
from app.services.agent_asset_spreadsheet import (
|
from app.services.agent_asset_spreadsheet import (
|
||||||
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
|
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
|
||||||
FINANCE_RULES_LIBRARY,
|
FINANCE_RULES_LIBRARY,
|
||||||
@@ -40,9 +41,7 @@ class OnlyOfficeCallbackPayload:
|
|||||||
class AgentAssetOnlyOfficeMixin:
|
class AgentAssetOnlyOfficeMixin:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _resolve_onlyoffice_settings():
|
def _resolve_onlyoffice_settings():
|
||||||
from app.services import agent_assets
|
return resolve_onlyoffice_settings()
|
||||||
|
|
||||||
return agent_assets.resolve_onlyoffice_settings()
|
|
||||||
|
|
||||||
def build_rule_spreadsheet_onlyoffice_config(
|
def build_rule_spreadsheet_onlyoffice_config(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -258,6 +258,8 @@ class AgentConversationService:
|
|||||||
)
|
)
|
||||||
if not should_hydrate_review_flow:
|
if not should_hydrate_review_flow:
|
||||||
for key in REVIEW_FLOW_CONTEXT_KEYS:
|
for key in REVIEW_FLOW_CONTEXT_KEYS:
|
||||||
|
if key == "business_time_context" and not self._is_empty_value(merged.get(key)):
|
||||||
|
continue
|
||||||
merged.pop(key, None)
|
merged.pop(key, None)
|
||||||
|
|
||||||
merged["conversation_id"] = conversation.conversation_id
|
merged["conversation_id"] = conversation.conversation_id
|
||||||
|
|||||||
@@ -143,6 +143,40 @@ class ExpenseClaimService(
|
|||||||
self._attachment_storage = ExpenseClaimAttachmentStorage()
|
self._attachment_storage = ExpenseClaimAttachmentStorage()
|
||||||
self._attachment_presentation = ExpenseClaimAttachmentPresentation(self._attachment_storage)
|
self._attachment_presentation = ExpenseClaimAttachmentPresentation(self._attachment_storage)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_expense_application_claim(claim: ExpenseClaim) -> bool:
|
||||||
|
claim_no = str(getattr(claim, "claim_no", "") or "").strip().upper()
|
||||||
|
expense_type = str(getattr(claim, "expense_type", "") or "").strip().lower()
|
||||||
|
document_type = str(
|
||||||
|
getattr(claim, "document_type_code", "")
|
||||||
|
or getattr(claim, "document_type", "")
|
||||||
|
or ""
|
||||||
|
).strip().lower()
|
||||||
|
return (
|
||||||
|
claim_no.startswith("APP-")
|
||||||
|
or expense_type == "application"
|
||||||
|
or expense_type.endswith("_application")
|
||||||
|
or document_type in {"application", "expense_application"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _validate_application_claim_for_submission(self, claim: ExpenseClaim) -> list[str]:
|
||||||
|
issues: list[str] = []
|
||||||
|
if self._is_missing_value(claim.employee_name):
|
||||||
|
issues.append("申请人未完善")
|
||||||
|
if self._is_missing_value(claim.department_name):
|
||||||
|
issues.append("所属部门未完善")
|
||||||
|
if self._is_missing_value(claim.expense_type):
|
||||||
|
issues.append("申请类型未完善")
|
||||||
|
if self._is_missing_value(claim.reason):
|
||||||
|
issues.append("申请事由未完善")
|
||||||
|
if self._is_missing_value(claim.location):
|
||||||
|
issues.append("业务地点未完善")
|
||||||
|
if claim.amount is None or claim.amount <= Decimal("0.00"):
|
||||||
|
issues.append("预计总费用未完善")
|
||||||
|
if claim.occurred_at is None:
|
||||||
|
issues.append("申请时间未完善")
|
||||||
|
return issues
|
||||||
|
|
||||||
def list_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
|
def list_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(ExpenseClaim)
|
select(ExpenseClaim)
|
||||||
@@ -389,18 +423,51 @@ class ExpenseClaimService(
|
|||||||
|
|
||||||
self._ensure_draft_claim(claim)
|
self._ensure_draft_claim(claim)
|
||||||
self._access_policy.backfill_claim_identity_from_current_user(claim, current_user)
|
self._access_policy.backfill_claim_identity_from_current_user(claim, current_user)
|
||||||
self._sync_claim_from_items(claim)
|
is_application_claim = self._is_expense_application_claim(claim)
|
||||||
missing_fields = self._validate_claim_for_submission(claim)
|
if not is_application_claim:
|
||||||
|
self._sync_claim_from_items(claim)
|
||||||
|
missing_fields = (
|
||||||
|
self._validate_application_claim_for_submission(claim)
|
||||||
|
if is_application_claim
|
||||||
|
else self._validate_claim_for_submission(claim)
|
||||||
|
)
|
||||||
if missing_fields:
|
if missing_fields:
|
||||||
raise ExpenseClaimSubmissionBlockedError(missing_fields)
|
raise ExpenseClaimSubmissionBlockedError(missing_fields)
|
||||||
|
|
||||||
before_json = self._serialize_claim(claim)
|
before_json = self._serialize_claim(claim)
|
||||||
review_result = self._run_ai_submission_review(claim)
|
if is_application_claim:
|
||||||
|
submitted_at = datetime.now(UTC)
|
||||||
|
preserved_flags = [
|
||||||
|
flag
|
||||||
|
for flag in list(claim.risk_flags_json or [])
|
||||||
|
if not (
|
||||||
|
isinstance(flag, dict)
|
||||||
|
and str(flag.get("source") or "").strip() in {"submission_review", "attachment_analysis"}
|
||||||
|
)
|
||||||
|
]
|
||||||
|
submit_flag = {
|
||||||
|
"source": "application_submission",
|
||||||
|
"event_type": "expense_application_submission",
|
||||||
|
"severity": "info",
|
||||||
|
"label": "申请提交",
|
||||||
|
"message": "费用申请已提交至直属领导审批,并同步纳入预算管理口径。",
|
||||||
|
"previous_status": str(claim.status or "").strip(),
|
||||||
|
"previous_approval_stage": str(claim.approval_stage or "").strip(),
|
||||||
|
"next_status": "submitted",
|
||||||
|
"next_approval_stage": "直属领导审批",
|
||||||
|
"created_at": submitted_at.isoformat(),
|
||||||
|
}
|
||||||
|
claim.status = "submitted"
|
||||||
|
claim.approval_stage = "直属领导审批"
|
||||||
|
claim.risk_flags_json = [*preserved_flags, submit_flag]
|
||||||
|
claim.submitted_at = submitted_at
|
||||||
|
else:
|
||||||
|
review_result = self._run_ai_submission_review(claim)
|
||||||
|
|
||||||
claim.status = str(review_result.get("status") or "supplement")
|
claim.status = str(review_result.get("status") or "supplement")
|
||||||
claim.approval_stage = str(review_result.get("approval_stage") or "待补充")
|
claim.approval_stage = str(review_result.get("approval_stage") or "待补充")
|
||||||
claim.risk_flags_json = list(review_result.get("risk_flags") or [])
|
claim.risk_flags_json = list(review_result.get("risk_flags") or [])
|
||||||
claim.submitted_at = datetime.now(UTC) if claim.status == "submitted" else None
|
claim.submitted_at = datetime.now(UTC) if claim.status == "submitted" else None
|
||||||
|
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.refresh(claim)
|
self.db.refresh(claim)
|
||||||
@@ -562,19 +629,29 @@ class ExpenseClaimService(
|
|||||||
|
|
||||||
normalized_status = str(claim.status or "").strip().lower()
|
normalized_status = str(claim.status or "").strip().lower()
|
||||||
if normalized_status != "submitted":
|
if normalized_status != "submitted":
|
||||||
raise ValueError("只有审批中的报销单可以审批通过。")
|
raise ValueError("只有审批中的单据可以审批通过。")
|
||||||
|
|
||||||
previous_stage = str(claim.approval_stage or "").strip()
|
previous_stage = str(claim.approval_stage or "").strip()
|
||||||
|
is_application_claim = self._is_expense_application_claim(claim)
|
||||||
if previous_stage == "直属领导审批":
|
if previous_stage == "直属领导审批":
|
||||||
if not self._access_policy.can_approve_claim(current_user, claim):
|
if not self._access_policy.can_approve_claim(current_user, claim):
|
||||||
raise ValueError("只有当前直属领导审批人可以审批通过该报销单。")
|
raise ValueError("只有当前直属领导审批人可以审批通过该单据。")
|
||||||
approval_source = "manual_approval"
|
approval_source = "manual_approval"
|
||||||
event_type = "expense_claim_approval"
|
if is_application_claim:
|
||||||
label = "领导审批通过"
|
event_type = "expense_application_approval"
|
||||||
next_status = "submitted"
|
label = "领导审批通过"
|
||||||
next_stage = "财务审批"
|
next_status = "approved"
|
||||||
default_message = "{operator} 已审批通过,流转至{next_stage}。"
|
next_stage = "审批完成"
|
||||||
|
default_message = "{operator} 已审批通过,申请流程完成。"
|
||||||
|
else:
|
||||||
|
event_type = "expense_claim_approval"
|
||||||
|
label = "领导审批通过"
|
||||||
|
next_status = "submitted"
|
||||||
|
next_stage = "财务审批"
|
||||||
|
default_message = "{operator} 已审批通过,流转至{next_stage}。"
|
||||||
elif previous_stage == "财务审批":
|
elif previous_stage == "财务审批":
|
||||||
|
if is_application_claim:
|
||||||
|
raise ValueError("费用申请无需财务审批,直属领导审批通过后即完成。")
|
||||||
if not self._access_policy.can_approve_claim(current_user, claim):
|
if not self._access_policy.can_approve_claim(current_user, claim):
|
||||||
raise ValueError("只有财务人员可以完成财务终审。")
|
raise ValueError("只有财务人员可以完成财务终审。")
|
||||||
approval_source = "finance_approval"
|
approval_source = "finance_approval"
|
||||||
@@ -606,7 +683,7 @@ class ExpenseClaimService(
|
|||||||
],
|
],
|
||||||
"previous_status": str(claim.status or "").strip(),
|
"previous_status": str(claim.status or "").strip(),
|
||||||
"previous_approval_stage": previous_stage,
|
"previous_approval_stage": previous_stage,
|
||||||
"next_status": "submitted",
|
"next_status": next_status,
|
||||||
"next_approval_stage": next_stage,
|
"next_approval_stage": next_stage,
|
||||||
"created_at": datetime.now(UTC).isoformat(),
|
"created_at": datetime.now(UTC).isoformat(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,10 +78,12 @@ class OntologyDetectionMixin:
|
|||||||
document_type = str(context_json.get("document_type") or "").strip()
|
document_type = str(context_json.get("document_type") or "").strip()
|
||||||
application_stage = str(context_json.get("application_stage") or "").strip()
|
application_stage = str(context_json.get("application_stage") or "").strip()
|
||||||
entry_source = str(context_json.get("entry_source") or "").strip()
|
entry_source = str(context_json.get("entry_source") or "").strip()
|
||||||
|
session_type = str(context_json.get("session_type") or "").strip()
|
||||||
return (
|
return (
|
||||||
document_type in EXPENSE_APPLICATION_CONTEXT_TYPES
|
document_type in EXPENSE_APPLICATION_CONTEXT_TYPES
|
||||||
or application_stage in EXPENSE_APPLICATION_CONTEXT_TYPES
|
or application_stage in EXPENSE_APPLICATION_CONTEXT_TYPES
|
||||||
or entry_source in {"documents_application", "expense_application"}
|
or session_type in EXPENSE_APPLICATION_CONTEXT_TYPES
|
||||||
|
or entry_source in {"application", "documents_application", "expense_application"}
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -40,10 +40,12 @@ class OntologyExtractionMixin:
|
|||||||
document_type = str(context_json.get("document_type") or "").strip()
|
document_type = str(context_json.get("document_type") or "").strip()
|
||||||
application_stage = str(context_json.get("application_stage") or "").strip()
|
application_stage = str(context_json.get("application_stage") or "").strip()
|
||||||
entry_source = str(context_json.get("entry_source") or "").strip()
|
entry_source = str(context_json.get("entry_source") or "").strip()
|
||||||
|
session_type = str(context_json.get("session_type") or "").strip()
|
||||||
return (
|
return (
|
||||||
document_type in EXPENSE_APPLICATION_CONTEXT_TYPES
|
document_type in EXPENSE_APPLICATION_CONTEXT_TYPES
|
||||||
or application_stage in EXPENSE_APPLICATION_CONTEXT_TYPES
|
or application_stage in EXPENSE_APPLICATION_CONTEXT_TYPES
|
||||||
or entry_source in {"documents_application", "expense_application"}
|
or session_type in EXPENSE_APPLICATION_CONTEXT_TYPES
|
||||||
|
or entry_source in {"application", "documents_application", "expense_application"}
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -31,6 +32,11 @@ from app.services.knowledge import KnowledgeService
|
|||||||
from app.services.ontology import SemanticOntologyService
|
from app.services.ontology import SemanticOntologyService
|
||||||
from app.services.orchestrator_execution import ExecutionOutcome, OrchestratorExecutionEngine
|
from app.services.orchestrator_execution import ExecutionOutcome, OrchestratorExecutionEngine
|
||||||
from app.services.orchestrator_expense_query import OrchestratorDatabaseQueryBuilder
|
from app.services.orchestrator_expense_query import OrchestratorDatabaseQueryBuilder
|
||||||
|
from app.services.user_agent_application import (
|
||||||
|
APPLICATION_CONTEXT_VALUES,
|
||||||
|
APPLICATION_SHORT_CONFIRMATIONS,
|
||||||
|
APPLICATION_SUBMIT_KEYWORDS,
|
||||||
|
)
|
||||||
from app.services.user_agent import UserAgentService
|
from app.services.user_agent import UserAgentService
|
||||||
|
|
||||||
logger = get_logger("app.services.orchestrator")
|
logger = get_logger("app.services.orchestrator")
|
||||||
@@ -131,9 +137,11 @@ class OrchestratorService:
|
|||||||
)
|
)
|
||||||
selected_capability_codes = self.execution_engine._flatten_capability_codes(capabilities)
|
selected_capability_codes = self.execution_engine._flatten_capability_codes(capabilities)
|
||||||
is_expense_review_action = self.execution_engine._is_expense_review_action(context_json)
|
is_expense_review_action = self.execution_engine._is_expense_review_action(context_json)
|
||||||
|
is_expense_application_context = self._is_expense_application_context(context_json)
|
||||||
requires_confirmation = (
|
requires_confirmation = (
|
||||||
ontology.permission.level == AgentPermissionLevel.APPROVAL_REQUIRED.value
|
ontology.permission.level == AgentPermissionLevel.APPROVAL_REQUIRED.value
|
||||||
and not is_expense_review_action
|
and not is_expense_review_action
|
||||||
|
and not is_expense_application_context
|
||||||
)
|
)
|
||||||
|
|
||||||
route_json = {
|
route_json = {
|
||||||
@@ -188,8 +196,16 @@ class OrchestratorService:
|
|||||||
"parse_strategy": ontology.parse_strategy,
|
"parse_strategy": ontology.parse_strategy,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
clarification_status = (
|
||||||
|
AgentRunStatus.SUCCEEDED.value
|
||||||
|
if self._is_application_submit_result(clarification_result)
|
||||||
|
else AgentRunStatus.BLOCKED.value
|
||||||
|
)
|
||||||
|
if clarification_status == AgentRunStatus.SUCCEEDED.value:
|
||||||
|
clarification_result["clarification_required"] = False
|
||||||
|
clarification_result["missing_slots"] = []
|
||||||
outcome = ExecutionOutcome(
|
outcome = ExecutionOutcome(
|
||||||
status=AgentRunStatus.BLOCKED.value,
|
status=clarification_status,
|
||||||
result=clarification_result,
|
result=clarification_result,
|
||||||
degraded=False,
|
degraded=False,
|
||||||
tool_count=0,
|
tool_count=0,
|
||||||
@@ -233,11 +249,23 @@ class OrchestratorService:
|
|||||||
context_json=context_json,
|
context_json=context_json,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
result_requires_confirmation = bool(outcome.result.get("requires_confirmation"))
|
||||||
|
response_requires_confirmation = requires_confirmation or (
|
||||||
|
is_expense_application_context and result_requires_confirmation
|
||||||
|
)
|
||||||
final_status = (
|
final_status = (
|
||||||
AgentRunStatus.BLOCKED.value
|
AgentRunStatus.BLOCKED.value
|
||||||
if requires_confirmation
|
if outcome.status == AgentRunStatus.SUCCEEDED.value
|
||||||
and outcome.status == AgentRunStatus.SUCCEEDED.value
|
and (
|
||||||
and ontology.permission.level == AgentPermissionLevel.APPROVAL_REQUIRED.value
|
(
|
||||||
|
requires_confirmation
|
||||||
|
and ontology.permission.level == AgentPermissionLevel.APPROVAL_REQUIRED.value
|
||||||
|
)
|
||||||
|
or (
|
||||||
|
is_expense_application_context
|
||||||
|
and result_requires_confirmation
|
||||||
|
)
|
||||||
|
)
|
||||||
else outcome.status
|
else outcome.status
|
||||||
)
|
)
|
||||||
response_status = self._normalize_response_status(final_status)
|
response_status = self._normalize_response_status(final_status)
|
||||||
@@ -259,7 +287,7 @@ class OrchestratorService:
|
|||||||
ontology_json=self.execution_engine._build_ontology_json(ontology),
|
ontology_json=self.execution_engine._build_ontology_json(ontology),
|
||||||
route_json={
|
route_json={
|
||||||
**route_json,
|
**route_json,
|
||||||
"requires_confirmation": requires_confirmation,
|
"requires_confirmation": response_requires_confirmation,
|
||||||
"degraded": outcome.degraded,
|
"degraded": outcome.degraded,
|
||||||
},
|
},
|
||||||
permission_level=ontology.permission.level,
|
permission_level=ontology.permission.level,
|
||||||
@@ -297,7 +325,7 @@ class OrchestratorService:
|
|||||||
"route_reason": route_reason,
|
"route_reason": route_reason,
|
||||||
"permission_level": ontology.permission.level,
|
"permission_level": ontology.permission.level,
|
||||||
"status": response_status,
|
"status": response_status,
|
||||||
"requires_confirmation": requires_confirmation,
|
"requires_confirmation": response_requires_confirmation,
|
||||||
"trace_summary": trace_summary.model_dump(),
|
"trace_summary": trace_summary.model_dump(),
|
||||||
"result": outcome.result,
|
"result": outcome.result,
|
||||||
},
|
},
|
||||||
@@ -311,7 +339,7 @@ class OrchestratorService:
|
|||||||
permission_level=ontology.permission.level,
|
permission_level=ontology.permission.level,
|
||||||
status=response_status,
|
status=response_status,
|
||||||
result=outcome.result,
|
result=outcome.result,
|
||||||
requires_confirmation=requires_confirmation,
|
requires_confirmation=response_requires_confirmation,
|
||||||
trace_summary=trace_summary,
|
trace_summary=trace_summary,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -462,3 +490,54 @@ class OrchestratorService:
|
|||||||
if status == AgentRunStatus.BLOCKED.value:
|
if status == AgentRunStatus.BLOCKED.value:
|
||||||
return "blocked"
|
return "blocked"
|
||||||
return "succeeded"
|
return "succeeded"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_expense_application_context(context_json: dict[str, Any]) -> bool:
|
||||||
|
context_json = context_json or {}
|
||||||
|
context_values = {
|
||||||
|
str(context_json.get("session_type") or "").strip(),
|
||||||
|
str(context_json.get("entry_source") or "").strip(),
|
||||||
|
str(context_json.get("document_type") or "").strip(),
|
||||||
|
str(context_json.get("application_stage") or "").strip(),
|
||||||
|
}
|
||||||
|
conversation_state = context_json.get("conversation_state")
|
||||||
|
if isinstance(conversation_state, dict):
|
||||||
|
context_values.update(
|
||||||
|
{
|
||||||
|
str(conversation_state.get("session_type") or "").strip(),
|
||||||
|
str(conversation_state.get("entry_source") or "").strip(),
|
||||||
|
str(conversation_state.get("document_type") or "").strip(),
|
||||||
|
str(conversation_state.get("application_stage") or "").strip(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if context_values & APPLICATION_CONTEXT_VALUES:
|
||||||
|
return True
|
||||||
|
|
||||||
|
history = context_json.get("conversation_history")
|
||||||
|
if not isinstance(history, list):
|
||||||
|
return False
|
||||||
|
current_message = re.sub(r"\s+", "", str(context_json.get("user_input_text") or ""))
|
||||||
|
looks_like_submit = (
|
||||||
|
any(keyword in current_message for keyword in APPLICATION_SUBMIT_KEYWORDS)
|
||||||
|
or current_message in APPLICATION_SHORT_CONFIRMATIONS
|
||||||
|
or not current_message
|
||||||
|
)
|
||||||
|
if not looks_like_submit:
|
||||||
|
return False
|
||||||
|
for item in history[-6:]:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
content = str(item.get("content") or "")
|
||||||
|
if "#application-submit" in content or ("费用申请" in content and "确认" in content):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_application_submit_result(result: dict[str, Any]) -> bool:
|
||||||
|
draft_payload = result.get("draft_payload")
|
||||||
|
return (
|
||||||
|
isinstance(draft_payload, dict)
|
||||||
|
and str(draft_payload.get("draft_type") or "").strip() == "expense_application"
|
||||||
|
and str(draft_payload.get("status") or "").strip() == "submitted"
|
||||||
|
and bool(str(draft_payload.get("claim_no") or draft_payload.get("claim_id") or "").strip())
|
||||||
|
)
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ from app.services.user_agent_documents import UserAgentDocumentService
|
|||||||
from app.services.user_agent_knowledge import UserAgentKnowledgeMixin
|
from app.services.user_agent_knowledge import UserAgentKnowledgeMixin
|
||||||
|
|
||||||
from app.services.user_agent_constants import *
|
from app.services.user_agent_constants import *
|
||||||
|
from app.services.user_agent_application import UserAgentApplicationMixin
|
||||||
from app.services.user_agent_response import UserAgentResponseMixin
|
from app.services.user_agent_response import UserAgentResponseMixin
|
||||||
from app.services.user_agent_review_core import UserAgentReviewCoreMixin
|
from app.services.user_agent_review_core import UserAgentReviewCoreMixin
|
||||||
from app.services.user_agent_review_messages import UserAgentReviewMessageMixin
|
from app.services.user_agent_review_messages import UserAgentReviewMessageMixin
|
||||||
@@ -55,6 +56,7 @@ from app.services.user_agent_review_travel_receipts import UserAgentReviewTravel
|
|||||||
|
|
||||||
class UserAgentService(
|
class UserAgentService(
|
||||||
UserAgentResponseMixin,
|
UserAgentResponseMixin,
|
||||||
|
UserAgentApplicationMixin,
|
||||||
UserAgentKnowledgeMixin,
|
UserAgentKnowledgeMixin,
|
||||||
UserAgentReviewCoreMixin,
|
UserAgentReviewCoreMixin,
|
||||||
UserAgentReviewTravelPolicyMixin,
|
UserAgentReviewTravelPolicyMixin,
|
||||||
@@ -72,6 +74,12 @@ class UserAgentService(
|
|||||||
def respond(self, payload: UserAgentRequest) -> UserAgentResponse:
|
def respond(self, payload: UserAgentRequest) -> UserAgentResponse:
|
||||||
AgentFoundationService(self.db).ensure_foundation_ready()
|
AgentFoundationService(self.db).ensure_foundation_ready()
|
||||||
citations = self._build_citations(payload)
|
citations = self._build_citations(payload)
|
||||||
|
risk_flags = self._resolve_risk_flags(payload)
|
||||||
|
if self._is_expense_application_request(payload):
|
||||||
|
return self._build_expense_application_response(
|
||||||
|
payload,
|
||||||
|
risk_flags=risk_flags,
|
||||||
|
)
|
||||||
suggested_actions = self._build_suggested_actions(payload)
|
suggested_actions = self._build_suggested_actions(payload)
|
||||||
if self._should_prompt_expense_scene_selection(payload):
|
if self._should_prompt_expense_scene_selection(payload):
|
||||||
return UserAgentResponse(
|
return UserAgentResponse(
|
||||||
@@ -84,7 +92,6 @@ class UserAgentService(
|
|||||||
risk_flags=[],
|
risk_flags=[],
|
||||||
requires_confirmation=False,
|
requires_confirmation=False,
|
||||||
)
|
)
|
||||||
risk_flags = self._resolve_risk_flags(payload)
|
|
||||||
query_payload = self._build_query_payload(payload)
|
query_payload = self._build_query_payload(payload)
|
||||||
draft_payload = (
|
draft_payload = (
|
||||||
self._build_draft_payload(payload)
|
self._build_draft_payload(payload)
|
||||||
|
|||||||
958
server/src/app/services/user_agent_application.py
Normal file
958
server/src/app/services/user_agent_application.py
Normal file
@@ -0,0 +1,958 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUserContext
|
||||||
|
from app.models.financial_record import ExpenseClaim
|
||||||
|
from app.schemas.user_agent import (
|
||||||
|
UserAgentDraftPayload,
|
||||||
|
UserAgentRequest,
|
||||||
|
UserAgentResponse,
|
||||||
|
UserAgentSuggestedAction,
|
||||||
|
)
|
||||||
|
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
|
||||||
|
|
||||||
|
APPLICATION_CONTEXT_VALUES = {
|
||||||
|
"application",
|
||||||
|
"documents_application",
|
||||||
|
"expense_application",
|
||||||
|
"pre_approval",
|
||||||
|
"preapproval",
|
||||||
|
}
|
||||||
|
APPLICATION_BASE_FIELDS = ("time", "location", "reason")
|
||||||
|
APPLICATION_TRANSPORT_OPTIONS = ("飞机", "火车", "轮船")
|
||||||
|
APPLICATION_TRANSPORT_KEYWORDS = {
|
||||||
|
"飞机": ("飞机", "机票", "航班", "乘机", "坐飞机"),
|
||||||
|
"火车": ("火车", "高铁", "动车", "铁路", "列车"),
|
||||||
|
"轮船": ("轮船", "船", "客轮", "邮轮", "坐船"),
|
||||||
|
}
|
||||||
|
APPLICATION_DESTINATION_PREFIXES = (
|
||||||
|
"上海",
|
||||||
|
"北京",
|
||||||
|
"广州",
|
||||||
|
"深圳",
|
||||||
|
"杭州",
|
||||||
|
"南京",
|
||||||
|
"苏州",
|
||||||
|
"成都",
|
||||||
|
"重庆",
|
||||||
|
"武汉",
|
||||||
|
"西安",
|
||||||
|
"天津",
|
||||||
|
"宁波",
|
||||||
|
"青岛",
|
||||||
|
"长沙",
|
||||||
|
"郑州",
|
||||||
|
"济南",
|
||||||
|
"合肥",
|
||||||
|
"福州",
|
||||||
|
"厦门",
|
||||||
|
"昆明",
|
||||||
|
"南昌",
|
||||||
|
"沈阳",
|
||||||
|
"大连",
|
||||||
|
"无锡",
|
||||||
|
"佛山",
|
||||||
|
"东莞",
|
||||||
|
)
|
||||||
|
APPLICATION_REASON_VERBS = (
|
||||||
|
"支撑",
|
||||||
|
"支持",
|
||||||
|
"部署",
|
||||||
|
"上线",
|
||||||
|
"实施",
|
||||||
|
"驻场",
|
||||||
|
"拜访",
|
||||||
|
"验收",
|
||||||
|
"会议",
|
||||||
|
"采购",
|
||||||
|
"培训",
|
||||||
|
"协助",
|
||||||
|
"处理",
|
||||||
|
"办理",
|
||||||
|
"参加",
|
||||||
|
"进行",
|
||||||
|
)
|
||||||
|
APPLICATION_SUBMIT_KEYWORDS = (
|
||||||
|
"确认提交",
|
||||||
|
"确认申请",
|
||||||
|
"提交审核",
|
||||||
|
"确认无误提交",
|
||||||
|
"直接提交",
|
||||||
|
)
|
||||||
|
APPLICATION_SHORT_CONFIRMATIONS = {"提交", "确认", "是", "好的", "可以", "没问题"}
|
||||||
|
|
||||||
|
|
||||||
|
class UserAgentApplicationMixin:
|
||||||
|
@staticmethod
|
||||||
|
def _is_expense_application_request(payload: UserAgentRequest) -> bool:
|
||||||
|
context_json = payload.context_json or {}
|
||||||
|
context_values = {
|
||||||
|
str(context_json.get("session_type") or "").strip(),
|
||||||
|
str(context_json.get("entry_source") or "").strip(),
|
||||||
|
str(context_json.get("document_type") or "").strip(),
|
||||||
|
str(context_json.get("application_stage") or "").strip(),
|
||||||
|
}
|
||||||
|
conversation_state = context_json.get("conversation_state")
|
||||||
|
if isinstance(conversation_state, dict):
|
||||||
|
context_values.update(
|
||||||
|
{
|
||||||
|
str(conversation_state.get("session_type") or "").strip(),
|
||||||
|
str(conversation_state.get("entry_source") or "").strip(),
|
||||||
|
str(conversation_state.get("document_type") or "").strip(),
|
||||||
|
str(conversation_state.get("application_stage") or "").strip(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if context_values & APPLICATION_CONTEXT_VALUES:
|
||||||
|
return True
|
||||||
|
|
||||||
|
history = context_json.get("conversation_history")
|
||||||
|
if not isinstance(history, list):
|
||||||
|
return False
|
||||||
|
compact_message = re.sub(r"\s+", "", str(payload.message or ""))
|
||||||
|
looks_like_submit = (
|
||||||
|
any(keyword in compact_message for keyword in APPLICATION_SUBMIT_KEYWORDS)
|
||||||
|
or compact_message in APPLICATION_SHORT_CONFIRMATIONS
|
||||||
|
)
|
||||||
|
if not looks_like_submit:
|
||||||
|
return False
|
||||||
|
return any(
|
||||||
|
isinstance(item, dict)
|
||||||
|
and str(item.get("role") or "").strip() == "assistant"
|
||||||
|
and (
|
||||||
|
"#application-submit" in str(item.get("content") or "")
|
||||||
|
or ("费用申请" in str(item.get("content") or "") and "确认" in str(item.get("content") or ""))
|
||||||
|
)
|
||||||
|
for item in history[-6:]
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_expense_application_response(
|
||||||
|
self,
|
||||||
|
payload: UserAgentRequest,
|
||||||
|
*,
|
||||||
|
risk_flags: list[str],
|
||||||
|
) -> UserAgentResponse:
|
||||||
|
facts = self._resolve_expense_application_facts(payload)
|
||||||
|
step = self._resolve_expense_application_step(payload, facts)
|
||||||
|
application_claim = None
|
||||||
|
if step == "submitted":
|
||||||
|
application_claim = self._create_expense_application_record(payload, facts)
|
||||||
|
facts["application_no"] = application_claim.claim_no
|
||||||
|
facts["application_claim_id"] = application_claim.id
|
||||||
|
facts["manager_name"] = self._resolve_application_manager_name(payload, application_claim)
|
||||||
|
return UserAgentResponse(
|
||||||
|
answer=self._build_expense_application_answer(payload, facts=facts, step=step),
|
||||||
|
citations=[],
|
||||||
|
suggested_actions=self._build_expense_application_actions(step, facts),
|
||||||
|
query_payload=None,
|
||||||
|
draft_payload=self._build_submitted_application_payload(application_claim, facts),
|
||||||
|
review_payload=None,
|
||||||
|
risk_flags=risk_flags,
|
||||||
|
requires_confirmation=step == "preview",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_expense_application_answer(
|
||||||
|
self,
|
||||||
|
payload: UserAgentRequest,
|
||||||
|
*,
|
||||||
|
facts: dict[str, str],
|
||||||
|
step: str,
|
||||||
|
) -> str:
|
||||||
|
recognized_table = self._build_application_summary_table(facts, include_empty=False)
|
||||||
|
|
||||||
|
if step == "ask_missing":
|
||||||
|
missing_fields = self._resolve_application_missing_fields(facts)
|
||||||
|
missing_text = "、".join(
|
||||||
|
self._display_application_slot_label(item)
|
||||||
|
for item in missing_fields
|
||||||
|
)
|
||||||
|
return "\n\n".join(
|
||||||
|
[
|
||||||
|
"我已按「费用申请 / 事前审批」来处理这条内容。",
|
||||||
|
"已识别信息:\n" + recognized_table,
|
||||||
|
f"当前还需要补充:{missing_text}。",
|
||||||
|
"请一次性补齐上述字段,我会继续生成模拟申请结果并让你确认是否提交。",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
if step == "submitted":
|
||||||
|
application_no = str(facts.get("application_no") or "").strip() or self._build_application_claim_no(payload, facts)
|
||||||
|
manager_name = str(facts.get("manager_name") or "").strip() or "直属领导"
|
||||||
|
return "\n\n".join(
|
||||||
|
[
|
||||||
|
f"当前操作已完成,单据已经推送给 {manager_name} 进行审核,请耐心等待。",
|
||||||
|
f"申请单号:{application_no}",
|
||||||
|
"申请信息:\n" + self._build_application_summary_table(facts),
|
||||||
|
f"当前状态:{manager_name}审核中。",
|
||||||
|
"预算处理:预计总费用已作为预算占用参考,等待领导审核确认。",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n\n".join(
|
||||||
|
[
|
||||||
|
"这是模拟的费用申请结果,请核对:",
|
||||||
|
self._build_application_summary_table(facts),
|
||||||
|
"请核对上述信息无误,确认无误后 [确认](#application-submit) 提交至审批流程。",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def _resolve_expense_application_facts(self, payload: UserAgentRequest) -> dict[str, str]:
|
||||||
|
facts = {
|
||||||
|
"time": "",
|
||||||
|
"location": "",
|
||||||
|
"reason": "",
|
||||||
|
"days": "",
|
||||||
|
"transport_mode": "",
|
||||||
|
"amount": "",
|
||||||
|
"application_type": "",
|
||||||
|
}
|
||||||
|
for message, is_current in self._iter_application_user_messages(payload):
|
||||||
|
partial = {
|
||||||
|
"time": self._resolve_application_time(payload, message=message) if is_current else self._resolve_application_time_from_text(message),
|
||||||
|
"location": self._resolve_application_location(payload, message=message, use_entities=is_current),
|
||||||
|
"reason": self._resolve_application_reason(message),
|
||||||
|
"days": self._resolve_application_days(message),
|
||||||
|
"transport_mode": self._resolve_application_transport_mode(message),
|
||||||
|
"amount": self._resolve_application_amount(payload, message=message) if is_current else self._resolve_application_amount_from_text(message),
|
||||||
|
"application_type": self._resolve_application_type_from_text(message),
|
||||||
|
}
|
||||||
|
for key, value in partial.items():
|
||||||
|
if value:
|
||||||
|
facts[key] = value
|
||||||
|
|
||||||
|
if not facts["application_type"]:
|
||||||
|
facts["application_type"] = self._infer_application_type(facts)
|
||||||
|
facts["time"] = self._expand_application_time_with_days(
|
||||||
|
facts.get("time", ""),
|
||||||
|
facts.get("days", ""),
|
||||||
|
)
|
||||||
|
return facts
|
||||||
|
|
||||||
|
def _resolve_expense_application_step(
|
||||||
|
self,
|
||||||
|
payload: UserAgentRequest,
|
||||||
|
facts: dict[str, str],
|
||||||
|
) -> str:
|
||||||
|
if self._resolve_application_missing_base_fields(facts):
|
||||||
|
return "ask_missing"
|
||||||
|
if self._resolve_application_missing_followup_fields(facts):
|
||||||
|
return "ask_missing"
|
||||||
|
if self._is_application_submit_confirmation(payload):
|
||||||
|
return "submitted"
|
||||||
|
return "preview"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _iter_application_user_messages(payload: UserAgentRequest) -> list[tuple[str, bool]]:
|
||||||
|
messages: list[tuple[str, bool]] = []
|
||||||
|
history = (payload.context_json or {}).get("conversation_history")
|
||||||
|
if isinstance(history, list):
|
||||||
|
for item in history:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
if str(item.get("role") or "").strip() != "user":
|
||||||
|
continue
|
||||||
|
content = str(item.get("content") or "").strip()
|
||||||
|
if content:
|
||||||
|
messages.append((content, False))
|
||||||
|
current_message = str(payload.message or "").strip()
|
||||||
|
if current_message:
|
||||||
|
messages.append((current_message, True))
|
||||||
|
return messages
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_application_missing_base_fields(facts: dict[str, str]) -> list[str]:
|
||||||
|
return [field for field in APPLICATION_BASE_FIELDS if not str(facts.get(field) or "").strip()]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_application_missing_followup_fields(facts: dict[str, str]) -> list[str]:
|
||||||
|
return [
|
||||||
|
field
|
||||||
|
for field in ("transport_mode", "amount")
|
||||||
|
if not str(facts.get(field) or "").strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
def _resolve_application_missing_fields(self, facts: dict[str, str]) -> list[str]:
|
||||||
|
return [
|
||||||
|
*self._resolve_application_missing_base_fields(facts),
|
||||||
|
*self._resolve_application_missing_followup_fields(facts),
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_application_time(payload: UserAgentRequest, *, message: str | None = None) -> str:
|
||||||
|
if message and UserAgentApplicationMixin._resolve_application_time_from_text(message):
|
||||||
|
return UserAgentApplicationMixin._resolve_application_time_from_text(message)
|
||||||
|
|
||||||
|
context_time = UserAgentApplicationMixin._resolve_application_time_from_context(payload.context_json or {})
|
||||||
|
if context_time:
|
||||||
|
return context_time
|
||||||
|
|
||||||
|
time_range = payload.ontology.time_range
|
||||||
|
if time_range.start_date and time_range.end_date:
|
||||||
|
return (
|
||||||
|
time_range.start_date
|
||||||
|
if time_range.start_date == time_range.end_date
|
||||||
|
else f"{time_range.start_date} 至 {time_range.end_date}"
|
||||||
|
)
|
||||||
|
return str(time_range.raw or "").strip()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_application_time_from_text(message: str) -> str:
|
||||||
|
labeled = UserAgentApplicationMixin._resolve_application_labeled_value(
|
||||||
|
message,
|
||||||
|
("发生时间", "业务发生时间", "申请时间", "时间"),
|
||||||
|
)
|
||||||
|
if labeled:
|
||||||
|
return labeled
|
||||||
|
match = re.search(
|
||||||
|
r"(?P<date>20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?)",
|
||||||
|
str(message or ""),
|
||||||
|
)
|
||||||
|
return match.group("date").rstrip("日") if match else ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_application_time_from_context(context_json: dict[str, object]) -> str:
|
||||||
|
business_time_context = context_json.get("business_time_context")
|
||||||
|
if not isinstance(business_time_context, dict):
|
||||||
|
return ""
|
||||||
|
start_date = str(business_time_context.get("start_date") or "").strip()
|
||||||
|
end_date = str(business_time_context.get("end_date") or start_date).strip()
|
||||||
|
display_value = str(business_time_context.get("display_value") or "").strip()
|
||||||
|
if start_date and end_date:
|
||||||
|
return start_date if start_date == end_date else f"{start_date} 至 {end_date}"
|
||||||
|
return display_value
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_application_labeled_value(message: str, labels: tuple[str, ...]) -> str:
|
||||||
|
label_pattern = "|".join(re.escape(label) for label in labels)
|
||||||
|
match = re.search(
|
||||||
|
rf"(?:{label_pattern})[::]\s*(?P<value>[^\n,。;;]+)",
|
||||||
|
str(message or ""),
|
||||||
|
)
|
||||||
|
return match.group("value").strip() if match else ""
|
||||||
|
|
||||||
|
def _resolve_application_entity_or_label(
|
||||||
|
self,
|
||||||
|
payload: UserAgentRequest,
|
||||||
|
entity_type: str,
|
||||||
|
labels: tuple[str, ...],
|
||||||
|
) -> str:
|
||||||
|
entity_value = next(
|
||||||
|
(
|
||||||
|
str(item.normalized_value or item.value or "").strip()
|
||||||
|
for item in payload.ontology.entities
|
||||||
|
if item.type == entity_type
|
||||||
|
and str(item.normalized_value or item.value or "").strip()
|
||||||
|
),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
return entity_value or self._resolve_application_labeled_value(payload.message, labels)
|
||||||
|
|
||||||
|
def _resolve_application_location(
|
||||||
|
self,
|
||||||
|
payload: UserAgentRequest,
|
||||||
|
*,
|
||||||
|
message: str,
|
||||||
|
use_entities: bool,
|
||||||
|
) -> str:
|
||||||
|
entity_or_labeled = (
|
||||||
|
self._resolve_application_entity_or_label(payload, "location", ("地点", "业务地点", "发生地点"))
|
||||||
|
if use_entities
|
||||||
|
else self._resolve_application_labeled_value(message, ("地点", "业务地点", "发生地点"))
|
||||||
|
)
|
||||||
|
return entity_or_labeled or self._resolve_application_location_from_text(message)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_application_location_from_text(message: str) -> str:
|
||||||
|
compact = re.sub(r"\s+", "", str(message or ""))
|
||||||
|
if not compact:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
for pattern in (
|
||||||
|
r"(?:出差|去|到|赴|前往)(?P<target>[\u4e00-\u9fa5]{1,24})",
|
||||||
|
r"(?P<target>[\u4e00-\u9fa5]{1,12})(?:出差|驻场)",
|
||||||
|
):
|
||||||
|
match = re.search(pattern, compact)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
target = str(match.group("target") or "").strip()
|
||||||
|
location = UserAgentApplicationMixin._normalize_application_location_target(target)
|
||||||
|
if location:
|
||||||
|
return location
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_application_location_target(target: str) -> str:
|
||||||
|
text = str(target or "").strip("::,,。;;")
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
known = next((item for item in APPLICATION_DESTINATION_PREFIXES if text.startswith(item)), "")
|
||||||
|
if known:
|
||||||
|
return known
|
||||||
|
|
||||||
|
verb_indexes = [
|
||||||
|
index
|
||||||
|
for keyword in APPLICATION_REASON_VERBS
|
||||||
|
for index in [text.find(keyword)]
|
||||||
|
if index > 0
|
||||||
|
]
|
||||||
|
if verb_indexes:
|
||||||
|
return text[: min(verb_indexes)]
|
||||||
|
return text[:12]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_application_days(message: str) -> str:
|
||||||
|
labeled = UserAgentApplicationMixin._resolve_application_labeled_value(
|
||||||
|
message,
|
||||||
|
("天数", "出差天数", "申请天数"),
|
||||||
|
)
|
||||||
|
if labeled:
|
||||||
|
return labeled if labeled.endswith("天") else f"{labeled}天"
|
||||||
|
match = re.search(r"(?P<days>\d+|[一二两三四五六七八九十]{1,3})\s*天", str(message or ""))
|
||||||
|
return f"{match.group('days')}天" if match else ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_application_reason(message: str) -> str:
|
||||||
|
labeled = UserAgentApplicationMixin._resolve_application_labeled_value(
|
||||||
|
message,
|
||||||
|
("事由", "申请事由", "出差事由", "原因", "用途"),
|
||||||
|
)
|
||||||
|
if labeled:
|
||||||
|
return labeled
|
||||||
|
|
||||||
|
text = str(message or "").strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
candidates: list[str] = []
|
||||||
|
for segment in re.split(r"[\n,。;;]+", text):
|
||||||
|
candidate = UserAgentApplicationMixin._cleanup_application_reason_candidate(segment)
|
||||||
|
if candidate:
|
||||||
|
candidates.append(candidate)
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return ""
|
||||||
|
return max(candidates, key=len)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _cleanup_application_reason_candidate(segment: str) -> str:
|
||||||
|
text = str(segment or "").strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
text = re.sub(
|
||||||
|
r"^(?:发生时间|业务发生时间|申请时间|时间|地点|业务地点|发生地点|天数|出差天数|申请天数|出行方式|交通方式|交通工具|预计总费用|预计费用|预计金额|申请金额|预算|金额)[::]\s*",
|
||||||
|
"",
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
if re.fullmatch(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?", text):
|
||||||
|
return ""
|
||||||
|
if re.fullmatch(r"(?P<days>\d+|[一二两三四五六七八九十]{1,3})\s*天", text):
|
||||||
|
return ""
|
||||||
|
if re.fullmatch(r"(?:¥|¥)?\s*\d+(?:\.\d+)?\s*(?:元|块|万元)?", text):
|
||||||
|
return ""
|
||||||
|
if "时间" in text and re.search(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}", text):
|
||||||
|
return ""
|
||||||
|
if re.fullmatch(r"(?:去|到|前往)?[\u4e00-\u9fa5]{1,8}出差(?P<days>\d+|[一二两三四五六七八九十]{1,3})?天?", text):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
text = re.sub(r"^.*?(?:出差|前往|去|到|赴)[\u4e00-\u9fa5]{1,8}(?:出差)?(?P<days>\d+|[一二两三四五六七八九十]{1,3})?天?[,,\s]*", "", text)
|
||||||
|
text = re.sub(r"^(?:出差|申请|费用申请|业务|本次|去|到|前往)\s*", "", text)
|
||||||
|
text = text.strip(" ::,,。;;")
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
if re.fullmatch(r"[\u4e00-\u9fa5]{1,8}", text) and not any(keyword in text for keyword in APPLICATION_REASON_VERBS):
|
||||||
|
return ""
|
||||||
|
return text
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _expand_application_time_with_days(time_text: str, days_text: str) -> str:
|
||||||
|
normalized_time = str(time_text or "").strip()
|
||||||
|
if not normalized_time or re.search(r"\s*(?:至|到|~|-{2,}|—)\s*", normalized_time):
|
||||||
|
return normalized_time
|
||||||
|
|
||||||
|
days = UserAgentApplicationMixin._resolve_application_days_count(days_text)
|
||||||
|
if not days:
|
||||||
|
return normalized_time
|
||||||
|
|
||||||
|
match = re.search(
|
||||||
|
r"(?P<date>20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?)",
|
||||||
|
normalized_time,
|
||||||
|
)
|
||||||
|
if not match:
|
||||||
|
return normalized_time
|
||||||
|
|
||||||
|
parsed_start = UserAgentApplicationMixin._parse_application_date(match.group("date"))
|
||||||
|
if parsed_start is None:
|
||||||
|
return normalized_time
|
||||||
|
|
||||||
|
end_date = parsed_start + timedelta(days=days)
|
||||||
|
return f"{parsed_start:%Y-%m-%d} 至 {end_date:%Y-%m-%d}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_application_days_count(days_text: str) -> int:
|
||||||
|
text = str(days_text or "").strip()
|
||||||
|
if not text:
|
||||||
|
return 0
|
||||||
|
digit_match = re.search(r"\d+", text)
|
||||||
|
if digit_match:
|
||||||
|
return max(0, int(digit_match.group(0)))
|
||||||
|
|
||||||
|
chinese_match = re.search(r"[一二两三四五六七八九十]{1,3}", text)
|
||||||
|
if not chinese_match:
|
||||||
|
return 0
|
||||||
|
return UserAgentApplicationMixin._parse_chinese_number(chinese_match.group(0))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_chinese_number(value: str) -> int:
|
||||||
|
digits = {
|
||||||
|
"一": 1,
|
||||||
|
"二": 2,
|
||||||
|
"两": 2,
|
||||||
|
"三": 3,
|
||||||
|
"四": 4,
|
||||||
|
"五": 5,
|
||||||
|
"六": 6,
|
||||||
|
"七": 7,
|
||||||
|
"八": 8,
|
||||||
|
"九": 9,
|
||||||
|
}
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return 0
|
||||||
|
if text == "十":
|
||||||
|
return 10
|
||||||
|
if "十" in text:
|
||||||
|
left, _, right = text.partition("十")
|
||||||
|
tens = digits.get(left, 1) if left else 1
|
||||||
|
ones = digits.get(right, 0) if right else 0
|
||||||
|
return tens * 10 + ones
|
||||||
|
return digits.get(text, 0)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_application_date(value: str) -> datetime | None:
|
||||||
|
normalized = str(value or "").strip().rstrip("日").replace("年", "-").replace("月", "-")
|
||||||
|
normalized = normalized.replace("/", "-").replace(".", "-")
|
||||||
|
parts = [part for part in normalized.split("-") if part]
|
||||||
|
if len(parts) != 3:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
year, month, day = (int(part) for part in parts)
|
||||||
|
return datetime(year, month, day)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _resolve_application_amount(
|
||||||
|
self,
|
||||||
|
payload: UserAgentRequest,
|
||||||
|
*,
|
||||||
|
message: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
entity_amount = next(
|
||||||
|
(
|
||||||
|
str(item.normalized_value or item.value or "").strip()
|
||||||
|
for item in payload.ontology.entities
|
||||||
|
if item.type == "amount"
|
||||||
|
and str(item.normalized_value or item.value or "").strip()
|
||||||
|
),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
if entity_amount:
|
||||||
|
return entity_amount if entity_amount.endswith("元") else f"{entity_amount}元"
|
||||||
|
return self._resolve_application_amount_from_text(message or payload.message)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_application_amount_from_text(message: str) -> str:
|
||||||
|
labeled = UserAgentApplicationMixin._resolve_application_labeled_value(
|
||||||
|
message,
|
||||||
|
("预计总费用", "预计费用", "预计金额", "申请金额", "预算", "费用", "金额"),
|
||||||
|
)
|
||||||
|
if labeled:
|
||||||
|
return UserAgentApplicationMixin._normalize_application_amount(labeled)
|
||||||
|
match = re.search(
|
||||||
|
r"(?P<amount>\d+(?:\.\d+)?\s*万?\s*(?:元|块|人民币))",
|
||||||
|
str(message or ""),
|
||||||
|
)
|
||||||
|
return UserAgentApplicationMixin._normalize_application_amount(match.group("amount")) if match else ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_application_amount(value: str) -> str:
|
||||||
|
normalized = str(value or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
return ""
|
||||||
|
normalized = re.sub(r"\s+", "", normalized)
|
||||||
|
if normalized.endswith(("元", "块")) or "人民币" in normalized:
|
||||||
|
return normalized.replace("块", "元").replace("人民币", "")
|
||||||
|
return f"{normalized}元"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_application_transport_mode(message: str) -> str:
|
||||||
|
compact_message = re.sub(r"\s+", "", str(message or ""))
|
||||||
|
for transport, keywords in APPLICATION_TRANSPORT_KEYWORDS.items():
|
||||||
|
if any(keyword in compact_message for keyword in keywords):
|
||||||
|
return transport
|
||||||
|
labeled = UserAgentApplicationMixin._resolve_application_labeled_value(
|
||||||
|
message,
|
||||||
|
("出行方式", "交通方式", "交通工具", "出行工具"),
|
||||||
|
)
|
||||||
|
if labeled:
|
||||||
|
for transport, keywords in APPLICATION_TRANSPORT_KEYWORDS.items():
|
||||||
|
if transport in labeled or any(keyword in labeled for keyword in keywords):
|
||||||
|
return transport
|
||||||
|
return labeled
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_application_type_from_text(message: str) -> str:
|
||||||
|
return UserAgentApplicationMixin._resolve_application_labeled_value(
|
||||||
|
message,
|
||||||
|
("申请类型", "费用类型"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_application_missing_slots(payload: UserAgentRequest) -> list[str]:
|
||||||
|
return [
|
||||||
|
str(item or "").strip()
|
||||||
|
for item in payload.ontology.missing_slots
|
||||||
|
if str(item or "").strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _display_application_slot_label(slot: str) -> str:
|
||||||
|
return {
|
||||||
|
"expense_type": "申请类型",
|
||||||
|
"amount": "预计金额/预算",
|
||||||
|
"time_range": "发生时间",
|
||||||
|
"time": "发生时间",
|
||||||
|
"location": "地点",
|
||||||
|
"reason": "申请事由",
|
||||||
|
"days": "天数",
|
||||||
|
"transport_mode": "出行方式",
|
||||||
|
"attachments": "申请材料/附件",
|
||||||
|
"customer_name": "业务对象",
|
||||||
|
"participants": "参与人员",
|
||||||
|
}.get(str(slot or "").strip(), str(slot or "").strip())
|
||||||
|
|
||||||
|
def _build_expense_application_actions(
|
||||||
|
self,
|
||||||
|
step: str,
|
||||||
|
facts: dict[str, str],
|
||||||
|
) -> list[UserAgentSuggestedAction]:
|
||||||
|
if step == "ask_missing":
|
||||||
|
missing_fields = self._resolve_application_missing_fields(facts)
|
||||||
|
return [
|
||||||
|
UserAgentSuggestedAction(
|
||||||
|
label="一次性补充申请信息",
|
||||||
|
action_type="prefill_composer",
|
||||||
|
description="在输入框预填所有待补充字段,填写后一次提交。",
|
||||||
|
payload={
|
||||||
|
"application_fields": missing_fields,
|
||||||
|
"prompt_prefill": self._build_application_prefill_template(missing_fields),
|
||||||
|
"missing_fields": missing_fields,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if step == "preview":
|
||||||
|
return []
|
||||||
|
if step == "submitted":
|
||||||
|
return []
|
||||||
|
return []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_application_prefill_config(field: str) -> tuple[str, str]:
|
||||||
|
config = {
|
||||||
|
"time": ("补充发生时间", "申请时间段:"),
|
||||||
|
"location": ("补充地点", "地点:"),
|
||||||
|
"reason": ("补充申请事由", "事由:"),
|
||||||
|
"days": ("补充天数", "天数:"),
|
||||||
|
"transport_mode": ("补充出行方式", "出行方式:"),
|
||||||
|
"amount": ("补充预计总费用", "预计总费用:"),
|
||||||
|
}
|
||||||
|
return config.get(field, ("补充申请信息", ""))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _build_application_prefill_template(cls, fields: list[str]) -> str:
|
||||||
|
lines = [
|
||||||
|
prefill
|
||||||
|
for field in fields
|
||||||
|
for _, prefill in [cls._resolve_application_prefill_config(field)]
|
||||||
|
if prefill
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _build_application_prefill_action(cls, field: str) -> UserAgentSuggestedAction:
|
||||||
|
label, prefill = cls._resolve_application_prefill_config(field)
|
||||||
|
return UserAgentSuggestedAction(
|
||||||
|
label=label,
|
||||||
|
action_type="prefill_composer",
|
||||||
|
description=f"在输入框预填“{prefill}”,用户补充后再提交。",
|
||||||
|
payload={
|
||||||
|
"application_field": field,
|
||||||
|
"prompt_prefill": prefill,
|
||||||
|
"missing_fields": [field],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _infer_application_type(facts: dict[str, str]) -> str:
|
||||||
|
text = " ".join(str(facts.get(key) or "") for key in ("reason", "transport_mode", "days"))
|
||||||
|
if "采购" in text:
|
||||||
|
return "采购费用申请"
|
||||||
|
if "会议" in text or "会务" in text:
|
||||||
|
return "会务费用申请"
|
||||||
|
return "差旅费用申请"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_application_summary(facts: dict[str, str]) -> str:
|
||||||
|
return "\n".join(
|
||||||
|
f"{label}:{value or '待补充'}"
|
||||||
|
for label, value in (
|
||||||
|
("申请类型", facts.get("application_type", "")),
|
||||||
|
("发生时间", facts.get("time", "")),
|
||||||
|
("地点", facts.get("location", "")),
|
||||||
|
("事由", facts.get("reason", "")),
|
||||||
|
("天数", facts.get("days", "")),
|
||||||
|
("出行方式", facts.get("transport_mode", "")),
|
||||||
|
("预计总费用", facts.get("amount", "")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_application_summary_table(
|
||||||
|
facts: dict[str, str],
|
||||||
|
*,
|
||||||
|
include_empty: bool = True,
|
||||||
|
) -> str:
|
||||||
|
rows = [
|
||||||
|
("申请类型", facts.get("application_type", "")),
|
||||||
|
("发生时间", facts.get("time", "")),
|
||||||
|
("地点", facts.get("location", "")),
|
||||||
|
("事由", facts.get("reason", "")),
|
||||||
|
("天数", facts.get("days", "")),
|
||||||
|
("出行方式", facts.get("transport_mode", "")),
|
||||||
|
("预计总费用", facts.get("amount", "")),
|
||||||
|
]
|
||||||
|
visible_rows = rows if include_empty else [(label, value) for label, value in rows if str(value or "").strip()]
|
||||||
|
if not visible_rows:
|
||||||
|
visible_rows = [("申请描述", "已收到,正在按费用申请上下文继续整理")]
|
||||||
|
lines = ["| 字段 | 内容 |", "| --- | --- |"]
|
||||||
|
lines.extend(f"| {label} | {value or '待补充'} |" for label, value in visible_rows)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _create_expense_application_record(
|
||||||
|
self,
|
||||||
|
payload: UserAgentRequest,
|
||||||
|
facts: dict[str, str],
|
||||||
|
) -> ExpenseClaim:
|
||||||
|
claim_no = self._build_application_claim_no(payload, facts)
|
||||||
|
existing = self.db.scalar(
|
||||||
|
select(ExpenseClaim)
|
||||||
|
.where(ExpenseClaim.claim_no == claim_no)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
current_user = self._build_application_current_user(payload)
|
||||||
|
access_policy = ExpenseClaimAccessPolicy(self.db)
|
||||||
|
employee = access_policy.resolve_current_employee(current_user)
|
||||||
|
department_name = str(current_user.department_name or "").strip() or "待补充"
|
||||||
|
department_id = None
|
||||||
|
employee_id = None
|
||||||
|
employee_name = str(current_user.username or current_user.name or payload.user_id or "anonymous").strip()
|
||||||
|
|
||||||
|
if employee is not None:
|
||||||
|
employee_id = employee.id
|
||||||
|
employee_name = str(employee.name or employee.employee_no or employee.email or employee_name).strip()
|
||||||
|
department_id = employee.organization_unit_id
|
||||||
|
if employee.organization_unit is not None and employee.organization_unit.name:
|
||||||
|
department_name = str(employee.organization_unit.name).strip()
|
||||||
|
|
||||||
|
claim = ExpenseClaim(
|
||||||
|
claim_no=claim_no,
|
||||||
|
employee_id=employee_id,
|
||||||
|
employee_name=employee_name,
|
||||||
|
department_id=department_id,
|
||||||
|
department_name=department_name,
|
||||||
|
project_code=None,
|
||||||
|
expense_type=self._resolve_application_expense_type_code(facts),
|
||||||
|
reason=str(facts.get("reason") or "费用申请").strip() or "费用申请",
|
||||||
|
location=str(facts.get("location") or "待补充").strip() or "待补充",
|
||||||
|
amount=self._parse_application_amount_to_decimal(facts.get("amount", "")),
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=0,
|
||||||
|
occurred_at=self._parse_application_occurred_at(facts.get("time", "")),
|
||||||
|
submitted_at=datetime.now(UTC),
|
||||||
|
status="submitted",
|
||||||
|
approval_stage="直属领导审批",
|
||||||
|
risk_flags_json=[],
|
||||||
|
)
|
||||||
|
self.db.add(claim)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(claim)
|
||||||
|
return claim
|
||||||
|
|
||||||
|
def _resolve_application_manager_name(
|
||||||
|
self,
|
||||||
|
payload: UserAgentRequest,
|
||||||
|
claim: ExpenseClaim | None = None,
|
||||||
|
) -> str:
|
||||||
|
if claim is not None:
|
||||||
|
manager_name = ExpenseClaimAccessPolicy.resolve_claim_manager_name(claim)
|
||||||
|
if manager_name and not ExpenseClaimAccessPolicy.is_missing_value(manager_name):
|
||||||
|
return manager_name
|
||||||
|
|
||||||
|
context_json = payload.context_json or {}
|
||||||
|
for key in ("manager_name", "managerName", "direct_manager_name", "directManagerName"):
|
||||||
|
value = str(context_json.get(key) or "").strip()
|
||||||
|
if value and not ExpenseClaimAccessPolicy.is_missing_value(value):
|
||||||
|
return value
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_application_current_user(payload: UserAgentRequest) -> CurrentUserContext:
|
||||||
|
context_json = payload.context_json or {}
|
||||||
|
raw_role_codes = context_json.get("role_codes")
|
||||||
|
if isinstance(raw_role_codes, list):
|
||||||
|
role_codes = [str(item).strip() for item in raw_role_codes if str(item).strip()]
|
||||||
|
else:
|
||||||
|
role_codes = [item.strip() for item in str(raw_role_codes or "").split(",") if item.strip()]
|
||||||
|
username = str(
|
||||||
|
payload.user_id
|
||||||
|
or context_json.get("username")
|
||||||
|
or context_json.get("user_id")
|
||||||
|
or context_json.get("employee_no")
|
||||||
|
or context_json.get("name")
|
||||||
|
or "anonymous"
|
||||||
|
).strip()
|
||||||
|
name = str(context_json.get("name") or context_json.get("user_name") or username).strip()
|
||||||
|
return CurrentUserContext(
|
||||||
|
username=username or name or "anonymous",
|
||||||
|
name=name or username or "anonymous",
|
||||||
|
role_codes=role_codes,
|
||||||
|
is_admin=bool(context_json.get("is_admin")),
|
||||||
|
department_name=str(
|
||||||
|
context_json.get("department_name")
|
||||||
|
or context_json.get("department")
|
||||||
|
or context_json.get("departmentName")
|
||||||
|
or ""
|
||||||
|
).strip(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_application_expense_type_code(facts: dict[str, str]) -> str:
|
||||||
|
application_type = str(facts.get("application_type") or "").strip()
|
||||||
|
if "差旅" in application_type:
|
||||||
|
return "travel_application"
|
||||||
|
if "采购" in application_type:
|
||||||
|
return "purchase_application"
|
||||||
|
if "会务" in application_type or "会议" in application_type:
|
||||||
|
return "meeting_application"
|
||||||
|
return "expense_application"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_application_amount_to_decimal(amount_text: str) -> Decimal:
|
||||||
|
normalized = str(amount_text or "").replace(",", "").replace(",", "").strip()
|
||||||
|
match = re.search(r"\d+(?:\.\d+)?", normalized)
|
||||||
|
if not match:
|
||||||
|
return Decimal("0.00")
|
||||||
|
try:
|
||||||
|
return Decimal(match.group(0)).quantize(Decimal("0.01"))
|
||||||
|
except (InvalidOperation, ValueError):
|
||||||
|
return Decimal("0.00")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_application_occurred_at(time_text: str) -> datetime:
|
||||||
|
normalized = str(time_text or "")
|
||||||
|
match = re.search(r"(20\d{2})[-/.年](\d{1,2})[-/.月](\d{1,2})", normalized)
|
||||||
|
if match:
|
||||||
|
year, month, day = (int(part) for part in match.groups())
|
||||||
|
return datetime(year, month, day, tzinfo=UTC)
|
||||||
|
return datetime.now(UTC)
|
||||||
|
|
||||||
|
def _build_submitted_application_payload(
|
||||||
|
self,
|
||||||
|
claim: ExpenseClaim | None,
|
||||||
|
facts: dict[str, str],
|
||||||
|
) -> UserAgentDraftPayload | None:
|
||||||
|
if claim is None:
|
||||||
|
return None
|
||||||
|
return UserAgentDraftPayload(
|
||||||
|
draft_type="expense_application",
|
||||||
|
title=str(facts.get("application_type") or "费用申请").strip() or "费用申请",
|
||||||
|
body=self._build_application_summary(facts),
|
||||||
|
confirmation_required=False,
|
||||||
|
claim_id=claim.id,
|
||||||
|
claim_no=claim.claim_no,
|
||||||
|
status=claim.status,
|
||||||
|
approval_stage=claim.approval_stage,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _is_application_submit_confirmation(self, payload: UserAgentRequest) -> bool:
|
||||||
|
compact_message = re.sub(r"\s+", "", str(payload.message or ""))
|
||||||
|
if any(keyword in compact_message for keyword in APPLICATION_SUBMIT_KEYWORDS):
|
||||||
|
return True
|
||||||
|
if compact_message not in APPLICATION_SHORT_CONFIRMATIONS:
|
||||||
|
return False
|
||||||
|
history = (payload.context_json or {}).get("conversation_history")
|
||||||
|
if not isinstance(history, list):
|
||||||
|
return False
|
||||||
|
return any(
|
||||||
|
isinstance(item, dict)
|
||||||
|
and str(item.get("role") or "").strip() == "assistant"
|
||||||
|
and (
|
||||||
|
"是否确认提交" in str(item.get("content") or "")
|
||||||
|
or "当前状态:待确认提交" in str(item.get("content") or "")
|
||||||
|
or "#application-submit" in str(item.get("content") or "")
|
||||||
|
or "确认无误后" in str(item.get("content") or "")
|
||||||
|
)
|
||||||
|
for item in history[-4:]
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_simulated_application_no(
|
||||||
|
self,
|
||||||
|
payload: UserAgentRequest,
|
||||||
|
facts: dict[str, str],
|
||||||
|
) -> str:
|
||||||
|
return self._build_simulated_application_no_from_facts(
|
||||||
|
facts,
|
||||||
|
fallback_seed=str(payload.run_id or ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_simulated_application_no_from_facts(
|
||||||
|
facts: dict[str, str],
|
||||||
|
*,
|
||||||
|
fallback_seed: str = "",
|
||||||
|
) -> str:
|
||||||
|
raw_date = str(facts.get("time") or "")
|
||||||
|
match = re.search(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}", raw_date)
|
||||||
|
date_text = match.group(0) if match else datetime.now().strftime("%Y-%m-%d")
|
||||||
|
digits = re.sub(r"\D", "", date_text)[:8].ljust(8, "0")
|
||||||
|
seed = re.sub(r"[^A-Za-z0-9]", "", fallback_seed)[-6:] or "SIM001"
|
||||||
|
return f"APP-{digits}-{seed.upper()}"
|
||||||
|
|
||||||
|
def _build_application_claim_no(
|
||||||
|
self,
|
||||||
|
payload: UserAgentRequest,
|
||||||
|
facts: dict[str, str],
|
||||||
|
) -> str:
|
||||||
|
context_json = payload.context_json or {}
|
||||||
|
seed_source = "|".join(
|
||||||
|
str(item or "").strip()
|
||||||
|
for item in (
|
||||||
|
context_json.get("conversation_id"),
|
||||||
|
payload.user_id,
|
||||||
|
facts.get("time"),
|
||||||
|
facts.get("location"),
|
||||||
|
facts.get("reason"),
|
||||||
|
facts.get("amount"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
digest = hashlib.sha1(seed_source.encode("utf-8")).hexdigest()[:6]
|
||||||
|
return self._build_simulated_application_no_from_facts(facts, fallback_seed=digest)
|
||||||
@@ -497,7 +497,7 @@ def test_spreadsheet_change_records_include_all_modified_sheets() -> None:
|
|||||||
def test_editable_spreadsheet_onlyoffice_config_enables_forcesave(monkeypatch) -> None:
|
def test_editable_spreadsheet_onlyoffice_config_enables_forcesave(monkeypatch) -> None:
|
||||||
with build_session() as db:
|
with build_session() as db:
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"app.services.agent_assets.resolve_onlyoffice_settings",
|
"app.services.agent_asset_onlyoffice.resolve_onlyoffice_settings",
|
||||||
lambda: OnlyOfficeRuntimeConfig(
|
lambda: OnlyOfficeRuntimeConfig(
|
||||||
enabled=True,
|
enabled=True,
|
||||||
public_url="http://onlyoffice.example.com",
|
public_url="http://onlyoffice.example.com",
|
||||||
|
|||||||
@@ -2883,6 +2883,133 @@ def test_direct_manager_can_approve_subordinate_claim_to_finance_review() -> Non
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_application_submit_skips_ai_review_and_receipt_requirements(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
current_user = CurrentUserContext(
|
||||||
|
username="application-owner@example.com",
|
||||||
|
name="张三",
|
||||||
|
role_codes=["employee"],
|
||||||
|
is_admin=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with build_session() as db:
|
||||||
|
claim = ExpenseClaim(
|
||||||
|
claim_no="APP-20260525-SUBMIT",
|
||||||
|
employee_name="张三",
|
||||||
|
department_name="交付部",
|
||||||
|
project_code="PRJ-A",
|
||||||
|
expense_type="travel_application",
|
||||||
|
reason="支撑国网服务器上线部署",
|
||||||
|
location="上海",
|
||||||
|
amount=Decimal("12000.00"),
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=0,
|
||||||
|
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
|
||||||
|
submitted_at=None,
|
||||||
|
status="draft",
|
||||||
|
approval_stage="待提交",
|
||||||
|
risk_flags_json=[
|
||||||
|
{
|
||||||
|
"source": "submission_review",
|
||||||
|
"severity": "medium",
|
||||||
|
"message": "旧 AI 预审提示不应保留到申请单提交结果。",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
db.add(claim)
|
||||||
|
db.commit()
|
||||||
|
claim_id = claim.id
|
||||||
|
service = ExpenseClaimService(db)
|
||||||
|
|
||||||
|
def fail_ai_review(_claim: ExpenseClaim) -> dict[str, object]:
|
||||||
|
raise AssertionError("费用申请提交不应进入 AI 预审")
|
||||||
|
|
||||||
|
monkeypatch.setattr(service, "_run_ai_submission_review", fail_ai_review)
|
||||||
|
|
||||||
|
submitted = service.submit_claim(claim_id, current_user)
|
||||||
|
|
||||||
|
assert submitted is not None
|
||||||
|
assert submitted.status == "submitted"
|
||||||
|
assert submitted.approval_stage == "直属领导审批"
|
||||||
|
assert submitted.invoice_count == 0
|
||||||
|
assert submitted.items == []
|
||||||
|
assert not any(
|
||||||
|
isinstance(flag, dict) and flag.get("source") == "submission_review"
|
||||||
|
for flag in submitted.risk_flags_json
|
||||||
|
)
|
||||||
|
assert any(
|
||||||
|
isinstance(flag, dict)
|
||||||
|
and flag.get("source") == "application_submission"
|
||||||
|
and flag.get("event_type") == "expense_application_submission"
|
||||||
|
and flag.get("next_approval_stage") == "直属领导审批"
|
||||||
|
for flag in submitted.risk_flags_json
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_direct_manager_can_approve_application_claim_to_completed_stage() -> None:
|
||||||
|
current_user = CurrentUserContext(
|
||||||
|
username="manager-application-approve@example.com",
|
||||||
|
name="李经理",
|
||||||
|
role_codes=["manager"],
|
||||||
|
is_admin=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
with build_session() as db:
|
||||||
|
manager = Employee(
|
||||||
|
employee_no="E8112",
|
||||||
|
name="李经理",
|
||||||
|
email="manager-application-approve@example.com",
|
||||||
|
)
|
||||||
|
employee = Employee(
|
||||||
|
employee_no="E8113",
|
||||||
|
name="张三",
|
||||||
|
email="zhangsan-application-approve@example.com",
|
||||||
|
manager=manager,
|
||||||
|
)
|
||||||
|
db.add_all([manager, employee])
|
||||||
|
db.flush()
|
||||||
|
claim = ExpenseClaim(
|
||||||
|
claim_no="APP-20260525-APPROVE",
|
||||||
|
employee_id=employee.id,
|
||||||
|
employee_name="张三",
|
||||||
|
department_name="交付部",
|
||||||
|
project_code="PRJ-A",
|
||||||
|
expense_type="travel_application",
|
||||||
|
reason="支撑国网服务器上线部署",
|
||||||
|
location="上海",
|
||||||
|
amount=Decimal("12000.00"),
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=0,
|
||||||
|
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
|
||||||
|
submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
|
||||||
|
status="submitted",
|
||||||
|
approval_stage="直属领导审批",
|
||||||
|
risk_flags_json=[],
|
||||||
|
)
|
||||||
|
db.add(claim)
|
||||||
|
db.commit()
|
||||||
|
claim_id = claim.id
|
||||||
|
|
||||||
|
approved = ExpenseClaimService(db).approve_claim(
|
||||||
|
claim_id,
|
||||||
|
current_user,
|
||||||
|
opinion="业务必要,同意申请。",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert approved is not None
|
||||||
|
assert approved.status == "approved"
|
||||||
|
assert approved.approval_stage == "审批完成"
|
||||||
|
assert any(
|
||||||
|
isinstance(flag, dict)
|
||||||
|
and flag.get("source") == "manual_approval"
|
||||||
|
and flag.get("event_type") == "expense_application_approval"
|
||||||
|
and flag.get("opinion") == "业务必要,同意申请。"
|
||||||
|
and flag.get("previous_approval_stage") == "直属领导审批"
|
||||||
|
and flag.get("next_status") == "approved"
|
||||||
|
and flag.get("next_approval_stage") == "审批完成"
|
||||||
|
for flag in approved.risk_flags_json
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_finance_can_approve_claim_to_archive_stage() -> None:
|
def test_finance_can_approve_claim_to_archive_stage() -> None:
|
||||||
current_user = CurrentUserContext(
|
current_user = CurrentUserContext(
|
||||||
username="finance-approve@example.com",
|
username="finance-approve@example.com",
|
||||||
|
|||||||
@@ -649,6 +649,40 @@ def test_semantic_ontology_service_requires_attachment_for_meeting_application()
|
|||||||
assert "attachments" in result.missing_slots
|
assert "attachments" in result.missing_slots
|
||||||
|
|
||||||
|
|
||||||
|
def test_semantic_ontology_service_treats_application_session_as_application_context() -> None:
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
result = SemanticOntologyService(db).parse(
|
||||||
|
OntologyParseRequest(
|
||||||
|
query=(
|
||||||
|
"发生时间:2026-05-25\n"
|
||||||
|
"地点:上海\n"
|
||||||
|
"事由:支持上海国网服务器部署\n"
|
||||||
|
"天数:3天"
|
||||||
|
),
|
||||||
|
user_id="pytest",
|
||||||
|
context_json={
|
||||||
|
"session_type": "application",
|
||||||
|
"entry_source": "application",
|
||||||
|
"attachment_count": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.scenario == "expense"
|
||||||
|
assert result.intent == "draft"
|
||||||
|
assert any(
|
||||||
|
item.type == "document_type" and item.normalized_value == "expense_application"
|
||||||
|
for item in result.entities
|
||||||
|
)
|
||||||
|
assert any(
|
||||||
|
item.type == "workflow_stage" and item.normalized_value == "pre_approval"
|
||||||
|
for item in result.entities
|
||||||
|
)
|
||||||
|
assert "expense_type" in result.missing_slots
|
||||||
|
assert "amount" in result.missing_slots
|
||||||
|
|
||||||
|
|
||||||
def test_semantic_ontology_service_uses_model_parse_when_available(monkeypatch) -> None:
|
def test_semantic_ontology_service_uses_model_parse_when_available(monkeypatch) -> None:
|
||||||
session_factory = build_session_factory()
|
session_factory = build_session_factory()
|
||||||
with session_factory() as db:
|
with session_factory() as db:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from sqlalchemy.pool import StaticPool
|
|||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
from app.models.employee import Employee
|
from app.models.employee import Employee
|
||||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||||
|
from app.schemas.ontology import OntologyParseResult, OntologyPermission
|
||||||
from app.schemas.orchestrator import OrchestratorRequest
|
from app.schemas.orchestrator import OrchestratorRequest
|
||||||
from app.services.agent_conversations import AgentConversationService
|
from app.services.agent_conversations import AgentConversationService
|
||||||
from app.services.orchestrator import OrchestratorService
|
from app.services.orchestrator import OrchestratorService
|
||||||
@@ -228,6 +229,50 @@ def test_conversation_hydration_does_not_reuse_review_type_for_fresh_expense_pro
|
|||||||
assert continued_context["review_form_values"]["expense_type"] == "差旅费"
|
assert continued_context["review_form_values"]["expense_type"] == "差旅费"
|
||||||
|
|
||||||
|
|
||||||
|
def test_conversation_hydration_preserves_incoming_application_time_context() -> None:
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
service = AgentConversationService(db)
|
||||||
|
conversation = service.get_or_create_conversation(
|
||||||
|
conversation_id="conv-application-time-context",
|
||||||
|
user_id="emp-application-time@example.com",
|
||||||
|
source="user_message",
|
||||||
|
context_json={
|
||||||
|
"session_type": "application",
|
||||||
|
"entry_source": "application",
|
||||||
|
"business_time_context": {
|
||||||
|
"mode": "single",
|
||||||
|
"start_date": "2026-05-01",
|
||||||
|
"end_date": "2026-05-01",
|
||||||
|
"display_value": "2026-05-01",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
stale_context = service.hydrate_context_json(
|
||||||
|
conversation=conversation,
|
||||||
|
context_json={"session_type": "application", "entry_source": "application"},
|
||||||
|
message="apply travel expense",
|
||||||
|
)
|
||||||
|
fresh_context = service.hydrate_context_json(
|
||||||
|
conversation=conversation,
|
||||||
|
context_json={
|
||||||
|
"session_type": "application",
|
||||||
|
"entry_source": "application",
|
||||||
|
"business_time_context": {
|
||||||
|
"mode": "single",
|
||||||
|
"start_date": "2026-05-25",
|
||||||
|
"end_date": "2026-05-25",
|
||||||
|
"display_value": "2026-05-25",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
message="apply travel expense",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "business_time_context" not in stale_context
|
||||||
|
assert fresh_context["business_time_context"]["start_date"] == "2026-05-25"
|
||||||
|
|
||||||
|
|
||||||
def test_conversation_scope_creates_new_session_for_different_claim() -> None:
|
def test_conversation_scope_creates_new_session_for_different_claim() -> None:
|
||||||
session_factory = build_session_factory()
|
session_factory = build_session_factory()
|
||||||
with session_factory() as db:
|
with session_factory() as db:
|
||||||
@@ -543,3 +588,225 @@ def test_orchestrator_prompts_scene_choices_before_review_for_fresh_ambiguous_ex
|
|||||||
assert result.get("draft_payload") is None
|
assert result.get("draft_payload") is None
|
||||||
assert "请先在下面选择报销场景" in result["answer"]
|
assert "请先在下面选择报销场景" in result["answer"]
|
||||||
assert [item["label"] for item in result["suggested_actions"][:3]] == ["差旅费", "交通费", "住宿费"]
|
assert [item["label"] for item in result["suggested_actions"][:3]] == ["差旅费", "交通费", "住宿费"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_orchestrator_application_session_does_not_use_reimbursement_scene_prompt(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.runtime_chat.RuntimeChatService.complete",
|
||||||
|
lambda *_args, **_kwargs: None,
|
||||||
|
)
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
message = (
|
||||||
|
"发生时间:2026-05-25\n"
|
||||||
|
"地点:上海\n"
|
||||||
|
"事由:支持上海国网服务器部署\n"
|
||||||
|
"天数:3天"
|
||||||
|
)
|
||||||
|
with session_factory() as db:
|
||||||
|
response = OrchestratorService(db).run(
|
||||||
|
OrchestratorRequest(
|
||||||
|
source="user_message",
|
||||||
|
user_id="application-session@example.com",
|
||||||
|
message=message,
|
||||||
|
context_json={
|
||||||
|
"session_type": "application",
|
||||||
|
"entry_source": "application",
|
||||||
|
"name": "申请员工",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = response.result
|
||||||
|
assert response.status == "blocked"
|
||||||
|
assert response.trace_summary.scenario == "expense"
|
||||||
|
assert "费用申请" in result["answer"]
|
||||||
|
assert "| 发生时间 | 2026-05-25" in result["answer"]
|
||||||
|
assert "请先在下面选择报销场景" not in result["answer"]
|
||||||
|
assert result.get("review_payload") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_orchestrator_application_session_guides_transport_amount_and_submit(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.runtime_chat.RuntimeChatService.complete",
|
||||||
|
lambda *_args, **_kwargs: None,
|
||||||
|
)
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
initial_message = (
|
||||||
|
"发生时间:2026-05-25\n"
|
||||||
|
"地点:上海\n"
|
||||||
|
"事由:支持上海国网服务器部署\n"
|
||||||
|
"天数:3天"
|
||||||
|
)
|
||||||
|
context_json = {
|
||||||
|
"session_type": "application",
|
||||||
|
"entry_source": "application",
|
||||||
|
"name": "申请员工",
|
||||||
|
"manager_name": "陈硕",
|
||||||
|
}
|
||||||
|
with session_factory() as db:
|
||||||
|
service = OrchestratorService(db)
|
||||||
|
|
||||||
|
first = service.run(
|
||||||
|
OrchestratorRequest(
|
||||||
|
source="user_message",
|
||||||
|
user_id="application-flow@example.com",
|
||||||
|
message=initial_message,
|
||||||
|
context_json=context_json,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
second = service.run(
|
||||||
|
OrchestratorRequest(
|
||||||
|
source="user_message",
|
||||||
|
user_id="application-flow@example.com",
|
||||||
|
conversation_id=first.conversation_id,
|
||||||
|
message="飞机",
|
||||||
|
context_json=context_json,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
third = service.run(
|
||||||
|
OrchestratorRequest(
|
||||||
|
source="user_message",
|
||||||
|
user_id="application-flow@example.com",
|
||||||
|
conversation_id=first.conversation_id,
|
||||||
|
message="预计总费用:12000元",
|
||||||
|
context_json=context_json,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fourth = service.run(
|
||||||
|
OrchestratorRequest(
|
||||||
|
source="user_message",
|
||||||
|
user_id="application-flow@example.com",
|
||||||
|
conversation_id=first.conversation_id,
|
||||||
|
message="确认提交",
|
||||||
|
context_json=context_json,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert first.status == "blocked"
|
||||||
|
assert "当前还需要补充:出行方式、预计金额/预算" in first.result["answer"]
|
||||||
|
assert [item["label"] for item in first.result["suggested_actions"]] == ["一次性补充申请信息"]
|
||||||
|
assert first.result["suggested_actions"][0]["payload"]["prompt_prefill"] == "出行方式:\n预计总费用:"
|
||||||
|
|
||||||
|
assert "当前还需要补充:预计金额/预算" in second.result["answer"]
|
||||||
|
assert [item["label"] for item in second.result["suggested_actions"]] == ["一次性补充申请信息"]
|
||||||
|
assert second.result["suggested_actions"][0]["action_type"] == "prefill_composer"
|
||||||
|
assert second.result["suggested_actions"][0]["payload"]["prompt_prefill"] == "预计总费用:"
|
||||||
|
|
||||||
|
assert "这是模拟的费用申请结果" in third.result["answer"]
|
||||||
|
assert "| 事由 | 支持上海国网服务器部署 |" in third.result["answer"]
|
||||||
|
assert "请核对上述信息无误" in third.result["answer"]
|
||||||
|
assert "[确认](#application-submit)" in third.result["answer"]
|
||||||
|
assert third.status == "blocked"
|
||||||
|
assert third.result["requires_confirmation"] is True
|
||||||
|
assert third.result["suggested_actions"] == []
|
||||||
|
|
||||||
|
assert fourth.status == "succeeded"
|
||||||
|
assert fourth.result["clarification_required"] is False
|
||||||
|
assert fourth.result["missing_slots"] == []
|
||||||
|
assert "当前操作已完成,单据已经推送给 陈硕 进行审核,请耐心等待" in fourth.result["answer"]
|
||||||
|
assert "当前状态:陈硕审核中" in fourth.result["answer"]
|
||||||
|
assert fourth.result["suggested_actions"] == []
|
||||||
|
application_claims = [
|
||||||
|
claim
|
||||||
|
for claim in db.query(ExpenseClaim).all()
|
||||||
|
if claim.claim_no.startswith("APP-20260525-")
|
||||||
|
]
|
||||||
|
assert len(application_claims) == 1
|
||||||
|
assert application_claims[0].status == "submitted"
|
||||||
|
assert application_claims[0].approval_stage == "直属领导审批"
|
||||||
|
assert fourth.result["draft_payload"]["claim_no"] == application_claims[0].claim_no
|
||||||
|
|
||||||
|
|
||||||
|
def test_orchestrator_application_submit_bypasses_generic_operation_block(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.runtime_chat.RuntimeChatService.complete",
|
||||||
|
lambda *_args, **_kwargs: None,
|
||||||
|
)
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
initial_message = (
|
||||||
|
"发生时间:2026-05-25\n"
|
||||||
|
"地点:上海\n"
|
||||||
|
"事由:支持上海国网服务器部署\n"
|
||||||
|
"天数:3天"
|
||||||
|
)
|
||||||
|
context_json = {
|
||||||
|
"session_type": "application",
|
||||||
|
"entry_source": "application",
|
||||||
|
"name": "申请员工",
|
||||||
|
"manager_name": "陈硕",
|
||||||
|
}
|
||||||
|
with session_factory() as db:
|
||||||
|
service = OrchestratorService(db)
|
||||||
|
|
||||||
|
first = service.run(
|
||||||
|
OrchestratorRequest(
|
||||||
|
source="user_message",
|
||||||
|
user_id="application-approval-required@example.com",
|
||||||
|
message=initial_message,
|
||||||
|
context_json=context_json,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
service.run(
|
||||||
|
OrchestratorRequest(
|
||||||
|
source="user_message",
|
||||||
|
user_id="application-approval-required@example.com",
|
||||||
|
conversation_id=first.conversation_id,
|
||||||
|
message="飞机",
|
||||||
|
context_json=context_json,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
preview = service.run(
|
||||||
|
OrchestratorRequest(
|
||||||
|
source="user_message",
|
||||||
|
user_id="application-approval-required@example.com",
|
||||||
|
conversation_id=first.conversation_id,
|
||||||
|
message="预计总费用:12000元",
|
||||||
|
context_json=context_json,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def approval_required_parse_for_run(self, request, run_id): # noqa: ANN001
|
||||||
|
return OntologyParseResult(
|
||||||
|
scenario="expense",
|
||||||
|
intent="operate",
|
||||||
|
entities=[],
|
||||||
|
permission=OntologyPermission(
|
||||||
|
level="approval_required",
|
||||||
|
allowed=False,
|
||||||
|
reason="操作类请求需要人工审批确认。",
|
||||||
|
),
|
||||||
|
confidence=0.95,
|
||||||
|
missing_slots=[],
|
||||||
|
ambiguity=[],
|
||||||
|
clarification_required=False,
|
||||||
|
clarification_question=None,
|
||||||
|
run_id=run_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.ontology.SemanticOntologyService.parse_for_run",
|
||||||
|
approval_required_parse_for_run,
|
||||||
|
)
|
||||||
|
submitted = service.run(
|
||||||
|
OrchestratorRequest(
|
||||||
|
source="user_message",
|
||||||
|
user_id="application-approval-required@example.com",
|
||||||
|
conversation_id=first.conversation_id,
|
||||||
|
message="确认提交",
|
||||||
|
context_json=context_json,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert preview.status == "blocked"
|
||||||
|
assert submitted.status == "succeeded"
|
||||||
|
assert submitted.requires_confirmation is False
|
||||||
|
assert "操作类请求需要人工审批确认" not in submitted.result["answer"]
|
||||||
|
assert "当前仅返回确认摘要" not in submitted.result["answer"]
|
||||||
|
assert "当前操作已完成,单据已经推送给 陈硕 进行审核,请耐心等待" in submitted.result["answer"]
|
||||||
|
assert submitted.result["draft_payload"]["status"] == "submitted"
|
||||||
|
|||||||
@@ -364,6 +364,70 @@ def test_approve_claim_endpoint_routes_direct_manager_claim_to_finance_review()
|
|||||||
assert "manager-approve-api@example.com" not in approval_events[0]["message"]
|
assert "manager-approve-api@example.com" not in approval_events[0]["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_approve_application_endpoint_completes_after_direct_manager_review() -> None:
|
||||||
|
client, session_factory = build_client()
|
||||||
|
with session_factory() as db:
|
||||||
|
manager = Employee(
|
||||||
|
id="mgr-application-approve-1",
|
||||||
|
employee_no="E21002",
|
||||||
|
name="李经理",
|
||||||
|
email="manager-application-approve-api@example.com",
|
||||||
|
)
|
||||||
|
employee = Employee(
|
||||||
|
id="emp-application-approve-1",
|
||||||
|
employee_no="E11002",
|
||||||
|
name="张三",
|
||||||
|
email="zhangsan-application-approve-api@example.com",
|
||||||
|
manager=manager,
|
||||||
|
)
|
||||||
|
claim = ExpenseClaim(
|
||||||
|
id="claim-application-approve-1",
|
||||||
|
claim_no="APP-20260525-API001",
|
||||||
|
employee_id=employee.id,
|
||||||
|
employee_name="张三",
|
||||||
|
department_id="dept-1",
|
||||||
|
department_name="交付部",
|
||||||
|
project_code=None,
|
||||||
|
expense_type="travel_application",
|
||||||
|
reason="支撑国网服务器上线部署",
|
||||||
|
location="上海",
|
||||||
|
amount=Decimal("12000.00"),
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=0,
|
||||||
|
occurred_at=datetime(2026, 5, 25, tzinfo=UTC),
|
||||||
|
submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
|
||||||
|
status="submitted",
|
||||||
|
approval_stage="直属领导审批",
|
||||||
|
risk_flags_json=[],
|
||||||
|
)
|
||||||
|
db.add_all([manager, employee, claim])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/reimbursements/claims/claim-application-approve-1/approve",
|
||||||
|
json={"opinion": "业务必要,同意申请。"},
|
||||||
|
headers={
|
||||||
|
"X-Auth-Username": "manager-application-approve-api@example.com",
|
||||||
|
"X-Auth-Name": "manager-application-approve-api@example.com",
|
||||||
|
"X-Auth-Role-Codes": "manager",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["status"] == "approved"
|
||||||
|
assert payload["approval_stage"] == "审批完成"
|
||||||
|
assert any(
|
||||||
|
item["source"] == "manual_approval"
|
||||||
|
and item["event_type"] == "expense_application_approval"
|
||||||
|
and item["opinion"] == "业务必要,同意申请。"
|
||||||
|
and item["operator"] == "李经理"
|
||||||
|
and item["next_status"] == "approved"
|
||||||
|
and item["next_approval_stage"] == "审批完成"
|
||||||
|
for item in payload["risk_flags_json"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_claim_item_pdf_attachment_preview_returns_generated_image(monkeypatch, tmp_path) -> None:
|
def test_claim_item_pdf_attachment_preview_returns_generated_image(monkeypatch, tmp_path) -> None:
|
||||||
preview_bytes = b"fake-preview-png"
|
preview_bytes = b"fake-preview-png"
|
||||||
preview_data_url = f"data:image/png;base64,{base64.b64encode(preview_bytes).decode('ascii')}"
|
preview_data_url = f"data:image/png;base64,{base64.b64encode(preview_bytes).decode('ascii')}"
|
||||||
|
|||||||
@@ -29,6 +29,41 @@ def build_session_factory() -> sessionmaker[Session]:
|
|||||||
return sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
return sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||||
|
|
||||||
|
|
||||||
|
def build_application_user_agent_response(
|
||||||
|
db: Session,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
history: list[dict[str, object]] | None = None,
|
||||||
|
context_overrides: dict[str, object] | None = None,
|
||||||
|
):
|
||||||
|
context_json = {
|
||||||
|
"session_type": "application",
|
||||||
|
"entry_source": "application",
|
||||||
|
"attachment_count": 0,
|
||||||
|
}
|
||||||
|
if context_overrides:
|
||||||
|
context_json.update(context_overrides)
|
||||||
|
if history is not None:
|
||||||
|
context_json["conversation_history"] = history
|
||||||
|
ontology = SemanticOntologyService(db).parse(
|
||||||
|
OntologyParseRequest(
|
||||||
|
query=message,
|
||||||
|
user_id="pytest",
|
||||||
|
context_json=context_json,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return UserAgentService(db).respond(
|
||||||
|
UserAgentRequest(
|
||||||
|
run_id=ontology.run_id,
|
||||||
|
user_id="pytest",
|
||||||
|
message=message,
|
||||||
|
ontology=ontology,
|
||||||
|
context_json=context_json,
|
||||||
|
tool_payload={"clarification_required": ontology.clarification_required},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_user_agent_query_returns_readable_answer_and_actions() -> None:
|
def test_user_agent_query_returns_readable_answer_and_actions() -> None:
|
||||||
session_factory = build_session_factory()
|
session_factory = build_session_factory()
|
||||||
with session_factory() as db:
|
with session_factory() as db:
|
||||||
@@ -137,6 +172,216 @@ def test_user_agent_knowledge_prompt_enforces_knowledge_boundary() -> None:
|
|||||||
assert '"knowledge_answer_evidence": []' in messages[1]["content"]
|
assert '"knowledge_answer_evidence": []' in messages[1]["content"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_agent_application_context_uses_application_language() -> None:
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
message = (
|
||||||
|
"发生时间:2026-05-25\n"
|
||||||
|
"地点:上海\n"
|
||||||
|
"事由:支持上海国网服务器部署\n"
|
||||||
|
"天数:3天"
|
||||||
|
)
|
||||||
|
context_json = {
|
||||||
|
"session_type": "application",
|
||||||
|
"entry_source": "application",
|
||||||
|
"attachment_count": 0,
|
||||||
|
}
|
||||||
|
with session_factory() as db:
|
||||||
|
ontology = SemanticOntologyService(db).parse(
|
||||||
|
OntologyParseRequest(
|
||||||
|
query=message,
|
||||||
|
user_id="pytest",
|
||||||
|
context_json=context_json,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
response = UserAgentService(db).respond(
|
||||||
|
UserAgentRequest(
|
||||||
|
run_id=ontology.run_id,
|
||||||
|
user_id="pytest",
|
||||||
|
message=message,
|
||||||
|
ontology=ontology,
|
||||||
|
context_json=context_json,
|
||||||
|
tool_payload={"clarification_required": True},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "费用申请" in response.answer
|
||||||
|
assert "| 字段 | 内容 |" in response.answer
|
||||||
|
assert "| 发生时间 | 2026-05-25 至 2026-05-28 |" in response.answer
|
||||||
|
assert "支持上海国网服务器部署" in response.answer
|
||||||
|
assert "当前还需要补充:出行方式、预计金额/预算" in response.answer
|
||||||
|
assert "请先在下面选择报销场景" not in response.answer
|
||||||
|
assert response.review_payload is None
|
||||||
|
assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"]
|
||||||
|
assert response.suggested_actions[0].payload["prompt_prefill"] == "出行方式:\n预计总费用:"
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_agent_application_infers_natural_reason_and_expands_single_date() -> None:
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
message = "发生时间:2026-05-25\n去上海出差3天,支撑上海国网服务器部署"
|
||||||
|
with session_factory() as db:
|
||||||
|
response = build_application_user_agent_response(db, message)
|
||||||
|
|
||||||
|
assert "| 发生时间 | 2026-05-25 至 2026-05-28 |" in response.answer
|
||||||
|
assert "| 地点 | 上海 |" in response.answer
|
||||||
|
assert "| 事由 | 支撑上海国网服务器部署 |" in response.answer
|
||||||
|
assert "当前还需要先补充:申请事由" not in response.answer
|
||||||
|
assert "当前还需要补充:出行方式、预计金额/预算" in response.answer
|
||||||
|
assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_agent_application_uses_selected_time_and_natural_language_fields() -> None:
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
message = "出差上海,支撑国网服务器上线部署"
|
||||||
|
context_json = {
|
||||||
|
"session_type": "application",
|
||||||
|
"entry_source": "application",
|
||||||
|
"business_time_context": {
|
||||||
|
"mode": "single",
|
||||||
|
"start_date": "2026-05-25",
|
||||||
|
"end_date": "2026-05-25",
|
||||||
|
"display_value": "2026-05-25",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
with session_factory() as db:
|
||||||
|
ontology = SemanticOntologyService(db).parse(
|
||||||
|
OntologyParseRequest(
|
||||||
|
query=message,
|
||||||
|
user_id="pytest",
|
||||||
|
context_json=context_json,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
response = UserAgentService(db).respond(
|
||||||
|
UserAgentRequest(
|
||||||
|
run_id=ontology.run_id,
|
||||||
|
user_id="pytest",
|
||||||
|
message=message,
|
||||||
|
ontology=ontology,
|
||||||
|
context_json=context_json,
|
||||||
|
tool_payload={"clarification_required": True},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "| 发生时间 | 2026-05-25 |" in response.answer
|
||||||
|
assert "| 地点 | 上海 |" in response.answer
|
||||||
|
assert "| 事由 | 支撑国网服务器上线部署 |" in response.answer
|
||||||
|
assert "当前还需要补充:出行方式、预计金额/预算" in response.answer
|
||||||
|
assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"]
|
||||||
|
assert response.suggested_actions[0].action_type == "prefill_composer"
|
||||||
|
assert response.suggested_actions[0].payload["prompt_prefill"] == "出行方式:\n预计总费用:"
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_agent_application_asks_amount_after_transport_choice() -> None:
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
initial_message = (
|
||||||
|
"发生时间:2026-05-25\n"
|
||||||
|
"地点:上海\n"
|
||||||
|
"事由:支持上海国网服务器部署\n"
|
||||||
|
"天数:3天"
|
||||||
|
)
|
||||||
|
with session_factory() as db:
|
||||||
|
response = build_application_user_agent_response(
|
||||||
|
db,
|
||||||
|
"飞机",
|
||||||
|
history=[{"role": "user", "content": initial_message}],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "| 出行方式 | 飞机 |" in response.answer
|
||||||
|
assert "当前还需要补充:预计金额/预算" in response.answer
|
||||||
|
assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"]
|
||||||
|
assert response.suggested_actions[0].action_type == "prefill_composer"
|
||||||
|
assert response.suggested_actions[0].payload["prompt_prefill"] == "预计总费用:"
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_agent_application_missing_base_actions_prefill_composer() -> None:
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
response = build_application_user_agent_response(
|
||||||
|
db,
|
||||||
|
"地点:上海\n事由:支撑国网服务器部署\n天数:3天",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "当前还需要补充:发生时间、出行方式、预计金额/预算" in response.answer
|
||||||
|
assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"]
|
||||||
|
assert response.suggested_actions[0].action_type == "prefill_composer"
|
||||||
|
assert response.suggested_actions[0].payload["prompt_prefill"] == "申请时间段:\n出行方式:\n预计总费用:"
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_agent_application_builds_preview_when_amount_is_ready() -> None:
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
initial_message = (
|
||||||
|
"发生时间:2026-05-25\n"
|
||||||
|
"地点:上海\n"
|
||||||
|
"事由:支持上海国网服务器部署\n"
|
||||||
|
"天数:3天"
|
||||||
|
)
|
||||||
|
with session_factory() as db:
|
||||||
|
response = build_application_user_agent_response(
|
||||||
|
db,
|
||||||
|
"预计总费用:12000元",
|
||||||
|
history=[
|
||||||
|
{"role": "user", "content": initial_message},
|
||||||
|
{"role": "user", "content": "飞机"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "这是模拟的费用申请结果" in response.answer
|
||||||
|
assert "| 字段 | 内容 |" in response.answer
|
||||||
|
assert "| 事由 | 支持上海国网服务器部署 |" in response.answer
|
||||||
|
assert "| 出行方式 | 飞机 |" in response.answer
|
||||||
|
assert "| 预计总费用 | 12000元 |" in response.answer
|
||||||
|
assert "请核对上述信息无误" in response.answer
|
||||||
|
assert "[确认](#application-submit)" in response.answer
|
||||||
|
assert response.requires_confirmation is True
|
||||||
|
assert response.suggested_actions == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_agent_application_submit_enters_leader_review() -> None:
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
initial_message = (
|
||||||
|
"发生时间:2026-05-25\n"
|
||||||
|
"地点:上海\n"
|
||||||
|
"事由:支持上海国网服务器部署\n"
|
||||||
|
"天数:3天"
|
||||||
|
)
|
||||||
|
preview_answer = (
|
||||||
|
"这是模拟的费用申请结果,请核对:\n"
|
||||||
|
"| 字段 | 内容 |\n"
|
||||||
|
"| --- | --- |\n"
|
||||||
|
"| 申请类型 | 差旅费用申请 |\n"
|
||||||
|
"| 发生时间 | 2026-05-25 |\n"
|
||||||
|
"| 地点 | 上海 |\n"
|
||||||
|
"| 事由 | 支持上海国网服务器部署 |\n"
|
||||||
|
"| 天数 | 3天 |\n"
|
||||||
|
"| 出行方式 | 飞机 |\n"
|
||||||
|
"| 预计总费用 | 12000元 |\n\n"
|
||||||
|
"请核对上述信息无误,确认无误后 [确认](#application-submit) 提交至审批流程。"
|
||||||
|
)
|
||||||
|
with session_factory() as db:
|
||||||
|
response = build_application_user_agent_response(
|
||||||
|
db,
|
||||||
|
"确认提交",
|
||||||
|
context_overrides={"manager_name": "陈硕"},
|
||||||
|
history=[
|
||||||
|
{"role": "user", "content": initial_message},
|
||||||
|
{"role": "user", "content": "飞机"},
|
||||||
|
{"role": "user", "content": "预计总费用:12000元"},
|
||||||
|
{"role": "assistant", "content": preview_answer},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "当前操作已完成,单据已经推送给 陈硕 进行审核,请耐心等待" in response.answer
|
||||||
|
assert "当前状态:陈硕审核中" in response.answer
|
||||||
|
assert "预算占用参考" in response.answer
|
||||||
|
assert "APP-20260525-" in response.answer
|
||||||
|
assert response.suggested_actions == []
|
||||||
|
claim = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("APP-20260525-%")).one()
|
||||||
|
assert claim.status == "submitted"
|
||||||
|
assert claim.approval_stage == "直属领导审批"
|
||||||
|
assert claim.expense_type == "travel_application"
|
||||||
|
assert claim.amount == Decimal("12000.00")
|
||||||
|
assert claim.employee_name == "pytest"
|
||||||
|
|
||||||
|
|
||||||
def test_user_agent_knowledge_fallback_is_honest_and_personalized() -> None:
|
def test_user_agent_knowledge_fallback_is_honest_and_personalized() -> None:
|
||||||
session_factory = build_session_factory()
|
session_factory = build_session_factory()
|
||||||
with session_factory() as db:
|
with session_factory() as db:
|
||||||
|
|||||||
@@ -15,13 +15,43 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app {
|
.app {
|
||||||
|
--sidebar-expanded-width: 220px;
|
||||||
|
--sidebar-collapsed-width: 64px;
|
||||||
|
--sidebar-motion: 320ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
|
||||||
height: var(--desktop-stage-height, 100dvh);
|
height: var(--desktop-stage-height, 100dvh);
|
||||||
min-height: var(--desktop-stage-height, 100dvh);
|
min-height: var(--desktop-stage-height, 100dvh);
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 220px minmax(0, 1fr);
|
align-items: stretch;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-sidebar {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: var(--sidebar-expanded-width);
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
will-change: width;
|
||||||
|
transition: width var(--sidebar-motion);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app.sidebar-collapsed .app-sidebar {
|
||||||
|
width: var(--sidebar-collapsed-width);
|
||||||
|
overflow: visible;
|
||||||
|
position: relative;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app.sidebar-collapsed > .main {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app > .main {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.boot-state {
|
.boot-state {
|
||||||
min-height: var(--desktop-stage-height, 100dvh);
|
min-height: var(--desktop-stage-height, 100dvh);
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -133,9 +163,28 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
.app { grid-template-columns: 220px minmax(0, 1fr); }
|
.app-sidebar {
|
||||||
|
width: var(--sidebar-expanded-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app.sidebar-collapsed .app-sidebar {
|
||||||
|
width: var(--sidebar-collapsed-width);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.app { display: block; }
|
.app {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar {
|
||||||
|
width: 100%;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
.workarea { padding: 18px 16px 28px; }
|
.workarea { padding: 18px 16px 28px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.app-sidebar {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -481,6 +481,33 @@ tbody tr:last-child td {
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.new-document-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 30px;
|
||||||
|
height: 17px;
|
||||||
|
margin-right: 6px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fff5f5;
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: .2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-document-badge::before {
|
||||||
|
content: "";
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
.doc-kind-tag,
|
.doc-kind-tag,
|
||||||
.type-tag,
|
.type-tag,
|
||||||
.status-tag {
|
.status-tag {
|
||||||
|
|||||||
@@ -567,6 +567,17 @@
|
|||||||
color: #059669;
|
color: #059669;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shortcut-chip.active {
|
||||||
|
border-color: rgba(5, 150, 105, 0.38);
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
color: #047857;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-chip.active i {
|
||||||
|
color: #047857;
|
||||||
|
}
|
||||||
|
|
||||||
.shortcut-chip:disabled {
|
.shortcut-chip:disabled {
|
||||||
opacity: 0.48;
|
opacity: 0.48;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="assistant-copy">
|
<div class="assistant-copy">
|
||||||
<h3>嗨,{{ assistantGreetingName }},描述费用或上传票据,AI 直接帮你判断怎么报</h3>
|
<h3>嗨,{{ assistantGreetingName }},描述您想做的事,AI 会直接帮您处理</h3>
|
||||||
<p>自动识别报销类别、核对附件完整性,并生成可继续提交的报销草稿。</p>
|
<p>我会自动识别您的意图,协助完成费用申请、报销、查询和制度问答等业务工作,耐心把事情推进到可执行的下一步。</p>
|
||||||
|
|
||||||
<div class="assistant-input">
|
<div class="assistant-input">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<aside class="rail" aria-label="主导航">
|
<aside class="rail" :class="{ 'rail-collapsed': collapsed }" aria-label="主导航">
|
||||||
<div class="rail-brand">
|
<div class="rail-brand">
|
||||||
<div class="brand-mark" aria-hidden="true">
|
<div class="brand-mark" aria-hidden="true">
|
||||||
<img v-if="companyLogo" :src="companyLogo" alt="System Logo" class="custom-logo" />
|
<img v-if="companyLogo" :src="companyLogo" alt="System Logo" class="custom-logo" />
|
||||||
@@ -9,6 +9,16 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<strong class="brand-name">{{ displayCompanyName }}</strong>
|
<strong class="brand-name">{{ displayCompanyName }}</strong>
|
||||||
|
<button
|
||||||
|
class="rail-collapse-btn"
|
||||||
|
type="button"
|
||||||
|
:aria-label="collapsed ? '展开侧边栏' : '折叠侧边栏'"
|
||||||
|
:title="collapsed ? '展开侧边栏' : '折叠侧边栏'"
|
||||||
|
:aria-expanded="!collapsed"
|
||||||
|
@click="emit('toggle-collapse')"
|
||||||
|
>
|
||||||
|
<i :class="collapsed ? 'mdi mdi-chevron-right' : 'mdi mdi-chevron-left'"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="rail-nav" aria-label="功能导航">
|
<nav class="rail-nav" aria-label="功能导航">
|
||||||
@@ -18,6 +28,7 @@
|
|||||||
class="nav-btn"
|
class="nav-btn"
|
||||||
:class="{ active: activeView === item.id }"
|
:class="{ active: activeView === item.id }"
|
||||||
type="button"
|
type="button"
|
||||||
|
:title="collapsed ? item.displayLabel : undefined"
|
||||||
@click="emit('navigate', item.id)"
|
@click="emit('navigate', item.id)"
|
||||||
>
|
>
|
||||||
<span class="nav-icon" v-html="item.icon"></span>
|
<span class="nav-icon" v-html="item.icon"></span>
|
||||||
@@ -26,15 +37,38 @@
|
|||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="rail-user">
|
<div
|
||||||
<div class="user-menu" role="menu" aria-label="用户菜单">
|
class="rail-user"
|
||||||
|
@mouseenter="openCollapsedUserMenu"
|
||||||
|
@mouseleave="closeCollapsedUserMenu"
|
||||||
|
@focusin="openCollapsedUserMenu"
|
||||||
|
@focusout="handleUserFocusOut"
|
||||||
|
>
|
||||||
|
<div v-if="!collapsed" class="user-menu" role="menu" aria-label="用户菜单">
|
||||||
<button class="user-menu-item" type="button" @click="emit('logout')">
|
<button class="user-menu-item" type="button" @click="emit('logout')">
|
||||||
<i class="mdi mdi-logout-variant"></i>
|
<i class="mdi mdi-logout-variant"></i>
|
||||||
<span>退出系统</span>
|
<span>退出系统</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="user-summary" tabindex="0" aria-label="用户信息">
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="collapsed && userMenuOpen"
|
||||||
|
class="rail-user-menu-floating"
|
||||||
|
:style="userMenuStyle"
|
||||||
|
role="menu"
|
||||||
|
aria-label="用户菜单"
|
||||||
|
@mouseenter="clearUserMenuCloseTimer"
|
||||||
|
@mouseleave="closeCollapsedUserMenu"
|
||||||
|
>
|
||||||
|
<button class="user-menu-item" type="button" @click="handleLogout">
|
||||||
|
<i class="mdi mdi-logout-variant"></i>
|
||||||
|
<span>退出系统</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<div class="user-summary" tabindex="0" aria-label="用户信息" :title="collapsed ? displayUser.name : undefined">
|
||||||
<span class="user-avatar">{{ displayUser.avatar }}</span>
|
<span class="user-avatar">{{ displayUser.avatar }}</span>
|
||||||
<span class="user-copy">
|
<span class="user-copy">
|
||||||
<strong>{{ displayUser.name }}</strong>
|
<strong>{{ displayUser.name }}</strong>
|
||||||
@@ -47,7 +81,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onBeforeUnmount, onMounted } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
|
||||||
import { useApprovalInbox } from '../../composables/useApprovalInbox.js'
|
import { useApprovalInbox } from '../../composables/useApprovalInbox.js'
|
||||||
|
|
||||||
@@ -69,10 +103,14 @@ const props = defineProps({
|
|||||||
role: '管理员',
|
role: '管理员',
|
||||||
avatar: '管'
|
avatar: '管'
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
collapsed: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['navigate', 'openChat', 'logout'])
|
const emit = defineEmits(['navigate', 'openChat', 'logout', 'toggle-collapse'])
|
||||||
|
|
||||||
const {
|
const {
|
||||||
badgeLabel: approvalBadgeLabel,
|
badgeLabel: approvalBadgeLabel,
|
||||||
@@ -108,9 +146,6 @@ onMounted(() => {
|
|||||||
startApprovalInboxPolling()
|
startApprovalInboxPolling()
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
stopApprovalInboxPolling()
|
|
||||||
})
|
|
||||||
|
|
||||||
const displayUser = computed(() => ({
|
const displayUser = computed(() => ({
|
||||||
name: props.currentUser?.name || '系统管理员',
|
name: props.currentUser?.name || '系统管理员',
|
||||||
@@ -119,33 +154,133 @@ const displayUser = computed(() => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const displayCompanyName = computed(() => props.companyName || 'X-Financial')
|
const displayCompanyName = computed(() => props.companyName || 'X-Financial')
|
||||||
|
|
||||||
|
const userMenuOpen = ref(false)
|
||||||
|
let userMenuCloseTimer = null
|
||||||
|
const userMenuPosition = reactive({
|
||||||
|
top: 0,
|
||||||
|
left: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const userMenuStyle = computed(() => ({
|
||||||
|
top: `${userMenuPosition.top}px`,
|
||||||
|
left: `${userMenuPosition.left}px`
|
||||||
|
}))
|
||||||
|
|
||||||
|
function resolveUserMenuAnchor(element) {
|
||||||
|
return element?.querySelector?.('.user-summary') || element
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearUserMenuCloseTimer() {
|
||||||
|
if (userMenuCloseTimer) {
|
||||||
|
clearTimeout(userMenuCloseTimer)
|
||||||
|
userMenuCloseTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCollapsedUserMenu(event) {
|
||||||
|
if (!props.collapsed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clearUserMenuCloseTimer()
|
||||||
|
|
||||||
|
const anchor = resolveUserMenuAnchor(event?.currentTarget)
|
||||||
|
if (!anchor?.getBoundingClientRect) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = anchor.getBoundingClientRect()
|
||||||
|
userMenuPosition.top = rect.top + rect.height / 2
|
||||||
|
userMenuPosition.left = rect.right + 12
|
||||||
|
userMenuOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCollapsedUserMenu() {
|
||||||
|
clearUserMenuCloseTimer()
|
||||||
|
userMenuCloseTimer = setTimeout(() => {
|
||||||
|
userMenuOpen.value = false
|
||||||
|
userMenuCloseTimer = null
|
||||||
|
}, 120)
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCollapsedUserMenuNow() {
|
||||||
|
clearUserMenuCloseTimer()
|
||||||
|
userMenuOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUserFocusOut(event) {
|
||||||
|
if (!props.collapsed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = event.currentTarget
|
||||||
|
const nextTarget = event.relatedTarget
|
||||||
|
if (nextTarget && container?.contains(nextTarget)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
closeCollapsedUserMenuNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
closeCollapsedUserMenuNow()
|
||||||
|
emit('logout')
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.collapsed,
|
||||||
|
(isCollapsed) => {
|
||||||
|
if (!isCollapsed) {
|
||||||
|
closeCollapsedUserMenuNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
stopApprovalInboxPolling()
|
||||||
|
closeCollapsedUserMenuNow()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.rail {
|
.rail {
|
||||||
|
--rail-motion-duration: 320ms;
|
||||||
|
--rail-motion-ease: cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
--rail-fade-duration: 160ms;
|
||||||
|
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
height: var(--desktop-stage-height, 100dvh);
|
height: var(--desktop-stage-height, 100dvh);
|
||||||
display: grid;
|
min-height: var(--desktop-stage-height, 100dvh);
|
||||||
grid-template-rows: auto 1fr auto;
|
min-width: 0;
|
||||||
background:
|
display: flex;
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 251, 250, 0.96)),
|
flex-direction: column;
|
||||||
#fff;
|
overflow: hidden;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 251, 250, 0.96)), #fff;
|
||||||
border-right: 1px solid #dbe4ee;
|
border-right: 1px solid #dbe4ee;
|
||||||
box-shadow: 1px 0 0 rgba(15, 23, 42, 0.02);
|
box-shadow: 1px 0 0 rgba(15, 23, 42, 0.02);
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rail-brand {
|
.rail-brand {
|
||||||
min-height: 86px;
|
position: relative;
|
||||||
|
min-height: 92px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: flex-start;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 22px 20px 18px;
|
padding: 22px 16px 18px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition:
|
||||||
|
padding var(--rail-motion-duration) var(--rail-motion-ease),
|
||||||
|
gap var(--rail-motion-duration) var(--rail-motion-ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-mark {
|
.brand-mark {
|
||||||
|
flex: 0 0 auto;
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -153,6 +288,10 @@ const displayCompanyName = computed(() => props.companyName || 'X-Financial')
|
|||||||
color: #07936f;
|
color: #07936f;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
transition:
|
||||||
|
width var(--rail-motion-duration) var(--rail-motion-ease),
|
||||||
|
height var(--rail-motion-duration) var(--rail-motion-ease),
|
||||||
|
margin var(--rail-motion-duration) var(--rail-motion-ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-logo {
|
.custom-logo {
|
||||||
@@ -168,26 +307,74 @@ const displayCompanyName = computed(() => props.companyName || 'X-Financial')
|
|||||||
}
|
}
|
||||||
|
|
||||||
.brand-name {
|
.brand-name {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 124px;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 0;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
transition:
|
||||||
|
max-width var(--rail-motion-duration) var(--rail-motion-ease),
|
||||||
|
opacity var(--rail-fade-duration) var(--rail-motion-ease),
|
||||||
|
transform var(--rail-fade-duration) var(--rail-motion-ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-collapse-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 16px;
|
||||||
|
top: 31px;
|
||||||
|
z-index: 2;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||||
|
border-radius: 9px;
|
||||||
|
background: rgba(255, 255, 255, 0.86);
|
||||||
|
color: #64748b;
|
||||||
|
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.06);
|
||||||
|
transition:
|
||||||
|
top var(--rail-motion-duration) var(--rail-motion-ease),
|
||||||
|
right var(--rail-motion-duration) var(--rail-motion-ease),
|
||||||
|
transform var(--rail-motion-duration) var(--rail-motion-ease),
|
||||||
|
background 180ms var(--ease),
|
||||||
|
border-color 180ms var(--ease),
|
||||||
|
color 180ms var(--ease),
|
||||||
|
box-shadow 180ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-collapse-btn:hover {
|
||||||
|
border-color: rgba(16, 185, 129, 0.28);
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-collapse-btn .mdi {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rail-nav {
|
.rail-nav {
|
||||||
display: grid;
|
display: flex;
|
||||||
align-content: start;
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 8px;
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
flex: 1;
|
||||||
|
transition:
|
||||||
|
padding var(--rail-motion-duration) var(--rail-motion-ease),
|
||||||
|
gap var(--rail-motion-duration) var(--rail-motion-ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-btn {
|
.nav-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 48px;
|
min-height: 48px;
|
||||||
display: grid;
|
position: relative;
|
||||||
grid-template-columns: 28px minmax(0, 1fr) auto;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
@@ -196,11 +383,13 @@ const displayCompanyName = computed(() => props.companyName || 'X-Financial')
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
overflow: hidden;
|
||||||
transition:
|
transition:
|
||||||
|
padding var(--rail-motion-duration) var(--rail-motion-ease),
|
||||||
|
gap var(--rail-motion-duration) var(--rail-motion-ease),
|
||||||
background 180ms var(--ease),
|
background 180ms var(--ease),
|
||||||
border-color 180ms var(--ease),
|
border-color 180ms var(--ease),
|
||||||
color 180ms var(--ease),
|
color 180ms var(--ease);
|
||||||
box-shadow 180ms var(--ease);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-btn:hover {
|
.nav-btn:hover {
|
||||||
@@ -215,12 +404,17 @@ const displayCompanyName = computed(() => props.companyName || 'X-Financial')
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-icon {
|
.nav-icon {
|
||||||
|
flex: 0 0 28px;
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
color: currentColor;
|
color: currentColor;
|
||||||
|
transition:
|
||||||
|
flex-basis var(--rail-motion-duration) var(--rail-motion-ease),
|
||||||
|
width var(--rail-motion-duration) var(--rail-motion-ease),
|
||||||
|
height var(--rail-motion-duration) var(--rail-motion-ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-btn :deep(svg) {
|
.nav-btn :deep(svg) {
|
||||||
@@ -234,17 +428,23 @@ const displayCompanyName = computed(() => props.companyName || 'X-Financial')
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-label {
|
.nav-label {
|
||||||
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
max-width: 128px;
|
||||||
color: currentColor;
|
color: currentColor;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1.2;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
opacity: 1;
|
||||||
|
transition:
|
||||||
|
max-width var(--rail-motion-duration) var(--rail-motion-ease),
|
||||||
|
opacity var(--rail-fade-duration) var(--rail-motion-ease),
|
||||||
|
transform var(--rail-fade-duration) var(--rail-motion-ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-badge {
|
.nav-badge {
|
||||||
|
flex: 0 0 auto;
|
||||||
min-width: 34px;
|
min-width: 34px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -256,7 +456,11 @@ const displayCompanyName = computed(() => props.companyName || 'X-Financial')
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
line-height: 1;
|
transition:
|
||||||
|
min-width var(--rail-motion-duration) var(--rail-motion-ease),
|
||||||
|
max-width var(--rail-motion-duration) var(--rail-motion-ease),
|
||||||
|
padding var(--rail-motion-duration) var(--rail-motion-ease),
|
||||||
|
opacity var(--rail-fade-duration) var(--rail-motion-ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rail-user {
|
.rail-user {
|
||||||
@@ -266,28 +470,32 @@ const displayCompanyName = computed(() => props.companyName || 'X-Financial')
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 16px 20px 18px;
|
padding: 16px 20px 18px;
|
||||||
border-top: 1px solid #edf2f7;
|
border-top: 1px solid #edf2f7;
|
||||||
|
transition: padding var(--rail-motion-duration) var(--rail-motion-ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-summary {
|
.user-summary {
|
||||||
|
position: relative;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 38px minmax(0, 1fr) 18px;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 4px 0 0;
|
padding: 4px;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
outline: none;
|
cursor: pointer;
|
||||||
transition: background 180ms var(--ease);
|
transition:
|
||||||
|
gap var(--rail-motion-duration) var(--rail-motion-ease),
|
||||||
|
padding var(--rail-motion-duration) var(--rail-motion-ease),
|
||||||
|
background 180ms var(--ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rail-user:hover .user-summary,
|
.rail-user:hover .user-summary {
|
||||||
.rail-user:focus-within .user-summary {
|
|
||||||
background: rgba(255, 255, 255, 0.72);
|
background: rgba(255, 255, 255, 0.72);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-avatar {
|
.user-avatar {
|
||||||
|
flex: 0 0 36px;
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -299,19 +507,30 @@ const displayCompanyName = computed(() => props.companyName || 'X-Financial')
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
|
transition:
|
||||||
|
flex-basis var(--rail-motion-duration) var(--rail-motion-ease),
|
||||||
|
width var(--rail-motion-duration) var(--rail-motion-ease),
|
||||||
|
height var(--rail-motion-duration) var(--rail-motion-ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-copy {
|
.user-copy {
|
||||||
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: grid;
|
max-width: 116px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
opacity: 1;
|
||||||
|
transition:
|
||||||
|
max-width var(--rail-motion-duration) var(--rail-motion-ease),
|
||||||
|
opacity var(--rail-fade-duration) var(--rail-motion-ease),
|
||||||
|
transform var(--rail-fade-duration) var(--rail-motion-ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-copy strong {
|
.user-copy strong {
|
||||||
color: #334155;
|
color: #334155;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
line-height: 1.2;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -320,24 +539,17 @@ const displayCompanyName = computed(() => props.companyName || 'X-Financial')
|
|||||||
.user-copy span {
|
.user-copy span {
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.2;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-summary .mdi {
|
.user-summary .mdi {
|
||||||
justify-self: end;
|
flex: 0 0 18px;
|
||||||
color: #94a3b8;
|
font-size: 18px;
|
||||||
font-size: 13px;
|
transition:
|
||||||
line-height: 1;
|
max-width var(--rail-motion-duration) var(--rail-motion-ease),
|
||||||
transition: transform 180ms var(--ease), color 180ms var(--ease);
|
opacity var(--rail-fade-duration) var(--rail-motion-ease);
|
||||||
}
|
|
||||||
|
|
||||||
.rail-user:hover .user-summary .mdi,
|
|
||||||
.rail-user:focus-within .user-summary .mdi {
|
|
||||||
color: #0f9f78;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-menu {
|
.user-menu {
|
||||||
@@ -349,34 +561,15 @@ const displayCompanyName = computed(() => props.companyName || 'X-Financial')
|
|||||||
border: 1px solid rgba(226, 232, 240, 0.96);
|
border: 1px solid rgba(226, 232, 240, 0.96);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: rgba(255, 255, 255, 0.98);
|
background: rgba(255, 255, 255, 0.98);
|
||||||
box-shadow:
|
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.1);
|
||||||
0 16px 32px rgba(15, 23, 42, 0.1),
|
|
||||||
0 2px 8px rgba(15, 23, 42, 0.04);
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(8px);
|
transform: translateY(8px);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition:
|
transition: all 180ms var(--ease);
|
||||||
opacity 180ms var(--ease),
|
|
||||||
transform 180ms var(--ease),
|
|
||||||
box-shadow 180ms var(--ease);
|
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-menu::after {
|
.rail-user:hover .user-menu {
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
right: 18px;
|
|
||||||
bottom: -6px;
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
border-right: 1px solid rgba(226, 232, 240, 0.96);
|
|
||||||
border-bottom: 1px solid rgba(226, 232, 240, 0.96);
|
|
||||||
background: rgba(255, 255, 255, 0.98);
|
|
||||||
transform: rotate(45deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rail-user:hover .user-menu,
|
|
||||||
.rail-user:focus-within .user-menu {
|
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
@@ -385,9 +578,8 @@ const displayCompanyName = computed(() => props.companyName || 'X-Financial')
|
|||||||
.user-menu-item {
|
.user-menu-item {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 38px;
|
height: 38px;
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -396,18 +588,138 @@ const displayCompanyName = computed(() => props.companyName || 'X-Financial')
|
|||||||
color: #dc2626;
|
color: #dc2626;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-align: left;
|
transition: all 180ms var(--ease);
|
||||||
transition: background 180ms var(--ease), color 180ms var(--ease);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-menu-item:hover {
|
/* ========================================= */
|
||||||
background: #fff5f5;
|
/* COLLAPSED STATE */
|
||||||
color: #b91c1c;
|
/* ========================================= */
|
||||||
|
|
||||||
|
.rail-collapsed .rail-brand {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 24px 8px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-menu-item .mdi {
|
.rail-collapsed .brand-mark {
|
||||||
font-size: 15px;
|
width: 36px;
|
||||||
line-height: 1;
|
height: 36px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-collapsed .brand-mark svg {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-collapsed .brand-name {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
max-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-collapsed .rail-collapse-btn {
|
||||||
|
position: static;
|
||||||
|
top: auto;
|
||||||
|
right: auto;
|
||||||
|
align-self: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 32px;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-collapsed .rail-nav {
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-collapsed .nav-btn {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-collapsed .nav-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
flex: 0 0 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-collapsed .nav-label {
|
||||||
|
max-width: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-collapsed .nav-badge {
|
||||||
|
max-width: 0;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
opacity: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-collapsed {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-collapsed .rail-user {
|
||||||
|
position: relative;
|
||||||
|
z-index: 6;
|
||||||
|
padding: 14px 8px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-collapsed .user-summary {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4px;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-user-menu-floating {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 12000;
|
||||||
|
min-width: 132px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid rgba(226, 232, 240, 0.96);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.98);
|
||||||
|
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.14);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
animation: railUserMenuIn 180ms var(--rail-motion-ease) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-user-menu-floating .user-menu-item {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes railUserMenuIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-50%) translateX(-6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-50%) translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-collapsed .user-copy,
|
||||||
|
.rail-collapsed .user-summary .mdi {
|
||||||
|
max-width: 0;
|
||||||
|
opacity: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transform: translateX(-6px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
@@ -417,30 +729,11 @@ const displayCompanyName = computed(() => props.companyName || 'X-Financial')
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.rail {
|
.rail *,
|
||||||
border-right: 0;
|
.rail *::before,
|
||||||
border-bottom: 1px solid #dbe4ee;
|
.rail *::after {
|
||||||
}
|
transition: none !important;
|
||||||
|
|
||||||
.rail-brand {
|
|
||||||
min-height: 68px;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rail-nav {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 8px 16px 16px;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-btn {
|
|
||||||
min-width: 148px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rail-user {
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { normalizeRequestForUi } from '../utils/requestViewModel.js'
|
|||||||
import { buildWorkbenchSummary } from '../utils/workbenchSummary.js'
|
import { buildWorkbenchSummary } from '../utils/workbenchSummary.js'
|
||||||
|
|
||||||
const SESSION_TYPE_EXPENSE = 'expense'
|
const SESSION_TYPE_EXPENSE = 'expense'
|
||||||
|
const SMART_ENTRY_SOURCE_APPLICATION = 'application'
|
||||||
|
const SMART_ENTRY_SOURCE_REIMBURSEMENT = 'topbar'
|
||||||
|
|
||||||
export function useAppShell() {
|
export function useAppShell() {
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -113,9 +115,14 @@ export function useAppShell() {
|
|||||||
|
|
||||||
const topBarView = computed(() => {
|
const topBarView = computed(() => {
|
||||||
if (detailMode.value) {
|
if (detailMode.value) {
|
||||||
|
const request = selectedRequest.value || {}
|
||||||
|
const claimNo = request.claimNo || request.claim_no || request.documentNo || request.id || ''
|
||||||
|
const isApplicationDocument = isApplicationDocumentPayload(request, claimNo)
|
||||||
return {
|
return {
|
||||||
title: '报销单详情',
|
title: isApplicationDocument ? '申请单详情' : '报销单详情',
|
||||||
desc: '查看报销明细、票据材料、审批进度与风险提示。'
|
desc: isApplicationDocument
|
||||||
|
? '查看申请信息、预计金额、审批进度与预算管理口径。'
|
||||||
|
: '查看报销明细、票据材料、审批进度与风险提示。'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,11 +177,11 @@ export function useAppShell() {
|
|||||||
setView(view)
|
setView(view)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openTravelCreate() {
|
function openFinancialAssistantCreate(source) {
|
||||||
smartEntryOpen.value = true
|
smartEntryOpen.value = true
|
||||||
smartEntryContext.value = {
|
smartEntryContext.value = {
|
||||||
prompt: '',
|
prompt: '',
|
||||||
source: 'topbar',
|
source,
|
||||||
request: null,
|
request: null,
|
||||||
files: [],
|
files: [],
|
||||||
conversation: null,
|
conversation: null,
|
||||||
@@ -183,6 +190,14 @@ export function useAppShell() {
|
|||||||
smartEntrySessionId.value += 1
|
smartEntrySessionId.value += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openTravelCreate() {
|
||||||
|
openFinancialAssistantCreate(SMART_ENTRY_SOURCE_REIMBURSEMENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openExpenseApplicationCreate() {
|
||||||
|
openFinancialAssistantCreate(SMART_ENTRY_SOURCE_APPLICATION)
|
||||||
|
}
|
||||||
|
|
||||||
function resolveCurrentUserId() {
|
function resolveCurrentUserId() {
|
||||||
const user = currentUser.value || {}
|
const user = currentUser.value || {}
|
||||||
return String(user.username || user.name || 'anonymous').trim() || 'anonymous'
|
return String(user.username || user.name || 'anonymous').trim() || 'anonymous'
|
||||||
@@ -208,6 +223,24 @@ export function useAppShell() {
|
|||||||
return String(payload.source || '').trim() === 'detail' && Boolean(resolveSmartEntryClaimScope(payload))
|
return String(payload.source || '').trim() === 'detail' && Boolean(resolveSmartEntryClaimScope(payload))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isApplicationDocumentPayload(payload = {}, claimNo = '') {
|
||||||
|
const documentType = String(
|
||||||
|
payload.documentType
|
||||||
|
|| payload.document_type
|
||||||
|
|| payload.documentTypeCode
|
||||||
|
|| payload.document_type_code
|
||||||
|
|| payload.draftType
|
||||||
|
|| payload.draft_type
|
||||||
|
|| ''
|
||||||
|
).trim()
|
||||||
|
const normalizedClaimNo = String(claimNo || payload.claimNo || payload.claim_no || '').trim().toUpperCase()
|
||||||
|
return (
|
||||||
|
documentType === 'application'
|
||||||
|
|| documentType === 'expense_application'
|
||||||
|
|| normalizedClaimNo.startsWith('APP-')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveSmartEntryConversation(payload = {}) {
|
async function resolveSmartEntryConversation(payload = {}) {
|
||||||
if (payload.conversation) {
|
if (payload.conversation) {
|
||||||
return payload.conversation
|
return payload.conversation
|
||||||
@@ -257,15 +290,24 @@ export function useAppShell() {
|
|||||||
const claimNo = String(payload.claimNo || payload.claim_no || '').trim()
|
const claimNo = String(payload.claimNo || payload.claim_no || '').trim()
|
||||||
const status = String(payload.status || payload.claimStatus || '').trim()
|
const status = String(payload.status || payload.claimStatus || '').trim()
|
||||||
const approvalStage = String(payload.approvalStage || payload.approval_stage || '').trim()
|
const approvalStage = String(payload.approvalStage || payload.approval_stage || '').trim()
|
||||||
|
const isApplicationDocument = isApplicationDocumentPayload(payload, claimNo)
|
||||||
await reloadRequests()
|
await reloadRequests()
|
||||||
if (status === 'submitted') {
|
if (status === 'submitted') {
|
||||||
smartEntryOpen.value = false
|
smartEntryOpen.value = false
|
||||||
void refreshApprovalInbox()
|
void refreshApprovalInbox()
|
||||||
toast(`${claimNo || '该'}单据已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}。`)
|
toast(
|
||||||
|
isApplicationDocument
|
||||||
|
? `${claimNo || '该'}申请单已提交${approvalStage ? `,当前节点:${approvalStage}` : ',等待直属领导审批'}。`
|
||||||
|
: `${claimNo || '该'}单据已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}。`
|
||||||
|
)
|
||||||
router.push({ name: activeView.value === 'documents' ? 'app-documents' : 'app-requests' })
|
router.push({ name: activeView.value === 'documents' ? 'app-documents' : 'app-requests' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
toast(`${claimNo || '该'}单据已保存为草稿,可继续上传票据或补充信息。`)
|
toast(
|
||||||
|
isApplicationDocument
|
||||||
|
? `${claimNo || '该'}申请单已保存为草稿,可继续补充申请信息。`
|
||||||
|
: `${claimNo || '该'}单据已保存为草稿,可继续上传票据或补充信息。`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openRequestDetail(request) {
|
function openRequestDetail(request) {
|
||||||
@@ -317,6 +359,7 @@ export function useAppShell() {
|
|||||||
handleRequestDeleted,
|
handleRequestDeleted,
|
||||||
handleRequestUpdated,
|
handleRequestUpdated,
|
||||||
navItems,
|
navItems,
|
||||||
|
openExpenseApplicationCreate,
|
||||||
openRequestDetail,
|
openRequestDetail,
|
||||||
openSmartEntry,
|
openSmartEntry,
|
||||||
openTravelCreate,
|
openTravelCreate,
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { filterActionableRiskFlags } from '../utils/riskFlags.js'
|
|||||||
|
|
||||||
const EXPENSE_TYPE_LABELS = {
|
const EXPENSE_TYPE_LABELS = {
|
||||||
travel: '差旅费',
|
travel: '差旅费',
|
||||||
|
travel_application: '差旅费用申请',
|
||||||
|
expense_application: '费用申请',
|
||||||
|
purchase_application: '采购费用申请',
|
||||||
|
meeting_application: '会务费用申请',
|
||||||
train_ticket: '火车票',
|
train_ticket: '火车票',
|
||||||
flight_ticket: '机票',
|
flight_ticket: '机票',
|
||||||
ship_ticket: '轮船票',
|
ship_ticket: '轮船票',
|
||||||
@@ -36,6 +40,8 @@ const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
|
|||||||
const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket'])
|
const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket'])
|
||||||
const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket'])
|
const 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 HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket'])
|
||||||
|
const DOCUMENT_TYPE_APPLICATION = 'application'
|
||||||
|
const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
|
||||||
|
|
||||||
const REIMBURSEMENT_PROGRESS_LABELS = [
|
const REIMBURSEMENT_PROGRESS_LABELS = [
|
||||||
'创建单据',
|
'创建单据',
|
||||||
@@ -46,6 +52,12 @@ const REIMBURSEMENT_PROGRESS_LABELS = [
|
|||||||
'归档入账'
|
'归档入账'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const APPLICATION_PROGRESS_LABELS = [
|
||||||
|
'创建申请',
|
||||||
|
'直属领导审批',
|
||||||
|
'审批完成'
|
||||||
|
]
|
||||||
|
|
||||||
function parseNumber(value) {
|
function parseNumber(value) {
|
||||||
const nextValue = Number(value)
|
const nextValue = Number(value)
|
||||||
return Number.isFinite(nextValue) ? nextValue : 0
|
return Number.isFinite(nextValue) ? nextValue : 0
|
||||||
@@ -123,6 +135,28 @@ function resolveTypeLabel(typeCode) {
|
|||||||
return EXPENSE_TYPE_LABELS[String(typeCode || '').trim()] || EXPENSE_TYPE_LABELS.other
|
return EXPENSE_TYPE_LABELS[String(typeCode || '').trim()] || EXPENSE_TYPE_LABELS.other
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveDocumentTypeMeta(claim, typeCode) {
|
||||||
|
const explicitType = String(
|
||||||
|
claim?.document_type_code
|
||||||
|
|| claim?.documentTypeCode
|
||||||
|
|| claim?.document_type
|
||||||
|
|| claim?.documentType
|
||||||
|
|| ''
|
||||||
|
).trim()
|
||||||
|
const claimNo = String(claim?.claim_no || claim?.claimNo || '').trim().toUpperCase()
|
||||||
|
const normalizedType = String(typeCode || '').trim()
|
||||||
|
const isApplication =
|
||||||
|
explicitType === DOCUMENT_TYPE_APPLICATION
|
||||||
|
|| explicitType === 'expense_application'
|
||||||
|
|| claimNo.startsWith('APP-')
|
||||||
|
|| normalizedType === 'application'
|
||||||
|
|| normalizedType.endsWith('_application')
|
||||||
|
|
||||||
|
return isApplication
|
||||||
|
? { documentTypeCode: DOCUMENT_TYPE_APPLICATION, documentTypeLabel: '申请单' }
|
||||||
|
: { documentTypeCode: DOCUMENT_TYPE_REIMBURSEMENT, documentTypeLabel: '报销单' }
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeExpenseType(typeCode) {
|
function normalizeExpenseType(typeCode) {
|
||||||
return String(typeCode || '').trim() || 'other'
|
return String(typeCode || '').trim() || 'other'
|
||||||
}
|
}
|
||||||
@@ -237,7 +271,7 @@ function resolveApprovalMeta(status) {
|
|||||||
return { key: 'in_progress', label: '审批中', tone: 'info' }
|
return { key: 'in_progress', label: '审批中', tone: 'info' }
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveWorkflowNode(claim, approvalMeta) {
|
function resolveWorkflowNode(claim, approvalMeta, isApplicationDocument = false) {
|
||||||
if (String(claim?.status || '').trim().toLowerCase() === 'returned') {
|
if (String(claim?.status || '').trim().toLowerCase() === 'returned') {
|
||||||
return '待提交'
|
return '待提交'
|
||||||
}
|
}
|
||||||
@@ -259,10 +293,10 @@ function resolveWorkflowNode(claim, approvalMeta) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (approvalMeta.key === 'completed') {
|
if (approvalMeta.key === 'completed') {
|
||||||
return '归档入账'
|
return isApplicationDocument ? '审批完成' : '归档入账'
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'AI预审'
|
return isApplicationDocument ? '直属领导审批' : 'AI预审'
|
||||||
}
|
}
|
||||||
|
|
||||||
function stringifyRiskFlag(value) {
|
function stringifyRiskFlag(value) {
|
||||||
@@ -345,6 +379,31 @@ function resolveProgressCurrentIndex(approvalMeta, workflowNode) {
|
|||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode) {
|
||||||
|
const normalizedNode = String(workflowNode || '').trim()
|
||||||
|
|
||||||
|
if (approvalMeta.key === 'completed') {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedNode.includes('审批完成') || normalizedNode.includes('申请完成')) {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalizedNode.includes('直属领导')
|
||||||
|
|| normalizedNode.includes('领导审批')
|
||||||
|
|| normalizedNode.includes('部门负责人')
|
||||||
|
|| normalizedNode.includes('负责人审批')
|
||||||
|
) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if (approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' || approvalMeta.key === 'rejected') {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeText(value) {
|
function normalizeText(value) {
|
||||||
return String(value || '').trim()
|
return String(value || '').trim()
|
||||||
}
|
}
|
||||||
@@ -438,9 +497,9 @@ function buildCompletedStepMeta(claim, label) {
|
|||||||
const stepLabel = normalizeText(label)
|
const stepLabel = normalizeText(label)
|
||||||
const employeeName = normalizeText(claim?.employee_name) || '申请人'
|
const employeeName = normalizeText(claim?.employee_name) || '申请人'
|
||||||
|
|
||||||
if (stepLabel === '创建单据') {
|
if (stepLabel === '创建单据' || stepLabel === '创建申请') {
|
||||||
const createdAt = formatDateTime(claim?.created_at)
|
const createdAt = formatDateTime(claim?.created_at)
|
||||||
return buildProgressStepMeta(`${employeeName}创建`, createdAt)
|
return buildProgressStepMeta(stepLabel === '创建申请' ? `${employeeName}发起申请` : `${employeeName}创建`, createdAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stepLabel === '待提交') {
|
if (stepLabel === '待提交') {
|
||||||
@@ -477,12 +536,17 @@ function buildCompletedStepMeta(claim, label) {
|
|||||||
return buildProgressStepMeta('归档入账', archivedAt)
|
return buildProgressStepMeta('归档入账', archivedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (stepLabel === '审批完成') {
|
||||||
|
const completedAt = formatDateTime(claim?.updated_at)
|
||||||
|
return buildProgressStepMeta('审批完成', completedAt)
|
||||||
|
}
|
||||||
|
|
||||||
return buildProgressStepMeta('已完成')
|
return buildProgressStepMeta('已完成')
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveCurrentStepStartedAt(claim, label) {
|
function resolveCurrentStepStartedAt(claim, label) {
|
||||||
const stepLabel = normalizeText(label)
|
const stepLabel = normalizeText(label)
|
||||||
if (stepLabel === '创建单据') {
|
if (stepLabel === '创建单据' || stepLabel === '创建申请') {
|
||||||
return claim?.created_at
|
return claim?.created_at
|
||||||
}
|
}
|
||||||
if (stepLabel === '待提交') {
|
if (stepLabel === '待提交') {
|
||||||
@@ -499,14 +563,22 @@ function resolveCurrentStepStartedAt(claim, label) {
|
|||||||
const leaderApprovalEvent = findApprovalEventForStep(claim, '直属领导审批')
|
const leaderApprovalEvent = findApprovalEventForStep(claim, '直属领导审批')
|
||||||
return leaderApprovalEvent?.created_at || leaderApprovalEvent?.createdAt || claim?.updated_at || claim?.submitted_at
|
return leaderApprovalEvent?.created_at || leaderApprovalEvent?.createdAt || claim?.updated_at || claim?.submitted_at
|
||||||
}
|
}
|
||||||
if (stepLabel === '归档入账') {
|
if (stepLabel === '归档入账' || stepLabel === '审批完成') {
|
||||||
return claim?.updated_at || claim?.submitted_at
|
return claim?.updated_at || claim?.submitted_at
|
||||||
}
|
}
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildProgressSteps(approvalMeta, workflowNode, claim = {}) {
|
function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {}) {
|
||||||
const currentIndex = resolveProgressCurrentIndex(approvalMeta, workflowNode)
|
const documentTypeCode = String(options.documentTypeCode || '').trim()
|
||||||
|
const progressLabels =
|
||||||
|
documentTypeCode === DOCUMENT_TYPE_APPLICATION
|
||||||
|
? APPLICATION_PROGRESS_LABELS
|
||||||
|
: REIMBURSEMENT_PROGRESS_LABELS
|
||||||
|
const currentIndex =
|
||||||
|
documentTypeCode === DOCUMENT_TYPE_APPLICATION
|
||||||
|
? resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode)
|
||||||
|
: resolveProgressCurrentIndex(approvalMeta, workflowNode)
|
||||||
const currentTime =
|
const currentTime =
|
||||||
approvalMeta.key === 'completed'
|
approvalMeta.key === 'completed'
|
||||||
? '已完成'
|
? '已完成'
|
||||||
@@ -516,7 +588,7 @@ function buildProgressSteps(approvalMeta, workflowNode, claim = {}) {
|
|||||||
? '已退回'
|
? '已退回'
|
||||||
: '进行中'
|
: '进行中'
|
||||||
|
|
||||||
return REIMBURSEMENT_PROGRESS_LABELS.map((label, index) => {
|
return progressLabels.map((label, index) => {
|
||||||
if (approvalMeta.key === 'completed') {
|
if (approvalMeta.key === 'completed') {
|
||||||
const stepMeta = buildCompletedStepMeta(claim, label)
|
const stepMeta = buildCompletedStepMeta(claim, label)
|
||||||
return {
|
return {
|
||||||
@@ -636,8 +708,10 @@ function buildExpenseItems(claim, riskSummary) {
|
|||||||
export function mapExpenseClaimToRequest(claim) {
|
export function mapExpenseClaimToRequest(claim) {
|
||||||
const typeCode = String(claim?.expense_type || '').trim() || 'other'
|
const typeCode = String(claim?.expense_type || '').trim() || 'other'
|
||||||
const typeLabel = resolveTypeLabel(typeCode)
|
const typeLabel = resolveTypeLabel(typeCode)
|
||||||
|
const documentTypeMeta = resolveDocumentTypeMeta(claim, typeCode)
|
||||||
|
const isApplicationDocument = documentTypeMeta.documentTypeCode === DOCUMENT_TYPE_APPLICATION
|
||||||
const approvalMeta = resolveApprovalMeta(claim?.status)
|
const approvalMeta = resolveApprovalMeta(claim?.status)
|
||||||
const workflowNode = resolveWorkflowNode(claim, approvalMeta)
|
const workflowNode = resolveWorkflowNode(claim, approvalMeta, isApplicationDocument)
|
||||||
const invoiceCount = Math.max(0, parseNumber(claim?.invoice_count))
|
const invoiceCount = Math.max(0, parseNumber(claim?.invoice_count))
|
||||||
const riskSummary = buildRiskSummary(claim?.risk_flags_json)
|
const riskSummary = buildRiskSummary(claim?.risk_flags_json)
|
||||||
const expenseItems = buildExpenseItems(claim, riskSummary)
|
const expenseItems = buildExpenseItems(claim, riskSummary)
|
||||||
@@ -659,8 +733,9 @@ export function mapExpenseClaimToRequest(claim) {
|
|||||||
entity: '',
|
entity: '',
|
||||||
typeCode,
|
typeCode,
|
||||||
typeLabel,
|
typeLabel,
|
||||||
detailVariant: typeCode === 'travel' ? 'travel' : 'general',
|
...documentTypeMeta,
|
||||||
title: String(claim?.reason || '').trim() || `${typeLabel}报销`,
|
detailVariant: typeCode === 'travel' || typeCode === 'travel_application' ? 'travel' : 'general',
|
||||||
|
title: String(claim?.reason || '').trim() || (isApplicationDocument ? typeLabel : `${typeLabel}报销`),
|
||||||
sceneLabel: typeLabel,
|
sceneLabel: typeLabel,
|
||||||
sceneTarget: String(claim?.location || '').trim() || '待补充',
|
sceneTarget: String(claim?.location || '').trim() || '待补充',
|
||||||
location: String(claim?.location || '').trim() || '待补充',
|
location: String(claim?.location || '').trim() || '待补充',
|
||||||
@@ -678,18 +753,24 @@ export function mapExpenseClaimToRequest(claim) {
|
|||||||
approvalKey: approvalMeta.key,
|
approvalKey: approvalMeta.key,
|
||||||
approvalStatus: approvalMeta.label,
|
approvalStatus: approvalMeta.label,
|
||||||
approvalTone: approvalMeta.tone,
|
approvalTone: approvalMeta.tone,
|
||||||
secondaryStatusLabel: typeCode === 'travel' ? '行程状态' : '票据状态',
|
secondaryStatusLabel: isApplicationDocument ? '申请材料' : (typeCode === 'travel' ? '行程状态' : '票据状态'),
|
||||||
secondaryStatusValue: invoiceCount > 0 ? `已关联 ${invoiceCount} 张票据` : '待上传票据',
|
secondaryStatusValue: isApplicationDocument
|
||||||
secondaryStatusTone: invoiceCount > 0 ? 'success' : 'warning',
|
? '已进入审批流程'
|
||||||
|
: (invoiceCount > 0 ? `已关联 ${invoiceCount} 张票据` : '待上传票据'),
|
||||||
|
secondaryStatusTone: isApplicationDocument ? 'success' : (invoiceCount > 0 ? 'success' : 'warning'),
|
||||||
riskSummary,
|
riskSummary,
|
||||||
attachmentSummary: invoiceCount > 0 ? `${invoiceCount} 张票据` : '无',
|
attachmentSummary: isApplicationDocument ? '申请单' : (invoiceCount > 0 ? `${invoiceCount} 张票据` : '无'),
|
||||||
expenseTableSummary: expenseItems.length
|
expenseTableSummary: isApplicationDocument
|
||||||
|
? '预计金额已纳入预算管理口径'
|
||||||
|
: expenseItems.length
|
||||||
? (invoiceCount > 0
|
? (invoiceCount > 0
|
||||||
? `共 ${expenseItems.length} 条费用明细,已关联 ${invoiceCount} 张票据`
|
? `共 ${expenseItems.length} 条费用明细,已关联 ${invoiceCount} 张票据`
|
||||||
: `共 ${expenseItems.length} 条费用明细,待补充票据`)
|
: `共 ${expenseItems.length} 条费用明细,待补充票据`)
|
||||||
: '暂无费用明细',
|
: '暂无费用明细',
|
||||||
note: String(claim?.reason || '').trim(),
|
note: String(claim?.reason || '').trim(),
|
||||||
progressSteps: buildProgressSteps(approvalMeta, workflowNode, claim),
|
progressSteps: buildProgressSteps(approvalMeta, workflowNode, claim, {
|
||||||
|
documentTypeCode: documentTypeMeta.documentTypeCode
|
||||||
|
}),
|
||||||
expenseItems
|
expenseItems
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
173
web/src/utils/assistantSessionScope.js
Normal file
173
web/src/utils/assistantSessionScope.js
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
export const ASSISTANT_SCOPE_ACTION_SWITCH = 'switch_assistant_session'
|
||||||
|
|
||||||
|
export const ASSISTANT_SCOPE_SESSION_APPLICATION = 'application'
|
||||||
|
export const ASSISTANT_SCOPE_SESSION_EXPENSE = 'expense'
|
||||||
|
export const ASSISTANT_SCOPE_SESSION_APPROVAL = 'approval'
|
||||||
|
export const ASSISTANT_SCOPE_SESSION_KNOWLEDGE = 'knowledge'
|
||||||
|
|
||||||
|
const SESSION_SCOPE_CONFIG = {
|
||||||
|
[ASSISTANT_SCOPE_SESSION_APPLICATION]: {
|
||||||
|
label: '申请助手',
|
||||||
|
icon: 'mdi mdi-file-plus-outline',
|
||||||
|
scope: '费用申请、事前审批、申请材料清单、申请单状态查询'
|
||||||
|
},
|
||||||
|
[ASSISTANT_SCOPE_SESSION_EXPENSE]: {
|
||||||
|
label: '报销助手',
|
||||||
|
icon: 'mdi mdi-receipt-text-plus-outline',
|
||||||
|
scope: '发起报销、票据识别、草稿归集、报销单状态查询和报销信息核对'
|
||||||
|
},
|
||||||
|
[ASSISTANT_SCOPE_SESSION_APPROVAL]: {
|
||||||
|
label: '审核助手',
|
||||||
|
icon: 'mdi mdi-clipboard-check-outline',
|
||||||
|
scope: '待审单据查询、审批动作、风险解释和审核意见草稿'
|
||||||
|
},
|
||||||
|
[ASSISTANT_SCOPE_SESSION_KNOWLEDGE]: {
|
||||||
|
label: '财务知识助手',
|
||||||
|
icon: 'mdi mdi-book-open-page-variant-outline',
|
||||||
|
scope: '财务制度、报销标准、票据要求、流程规则和政策口径解释'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SESSION_SCOPE_TYPES = Object.keys(SESSION_SCOPE_CONFIG)
|
||||||
|
|
||||||
|
const APPLICATION_PATTERN =
|
||||||
|
/费用申请|发起申请|申请单|事前申请|事前审批|前置审批|出差申请|采购申请|用款申请|预算申请|申请材料|材料清单|先申请|立项申请/
|
||||||
|
const EXPENSE_PATTERN =
|
||||||
|
/报销|报销单|票据|发票|火车票|高铁票|机票|飞机票|的士票|出租车|网约车|酒店票|住宿票|住宿单据|保存草稿|草稿|费用明细|归集|上传.*票|关联单据|继续下一步/
|
||||||
|
const APPROVAL_PATTERN =
|
||||||
|
/待我审核|待审|审核|审批|审核意见|审批意见|审批通过|审批驳回|驳回|退回|审核中心|审批中心|领导审批|财务审核|处理意见/
|
||||||
|
const KNOWLEDGE_PATTERN =
|
||||||
|
/制度|政策|标准|规则|规定|流程|口径|依据|上限|额度|补贴|住宿标准|差旅标准|报销标准|票据要求|可不可以|能不能|怎么规定|如何计算|怎么算/
|
||||||
|
const EXPENSE_OPERATION_PATTERN = /发起报销|报销单|票据|发票|火车票|高铁票|机票|的士票|草稿|归集|上传|关联单据|继续下一步/
|
||||||
|
const CURRENT_CLAIM_RISK_PATTERN = /这张|当前|本单|该单|单据|风险|超标|异常|重复|待补/
|
||||||
|
|
||||||
|
function normalizeSessionType(sessionType) {
|
||||||
|
const normalized = String(sessionType || '').trim()
|
||||||
|
return SESSION_SCOPE_TYPES.includes(normalized) ? normalized : ASSISTANT_SCOPE_SESSION_EXPENSE
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeText(rawText) {
|
||||||
|
return String(rawText || '')
|
||||||
|
.replace(/\s+/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveScopeConfig(sessionType) {
|
||||||
|
return SESSION_SCOPE_CONFIG[normalizeSessionType(sessionType)] || SESSION_SCOPE_CONFIG[ASSISTANT_SCOPE_SESSION_EXPENSE]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferAssistantScopeTarget(rawText, options = {}) {
|
||||||
|
const text = normalizeText(rawText)
|
||||||
|
if (!text) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const applicationMatched = APPLICATION_PATTERN.test(text)
|
||||||
|
const expenseMatched = EXPENSE_PATTERN.test(text)
|
||||||
|
const approvalMatched = APPROVAL_PATTERN.test(text)
|
||||||
|
const knowledgeMatched = KNOWLEDGE_PATTERN.test(text)
|
||||||
|
|
||||||
|
if (approvalMatched && /(待我审核|待审|审核意见|审批意见|审批通过|审批驳回|驳回|退回|审核中心|审批中心|处理意见)/.test(text)) {
|
||||||
|
return ASSISTANT_SCOPE_SESSION_APPROVAL
|
||||||
|
}
|
||||||
|
|
||||||
|
if (knowledgeMatched && !options.hasActiveReviewPayload && !EXPENSE_OPERATION_PATTERN.test(text)) {
|
||||||
|
return ASSISTANT_SCOPE_SESSION_KNOWLEDGE
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expenseMatched && !applicationMatched) {
|
||||||
|
return ASSISTANT_SCOPE_SESSION_EXPENSE
|
||||||
|
}
|
||||||
|
|
||||||
|
if (applicationMatched && !expenseMatched) {
|
||||||
|
return ASSISTANT_SCOPE_SESSION_APPLICATION
|
||||||
|
}
|
||||||
|
|
||||||
|
if (knowledgeMatched && !expenseMatched && !approvalMatched && !applicationMatched) {
|
||||||
|
return ASSISTANT_SCOPE_SESSION_KNOWLEDGE
|
||||||
|
}
|
||||||
|
|
||||||
|
if (knowledgeMatched && !options.hasActiveReviewPayload) {
|
||||||
|
return ASSISTANT_SCOPE_SESSION_KNOWLEDGE
|
||||||
|
}
|
||||||
|
|
||||||
|
if (approvalMatched) {
|
||||||
|
return ASSISTANT_SCOPE_SESSION_APPROVAL
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expenseMatched) {
|
||||||
|
return ASSISTANT_SCOPE_SESSION_EXPENSE
|
||||||
|
}
|
||||||
|
|
||||||
|
if (applicationMatched) {
|
||||||
|
return ASSISTANT_SCOPE_SESSION_APPLICATION
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldAllowCurrentExpensePolicyQuestion(rawText, currentSessionType, targetSessionType, options = {}) {
|
||||||
|
if (
|
||||||
|
normalizeSessionType(currentSessionType) !== ASSISTANT_SCOPE_SESSION_EXPENSE ||
|
||||||
|
targetSessionType !== ASSISTANT_SCOPE_SESSION_KNOWLEDGE ||
|
||||||
|
!options.hasActiveReviewPayload
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return CURRENT_CLAIM_RISK_PATTERN.test(normalizeText(rawText))
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildScopeSwitchAction(targetSessionType, rawText, options = {}) {
|
||||||
|
const target = resolveScopeConfig(targetSessionType)
|
||||||
|
const carryText = String(rawText || '').trim()
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: `切换到${target.label}`,
|
||||||
|
description: `带着这条内容进入${target.label}继续处理`,
|
||||||
|
icon: target.icon,
|
||||||
|
action_type: ASSISTANT_SCOPE_ACTION_SWITCH,
|
||||||
|
payload: {
|
||||||
|
session_type: normalizeSessionType(targetSessionType),
|
||||||
|
carry_text: carryText,
|
||||||
|
carry_files: Boolean(options.attachmentCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildScopeBoundaryText(currentSessionType, targetSessionType) {
|
||||||
|
const current = resolveScopeConfig(currentSessionType)
|
||||||
|
const target = resolveScopeConfig(targetSessionType)
|
||||||
|
|
||||||
|
return [
|
||||||
|
`我先暂停在「${current.label}」里继续处理这条消息。`,
|
||||||
|
'',
|
||||||
|
`当前助手的业务范围是:${current.scope}。`,
|
||||||
|
'',
|
||||||
|
`您这条内容更适合交给「${target.label}」处理;它的业务范围是:${target.scope}。`,
|
||||||
|
'',
|
||||||
|
`建议切换到「${target.label}」后继续,我会尽量把这条内容带过去,避免在错误的会话里把流程跑偏。`
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAssistantScopeGuard(rawText, currentSessionType, options = {}) {
|
||||||
|
const normalizedCurrent = normalizeSessionType(currentSessionType)
|
||||||
|
const targetSessionType = inferAssistantScopeTarget(rawText, options)
|
||||||
|
if (!targetSessionType || targetSessionType === normalizedCurrent) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldAllowCurrentExpensePolicyQuestion(rawText, normalizedCurrent, targetSessionType, options)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = resolveScopeConfig(targetSessionType)
|
||||||
|
|
||||||
|
return {
|
||||||
|
targetSessionType,
|
||||||
|
targetLabel: target.label,
|
||||||
|
text: buildScopeBoundaryText(normalizedCurrent, targetSessionType),
|
||||||
|
meta: [`建议切换至${target.label}`],
|
||||||
|
suggestedActions: [buildScopeSwitchAction(targetSessionType, rawText, options)]
|
||||||
|
}
|
||||||
|
}
|
||||||
46
web/src/utils/assistantSuggestedActionPrefill.js
Normal file
46
web/src/utils/assistantSuggestedActionPrefill.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
const APPLICATION_FIELD_PREFILLS = {
|
||||||
|
time: '申请时间段:',
|
||||||
|
time_range: '申请时间段:',
|
||||||
|
location: '地点:',
|
||||||
|
reason: '事由:',
|
||||||
|
days: '天数:',
|
||||||
|
transport_mode: '出行方式:',
|
||||||
|
amount: '预计总费用:'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSuggestedActionPrefill(action = {}) {
|
||||||
|
const payload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||||||
|
const explicitPrefill = String(
|
||||||
|
payload.prompt_prefill
|
||||||
|
|| payload.input_prefill
|
||||||
|
|| payload.prefill_text
|
||||||
|
|| ''
|
||||||
|
).trim()
|
||||||
|
if (explicitPrefill) {
|
||||||
|
return explicitPrefill
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionType = String(action?.action_type || '').trim()
|
||||||
|
if (actionType !== 'prefill_composer') {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const applicationField = String(payload.application_field || '').trim()
|
||||||
|
return APPLICATION_FIELD_PREFILLS[applicationField] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeComposerPrefill(currentDraft = '', prefill = '') {
|
||||||
|
const normalizedPrefill = String(prefill || '').trim()
|
||||||
|
if (!normalizedPrefill) {
|
||||||
|
return String(currentDraft || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = String(currentDraft || '')
|
||||||
|
if (!current.trim()) {
|
||||||
|
return normalizedPrefill
|
||||||
|
}
|
||||||
|
if (current.includes(normalizedPrefill)) {
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
return `${current.trimEnd()}\n${normalizedPrefill}`
|
||||||
|
}
|
||||||
@@ -14,6 +14,32 @@ function normalizeExpenseType(value) {
|
|||||||
return String(value || '').trim() || 'other'
|
return String(value || '').trim() || 'other'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isApplicationDocumentRequest(request) {
|
||||||
|
const documentType = String(
|
||||||
|
request?.documentTypeCode
|
||||||
|
|| request?.document_type_code
|
||||||
|
|| request?.documentType
|
||||||
|
|| request?.document_type
|
||||||
|
|| ''
|
||||||
|
).trim()
|
||||||
|
const claimNo = String(
|
||||||
|
request?.claimNo
|
||||||
|
|| request?.claim_no
|
||||||
|
|| request?.documentNo
|
||||||
|
|| request?.id
|
||||||
|
|| ''
|
||||||
|
).trim().toUpperCase()
|
||||||
|
const typeCode = normalizeExpenseType(request?.typeCode || request?.expense_type)
|
||||||
|
|
||||||
|
return (
|
||||||
|
documentType === 'application'
|
||||||
|
|| documentType === 'expense_application'
|
||||||
|
|| claimNo.startsWith('APP-')
|
||||||
|
|| typeCode === 'application'
|
||||||
|
|| typeCode.endsWith('_application')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function isSystemGeneratedExpenseItem(item) {
|
function isSystemGeneratedExpenseItem(item) {
|
||||||
const itemType = normalizeExpenseType(item?.itemType || item?.item_type)
|
const itemType = normalizeExpenseType(item?.itemType || item?.item_type)
|
||||||
return Boolean(item?.isSystemGenerated || item?.is_system_generated || SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType))
|
return Boolean(item?.isSystemGenerated || item?.is_system_generated || SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType))
|
||||||
@@ -29,6 +55,10 @@ function getExpenseItems(request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function hasMissingAttachment(request) {
|
export function hasMissingAttachment(request) {
|
||||||
|
if (isApplicationDocumentRequest(request)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const expenseItems = getExpenseItems(request)
|
const expenseItems = getExpenseItems(request)
|
||||||
|
|
||||||
if (expenseItems.length) {
|
if (expenseItems.length) {
|
||||||
|
|||||||
71
web/src/utils/documentCenterNewState.js
Normal file
71
web/src/utils/documentCenterNewState.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
const STORAGE_KEY = 'x-financial.documents.viewed'
|
||||||
|
const SCOPE_STORAGE_KEY = 'x-financial.documents.scope'
|
||||||
|
|
||||||
|
function getStorage() {
|
||||||
|
return typeof window === 'undefined' ? null : window.localStorage
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDocumentNewKey(row) {
|
||||||
|
const source = String(row?.source || 'document').trim()
|
||||||
|
const id = String(row?.claimId || row?.documentNo || row?.documentKey || row?.id || '').trim()
|
||||||
|
return id ? `${source}:${id}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readViewedDocumentKeys(storage = getStorage()) {
|
||||||
|
if (!storage) {
|
||||||
|
return new Set()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(storage.getItem(STORAGE_KEY) || '[]')
|
||||||
|
return new Set(Array.isArray(parsed) ? parsed.map((item) => String(item || '').trim()).filter(Boolean) : [])
|
||||||
|
} catch {
|
||||||
|
return new Set()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeViewedDocumentKeys(keys, storage = getStorage()) {
|
||||||
|
if (!storage) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
storage.setItem(STORAGE_KEY, JSON.stringify(Array.from(keys).filter(Boolean)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readDocumentScope(fallback, allowedScopes = [], storage = getStorage()) {
|
||||||
|
if (!storage) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedScope = String(storage.getItem(SCOPE_STORAGE_KEY) || '').trim()
|
||||||
|
return allowedScopes.includes(storedScope) ? storedScope : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeDocumentScope(scope, allowedScopes = [], storage = getStorage()) {
|
||||||
|
if (!storage || !allowedScopes.includes(scope)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
storage.setItem(SCOPE_STORAGE_KEY, scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNewDocument(row, viewedKeys) {
|
||||||
|
const key = resolveDocumentNewKey(row)
|
||||||
|
return Boolean(key) && !viewedKeys.has(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countNewDocuments(rows, viewedKeys) {
|
||||||
|
return rows.filter((row) => isNewDocument(row, viewedKeys)).length
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markDocumentViewed(row, viewedKeys, storage = getStorage()) {
|
||||||
|
const key = resolveDocumentNewKey(row)
|
||||||
|
if (!key) {
|
||||||
|
return viewedKeys
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextKeys = new Set(viewedKeys)
|
||||||
|
nextKeys.add(key)
|
||||||
|
writeViewedDocumentKeys(nextKeys, storage)
|
||||||
|
return nextKeys
|
||||||
|
}
|
||||||
@@ -16,7 +16,10 @@ const SLOT_LABELS = {
|
|||||||
expense_type: '费用场景',
|
expense_type: '费用场景',
|
||||||
amount: '申请金额',
|
amount: '申请金额',
|
||||||
time_range: '业务时间',
|
time_range: '业务时间',
|
||||||
|
location: '业务地点',
|
||||||
reason: '申请事由',
|
reason: '申请事由',
|
||||||
|
days: '天数',
|
||||||
|
transport_mode: '出行方式',
|
||||||
attachments: '附件说明',
|
attachments: '附件说明',
|
||||||
customer_name: '客户名称',
|
customer_name: '客户名称',
|
||||||
participants: '参与人员'
|
participants: '参与人员'
|
||||||
@@ -24,6 +27,33 @@ const SLOT_LABELS = {
|
|||||||
|
|
||||||
const PRE_APPROVAL_TYPES = new Set(['travel', 'meeting', 'office', 'training'])
|
const PRE_APPROVAL_TYPES = new Set(['travel', 'meeting', 'office', 'training'])
|
||||||
const ATTACHMENT_REQUIRED_TYPES = new Set(['meeting', 'training'])
|
const ATTACHMENT_REQUIRED_TYPES = new Set(['meeting', 'training'])
|
||||||
|
const PLACEHOLDER_VALUES = new Set(['', '待补充', '暂无', '无', '未知'])
|
||||||
|
const PROMPT_FIELD_LABELS = [
|
||||||
|
'发生时间',
|
||||||
|
'业务发生时间',
|
||||||
|
'申请时间',
|
||||||
|
'时间',
|
||||||
|
'地点',
|
||||||
|
'业务地点',
|
||||||
|
'发生地点',
|
||||||
|
'事由',
|
||||||
|
'申请事由',
|
||||||
|
'出差事由',
|
||||||
|
'原因',
|
||||||
|
'用途',
|
||||||
|
'天数',
|
||||||
|
'出差天数',
|
||||||
|
'申请天数',
|
||||||
|
'出行方式',
|
||||||
|
'交通方式',
|
||||||
|
'交通工具',
|
||||||
|
'预计总费用',
|
||||||
|
'预计费用',
|
||||||
|
'预计金额',
|
||||||
|
'申请金额',
|
||||||
|
'预算',
|
||||||
|
'金额'
|
||||||
|
]
|
||||||
|
|
||||||
export const APPLICATION_EXAMPLES = [
|
export const APPLICATION_EXAMPLES = [
|
||||||
'申请下周去北京做客户现场验收,差旅预算18000元',
|
'申请下周去北京做客户现场验收,差旅预算18000元',
|
||||||
@@ -89,6 +119,112 @@ export function resolveTimeRangeText(ontology) {
|
|||||||
return String(range.raw || '').trim()
|
return String(range.raw || '').trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseApplicationDate(value) {
|
||||||
|
const normalized = String(value || '')
|
||||||
|
.trim()
|
||||||
|
.replace(/日$/, '')
|
||||||
|
.replace(/年|月|\//g, '-')
|
||||||
|
.replace(/\./g, '-')
|
||||||
|
const match = normalized.match(/^(20\d{2})-(\d{1,2})-(\d{1,2})$/)
|
||||||
|
if (!match) return null
|
||||||
|
const [, year, month, day] = match
|
||||||
|
const date = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)))
|
||||||
|
if (Number.isNaN(date.getTime())) return null
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatApplicationDate(date) {
|
||||||
|
return date.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseChineseNumber(value) {
|
||||||
|
const digits = {
|
||||||
|
一: 1,
|
||||||
|
二: 2,
|
||||||
|
两: 2,
|
||||||
|
三: 3,
|
||||||
|
四: 4,
|
||||||
|
五: 5,
|
||||||
|
六: 6,
|
||||||
|
七: 7,
|
||||||
|
八: 8,
|
||||||
|
九: 9
|
||||||
|
}
|
||||||
|
const text = String(value || '').trim()
|
||||||
|
if (!text) return 0
|
||||||
|
if (text === '十') return 10
|
||||||
|
if (text.includes('十')) {
|
||||||
|
const [left, right] = text.split('十')
|
||||||
|
const tens = left ? digits[left] || 0 : 1
|
||||||
|
const ones = right ? digits[right] || 0 : 0
|
||||||
|
return tens * 10 + ones
|
||||||
|
}
|
||||||
|
return digits[text] || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePromptDays(prompt) {
|
||||||
|
const labeled = resolvePromptField(prompt, ['天数', '出差天数', '申请天数'])
|
||||||
|
const source = labeled || String(prompt || '')
|
||||||
|
const match = source.match(/(?<days>\d+|[一二两三四五六七八九十]{1,3})\s*天/)
|
||||||
|
if (!match?.groups?.days) return 0
|
||||||
|
if (/^\d+$/.test(match.groups.days)) return Number(match.groups.days)
|
||||||
|
return parseChineseNumber(match.groups.days)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expandApplicationTimeWithDays(timeText, days = 0) {
|
||||||
|
const normalizedTime = String(timeText || '').trim()
|
||||||
|
const dayCount = Number(days || 0)
|
||||||
|
if (!normalizedTime || !dayCount) return normalizedTime
|
||||||
|
if (/\s*(至|到|~|--|—)\s*/.test(normalizedTime)) return normalizedTime
|
||||||
|
|
||||||
|
const match = normalizedTime.match(/20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?/)
|
||||||
|
const startDate = parseApplicationDate(match?.[0] || '')
|
||||||
|
if (!startDate) return normalizedTime
|
||||||
|
|
||||||
|
const endDate = new Date(startDate.getTime())
|
||||||
|
endDate.setUTCDate(endDate.getUTCDate() + dayCount)
|
||||||
|
return `${formatApplicationDate(startDate)} 至 ${formatApplicationDate(endDate)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveApplicationTimeRange(ontology, prompt) {
|
||||||
|
const range = ontology?.time_range || {}
|
||||||
|
const baseTime = resolveTimeRangeText(ontology)
|
||||||
|
|| resolvePromptField(prompt, ['发生时间', '业务发生时间', '申请时间', '时间'])
|
||||||
|
if (range.start_date && range.end_date && range.start_date !== range.end_date) {
|
||||||
|
return `${range.start_date} 至 ${range.end_date}`
|
||||||
|
}
|
||||||
|
return expandApplicationTimeWithDays(baseTime, resolvePromptDays(prompt)) || baseTime
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(value) {
|
||||||
|
return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePromptField(prompt, labels = []) {
|
||||||
|
const text = String(prompt || '').trim()
|
||||||
|
if (!text) return ''
|
||||||
|
const labelSet = new Set(labels.map((item) => String(item || '').trim()).filter(Boolean))
|
||||||
|
for (const line of text.split(/\r?\n/)) {
|
||||||
|
const match = line.match(/^\s*([^::\s]+)\s*[::]\s*(.+?)\s*$/)
|
||||||
|
if (match && labelSet.has(match[1].trim())) {
|
||||||
|
return match[2].trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelPattern = labels.map(escapeRegExp).join('|')
|
||||||
|
const nextLabelPattern = PROMPT_FIELD_LABELS.map(escapeRegExp).join('|')
|
||||||
|
if (!labelPattern) return ''
|
||||||
|
const match = text.match(
|
||||||
|
new RegExp(`(?:${labelPattern})\\s*[::]\\s*([\\s\\S]*?)(?=\\s*(?:${nextLabelPattern})\\s*[::]|$)`)
|
||||||
|
)
|
||||||
|
return match ? match[1].trim().replace(/[,。;;]$/, '') : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveApplicationReason(prompt) {
|
||||||
|
const labeled = resolvePromptField(prompt, ['事由', '申请事由', '出差事由', '原因', '用途'])
|
||||||
|
return labeled || String(prompt || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveAttachmentPolicy(expenseTypeCode, amount = 0) {
|
export function resolveAttachmentPolicy(expenseTypeCode, amount = 0) {
|
||||||
const code = String(expenseTypeCode || '').trim()
|
const code = String(expenseTypeCode || '').trim()
|
||||||
if (ATTACHMENT_REQUIRED_TYPES.has(code)) {
|
if (ATTACHMENT_REQUIRED_TYPES.has(code)) {
|
||||||
@@ -128,8 +264,15 @@ export function buildApplicationFieldsFromOntology(ontology, prompt, currentUser
|
|||||||
const documentTypeEntity = resolveEntity(ontology, 'document_type')
|
const documentTypeEntity = resolveEntity(ontology, 'document_type')
|
||||||
const workflowStageEntity = resolveEntity(ontology, 'workflow_stage')
|
const workflowStageEntity = resolveEntity(ontology, 'workflow_stage')
|
||||||
const attachmentPolicy = resolveAttachmentPolicy(expenseTypeCode, amount.value)
|
const attachmentPolicy = resolveAttachmentPolicy(expenseTypeCode, amount.value)
|
||||||
|
const timeRange = resolveApplicationTimeRange(ontology, prompt)
|
||||||
|
|| '待补充'
|
||||||
|
const location = locationEntity?.normalized_value
|
||||||
|
|| locationEntity?.value
|
||||||
|
|| resolvePromptField(prompt, ['地点', '业务地点', '发生地点'])
|
||||||
|
|| '待补充'
|
||||||
|
const reason = resolveApplicationReason(prompt) || '待补充'
|
||||||
|
|
||||||
return {
|
const fields = {
|
||||||
documentType: documentTypeEntity?.normalized_value || 'expense_application',
|
documentType: documentTypeEntity?.normalized_value || 'expense_application',
|
||||||
documentTypeLabel: documentTypeEntity?.value || '费用申请',
|
documentTypeLabel: documentTypeEntity?.value || '费用申请',
|
||||||
workflowStage: workflowStageEntity?.normalized_value || 'pre_approval',
|
workflowStage: workflowStageEntity?.normalized_value || 'pre_approval',
|
||||||
@@ -138,21 +281,40 @@ export function buildApplicationFieldsFromOntology(ontology, prompt, currentUser
|
|||||||
expenseTypeLabel: resolveExpenseTypeLabel(expenseTypeCode),
|
expenseTypeLabel: resolveExpenseTypeLabel(expenseTypeCode),
|
||||||
amount: amount.value,
|
amount: amount.value,
|
||||||
amountDisplay: amount.value ? `¥${amount.value.toLocaleString('zh-CN')}` : '待补充',
|
amountDisplay: amount.value ? `¥${amount.value.toLocaleString('zh-CN')}` : '待补充',
|
||||||
timeRange: resolveTimeRangeText(ontology) || '待补充',
|
timeRange,
|
||||||
location: locationEntity?.normalized_value || locationEntity?.value || '待补充',
|
location,
|
||||||
reason: String(prompt || '').trim() || '待补充',
|
reason,
|
||||||
applicant: currentUser.name || currentUser.username || '当前用户',
|
applicant: currentUser.name || currentUser.username || '当前用户',
|
||||||
department: currentUser.department || currentUser.departmentName || '待补充',
|
department: currentUser.department || currentUser.departmentName || '待补充',
|
||||||
preApprovalRequired: PRE_APPROVAL_TYPES.has(expenseTypeCode),
|
preApprovalRequired: PRE_APPROVAL_TYPES.has(expenseTypeCode),
|
||||||
attachmentPolicy,
|
attachmentPolicy
|
||||||
missingSlots: normalizeMissingSlots(ontology?.missing_slots || [])
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...fields,
|
||||||
|
missingSlots: normalizeMissingSlots(ontology?.missing_slots || [], fields)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeMissingSlots(slots = []) {
|
function hasProvidedValue(value) {
|
||||||
|
const normalized = String(value || '').trim()
|
||||||
|
return !PLACEHOLDER_VALUES.has(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSlotAlreadyResolved(slot, fields = {}) {
|
||||||
|
const key = String(slot || '').trim()
|
||||||
|
if (key === 'reason') return hasProvidedValue(fields.reason)
|
||||||
|
if (key === 'time_range' || key === 'time') return hasProvidedValue(fields.timeRange)
|
||||||
|
if (key === 'location') return hasProvidedValue(fields.location)
|
||||||
|
if (key === 'amount') return Number(fields.amount || 0) > 0
|
||||||
|
if (key === 'transport_mode') return hasProvidedValue(fields.transportMode)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeMissingSlots(slots = [], fields = {}) {
|
||||||
const normalized = Array.isArray(slots) ? slots : []
|
const normalized = Array.isArray(slots) ? slots : []
|
||||||
return normalized.map((item) => ({
|
return normalized.map((item) => ({
|
||||||
key: String(item || '').trim(),
|
key: String(item || '').trim(),
|
||||||
label: SLOT_LABELS[String(item || '').trim()] || String(item || '').trim()
|
label: SLOT_LABELS[String(item || '').trim()] || String(item || '').trim()
|
||||||
})).filter((item) => item.key)
|
})).filter((item) => item.key && !isSlotAlreadyResolved(item.key, fields))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const RISK_TEXT_CLASS_BY_LABEL = {
|
|||||||
|
|
||||||
const ACTION_LINK_CLASS_BY_HREF = {
|
const ACTION_LINK_CLASS_BY_HREF = {
|
||||||
'#confirm-attachment-association': 'markdown-action-link-confirm',
|
'#confirm-attachment-association': 'markdown-action-link-confirm',
|
||||||
|
'#application-submit': 'markdown-action-link-confirm',
|
||||||
'#review-next-step': 'markdown-action-link-next',
|
'#review-next-step': 'markdown-action-link-next',
|
||||||
'#review-quick-edit': 'markdown-action-link-edit',
|
'#review-quick-edit': 'markdown-action-link-edit',
|
||||||
'#review-risk-panel': 'markdown-action-link-risk'
|
'#review-risk-panel': 'markdown-action-link-risk'
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
const DEFAULT_SESSION_TYPE_EXPENSE = 'expense'
|
const DEFAULT_SESSION_TYPE_EXPENSE = 'expense'
|
||||||
|
const DEFAULT_SESSION_TYPE_APPLICATION = 'application'
|
||||||
|
const DEFAULT_SESSION_TYPE_APPROVAL = 'approval'
|
||||||
const DEFAULT_SESSION_TYPE_KNOWLEDGE = 'knowledge'
|
const DEFAULT_SESSION_TYPE_KNOWLEDGE = 'knowledge'
|
||||||
|
|
||||||
const DEFAULT_INTENT_LABELS = {
|
const DEFAULT_INTENT_LABELS = {
|
||||||
@@ -145,7 +147,8 @@ export function inferLocalFlowCandidates(rawText) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function shouldRequestExpenseSceneSelection(rawText, options = {}) {
|
export function shouldRequestExpenseSceneSelection(rawText, options = {}) {
|
||||||
if (options.sessionType === DEFAULT_SESSION_TYPE_KNOWLEDGE) {
|
const sessionType = options.sessionType || DEFAULT_SESSION_TYPE_EXPENSE
|
||||||
|
if (sessionType !== DEFAULT_SESSION_TYPE_EXPENSE) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (Number(options.attachmentCount || 0) > 0) {
|
if (Number(options.attachmentCount || 0) > 0) {
|
||||||
@@ -172,7 +175,8 @@ export function shouldRequestExpenseSceneSelection(rawText, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function shouldRequestExpenseIntentConfirmation(rawText, options = {}) {
|
export function shouldRequestExpenseIntentConfirmation(rawText, options = {}) {
|
||||||
if (options.sessionType === DEFAULT_SESSION_TYPE_KNOWLEDGE) {
|
const sessionType = options.sessionType || DEFAULT_SESSION_TYPE_EXPENSE
|
||||||
|
if (sessionType !== DEFAULT_SESSION_TYPE_EXPENSE) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (Number(options.attachmentCount || 0) > 0) {
|
if (Number(options.attachmentCount || 0) > 0) {
|
||||||
@@ -203,6 +207,12 @@ export function buildLocalIntentPreview(rawText, sessionType = DEFAULT_SESSION_T
|
|||||||
if (sessionType === DEFAULT_SESSION_TYPE_KNOWLEDGE) {
|
if (sessionType === DEFAULT_SESSION_TYPE_KNOWLEDGE) {
|
||||||
return '初步识别为财务知识问答,正在准备检索范围'
|
return '初步识别为财务知识问答,正在准备检索范围'
|
||||||
}
|
}
|
||||||
|
if (sessionType === DEFAULT_SESSION_TYPE_APPLICATION) {
|
||||||
|
return '初步识别为费用申请事项,准备进入申请信息识别'
|
||||||
|
}
|
||||||
|
if (sessionType === DEFAULT_SESSION_TYPE_APPROVAL) {
|
||||||
|
return '初步识别为审核处理事项,准备进入单据查询或风险核对'
|
||||||
|
}
|
||||||
|
|
||||||
if (shouldRequestExpenseIntentConfirmation(rawText, { ...options, sessionType })) {
|
if (shouldRequestExpenseIntentConfirmation(rawText, { ...options, sessionType })) {
|
||||||
return '识别到业务事项描述,但是否发起报销尚不明确,需要先由用户确认'
|
return '识别到业务事项描述,但是否发起报销尚不明确,需要先由用户确认'
|
||||||
|
|||||||
@@ -5,6 +5,30 @@ const REQUEST_TYPE_META = {
|
|||||||
tone: 'travel',
|
tone: 'travel',
|
||||||
secondaryStatusLabel: '行程状态'
|
secondaryStatusLabel: '行程状态'
|
||||||
},
|
},
|
||||||
|
travel_application: {
|
||||||
|
label: '差旅费用申请',
|
||||||
|
detailVariant: 'travel',
|
||||||
|
tone: 'travel',
|
||||||
|
secondaryStatusLabel: '申请材料'
|
||||||
|
},
|
||||||
|
expense_application: {
|
||||||
|
label: '费用申请',
|
||||||
|
detailVariant: 'general',
|
||||||
|
tone: 'other',
|
||||||
|
secondaryStatusLabel: '申请材料'
|
||||||
|
},
|
||||||
|
purchase_application: {
|
||||||
|
label: '采购费用申请',
|
||||||
|
detailVariant: 'general',
|
||||||
|
tone: 'office',
|
||||||
|
secondaryStatusLabel: '申请材料'
|
||||||
|
},
|
||||||
|
meeting_application: {
|
||||||
|
label: '会务费用申请',
|
||||||
|
detailVariant: 'general',
|
||||||
|
tone: 'meeting',
|
||||||
|
secondaryStatusLabel: '申请材料'
|
||||||
|
},
|
||||||
train_ticket: {
|
train_ticket: {
|
||||||
label: '火车票',
|
label: '火车票',
|
||||||
detailVariant: 'travel',
|
detailVariant: 'travel',
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app">
|
<div class="app" :class="{ 'sidebar-collapsed': sidebarCollapsed }">
|
||||||
<SidebarRail
|
<div class="app-sidebar">
|
||||||
:nav-items="filteredNavItems"
|
<SidebarRail
|
||||||
:active-view="activeView"
|
:nav-items="filteredNavItems"
|
||||||
:company-name="companyProfile.name"
|
:active-view="activeView"
|
||||||
:company-logo="companyProfile.logo"
|
:company-name="companyProfile.name"
|
||||||
:current-user="currentUser"
|
:company-logo="companyProfile.logo"
|
||||||
@navigate="handleNavigate"
|
:current-user="currentUser"
|
||||||
@open-chat="openSmartEntry"
|
:collapsed="sidebarCollapsed"
|
||||||
@logout="handleLogout"
|
@navigate="handleNavigate"
|
||||||
/>
|
@open-chat="openSmartEntry"
|
||||||
|
@logout="handleLogout"
|
||||||
|
@toggle-collapse="toggleSidebarCollapsed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<main
|
<main
|
||||||
class="main"
|
class="main"
|
||||||
@@ -49,7 +53,7 @@
|
|||||||
@update:active-range="activeRange = $event"
|
@update:active-range="activeRange = $event"
|
||||||
@update:custom-range="customRange = $event"
|
@update:custom-range="customRange = $event"
|
||||||
@batch-approve="toast('已批量通过 23 条审批任务。')"
|
@batch-approve="toast('已批量通过 23 条审批任务。')"
|
||||||
@new-application="openExpenseApplicationDialog"
|
@new-application="openExpenseApplicationCreate"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FilterBar
|
<FilterBar
|
||||||
@@ -91,6 +95,7 @@
|
|||||||
<TravelRequestDetailView
|
<TravelRequestDetailView
|
||||||
v-else-if="['requests', 'documents'].includes(activeView) && detailMode && selectedRequest"
|
v-else-if="['requests', 'documents'].includes(activeView) && detailMode && selectedRequest"
|
||||||
:request="selectedRequest"
|
:request="selectedRequest"
|
||||||
|
:back-label="activeView === 'documents' ? '返回单据中心' : '返回报销列表'"
|
||||||
@back-to-requests="closeRequestDetail"
|
@back-to-requests="closeRequestDetail"
|
||||||
@open-assistant="openSmartEntry"
|
@open-assistant="openSmartEntry"
|
||||||
@request-updated="handleRequestUpdated"
|
@request-updated="handleRequestUpdated"
|
||||||
@@ -105,7 +110,7 @@
|
|||||||
:error="requestsError"
|
:error="requestsError"
|
||||||
@open-document="openRequestDetail"
|
@open-document="openRequestDetail"
|
||||||
@create-request="openTravelCreate"
|
@create-request="openTravelCreate"
|
||||||
@create-application="openExpenseApplicationDialog"
|
@create-application="openExpenseApplicationCreate"
|
||||||
@reload="reloadRequests"
|
@reload="reloadRequests"
|
||||||
@summary-change="documentSummary = $event"
|
@summary-change="documentSummary = $event"
|
||||||
/>
|
/>
|
||||||
@@ -146,12 +151,6 @@
|
|||||||
@close="closeSmartEntry"
|
@close="closeSmartEntry"
|
||||||
@draft-saved="handleDraftSaved"
|
@draft-saved="handleDraftSaved"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ExpenseApplicationDialog
|
|
||||||
v-if="expenseApplicationDialogOpen"
|
|
||||||
@close="closeExpenseApplicationDialog"
|
|
||||||
@confirmed="handleExpenseApplicationConfirmed"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -161,7 +160,6 @@ import { computed, ref } from 'vue'
|
|||||||
import SidebarRail from '../components/layout/SidebarRail.vue'
|
import SidebarRail from '../components/layout/SidebarRail.vue'
|
||||||
import TopBar from '../components/layout/TopBar.vue'
|
import TopBar from '../components/layout/TopBar.vue'
|
||||||
import FilterBar from '../components/layout/FilterBar.vue'
|
import FilterBar from '../components/layout/FilterBar.vue'
|
||||||
import ExpenseApplicationDialog from '../components/shared/ExpenseApplicationDialog.vue'
|
|
||||||
import OverviewView from './OverviewView.vue'
|
import OverviewView from './OverviewView.vue'
|
||||||
import PersonalWorkbenchView from './PersonalWorkbenchView.vue'
|
import PersonalWorkbenchView from './PersonalWorkbenchView.vue'
|
||||||
import TravelReimbursementCreateView from './TravelReimbursementCreateView.vue'
|
import TravelReimbursementCreateView from './TravelReimbursementCreateView.vue'
|
||||||
@@ -186,7 +184,11 @@ const knowledgeSummary = ref(null)
|
|||||||
const logsSummary = ref(null)
|
const logsSummary = ref(null)
|
||||||
const documentSummary = ref(null)
|
const documentSummary = ref(null)
|
||||||
const auditDetailOpen = ref(false)
|
const auditDetailOpen = ref(false)
|
||||||
const expenseApplicationDialogOpen = ref(false)
|
const sidebarCollapsed = ref(true)
|
||||||
|
|
||||||
|
function toggleSidebarCollapsed() {
|
||||||
|
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
activeRange,
|
activeRange,
|
||||||
@@ -206,6 +208,7 @@ const {
|
|||||||
handleRequestDeleted,
|
handleRequestDeleted,
|
||||||
handleRequestUpdated,
|
handleRequestUpdated,
|
||||||
navItems,
|
navItems,
|
||||||
|
openExpenseApplicationCreate,
|
||||||
openRequestDetail,
|
openRequestDetail,
|
||||||
openSmartEntry,
|
openSmartEntry,
|
||||||
openTravelCreate,
|
openTravelCreate,
|
||||||
@@ -229,19 +232,6 @@ const {
|
|||||||
const { companyProfile, currentUser, logout } = useSystemState()
|
const { companyProfile, currentUser, logout } = useSystemState()
|
||||||
const filteredNavItems = computed(() => filterNavItemsByAccess(navItems, currentUser.value))
|
const filteredNavItems = computed(() => filterNavItemsByAccess(navItems, currentUser.value))
|
||||||
|
|
||||||
function openExpenseApplicationDialog() {
|
|
||||||
expenseApplicationDialogOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeExpenseApplicationDialog() {
|
|
||||||
expenseApplicationDialogOpen.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleExpenseApplicationConfirmed() {
|
|
||||||
expenseApplicationDialogOpen.value = false
|
|
||||||
toast('费用申请字段已接入本体识别,后续会按申请审批流落单。')
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
logout('manual')
|
logout('manual')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -193,7 +193,10 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="row in visibleRows" :key="row.documentKey" @click="openDocument(row)">
|
<tr v-for="row in visibleRows" :key="row.documentKey" @click="openDocument(row)">
|
||||||
<td><strong class="doc-id">{{ row.documentNo }}</strong></td>
|
<td>
|
||||||
|
<span v-if="row.isNewDocument" class="new-document-badge">NEW</span>
|
||||||
|
<strong class="doc-id">{{ row.documentNo }}</strong>
|
||||||
|
</td>
|
||||||
<td>{{ row.createdAtDisplay }}</td>
|
<td>{{ row.createdAtDisplay }}</td>
|
||||||
<td v-if="showStayTimeColumn">{{ row.stayTimeDisplay }}</td>
|
<td v-if="showStayTimeColumn">{{ row.stayTimeDisplay }}</td>
|
||||||
<td><span class="doc-kind-tag" :class="row.documentTypeCode">{{ row.documentTypeLabel }}</span></td>
|
<td><span class="doc-kind-tag" :class="row.documentTypeCode">{{ row.documentTypeLabel }}</span></td>
|
||||||
@@ -259,31 +262,31 @@ import TableEmptyState from '../components/shared/TableEmptyState.vue'
|
|||||||
import TableLoadingState from '../components/shared/TableLoadingState.vue'
|
import TableLoadingState from '../components/shared/TableLoadingState.vue'
|
||||||
import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
|
import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
|
||||||
import { fetchApprovalExpenseClaims, fetchArchivedExpenseClaims } from '../services/reimbursements.js'
|
import { fetchApprovalExpenseClaims, fetchArchivedExpenseClaims } from '../services/reimbursements.js'
|
||||||
import {
|
import { countNewDocuments, isNewDocument, markDocumentViewed, readDocumentScope, readViewedDocumentKeys, writeDocumentScope } from '../utils/documentCenterNewState.js'
|
||||||
extractDateText,
|
import { extractDateText, formatDocumentListTime, resolveDocumentSortTime, resolveDocumentStayTimeDisplay } from '../utils/documentCenterTime.js'
|
||||||
formatDocumentListTime,
|
|
||||||
resolveDocumentSortTime,
|
|
||||||
resolveDocumentStayTimeDisplay
|
|
||||||
} from '../utils/documentCenterTime.js'
|
|
||||||
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
|
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
|
||||||
|
|
||||||
const DOCUMENT_TYPE_ALL = 'all'
|
const DOCUMENT_TYPE_ALL = 'all'
|
||||||
const DOCUMENT_TYPE_APPLICATION = 'application'
|
const DOCUMENT_TYPE_APPLICATION = 'application'
|
||||||
const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
|
const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
|
||||||
const SCENE_ALL = 'all'
|
const SCENE_ALL = 'all'
|
||||||
|
const DOCUMENT_SCOPE_ALL = '全部'
|
||||||
const DOCUMENT_SCOPE_APPLICATION = '申请单'
|
const DOCUMENT_SCOPE_APPLICATION = '申请单'
|
||||||
const DOCUMENT_SCOPE_REIMBURSEMENT = '报销单'
|
const DOCUMENT_SCOPE_REIMBURSEMENT = '报销单'
|
||||||
const DOCUMENT_SCOPE_REVIEW = '审核单'
|
const DOCUMENT_SCOPE_REVIEW = '审核单'
|
||||||
const DOCUMENT_SCOPE_ARCHIVE = '归档'
|
const DOCUMENT_SCOPE_ARCHIVE = '归档'
|
||||||
|
|
||||||
const scopeTabs = [
|
const scopeTabs = [DOCUMENT_SCOPE_ALL, DOCUMENT_SCOPE_APPLICATION, DOCUMENT_SCOPE_REIMBURSEMENT, DOCUMENT_SCOPE_REVIEW, DOCUMENT_SCOPE_ARCHIVE]
|
||||||
DOCUMENT_SCOPE_APPLICATION,
|
|
||||||
DOCUMENT_SCOPE_REIMBURSEMENT,
|
|
||||||
DOCUMENT_SCOPE_REVIEW,
|
|
||||||
DOCUMENT_SCOPE_ARCHIVE
|
|
||||||
]
|
|
||||||
const statusTabs = ['全部', '草稿', '待提交', '审批中', '待补充', '已完成']
|
const statusTabs = ['全部', '草稿', '待提交', '审批中', '待补充', '已完成']
|
||||||
const FILTER_CONFIG_BY_SCOPE = {
|
const FILTER_CONFIG_BY_SCOPE = {
|
||||||
|
[DOCUMENT_SCOPE_ALL]: {
|
||||||
|
searchPlaceholder: '搜索单号、事项、费用场景...',
|
||||||
|
sceneFallbackLabel: '单据场景',
|
||||||
|
dateLabel: '单据时间',
|
||||||
|
statusTitle: '单据状态',
|
||||||
|
statusTabs,
|
||||||
|
showDocumentType: true
|
||||||
|
},
|
||||||
[DOCUMENT_SCOPE_APPLICATION]: {
|
[DOCUMENT_SCOPE_APPLICATION]: {
|
||||||
searchPlaceholder: '搜索申请单号、申请事项、申请场景...',
|
searchPlaceholder: '搜索申请单号、申请事项、申请场景...',
|
||||||
sceneFallbackLabel: '申请场景',
|
sceneFallbackLabel: '申请场景',
|
||||||
@@ -339,7 +342,7 @@ const emit = defineEmits([
|
|||||||
'summary-change'
|
'summary-change'
|
||||||
])
|
])
|
||||||
|
|
||||||
const activeScopeTab = ref(DOCUMENT_SCOPE_APPLICATION)
|
const activeScopeTab = ref(readDocumentScope(DOCUMENT_SCOPE_ALL, scopeTabs))
|
||||||
const activeStatusTab = ref('全部')
|
const activeStatusTab = ref('全部')
|
||||||
const activeDocumentType = ref(DOCUMENT_TYPE_ALL)
|
const activeDocumentType = ref(DOCUMENT_TYPE_ALL)
|
||||||
const activeScene = ref(SCENE_ALL)
|
const activeScene = ref(SCENE_ALL)
|
||||||
@@ -357,6 +360,7 @@ const archiveRows = ref([])
|
|||||||
const approvalRows = ref([])
|
const approvalRows = ref([])
|
||||||
const supportingLoading = ref(false)
|
const supportingLoading = ref(false)
|
||||||
const supportingError = ref('')
|
const supportingError = ref('')
|
||||||
|
const viewedDocumentKeys = ref(readViewedDocumentKeys())
|
||||||
|
|
||||||
const activeFilterConfig = computed(() =>
|
const activeFilterConfig = computed(() =>
|
||||||
FILTER_CONFIG_BY_SCOPE[activeScopeTab.value] || FILTER_CONFIG_BY_SCOPE[DOCUMENT_SCOPE_APPLICATION]
|
FILTER_CONFIG_BY_SCOPE[activeScopeTab.value] || FILTER_CONFIG_BY_SCOPE[DOCUMENT_SCOPE_APPLICATION]
|
||||||
@@ -389,13 +393,14 @@ const ownedRows = computed(() =>
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
)
|
)
|
||||||
|
|
||||||
const allSummaryRows = computed(() => mergeDocumentRows([...ownedRows.value, ...approvalRows.value, ...archiveRows.value]))
|
const nonArchivedRows = computed(() => mergeDocumentRows([...ownedRows.value, ...approvalRows.value]))
|
||||||
|
|
||||||
const scopeNewCountMap = computed(() => ({
|
const scopeNewCountMap = computed(() => ({
|
||||||
[DOCUMENT_SCOPE_APPLICATION]: allSummaryRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_APPLICATION).length,
|
[DOCUMENT_SCOPE_ALL]: countNewDocuments(nonArchivedRows.value, viewedDocumentKeys.value),
|
||||||
[DOCUMENT_SCOPE_REIMBURSEMENT]: ownedRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT).length,
|
[DOCUMENT_SCOPE_APPLICATION]: countNewDocuments(nonArchivedRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_APPLICATION), viewedDocumentKeys.value),
|
||||||
[DOCUMENT_SCOPE_REVIEW]: approvalRows.value.length,
|
[DOCUMENT_SCOPE_REIMBURSEMENT]: countNewDocuments(ownedRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT), viewedDocumentKeys.value),
|
||||||
[DOCUMENT_SCOPE_ARCHIVE]: archiveRows.value.length
|
[DOCUMENT_SCOPE_REVIEW]: countNewDocuments(approvalRows.value, viewedDocumentKeys.value),
|
||||||
|
[DOCUMENT_SCOPE_ARCHIVE]: countNewDocuments(archiveRows.value, viewedDocumentKeys.value)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const scopeTabItems = computed(() =>
|
const scopeTabItems = computed(() =>
|
||||||
@@ -407,8 +412,10 @@ const scopeTabItems = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const activeScopeRows = computed(() => {
|
const activeScopeRows = computed(() => {
|
||||||
|
if (activeScopeTab.value === DOCUMENT_SCOPE_ALL) return nonArchivedRows.value
|
||||||
|
|
||||||
if (activeScopeTab.value === DOCUMENT_SCOPE_APPLICATION) {
|
if (activeScopeTab.value === DOCUMENT_SCOPE_APPLICATION) {
|
||||||
return allSummaryRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_APPLICATION)
|
return nonArchivedRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_APPLICATION)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeScopeTab.value === DOCUMENT_SCOPE_REIMBURSEMENT) {
|
if (activeScopeTab.value === DOCUMENT_SCOPE_REIMBURSEMENT) {
|
||||||
@@ -423,7 +430,7 @@ const activeScopeRows = computed(() => {
|
|||||||
return archiveRows.value
|
return archiveRows.value
|
||||||
}
|
}
|
||||||
|
|
||||||
return allSummaryRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_APPLICATION)
|
return nonArchivedRows.value
|
||||||
})
|
})
|
||||||
|
|
||||||
const sceneFilterOptions = computed(() => {
|
const sceneFilterOptions = computed(() => {
|
||||||
@@ -487,7 +494,7 @@ const showStayTimeColumn = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const documentSummary = computed(() => {
|
const documentSummary = computed(() => {
|
||||||
const rows = allSummaryRows.value
|
const rows = nonArchivedRows.value
|
||||||
return {
|
return {
|
||||||
total: rows.length,
|
total: rows.length,
|
||||||
toSubmit: rows.filter((row) => ['draft', 'pending_submit'].includes(row.statusGroup)).length,
|
toSubmit: rows.filter((row) => ['draft', 'pending_submit'].includes(row.statusGroup)).length,
|
||||||
@@ -507,9 +514,9 @@ const emptyState = computed(() => {
|
|||||||
title: '当前还没有申请单数据',
|
title: '当前还没有申请单数据',
|
||||||
desc: '费用申请功能接入后,差旅、会务、办公采购等前置申请会统一汇总到这里。',
|
desc: '费用申请功能接入后,差旅、会务、办公采购等前置申请会统一汇总到这里。',
|
||||||
icon: 'mdi mdi-file-sign-outline',
|
icon: 'mdi mdi-file-sign-outline',
|
||||||
actionLabel: '发起申请',
|
actionLabel: '',
|
||||||
actionIcon: 'mdi mdi-file-plus-outline',
|
actionIcon: '',
|
||||||
tone: 'sky',
|
tone: 'emerald',
|
||||||
artLabel: 'APPLY',
|
artLabel: 'APPLY',
|
||||||
tips: ['旧报销中心仍保留', '申请批准后可继续发起报销']
|
tips: ['旧报销中心仍保留', '申请批准后可继续发起报销']
|
||||||
}
|
}
|
||||||
@@ -522,9 +529,9 @@ const emptyState = computed(() => {
|
|||||||
? '可以清空当前分类下的筛选条件后再看看。'
|
? '可以清空当前分类下的筛选条件后再看看。'
|
||||||
: '当前视角暂无可展示单据,可以切换其他视角或发起一笔报销。',
|
: '当前视角暂无可展示单据,可以切换其他视角或发起一笔报销。',
|
||||||
icon: filtered ? 'mdi mdi-magnify-scan' : 'mdi mdi-file-document-multiple-outline',
|
icon: filtered ? 'mdi mdi-magnify-scan' : 'mdi mdi-file-document-multiple-outline',
|
||||||
actionLabel: filtered ? '清空筛选' : '发起报销',
|
actionLabel: '',
|
||||||
actionIcon: filtered ? 'mdi mdi-filter-remove-outline' : 'mdi mdi-plus-circle-outline',
|
actionIcon: '',
|
||||||
tone: filtered ? 'sky' : 'emerald',
|
tone: 'emerald',
|
||||||
artLabel: filtered ? 'FILTER' : 'DOCS',
|
artLabel: filtered ? 'FILTER' : 'DOCS',
|
||||||
tips: ['单据中心已接入当前报销单据', '归档视角会同步归档中心数据']
|
tips: ['单据中心已接入当前报销单据', '归档视角会同步归档中心数据']
|
||||||
}
|
}
|
||||||
@@ -543,13 +550,17 @@ function buildDocumentRow(request, options = {}) {
|
|||||||
const claimId = normalized.claimId || normalized.id || documentNo
|
const claimId = normalized.claimId || normalized.id || documentNo
|
||||||
const createdAtSource = normalized.createdAt || normalized.submittedAt || normalized.applyTime || normalized.updatedAt
|
const createdAtSource = normalized.createdAt || normalized.submittedAt || normalized.applyTime || normalized.updatedAt
|
||||||
const updatedAtSource = normalized.updatedAt || normalized.submittedAt || normalized.createdAt || normalized.applyTime
|
const updatedAtSource = normalized.updatedAt || normalized.submittedAt || normalized.createdAt || normalized.applyTime
|
||||||
|
const documentTypeCode = normalized.documentTypeCode || DOCUMENT_TYPE_REIMBURSEMENT
|
||||||
|
const documentTypeLabel =
|
||||||
|
normalized.documentTypeLabel
|
||||||
|
|| (documentTypeCode === DOCUMENT_TYPE_APPLICATION ? '申请单' : '报销单')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...normalized,
|
...normalized,
|
||||||
rawRequest: request,
|
rawRequest: request,
|
||||||
documentKey: `${options.source || 'owned'}:${claimId || documentNo}`,
|
documentKey: `${options.source || 'owned'}:${claimId || documentNo}`,
|
||||||
documentTypeCode: DOCUMENT_TYPE_REIMBURSEMENT,
|
documentTypeCode,
|
||||||
documentTypeLabel: '报销单',
|
documentTypeLabel,
|
||||||
claimId,
|
claimId,
|
||||||
documentNo,
|
documentNo,
|
||||||
node: archived ? '财务归档' : (normalized.node || normalized.workflowNode || '待提交'),
|
node: archived ? '财务归档' : (normalized.node || normalized.workflowNode || '待提交'),
|
||||||
@@ -560,6 +571,7 @@ function buildDocumentRow(request, options = {}) {
|
|||||||
archived,
|
archived,
|
||||||
createdAtDisplay: formatDocumentListTime(createdAtSource),
|
createdAtDisplay: formatDocumentListTime(createdAtSource),
|
||||||
stayTimeDisplay: resolveDocumentStayTimeDisplay(normalized),
|
stayTimeDisplay: resolveDocumentStayTimeDisplay(normalized),
|
||||||
|
isNewDocument: isNewDocument({ ...normalized, source: options.source || 'owned', claimId, documentNo }, viewedDocumentKeys.value),
|
||||||
updatedAtDisplay: formatDocumentListTime(updatedAtSource),
|
updatedAtDisplay: formatDocumentListTime(updatedAtSource),
|
||||||
sortTime: resolveDocumentSortTime(updatedAtSource)
|
sortTime: resolveDocumentSortTime(updatedAtSource)
|
||||||
}
|
}
|
||||||
@@ -703,6 +715,8 @@ function changePageSize(size) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openDocument(row) {
|
function openDocument(row) {
|
||||||
|
writeDocumentScope(activeScopeTab.value, scopeTabs)
|
||||||
|
viewedDocumentKeys.value = markDocumentViewed(row, viewedDocumentKeys.value)
|
||||||
emit('open-document', row.rawRequest || row)
|
emit('open-document', row.rawRequest || row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,8 +40,8 @@
|
|||||||
<header class="assistant-header">
|
<header class="assistant-header">
|
||||||
<div class="assistant-header-main">
|
<div class="assistant-header-main">
|
||||||
<div>
|
<div>
|
||||||
<h2>财务助手</h2>
|
<h2>{{ assistantHeaderTitle }}</h2>
|
||||||
<p>个人财务中心 · 报销识别、票据核对与制度咨询,右侧会随处理进度展示识别结果与风险提示。</p>
|
<p>{{ assistantHeaderDescription }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -60,7 +60,9 @@
|
|||||||
:key="shortcut.label"
|
:key="shortcut.label"
|
||||||
type="button"
|
type="button"
|
||||||
class="shortcut-chip"
|
class="shortcut-chip"
|
||||||
:disabled="submitting || reviewActionBusy || deleteSessionBusy || sessionSwitchBusy"
|
:class="{ active: shortcut.active }"
|
||||||
|
:aria-pressed="shortcut.active ? 'true' : 'false'"
|
||||||
|
:disabled="shortcut.active || submitting || reviewActionBusy || deleteSessionBusy || sessionSwitchBusy"
|
||||||
@click="runShortcut(shortcut)"
|
@click="runShortcut(shortcut)"
|
||||||
>
|
>
|
||||||
<i :class="shortcut.icon"></i>
|
<i :class="shortcut.icon"></i>
|
||||||
@@ -1313,6 +1315,22 @@
|
|||||||
@confirm="confirmDeleteCurrentSession"
|
@confirm="confirmDeleteCurrentSession"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
:open="applicationSubmitConfirmDialog.open"
|
||||||
|
badge="提交确认"
|
||||||
|
badge-tone="primary"
|
||||||
|
title="确认提交当前费用申请?"
|
||||||
|
description="提交后申请将进入领导审核流程,并同步纳入预算管理口径,请确认关键申请信息和预计费用已经核对无误。"
|
||||||
|
cancel-text="再检查一下"
|
||||||
|
confirm-text="确认提交"
|
||||||
|
busy-text="提交中..."
|
||||||
|
confirm-tone="primary"
|
||||||
|
confirm-icon="mdi mdi-send-check-outline"
|
||||||
|
:busy="reviewActionBusy"
|
||||||
|
@close="closeApplicationSubmitConfirm"
|
||||||
|
@confirm="confirmApplicationSubmit"
|
||||||
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
:open="nextStepConfirmDialog.open"
|
:open="nextStepConfirmDialog.open"
|
||||||
badge="提交确认"
|
badge="提交确认"
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
<article class="progress-card panel">
|
<article class="progress-card panel">
|
||||||
<div class="progress-block">
|
<div class="progress-block">
|
||||||
<div class="progress-head">
|
<div class="progress-head">
|
||||||
<h3>{{ isTravelRequest ? '差旅进度' : '报销进度' }}</h3>
|
<h3>{{ isApplicationDocument ? '申请进度' : isTravelRequest ? '差旅进度' : '报销进度' }}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="progress-line" :style="{ '--progress-columns': progressSteps.length }">
|
<div class="progress-line" :style="{ '--progress-columns': progressSteps.length }">
|
||||||
<div
|
<div
|
||||||
@@ -133,12 +133,18 @@
|
|||||||
<article class="detail-card panel">
|
<article class="detail-card panel">
|
||||||
<div class="detail-card-head">
|
<div class="detail-card-head">
|
||||||
<div>
|
<div>
|
||||||
<h3>费用明细</h3>
|
<h3>{{ isApplicationDocument ? '申请预算' : '费用明细' }}</h3>
|
||||||
<p>
|
<p>
|
||||||
{{ isTravelRequest ? '按出行时间逐笔核对票据与差旅规则。' : '按业务发生时间逐笔核对票据、用途说明与 AI 识别结果。' }}
|
{{
|
||||||
|
isApplicationDocument
|
||||||
|
? '展示本次费用申请的预计金额,提交后纳入预算管理口径。'
|
||||||
|
: isTravelRequest
|
||||||
|
? '按出行时间逐笔核对票据与差旅规则。'
|
||||||
|
: '按业务发生时间逐笔核对票据、用途说明与 AI 识别结果。'
|
||||||
|
}}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-card-actions">
|
<div v-if="!isApplicationDocument" class="detail-card-actions">
|
||||||
<button v-if="canOpenAiEntry" class="smart-entry-btn" type="button" @click="openAiEntry">
|
<button v-if="canOpenAiEntry" class="smart-entry-btn" type="button" @click="openAiEntry">
|
||||||
<i class="mdi mdi-robot-outline"></i>
|
<i class="mdi mdi-robot-outline"></i>
|
||||||
<span>智能录入</span>
|
<span>智能录入</span>
|
||||||
@@ -156,7 +162,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail-expense-table">
|
<div v-if="isApplicationDocument" class="detail-note readonly">
|
||||||
|
<p>
|
||||||
|
预计总费用:{{ request.amountDisplay }}。该金额用于领导审批和预算管理,无需补充任何报销票据。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="detail-expense-table">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -381,7 +393,7 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="expenseItems.length" class="expense-total-under-table">
|
<div v-if="expenseItems.length && !isApplicationDocument" class="expense-total-under-table">
|
||||||
<span>金额合计</span>
|
<span>金额合计</span>
|
||||||
<strong>{{ expenseTotal }}</strong>
|
<strong>{{ expenseTotal }}</strong>
|
||||||
</div>
|
</div>
|
||||||
@@ -476,7 +488,7 @@
|
|||||||
@click="handleReturnRequest"
|
@click="handleReturnRequest"
|
||||||
>
|
>
|
||||||
<i class="mdi mdi-undo"></i>
|
<i class="mdi mdi-undo"></i>
|
||||||
{{ returnBusy ? '退回中' : '退回单据' }}
|
{{ returnBusy ? '退回中' : isApplicationDocument ? '退回申请' : '退回单据' }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="canApproveRequest"
|
v-if="canApproveRequest"
|
||||||
@@ -496,10 +508,12 @@
|
|||||||
@click="handleDeleteRequest"
|
@click="handleDeleteRequest"
|
||||||
>
|
>
|
||||||
<i class="mdi mdi-trash-can-outline"></i>
|
<i class="mdi mdi-trash-can-outline"></i>
|
||||||
{{ deleteBusy ? '删除中' : '删除单据' }}
|
{{ deleteBusy ? '删除中' : isApplicationDocument ? '删除申请' : '删除单据' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="detail-action-hint">当前单据已进入流程,详情页仅展示状态与费用明细。</p>
|
<p v-else class="detail-action-hint">
|
||||||
|
{{ isApplicationDocument ? '当前申请单已进入流程,详情页仅展示状态与申请信息。' : '当前单据已进入流程,详情页仅展示状态与费用明细。' }}
|
||||||
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -633,7 +647,7 @@
|
|||||||
badge="提交确认"
|
badge="提交确认"
|
||||||
badge-tone="warning"
|
badge-tone="warning"
|
||||||
:title="`确认提交 ${request.id} 吗?`"
|
:title="`确认提交 ${request.id} 吗?`"
|
||||||
description="请确认报销事由、金额、费用明细和附件材料均已核对无误。确认后系统将发起 AI 预审并进入审批流程。"
|
:description="isApplicationDocument ? '请确认申请事由、预计金额和申请信息均已核对无误。确认后将提交至直属领导审批。' : '请确认报销事由、金额、费用明细和附件材料均已核对无误。确认后系统将发起 AI 预审并进入审批流程。'"
|
||||||
cancel-text="返回核对"
|
cancel-text="返回核对"
|
||||||
confirm-text="确认提交"
|
confirm-text="确认提交"
|
||||||
busy-text="提交中..."
|
busy-text="提交中..."
|
||||||
@@ -649,14 +663,14 @@
|
|||||||
<strong>{{ request.documentNo || request.id }}</strong>
|
<strong>{{ request.documentNo || request.id }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="submit-confirm-row">
|
<div class="submit-confirm-row">
|
||||||
<span>报销类型</span>
|
<span>{{ isApplicationDocument ? '申请类型' : '报销类型' }}</span>
|
||||||
<strong>{{ request.typeLabel }}</strong>
|
<strong>{{ request.typeLabel }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="submit-confirm-row">
|
<div class="submit-confirm-row">
|
||||||
<span>报销金额</span>
|
<span>{{ isApplicationDocument ? '预计金额' : '报销金额' }}</span>
|
||||||
<strong>{{ request.amountDisplay || expenseTotal }}</strong>
|
<strong>{{ request.amountDisplay || expenseTotal }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="submit-confirm-row">
|
<div v-if="!isApplicationDocument" class="submit-confirm-row">
|
||||||
<span>费用明细</span>
|
<span>费用明细</span>
|
||||||
<strong>{{ expenseItems.length }} 条 / {{ uploadedExpenseCount }} 张单据</strong>
|
<strong>{{ expenseItems.length }} 条 / {{ uploadedExpenseCount }} 张单据</strong>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ import {
|
|||||||
buildExpenseSceneSelectionActions
|
buildExpenseSceneSelectionActions
|
||||||
} from '../../utils/expenseAssistantActions.js'
|
} from '../../utils/expenseAssistantActions.js'
|
||||||
import { buildSuggestedActionKey } from '../../utils/suggestedActionKey.js'
|
import { buildSuggestedActionKey } from '../../utils/suggestedActionKey.js'
|
||||||
|
import { ASSISTANT_SCOPE_ACTION_SWITCH } from '../../utils/assistantSessionScope.js'
|
||||||
|
import {
|
||||||
|
mergeComposerPrefill,
|
||||||
|
resolveSuggestedActionPrefill
|
||||||
|
} from '../../utils/assistantSuggestedActionPrefill.js'
|
||||||
import {
|
import {
|
||||||
calculateTravelReimbursement,
|
calculateTravelReimbursement,
|
||||||
fetchExpenseClaims,
|
fetchExpenseClaims,
|
||||||
@@ -143,11 +148,14 @@ import {
|
|||||||
resolveDocumentPreview
|
resolveDocumentPreview
|
||||||
} from './travelReimbursementAttachmentModel.js'
|
} from './travelReimbursementAttachmentModel.js'
|
||||||
import {
|
import {
|
||||||
|
ASSISTANT_SESSION_MODE_OPTIONS,
|
||||||
ASSISTANT_DISPLAY_NAME,
|
ASSISTANT_DISPLAY_NAME,
|
||||||
FLOW_STEP_FALLBACKS,
|
FLOW_STEP_FALLBACKS,
|
||||||
HOT_KNOWLEDGE_QUESTIONS,
|
HOT_KNOWLEDGE_QUESTIONS,
|
||||||
INTENT_LABELS,
|
INTENT_LABELS,
|
||||||
SCENARIO_LABELS,
|
SCENARIO_LABELS,
|
||||||
|
SESSION_TYPE_APPLICATION,
|
||||||
|
SESSION_TYPE_APPROVAL,
|
||||||
SESSION_TYPE_EXPENSE,
|
SESSION_TYPE_EXPENSE,
|
||||||
SESSION_TYPE_KNOWLEDGE,
|
SESSION_TYPE_KNOWLEDGE,
|
||||||
aiAvatar,
|
aiAvatar,
|
||||||
@@ -156,6 +164,7 @@ import {
|
|||||||
buildMessageMeta,
|
buildMessageMeta,
|
||||||
buildWelcomeInsight,
|
buildWelcomeInsight,
|
||||||
createMessage,
|
createMessage,
|
||||||
|
resolveAssistantSessionMode,
|
||||||
resolveKnowledgeRankLabel,
|
resolveKnowledgeRankLabel,
|
||||||
resolveKnowledgeRankTone,
|
resolveKnowledgeRankTone,
|
||||||
sanitizeRequest,
|
sanitizeRequest,
|
||||||
@@ -195,6 +204,7 @@ const REVIEW_PANEL_SCOPE_OVERVIEW = 'overview'
|
|||||||
const REVIEW_PANEL_SCOPE_DOCUMENTS = 'documents'
|
const REVIEW_PANEL_SCOPE_DOCUMENTS = 'documents'
|
||||||
const REVIEW_PANEL_SCOPE_RISK = 'risk'
|
const REVIEW_PANEL_SCOPE_RISK = 'risk'
|
||||||
const REVIEW_NEXT_STEP_HREF = '#review-next-step'
|
const REVIEW_NEXT_STEP_HREF = '#review-next-step'
|
||||||
|
const APPLICATION_SUBMIT_HREF = '#application-submit'
|
||||||
const REVIEW_RISK_PANEL_HREF_PREFIX = '#review-risk'
|
const REVIEW_RISK_PANEL_HREF_PREFIX = '#review-risk'
|
||||||
const REVIEW_QUICK_EDIT_HREF = '#review-quick-edit'
|
const REVIEW_QUICK_EDIT_HREF = '#review-quick-edit'
|
||||||
const FLOW_STEP_STATUS_PENDING = 'pending'
|
const FLOW_STEP_STATUS_PENDING = 'pending'
|
||||||
@@ -544,7 +554,6 @@ export default {
|
|||||||
resolveCurrentUserId,
|
resolveCurrentUserId,
|
||||||
persistSessionState,
|
persistSessionState,
|
||||||
applySessionState,
|
applySessionState,
|
||||||
clearKnowledgeSessionOnEntry,
|
|
||||||
switchSessionType
|
switchSessionType
|
||||||
} = useTravelReimbursementSessionState({
|
} = useTravelReimbursementSessionState({
|
||||||
props,
|
props,
|
||||||
@@ -557,6 +566,10 @@ export default {
|
|||||||
getSessionRuntimeRefs: () => sessionRuntimeRefs
|
getSessionRuntimeRefs: () => sessionRuntimeRefs
|
||||||
})
|
})
|
||||||
const deleteSessionDialogOpen = ref(false)
|
const deleteSessionDialogOpen = ref(false)
|
||||||
|
const applicationSubmitConfirmDialog = ref({
|
||||||
|
open: false,
|
||||||
|
message: null
|
||||||
|
})
|
||||||
const nextStepConfirmDialog = ref({
|
const nextStepConfirmDialog = ref({
|
||||||
open: false,
|
open: false,
|
||||||
message: null,
|
message: null,
|
||||||
@@ -566,6 +579,9 @@ export default {
|
|||||||
const deleteSessionBusy = ref(false)
|
const deleteSessionBusy = ref(false)
|
||||||
const reviewDrawerMode = ref(REVIEW_DRAWER_MODE_REVIEW)
|
const reviewDrawerMode = ref(REVIEW_DRAWER_MODE_REVIEW)
|
||||||
const isKnowledgeSession = computed(() => activeSessionType.value === SESSION_TYPE_KNOWLEDGE)
|
const isKnowledgeSession = computed(() => activeSessionType.value === SESSION_TYPE_KNOWLEDGE)
|
||||||
|
const activeAssistantMode = computed(() => resolveAssistantSessionMode(activeSessionType.value))
|
||||||
|
const assistantHeaderTitle = computed(() => activeAssistantMode.value?.label || '财务助手')
|
||||||
|
const assistantHeaderDescription = computed(() => activeAssistantMode.value?.description || '个人财务中心')
|
||||||
const {
|
const {
|
||||||
flowRunId,
|
flowRunId,
|
||||||
flowSteps,
|
flowSteps,
|
||||||
@@ -640,6 +656,12 @@ export default {
|
|||||||
if (props.entrySource === 'detail' && linkedRequest.value?.id) {
|
if (props.entrySource === 'detail' && linkedRequest.value?.id) {
|
||||||
return `例如:解释一下 ${linkedRequest.value.id} 的报销风险,或帮我生成处理意见草稿。`
|
return `例如:解释一下 ${linkedRequest.value.id} 的报销风险,或帮我生成处理意见草稿。`
|
||||||
}
|
}
|
||||||
|
if (activeSessionType.value === SESSION_TYPE_APPLICATION) {
|
||||||
|
return '例如:我想先申请一笔差旅费用,去上海支持项目部署。'
|
||||||
|
}
|
||||||
|
if (activeSessionType.value === SESSION_TYPE_APPROVAL) {
|
||||||
|
return '例如:查一下待我审核的单据,或帮我生成这张单据的审核意见。'
|
||||||
|
}
|
||||||
return '例如:查一下近10日报销金额、解释酒店超标风险,或根据附件整理报销核对信息。'
|
return '例如:查一下近10日报销金额、解释酒店超标风险,或根据附件整理报销核对信息。'
|
||||||
})
|
})
|
||||||
const currentIntentLabel = computed(() => {
|
const currentIntentLabel = computed(() => {
|
||||||
@@ -652,12 +674,11 @@ export default {
|
|||||||
agent: '知识回答'
|
agent: '知识回答'
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
welcome: '财务助手',
|
welcome: activeAssistantMode.value?.label || '财务助手',
|
||||||
agent: '处理中'
|
agent: '处理中'
|
||||||
}
|
}
|
||||||
return labels[currentInsight.value.intent] ?? 'AI 处理中'
|
return labels[currentInsight.value.intent] ?? 'AI 处理中'
|
||||||
})
|
})
|
||||||
let knowledgeSessionResetPromise = Promise.resolve()
|
|
||||||
const canDeleteCurrentSession = computed(
|
const canDeleteCurrentSession = computed(
|
||||||
() => Boolean(conversationId.value) || messages.value.some((item) => item.role === 'user')
|
() => Boolean(conversationId.value) || messages.value.some((item) => item.role === 'user')
|
||||||
)
|
)
|
||||||
@@ -1008,14 +1029,15 @@ export default {
|
|||||||
}
|
}
|
||||||
const isReviewOverviewDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_REVIEW)
|
const isReviewOverviewDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_REVIEW)
|
||||||
|
|
||||||
const shortcuts = computed(() => [
|
const shortcuts = computed(() =>
|
||||||
{
|
ASSISTANT_SESSION_MODE_OPTIONS.map((mode) => ({
|
||||||
label: isKnowledgeSession.value ? '切换为个人工作台' : '切换为财务知识问答',
|
label: mode.label,
|
||||||
icon: isKnowledgeSession.value ? 'mdi mdi-briefcase-outline' : 'mdi mdi-book-open-page-variant-outline',
|
icon: mode.icon,
|
||||||
action: 'switch_view',
|
action: 'switch_view',
|
||||||
targetSessionType: isKnowledgeSession.value ? SESSION_TYPE_EXPENSE : SESSION_TYPE_KNOWLEDGE
|
targetSessionType: mode.key,
|
||||||
}
|
active: mode.key === activeSessionType.value
|
||||||
])
|
}))
|
||||||
|
)
|
||||||
watch(
|
watch(
|
||||||
() => [activeReviewPayload.value, activeReviewPanelScope.value],
|
() => [activeReviewPayload.value, activeReviewPanelScope.value],
|
||||||
([payload]) => {
|
([payload]) => {
|
||||||
@@ -1147,7 +1169,6 @@ export default {
|
|||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
void clearKnowledgeSessionOnEntry()
|
|
||||||
currentInsight.value =
|
currentInsight.value =
|
||||||
currentInsight.value
|
currentInsight.value
|
||||||
|| buildWelcomeInsight(props.entrySource, linkedRequest.value, activeSessionType.value, currentUser.value)
|
|| buildWelcomeInsight(props.entrySource, linkedRequest.value, activeSessionType.value, currentUser.value)
|
||||||
@@ -1269,6 +1290,9 @@ export default {
|
|||||||
|
|
||||||
async function runShortcut(shortcut) {
|
async function runShortcut(shortcut) {
|
||||||
if (shortcut?.action === 'switch_view' && shortcut?.targetSessionType) {
|
if (shortcut?.action === 'switch_view' && shortcut?.targetSessionType) {
|
||||||
|
if (shortcut.active) {
|
||||||
|
return
|
||||||
|
}
|
||||||
await switchSessionType(shortcut.targetSessionType)
|
await switchSessionType(shortcut.targetSessionType)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1325,12 +1349,52 @@ export default {
|
|||||||
persistSessionState()
|
persistSessionState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applySuggestedActionPrefill(action) {
|
||||||
|
const prefillText = resolveSuggestedActionPrefill(action)
|
||||||
|
if (!prefillText) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
composerDraft.value = mergeComposerPrefill(composerDraft.value, prefillText)
|
||||||
|
nextTick(() => {
|
||||||
|
adjustComposerTextareaHeight()
|
||||||
|
composerTextareaRef.value?.focus()
|
||||||
|
})
|
||||||
|
persistSessionState()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSuggestedAction(message, action) {
|
async function handleSuggestedAction(message, action) {
|
||||||
const actionType = String(action?.action_type || '').trim()
|
const actionType = String(action?.action_type || '').trim()
|
||||||
if (!actionType || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) return
|
if (!actionType || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) return
|
||||||
if (message?.suggestedActionsLocked) return
|
if (message?.suggestedActionsLocked) return
|
||||||
|
if (applySuggestedActionPrefill(action)) return
|
||||||
if (await handleGuidedSuggestedAction(message, action)) return
|
if (await handleGuidedSuggestedAction(message, action)) return
|
||||||
|
|
||||||
|
if (actionType === ASSISTANT_SCOPE_ACTION_SWITCH) {
|
||||||
|
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||||||
|
const targetSessionType = String(actionPayload.session_type || '').trim()
|
||||||
|
if (!targetSessionType) return
|
||||||
|
const carryText = String(actionPayload.carry_text || '').trim()
|
||||||
|
const carryFiles = actionPayload.carry_files ? Array.from(attachedFiles.value || []) : []
|
||||||
|
if (!lockSuggestedActionMessage(message, action)) return
|
||||||
|
await switchSessionType(targetSessionType)
|
||||||
|
if (carryText) {
|
||||||
|
composerDraft.value = carryText
|
||||||
|
}
|
||||||
|
if (carryFiles.length) {
|
||||||
|
const fileMergeResult = mergeFilesWithLimit([], carryFiles, MAX_ATTACHMENTS)
|
||||||
|
attachedFiles.value = fileMergeResult.files
|
||||||
|
composerFilesExpanded.value = fileMergeResult.files.length > VISIBLE_ATTACHMENT_CHIPS
|
||||||
|
}
|
||||||
|
nextTick(() => {
|
||||||
|
adjustComposerTextareaHeight()
|
||||||
|
scrollToBottom()
|
||||||
|
})
|
||||||
|
persistSessionState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (actionType === 'confirm_expense_intent') {
|
if (actionType === 'confirm_expense_intent') {
|
||||||
const originalMessage = String(action?.payload?.original_message || message?.text || '').trim()
|
const originalMessage = String(action?.payload?.original_message || message?.text || '').trim()
|
||||||
if (!originalMessage) return
|
if (!originalMessage) return
|
||||||
@@ -1571,6 +1635,60 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openApplicationSubmitConfirm(message) {
|
||||||
|
if (!message) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applicationSubmitConfirmDialog.value = {
|
||||||
|
open: true,
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeApplicationSubmitConfirm() {
|
||||||
|
if (reviewActionBusy.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applicationSubmitConfirmDialog.value = {
|
||||||
|
open: false,
|
||||||
|
message: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmApplicationSubmit() {
|
||||||
|
const message = applicationSubmitConfirmDialog.value.message
|
||||||
|
if (!message || submitting.value || reviewActionBusy.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applicationSubmitConfirmDialog.value = {
|
||||||
|
open: false,
|
||||||
|
message: null
|
||||||
|
}
|
||||||
|
reviewActionBusy.value = true
|
||||||
|
try {
|
||||||
|
const payload = await submitComposer({
|
||||||
|
rawText: '确认提交',
|
||||||
|
userText: '确认提交',
|
||||||
|
pendingText: '正在提交费用申请...',
|
||||||
|
systemGenerated: true
|
||||||
|
})
|
||||||
|
const draftPayload = payload?.result?.draft_payload || {}
|
||||||
|
const claimNo = String(draftPayload.claim_no || '').trim()
|
||||||
|
const claimId = String(draftPayload.claim_id || '').trim()
|
||||||
|
if (String(payload?.status || '').trim() === 'succeeded' && (claimNo || claimId)) {
|
||||||
|
emit('draft-saved', {
|
||||||
|
claimId,
|
||||||
|
claimNo,
|
||||||
|
status: 'submitted',
|
||||||
|
approvalStage: String(draftPayload.approval_stage || '直属领导审批').trim(),
|
||||||
|
documentType: 'application'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reviewActionBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isWorkbenchBusy() {
|
function isWorkbenchBusy() {
|
||||||
return submitting.value || reviewActionBusy.value || sessionSwitchBusy.value
|
return submitting.value || reviewActionBusy.value || sessionSwitchBusy.value
|
||||||
}
|
}
|
||||||
@@ -1796,6 +1914,12 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const href = String(anchor.getAttribute('href') || '').trim()
|
const href = String(anchor.getAttribute('href') || '').trim()
|
||||||
|
if (href === APPLICATION_SUBMIT_HREF) {
|
||||||
|
event.preventDefault()
|
||||||
|
openApplicationSubmitConfirm(message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (href === REVIEW_NEXT_STEP_HREF) {
|
if (href === REVIEW_NEXT_STEP_HREF) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
openReviewNextStepConfirm(message)
|
openReviewNextStepConfirm(message)
|
||||||
@@ -1890,16 +2014,16 @@ export default {
|
|||||||
emit, ASSISTANT_DISPLAY_NAME, aiAvatar, userAvatar, fileInputRef, composerTextareaRef, messageListRef, composerDraft, composerDatePickerOpen, composerDateMode, composerSingleDate, composerRangeStartDate, composerRangeEndDate, composerBusinessTimeTags, composerCanApplyDateSelection,
|
emit, ASSISTANT_DISPLAY_NAME, aiAvatar, userAvatar, fileInputRef, composerTextareaRef, messageListRef, composerDraft, composerDatePickerOpen, composerDateMode, composerSingleDate, composerRangeStartDate, composerRangeEndDate, composerBusinessTimeTags, composerCanApplyDateSelection,
|
||||||
toggleComposerDatePicker, closeComposerDatePicker, setComposerDateMode, handleComposerDateInputChange, removeComposerBusinessTimeTag, flowSteps, flowRunId, flowRefreshBusy, completedFlowStepCount, flowOverallStatusTone, flowOverallStatusText, flowTotalDurationText,
|
toggleComposerDatePicker, closeComposerDatePicker, setComposerDateMode, handleComposerDateInputChange, removeComposerBusinessTimeTag, flowSteps, flowRunId, flowRefreshBusy, completedFlowStepCount, flowOverallStatusTone, flowOverallStatusText, flowTotalDurationText,
|
||||||
attachedFiles, composerFilesExpanded, visibleAttachedFiles, hiddenAttachedFileCount, submitting, sessionSwitchBusy, messages, currentInsight, linkedRequest, canSubmit, activeSessionType, isKnowledgeSession, hotKnowledgeQuestions,
|
attachedFiles, composerFilesExpanded, visibleAttachedFiles, hiddenAttachedFileCount, submitting, sessionSwitchBusy, messages, currentInsight, linkedRequest, canSubmit, activeSessionType, isKnowledgeSession, hotKnowledgeQuestions,
|
||||||
hasInsightPanelContent, showInsightPanel, insightPanelToggleLabel, composerPlaceholder, currentIntentLabel, canDeleteCurrentSession, latestReviewMessage, activeReviewPayload, activeReviewPanelScope, activeReviewFilePreviews, reviewDrawerMode, isReviewOverviewDrawer, isReviewDocumentDrawer, isReviewRiskDrawer, isReviewFlowDrawer,
|
hasInsightPanelContent, showInsightPanel, insightPanelToggleLabel, assistantHeaderTitle, assistantHeaderDescription, 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,
|
reviewDrawerTitle, reviewOverviewDrawerAvailable, reviewDocumentDrawerAvailable, reviewRiskDrawerAvailable, reviewFlowDrawerAvailable, reviewDocumentDrawerLabel, reviewDocumentDrawerIcon, reviewRiskDrawerLabel, reviewRiskDrawerIcon, reviewFlowDrawerLabel, reviewFlowDrawerIcon, activeReviewDocument, activeReviewDocumentIndex, activeReviewDocumentPreview, canPreviewActiveReviewDocument,
|
||||||
reviewIntentText, reviewFactCards, reviewCategoryOptions, reviewOtherCategoryOptions, reviewSelectedOtherCategory, reviewInlineDirty, reviewInlineForm, reviewInlineEditorKey, reviewInlineErrors, reviewOtherCategoryOpen, reviewInlinePendingFiles, DATE_INPUT_FORMAT, REVIEW_SCENE_OTHER_OPTION, REVIEW_SCENE_OPTIONS, REVIEW_OTHER_CATEGORY_OPTIONS,
|
reviewIntentText, reviewFactCards, reviewCategoryOptions, reviewOtherCategoryOptions, reviewSelectedOtherCategory, reviewInlineDirty, reviewInlineForm, reviewInlineEditorKey, reviewInlineErrors, reviewOtherCategoryOpen, reviewInlinePendingFiles, DATE_INPUT_FORMAT, REVIEW_SCENE_OTHER_OPTION, REVIEW_SCENE_OPTIONS, REVIEW_OTHER_CATEGORY_OPTIONS,
|
||||||
workbenchVisible, reviewPanelConfidence, reviewRiskSummary, reviewRiskItems, reviewRiskEmpty, recognizedNarratives, reviewRecognitionNotes, reviewDocumentSummaries, reviewDocumentCount, reviewDocumentDirty, reviewHasUnsavedChanges,
|
workbenchVisible, reviewPanelConfidence, reviewRiskSummary, reviewRiskItems, reviewRiskEmpty, recognizedNarratives, reviewRecognitionNotes, reviewDocumentSummaries, reviewDocumentCount, reviewDocumentDirty, reviewHasUnsavedChanges,
|
||||||
travelCalculatorOpen, travelCalculatorBusy, travelCalculatorError, travelCalculatorResult, travelCalculatorForm, travelCalculatorCanSubmit, deleteSessionDialogOpen, nextStepConfirmDialog, reviewActionBusy, deleteSessionBusy, documentPreviewDialog, shortcuts,
|
travelCalculatorOpen, travelCalculatorBusy, travelCalculatorError, travelCalculatorResult, travelCalculatorForm, travelCalculatorCanSubmit, deleteSessionDialogOpen, applicationSubmitConfirmDialog, nextStepConfirmDialog, reviewActionBusy, deleteSessionBusy, documentPreviewDialog, shortcuts,
|
||||||
resolveReviewMissingSlotCards, resolveReviewRiskBriefs, buildReviewHeadline, buildReviewSubline, buildReviewStateLabel, buildReviewStateTone, buildReviewPlainFollowupCopy, buildReviewPlainFollowupForMessage, buildReviewNextStepRichCopyForMessage, buildMessageBubbleClass, resolveReviewFooterActions, resolveReviewSaveDraftAction, buildReviewPrimaryButtonLabel, buildReviewMainMessageText,
|
resolveReviewMissingSlotCards, resolveReviewRiskBriefs, buildReviewHeadline, buildReviewSubline, buildReviewStateLabel, buildReviewStateTone, buildReviewPlainFollowupCopy, buildReviewPlainFollowupForMessage, buildReviewNextStepRichCopyForMessage, buildMessageBubbleClass, resolveReviewFooterActions, resolveReviewSaveDraftAction, buildReviewPrimaryButtonLabel, buildReviewMainMessageText,
|
||||||
renderMarkdown, buildExpenseQueryWindowLabel, buildExpenseQueryHint, getExpenseQueryActivePage, getExpenseQueryTotalPages, getExpenseQueryVisibleRecords, resolveDocumentPreview, triggerFileUpload, applyComposerDateSelection, handleFilesChange, handleComposerInput, handleComposerEnter, runShortcut, runWelcomeQuickAction: runShortcut, handleSuggestedAction, isSuggestedActionSelected, askHotKnowledgeQuestion, resolveKnowledgeRankLabel, resolveKnowledgeRankTone,
|
renderMarkdown, buildExpenseQueryWindowLabel, buildExpenseQueryHint, getExpenseQueryActivePage, getExpenseQueryTotalPages, getExpenseQueryVisibleRecords, resolveDocumentPreview, triggerFileUpload, applyComposerDateSelection, handleFilesChange, handleComposerInput, handleComposerEnter, runShortcut, runWelcomeQuickAction: runShortcut, handleSuggestedAction, isSuggestedActionSelected, askHotKnowledgeQuestion, resolveKnowledgeRankLabel, resolveKnowledgeRankTone,
|
||||||
refreshFlowRunDetail, formatFlowStepDuration, resolveFlowStepStatusLabel, resolveFlowStepDetail, toggleInsightPanel, openTravelCalculator, toggleTravelCalculator, closeTravelCalculator, submitTravelCalculator, switchToReviewOverviewDrawer, toggleReviewDocumentDrawer, toggleReviewRiskDrawer, toggleReviewFlowDrawer, toggleAttachedFilesExpanded, removeAttachedFile, clearAttachedFiles,
|
refreshFlowRunDetail, formatFlowStepDuration, resolveFlowStepStatusLabel, resolveFlowStepDetail, toggleInsightPanel, openTravelCalculator, toggleTravelCalculator, closeTravelCalculator, submitTravelCalculator, switchToReviewOverviewDrawer, toggleReviewDocumentDrawer, toggleReviewRiskDrawer, toggleReviewFlowDrawer, toggleAttachedFilesExpanded, removeAttachedFile, clearAttachedFiles,
|
||||||
requestCloseWorkbench, emitCloseAfterLeave, handleAssistantModalAfterEnter, openExpenseQueryRecord, handleExpenseQueryRecordClick, setExpenseQueryPage, shiftExpenseQueryPage, openDeleteSessionDialog, closeDeleteSessionDialog, confirmDeleteCurrentSession, openInlineReviewEditor, closeInlineReviewEditor, commitInlineReviewEditor, clearInlineReviewFieldError, selectInlineScene, selectReviewCategory, selectReviewOtherCategory,
|
requestCloseWorkbench, emitCloseAfterLeave, handleAssistantModalAfterEnter, openExpenseQueryRecord, handleExpenseQueryRecordClick, setExpenseQueryPage, shiftExpenseQueryPage, openDeleteSessionDialog, closeDeleteSessionDialog, confirmDeleteCurrentSession, openInlineReviewEditor, closeInlineReviewEditor, commitInlineReviewEditor, clearInlineReviewFieldError, selectInlineScene, selectReviewCategory, selectReviewOtherCategory,
|
||||||
queryDraftByClaimNo, appendReviewRiskBriefToConversation, appendExpenseQueryRiskToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, closeReviewNextStepConfirm, confirmReviewNextStepSubmit, isDraftSavedReviewMessage, canUseInlineSaveDraft, handleInlineSaveDraft
|
queryDraftByClaimNo, appendReviewRiskBriefToConversation, appendExpenseQueryRiskToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, closeApplicationSubmitConfirm, confirmApplicationSubmit, closeReviewNextStepConfirm, confirmReviewNextStepSubmit, isDraftSavedReviewMessage, canUseInlineSaveDraft, handleInlineSaveDraft
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
buildOptionalTravelReceiptRiskCards,
|
buildOptionalTravelReceiptRiskCards,
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
isPlaceholderValue,
|
isPlaceholderValue,
|
||||||
|
isApplicationDocumentRequest,
|
||||||
isRouteDescriptionExpenseType,
|
isRouteDescriptionExpenseType,
|
||||||
isSyntheticLocationDisplay,
|
isSyntheticLocationDisplay,
|
||||||
isValidIsoDate,
|
isValidIsoDate,
|
||||||
@@ -192,6 +193,10 @@ function buildExpenseItemViewModel(source, index, requestModel, travelTimeLabelM
|
|||||||
attachmentStatus: isSystemGenerated ? '无需附件' : attachments.length ? '已关联票据' : '未上传',
|
attachmentStatus: isSystemGenerated ? '无需附件' : attachments.length ? '已关联票据' : '未上传',
|
||||||
|
|
||||||
function buildOptionalTravelReceiptRiskCards(requestModel, items) {
|
function buildOptionalTravelReceiptRiskCards(requestModel, items) {
|
||||||
|
if (isApplicationDocumentRequest(requestModel)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedItems = Array.isArray(items) ? items : []
|
const normalizedItems = Array.isArray(items) ? items : []
|
||||||
const isTravelContext =
|
const isTravelContext =
|
||||||
requestModel?.detailVariant === 'travel' ||
|
requestModel?.detailVariant === 'travel' ||
|
||||||
@@ -449,7 +454,8 @@ export default {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const isTravelRequest = computed(() => request.value.detailVariant === 'travel')
|
const isApplicationDocument = computed(() => isApplicationDocumentRequest(request.value))
|
||||||
|
const isTravelRequest = computed(() => request.value.detailVariant === 'travel' && !isApplicationDocument.value)
|
||||||
const isDraftRequest = computed(() => request.value.approvalKey === 'draft')
|
const isDraftRequest = computed(() => request.value.approvalKey === 'draft')
|
||||||
const isEditableRequest = computed(() => ['draft', 'supplement'].includes(request.value.approvalKey))
|
const isEditableRequest = computed(() => ['draft', 'supplement'].includes(request.value.approvalKey))
|
||||||
const canOpenAiEntry = computed(() => isEditableRequest.value)
|
const canOpenAiEntry = computed(() => isEditableRequest.value)
|
||||||
@@ -478,39 +484,59 @@ export default {
|
|||||||
&& canApproveLeaderExpenseClaims(currentUser.value)
|
&& canApproveLeaderExpenseClaims(currentUser.value)
|
||||||
)
|
)
|
||||||
|| (
|
|| (
|
||||||
isFinanceApprovalStage.value
|
!isApplicationDocument.value
|
||||||
|
&& isFinanceApprovalStage.value
|
||||||
&& isFinanceUser(currentUser.value)
|
&& isFinanceUser(currentUser.value)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
const showLeaderApprovalPanel = computed(() => canApproveRequest.value)
|
const showLeaderApprovalPanel = computed(() => canApproveRequest.value)
|
||||||
const approvalOpinionTitle = computed(() => (isFinanceApprovalStage.value ? '财务意见' : '领导意见'))
|
const approvalOpinionTitle = computed(() => (isFinanceApprovalStage.value ? '财务意见' : '领导意见'))
|
||||||
const approvalOpinionPlaceholder = computed(() =>
|
const approvalOpinionPlaceholder = computed(() => {
|
||||||
isFinanceApprovalStage.value
|
if (isFinanceApprovalStage.value) {
|
||||||
? '请输入财务终审意见,可补充票据核验、金额一致性或入账关注点。'
|
return '请输入财务终审意见,可补充票据核验、金额一致性或入账关注点。'
|
||||||
: '请输入审批意见,可补充核实情况、费用合理性或后续财务关注点。'
|
}
|
||||||
)
|
if (isApplicationDocument.value) {
|
||||||
const approvalOpinionHint = computed(() =>
|
return '请输入审批意见,可补充业务必要性、预算合理性或执行要求。'
|
||||||
isFinanceApprovalStage.value ? '审核通过后将进入归档入账。' : '审批通过后将流转至财务审批。'
|
}
|
||||||
)
|
return '请输入审批意见,可补充核实情况、费用合理性或后续财务关注点。'
|
||||||
|
})
|
||||||
|
const approvalOpinionHint = computed(() => {
|
||||||
|
if (isFinanceApprovalStage.value) {
|
||||||
|
return '审核通过后将进入归档入账。'
|
||||||
|
}
|
||||||
|
return isApplicationDocument.value ? '审批通过后申请流程完成。' : '审批通过后将流转至财务审批。'
|
||||||
|
})
|
||||||
const approvalConfirmBadge = computed(() => (isFinanceApprovalStage.value ? '财务终审' : '领导审批'))
|
const approvalConfirmBadge = computed(() => (isFinanceApprovalStage.value ? '财务终审' : '领导审批'))
|
||||||
const approvalConfirmDescription = computed(() =>
|
const approvalConfirmDescription = computed(() => {
|
||||||
isFinanceApprovalStage.value
|
if (isFinanceApprovalStage.value) {
|
||||||
? '确认后该报销单会完成财务终审并进入归档入账,请确认票据、金额与财务意见无误。'
|
return '确认后该报销单会完成财务终审并进入归档入账,请确认票据、金额与财务意见无误。'
|
||||||
: '确认后该报销单会从直属领导审批流转到财务审批,请确认申请信息与领导意见无误。'
|
}
|
||||||
)
|
if (isApplicationDocument.value) {
|
||||||
const approvalNextStage = computed(() => (isFinanceApprovalStage.value ? '归档入账' : '财务审批'))
|
return '确认后该申请单会完成直属领导审批,请确认申请信息与领导意见无误。'
|
||||||
const approvalSuccessToast = computed(() =>
|
}
|
||||||
isFinanceApprovalStage.value
|
return '确认后该报销单会从直属领导审批流转到财务审批,请确认申请信息与领导意见无误。'
|
||||||
? `${request.value.id} 已完成财务终审,进入归档入账。`
|
})
|
||||||
|
const approvalNextStage = computed(() => {
|
||||||
|
if (isFinanceApprovalStage.value) {
|
||||||
|
return '归档入账'
|
||||||
|
}
|
||||||
|
return isApplicationDocument.value ? '审批完成' : '财务审批'
|
||||||
|
})
|
||||||
|
const approvalSuccessToast = computed(() => {
|
||||||
|
if (isFinanceApprovalStage.value) {
|
||||||
|
return `${request.value.id} 已完成财务终审,进入归档入账。`
|
||||||
|
}
|
||||||
|
return isApplicationDocument.value
|
||||||
|
? `${request.value.id} 申请已审批通过。`
|
||||||
: `${request.value.id} 已审批通过,流转至财务审批。`
|
: `${request.value.id} 已审批通过,流转至财务审批。`
|
||||||
)
|
})
|
||||||
const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据'))
|
const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据'))
|
||||||
const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value} ${request.value.id} 吗?`)
|
const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value} ${request.value.id} 吗?`)
|
||||||
const deleteDialogDescription = computed(() =>
|
const deleteDialogDescription = computed(() =>
|
||||||
isDraftRequest.value
|
isDraftRequest.value
|
||||||
? '删除后该草稿及其当前费用明细将不可恢复,请确认本次操作。'
|
? '删除后该草稿及其当前费用明细将不可恢复,请确认本次操作。'
|
||||||
: '删除后该报销单及费用明细将不可恢复,请确认本次操作。'
|
: `删除后该${isApplicationDocument.value ? '申请单' : '报销单'}及费用明细将不可恢复,请确认本次操作。`
|
||||||
)
|
)
|
||||||
const actionBusy = computed(() =>
|
const actionBusy = computed(() =>
|
||||||
Boolean(savingExpenseId.value)
|
Boolean(savingExpenseId.value)
|
||||||
@@ -562,7 +588,7 @@ export default {
|
|||||||
const heroFactItems = computed(() => [
|
const heroFactItems = computed(() => [
|
||||||
{
|
{
|
||||||
key: 'document',
|
key: 'document',
|
||||||
label: '报销单号',
|
label: isApplicationDocument.value ? '申请单号' : '报销单号',
|
||||||
value: request.value.documentNo || request.value.id,
|
value: request.value.documentNo || request.value.id,
|
||||||
icon: 'mdi mdi-camera-outline',
|
icon: 'mdi mdi-camera-outline',
|
||||||
valueClass: ''
|
valueClass: ''
|
||||||
@@ -576,14 +602,14 @@ export default {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'amount',
|
key: 'amount',
|
||||||
label: '报销金额',
|
label: isApplicationDocument.value ? '预计金额' : '报销金额',
|
||||||
value: request.value.amountDisplay,
|
value: request.value.amountDisplay,
|
||||||
icon: '',
|
icon: '',
|
||||||
valueClass: 'amount'
|
valueClass: 'amount'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'type',
|
key: 'type',
|
||||||
label: isTravelRequest.value ? '差旅类型' : '报销类型',
|
label: isApplicationDocument.value ? '申请类型' : isTravelRequest.value ? '差旅类型' : '报销类型',
|
||||||
value: request.value.typeLabel,
|
value: request.value.typeLabel,
|
||||||
icon: '',
|
icon: '',
|
||||||
valueClass: ''
|
valueClass: ''
|
||||||
@@ -600,7 +626,7 @@ export default {
|
|||||||
const progressSteps = computed(() =>
|
const progressSteps = computed(() =>
|
||||||
Array.isArray(request.value.progressSteps) && request.value.progressSteps.length
|
Array.isArray(request.value.progressSteps) && request.value.progressSteps.length
|
||||||
? request.value.progressSteps
|
? request.value.progressSteps
|
||||||
: buildFallbackProgressSteps()
|
: buildFallbackProgressSteps(request.value)
|
||||||
)
|
)
|
||||||
|
|
||||||
const currentProgressRingMotion = {
|
const currentProgressRingMotion = {
|
||||||
@@ -1530,7 +1556,11 @@ export default {
|
|||||||
const claimStatus = String(payload?.status || '').trim().toLowerCase()
|
const claimStatus = String(payload?.status || '').trim().toLowerCase()
|
||||||
const approvalStage = String(payload?.approval_stage || payload?.approvalStage || '').trim()
|
const approvalStage = String(payload?.approval_stage || payload?.approvalStage || '').trim()
|
||||||
if (claimStatus === 'submitted') {
|
if (claimStatus === 'submitted') {
|
||||||
toast(`${request.value.id} 已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}。`)
|
toast(
|
||||||
|
isApplicationDocument.value
|
||||||
|
? `${request.value.id} 申请单已提交${approvalStage ? `,当前节点:${approvalStage}` : ',等待直属领导审批'}。`
|
||||||
|
: `${request.value.id} 已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}。`
|
||||||
|
)
|
||||||
} else if (claimStatus === 'supplement') {
|
} else if (claimStatus === 'supplement') {
|
||||||
toast(`${request.value.id} AI预审未通过,已转待补充。`)
|
toast(`${request.value.id} AI预审未通过,已转待补充。`)
|
||||||
} else {
|
} else {
|
||||||
@@ -1577,7 +1607,7 @@ export default {
|
|||||||
try {
|
try {
|
||||||
const payload = await deleteExpenseClaim(request.value.claimId)
|
const payload = await deleteExpenseClaim(request.value.claimId)
|
||||||
deleteDialogOpen.value = false
|
deleteDialogOpen.value = false
|
||||||
toast(payload?.message || `${request.value.id} 报销单已删除。`)
|
toast(payload?.message || `${request.value.id} ${isApplicationDocument.value ? '申请单' : '报销单'}已删除。`)
|
||||||
emit('request-deleted', { claimId: request.value.claimId })
|
emit('request-deleted', { claimId: request.value.claimId })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast(error?.message || '删除单据失败,请稍后重试。')
|
toast(error?.message || '删除单据失败,请稍后重试。')
|
||||||
@@ -1722,7 +1752,7 @@ export default {
|
|||||||
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
|
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
|
||||||
goToNextSubmitRisk, goToPreviousSubmitRisk,
|
goToNextSubmitRisk, goToPreviousSubmitRisk,
|
||||||
handleAddExpenseItem, handleApproveRequest, handleDeleteRequest, handleExpenseFileChange,
|
handleAddExpenseItem, handleApproveRequest, handleDeleteRequest, handleExpenseFileChange,
|
||||||
handleReturnRequest, handleSubmit, heroFactItems, isDraftRequest, isEditableRequest, isTravelRequest,
|
handleReturnRequest, handleSubmit, heroFactItems, isApplicationDocument, isDraftRequest, isEditableRequest, isTravelRequest,
|
||||||
isMajorExpenseRisk,
|
isMajorExpenseRisk,
|
||||||
openAiEntry, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview,
|
openAiEntry, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview,
|
||||||
profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
|
profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
|
||||||
|
|||||||
@@ -9,14 +9,65 @@ import {
|
|||||||
} from './travelReimbursementGuidedFlowModel.js'
|
} from './travelReimbursementGuidedFlowModel.js'
|
||||||
|
|
||||||
export const SESSION_TYPE_EXPENSE = 'expense'
|
export const SESSION_TYPE_EXPENSE = 'expense'
|
||||||
|
export const SESSION_TYPE_APPLICATION = 'application'
|
||||||
|
export const SESSION_TYPE_APPROVAL = 'approval'
|
||||||
export const SESSION_TYPE_KNOWLEDGE = 'knowledge'
|
export const SESSION_TYPE_KNOWLEDGE = 'knowledge'
|
||||||
|
|
||||||
|
export const ASSISTANT_SESSION_TYPES = [
|
||||||
|
SESSION_TYPE_APPLICATION,
|
||||||
|
SESSION_TYPE_EXPENSE,
|
||||||
|
SESSION_TYPE_APPROVAL,
|
||||||
|
SESSION_TYPE_KNOWLEDGE
|
||||||
|
]
|
||||||
|
|
||||||
|
export const ASSISTANT_SESSION_MODE_OPTIONS = [
|
||||||
|
{
|
||||||
|
key: SESSION_TYPE_APPLICATION,
|
||||||
|
label: '申请助手',
|
||||||
|
icon: 'mdi mdi-file-plus-outline',
|
||||||
|
description: '只处理费用申请、事前审批、申请材料和申请状态'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: SESSION_TYPE_EXPENSE,
|
||||||
|
label: '报销助手',
|
||||||
|
icon: 'mdi mdi-receipt-text-plus-outline',
|
||||||
|
description: '只处理报销发起、票据识别、草稿归集和报销状态'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: SESSION_TYPE_APPROVAL,
|
||||||
|
label: '审核助手',
|
||||||
|
icon: 'mdi mdi-clipboard-check-outline',
|
||||||
|
description: '只处理待审单据、风险解释、审批动作和审核意见'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: SESSION_TYPE_KNOWLEDGE,
|
||||||
|
label: '财务知识助手',
|
||||||
|
icon: 'mdi mdi-book-open-page-variant-outline',
|
||||||
|
description: '只处理财务制度、标准规则、票据要求和政策解释'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export function normalizeAssistantSessionType(sessionType, fallback = SESSION_TYPE_EXPENSE) {
|
||||||
|
const normalized = String(sessionType || '').trim()
|
||||||
|
if (ASSISTANT_SESSION_TYPES.includes(normalized)) {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
const fallbackType = String(fallback || '').trim()
|
||||||
|
return ASSISTANT_SESSION_TYPES.includes(fallbackType) ? fallbackType : SESSION_TYPE_EXPENSE
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAssistantSessionMode(sessionType) {
|
||||||
|
const normalized = normalizeAssistantSessionType(sessionType)
|
||||||
|
return ASSISTANT_SESSION_MODE_OPTIONS.find((item) => item.key === normalized) || ASSISTANT_SESSION_MODE_OPTIONS[1]
|
||||||
|
}
|
||||||
|
|
||||||
export const aiAvatar = '/assets/header.png'
|
export const aiAvatar = '/assets/header.png'
|
||||||
export const userAvatar = '/assets/person.png'
|
export const userAvatar = '/assets/person.png'
|
||||||
|
|
||||||
export const SOURCE_LABELS = {
|
export const SOURCE_LABELS = {
|
||||||
workbench: '来自个人工作台',
|
workbench: '来自个人工作台',
|
||||||
topbar: '来自发起报销',
|
topbar: '来自发起报销',
|
||||||
|
application: '来自发起申请',
|
||||||
detail: '来自智能录入',
|
detail: '来自智能录入',
|
||||||
upload: '来自附件上传',
|
upload: '来自附件上传',
|
||||||
requests: '来自报销列表'
|
requests: '来自报销列表'
|
||||||
@@ -109,6 +160,42 @@ export const EXPENSE_WELCOME_QUICK_ACTIONS = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export const APPLICATION_WELCOME_QUICK_ACTIONS = [
|
||||||
|
{
|
||||||
|
label: '快速发起申请',
|
||||||
|
prompt: '我想快速发起一笔费用申请,请先帮我判断申请类型并引导补充信息。',
|
||||||
|
icon: 'mdi mdi-file-plus-outline'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '查询申请状态',
|
||||||
|
prompt: '帮我查询我的费用申请单状态,筛选最近的 5 条记录。',
|
||||||
|
icon: 'mdi mdi-file-search-outline'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '申请材料清单',
|
||||||
|
prompt: '请告诉我发起费用申请通常需要准备哪些关键信息和附件。',
|
||||||
|
icon: 'mdi mdi-clipboard-text-search-outline'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const APPROVAL_WELCOME_QUICK_ACTIONS = [
|
||||||
|
{
|
||||||
|
label: '待我审核',
|
||||||
|
prompt: '帮我查询当前待我审核的单据,筛选最近的 5 条记录。',
|
||||||
|
icon: 'mdi mdi-clipboard-list-outline'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '审核风险说明',
|
||||||
|
prompt: '帮我梳理待审核单据中需要重点关注的风险,并按高、中、低风险分类说明。',
|
||||||
|
icon: 'mdi mdi-alert-circle-outline'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '生成审核意见',
|
||||||
|
prompt: '请根据当前待审核单据的风险点,帮我生成一段专业、克制的审核意见草稿。',
|
||||||
|
icon: 'mdi mdi-text-box-edit-outline'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
export const HOT_KNOWLEDGE_QUESTIONS = [
|
export const HOT_KNOWLEDGE_QUESTIONS = [
|
||||||
'差旅住宿标准按什么规则执行?',
|
'差旅住宿标准按什么规则执行?',
|
||||||
'酒店超标后如何申请例外报销?',
|
'酒店超标后如何申请例外报销?',
|
||||||
@@ -418,7 +505,8 @@ export function buildWelcomeUserContext(user = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildWelcomeQuickActions(sessionType, user, entrySource, linkedRequest) {
|
export function buildWelcomeQuickActions(sessionType, user, entrySource, linkedRequest) {
|
||||||
if (sessionType === SESSION_TYPE_KNOWLEDGE) {
|
const normalizedSessionType = normalizeAssistantSessionType(sessionType)
|
||||||
|
if (normalizedSessionType === SESSION_TYPE_KNOWLEDGE) {
|
||||||
return HOT_KNOWLEDGE_QUESTIONS.slice(0, 6).map((question) => ({
|
return HOT_KNOWLEDGE_QUESTIONS.slice(0, 6).map((question) => ({
|
||||||
label: question.length > 20 ? `${question.slice(0, 20)}…` : question,
|
label: question.length > 20 ? `${question.slice(0, 20)}…` : question,
|
||||||
prompt: question,
|
prompt: question,
|
||||||
@@ -426,23 +514,58 @@ export function buildWelcomeQuickActions(sessionType, user, entrySource, linkedR
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (normalizedSessionType === SESSION_TYPE_APPLICATION) {
|
||||||
|
return APPLICATION_WELCOME_QUICK_ACTIONS
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedSessionType === SESSION_TYPE_APPROVAL) {
|
||||||
|
return APPROVAL_WELCOME_QUICK_ACTIONS
|
||||||
|
}
|
||||||
|
|
||||||
return EXPENSE_WELCOME_QUICK_ACTIONS
|
return EXPENSE_WELCOME_QUICK_ACTIONS
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildWelcomeMessage(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) {
|
export function buildWelcomeMessage(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) {
|
||||||
|
const normalizedSessionType = normalizeAssistantSessionType(sessionType)
|
||||||
const ctx = buildWelcomeUserContext(user || {})
|
const ctx = buildWelcomeUserContext(user || {})
|
||||||
const greeting = ctx.isAdmin ? `${ctx.honorific},您好` : `您好,${ctx.honorific}`
|
const greeting = ctx.isAdmin ? `${ctx.honorific},您好` : `您好,${ctx.honorific}`
|
||||||
|
|
||||||
if (sessionType === SESSION_TYPE_KNOWLEDGE) {
|
if (normalizedSessionType === SESSION_TYPE_KNOWLEDGE) {
|
||||||
return [
|
return [
|
||||||
`${greeting}!今日是 **${ctx.dateLine}**。`,
|
`${greeting}!今日是 **${ctx.dateLine}**。`,
|
||||||
'',
|
'',
|
||||||
'欢迎进入 **个人财务中心 · 知识问答**。我是您的财务助手,可以帮您查制度、报销标准、票据要求和常见财务问题。',
|
'**欢迎来到个人财务中心 · 财务知识助手。** 我可以帮您查制度、报销标准、票据要求和常见财务问题,并保持知识问答对话独立记录。',
|
||||||
|
'',
|
||||||
|
'业务范围:财务制度、标准规则、票据要求和政策口径解释。发起申请、报销处理或审核动作请切换到对应助手。',
|
||||||
'',
|
'',
|
||||||
'您可以直接输入问题,或点击下方「猜你想问」快速开始。'
|
'您可以直接输入问题,或点击下方「猜你想问」快速开始。'
|
||||||
].join('\n')
|
].join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (normalizedSessionType === SESSION_TYPE_APPLICATION) {
|
||||||
|
return [
|
||||||
|
`${greeting}!今日是 **${ctx.dateLine}**。`,
|
||||||
|
'',
|
||||||
|
'**欢迎来到个人财务中心 · 申请助手。** 我会先判断您要处理的是费用申请、报销申请还是其他财务事项,再按对应流程引导补充信息。',
|
||||||
|
'',
|
||||||
|
'业务范围:费用申请、事前审批、申请材料清单和申请单状态。报销票据、审核处理和制度问答请切换到对应助手。',
|
||||||
|
'',
|
||||||
|
'您可以直接描述申请事项,或点击下方快捷操作开始发起申请。'
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedSessionType === SESSION_TYPE_APPROVAL) {
|
||||||
|
return [
|
||||||
|
`${greeting}!今日是 **${ctx.dateLine}**。`,
|
||||||
|
'',
|
||||||
|
'**欢迎来到个人财务中心 · 审核助手。** 我可以帮您查询待审单据、解释风险点、整理审核意见,并保持审核对话独立记录。',
|
||||||
|
'',
|
||||||
|
'业务范围:待审单据查询、审批动作、风险解释和审核意见草稿。申请、报销和制度问答请切换到对应助手。',
|
||||||
|
'',
|
||||||
|
'您可以直接输入要审核或查询的内容,或点击下方快捷操作快速开始。'
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
if (entrySource === 'detail' && linkedRequest?.id) {
|
if (entrySource === 'detail' && linkedRequest?.id) {
|
||||||
return [
|
return [
|
||||||
`${greeting}!今日是 **${ctx.dateLine}**。`,
|
`${greeting}!今日是 **${ctx.dateLine}**。`,
|
||||||
@@ -456,16 +579,19 @@ export function buildWelcomeMessage(entrySource, linkedRequest, sessionType = SE
|
|||||||
return [
|
return [
|
||||||
`${greeting}!今日是 **${ctx.dateLine}**。`,
|
`${greeting}!今日是 **${ctx.dateLine}**。`,
|
||||||
'',
|
'',
|
||||||
'**欢迎来到个人财务中心。** 我是您的财务助手,可以陪您完成票据识别、报销信息核对、待补项提醒和风险说明。',
|
'**欢迎来到个人财务中心 · 报销助手。** 我可以陪您完成报销发起、票据识别、草稿归集、报销信息核对、待补项提醒和风险说明,并保持报销对话独立记录。',
|
||||||
|
'',
|
||||||
|
'业务范围:发起报销、票据识别、草稿归集、报销状态查询和报销信息核对。申请、审核和制度问答请切换到对应助手。',
|
||||||
'',
|
'',
|
||||||
'您可以描述一笔费用、上传票据,或点击下方快捷操作直接开始。'
|
'您可以描述一笔费用、上传票据,或点击下方快捷操作直接开始。'
|
||||||
].join('\n')
|
].join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildWelcomeInsight(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) {
|
export function buildWelcomeInsight(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) {
|
||||||
|
const normalizedSessionType = normalizeAssistantSessionType(sessionType)
|
||||||
const ctx = buildWelcomeUserContext(user || {})
|
const ctx = buildWelcomeUserContext(user || {})
|
||||||
|
|
||||||
if (sessionType === SESSION_TYPE_KNOWLEDGE) {
|
if (normalizedSessionType === SESSION_TYPE_KNOWLEDGE) {
|
||||||
return {
|
return {
|
||||||
intent: 'welcome',
|
intent: 'welcome',
|
||||||
metricLabel: '今日',
|
metricLabel: '今日',
|
||||||
@@ -476,11 +602,36 @@ export function buildWelcomeInsight(entrySource, linkedRequest, sessionType = SE
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (normalizedSessionType === SESSION_TYPE_APPLICATION) {
|
||||||
|
return {
|
||||||
|
intent: 'welcome',
|
||||||
|
metricLabel: '当前助手',
|
||||||
|
metricValue: '申请助手',
|
||||||
|
title: '申请助手',
|
||||||
|
summary: `${ctx.honorific},这里会单独保存费用申请相关对话,不会混入报销、审核或知识问答记录。`,
|
||||||
|
agent: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedSessionType === SESSION_TYPE_APPROVAL) {
|
||||||
|
return {
|
||||||
|
intent: 'welcome',
|
||||||
|
metricLabel: '当前助手',
|
||||||
|
metricValue: '审核助手',
|
||||||
|
title: '审核助手',
|
||||||
|
summary: `${ctx.honorific},这里会单独保存审核相关对话,适合查询待审单据、风险点和审核意见。`,
|
||||||
|
agent: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
intent: 'welcome',
|
intent: 'welcome',
|
||||||
metricLabel: '助手状态',
|
metricLabel: '当前助手',
|
||||||
metricValue: '待您吩咐',
|
metricValue: '报销助手',
|
||||||
title: entrySource === 'detail' && linkedRequest?.id ? `已关联 ${linkedRequest.id}` : '个人财务中心',
|
title:
|
||||||
|
entrySource === 'detail' && linkedRequest?.id
|
||||||
|
? `已关联 ${linkedRequest.id}`
|
||||||
|
: '报销助手',
|
||||||
summary:
|
summary:
|
||||||
entrySource === 'detail' && linkedRequest?.id
|
entrySource === 'detail' && linkedRequest?.id
|
||||||
? `${ctx.honorific},发送消息或上传附件后,我会结合当前单据继续识别并提示待补项。`
|
? `${ctx.honorific},发送消息或上传附件后,我会结合当前单据继续识别并提示待补项。`
|
||||||
@@ -497,10 +648,10 @@ export function createWelcomeAssistantMessage(entrySource, linkedRequest, sessio
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveInitialSessionType(conversation) {
|
export function resolveInitialSessionType(conversation, fallback = SESSION_TYPE_EXPENSE) {
|
||||||
const stateJson = conversation?.state_json || conversation?.stateJson || {}
|
const stateJson = conversation?.state_json || conversation?.stateJson || {}
|
||||||
const sessionType = String(stateJson?.session_type || '').trim()
|
const sessionType = String(stateJson?.session_type || '').trim()
|
||||||
return sessionType || SESSION_TYPE_EXPENSE
|
return normalizeAssistantSessionType(sessionType, fallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildInitialInsightFromConversation(conversation) {
|
export function buildInitialInsightFromConversation(conversation) {
|
||||||
|
|||||||
@@ -49,6 +49,32 @@ export function normalizeExpenseType(value) {
|
|||||||
return String(value || '').trim() || 'other'
|
return String(value || '').trim() || 'other'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isApplicationDocumentRequest(request) {
|
||||||
|
const documentType = String(
|
||||||
|
request?.documentTypeCode
|
||||||
|
|| request?.document_type_code
|
||||||
|
|| request?.documentType
|
||||||
|
|| request?.document_type
|
||||||
|
|| ''
|
||||||
|
).trim()
|
||||||
|
const claimNo = String(
|
||||||
|
request?.claimNo
|
||||||
|
|| request?.claim_no
|
||||||
|
|| request?.documentNo
|
||||||
|
|| request?.id
|
||||||
|
|| ''
|
||||||
|
).trim().toUpperCase()
|
||||||
|
const typeCode = normalizeExpenseType(request?.typeCode || request?.expense_type)
|
||||||
|
|
||||||
|
return (
|
||||||
|
documentType === 'application'
|
||||||
|
|| documentType === 'expense_application'
|
||||||
|
|| claimNo.startsWith('APP-')
|
||||||
|
|| typeCode === 'application'
|
||||||
|
|| typeCode.endsWith('_application')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveExpenseTypeLabel(value) {
|
export function resolveExpenseTypeLabel(value) {
|
||||||
const normalized = normalizeExpenseType(value)
|
const normalized = normalizeExpenseType(value)
|
||||||
return EXPENSE_TYPE_OPTIONS.find((option) => option.value === normalized)?.label
|
return EXPENSE_TYPE_OPTIONS.find((option) => option.value === normalized)?.label
|
||||||
@@ -131,7 +157,41 @@ export function resolveExpenseDescriptionDetail(itemType, itemLocation) {
|
|||||||
return resolveLocationDisplay(itemLocation, itemType)
|
return resolveLocationDisplay(itemLocation, itemType)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildFallbackProgressSteps() {
|
export function buildFallbackProgressSteps(requestModel = {}) {
|
||||||
|
if (isApplicationDocumentRequest(requestModel)) {
|
||||||
|
const node = String(requestModel?.node || requestModel?.workflowNode || requestModel?.approvalStage || '').trim()
|
||||||
|
const approvalKey = String(requestModel?.approvalKey || '').trim()
|
||||||
|
const completed = approvalKey === 'completed' || /审批完成|申请完成|已完成/.test(node)
|
||||||
|
const inLeaderApproval = approvalKey === 'in_progress' || /直属领导|领导审批|审批中/.test(node)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
label: '创建申请',
|
||||||
|
time: completed || inLeaderApproval ? '已完成' : '进行中',
|
||||||
|
done: completed || inLeaderApproval,
|
||||||
|
active: true,
|
||||||
|
current: !(completed || inLeaderApproval)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 2,
|
||||||
|
label: '直属领导审批',
|
||||||
|
time: completed ? '已完成' : inLeaderApproval ? '进行中' : '待处理',
|
||||||
|
done: completed,
|
||||||
|
active: completed || inLeaderApproval,
|
||||||
|
current: !completed && inLeaderApproval
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 3,
|
||||||
|
label: '审批完成',
|
||||||
|
time: completed ? '已完成' : '待处理',
|
||||||
|
done: completed,
|
||||||
|
active: completed,
|
||||||
|
current: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ index: 1, label: '创建单据', time: '已完成', done: true, active: true },
|
{ index: 1, label: '创建单据', time: '已完成', done: true, active: true },
|
||||||
{ index: 2, label: '待提交', time: '进行中', active: true, current: true },
|
{ index: 2, label: '待提交', time: '进行中', active: true, current: true },
|
||||||
@@ -143,6 +203,10 @@ export function buildFallbackProgressSteps() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildFallbackExpenseItems(request) {
|
export function buildFallbackExpenseItems(request) {
|
||||||
|
if (isApplicationDocumentRequest(request)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
buildExpenseItemViewModel({
|
buildExpenseItemViewModel({
|
||||||
id: 'fallback-1',
|
id: 'fallback-1',
|
||||||
@@ -413,6 +477,10 @@ export function buildExpenseDraftIssues(item) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildOptionalTravelReceiptRiskCards(requestModel, items) {
|
export function buildOptionalTravelReceiptRiskCards(requestModel, items) {
|
||||||
|
if (isApplicationDocumentRequest(requestModel)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedItems = Array.isArray(items) ? items : []
|
const normalizedItems = Array.isArray(items) ? items : []
|
||||||
const isTravelContext =
|
const isTravelContext =
|
||||||
requestModel?.detailVariant === 'travel' ||
|
requestModel?.detailVariant === 'travel' ||
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ function stripBusinessTimePrefix(text) {
|
|||||||
|
|
||||||
function resolveDestinationFromText(text) {
|
function resolveDestinationFromText(text) {
|
||||||
const normalized = normalizeComposerText(text).replace(/\s+/g, '')
|
const normalized = normalizeComposerText(text).replace(/\s+/g, '')
|
||||||
const targetMatch = normalized.match(/(?:去|到|赴|前往)([^,,。;;]+)/u)
|
const targetMatch = normalized.match(/(?:出差|去|到|赴|前往)([^,,。;;]+)/u)
|
||||||
const targetText = String(targetMatch?.[1] || '').trim()
|
const targetText = String(targetMatch?.[1] || '').trim()
|
||||||
if (!targetText) {
|
if (!targetText) {
|
||||||
return ''
|
return ''
|
||||||
@@ -117,7 +117,7 @@ function resolveTripDaysFromText(text, businessTimeContext) {
|
|||||||
|
|
||||||
function resolveReasonFromText(text, destination) {
|
function resolveReasonFromText(text, destination) {
|
||||||
let reason = normalizeComposerText(text)
|
let reason = normalizeComposerText(text)
|
||||||
.replace(/^(?:去|到|赴|前往)\s*/u, '')
|
.replace(/^(?:出差|去|到|赴|前往)\s*/u, '')
|
||||||
.trim()
|
.trim()
|
||||||
|
|
||||||
if (destination && reason.startsWith(destination)) {
|
if (destination && reason.startsWith(destination)) {
|
||||||
|
|||||||
@@ -459,7 +459,21 @@ export function useTravelReimbursementFlow({
|
|||||||
detail: '正在根据当前票据新建报销草稿...'
|
detail: '正在根据当前票据新建报销草稿...'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const config = configs[reviewAction] || {
|
const defaultConfigBySessionType = {
|
||||||
|
application: {
|
||||||
|
key: 'application-review-preview',
|
||||||
|
title: '申请信息核对',
|
||||||
|
tool: 'user_agent.application_review_preview',
|
||||||
|
detail: '正在整理申请事项和待补充信息...'
|
||||||
|
},
|
||||||
|
approval: {
|
||||||
|
key: 'approval-review-preview',
|
||||||
|
title: '审核信息核对',
|
||||||
|
tool: 'user_agent.approval_review_preview',
|
||||||
|
detail: '正在整理待审核单据、风险点和审核建议...'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const config = configs[reviewAction] || defaultConfigBySessionType[String(activeSessionType.value || '').trim()] || {
|
||||||
key: 'expense-review-preview',
|
key: 'expense-review-preview',
|
||||||
title: '报销信息核对',
|
title: '报销信息核对',
|
||||||
tool: 'user_agent.expense_review_preview',
|
tool: 'user_agent.expense_review_preview',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { nextTick, ref } from 'vue'
|
import { nextTick, ref } from 'vue'
|
||||||
|
|
||||||
import { clearUserConversations, fetchLatestConversation } from '../../services/orchestrator.js'
|
import { fetchLatestConversation } from '../../services/orchestrator.js'
|
||||||
import {
|
import {
|
||||||
clearAssistantSessionSnapshot,
|
clearAssistantSessionSnapshot,
|
||||||
readAssistantSessionSnapshot,
|
readAssistantSessionSnapshot,
|
||||||
@@ -11,8 +11,9 @@ import {
|
|||||||
filterPersistableFilePreviews
|
filterPersistableFilePreviews
|
||||||
} from './travelReimbursementAttachmentModel.js'
|
} from './travelReimbursementAttachmentModel.js'
|
||||||
import {
|
import {
|
||||||
|
ASSISTANT_SESSION_TYPES,
|
||||||
|
SESSION_TYPE_APPLICATION,
|
||||||
SESSION_TYPE_EXPENSE,
|
SESSION_TYPE_EXPENSE,
|
||||||
SESSION_TYPE_KNOWLEDGE,
|
|
||||||
buildInitialInsightFromConversation,
|
buildInitialInsightFromConversation,
|
||||||
buildWelcomeInsight,
|
buildWelcomeInsight,
|
||||||
buildWelcomeQuickActions,
|
buildWelcomeQuickActions,
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
hasMeaningfulSessionMessages,
|
hasMeaningfulSessionMessages,
|
||||||
normalizeInitialConversationMessages,
|
normalizeInitialConversationMessages,
|
||||||
normalizeSnapshotMessages,
|
normalizeSnapshotMessages,
|
||||||
|
normalizeAssistantSessionType,
|
||||||
resolveInitialConversationId,
|
resolveInitialConversationId,
|
||||||
resolveInitialDraftClaimId,
|
resolveInitialDraftClaimId,
|
||||||
resolveInitialSessionType,
|
resolveInitialSessionType,
|
||||||
@@ -41,6 +43,10 @@ export function useTravelReimbursementSessionState({
|
|||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
getSessionRuntimeRefs = () => ({})
|
getSessionRuntimeRefs = () => ({})
|
||||||
}) {
|
}) {
|
||||||
|
function resolveDefaultSessionTypeFromEntry() {
|
||||||
|
return props.entrySource === 'application' ? SESSION_TYPE_APPLICATION : SESSION_TYPE_EXPENSE
|
||||||
|
}
|
||||||
|
|
||||||
function refreshWelcomeQuickActions(messages, sessionType) {
|
function refreshWelcomeQuickActions(messages, sessionType) {
|
||||||
if (!Array.isArray(messages) || !messages.length) {
|
if (!Array.isArray(messages) || !messages.length) {
|
||||||
return []
|
return []
|
||||||
@@ -58,8 +64,8 @@ export function useTravelReimbursementSessionState({
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildConversationSessionState(conversation, fallbackSessionType = SESSION_TYPE_EXPENSE) {
|
function buildConversationSessionState(conversation, fallbackSessionType = resolveDefaultSessionTypeFromEntry()) {
|
||||||
const sessionType = resolveInitialSessionType(conversation) || fallbackSessionType
|
const sessionType = resolveInitialSessionType(conversation, fallbackSessionType)
|
||||||
const restoredMessages = refreshWelcomeQuickActions(normalizeInitialConversationMessages(conversation), sessionType)
|
const restoredMessages = refreshWelcomeQuickActions(normalizeInitialConversationMessages(conversation), sessionType)
|
||||||
const initialInsight = buildInitialInsightFromConversation(conversation)
|
const initialInsight = buildInitialInsightFromConversation(conversation)
|
||||||
const restoredReviewFilePreviews = buildReviewFilePreviewsFromMessages(restoredMessages)
|
const restoredReviewFilePreviews = buildReviewFilePreviewsFromMessages(restoredMessages)
|
||||||
@@ -84,17 +90,18 @@ export function useTravelReimbursementSessionState({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildEmptySessionState(sessionType) {
|
function buildEmptySessionState(sessionType) {
|
||||||
|
const normalizedSessionType = normalizeAssistantSessionType(sessionType, resolveDefaultSessionTypeFromEntry())
|
||||||
return {
|
return {
|
||||||
sessionType,
|
sessionType: normalizedSessionType,
|
||||||
messages: [
|
messages: [
|
||||||
createWelcomeAssistantMessage(props.entrySource, linkedRequest.value, sessionType, currentUser.value)
|
createWelcomeAssistantMessage(props.entrySource, linkedRequest.value, normalizedSessionType, currentUser.value)
|
||||||
],
|
],
|
||||||
conversationId: '',
|
conversationId: '',
|
||||||
draftClaimId: '',
|
draftClaimId: '',
|
||||||
currentInsight: buildWelcomeInsight(
|
currentInsight: buildWelcomeInsight(
|
||||||
props.entrySource,
|
props.entrySource,
|
||||||
linkedRequest.value,
|
linkedRequest.value,
|
||||||
sessionType,
|
normalizedSessionType,
|
||||||
currentUser.value
|
currentUser.value
|
||||||
),
|
),
|
||||||
reviewFilePreviews: [],
|
reviewFilePreviews: [],
|
||||||
@@ -107,13 +114,16 @@ export function useTravelReimbursementSessionState({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPersistedSessionState(snapshot, fallbackSessionType = SESSION_TYPE_EXPENSE) {
|
function buildPersistedSessionState(snapshot, fallbackSessionType = resolveDefaultSessionTypeFromEntry()) {
|
||||||
const state = snapshot?.state && typeof snapshot.state === 'object' ? snapshot.state : null
|
const state = snapshot?.state && typeof snapshot.state === 'object' ? snapshot.state : null
|
||||||
if (!state) {
|
if (!state) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionType = String(state.sessionType || snapshot.sessionType || fallbackSessionType || '').trim() || SESSION_TYPE_EXPENSE
|
const sessionType = normalizeAssistantSessionType(
|
||||||
|
state.sessionType || snapshot.sessionType || fallbackSessionType,
|
||||||
|
fallbackSessionType
|
||||||
|
)
|
||||||
const restoredMessages = refreshWelcomeQuickActions(normalizeSnapshotMessages(state.messages), sessionType)
|
const restoredMessages = refreshWelcomeQuickActions(normalizeSnapshotMessages(state.messages), sessionType)
|
||||||
if (
|
if (
|
||||||
!hasMeaningfulSessionMessages(restoredMessages)
|
!hasMeaningfulSessionMessages(restoredMessages)
|
||||||
@@ -148,13 +158,16 @@ export function useTravelReimbursementSessionState({
|
|||||||
return String(user.username || user.name || 'anonymous').trim() || 'anonymous'
|
return String(user.username || user.name || 'anonymous').trim() || 'anonymous'
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialSessionType = resolveInitialSessionType(props.initialConversation)
|
const defaultInitialSessionType = resolveDefaultSessionTypeFromEntry()
|
||||||
|
const initialSessionType = props.initialConversation
|
||||||
|
? resolveInitialSessionType(props.initialConversation, defaultInitialSessionType)
|
||||||
|
: defaultInitialSessionType
|
||||||
const shouldPersistLocalSnapshot = props.entrySource !== 'detail'
|
const shouldPersistLocalSnapshot = props.entrySource !== 'detail'
|
||||||
const conversationInitialState = props.initialConversation
|
const conversationInitialState = props.initialConversation
|
||||||
? buildConversationSessionState(props.initialConversation, initialSessionType)
|
? buildConversationSessionState(props.initialConversation, initialSessionType)
|
||||||
: buildEmptySessionState(initialSessionType)
|
: buildEmptySessionState(initialSessionType)
|
||||||
const canRestorePersistedInitialState =
|
const canRestorePersistedInitialState =
|
||||||
props.entrySource === 'workbench'
|
shouldPersistLocalSnapshot
|
||||||
&& !String(props.initialPrompt || '').trim()
|
&& !String(props.initialPrompt || '').trim()
|
||||||
&& !props.initialFiles.length
|
&& !props.initialFiles.length
|
||||||
const persistedInitialSnapshot = readAssistantSessionSnapshot(resolveCurrentUserId(), initialSessionType)
|
const persistedInitialSnapshot = readAssistantSessionSnapshot(resolveCurrentUserId(), initialSessionType)
|
||||||
@@ -174,21 +187,22 @@ export function useTravelReimbursementSessionState({
|
|||||||
const conversationId = ref(initialSessionState.conversationId)
|
const conversationId = ref(initialSessionState.conversationId)
|
||||||
const draftClaimId = ref(initialSessionState.draftClaimId)
|
const draftClaimId = ref(initialSessionState.draftClaimId)
|
||||||
const reviewFilePreviews = ref(initialSessionState.reviewFilePreviews)
|
const reviewFilePreviews = ref(initialSessionState.reviewFilePreviews)
|
||||||
const sessionSnapshots = ref({
|
const sessionSnapshots = ref(
|
||||||
[SESSION_TYPE_EXPENSE]: null,
|
ASSISTANT_SESSION_TYPES.reduce((result, sessionType) => {
|
||||||
[SESSION_TYPE_KNOWLEDGE]: null
|
result[sessionType] = null
|
||||||
})
|
return result
|
||||||
|
}, {})
|
||||||
|
)
|
||||||
const currentInsight = ref(initialSessionState.currentInsight)
|
const currentInsight = ref(initialSessionState.currentInsight)
|
||||||
const composerUploadIntent = ref(String(initialSessionState.composerUploadIntent || '').trim())
|
const composerUploadIntent = ref(String(initialSessionState.composerUploadIntent || '').trim())
|
||||||
const guidedFlowState = ref(normalizeGuidedFlowState(initialSessionState.guidedFlowState))
|
const guidedFlowState = ref(normalizeGuidedFlowState(initialSessionState.guidedFlowState))
|
||||||
const insightPanelCollapsed = ref(false)
|
const insightPanelCollapsed = ref(false)
|
||||||
const sessionSwitchBusy = ref(false)
|
const sessionSwitchBusy = ref(false)
|
||||||
let knowledgeSessionResetPromise = Promise.resolve()
|
|
||||||
|
|
||||||
function buildPersistableSessionState(sessionState) {
|
function buildPersistableSessionState(sessionState) {
|
||||||
const state = sessionState || captureCurrentSessionState()
|
const state = sessionState || captureCurrentSessionState()
|
||||||
return {
|
return {
|
||||||
sessionType: state.sessionType || SESSION_TYPE_EXPENSE,
|
sessionType: normalizeAssistantSessionType(state.sessionType, resolveDefaultSessionTypeFromEntry()),
|
||||||
messages: serializeSessionMessages(state.messages),
|
messages: serializeSessionMessages(state.messages),
|
||||||
conversationId: String(state.conversationId || '').trim(),
|
conversationId: String(state.conversationId || '').trim(),
|
||||||
draftClaimId: String(state.draftClaimId || '').trim(),
|
draftClaimId: String(state.draftClaimId || '').trim(),
|
||||||
@@ -244,7 +258,7 @@ export function useTravelReimbursementSessionState({
|
|||||||
function applySessionState(sessionState) {
|
function applySessionState(sessionState) {
|
||||||
const runtimeRefs = getSessionRuntimeRefs()
|
const runtimeRefs = getSessionRuntimeRefs()
|
||||||
const nextState = sessionState || buildEmptySessionState(activeSessionType.value)
|
const nextState = sessionState || buildEmptySessionState(activeSessionType.value)
|
||||||
activeSessionType.value = nextState.sessionType || SESSION_TYPE_EXPENSE
|
activeSessionType.value = normalizeAssistantSessionType(nextState.sessionType, resolveDefaultSessionTypeFromEntry())
|
||||||
messages.value = Array.isArray(nextState.messages) && nextState.messages.length
|
messages.value = Array.isArray(nextState.messages) && nextState.messages.length
|
||||||
? nextState.messages
|
? nextState.messages
|
||||||
: [
|
: [
|
||||||
@@ -287,38 +301,18 @@ export function useTravelReimbursementSessionState({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadLatestSessionState(targetSessionType) {
|
async function loadLatestSessionState(targetSessionType) {
|
||||||
const payload = await fetchLatestConversation(resolveCurrentUserId(), targetSessionType, {
|
const normalizedTarget = normalizeAssistantSessionType(targetSessionType, resolveDefaultSessionTypeFromEntry())
|
||||||
preferRecoverable: targetSessionType === SESSION_TYPE_EXPENSE
|
const payload = await fetchLatestConversation(resolveCurrentUserId(), normalizedTarget, {
|
||||||
|
preferRecoverable: normalizedTarget === SESSION_TYPE_EXPENSE
|
||||||
})
|
})
|
||||||
if (payload?.found && payload.conversation) {
|
if (payload?.found && payload.conversation) {
|
||||||
return buildConversationSessionState(payload.conversation, targetSessionType)
|
return buildConversationSessionState(payload.conversation, normalizedTarget)
|
||||||
}
|
}
|
||||||
return buildEmptySessionState(targetSessionType)
|
return buildEmptySessionState(normalizedTarget)
|
||||||
}
|
|
||||||
|
|
||||||
function resetKnowledgeSessionSnapshot() {
|
|
||||||
const emptyKnowledgeState = buildEmptySessionState(SESSION_TYPE_KNOWLEDGE)
|
|
||||||
sessionSnapshots.value[SESSION_TYPE_KNOWLEDGE] = emptyKnowledgeState
|
|
||||||
|
|
||||||
if (activeSessionType.value === SESSION_TYPE_KNOWLEDGE) {
|
|
||||||
applySessionState(emptyKnowledgeState)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearKnowledgeSessionOnEntry() {
|
|
||||||
resetKnowledgeSessionSnapshot()
|
|
||||||
knowledgeSessionResetPromise = clearUserConversations(resolveCurrentUserId(), SESSION_TYPE_KNOWLEDGE)
|
|
||||||
.catch((error) => {
|
|
||||||
console.warn('Failed to clear knowledge session on entry:', error)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
resetKnowledgeSessionSnapshot()
|
|
||||||
})
|
|
||||||
return knowledgeSessionResetPromise
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function switchSessionType(targetSessionType) {
|
async function switchSessionType(targetSessionType) {
|
||||||
const normalizedTarget = String(targetSessionType || '').trim() || SESSION_TYPE_EXPENSE
|
const normalizedTarget = normalizeAssistantSessionType(targetSessionType, resolveDefaultSessionTypeFromEntry())
|
||||||
if (normalizedTarget === activeSessionType.value || sessionSwitchBusy.value) {
|
if (normalizedTarget === activeSessionType.value || sessionSwitchBusy.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -338,7 +332,7 @@ export function useTravelReimbursementSessionState({
|
|||||||
const emptyState = buildEmptySessionState(normalizedTarget)
|
const emptyState = buildEmptySessionState(normalizedTarget)
|
||||||
sessionSnapshots.value[normalizedTarget] = emptyState
|
sessionSnapshots.value[normalizedTarget] = emptyState
|
||||||
applySessionState(emptyState)
|
applySessionState(emptyState)
|
||||||
toast(error?.message || '?????????????????')
|
toast(error?.message || '切换助手失败,请稍后重试。')
|
||||||
} finally {
|
} finally {
|
||||||
sessionSwitchBusy.value = false
|
sessionSwitchBusy.value = false
|
||||||
}
|
}
|
||||||
@@ -368,8 +362,6 @@ export function useTravelReimbursementSessionState({
|
|||||||
captureCurrentSessionState,
|
captureCurrentSessionState,
|
||||||
applySessionState,
|
applySessionState,
|
||||||
loadLatestSessionState,
|
loadLatestSessionState,
|
||||||
resetKnowledgeSessionSnapshot,
|
|
||||||
clearKnowledgeSessionOnEntry,
|
|
||||||
switchSessionType
|
switchSessionType
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
buildAttachmentAssociationConfirmationMessage,
|
buildAttachmentAssociationConfirmationMessage,
|
||||||
buildUnsavedDraftAttachmentConfirmationMessage
|
buildUnsavedDraftAttachmentConfirmationMessage
|
||||||
} from './travelReimbursementAttachmentModel.js'
|
} from './travelReimbursementAttachmentModel.js'
|
||||||
|
import { resolveAssistantScopeGuard } from '../../utils/assistantSessionScope.js'
|
||||||
|
|
||||||
export function useTravelReimbursementSubmitComposer(ctx) {
|
export function useTravelReimbursementSubmitComposer(ctx) {
|
||||||
const {
|
const {
|
||||||
@@ -238,6 +239,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
|||||||
function buildBackendMessage(rawText, fileNames, ocrSummary = '') {
|
function buildBackendMessage(rawText, fileNames, ocrSummary = '') {
|
||||||
const parts = []
|
const parts = []
|
||||||
const normalizedText = String(rawText || '').trim()
|
const normalizedText = String(rawText || '').trim()
|
||||||
|
const sessionType = String(activeSessionType.value || '').trim()
|
||||||
|
|
||||||
if (normalizedText) {
|
if (normalizedText) {
|
||||||
parts.push(normalizedText)
|
parts.push(normalizedText)
|
||||||
@@ -245,7 +247,11 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
|||||||
parts.push(
|
parts.push(
|
||||||
isKnowledgeSession.value
|
isKnowledgeSession.value
|
||||||
? `我上传了 ${fileNames.length} 份附件,请结合附件名称回答财务相关问题。`
|
? `我上传了 ${fileNames.length} 份附件,请结合附件名称回答财务相关问题。`
|
||||||
: `我上传了 ${fileNames.length} 份票据,请结合附件名称给出报销建议并整理待核对信息。`
|
: sessionType === 'application'
|
||||||
|
? `我上传了 ${fileNames.length} 份附件,请结合附件名称整理费用申请建议和待核对信息。`
|
||||||
|
: sessionType === 'approval'
|
||||||
|
? `我上传了 ${fileNames.length} 份附件,请结合附件名称整理审核风险和处理建议。`
|
||||||
|
: `我上传了 ${fileNames.length} 份票据,请结合附件名称给出报销建议并整理待核对信息。`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,6 +364,30 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
|||||||
? `新上传 ${fileNames.length} 份票据,请单独建立报销单。`
|
? `新上传 ${fileNames.length} 份票据,请单独建立报销单。`
|
||||||
: `我上传了 ${fileNames.length} 份票据,请帮我识别并整理报销建议。`)
|
: `我上传了 ${fileNames.length} 份票据,请帮我识别并整理报销建议。`)
|
||||||
|
|
||||||
|
const scopeGuard = resolveAssistantScopeGuard(rawText, activeSessionType.value, {
|
||||||
|
attachmentCount: files.length,
|
||||||
|
hasActiveReviewPayload: Boolean(activeReviewPayload.value),
|
||||||
|
reviewAction
|
||||||
|
})
|
||||||
|
if (scopeGuard && !systemGenerated && !reviewAction && !options.skipScopeGuard) {
|
||||||
|
if (!options.skipUserMessage) {
|
||||||
|
messages.value.push(createMessage('user', userText, fileNames))
|
||||||
|
}
|
||||||
|
messages.value.push(createMessage('assistant', scopeGuard.text, [], {
|
||||||
|
meta: scopeGuard.meta,
|
||||||
|
suggestedActions: scopeGuard.suggestedActions
|
||||||
|
}))
|
||||||
|
composerDraft.value = ''
|
||||||
|
composerBusinessTimeTags.value = []
|
||||||
|
composerBusinessTimeDraftTouched.value = false
|
||||||
|
nextTick(() => {
|
||||||
|
adjustComposerTextareaHeight()
|
||||||
|
scrollToBottom()
|
||||||
|
})
|
||||||
|
persistSessionState()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const hasUnsavedReviewDraft = Boolean(
|
const hasUnsavedReviewDraft = Boolean(
|
||||||
!isKnowledgeSession.value &&
|
!isKnowledgeSession.value &&
|
||||||
files.length &&
|
files.length &&
|
||||||
@@ -521,7 +551,11 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
|||||||
options.pendingText || (
|
options.pendingText || (
|
||||||
isKnowledgeSession.value
|
isKnowledgeSession.value
|
||||||
? '正在整理财务知识答案...'
|
? '正在整理财务知识答案...'
|
||||||
: '正在识别并整理右侧核对信息...'
|
: activeSessionType.value === 'application'
|
||||||
|
? '正在识别并整理申请核对信息...'
|
||||||
|
: activeSessionType.value === 'approval'
|
||||||
|
? '正在查询审核上下文并整理风险提示...'
|
||||||
|
: '正在识别并整理右侧核对信息...'
|
||||||
),
|
),
|
||||||
[],
|
[],
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -98,3 +98,29 @@ test('detail topbar still flags real manual rows without required ticket info',
|
|||||||
assert.equal(hasPendingInfo(request), true)
|
assert.equal(hasPendingInfo(request), true)
|
||||||
assert.deepEqual(alerts, ['待提交', '缺少票据', '待补信息'])
|
assert.deepEqual(alerts, ['待提交', '缺少票据', '待补信息'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('application detail topbar does not ask for receipt attachments', () => {
|
||||||
|
const request = {
|
||||||
|
id: 'APP-20260525-ABC123',
|
||||||
|
claimNo: 'APP-20260525-ABC123',
|
||||||
|
documentTypeCode: 'application',
|
||||||
|
node: '直属领导审批',
|
||||||
|
approvalKey: 'in_progress',
|
||||||
|
typeCode: 'travel_application',
|
||||||
|
typeLabel: '差旅费用申请',
|
||||||
|
reason: '支撑国网服务器上线部署',
|
||||||
|
location: '上海',
|
||||||
|
city: '上海',
|
||||||
|
occurredDisplay: '2026-05-25 ~ 2026-05-28',
|
||||||
|
amountValue: 12000,
|
||||||
|
attachmentSummary: '申请单',
|
||||||
|
secondaryStatusValue: '已进入审批流程',
|
||||||
|
expenseItems: []
|
||||||
|
}
|
||||||
|
|
||||||
|
const alerts = buildDetailAlerts(request).map((item) => item.label)
|
||||||
|
|
||||||
|
assert.equal(hasMissingAttachment(request), false)
|
||||||
|
assert.equal(alerts.includes('缺少票据'), false)
|
||||||
|
assert.deepEqual(alerts, ['直属领导审批'])
|
||||||
|
})
|
||||||
|
|||||||
71
web/tests/app-shell-financial-assistant-entry.test.mjs
Normal file
71
web/tests/app-shell-financial-assistant-entry.test.mjs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
import {
|
||||||
|
SESSION_TYPE_APPLICATION,
|
||||||
|
SESSION_TYPE_EXPENSE,
|
||||||
|
SESSION_TYPE_KNOWLEDGE,
|
||||||
|
buildWelcomeInsight,
|
||||||
|
buildWelcomeMessage
|
||||||
|
} from '../src/views/scripts/travelReimbursementConversationModel.js'
|
||||||
|
|
||||||
|
const appShellRouteView = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/views/AppShellRouteView.vue', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
const appShellComposable = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
const assistantScript = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
const assistantTemplate = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
|
test('application and reimbursement entries open the same financial assistant modal', () => {
|
||||||
|
assert.match(appShellRouteView, /<TravelReimbursementCreateView[\s\S]*:entry-source="smartEntryContext\.source"/)
|
||||||
|
assert.match(appShellRouteView, /@create-request="openTravelCreate"/)
|
||||||
|
assert.match(appShellRouteView, /@create-application="openExpenseApplicationCreate"/)
|
||||||
|
assert.match(appShellRouteView, /@new-application="openExpenseApplicationCreate"/)
|
||||||
|
assert.doesNotMatch(appShellRouteView, /ExpenseApplicationDialog/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('application entry keeps its own assistant source without creating a separate dialog', () => {
|
||||||
|
assert.match(appShellComposable, /const SMART_ENTRY_SOURCE_APPLICATION = 'application'/)
|
||||||
|
assert.match(appShellComposable, /function openExpenseApplicationCreate\(\) \{[\s\S]*openFinancialAssistantCreate\(SMART_ENTRY_SOURCE_APPLICATION\)/)
|
||||||
|
assert.match(appShellComposable, /function openTravelCreate\(\) \{[\s\S]*openFinancialAssistantCreate\(SMART_ENTRY_SOURCE_REIMBURSEMENT\)/)
|
||||||
|
assert.match(appShellComposable, /openExpenseApplicationCreate,/)
|
||||||
|
assert.match(assistantScript, /activeSessionType\.value === SESSION_TYPE_APPLICATION[\s\S]*我想先申请一笔差旅费用/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('financial assistant toolbar renders four isolated assistant sessions', () => {
|
||||||
|
assert.match(assistantScript, /ASSISTANT_SESSION_MODE_OPTIONS\.map/)
|
||||||
|
assert.match(assistantScript, /targetSessionType:\s*mode\.key/)
|
||||||
|
assert.match(assistantScript, /active:\s*mode\.key === activeSessionType\.value/)
|
||||||
|
assert.match(assistantTemplate, /:class="\{ active: shortcut\.active \}"/)
|
||||||
|
assert.match(assistantTemplate, /:aria-pressed="shortcut\.active \? 'true' : 'false'"/)
|
||||||
|
assert.match(assistantTemplate, /:disabled="shortcut\.active \|\| submitting/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('financial assistant welcome copy differentiates application intent from reimbursement entry', () => {
|
||||||
|
const user = { name: '李文静', username: 'wenjing.li', grade: 'P5' }
|
||||||
|
const applicationWelcome = buildWelcomeMessage('application', null, SESSION_TYPE_APPLICATION, user)
|
||||||
|
const reimbursementWelcome = buildWelcomeMessage('topbar', null, SESSION_TYPE_EXPENSE, user)
|
||||||
|
const knowledgeWelcome = buildWelcomeMessage('topbar', null, SESSION_TYPE_KNOWLEDGE, user)
|
||||||
|
const applicationInsight = buildWelcomeInsight('application', null, SESSION_TYPE_APPLICATION, user)
|
||||||
|
|
||||||
|
assert.match(applicationWelcome, /申请助手/)
|
||||||
|
assert.match(applicationWelcome, /费用申请、报销申请还是其他财务事项/)
|
||||||
|
assert.match(reimbursementWelcome, /报销助手/)
|
||||||
|
assert.match(reimbursementWelcome, /报销发起、票据识别、草稿归集、报销信息核对/)
|
||||||
|
assert.match(knowledgeWelcome, /财务知识助手/)
|
||||||
|
assert.notEqual(applicationWelcome, reimbursementWelcome)
|
||||||
|
assert.equal(applicationInsight.metricValue, '申请助手')
|
||||||
|
assert.equal(applicationInsight.title, '申请助手')
|
||||||
|
})
|
||||||
@@ -90,12 +90,12 @@ test('saving a draft keeps the financial assistant open for continued work', ()
|
|||||||
|
|
||||||
assert.ok(handleDraftSavedBlock)
|
assert.ok(handleDraftSavedBlock)
|
||||||
assert.match(handleDraftSavedBlock, /if \(status === 'submitted'\) \{[\s\S]*smartEntryOpen\.value = false/)
|
assert.match(handleDraftSavedBlock, /if \(status === 'submitted'\) \{[\s\S]*smartEntryOpen\.value = false/)
|
||||||
assert.match(handleDraftSavedBlock, /if \(status === 'submitted'\) \{[\s\S]*router\.push\(\{ name: 'app-requests' \}\)/)
|
assert.match(handleDraftSavedBlock, /if \(status === 'submitted'\) \{[\s\S]*router\.push\(\{ name: activeView\.value === 'documents' \? 'app-documents' : 'app-requests' \}\)/)
|
||||||
assert.match(handleDraftSavedBlock, /return[\s\S]*单据已保存为草稿,可继续上传票据或补充信息。/)
|
assert.match(handleDraftSavedBlock, /return[\s\S]*单据已保存为草稿,可继续上传票据或补充信息。/)
|
||||||
|
|
||||||
const draftSuccessIndex = handleDraftSavedBlock.indexOf('单据已保存为草稿,可继续上传票据或补充信息。')
|
const draftSuccessIndex = handleDraftSavedBlock.indexOf('单据已保存为草稿,可继续上传票据或补充信息。')
|
||||||
assert.equal(handleDraftSavedBlock.indexOf('smartEntryOpen.value = false', draftSuccessIndex), -1)
|
assert.equal(handleDraftSavedBlock.indexOf('smartEntryOpen.value = false', draftSuccessIndex), -1)
|
||||||
assert.equal(handleDraftSavedBlock.indexOf("router.push({ name: 'app-requests' })", draftSuccessIndex), -1)
|
assert.equal(handleDraftSavedBlock.indexOf("router.push({ name: activeView.value === 'documents' ? 'app-documents' : 'app-requests' })", draftSuccessIndex), -1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('detail smart entry is scoped to the current claim instead of the latest conversation', () => {
|
test('detail smart entry is scoped to the current claim instead of the latest conversation', () => {
|
||||||
|
|||||||
43
web/tests/assistant-suggested-action-prefill.test.mjs
Normal file
43
web/tests/assistant-suggested-action-prefill.test.mjs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
mergeComposerPrefill,
|
||||||
|
resolveSuggestedActionPrefill
|
||||||
|
} from '../src/utils/assistantSuggestedActionPrefill.js'
|
||||||
|
|
||||||
|
test('suggested action prefill uses backend prompt payload', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveSuggestedActionPrefill({
|
||||||
|
action_type: 'prefill_composer',
|
||||||
|
payload: {
|
||||||
|
application_field: 'time',
|
||||||
|
prompt_prefill: '申请时间段:'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
'申请时间段:'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('suggested action prefill falls back to application field templates', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveSuggestedActionPrefill({
|
||||||
|
action_type: 'prefill_composer',
|
||||||
|
payload: { application_field: 'amount' }
|
||||||
|
}),
|
||||||
|
'预计总费用:'
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
resolveSuggestedActionPrefill({
|
||||||
|
action_type: 'ask_clarification',
|
||||||
|
payload: { application_field: 'amount' }
|
||||||
|
}),
|
||||||
|
''
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('composer prefill appends to existing draft without duplication', () => {
|
||||||
|
assert.equal(mergeComposerPrefill('', '事由:'), '事由:')
|
||||||
|
assert.equal(mergeComposerPrefill('地点:上海', '事由:'), '地点:上海\n事由:')
|
||||||
|
assert.equal(mergeComposerPrefill('地点:上海\n事由:', '事由:'), '地点:上海\n事由:')
|
||||||
|
})
|
||||||
60
web/tests/document-center-new-state.test.mjs
Normal file
60
web/tests/document-center-new-state.test.mjs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
countNewDocuments,
|
||||||
|
isNewDocument,
|
||||||
|
markDocumentViewed,
|
||||||
|
readDocumentScope,
|
||||||
|
readViewedDocumentKeys,
|
||||||
|
resolveDocumentNewKey,
|
||||||
|
writeDocumentScope
|
||||||
|
} from '../src/utils/documentCenterNewState.js'
|
||||||
|
|
||||||
|
function createMemoryStorage(initial = {}) {
|
||||||
|
const store = new Map(Object.entries(initial))
|
||||||
|
return {
|
||||||
|
getItem(key) {
|
||||||
|
return store.has(key) ? store.get(key) : null
|
||||||
|
},
|
||||||
|
setItem(key, value) {
|
||||||
|
store.set(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('document center new state resolves source scoped document keys', () => {
|
||||||
|
assert.equal(resolveDocumentNewKey({ source: 'archive', claimId: 'claim-1' }), 'archive:claim-1')
|
||||||
|
assert.equal(resolveDocumentNewKey({ source: 'approval', documentNo: 'EXP-1' }), 'approval:EXP-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('document center new state counts unseen documents and persists viewed rows', () => {
|
||||||
|
const storage = createMemoryStorage()
|
||||||
|
const rows = [
|
||||||
|
{ source: 'archive', claimId: 'claim-1' },
|
||||||
|
{ source: 'archive', claimId: 'claim-2' }
|
||||||
|
]
|
||||||
|
let viewedKeys = readViewedDocumentKeys(storage)
|
||||||
|
|
||||||
|
assert.equal(countNewDocuments(rows, viewedKeys), 2)
|
||||||
|
assert.equal(isNewDocument(rows[0], viewedKeys), true)
|
||||||
|
|
||||||
|
viewedKeys = markDocumentViewed(rows[0], viewedKeys, storage)
|
||||||
|
|
||||||
|
assert.equal(countNewDocuments(rows, viewedKeys), 1)
|
||||||
|
assert.equal(isNewDocument(rows[0], viewedKeys), false)
|
||||||
|
assert.deepEqual([...readViewedDocumentKeys(storage)], ['archive:claim-1'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('document center scope state restores only allowed tabs', () => {
|
||||||
|
const storage = createMemoryStorage()
|
||||||
|
const scopes = ['全部', '申请单', '报销单', '审核单', '归档']
|
||||||
|
|
||||||
|
assert.equal(readDocumentScope('全部', scopes, storage), '全部')
|
||||||
|
|
||||||
|
writeDocumentScope('归档', scopes, storage)
|
||||||
|
assert.equal(readDocumentScope('全部', scopes, storage), '归档')
|
||||||
|
|
||||||
|
writeDocumentScope('不存在', scopes, storage)
|
||||||
|
assert.equal(readDocumentScope('全部', scopes, storage), '归档')
|
||||||
|
})
|
||||||
@@ -25,24 +25,28 @@ test('documents center keeps only the top scope tabs and renders status as a dro
|
|||||||
assert.match(documentsCenterView, /@click="selectStatusTab\(option\.value\)"/)
|
assert.match(documentsCenterView, /@click="selectStatusTab\(option\.value\)"/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('documents center top tabs start from application and show document category labels', () => {
|
test('documents center top tabs start from all and show document category labels', () => {
|
||||||
assert.doesNotMatch(documentsCenterView, /const DOCUMENT_SCOPE_ALL = '全部'/)
|
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_ALL = '全部'/)
|
||||||
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_APPLICATION = '申请单'/)
|
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_APPLICATION = '申请单'/)
|
||||||
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_REIMBURSEMENT = '报销单'/)
|
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_REIMBURSEMENT = '报销单'/)
|
||||||
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_REVIEW = '审核单'/)
|
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_REVIEW = '审核单'/)
|
||||||
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_ARCHIVE = '归档'/)
|
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_ARCHIVE = '归档'/)
|
||||||
assert.match(documentsCenterView, /const activeScopeTab = ref\(DOCUMENT_SCOPE_APPLICATION\)/)
|
assert.match(documentsCenterView, /const activeScopeTab = ref\(readDocumentScope\(DOCUMENT_SCOPE_ALL, scopeTabs\)\)/)
|
||||||
assert.match(
|
assert.match(
|
||||||
documentsCenterView,
|
documentsCenterView,
|
||||||
/const scopeTabs = \[[\s\S]*DOCUMENT_SCOPE_APPLICATION[\s\S]*DOCUMENT_SCOPE_REIMBURSEMENT[\s\S]*DOCUMENT_SCOPE_REVIEW[\s\S]*DOCUMENT_SCOPE_ARCHIVE[\s\S]*\]/
|
/const scopeTabs = \[[\s\S]*DOCUMENT_SCOPE_ALL[\s\S]*DOCUMENT_SCOPE_APPLICATION[\s\S]*DOCUMENT_SCOPE_REIMBURSEMENT[\s\S]*DOCUMENT_SCOPE_REVIEW[\s\S]*DOCUMENT_SCOPE_ARCHIVE[\s\S]*\]/
|
||||||
)
|
)
|
||||||
assert.doesNotMatch(documentsCenterView, /DOCUMENT_SCOPE_ALL/)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('documents center category tabs map to the intended row sources', () => {
|
test('documents center category tabs map to the intended row sources', () => {
|
||||||
|
assert.match(documentsCenterView, /const nonArchivedRows = computed\(\(\) => mergeDocumentRows\(\[\.\.\.ownedRows\.value, \.\.\.approvalRows\.value\]\)\)/)
|
||||||
assert.match(
|
assert.match(
|
||||||
documentsCenterView,
|
documentsCenterView,
|
||||||
/activeScopeTab\.value === DOCUMENT_SCOPE_APPLICATION[\s\S]*row\.documentTypeCode === DOCUMENT_TYPE_APPLICATION/
|
/activeScopeTab\.value === DOCUMENT_SCOPE_ALL[\s\S]*return nonArchivedRows\.value/
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
documentsCenterView,
|
||||||
|
/activeScopeTab\.value === DOCUMENT_SCOPE_APPLICATION[\s\S]*nonArchivedRows\.value\.filter\(\(row\) => row\.documentTypeCode === DOCUMENT_TYPE_APPLICATION/
|
||||||
)
|
)
|
||||||
assert.match(
|
assert.match(
|
||||||
documentsCenterView,
|
documentsCenterView,
|
||||||
@@ -56,7 +60,22 @@ test('documents center category tabs map to the intended row sources', () => {
|
|||||||
documentsCenterView,
|
documentsCenterView,
|
||||||
/activeScopeTab\.value === DOCUMENT_SCOPE_ARCHIVE[\s\S]*return archiveRows\.value/
|
/activeScopeTab\.value === DOCUMENT_SCOPE_ARCHIVE[\s\S]*return archiveRows\.value/
|
||||||
)
|
)
|
||||||
assert.match(documentsCenterView, /return allSummaryRows\.value\.filter\(\(row\) => row\.documentTypeCode === DOCUMENT_TYPE_APPLICATION\)/)
|
assert.match(documentsCenterView, /return nonArchivedRows\.value/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('documents center preserves application document type from mapped requests', () => {
|
||||||
|
assert.match(
|
||||||
|
documentsCenterView,
|
||||||
|
/const documentTypeCode = normalized\.documentTypeCode \|\| DOCUMENT_TYPE_REIMBURSEMENT/
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
documentsCenterView,
|
||||||
|
/documentTypeCode === DOCUMENT_TYPE_APPLICATION \? '申请单' : '报销单'/
|
||||||
|
)
|
||||||
|
assert.doesNotMatch(
|
||||||
|
documentsCenterView,
|
||||||
|
/documentTypeCode:\s*DOCUMENT_TYPE_REIMBURSEMENT,[\s\S]*documentTypeLabel:\s*'报销单'/
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('documents center list shows created time and conditional stay time columns', () => {
|
test('documents center list shows created time and conditional stay time columns', () => {
|
||||||
@@ -91,25 +110,70 @@ test('documents center action buttons are scoped to application and reimbursemen
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('documents center category tabs render bubble counts for new documents', () => {
|
test('documents center category tabs render bubble counts for new documents', () => {
|
||||||
|
assert.match(documentsCenterView, /readViewedDocumentKeys/)
|
||||||
|
assert.match(documentsCenterView, /const viewedDocumentKeys = ref\(readViewedDocumentKeys\(\)\)/)
|
||||||
assert.match(documentsCenterView, /v-for="tab in scopeTabItems"/)
|
assert.match(documentsCenterView, /v-for="tab in scopeTabItems"/)
|
||||||
assert.match(documentsCenterView, /<span v-if="tab\.badgeCount > 0" class="scope-tab-badge"/)
|
assert.match(documentsCenterView, /<span v-if="tab\.badgeCount > 0" class="scope-tab-badge"/)
|
||||||
assert.match(documentsCenterView, /tab\.badgeCount > 99 \? '99\+' : tab\.badgeCount/)
|
assert.match(documentsCenterView, /tab\.badgeCount > 99 \? '99\+' : tab\.badgeCount/)
|
||||||
assert.match(documentsCenterView, /const scopeNewCountMap = computed\(\(\) => \(\{/)
|
assert.match(documentsCenterView, /const scopeNewCountMap = computed\(\(\) => \(\{/)
|
||||||
|
assert.match(documentsCenterView, /\[DOCUMENT_SCOPE_ALL\]: countNewDocuments\(nonArchivedRows\.value, viewedDocumentKeys\.value\)/)
|
||||||
assert.match(
|
assert.match(
|
||||||
documentsCenterView,
|
documentsCenterView,
|
||||||
/\[DOCUMENT_SCOPE_REIMBURSEMENT\]: ownedRows\.value\.filter\(\(row\) => row\.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT\)\.length/
|
/\[DOCUMENT_SCOPE_APPLICATION\]: countNewDocuments\(nonArchivedRows\.value\.filter\(\(row\) => row\.documentTypeCode === DOCUMENT_TYPE_APPLICATION\), viewedDocumentKeys\.value\)/
|
||||||
)
|
)
|
||||||
assert.match(documentsCenterView, /\[DOCUMENT_SCOPE_REVIEW\]: approvalRows\.value\.length/)
|
assert.match(
|
||||||
assert.match(documentsCenterView, /\[DOCUMENT_SCOPE_ARCHIVE\]: archiveRows\.value\.length/)
|
documentsCenterView,
|
||||||
|
/\[DOCUMENT_SCOPE_REIMBURSEMENT\]: countNewDocuments\(ownedRows\.value\.filter\(\(row\) => row\.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT\), viewedDocumentKeys\.value\)/
|
||||||
|
)
|
||||||
|
assert.match(documentsCenterView, /\[DOCUMENT_SCOPE_REVIEW\]: countNewDocuments\(approvalRows\.value, viewedDocumentKeys\.value\)/)
|
||||||
|
assert.match(documentsCenterView, /\[DOCUMENT_SCOPE_ARCHIVE\]: countNewDocuments\(archiveRows\.value, viewedDocumentKeys\.value\)/)
|
||||||
assert.match(
|
assert.match(
|
||||||
documentsCenterView,
|
documentsCenterView,
|
||||||
/const scopeTabItems = computed\(\(\) =>[\s\S]*badgeCount: scopeNewCountMap\.value\[tab\] \|\| 0/
|
/const scopeTabItems = computed\(\(\) =>[\s\S]*badgeCount: scopeNewCountMap\.value\[tab\] \|\| 0/
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('documents center rows show NEW marker until the row is opened', () => {
|
||||||
|
assert.match(documentsCenterView, /<span v-if="row\.isNewDocument" class="new-document-badge">NEW<\/span>/)
|
||||||
|
assert.match(documentsCenterView, /isNewDocument: isNewDocument\(/)
|
||||||
|
assert.match(
|
||||||
|
documentsCenterView,
|
||||||
|
/function openDocument\(row\) \{[\s\S]*writeDocumentScope\(activeScopeTab\.value, scopeTabs\)[\s\S]*viewedDocumentKeys\.value = markDocumentViewed\(row, viewedDocumentKeys\.value\)[\s\S]*emit\('open-document', row\.rawRequest \|\| row\)/
|
||||||
|
)
|
||||||
|
assert.match(documentsCenterStyles, /\.new-document-badge\s*\{[\s\S]*background:\s*#fff5f5;/)
|
||||||
|
assert.match(documentsCenterStyles, /\.new-document-badge\s*\{[\s\S]*border:\s*1px solid #fecaca;/)
|
||||||
|
assert.match(documentsCenterStyles, /\.new-document-badge::before\s*\{[\s\S]*background:\s*#ef4444;/)
|
||||||
|
assert.doesNotMatch(documentsCenterStyles, /newDocumentPulse/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('documents center empty states stay emerald across all scope tabs', () => {
|
||||||
|
const emptyStateBlock = documentsCenterView.match(/const emptyState = computed\(\(\) => \{[\s\S]*?\n\}\)/)?.[0] || ''
|
||||||
|
|
||||||
|
assert.match(emptyStateBlock, /eyebrow: '申请单'[\s\S]*tone: 'emerald'/)
|
||||||
|
assert.match(emptyStateBlock, /title: filtered \? '没有符合当前条件的单据'[\s\S]*tone: 'emerald'/)
|
||||||
|
assert.doesNotMatch(emptyStateBlock, /tone:\s*'sky'/)
|
||||||
|
assert.doesNotMatch(emptyStateBlock, /tone:\s*'slate'/)
|
||||||
|
assert.doesNotMatch(emptyStateBlock, /tone:\s*'amber'/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('documents center empty states do not render small action buttons', () => {
|
||||||
|
const emptyStateBlock = documentsCenterView.match(/const emptyState = computed\(\(\) => \{[\s\S]*?\n\}\)/)?.[0] || ''
|
||||||
|
|
||||||
|
assert.match(emptyStateBlock, /actionLabel:\s*''/)
|
||||||
|
assert.match(emptyStateBlock, /actionIcon:\s*''/)
|
||||||
|
assert.doesNotMatch(emptyStateBlock, /actionLabel:\s*filtered/)
|
||||||
|
assert.doesNotMatch(emptyStateBlock, /actionIcon:\s*filtered/)
|
||||||
|
assert.doesNotMatch(emptyStateBlock, /actionLabel:\s*'发起申请'/)
|
||||||
|
assert.doesNotMatch(emptyStateBlock, /actionLabel:\s*'发起报销'/)
|
||||||
|
assert.doesNotMatch(emptyStateBlock, /actionLabel:\s*'清空筛选'/)
|
||||||
|
})
|
||||||
|
|
||||||
test('documents center switches filter conditions by category tab', () => {
|
test('documents center switches filter conditions by category tab', () => {
|
||||||
assert.match(documentsCenterView, /const FILTER_CONFIG_BY_SCOPE = \{/)
|
assert.match(documentsCenterView, /const FILTER_CONFIG_BY_SCOPE = \{/)
|
||||||
assert.doesNotMatch(documentsCenterView, /\[DOCUMENT_SCOPE_ALL\]: \{/)
|
assert.match(
|
||||||
|
documentsCenterView,
|
||||||
|
/\[DOCUMENT_SCOPE_ALL\]: \{[\s\S]*sceneFallbackLabel: '单据场景'[\s\S]*statusTitle: '单据状态'[\s\S]*showDocumentType: true/
|
||||||
|
)
|
||||||
assert.match(
|
assert.match(
|
||||||
documentsCenterView,
|
documentsCenterView,
|
||||||
/\[DOCUMENT_SCOPE_APPLICATION\]: \{[\s\S]*sceneFallbackLabel: '申请场景'[\s\S]*statusTitle: '申请状态'[\s\S]*showDocumentType: false/
|
/\[DOCUMENT_SCOPE_APPLICATION\]: \{[\s\S]*sceneFallbackLabel: '申请场景'[\s\S]*statusTitle: '申请状态'[\s\S]*showDocumentType: false/
|
||||||
|
|||||||
69
web/tests/expense-application-ontology.test.mjs
Normal file
69
web/tests/expense-application-ontology.test.mjs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildApplicationFieldsFromOntology,
|
||||||
|
expandApplicationTimeWithDays,
|
||||||
|
resolveApplicationReason,
|
||||||
|
resolveApplicationTimeRange,
|
||||||
|
resolvePromptField
|
||||||
|
} from '../src/utils/expenseApplicationOntology.js'
|
||||||
|
|
||||||
|
const structuredApplicationPrompt = [
|
||||||
|
'发生时间:2026-05-25',
|
||||||
|
'地点:上海',
|
||||||
|
'事由:支撑国网服务器部署',
|
||||||
|
'天数:3天'
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
test('expense application fields use labeled reason and filter resolved missing slots', () => {
|
||||||
|
const fields = buildApplicationFieldsFromOntology(
|
||||||
|
{
|
||||||
|
scenario: 'expense',
|
||||||
|
intent: 'draft',
|
||||||
|
entities: [],
|
||||||
|
time_range: {},
|
||||||
|
missing_slots: ['time_range', 'location', 'reason', 'amount']
|
||||||
|
},
|
||||||
|
structuredApplicationPrompt,
|
||||||
|
{ name: '申请员工', departmentName: '交付部' }
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(fields.timeRange, '2026-05-25 至 2026-05-28')
|
||||||
|
assert.equal(fields.location, '上海')
|
||||||
|
assert.equal(fields.reason, '支撑国网服务器部署')
|
||||||
|
assert.deepEqual(
|
||||||
|
fields.missingSlots.map((item) => item.key),
|
||||||
|
['amount']
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('expense application prompt field parser supports multiline labels', () => {
|
||||||
|
assert.equal(resolvePromptField(structuredApplicationPrompt, ['事由']), '支撑国网服务器部署')
|
||||||
|
assert.equal(resolveApplicationReason(structuredApplicationPrompt), '支撑国网服务器部署')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('expense application expands a single selected date with natural days', () => {
|
||||||
|
const prompt = [
|
||||||
|
'发生时间:2026-05-25',
|
||||||
|
'去上海出差3天,支撑国网服务器部署'
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
assert.equal(expandApplicationTimeWithDays('2026-05-25', 3), '2026-05-25 至 2026-05-28')
|
||||||
|
assert.equal(resolveApplicationTimeRange({ time_range: {} }, prompt), '2026-05-25 至 2026-05-28')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('expense application keeps explicit time range before applying days', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveApplicationTimeRange(
|
||||||
|
{
|
||||||
|
time_range: {
|
||||||
|
start_date: '2026-05-25',
|
||||||
|
end_date: '2026-05-27'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'去上海出差3天,支撑国网服务器部署'
|
||||||
|
),
|
||||||
|
'2026-05-25 至 2026-05-27'
|
||||||
|
)
|
||||||
|
})
|
||||||
46
web/tests/expense-application-submit-rich-confirm.test.mjs
Normal file
46
web/tests/expense-application-submit-rich-confirm.test.mjs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
import { renderMarkdown } from '../src/utils/markdown.js'
|
||||||
|
|
||||||
|
const createViewTemplate = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
const createViewScript = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
|
test('expense application submit uses rich text link and confirm dialog', () => {
|
||||||
|
const copy = '请核对上述信息无误,确认无误后 [确认](#application-submit) 提交至审批流程。'
|
||||||
|
const rendered = renderMarkdown(copy)
|
||||||
|
|
||||||
|
assert.match(
|
||||||
|
rendered,
|
||||||
|
/<a href="#application-submit" class="markdown-action-link markdown-action-link-confirm">确认<\/a>/
|
||||||
|
)
|
||||||
|
assert.match(createViewTemplate, /:open="applicationSubmitConfirmDialog\.open"/)
|
||||||
|
assert.match(createViewTemplate, /title="确认提交当前费用申请?"/)
|
||||||
|
assert.match(createViewTemplate, /description="提交后申请将进入领导审核流程,并同步纳入预算管理口径/)
|
||||||
|
assert.match(createViewTemplate, /@confirm="confirmApplicationSubmit"/)
|
||||||
|
assert.match(createViewScript, /const APPLICATION_SUBMIT_HREF = '#application-submit'/)
|
||||||
|
assert.match(
|
||||||
|
createViewScript,
|
||||||
|
/href === APPLICATION_SUBMIT_HREF[\s\S]*openApplicationSubmitConfirm\(message\)/
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
createViewScript,
|
||||||
|
/async function confirmApplicationSubmit\(\)[\s\S]*rawText: '确认提交'[\s\S]*systemGenerated: true/
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
createViewScript,
|
||||||
|
/applicationSubmitConfirmDialog\.value = \{[\s\S]*open: false,[\s\S]*message: null[\s\S]*\}[\s\S]*const payload = await submitComposer/
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
createViewScript,
|
||||||
|
/emit\('draft-saved', \{[\s\S]*status: 'submitted'[\s\S]*documentType: 'application'/
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -11,7 +11,8 @@ const workbench = readFileSync(
|
|||||||
test('workbench assistant greets the current employee without the old helper tag', () => {
|
test('workbench assistant greets the current employee without the old helper tag', () => {
|
||||||
assert.doesNotMatch(workbench, /assistant-tag/)
|
assert.doesNotMatch(workbench, /assistant-tag/)
|
||||||
assert.doesNotMatch(workbench, /AI 报销助手/)
|
assert.doesNotMatch(workbench, /AI 报销助手/)
|
||||||
assert.match(workbench, /嗨,\{\{ assistantGreetingName \}\},描述费用或上传票据,AI 直接帮你判断怎么报/)
|
assert.match(workbench, /嗨,\{\{ assistantGreetingName \}\},描述您想做的事,AI 会直接帮您处理/)
|
||||||
|
assert.match(workbench, /我会自动识别您的意图,协助完成费用申请、报销、查询和制度问答等业务工作/)
|
||||||
assert.match(workbench, /const assistantGreetingName = computed/)
|
assert.match(workbench, /const assistantGreetingName = computed/)
|
||||||
assert.match(workbench, /user\.name/)
|
assert.match(workbench, /user\.name/)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -79,6 +79,15 @@ test('business activity without expense intent asks for reimbursement confirmati
|
|||||||
assert.equal(shouldRequestExpenseSceneSelection(businessMessage), false)
|
assert.equal(shouldRequestExpenseSceneSelection(businessMessage), false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('non-reimbursement assistant sessions do not trigger reimbursement scene selection', () => {
|
||||||
|
const ambiguousMessage = '业务发生时间:2026-02-20 至 2026-02-23,去上海支持上海电力部署项目,申请报销'
|
||||||
|
|
||||||
|
assert.equal(shouldRequestExpenseSceneSelection(ambiguousMessage, { sessionType: 'application' }), false)
|
||||||
|
assert.equal(shouldRequestExpenseIntentConfirmation('去上海电力支撑项目部署', { sessionType: 'approval' }), false)
|
||||||
|
assert.match(buildLocalIntentPreview(ambiguousMessage, 'application'), /费用申请事项/)
|
||||||
|
assert.match(buildLocalIntentPreview('查一下待我审核的单据', 'approval'), /审核处理事项/)
|
||||||
|
})
|
||||||
|
|
||||||
test('explicit technical operation does not ask for reimbursement confirmation', () => {
|
test('explicit technical operation does not ask for reimbursement confirmation', () => {
|
||||||
const operationMessage = '去上海电力支撑项目部署,帮我整理服务器部署步骤'
|
const operationMessage = '去上海电力支撑项目部署,帮我整理服务器部署步骤'
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,83 @@ import test from 'node:test'
|
|||||||
|
|
||||||
import { mapExpenseClaimToRequest } from '../src/composables/useRequests.js'
|
import { mapExpenseClaimToRequest } from '../src/composables/useRequests.js'
|
||||||
|
|
||||||
|
test('application claims are mapped as application documents', () => {
|
||||||
|
const request = mapExpenseClaimToRequest({
|
||||||
|
id: 'claim-application-1',
|
||||||
|
claim_no: 'APP-20260525-ABC123',
|
||||||
|
employee_name: '张三',
|
||||||
|
department_name: '交付部',
|
||||||
|
expense_type: 'travel_application',
|
||||||
|
reason: '支撑国网服务器上线部署',
|
||||||
|
location: '上海',
|
||||||
|
amount: 12000,
|
||||||
|
invoice_count: 0,
|
||||||
|
occurred_at: '2026-05-25T00:00:00.000Z',
|
||||||
|
submitted_at: '2026-05-25T02:00:00.000Z',
|
||||||
|
created_at: '2026-05-25T01:30:00.000Z',
|
||||||
|
updated_at: '2026-05-25T02:00:00.000Z',
|
||||||
|
status: 'submitted',
|
||||||
|
approval_stage: '直属领导审批',
|
||||||
|
risk_flags_json: [],
|
||||||
|
items: []
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(request.documentTypeCode, 'application')
|
||||||
|
assert.equal(request.documentTypeLabel, '申请单')
|
||||||
|
assert.equal(request.typeLabel, '差旅费用申请')
|
||||||
|
assert.equal(request.secondaryStatusLabel, '申请材料')
|
||||||
|
assert.equal(request.secondaryStatusValue, '已进入审批流程')
|
||||||
|
assert.equal(request.expenseTableSummary, '预计金额已纳入预算管理口径')
|
||||||
|
assert.deepEqual(
|
||||||
|
request.progressSteps.map((step) => step.label),
|
||||||
|
['创建申请', '直属领导审批', '审批完成']
|
||||||
|
)
|
||||||
|
assert.equal(request.progressSteps.some((step) => step.label === 'AI预审'), false)
|
||||||
|
assert.equal(request.progressSteps.some((step) => step.label === '财务审批'), false)
|
||||||
|
assert.equal(request.progressSteps.find((step) => step.label === '直属领导审批')?.current, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('approved application claims complete after direct manager approval only', () => {
|
||||||
|
const request = mapExpenseClaimToRequest({
|
||||||
|
id: 'claim-application-approved',
|
||||||
|
claim_no: 'APP-20260525-DONE01',
|
||||||
|
employee_name: '张三',
|
||||||
|
department_name: '交付部',
|
||||||
|
manager_name: '李经理',
|
||||||
|
expense_type: 'travel_application',
|
||||||
|
reason: '支撑国网服务器上线部署',
|
||||||
|
location: '上海',
|
||||||
|
amount: 12000,
|
||||||
|
invoice_count: 0,
|
||||||
|
occurred_at: '2026-05-25T00:00:00.000Z',
|
||||||
|
submitted_at: '2026-05-25T02:00:00.000Z',
|
||||||
|
created_at: '2026-05-25T01:30:00.000Z',
|
||||||
|
updated_at: '2026-05-25T03:00:00.000Z',
|
||||||
|
status: 'approved',
|
||||||
|
approval_stage: '审批完成',
|
||||||
|
risk_flags_json: [
|
||||||
|
{
|
||||||
|
source: 'manual_approval',
|
||||||
|
event_type: 'expense_application_approval',
|
||||||
|
operator: '李经理',
|
||||||
|
previous_approval_stage: '直属领导审批',
|
||||||
|
next_approval_stage: '审批完成',
|
||||||
|
created_at: '2026-05-25T03:00:00.000Z'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
items: []
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(request.documentTypeCode, 'application')
|
||||||
|
assert.equal(request.workflowNode, '审批完成')
|
||||||
|
assert.deepEqual(
|
||||||
|
request.progressSteps.map((step) => step.label),
|
||||||
|
['创建申请', '直属领导审批', '审批完成']
|
||||||
|
)
|
||||||
|
assert.equal(request.progressSteps.every((step) => step.done), true)
|
||||||
|
assert.equal(request.progressSteps.find((step) => step.label === '直属领导审批')?.time, '李经理通过')
|
||||||
|
})
|
||||||
|
|
||||||
test('progress steps show approval operator time and current stay duration', () => {
|
test('progress steps show approval operator time and current stay duration', () => {
|
||||||
const originalNow = Date.now
|
const originalNow = Date.now
|
||||||
Date.now = () => new Date('2026-05-20T05:00:00.000Z').getTime()
|
Date.now = () => new Date('2026-05-20T05:00:00.000Z').getTime()
|
||||||
|
|||||||
41
web/tests/sidebar-collapse.test.mjs
Normal file
41
web/tests/sidebar-collapse.test.mjs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const appShell = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/views/AppShellRouteView.vue', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
|
const sidebar = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/components/layout/SidebarRail.vue', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
|
const appStyles = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/assets/styles/app.css', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
|
test('sidebar supports smooth animated collapsed layout', () => {
|
||||||
|
assert.match(appShell, /sidebarCollapsed = ref\(true\)/)
|
||||||
|
assert.match(appShell, /:class="\{ 'sidebar-collapsed': sidebarCollapsed \}"/)
|
||||||
|
assert.match(appShell, /:collapsed="sidebarCollapsed"/)
|
||||||
|
assert.match(appShell, /class="app-sidebar"/)
|
||||||
|
assert.match(appShell, /@toggle-collapse="toggleSidebarCollapsed"/)
|
||||||
|
assert.match(appShell, /function toggleSidebarCollapsed\(\)/)
|
||||||
|
assert.match(appShell, /sidebarCollapsed\.value = !sidebarCollapsed\.value/)
|
||||||
|
|
||||||
|
assert.match(sidebar, /collapsed:\s*\{\s*type: Boolean/)
|
||||||
|
assert.match(sidebar, /'toggle-collapse'/)
|
||||||
|
assert.match(sidebar, /rail-collapsed/)
|
||||||
|
assert.match(sidebar, /折叠侧边栏/)
|
||||||
|
assert.match(sidebar, /展开侧边栏/)
|
||||||
|
assert.match(sidebar, /--rail-motion-duration: 320ms/)
|
||||||
|
assert.match(sidebar, /opacity var\(--rail-fade-duration\)/)
|
||||||
|
|
||||||
|
assert.match(appStyles, /--sidebar-collapsed-width: 64px/)
|
||||||
|
assert.match(appStyles, /\.app-sidebar\s*\{[^}]*transition:\s*width var\(--sidebar-motion\)/)
|
||||||
|
assert.match(appStyles, /\.app\.sidebar-collapsed\s+\.app-sidebar\s*\{\s*width:\s*var\(--sidebar-collapsed-width\)/)
|
||||||
|
})
|
||||||
@@ -34,6 +34,28 @@ test('composer formats date-picker expense text into readable structured fields'
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('composer extracts destination and reason from compact travel text', () => {
|
||||||
|
const formatted = buildStructuredComposerSubmitText(
|
||||||
|
'出差上海,支撑国网服务器上线部署',
|
||||||
|
{
|
||||||
|
mode: 'single',
|
||||||
|
start_date: '2026-05-25',
|
||||||
|
end_date: '2026-05-25',
|
||||||
|
business_time: '2026-05-25'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
formatted,
|
||||||
|
[
|
||||||
|
'发生时间:2026-05-25',
|
||||||
|
'地点:上海',
|
||||||
|
'事由:支撑国网服务器上线部署',
|
||||||
|
'天数:1天'
|
||||||
|
].join('\n')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
test('composer keeps backend raw text but displays structured user message', () => {
|
test('composer keeps backend raw text but displays structured user message', () => {
|
||||||
assert.match(submitComposerScript, /const rawText = resolveComposerSubmitText\(options\.rawText\)\.trim\(\)/)
|
assert.match(submitComposerScript, /const rawText = resolveComposerSubmitText\(options\.rawText\)\.trim\(\)/)
|
||||||
assert.match(submitComposerScript, /resolveComposerDisplaySubmitText\(rawText\)/)
|
assert.match(submitComposerScript, /resolveComposerDisplaySubmitText\(rawText\)/)
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import test from 'node:test'
|
|||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
APPLICATION_WELCOME_QUICK_ACTIONS,
|
||||||
|
APPROVAL_WELCOME_QUICK_ACTIONS,
|
||||||
|
ASSISTANT_SESSION_MODE_OPTIONS,
|
||||||
EXPENSE_WELCOME_QUICK_ACTIONS,
|
EXPENSE_WELCOME_QUICK_ACTIONS,
|
||||||
|
SESSION_TYPE_APPLICATION,
|
||||||
|
SESSION_TYPE_APPROVAL,
|
||||||
SESSION_TYPE_EXPENSE,
|
SESSION_TYPE_EXPENSE,
|
||||||
SESSION_TYPE_KNOWLEDGE,
|
SESSION_TYPE_KNOWLEDGE,
|
||||||
buildWelcomeQuickActions
|
buildWelcomeQuickActions
|
||||||
@@ -40,6 +45,10 @@ import {
|
|||||||
selectGuidedQueryMode,
|
selectGuidedQueryMode,
|
||||||
shouldConfirmGuidedInterruption
|
shouldConfirmGuidedInterruption
|
||||||
} from '../src/views/scripts/travelReimbursementGuidedFlowModel.js'
|
} from '../src/views/scripts/travelReimbursementGuidedFlowModel.js'
|
||||||
|
import {
|
||||||
|
ASSISTANT_SCOPE_ACTION_SWITCH,
|
||||||
|
resolveAssistantScopeGuard
|
||||||
|
} from '../src/utils/assistantSessionScope.js'
|
||||||
|
|
||||||
const createViewScript = readFileSync(
|
const createViewScript = readFileSync(
|
||||||
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
||||||
@@ -53,8 +62,16 @@ const sessionStateScript = readFileSync(
|
|||||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSessionState.js', import.meta.url)),
|
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSessionState.js', import.meta.url)),
|
||||||
'utf8'
|
'utf8'
|
||||||
)
|
)
|
||||||
|
const submitComposerScript = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
test('welcome quick actions are reduced to three guided local actions', () => {
|
test('assistant session modes expose independent quick actions', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
ASSISTANT_SESSION_MODE_OPTIONS.map((item) => item.label),
|
||||||
|
['申请助手', '报销助手', '审核助手', '财务知识助手']
|
||||||
|
)
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
EXPENSE_WELCOME_QUICK_ACTIONS.map((item) => item.label),
|
EXPENSE_WELCOME_QUICK_ACTIONS.map((item) => item.label),
|
||||||
['快速发起报销', '查询单据状态', '差旅计算器']
|
['快速发起报销', '查询单据状态', '差旅计算器']
|
||||||
@@ -69,6 +86,14 @@ test('welcome quick actions are reduced to three guided local actions', () => {
|
|||||||
)
|
)
|
||||||
assert.ok(EXPENSE_WELCOME_QUICK_ACTIONS.every((item) => !item.prompt))
|
assert.ok(EXPENSE_WELCOME_QUICK_ACTIONS.every((item) => !item.prompt))
|
||||||
assert.equal(buildWelcomeQuickActions(SESSION_TYPE_EXPENSE).length, 3)
|
assert.equal(buildWelcomeQuickActions(SESSION_TYPE_EXPENSE).length, 3)
|
||||||
|
assert.deepEqual(
|
||||||
|
buildWelcomeQuickActions(SESSION_TYPE_APPLICATION).map((item) => item.label),
|
||||||
|
APPLICATION_WELCOME_QUICK_ACTIONS.map((item) => item.label)
|
||||||
|
)
|
||||||
|
assert.deepEqual(
|
||||||
|
buildWelcomeQuickActions(SESSION_TYPE_APPROVAL).map((item) => item.label),
|
||||||
|
APPROVAL_WELCOME_QUICK_ACTIONS.map((item) => item.label)
|
||||||
|
)
|
||||||
assert.notDeepEqual(
|
assert.notDeepEqual(
|
||||||
buildWelcomeQuickActions(SESSION_TYPE_KNOWLEDGE).map((item) => item.label),
|
buildWelcomeQuickActions(SESSION_TYPE_KNOWLEDGE).map((item) => item.label),
|
||||||
EXPENSE_WELCOME_QUICK_ACTIONS.map((item) => item.label),
|
EXPENSE_WELCOME_QUICK_ACTIONS.map((item) => item.label),
|
||||||
@@ -76,6 +101,34 @@ test('welcome quick actions are reduced to three guided local actions', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('assistant session scope guard keeps business boundaries isolated', () => {
|
||||||
|
const expenseInApplication = resolveAssistantScopeGuard('我想报销的士票', SESSION_TYPE_APPLICATION)
|
||||||
|
assert.equal(expenseInApplication.targetSessionType, SESSION_TYPE_EXPENSE)
|
||||||
|
assert.match(expenseInApplication.text, /申请助手/)
|
||||||
|
assert.match(expenseInApplication.text, /报销助手/)
|
||||||
|
assert.equal(expenseInApplication.suggestedActions[0].action_type, ASSISTANT_SCOPE_ACTION_SWITCH)
|
||||||
|
assert.equal(expenseInApplication.suggestedActions[0].payload.session_type, SESSION_TYPE_EXPENSE)
|
||||||
|
assert.equal(expenseInApplication.suggestedActions[0].payload.carry_text, '我想报销的士票')
|
||||||
|
|
||||||
|
assert.equal(resolveAssistantScopeGuard('我想发起一笔费用申请', SESSION_TYPE_APPLICATION), null)
|
||||||
|
assert.equal(
|
||||||
|
resolveAssistantScopeGuard('帮我查询待我审核的单据', SESSION_TYPE_EXPENSE).targetSessionType,
|
||||||
|
SESSION_TYPE_APPROVAL
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
resolveAssistantScopeGuard('差旅住宿标准是多少', SESSION_TYPE_EXPENSE).targetSessionType,
|
||||||
|
SESSION_TYPE_KNOWLEDGE
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
resolveAssistantScopeGuard('报销标准是多少', SESSION_TYPE_EXPENSE).targetSessionType,
|
||||||
|
SESSION_TYPE_KNOWLEDGE
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
resolveAssistantScopeGuard('解释这张单据酒店超标风险', SESSION_TYPE_EXPENSE, { hasActiveReviewPayload: true }),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
test('guided reimbursement asks type first and walks travel fields in order', () => {
|
test('guided reimbursement asks type first and walks travel fields in order', () => {
|
||||||
const typeActions = buildGuidedExpenseTypeActions()
|
const typeActions = buildGuidedExpenseTypeActions()
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
@@ -177,6 +230,9 @@ test('guided flow state is serializable and restored through session state', ()
|
|||||||
assert.match(sessionStateScript, /guidedFlowState,\s*\n\s*insightPanelCollapsed/)
|
assert.match(sessionStateScript, /guidedFlowState,\s*\n\s*insightPanelCollapsed/)
|
||||||
assert.match(sessionStateScript, /function refreshWelcomeQuickActions/)
|
assert.match(sessionStateScript, /function refreshWelcomeQuickActions/)
|
||||||
assert.match(sessionStateScript, /buildWelcomeQuickActions\(/)
|
assert.match(sessionStateScript, /buildWelcomeQuickActions\(/)
|
||||||
|
assert.match(sessionStateScript, /ASSISTANT_SESSION_TYPES\.reduce/)
|
||||||
|
assert.match(sessionStateScript, /props\.entrySource === 'application' \? SESSION_TYPE_APPLICATION : SESSION_TYPE_EXPENSE/)
|
||||||
|
assert.match(sessionStateScript, /const canRestorePersistedInitialState =[\s\S]*shouldPersistLocalSnapshot/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('guided flow is local until final confirmation or collected query handoff', () => {
|
test('guided flow is local until final confirmation or collected query handoff', () => {
|
||||||
@@ -184,6 +240,10 @@ test('guided flow is local until final confirmation or collected query handoff',
|
|||||||
assert.doesNotMatch(guidedFlowScript, /startExpenseClaimDraftFlowStep/)
|
assert.doesNotMatch(guidedFlowScript, /startExpenseClaimDraftFlowStep/)
|
||||||
assert.doesNotMatch(guidedFlowScript, /review_action:\s*['"]save_draft['"]/)
|
assert.doesNotMatch(guidedFlowScript, /review_action:\s*['"]save_draft['"]/)
|
||||||
assert.match(createViewScript, /if \(await handleGuidedComposerSubmit\(options\)\) \{[\s\S]*return null[\s\S]*\}[\s\S]*return submitComposerInternal\(options\)/)
|
assert.match(createViewScript, /if \(await handleGuidedComposerSubmit\(options\)\) \{[\s\S]*return null[\s\S]*\}[\s\S]*return submitComposerInternal\(options\)/)
|
||||||
|
assert.match(createViewScript, /ASSISTANT_SCOPE_ACTION_SWITCH/)
|
||||||
|
assert.match(createViewScript, /actionPayload\.carry_text/)
|
||||||
|
assert.match(submitComposerScript, /resolveAssistantScopeGuard/)
|
||||||
|
assert.match(submitComposerScript, /skipScopeGuard/)
|
||||||
assert.match(guidedFlowScript, /submitExistingComposer\(submitOptions\)/)
|
assert.match(guidedFlowScript, /submitExistingComposer\(submitOptions\)/)
|
||||||
assert.match(guidedFlowScript, /submitExistingComposer\(\{[\s\S]*pendingText:\s*'正在查询单据状态\.\.\.'/)
|
assert.match(guidedFlowScript, /submitExistingComposer\(\{[\s\S]*pendingText:\s*'正在查询单据状态\.\.\.'/)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import {
|
|||||||
} from '../src/views/scripts/travelRequestDetailInsights.js'
|
} from '../src/views/scripts/travelRequestDetailInsights.js'
|
||||||
import {
|
import {
|
||||||
buildExpenseItemViewModel,
|
buildExpenseItemViewModel,
|
||||||
buildDraftBlockingIssues
|
buildDraftBlockingIssues,
|
||||||
|
buildOptionalTravelReceiptRiskCards,
|
||||||
|
isApplicationDocumentRequest
|
||||||
} from '../src/views/scripts/travelRequestDetailExpenseModel.js'
|
} from '../src/views/scripts/travelRequestDetailExpenseModel.js'
|
||||||
|
|
||||||
const detailViewTemplate = readFileSync(
|
const detailViewTemplate = readFileSync(
|
||||||
@@ -412,7 +414,7 @@ test('expense attachment actions keep preview as the only recognition entry poin
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('expense detail table shows the amount total below detail rows', () => {
|
test('expense detail table shows the amount total below detail rows', () => {
|
||||||
assert.match(detailViewTemplate, /<div class="detail-expense-table">/)
|
assert.match(detailViewTemplate, /<div[^>]*class="detail-expense-table"/)
|
||||||
assert.match(detailViewTemplate, /当前还没有费用明细/)
|
assert.match(detailViewTemplate, /当前还没有费用明细/)
|
||||||
assert.doesNotMatch(detailViewTemplate, /class="total-row"/)
|
assert.doesNotMatch(detailViewTemplate, /class="total-row"/)
|
||||||
assert.match(detailViewTemplate, /class="expense-total-under-table"[\s\S]*金额合计[\s\S]*\{\{ expenseTotal \}\}/)
|
assert.match(detailViewTemplate, /class="expense-total-under-table"[\s\S]*金额合计[\s\S]*\{\{ expenseTotal \}\}/)
|
||||||
@@ -421,7 +423,10 @@ test('expense detail table shows the amount total below detail rows', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('additional note is shown above expense details as travel purpose text', () => {
|
test('additional note is shown above expense details as travel purpose text', () => {
|
||||||
assert.ok(detailViewTemplate.indexOf('<h3>附加说明</h3>') < detailViewTemplate.indexOf('<h3>费用明细</h3>'))
|
assert.ok(
|
||||||
|
detailViewTemplate.indexOf('<h3>附加说明</h3>')
|
||||||
|
< detailViewTemplate.indexOf("isApplicationDocument ? '申请预算' : '费用明细'")
|
||||||
|
)
|
||||||
assert.match(detailViewTemplate, /用于说明本次出差或办事目的/)
|
assert.match(detailViewTemplate, /用于说明本次出差或办事目的/)
|
||||||
assert.match(detailViewTemplate, /v-if="canEditDetailNote" class="detail-note-editor"/)
|
assert.match(detailViewTemplate, /v-if="canEditDetailNote" class="detail-note-editor"/)
|
||||||
assert.match(detailViewTemplate, /v-else class="detail-note readonly"/)
|
assert.match(detailViewTemplate, /v-else class="detail-note readonly"/)
|
||||||
@@ -514,6 +519,7 @@ test('expense detail edit keeps delete but removes cancel and allows draft place
|
|||||||
|
|
||||||
test('travel detail AI advice adds low risk reminders for optional receipts', () => {
|
test('travel detail AI advice adds low risk reminders for optional receipts', () => {
|
||||||
assert.match(detailViewScript, /function buildOptionalTravelReceiptRiskCards\(requestModel, items\)/)
|
assert.match(detailViewScript, /function buildOptionalTravelReceiptRiskCards\(requestModel, items\)/)
|
||||||
|
assert.match(detailViewScript, /isApplicationDocumentRequest\(requestModel\)[\s\S]*return \[\]/)
|
||||||
assert.match(detailViewScript, /id: 'travel-optional-hotel-ticket'[\s\S]*tone: 'low'[\s\S]*住宿票据提醒/)
|
assert.match(detailViewScript, /id: 'travel-optional-hotel-ticket'[\s\S]*tone: 'low'[\s\S]*住宿票据提醒/)
|
||||||
assert.match(detailViewScript, /不要忘记补充酒店住宿票据/)
|
assert.match(detailViewScript, /不要忘记补充酒店住宿票据/)
|
||||||
assert.match(detailViewScript, /id: 'travel-optional-ride-ticket'[\s\S]*tone: 'low'[\s\S]*乘车票据提醒/)
|
assert.match(detailViewScript, /id: 'travel-optional-ride-ticket'[\s\S]*tone: 'low'[\s\S]*乘车票据提醒/)
|
||||||
@@ -539,6 +545,33 @@ test('expense detail save is blocked while attachment recognition is running', (
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('application detail uses application labels instead of reimbursement labels', () => {
|
||||||
|
assert.match(detailViewTemplate, /isApplicationDocument \? '申请进度'/)
|
||||||
|
assert.match(detailViewTemplate, /isApplicationDocument \? '申请预算' : '费用明细'/)
|
||||||
|
assert.match(detailViewTemplate, /无需补充任何报销票据/)
|
||||||
|
assert.match(detailViewTemplate, /isApplicationDocument \? '申请类型' : '报销类型'/)
|
||||||
|
assert.match(detailViewTemplate, /isApplicationDocument \? '预计金额' : '报销金额'/)
|
||||||
|
assert.match(detailViewTemplate, /isApplicationDocument \? '退回申请' : '退回单据'/)
|
||||||
|
assert.match(detailViewTemplate, /当前申请单已进入流程,详情页仅展示状态与申请信息。/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('application detail does not show optional travel receipt reminders', () => {
|
||||||
|
const request = {
|
||||||
|
documentTypeCode: 'application',
|
||||||
|
claimNo: 'APP-20260525-ABC123',
|
||||||
|
typeCode: 'travel_application',
|
||||||
|
detailVariant: 'travel'
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(isApplicationDocumentRequest(request), true)
|
||||||
|
assert.deepEqual(
|
||||||
|
buildOptionalTravelReceiptRiskCards(request, [
|
||||||
|
{ id: 'allowance', itemType: 'travel_allowance', isSystemGenerated: true, invoiceId: '' }
|
||||||
|
]),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
test('draft submit validation uses expense detail date and amount when claim summary is stale', () => {
|
test('draft submit validation uses expense detail date and amount when claim summary is stale', () => {
|
||||||
const issues = buildDraftBlockingIssues(
|
const issues = buildDraftBlockingIssues(
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user