feat: 同步报销流程与工作台改动

This commit is contained in:
caoxiaozhu
2026-06-09 08:32:00 +00:00
parent e124e4bbcb
commit 25724c354f
64 changed files with 6518 additions and 687 deletions

View File

@@ -48,4 +48,8 @@ SQLALCHEMY_ECHO=false
REDIS_URL= REDIS_URL=
VITE_REDIS_URL= VITE_REDIS_URL=
OCR_DEVICE=
OCR_TIMEOUT_SECONDS=180
OCR_MAX_CONCURRENT_WORKERS=1
CORS_ORIGINS='["http://127.0.0.1:5173","http://localhost:5173","http://0.0.0.0:5173"]' CORS_ORIGINS='["http://127.0.0.1:5173","http://localhost:5173","http://0.0.0.0:5173"]'

8
docker-compose.gpu.yml Normal file
View File

@@ -0,0 +1,8 @@
services:
main:
gpus: all
shm_size: "8gb"
environment:
NVIDIA_VISIBLE_DEVICES: all
NVIDIA_DRIVER_CAPABILITIES: compute,utility
OCR_DEVICE: "${OCR_DEVICE:-gpu:0}"

View File

@@ -0,0 +1,29 @@
services:
main:
depends_on:
postgres:
condition: service_healthy
postgres:
image: pgvector/pgvector:pg17
container_name: x-financial-postgres
restart: unless-stopped
environment:
POSTGRES_DB: "${POSTGRES_DB:-x_financial}"
POSTGRES_USER: "${POSTGRES_USER:-x_financial}"
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-x_financial}"
ports:
- "${POSTGRES_HOST_PORT:-55432}:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
interval: 15s
timeout: 5s
retries: 10
start_period: 30s
networks:
- financial-internal
volumes:
postgres-data:

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 734 KiB

View File

@@ -0,0 +1,724 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>X-Financial Expense 启动页素材系统</title>
<style>
:root {
--bg: #edf2f0;
--ink: #0f172a;
--muted: #64748b;
--surface: #ffffff;
--line: #d8e1e8;
--primary: #006b5e;
--primary-2: #0f766e;
--primary-dark: #061416;
--mint: #e1f6f1;
--gold: #f5a524;
--gold-soft: #fff2cf;
--blue: #2563eb;
--blue-soft: #e7efff;
--paper: #f8fafc;
--shadow: 0 24px 70px rgba(15, 23, 42, 0.14);
}
* { box-sizing: border-box; }
body {
margin: 0;
color: var(--ink);
background:
linear-gradient(90deg, rgba(15, 23, 42, 0.045) 1px, transparent 1px),
linear-gradient(rgba(15, 23, 42, 0.045) 1px, transparent 1px),
var(--bg);
background-size: 28px 28px;
font-family: "IBM Plex Sans", "Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC", sans-serif;
letter-spacing: 0;
}
.wrap {
max-width: 1500px;
margin: 0 auto;
padding: 34px 30px 70px;
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: 22px;
margin-bottom: 24px;
}
.panel {
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 8px;
background: rgba(255,255,255,0.86);
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.08);
}
.hero-main {
min-height: 260px;
padding: 30px;
display: grid;
align-content: center;
}
.kicker {
width: fit-content;
min-height: 28px;
padding: 5px 10px;
border-radius: 999px;
border: 1px solid rgba(0, 107, 94, 0.18);
color: var(--primary);
background: var(--mint);
font-size: 12px;
font-weight: 900;
}
h1 {
max-width: 760px;
margin: 14px 0 10px;
font-size: 36px;
line-height: 1.12;
letter-spacing: 0;
}
.hero p,
.section p {
max-width: 860px;
margin: 0;
color: var(--muted);
font-size: 14px;
line-height: 1.62;
font-weight: 650;
}
.hero-side {
padding: 18px;
display: grid;
gap: 10px;
align-content: center;
}
.checkline {
display: grid;
grid-template-columns: 34px minmax(0, 1fr);
gap: 10px;
align-items: center;
min-height: 58px;
padding: 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
}
.checkline strong {
display: block;
font-size: 13px;
margin-bottom: 2px;
}
.checkline span {
display: block;
color: var(--muted);
font-size: 12px;
line-height: 1.35;
font-weight: 650;
}
.icon {
width: 34px;
height: 34px;
border-radius: 8px;
display: grid;
place-items: center;
color: var(--primary);
background: var(--mint);
}
.icon.gold { color: #8a5a00; background: var(--gold-soft); }
.icon.blue { color: var(--blue); background: var(--blue-soft); }
.grid {
display: grid;
grid-template-columns: 420px minmax(0, 1fr);
gap: 24px;
align-items: start;
}
.section {
padding: 20px;
margin-bottom: 24px;
}
.section h2 {
margin: 0 0 6px;
font-size: 20px;
letter-spacing: 0;
}
.phone {
width: 316px;
height: 684px;
margin: 0 auto;
padding: 9px;
border-radius: 34px;
background: #101827;
box-shadow: var(--shadow);
}
.screen {
position: relative;
overflow: hidden;
width: 100%;
height: 100%;
border-radius: 27px;
color: #fff;
background: var(--primary-dark);
}
.status {
display: flex;
justify-content: space-between;
align-items: center;
height: 30px;
padding: 0 16px;
font-size: 11px;
font-weight: 900;
}
.sys { display: flex; gap: 5px; align-items: center; }
.sig, .wifi, .bat { display: inline-block; border: 1.7px solid currentColor; }
.sig { width: 12px; height: 10px; border-top: 0; border-left: 0; transform: skew(-8deg); }
.wifi { width: 13px; height: 8px; border-bottom: 0; border-left-color: transparent; border-right-color: transparent; border-radius: 14px 14px 0 0; }
.bat { width: 18px; height: 10px; border-radius: 2px; background: linear-gradient(90deg, currentColor 68%, transparent 68%); }
.splash {
position: relative;
height: calc(100% - 30px);
padding: 48px 16px 22px;
display: flex;
flex-direction: column;
isolation: isolate;
}
.splash::before {
content: "";
position: absolute;
inset: -34px -18px auto;
height: 320px;
z-index: -1;
background:
radial-gradient(circle at 28% 18%, rgba(245, 165, 36, 0.18), transparent 26%),
linear-gradient(135deg, rgba(255,255,255,0.12), transparent 34%),
linear-gradient(90deg, rgba(255,255,255,0.07) 1px, transparent 1px),
linear-gradient(rgba(255,255,255,0.07) 1px, transparent 1px);
background-size: auto, auto, 24px 24px, 24px 24px;
}
.topline {
display: flex;
justify-content: space-between;
color: rgba(255,255,255,0.68);
font-size: 10px;
font-weight: 900;
letter-spacing: 0.08em;
}
.lockup {
display: grid;
justify-items: center;
gap: 15px;
margin-top: 42px;
text-align: center;
}
.brand-mark {
position: relative;
display: grid;
place-items: center;
width: 74px;
height: 74px;
border-radius: 22px;
background: linear-gradient(145deg, #0b3b36, #00806f);
box-shadow: 0 24px 58px rgba(0,0,0,0.36), 0 0 0 1px rgba(255,255,255,0.16) inset;
}
.brand-mark::before,
.brand-mark::after {
content: "";
position: absolute;
width: 20px;
height: 42px;
border-radius: 999px;
background: rgba(255,255,255,0.92);
transform: rotate(28deg);
}
.brand-mark::before { left: 20px; top: 15px; }
.brand-mark::after { right: 20px; bottom: 15px; opacity: 0.72; }
.splash-name {
font-size: 25px;
line-height: 1.05;
font-weight: 950;
}
.splash-tag {
max-width: 230px;
margin-top: 8px;
color: rgba(255,255,255,0.68);
font-size: 12px;
line-height: 1.48;
font-weight: 750;
}
.scene {
position: relative;
height: 252px;
margin-top: 34px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.13);
overflow: hidden;
background:
linear-gradient(90deg, rgba(255,255,255,0.07) 1px, transparent 1px),
linear-gradient(rgba(255,255,255,0.07) 1px, transparent 1px),
linear-gradient(145deg, #062425, #0a5f56 58%, #103a36);
background-size: 22px 22px, 22px 22px, auto;
box-shadow: 0 22px 50px rgba(0,0,0,0.34);
}
.device {
position: absolute;
left: 18px;
top: 28px;
width: 128px;
height: 188px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.2);
background: rgba(3, 21, 24, 0.72);
box-shadow: 0 18px 36px rgba(0,0,0,0.28);
}
.device span {
display: block;
height: 8px;
margin: 8px 14px;
border-radius: 999px;
background: rgba(255,255,255,0.2);
}
.device span:first-child {
width: 72%;
margin-top: 26px;
background: rgba(255,255,255,0.78);
}
.amount {
position: absolute;
left: 14px;
right: 14px;
bottom: 18px;
padding: 11px;
border-radius: 8px;
background: rgba(255,255,255,0.08);
font-size: 19px;
font-weight: 950;
}
.amount small {
display: block;
margin-bottom: 3px;
color: rgba(255,255,255,0.52);
font-size: 9px;
font-weight: 900;
letter-spacing: 0.05em;
}
.asset-card {
position: absolute;
right: 15px;
top: 42px;
width: 156px;
padding: 13px;
border-radius: 8px;
background: rgba(255,255,255,0.94);
color: var(--ink);
box-shadow: 0 18px 42px rgba(0,0,0,0.28);
}
.asset-card.secondary {
top: 132px;
right: 34px;
width: 174px;
}
.chip {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 3px 9px;
border-radius: 999px;
color: #8a5a00;
background: var(--gold-soft);
font-size: 11px;
font-weight: 950;
}
.asset-card h3 {
margin: 8px 0 4px;
font-size: 15px;
line-height: 1.18;
}
.asset-card p {
margin: 0;
color: var(--muted);
font-size: 11px;
line-height: 1.4;
font-weight: 750;
}
.float-pill {
position: absolute;
left: 18px;
bottom: 18px;
min-height: 32px;
padding: 7px 10px;
border-radius: 999px;
color: #d7fff7;
background: rgba(0, 91, 79, 0.75);
border: 1px solid rgba(255,255,255,0.12);
font-size: 11px;
font-weight: 950;
}
.splash-footer {
margin-top: auto;
display: grid;
gap: 11px;
text-align: center;
}
.progress {
width: 118px;
height: 4px;
margin: 0 auto;
border-radius: 999px;
background: rgba(255,255,255,0.14);
overflow: hidden;
}
.progress::after {
content: "";
display: block;
width: 66%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #dffcf4, var(--gold));
}
.small-copy {
color: rgba(255,255,255,0.68);
font-size: 12px;
font-weight: 750;
}
.asset-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-top: 16px;
}
.asset {
min-height: 138px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
display: grid;
align-content: space-between;
gap: 12px;
}
.asset.dark {
color: #fff;
background: var(--primary-dark);
border-color: rgba(255,255,255,0.16);
}
.asset-title {
font-size: 13px;
font-weight: 950;
}
.asset-meta {
color: var(--muted);
font-size: 11px;
line-height: 1.4;
font-weight: 700;
}
.asset.dark .asset-meta { color: rgba(255,255,255,0.62); }
.app-icons {
display: flex;
gap: 14px;
align-items: end;
margin-top: 16px;
}
.app-icon {
position: relative;
border-radius: 22%;
background:
linear-gradient(145deg, #0b3b36, #00806f);
box-shadow: 0 16px 38px rgba(0, 91, 79, 0.26);
}
.app-icon::before,
.app-icon::after {
content: "";
position: absolute;
border-radius: 999px;
background: rgba(255,255,255,0.92);
transform: rotate(28deg);
}
.app-icon::before { left: 27%; top: 20%; width: 22%; height: 56%; }
.app-icon::after { right: 27%; bottom: 20%; width: 22%; height: 56%; opacity: 0.72; }
.app-icon.lg { width: 96px; height: 96px; }
.app-icon.md { width: 72px; height: 72px; }
.app-icon.sm { width: 48px; height: 48px; }
.spec-list {
display: grid;
gap: 10px;
margin-top: 16px;
}
.spec {
display: grid;
grid-template-columns: 180px minmax(0, 1fr);
gap: 12px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
font-size: 13px;
}
.spec strong {
min-width: 0;
font-weight: 950;
overflow-wrap: anywhere;
}
.spec span {
min-width: 0;
color: var(--muted);
line-height: 1.45;
font-weight: 650;
overflow-wrap: anywhere;
}
.motion {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-top: 16px;
}
.frame {
padding: 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
}
.frame strong {
display: block;
margin-bottom: 6px;
font-size: 13px;
}
.frame span {
color: var(--muted);
font-size: 12px;
line-height: 1.45;
font-weight: 650;
}
.mini-scene {
height: 72px;
margin-bottom: 10px;
border-radius: 8px;
background:
linear-gradient(90deg, rgba(255,255,255,0.08) 1px, transparent 1px),
linear-gradient(rgba(255,255,255,0.08) 1px, transparent 1px),
linear-gradient(145deg, #062425, #0a5f56);
background-size: 18px 18px, 18px 18px, auto;
}
.color-row {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
margin-top: 16px;
}
.swatch {
min-height: 76px;
padding: 10px;
border-radius: 8px;
border: 1px solid rgba(15,23,42,0.1);
display: grid;
align-content: end;
color: #fff;
font-size: 11px;
font-weight: 900;
}
.swatch.light { color: var(--ink); }
@media (max-width: 1180px) {
.hero,
.grid { grid-template-columns: 1fr; }
.phone { margin-left: 0; }
}
@media (max-width: 760px) {
.wrap { padding: 22px 12px 40px; }
h1 { font-size: 28px; }
.asset-grid,
.motion,
.color-row { grid-template-columns: 1fr; }
.spec { grid-template-columns: 1fr; }
.phone { width: min(316px, 100%); }
}
</style>
</head>
<body>
<main class="wrap">
<section class="hero">
<div class="panel hero-main">
<span class="kicker">Splash asset system</span>
<h1>X-Financial Expense 启动页素材系统</h1>
<p>这页专门给 Android 开机页使用,不直接复用业务页面卡片。素材分成品牌锁定、深色背景、票据场景、启动动效和 Android 落地规格,后续开发 Compose 或原生启动页时按这里取值。</p>
</div>
<aside class="panel hero-side">
<div class="checkline"><div class="icon">01</div><div><strong>启动页先看品牌</strong><span>Logo、产品名和安全工作台定位占主导不放登录表单。</span></div></div>
<div class="checkline"><div class="icon gold">02</div><div><strong>业务只做隐喻</strong><span>票据、审批、金额作为启动素材出现,不承载真实操作。</span></div></div>
<div class="checkline"><div class="icon blue">03</div><div><strong>可直接落 Android</strong><span>提供颜色、图标尺寸、动效节奏和 SplashScreen API 参数。</span></div></div>
</aside>
</section>
<section class="grid">
<div class="panel section">
<h2>启动页预览</h2>
<p>用于 9:16 Android 手机首屏,登录态检查完成后跳转登录页或首页。</p>
<div style="height:16px"></div>
<div class="phone">
<div class="screen">
<div class="status"><span>9:41</span><span class="sys"><i class="sig"></i><i class="wifi"></i><i class="bat"></i></span></div>
<div class="splash">
<div class="topline"><span>SECURE EXPENSE</span><span>ANDROID</span></div>
<div class="lockup">
<div class="brand-mark"></div>
<div>
<div class="splash-name">X-Financial<br/>Expense</div>
<div class="splash-tag">出差申请、票据采集、报销提交、移动审批</div>
</div>
</div>
<div class="scene">
<div class="device">
<span></span><span></span><span></span>
<div class="amount"><small>本月待处理</small>¥ 3,280</div>
</div>
<div class="asset-card">
<span class="chip">AI 识别</span>
<h3>票据已归类</h3>
<p>5 张票据 · 2 项待补</p>
</div>
<div class="asset-card secondary">
<h3>审批流已同步</h3>
<p>财务复核中 · 待提醒</p>
</div>
<div class="float-pill">拍照上传</div>
</div>
<div class="splash-footer">
<div class="progress"></div>
<div class="small-copy">正在加载安全工作台</div>
</div>
</div>
</div>
</div>
</div>
<div>
<section class="panel section">
<h2>品牌与 App 图标</h2>
<p>App 图标保留双斜票据形态,避免直接使用文字。启动页 Logo 使用 74dp 等比缩放,图标安全区不低于 12dp。</p>
<div class="app-icons">
<div class="app-icon lg"></div>
<div class="app-icon md"></div>
<div class="app-icon sm"></div>
</div>
<div class="asset-grid">
<div class="asset"><div class="brand-mark" style="width:58px;height:58px;border-radius:17px"></div><div><div class="asset-title">启动 Logo</div><div class="asset-meta">74dp / 58dp / 44dp 三档,深色背景使用。</div></div></div>
<div class="asset dark"><div class="app-icon md"></div><div><div class="asset-title">Launcher Icon</div><div class="asset-meta">前景图形居中,保留 Android 自适应图标裁切空间。</div></div></div>
<div class="asset"><div class="chip">AI 识别</div><div><div class="asset-title">业务状态 Chip</div><div class="asset-meta">仅作为视觉线索,不做主操作入口。</div></div></div>
</div>
</section>
<section class="panel section">
<h2>颜色与背景</h2>
<p>启动页走深色金融安全感。绿色做品牌,金色只用于识别和进度强调,蓝色用于信息状态。</p>
<div class="color-row">
<div class="swatch" style="background:#061416">#061416<br/>启动底色</div>
<div class="swatch" style="background:#006b5e">#006B5E<br/>品牌主色</div>
<div class="swatch" style="background:#0f766e">#0F766E<br/>场景渐变</div>
<div class="swatch" style="background:#f5a524;color:#251500">#F5A524<br/>识别强调</div>
<div class="swatch light" style="background:#f8fafc">#F8FAFC<br/>票据纸面</div>
</div>
</section>
<section class="panel section">
<h2>素材拆分</h2>
<p>开发落地时建议拆成 5 个可复用素材层Compose 中可分别写成 Composable。</p>
<div class="asset-grid">
<div class="asset dark"><div class="mini-scene"></div><div><div class="asset-title">背景层</div><div class="asset-meta">深色底、细网格、顶部柔光,静态即可。</div></div></div>
<div class="asset"><div class="asset-card" style="position:static;width:auto;box-shadow:none;border:1px solid var(--line)"><span class="chip">AI 识别</span><h3>票据已归类</h3><p>5 张票据 · 2 项待补</p></div><div><div class="asset-title">票据层</div><div class="asset-meta">表达拍照上传和 OCR不显示真实敏感票据信息。</div></div></div>
<div class="asset"><div class="float-pill" style="position:static;color:#006b5e;background:var(--mint);border:0;width:max-content">拍照上传</div><div><div class="asset-title">状态层</div><div class="asset-meta">启动时轻提示900ms 后淡出。</div></div></div>
</div>
</section>
<section class="panel section">
<h2>启动动效节奏</h2>
<p>控制在 900ms 左右,不阻塞用户进入登录或首页。系统检测慢时只延长进度条,不重复播放动画。</p>
<div class="motion">
<div class="frame"><div class="mini-scene"></div><strong>0ms</strong><span>深色底和网格先出现,避免白屏。</span></div>
<div class="frame"><div class="mini-scene"></div><strong>160ms</strong><span>Logo scale 0.92 到 1透明度进入。</span></div>
<div class="frame"><div class="mini-scene"></div><strong>320ms</strong><span>产品名和定位文案淡入。</span></div>
<div class="frame"><div class="mini-scene"></div><strong>520ms</strong><span>票据场景上移 8dp进度条启动。</span></div>
</div>
</section>
<section class="panel section">
<h2>Android 落地规格</h2>
<p>先走 Android 原生启动,再进入 Compose 首页。冷启动用系统 SplashScreen业务素材用于第一个 Activity 的过渡页。</p>
<div class="spec-list">
<div class="spec"><strong>windowSplashScreenBackground</strong><span>#061416。系统启动页避免白屏和业务过渡页底色一致。</span></div>
<div class="spec"><strong>windowSplashScreenAnimatedIcon</strong><span>只放 App 图标前景,不放产品文字,防止小屏裁切。</span></div>
<div class="spec"><strong>过渡页时长</strong><span>登录态检查完成即跳转;正常 600-900ms异常最长 1800ms 后进入登录页。</span></div>
<div class="spec"><strong>Compose 拆分</strong><span>SplashBackground、BrandLockup、ExpenseScene、LoadingRail 四个组件。</span></div>
<div class="spec"><strong>无障碍</strong><span>启动页只设置应用名称 contentDescription业务装饰图不参与朗读。</span></div>
</div>
</section>
</div>
</section>
</main>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 KiB

39
remove_bg.py Normal file
View File

@@ -0,0 +1,39 @@
import sys
from PIL import Image
def remove_white_bg(input_path, output_path, threshold=235):
img = Image.open(input_path).convert("RGBA")
data = img.getdata()
new_data = []
for item in data:
r, g, b, a = item
avg = (r + g + b) / 3.0
# If it's very close to white, make it transparent
if avg > threshold and min(r,g,b) > threshold - 10:
# Feathering the alpha channel
# 255 = fully transparent
# threshold = fully opaque
factor = (avg - threshold) / (255 - threshold)
alpha = int(255 * (1 - factor))
# Clamp alpha
alpha = max(0, min(255, alpha))
# We keep the pixel white to avoid dark fringes, but lower its opacity
new_data.append((255, 255, 255, alpha))
else:
new_data.append(item)
img.putdata(new_data)
# Optional: crop the image to its bounding box
bbox = img.getbbox()
if bbox:
img = img.crop(bbox)
img.save(output_path, "PNG")
if __name__ == "__main__":
remove_white_bg(sys.argv[1], sys.argv[2])

45
remove_bg_fast.ps1 Normal file
View File

@@ -0,0 +1,45 @@
$code = @"
using System;
using System.Drawing;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
public class ImageProcessor {
public static void RemoveWhiteBg(string inputFile, string outputFile, int threshold) {
Bitmap bmp = new Bitmap(inputFile);
Rectangle rect = new Rectangle(0, 0, bmp.Width, bmp.Height);
BitmapData bmpData = bmp.LockBits(rect, ImageLockMode.ReadWrite, PixelFormat.Format32bppArgb);
int bytes = Math.Abs(bmpData.Stride) * bmp.Height;
byte[] rgbValues = new byte[bytes];
Marshal.Copy(bmpData.Scan0, rgbValues, 0, bytes);
for (int i = 0; i < rgbValues.Length; i += 4) {
byte b = rgbValues[i];
byte g = rgbValues[i + 1];
byte r = rgbValues[i + 2];
byte a = rgbValues[i + 3];
float avg = (r + g + b) / 3f;
if (avg > threshold && r > threshold - 10 && g > threshold - 10 && b > threshold - 10) {
float factor = (avg - threshold) / (255f - threshold);
int alpha = (int)(255 * (1 - factor));
if (alpha < 0) alpha = 0;
if (alpha > 255) alpha = 255;
rgbValues[i] = 255;
rgbValues[i + 1] = 255;
rgbValues[i + 2] = 255;
rgbValues[i + 3] = (byte)alpha;
}
}
Marshal.Copy(rgbValues, 0, bmpData.Scan0, bytes);
bmp.UnlockBits(bmpData);
bmp.Save(outputFile, ImageFormat.Png);
bmp.Dispose();
}
}
"@
Add-Type -TypeDefinition $code -ReferencedAssemblies System.Drawing
[ImageProcessor]::RemoveWhiteBg("d:\Code\Project\X-Financial\web\src\assets\images\raw_documents.png", "d:\Code\Project\X-Financial\web\src\assets\images\hero-financial-decor.png", 235)
Write-Host "Done"

View File

@@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OCR_VENV_DIR="${OCR_VENV_DIR:-${ROOT_DIR}/.venv-ocr312}"
PYTHON_BIN="${PYTHON_BIN:-python3.12}"
PADDLEPADDLE_GPU_VERSION="${PADDLEPADDLE_GPU_VERSION:-3.3.0}"
PADDLEOCR_VERSION="${PADDLEOCR_VERSION:-3.6.0}"
PADDLE_GPU_INDEX_URL="${PADDLE_GPU_INDEX_URL:-https://www.paddlepaddle.org.cn/packages/stable/cu126/}"
if ! command -v "${PYTHON_BIN}" >/dev/null 2>&1; then
echo "python3.12 不存在,请先安装 Python 3.12。" >&2
exit 1
fi
apt-get update
apt-get install -y --no-install-recommends libgl1 libglib2.0-0 poppler-utils
rm -rf "${OCR_VENV_DIR}"
"${PYTHON_BIN}" -m venv "${OCR_VENV_DIR}"
"${OCR_VENV_DIR}/bin/pip" install --upgrade pip
"${OCR_VENV_DIR}/bin/pip" install \
"paddlepaddle-gpu==${PADDLEPADDLE_GPU_VERSION}" \
-i "${PADDLE_GPU_INDEX_URL}"
"${OCR_VENV_DIR}/bin/pip" install "paddleocr==${PADDLEOCR_VERSION}"
"${OCR_VENV_DIR}/bin/python" - <<'PY'
import paddle
print("PaddlePaddle:", paddle.__version__)
print("CUDA compiled:", paddle.is_compiled_with_cuda())
print("CUDA device count:", paddle.device.cuda.device_count())
paddle.utils.run_check()
PY
echo "PaddleOCR GPU runtime ${PADDLEOCR_VERSION} 已安装到 ${OCR_VENV_DIR}"

View File

@@ -21,6 +21,7 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--lang", default="ch") parser.add_argument("--lang", default="ch")
parser.add_argument("--text-detection-model", default="PP-OCRv5_mobile_det") parser.add_argument("--text-detection-model", default="PP-OCRv5_mobile_det")
parser.add_argument("--text-recognition-model", default="PP-OCRv5_mobile_rec") parser.add_argument("--text-recognition-model", default="PP-OCRv5_mobile_rec")
parser.add_argument("--device", default=os.environ.get("OCR_DEVICE", ""))
parser.add_argument("--enable-mkldnn", action="store_true") parser.add_argument("--enable-mkldnn", action="store_true")
return parser.parse_args() return parser.parse_args()
@@ -100,16 +101,20 @@ def build_document(input_path: str, results: list[Any]) -> dict[str, Any]:
def main() -> int: def main() -> int:
args = parse_args() args = parse_args()
ocr = PaddleOCR( ocr_options = {
text_detection_model_name=args.text_detection_model, "text_detection_model_name": args.text_detection_model,
text_recognition_model_name=args.text_recognition_model, "text_recognition_model_name": args.text_recognition_model,
use_doc_orientation_classify=False, "use_doc_orientation_classify": False,
use_doc_unwarping=False, "use_doc_unwarping": False,
use_textline_orientation=False, "use_textline_orientation": False,
lang=args.lang, "lang": args.lang,
# PaddlePaddle 3.3.x CPU oneDNN can fail on PP-OCRv5 static inference. # PaddlePaddle 3.3.x CPU oneDNN can fail on PP-OCRv5 static inference.
enable_mkldnn=args.enable_mkldnn, "enable_mkldnn": args.enable_mkldnn,
) }
configured_device = str(args.device or "").strip()
if configured_device:
ocr_options["device"] = configured_device
ocr = PaddleOCR(**ocr_options)
documents = [] documents = []
for input_path in args.inputs: for input_path in args.inputs:

View File

@@ -19,25 +19,25 @@ ONLYOFFICE_FIELD_NAMES = {
_settings_cache: Settings | None = None _settings_cache: Settings | None = None
_settings_cache_signature: tuple[tuple[str, bool, int | None, int | None], ...] | None = None _settings_cache_signature: tuple[tuple[str, bool, int | None, int | None], ...] | None = None
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=DEFAULT_ENV_FILES, env_file=DEFAULT_ENV_FILES,
env_file_encoding="utf-8", env_file_encoding="utf-8",
extra="ignore", extra="ignore",
) )
app_name: str = Field(default="X-Financial Server", alias="APP_NAME") app_name: str = Field(default="X-Financial Server", alias="APP_NAME")
app_env: str = Field(default="local", alias="APP_ENV") app_env: str = Field(default="local", alias="APP_ENV")
app_debug: bool = Field(default=True, alias="APP_DEBUG") app_debug: bool = Field(default=True, alias="APP_DEBUG")
setup_completed: bool = Field(default=False, alias="SETUP_COMPLETED") setup_completed: bool = Field(default=False, alias="SETUP_COMPLETED")
company_name: str = Field(default="", alias="COMPANY_NAME") company_name: str = Field(default="", alias="COMPANY_NAME")
company_code: str = Field(default="", alias="COMPANY_CODE") company_code: str = Field(default="", alias="COMPANY_CODE")
admin_email: str = Field(default="", alias="ADMIN_EMAIL") admin_email: str = Field(default="", alias="ADMIN_EMAIL")
web_host: str = Field(default="0.0.0.0", alias="WEB_HOST") web_host: str = Field(default="0.0.0.0", alias="WEB_HOST")
web_port: int = Field(default=5173, alias="WEB_PORT") web_port: int = Field(default=5173, alias="WEB_PORT")
app_host: str = Field(default="0.0.0.0", alias="SERVER_HOST") app_host: str = Field(default="0.0.0.0", alias="SERVER_HOST")
app_port: int = Field(default=8000, alias="SERVER_PORT") app_port: int = Field(default=8000, alias="SERVER_PORT")
@@ -48,21 +48,21 @@ class Settings(BaseSettings):
alias="BACKGROUND_SCHEDULERS_ENABLED", alias="BACKGROUND_SCHEDULERS_ENABLED",
) )
api_v1_prefix: str = Field(default="/api/v1", alias="API_V1_PREFIX") api_v1_prefix: str = Field(default="/api/v1", alias="API_V1_PREFIX")
postgres_host: str = Field(default="127.0.0.1", alias="POSTGRES_HOST") postgres_host: str = Field(default="127.0.0.1", alias="POSTGRES_HOST")
postgres_port: int = Field(default=5432, alias="POSTGRES_PORT") postgres_port: int = Field(default=5432, alias="POSTGRES_PORT")
postgres_db: str = Field(default="x_financial", alias="POSTGRES_DB") postgres_db: str = Field(default="x_financial", alias="POSTGRES_DB")
postgres_user: str = Field(default="postgres", alias="POSTGRES_USER") postgres_user: str = Field(default="postgres", alias="POSTGRES_USER")
postgres_password: str = Field(default="postgres", alias="POSTGRES_PASSWORD") postgres_password: str = Field(default="postgres", alias="POSTGRES_PASSWORD")
database_url: str | None = Field(default=None, alias="DATABASE_URL") database_url: str | None = Field(default=None, alias="DATABASE_URL")
sqlalchemy_echo: bool = Field(default=False, alias="SQLALCHEMY_ECHO") sqlalchemy_echo: bool = Field(default=False, alias="SQLALCHEMY_ECHO")
sqlalchemy_pool_size: int = Field(default=10, alias="SQLALCHEMY_POOL_SIZE") sqlalchemy_pool_size: int = Field(default=10, alias="SQLALCHEMY_POOL_SIZE")
sqlalchemy_max_overflow: int = Field(default=20, alias="SQLALCHEMY_MAX_OVERFLOW") sqlalchemy_max_overflow: int = Field(default=20, alias="SQLALCHEMY_MAX_OVERFLOW")
sqlalchemy_pool_timeout: int = Field(default=30, alias="SQLALCHEMY_POOL_TIMEOUT") sqlalchemy_pool_timeout: int = Field(default=30, alias="SQLALCHEMY_POOL_TIMEOUT")
redis_url: str | None = Field(default=None, alias="REDIS_URL") redis_url: str | None = Field(default=None, alias="REDIS_URL")
cors_origins: list[str] = Field(default_factory=list, alias="CORS_ORIGINS") cors_origins: list[str] = Field(default_factory=list, alias="CORS_ORIGINS")
vite_api_base_url: str = Field( vite_api_base_url: str = Field(
default="http://127.0.0.1:8000/api/v1", alias="VITE_API_BASE_URL" default="http://127.0.0.1:8000/api/v1", alias="VITE_API_BASE_URL"
) )
@@ -77,6 +77,7 @@ class Settings(BaseSettings):
log_file_enabled: bool = Field(default=True, alias="LOG_FILE_ENABLED") log_file_enabled: bool = Field(default=True, alias="LOG_FILE_ENABLED")
storage_root_dir: str = Field(default="storage", alias="STORAGE_ROOT_DIR") storage_root_dir: str = Field(default="storage", alias="STORAGE_ROOT_DIR")
ocr_python_bin: str = Field(default="", alias="OCR_PYTHON_BIN") ocr_python_bin: str = Field(default="", alias="OCR_PYTHON_BIN")
ocr_device: str = Field(default="", alias="OCR_DEVICE")
ocr_timeout_seconds: int = Field(default=180, alias="OCR_TIMEOUT_SECONDS") ocr_timeout_seconds: int = Field(default=180, alias="OCR_TIMEOUT_SECONDS")
ocr_max_file_size_mb: int = Field(default=20, alias="OCR_MAX_FILE_SIZE_MB") ocr_max_file_size_mb: int = Field(default=20, alias="OCR_MAX_FILE_SIZE_MB")
ocr_max_concurrent_workers: int = Field(default=1, alias="OCR_MAX_CONCURRENT_WORKERS") ocr_max_concurrent_workers: int = Field(default=1, alias="OCR_MAX_CONCURRENT_WORKERS")
@@ -98,7 +99,7 @@ class Settings(BaseSettings):
def resolved_database_url(self) -> str: def resolved_database_url(self) -> str:
if self.database_url: if self.database_url:
return self.database_url return self.database_url
return ( return (
f"postgresql+psycopg://{self.postgres_user}:{self.postgres_password}" f"postgresql+psycopg://{self.postgres_user}:{self.postgres_password}"
f"@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}" f"@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}"

View File

@@ -68,8 +68,16 @@ class ExpenseClaim(Base):
return None return None
if self.employee.manager is not None and self.employee.manager.name: if self.employee.manager is not None and self.employee.manager.name:
return str(self.employee.manager.name).strip() or None return str(self.employee.manager.name).strip() or None
if self.employee.organization_unit is not None and self.employee.organization_unit.manager_name:
return str(self.employee.organization_unit.manager_name).strip() or None
return None return None
@property
def finance_owner_name(self) -> str | None:
if self.employee is None or not self.employee.finance_owner_name:
return None
return str(self.employee.finance_owner_name).strip() or None
@property @property
def role_labels(self) -> list[str]: def role_labels(self) -> list[str]:
if self.employee is None or not self.employee.roles: if self.employee is None or not self.employee.roles:

View File

@@ -4,7 +4,9 @@ from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from typing import Any from typing import Any
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.services.expense_claim_budget_risk_flags import dedupe_budget_risk_flags
class ReimbursementCreate(BaseModel): class ReimbursementCreate(BaseModel):
@@ -147,6 +149,8 @@ class ExpenseClaimRead(BaseModel):
employee_position: str | None = None employee_position: str | None = None
employee_grade: str | None = None employee_grade: str | None = None
manager_name: str | None = None manager_name: str | None = None
finance_owner_name: str | None = None
finance_approver_name: str | None = None
budget_approver_name: str | None = None budget_approver_name: str | None = None
budget_approver_grade: str | None = None budget_approver_grade: str | None = None
budget_approver_role_code: str | None = None budget_approver_role_code: str | None = None
@@ -167,6 +171,13 @@ class ExpenseClaimRead(BaseModel):
updated_at: datetime updated_at: datetime
items: list[ExpenseClaimItemRead] = Field(default_factory=list) items: list[ExpenseClaimItemRead] = Field(default_factory=list)
@field_validator("risk_flags_json", mode="before")
@classmethod
def dedupe_budget_risk_flags_for_read(cls, value: Any) -> list[Any]:
if isinstance(value, list):
return dedupe_budget_risk_flags(value)
return []
class ExpenseClaimActionResponse(BaseModel): class ExpenseClaimActionResponse(BaseModel):
message: str message: str

View File

@@ -250,6 +250,45 @@ class ExpenseClaimAccessPolicy:
return role_code return role_code
return BUDGET_MONITOR_ROLE_CODE return BUDGET_MONITOR_ROLE_CODE
@staticmethod
def resolve_claim_finance_owner_name(claim: ExpenseClaim) -> str:
employee = claim.employee
if employee is not None and employee.finance_owner_name:
return str(employee.finance_owner_name).strip()
return ""
def resolve_finance_approver(self, claim: ExpenseClaim) -> Employee | None:
claim_employee_id = str(claim.employee_id or "").strip()
base_stmt = (
select(Employee)
.options(selectinload(Employee.roles))
.where(Employee.roles.any(Role.role_code == "finance"))
)
if claim_employee_id:
base_stmt = base_stmt.where(Employee.id != claim_employee_id)
finance_owner_name = self.resolve_claim_finance_owner_name(claim)
if finance_owner_name:
named_finance = self.db.scalar(
base_stmt
.where(Employee.name == finance_owner_name)
.order_by(Employee.name.asc(), Employee.employee_no.asc())
.limit(1)
)
if named_finance is not None:
return named_finance
owner_matched_finance = self.db.scalar(
base_stmt
.where(func.lower(func.coalesce(Employee.finance_owner_name, "")) == finance_owner_name.lower())
.order_by(Employee.name.asc(), Employee.employee_no.asc())
.limit(1)
)
if owner_matched_finance is not None:
return owner_matched_finance
return self.db.scalar(base_stmt.order_by(Employee.name.asc(), Employee.employee_no.asc()).limit(1))
def attach_budget_approval_snapshot(self, claim: ExpenseClaim | None) -> ExpenseClaim | None: def attach_budget_approval_snapshot(self, claim: ExpenseClaim | None) -> ExpenseClaim | None:
if claim is None: if claim is None:
return None return None
@@ -269,9 +308,25 @@ class ExpenseClaimAccessPolicy:
) )
return claim return claim
def attach_finance_approval_snapshot(self, claim: ExpenseClaim | None) -> ExpenseClaim | None:
if claim is None:
return None
if str(claim.approval_stage or "").strip() != FINANCE_APPROVAL_STAGE:
return claim
finance_approver = self.resolve_finance_approver(claim)
if finance_approver is not None and finance_approver.name:
setattr(claim, "finance_approver_name", str(finance_approver.name).strip())
return claim
def attach_approval_snapshot(self, claim: ExpenseClaim | None) -> ExpenseClaim | None:
self.attach_budget_approval_snapshot(claim)
self.attach_finance_approval_snapshot(claim)
return claim
def attach_budget_approval_snapshots(self, claims: list[ExpenseClaim]) -> list[ExpenseClaim]: def attach_budget_approval_snapshots(self, claims: list[ExpenseClaim]) -> list[ExpenseClaim]:
for claim in claims: for claim in claims:
self.attach_budget_approval_snapshot(claim) self.attach_approval_snapshot(claim)
return claims return claims
@staticmethod @staticmethod
@@ -647,6 +702,11 @@ class ExpenseClaimAccessPolicy:
*, *,
include_approval_scope: bool = False, include_approval_scope: bool = False,
) -> Any: ) -> Any:
if current_user.is_admin:
if include_approval_scope:
return stmt
return stmt.where(~self.build_archived_claim_condition())
conditions = self.build_personal_claim_conditions(current_user) conditions = self.build_personal_claim_conditions(current_user)
role_codes = self.normalize_role_codes(current_user) role_codes = self.normalize_role_codes(current_user)

View File

@@ -17,6 +17,7 @@ from app.services.expense_claim_risk_stage import (
class ExpenseClaimApprovalRoutingMixin: class ExpenseClaimApprovalRoutingMixin:
_APPLICATION_BUDGET_REVIEW_USAGE_THRESHOLD = Decimal("90.00")
_BUDGET_REVIEW_RATINGS = {"block"} _BUDGET_REVIEW_RATINGS = {"block"}
_BUDGET_REVIEW_RISK_LEVELS = {"high", "critical"} _BUDGET_REVIEW_RISK_LEVELS = {"high", "critical"}
_ROUTE_RISK_SEVERITIES = {"medium", "high", "critical", "danger"} _ROUTE_RISK_SEVERITIES = {"medium", "high", "critical", "danger"}
@@ -63,7 +64,11 @@ class ExpenseClaimApprovalRoutingMixin:
) -> dict[str, Any]: ) -> dict[str, Any]:
business_stage = risk_business_stage_for_claim(is_application_claim=is_application_claim) business_stage = risk_business_stage_for_claim(is_application_claim=is_application_claim)
budget_result = BudgetService(self.db).analyze_claim_budget(claim) budget_result = BudgetService(self.db).analyze_claim_budget(claim)
budget_reasons = self._collect_budget_route_reasons(budget_result) budget_reasons = (
self._collect_application_budget_route_reasons(budget_result)
if is_application_claim
else self._collect_budget_route_reasons(budget_result)
)
current_risk_reasons = self._collect_current_route_risk_reasons( current_risk_reasons = self._collect_current_route_risk_reasons(
claim.risk_flags_json, claim.risk_flags_json,
business_stage=business_stage, business_stage=business_stage,
@@ -75,7 +80,9 @@ class ExpenseClaimApprovalRoutingMixin:
else [] else []
) )
reasons = self._dedupe_reasons( reasons = self._dedupe_reasons(
[*budget_reasons, *current_risk_reasons, *historical_risk_reasons] budget_reasons
if is_application_claim
else [*budget_reasons, *current_risk_reasons, *historical_risk_reasons]
) )
requires_budget_review = bool(reasons) requires_budget_review = bool(reasons)
route = ( route = (
@@ -86,11 +93,18 @@ class ExpenseClaimApprovalRoutingMixin:
else "finance" else "finance"
) )
label = "需要预算管理者复核" if requires_budget_review else "跳过预算管理者复核" label = "需要预算管理者复核" if requires_budget_review else "跳过预算管理者复核"
message = ( if is_application_claim:
"系统根据预算、当前风险和历史风险判断,该单据需要预算管理者二次确认。" message = (
if requires_budget_review "系统根据预算占用阈值判断,该申请单达到 90% 预算复核线,需要预算管理者二次确认。"
else "系统根据预算、当前风险和历史风险判断,该单据可跳过预算管理者复核。" if requires_budget_review
) else "系统根据预算占用阈值判断,该申请单未达到 90% 预算复核线,可跳过预算管理者复核。"
)
else:
message = (
"系统根据预算、当前风险和历史风险判断,该单据需要预算管理者二次确认。"
if requires_budget_review
else "系统根据预算、当前风险和历史风险判断,该单据可跳过预算管理者复核。"
)
return with_risk_business_stage( return with_risk_business_stage(
{ {
@@ -136,6 +150,20 @@ class ExpenseClaimApprovalRoutingMixin:
reasons.append(f"预计超预算 {over_budget_amount}") reasons.append(f"预计超预算 {over_budget_amount}")
return self._dedupe_reasons(reasons) return self._dedupe_reasons(reasons)
def _collect_application_budget_route_reasons(self, budget_result: dict[str, Any]) -> list[str]:
metrics = budget_result.get("metrics") if isinstance(budget_result.get("metrics"), dict) else {}
over_budget_amount = self._decimal(metrics.get("over_budget_amount"))
if over_budget_amount > Decimal("0.00"):
return [f"预计超预算 {over_budget_amount}"]
after_usage_rate = self._decimal(metrics.get("after_usage_rate"))
claim_amount_ratio = self._decimal(metrics.get("claim_amount_ratio"))
budget_usage_rate = max(after_usage_rate, claim_amount_ratio)
if budget_usage_rate >= self._APPLICATION_BUDGET_REVIEW_USAGE_THRESHOLD:
return [f"审批后预算占用达到 {budget_usage_rate}%,触发 90% 预算复核线"]
return []
def _collect_current_route_risk_reasons( def _collect_current_route_risk_reasons(
self, self,
risk_flags: list[Any] | None, risk_flags: list[Any] | None,

View File

@@ -352,9 +352,15 @@ class ExpenseClaimAttachmentOperationsMixin:
self._ensure_draft_claim(claim) self._ensure_draft_claim(claim)
self._ensure_mutable_claim_item(item) self._ensure_mutable_claim_item(item)
before_json = self._serialize_claim(claim) before_json = self._serialize_claim(claim)
previous_invoice_id = str(item.invoice_id or "").strip()
previous_name = self._attachment_presentation.resolve_display_name(item.invoice_id) previous_name = self._attachment_presentation.resolve_display_name(item.invoice_id)
self._attachment_storage.delete_item_files(item) self._attachment_storage.delete_item_files(item)
item.invoice_id = None item.invoice_id = None
claim.risk_flags_json = self._remove_deleted_attachment_risk_flags(
claim.risk_flags_json,
item_id=item.id,
invoice_id=previous_invoice_id,
)
self._sync_claim_from_items(claim) self._sync_claim_from_items(claim)
self._refresh_claim_pre_review_flags(claim, is_application_claim=False) self._refresh_claim_pre_review_flags(claim, is_application_claim=False)
@@ -379,6 +385,36 @@ class ExpenseClaimAttachmentOperationsMixin:
"attachment": None, "attachment": None,
} }
@staticmethod
def _remove_deleted_attachment_risk_flags(
risk_flags: Any,
*,
item_id: str | None,
invoice_id: str | None,
) -> list[Any]:
normalized_item_id = str(item_id or "").strip()
normalized_invoice_id = str(invoice_id or "").strip()
cleaned_flags: list[Any] = []
for flag in list(risk_flags or []):
if not isinstance(flag, dict):
cleaned_flags.append(flag)
continue
source = str(flag.get("source") or "").strip()
if source != "attachment_analysis":
cleaned_flags.append(flag)
continue
flag_item_id = str(flag.get("item_id") or flag.get("itemId") or "").strip()
flag_invoice_id = str(flag.get("invoice_id") or flag.get("invoiceId") or "").strip()
matches_deleted_item = bool(normalized_item_id and flag_item_id == normalized_item_id)
matches_deleted_invoice = bool(normalized_invoice_id and flag_invoice_id == normalized_invoice_id)
if matches_deleted_item or matches_deleted_invoice:
continue
cleaned_flags.append(flag)
return cleaned_flags
def _get_claim_item_or_raise( def _get_claim_item_or_raise(
self, self,
*, *,

View File

@@ -5,6 +5,7 @@ from typing import Any
from app.api.deps import CurrentUserContext from app.api.deps import CurrentUserContext
from app.models.financial_record import ExpenseClaim from app.models.financial_record import ExpenseClaim
from app.services.budget import BudgetService from app.services.budget import BudgetService
from app.services.expense_claim_budget_risk_flags import dedupe_budget_risk_flags
from app.services.expense_claim_risk_stage import enrich_risk_flag_semantics from app.services.expense_claim_risk_stage import enrich_risk_flag_semantics
@@ -104,7 +105,7 @@ class ExpenseClaimBudgetFlowMixin:
else flag else flag
for flag in next_flags for flag in next_flags
] ]
return [*list(risk_flags or []), *enriched_flags] return dedupe_budget_risk_flags([*list(risk_flags or []), *enriched_flags])
@staticmethod @staticmethod
def _resolve_budget_operator(current_user: CurrentUserContext) -> str: def _resolve_budget_operator(current_user: CurrentUserContext) -> str:

View File

@@ -0,0 +1,47 @@
from __future__ import annotations
from typing import Any
_DEDUPED_BUDGET_RISK_EVENT_TYPES = {
"budget_frozen",
"budget_insufficient",
"budget_missing",
"budget_warning",
}
def dedupe_budget_risk_flags(flags: list[Any] | None) -> list[Any]:
"""Collapse repeated budget risk warnings while preserving non-risk audit flags."""
deduped: list[Any] = []
key_to_index: dict[tuple[str, str, str, str], int] = {}
for flag in list(flags or []):
key = budget_risk_flag_key(flag)
if key is None:
deduped.append(flag)
continue
existing_index = key_to_index.get(key)
if existing_index is None:
key_to_index[key] = len(deduped)
deduped.append(flag)
continue
deduped[existing_index] = flag
return deduped
def budget_risk_flag_key(flag: Any) -> tuple[str, str, str, str] | None:
if not isinstance(flag, dict):
return None
source = str(flag.get("source") or "").strip()
event_type = str(flag.get("event_type") or flag.get("eventType") or "").strip()
if source != "budget_control" or event_type not in _DEDUPED_BUDGET_RISK_EVENT_TYPES:
return None
allocation_key = str(flag.get("allocation_id") or flag.get("allocationId") or "").strip()
budget_no = str(flag.get("budget_no") or flag.get("budgetNo") or "").strip()
subject_code = str(flag.get("subject_code") or flag.get("subjectCode") or "").strip()
return (source, event_type, allocation_key or budget_no, subject_code)

View File

@@ -130,7 +130,10 @@ class ExpenseClaimRiskReviewMixin(
attention_reasons.extend(scene_policy_review["blocking_reasons"]) attention_reasons.extend(scene_policy_review["blocking_reasons"])
review_flags.extend(scene_policy_review["flags"]) review_flags.extend(scene_policy_review["flags"])
platform_risk_review = self.evaluate_platform_risk_rules(claim) platform_risk_review = self.evaluate_platform_risk_rules(
claim,
business_stage="reimbursement",
)
attention_reasons.extend(platform_risk_review["blocking_reasons"]) attention_reasons.extend(platform_risk_review["blocking_reasons"])
platform_risk_flags = list(platform_risk_review["flags"]) platform_risk_flags = list(platform_risk_review["flags"])
review_flags.extend(platform_risk_flags) review_flags.extend(platform_risk_flags)

View File

@@ -204,6 +204,7 @@ class ExpenseClaimService(
.options( .options(
selectinload(ExpenseClaim.items), selectinload(ExpenseClaim.items),
selectinload(ExpenseClaim.employee).selectinload(Employee.manager), selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
selectinload(ExpenseClaim.employee).selectinload(Employee.roles), selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
) )
.order_by(ExpenseClaim.created_at.desc(), ExpenseClaim.occurred_at.desc()) .order_by(ExpenseClaim.created_at.desc(), ExpenseClaim.occurred_at.desc())
@@ -217,6 +218,7 @@ class ExpenseClaimService(
.options( .options(
selectinload(ExpenseClaim.items), selectinload(ExpenseClaim.items),
selectinload(ExpenseClaim.employee).selectinload(Employee.manager), selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
selectinload(ExpenseClaim.employee).selectinload(Employee.roles), selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
) )
.order_by(ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc()) .order_by(ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc())
@@ -230,6 +232,7 @@ class ExpenseClaimService(
.options( .options(
selectinload(ExpenseClaim.items), selectinload(ExpenseClaim.items),
selectinload(ExpenseClaim.employee).selectinload(Employee.manager), selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
selectinload(ExpenseClaim.employee).selectinload(Employee.roles), selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
) )
.order_by(ExpenseClaim.updated_at.desc(), ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc()) .order_by(ExpenseClaim.updated_at.desc(), ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc())
@@ -243,12 +246,13 @@ class ExpenseClaimService(
.options( .options(
selectinload(ExpenseClaim.items), selectinload(ExpenseClaim.items),
selectinload(ExpenseClaim.employee).selectinload(Employee.manager), selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
selectinload(ExpenseClaim.employee).selectinload(Employee.roles), selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
) )
.where(ExpenseClaim.id == claim_id) .where(ExpenseClaim.id == claim_id)
) )
stmt = self._access_policy.apply_claim_scope(stmt, current_user, include_approval_scope=True) stmt = self._access_policy.apply_claim_scope(stmt, current_user, include_approval_scope=True)
return self._access_policy.attach_budget_approval_snapshot(self.db.scalar(stmt)) return self._access_policy.attach_approval_snapshot(self.db.scalar(stmt))
def can_view_budget_analysis(self, current_user: CurrentUserContext, claim: ExpenseClaim | None = None) -> bool: def can_view_budget_analysis(self, current_user: CurrentUserContext, claim: ExpenseClaim | None = None) -> bool:
if claim is None: if claim is None:
@@ -1019,5 +1023,3 @@ class ExpenseClaimService(

View File

@@ -248,6 +248,7 @@ class OcrService:
return "|".join( return "|".join(
[ [
self.settings.ocr_language, self.settings.ocr_language,
self.settings.ocr_device,
self.settings.ocr_text_detection_model, self.settings.ocr_text_detection_model,
self.settings.ocr_text_recognition_model, self.settings.ocr_text_recognition_model,
digest, digest,
@@ -333,6 +334,9 @@ class OcrService:
"--text-recognition-model", "--text-recognition-model",
self.settings.ocr_text_recognition_model, self.settings.ocr_text_recognition_model,
] ]
configured_device = str(self.settings.ocr_device or "").strip()
if configured_device:
command.extend(["--device", configured_device])
for path in input_paths: for path in input_paths:
command.extend(["--input", str(path)]) command.extend(["--input", str(path)])

View File

@@ -52,6 +52,8 @@ ONTOLOGY_FIELD_ALIASES: dict[str, tuple[str, ...]] = {
"employee_no": ("employeeNo",), "employee_no": ("employeeNo",),
"employee_position": ("position", "employeePosition"), "employee_position": ("position", "employeePosition"),
"manager_name": ("managerName", "direct_manager_name", "directManagerName"), "manager_name": ("managerName", "direct_manager_name", "directManagerName"),
"finance_owner_name": ("financeOwnerName",),
"finance_approver_name": ("financeApproverName",),
} }
CANONICAL_ONTOLOGY_FIELDS = frozenset(ONTOLOGY_FIELD_ALIASES) | frozenset( CANONICAL_ONTOLOGY_FIELDS = frozenset(ONTOLOGY_FIELD_ALIASES) | frozenset(
@@ -66,7 +68,6 @@ CANONICAL_ONTOLOGY_FIELDS = frozenset(ONTOLOGY_FIELD_ALIASES) | frozenset(
"control_action", "control_action",
"employee_location", "employee_location",
"employee_risk_profile", "employee_risk_profile",
"finance_owner_name",
"document_id", "document_id",
"application_claim_id", "application_claim_id",
"application_claim_no", "application_claim_no",

View File

@@ -5,7 +5,7 @@ from dataclasses import dataclass, field
from datetime import UTC, date, datetime, timedelta from datetime import UTC, date, datetime, timedelta
from typing import Any from typing import Any
from sqlalchemy import select from sqlalchemy import or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.db.base import Base from app.db.base import Base
@@ -114,6 +114,7 @@ class SystemDashboardService:
succeeded_runs = sum(1 for run in runs if self._is_success(run.status)) succeeded_runs = sum(1 for run in runs if self._is_success(run.status))
failed_runs = sum(1 for run in runs if self._is_failed(run.status)) failed_runs = sum(1 for run in runs if self._is_failed(run.status))
active_sessions = [item for item in sessions if str(item.status or "") == "active"] active_sessions = [item for item in sessions if str(item.status or "") == "active"]
online_user_count = len(self._unique_session_identities(active_sessions))
return SystemDashboardRead( return SystemDashboardRead(
window_days=window_days, window_days=window_days,
@@ -122,7 +123,7 @@ class SystemDashboardService:
totals={ totals={
"toolCalls": len(tool_calls), "toolCalls": len(tool_calls),
"modelTokens": total_tokens, "modelTokens": total_tokens,
"onlineUsers": len(active_sessions), "onlineUsers": online_user_count,
"avgOnlineMinutes": self._average_session_minutes(sessions, now), "avgOnlineMinutes": self._average_session_minutes(sessions, now),
"executionSuccessRate": self._percent(succeeded_runs, len(runs)), "executionSuccessRate": self._percent(succeeded_runs, len(runs)),
"positiveFeedback": positive_feedback, "positiveFeedback": positive_feedback,
@@ -132,7 +133,7 @@ class SystemDashboardService:
"modelTokensChange": self._change_percent(total_tokens, previous_tokens), "modelTokensChange": self._change_percent(total_tokens, previous_tokens),
}, },
agent_daily_ratio=self._agent_daily_ratio(labels, tool_calls), agent_daily_ratio=self._agent_daily_ratio(labels, tool_calls),
login_wave=self._login_wave(sessions), login_wave=self._login_wave(sessions, start, now),
token_daily_wave=self._token_daily_wave(labels, token_records), token_daily_wave=self._token_daily_wave(labels, token_records),
user_token_usage=self._user_token_usage(token_records, user_names), user_token_usage=self._user_token_usage(token_records, user_names),
accuracy_comparison=self._accuracy_comparison(tool_calls), accuracy_comparison=self._accuracy_comparison(tool_calls),
@@ -215,7 +216,14 @@ class SystemDashboardService:
def _fetch_sessions(self, start: datetime) -> list[UserSessionMetric]: def _fetch_sessions(self, start: datetime) -> list[UserSessionMetric]:
stmt = ( stmt = (
select(UserSessionMetric) select(UserSessionMetric)
.where(UserSessionMetric.login_at >= start) .where(
or_(
UserSessionMetric.login_at >= start,
UserSessionMetric.logout_at >= start,
UserSessionMetric.last_activity_at >= start,
UserSessionMetric.status == "active",
)
)
.order_by(UserSessionMetric.login_at.asc()) .order_by(UserSessionMetric.login_at.asc())
) )
return list(self.db.scalars(stmt).all()) return list(self.db.scalars(stmt).all())
@@ -258,19 +266,43 @@ class SystemDashboardService:
"series": ratio_series, "series": ratio_series,
} }
def _login_wave(self, sessions: list[UserSessionMetric]) -> dict[str, Any]: def _login_wave(
labels = [f"{hour:02d}:00" for hour in range(8, 21)] self,
login_users = [0 for _ in labels] sessions: list[UserSessionMetric],
start: datetime,
now: datetime,
) -> dict[str, Any]:
labels = [f"{hour:02d}:00" for hour in range(24)]
online_users = [set[str]() for _ in labels]
interactions = [0 for _ in labels] interactions = [0 for _ in labels]
index = {label: idx for idx, label in enumerate(labels)} index = {label: idx for idx, label in enumerate(labels)}
window_start = self._as_utc(start)
window_end = self._as_utc(now)
for session in sessions: for session in sessions:
hour = self._as_utc(session.login_at).hour login_at = self._as_utc(session.login_at)
label = f"{hour:02d}:00" end_at = self._session_end_at(session, now)
if label not in index: if end_at < window_start or login_at > window_end:
continue continue
login_users[index[label]] += 1
interactions[index[label]] += max(0, int(session.activity_event_count or 0)) identity = self._session_identity(session)
return {"labels": labels, "loginUsers": login_users, "interactions": interactions} overlap_start = max(login_at, window_start)
overlap_end = min(end_at, window_end)
cursor = overlap_start.replace(minute=0, second=0, microsecond=0)
while cursor <= overlap_end:
label = f"{cursor.hour:02d}:00"
online_users[index[label]].add(identity)
cursor += timedelta(hours=1)
login_label = f"{login_at.hour:02d}:00"
if login_at >= window_start and login_label in index:
interactions[index[login_label]] += max(0, int(session.activity_event_count or 0))
return {
"labels": labels,
"loginUsers": [len(items) for items in online_users],
"interactions": interactions,
}
def _token_daily_wave(self, labels: list[str], records: list[dict[str, Any]]) -> dict[str, Any]: def _token_daily_wave(self, labels: list[str], records: list[dict[str, Any]]) -> dict[str, Any]:
input_tokens = [0 for _ in labels] input_tokens = [0 for _ in labels]
@@ -547,12 +579,28 @@ class SystemDashboardService:
if int(session.duration_ms or 0) > 0: if int(session.duration_ms or 0) > 0:
return max(0, int(session.duration_ms or 0)) return max(0, int(session.duration_ms or 0))
login_at = self._as_utc(session.login_at) login_at = self._as_utc(session.login_at)
end_at = self._as_utc(session.logout_at or session.last_activity_at or now) end_at = self._session_end_at(session, now)
try: try:
return max(0, min(int((end_at - login_at).total_seconds() * 1000), 24 * 60 * 60 * 1000)) return max(0, min(int((end_at - login_at).total_seconds() * 1000), 24 * 60 * 60 * 1000))
except TypeError: except TypeError:
return 0 return 0
def _session_end_at(self, session: UserSessionMetric, now: datetime) -> datetime:
if str(session.status or "").strip().lower() == "active":
return self._as_utc(now)
return self._as_utc(session.logout_at or session.last_activity_at or session.login_at or now)
def _unique_session_identities(self, sessions: list[UserSessionMetric]) -> set[str]:
return {self._session_identity(item) for item in sessions}
@staticmethod
def _session_identity(session: UserSessionMetric) -> str:
for value in (session.username, session.email, session.employee_no, session.display_name):
normalized = str(value or "").strip().lower()
if normalized:
return normalized
return str(session.session_id or "").strip().lower()
@staticmethod @staticmethod
def _date_labels(start_date: date, days: int) -> list[str]: def _date_labels(start_date: date, days: int) -> list[str]:
return [(start_date + timedelta(days=index)).strftime("%m-%d") for index in range(days)] return [(start_date + timedelta(days=index)).strftime("%m-%d") for index in range(days)]

View File

@@ -234,6 +234,124 @@ def test_budget_warning_application_still_skips_budget_manager_when_not_over_bud
) )
def test_application_routes_to_budget_manager_when_usage_reaches_90_percent() -> None:
with build_session() as db:
department, manager, _budget_manager, employee = _seed_people(db, suffix="OVER-90-APP")
_seed_budget_allocation(
db,
department_id=department.id,
department_name=department.name,
amount=Decimal("10000.00"),
)
claim = ExpenseClaim(
claim_no="APP-20260530-OVER-90",
employee_id=employee.id,
employee_name=employee.name,
department_id=department.id,
department_name=department.name,
project_code=None,
expense_type="travel_application",
reason="客户现场支持",
location="上海",
amount=Decimal("9500.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 30, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 30, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
risk_flags_json=[],
)
db.add(claim)
db.commit()
routed = ExpenseClaimService(db).approve_claim(
claim.id,
CurrentUserContext(
username=manager.email,
name=manager.name,
role_codes=["manager"],
is_admin=False,
),
opinion="业务必要,同意申请。",
)
assert routed is not None
assert routed.status == "submitted"
assert routed.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE
assert any(
isinstance(flag, dict)
and flag.get("source") == "approval_routing"
and flag.get("requires_budget_review") is True
and flag.get("route") == "budget_manager"
and any("90%" in item or "90" in item for item in flag.get("reasons", []))
for flag in routed.risk_flags_json
)
def test_application_stage_risk_under_90_percent_does_not_route_to_budget_manager() -> None:
with build_session() as db:
department, manager, _budget_manager, employee = _seed_people(db, suffix="RISK-APP")
_seed_budget_allocation(
db,
department_id=department.id,
department_name=department.name,
amount=Decimal("10000.00"),
)
claim = ExpenseClaim(
claim_no="APP-20260530-RISK-UNDER-90",
employee_id=employee.id,
employee_name=employee.name,
department_id=department.id,
department_name=department.name,
project_code=None,
expense_type="travel_application",
reason="客户现场支持",
location="上海",
amount=Decimal("500.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 30, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 30, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
risk_flags_json=[
{
"source": "submission_review",
"severity": "high",
"label": "申请信息风险",
"message": "申请事由需要领导关注。",
"business_stage": "expense_application",
}
],
)
db.add(claim)
db.commit()
approved = ExpenseClaimService(db).approve_claim(
claim.id,
CurrentUserContext(
username=manager.email,
name=manager.name,
role_codes=["manager"],
is_admin=False,
),
opinion="业务必要,同意申请。",
)
assert approved is not None
assert approved.status == "approved"
assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
route_flag = [
flag
for flag in approved.risk_flags_json
if isinstance(flag, dict) and flag.get("source") == "approval_routing"
][0]
assert route_flag["requires_budget_review"] is False
assert route_flag["route"] == "approval_done"
assert route_flag["current_risk_count"] == 1
def test_application_route_ignores_reimbursement_stage_current_risks() -> None: def test_application_route_ignores_reimbursement_stage_current_risks() -> None:
with build_session() as db: with build_session() as db:
department, manager, _budget_manager, employee = _seed_people(db, suffix="MIXED-STAGE") department, manager, _budget_manager, employee = _seed_people(db, suffix="MIXED-STAGE")

View File

@@ -28,6 +28,7 @@ from app.schemas.reimbursement import (
) )
from app.services.agent_conversations import AgentConversationService from app.services.agent_conversations import AgentConversationService
from app.services.budget import BudgetService from app.services.budget import BudgetService
from app.services.expense_claim_budget_flow import ExpenseClaimBudgetFlowMixin
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
from app.services.expense_claims import ExpenseClaimService from app.services.expense_claims import ExpenseClaimService
from app.services.expense_claim_workflow_constants import ( from app.services.expense_claim_workflow_constants import (
@@ -119,6 +120,42 @@ def build_session() -> Session:
return session_factory() return session_factory()
def test_append_budget_flags_replaces_duplicate_budget_warning() -> None:
base_warning = {
"source": "budget_control",
"event_type": "budget_warning",
"severity": "medium",
"label": "预算接近预警线",
"message": "预算 SIM-BUD-2026-R0048 本次占用后使用率预计达到 98.00%,已达到预警线 80.00%",
"budget_no": "SIM-BUD-2026-R0048",
"allocation_id": "allocation-0048",
"subject_code": "travel",
"created_at": "2026-06-03T10:00:00+00:00",
}
latest_warning = {
**base_warning,
"message": "预算 SIM-BUD-2026-R0048 本次占用后使用率预计达到 99.27%,已达到预警线 80.00%",
"created_at": "2026-06-03T10:01:00+00:00",
}
flags = ExpenseClaimBudgetFlowMixin._append_budget_flags(
[base_warning],
latest_warning,
business_stage="reimbursement",
)
warnings = [
flag
for flag in flags
if isinstance(flag, dict)
and flag.get("source") == "budget_control"
and flag.get("event_type") == "budget_warning"
]
assert len(warnings) == 1
assert "99.27%" in warnings[0]["message"]
assert warnings[0]["business_stage"] == "reimbursement"
def _count_claims(db: Session) -> int: def _count_claims(db: Session) -> int:
return int(db.query(ExpenseClaim).count()) return int(db.query(ExpenseClaim).count())
@@ -2360,6 +2397,112 @@ def test_upload_hotel_attachment_flags_amount_over_travel_policy(monkeypatch, tm
) )
def test_delete_claim_item_attachment_removes_attachment_analysis_risk(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext(
username="emp-hotel-risk@example.com",
name="张三",
role_codes=[],
is_admin=False,
)
def fake_recognize(
self,
files: list[tuple[str, bytes, str | None]],
) -> OcrRecognizeBatchRead:
return OcrRecognizeBatchRead(
total_file_count=1,
success_count=1,
documents=[
OcrRecognizeDocumentRead(
filename="hotel-risk.png",
media_type="image/png",
text="北京全季酒店 住宿 1晚 金额800元 2026-05-13",
summary="北京全季酒店住宿发票,住宿 1 晚,金额 800 元。",
avg_score=0.98,
line_count=1,
page_count=1,
document_type="hotel_invoice",
document_type_label="酒店住宿票据",
scene_code="hotel",
scene_label="住宿票据",
document_fields=[
{"key": "merchant_name", "label": "商户", "value": "北京全季酒店"},
{"key": "amount", "label": "金额", "value": "800元"},
{"key": "date", "label": "日期", "value": "2026-05-13"},
],
)
],
)
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
with build_session() as db:
employee = Employee(
employee_no="E7402",
name="张三",
email="emp-hotel-risk@example.com",
grade="P4",
)
db.add(employee)
db.flush()
claim = build_claim(expense_type="travel", location="北京")
claim.employee = employee
claim.employee_id = employee.id
claim.reason = "北京客户现场出差"
claim.invoice_count = 0
claim.items[0].item_type = "hotel"
claim.items[0].item_reason = "北京住宿"
claim.items[0].item_location = "北京"
claim.items[0].item_amount = Decimal("0.00")
claim.items[0].invoice_id = None
db.add(claim)
db.commit()
service = ExpenseClaimService(db)
upload_payload = service.upload_claim_item_attachment(
claim_id=claim.id,
item_id=claim.items[0].id,
filename="hotel-risk.png",
content=b"fake-image-bytes",
media_type="image/png",
current_user=current_user,
)
assert upload_payload is not None
assert any(
isinstance(flag, dict) and str(flag.get("source") or "").strip() == "attachment_analysis"
for flag in upload_payload["claim_risk_flags"]
)
delete_payload = service.delete_claim_item_attachment(
claim_id=claim.id,
item_id=claim.items[0].id,
current_user=current_user,
)
assert delete_payload is not None
assert delete_payload["invoice_id"] is None
assert not any(
isinstance(flag, dict) and str(flag.get("source") or "").strip() == "attachment_analysis"
for flag in delete_payload["claim_risk_flags"]
)
assert not any(
"高风险附件" in str(flag.get("message") or "")
for flag in delete_payload["claim_risk_flags"]
if isinstance(flag, dict)
)
db.refresh(claim)
assert claim.invoice_count == 0
assert claim.items[0].invoice_id is None
assert not any(
isinstance(flag, dict) and str(flag.get("source") or "").strip() == "attachment_analysis"
for flag in list(claim.risk_flags_json or [])
)
def test_attachment_analysis_does_not_compare_business_purpose_with_ticket_scene() -> None: def test_attachment_analysis_does_not_compare_business_purpose_with_ticket_scene() -> None:
with build_session() as db: with build_session() as db:
claim = build_claim(expense_type="travel", location="上海") claim = build_claim(expense_type="travel", location="上海")
@@ -3741,6 +3884,101 @@ def test_list_claims_returns_company_reimbursements_for_finance_document_center(
assert "EXP-FIN-COMPANY-PAID" in archived_nos assert "EXP-FIN-COMPANY-PAID" in archived_nos
def test_list_claims_returns_all_active_documents_for_admin_document_center() -> None:
current_user = CurrentUserContext(
username="admin",
name="admin",
role_codes=["admin"],
is_admin=True,
)
with build_session() as db:
db.add_all(
[
ExpenseClaim(
claim_no="AP-ADMIN-DRAFT",
employee_name="Applicant A",
department_name="Tech",
project_code="PRJ-APP",
expense_type="travel_application",
reason="Travel application draft",
location="Shanghai",
amount=Decimal("1200.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
submitted_at=None,
status="draft",
approval_stage="draft",
risk_flags_json=[],
),
ExpenseClaim(
claim_no="AP-ADMIN-LINKING",
employee_name="Applicant B",
department_name="Tech",
project_code="PRJ-APP",
expense_type="travel_application",
reason="Travel application approved",
location="Beijing",
amount=Decimal("2200.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
status="approved",
approval_stage=APPLICATION_LINK_STATUS_STAGE,
risk_flags_json=[],
),
ExpenseClaim(
claim_no="EXP-ADMIN-DRAFT",
employee_name="Applicant C",
department_name="Finance",
project_code="PRJ-EXP",
expense_type="office",
reason="Office draft",
location="Hangzhou",
amount=Decimal("300.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 13, 9, 0, tzinfo=UTC),
submitted_at=None,
status="draft",
approval_stage="draft",
risk_flags_json=[],
),
ExpenseClaim(
claim_no="EXP-ADMIN-ARCHIVED",
employee_name="Applicant D",
department_name="Finance",
project_code="PRJ-EXP",
expense_type="office",
reason="Archived reimbursement",
location="Hangzhou",
amount=Decimal("500.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 14, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 14, 10, 0, tzinfo=UTC),
status="paid",
approval_stage="payment",
risk_flags_json=[],
),
]
)
db.commit()
claim_nos = {claim.claim_no for claim in ExpenseClaimService(db).list_claims(current_user)}
archived_nos = {
claim.claim_no for claim in ExpenseClaimService(db).list_archived_claims(current_user)
}
assert "AP-ADMIN-DRAFT" in claim_nos
assert "AP-ADMIN-LINKING" in claim_nos
assert "EXP-ADMIN-DRAFT" in claim_nos
assert "EXP-ADMIN-ARCHIVED" not in claim_nos
assert "EXP-ADMIN-ARCHIVED" in archived_nos
def test_list_claims_limits_executive_to_personal_records() -> None: def test_list_claims_limits_executive_to_personal_records() -> None:
current_user = CurrentUserContext( current_user = CurrentUserContext(
username="executive@example.com", username="executive@example.com",

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import stat import stat
import subprocess
from pathlib import Path from pathlib import Path
from app.core.config import get_settings from app.core.config import get_settings
@@ -88,6 +89,46 @@ print("__OCR_JSON__=" + json.dumps(payload, ensure_ascii=False))
assert skipped.warnings == ["当前仅支持图片和 PDF 文件进行 OCR。"] assert skipped.warnings == ["当前仅支持图片和 PDF 文件进行 OCR。"]
def test_ocr_service_passes_configured_device_to_worker(
monkeypatch,
tmp_path: Path,
) -> None:
captured_commands: list[list[str]] = []
def fake_run(
command: list[str],
*,
capture_output: bool,
text: bool,
timeout: int,
check: bool,
) -> subprocess.CompletedProcess[str]:
captured_commands.append(command)
return subprocess.CompletedProcess(
args=command,
returncode=0,
stdout='__OCR_JSON__={"engine":"paddleocr_mobile","model":"PP-OCRv5_mobile","documents":[]}\n',
stderr="",
)
monkeypatch.setenv("OCR_DEVICE", "gpu:0")
get_settings.cache_clear()
monkeypatch.setattr(subprocess, "run", fake_run)
try:
payload = OcrService()._invoke_worker(
python_bin="python",
worker_path="worker.py",
input_paths=[tmp_path / "invoice.png"],
)
finally:
get_settings.cache_clear()
assert payload["engine"] == "paddleocr_mobile"
command = captured_commands[0]
device_index = command.index("--device")
assert command[device_index + 1] == "gpu:0"
def test_ocr_service_converts_pdf_to_images_and_returns_image_preview( def test_ocr_service_converts_pdf_to_images_and_returns_image_preview(
monkeypatch, monkeypatch,
tmp_path: Path, tmp_path: Path,

View File

@@ -113,6 +113,135 @@ def seed_claim(db: Session) -> tuple[ExpenseClaim, ExpenseClaimItem]:
return claim, item return claim, item
def test_claim_read_uses_organization_manager_and_dedupes_budget_warnings() -> None:
client, session_factory = build_client()
with session_factory() as db:
department = OrganizationUnit(
id="dept-org-manager",
unit_code="DEPT-ORG-MANAGER",
name="交付部",
manager_name="王总",
)
employee = Employee(
id="emp-org-manager",
employee_no="E30001",
name="赵六",
email="zhaoliu@example.com",
organization_unit=department,
position="实施顾问",
grade="P5",
finance_owner_name="Wang Finance",
)
duplicated_warning = {
"source": "budget_control",
"event_type": "budget_warning",
"severity": "medium",
"label": "预算接近预警线",
"message": "预算 SIM-BUD-2026-R0048 本次占用后使用率预计达到 99.27%,已达到预警线 80.00%",
"budget_no": "SIM-BUD-2026-R0048",
"allocation_id": "allocation-0048",
"subject_code": "travel",
}
claim = ExpenseClaim(
id="claim-org-manager",
claim_no="EXP-202606-ORG-MGR",
employee_id=employee.id,
employee_name=employee.name,
department_id=department.id,
department_name=department.name,
project_code=None,
expense_type="travel",
reason="差旅报销",
location="上海",
amount=Decimal("880.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 6, 3, tzinfo=UTC),
submitted_at=datetime(2026, 6, 3, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[
{**duplicated_warning, "created_at": "2026-06-03T10:00:00+00:00"},
{**duplicated_warning, "created_at": "2026-06-03T10:01:00+00:00"},
],
)
db.add_all([department, employee, claim])
db.commit()
headers = {"x-auth-username": "zhaoliu@example.com"}
response = client.get("/api/v1/reimbursements/claims/claim-org-manager", headers=headers)
assert response.status_code == 200
payload = response.json()
assert payload["manager_name"] == "王总"
assert payload["finance_owner_name"] == "Wang Finance"
budget_warnings = [
flag
for flag in payload["risk_flags_json"]
if flag.get("source") == "budget_control" and flag.get("event_type") == "budget_warning"
]
assert len(budget_warnings) == 1
assert budget_warnings[0]["message"] == duplicated_warning["message"]
def test_claim_read_attaches_finance_approver_name_for_finance_stage() -> None:
client, session_factory = build_client()
with session_factory() as db:
finance_role = Role(
id="role-finance-reader",
role_code="finance",
name="财务",
description="可处理财务复核任务",
)
applicant = Employee(
id="emp-finance-stage-applicant",
employee_no="E30002",
name="钱七",
email="qianqi@example.com",
position="实施顾问",
grade="P5",
finance_owner_name="Wang Finance Group",
)
finance_user = Employee(
id="emp-finance-stage-approver",
employee_no="F30002",
name="Wang Finance",
email="wang.finance@example.com",
position="财务专员",
grade="P6",
finance_owner_name="Wang Finance Group",
roles=[finance_role],
)
claim = ExpenseClaim(
id="claim-finance-stage-reader",
claim_no="EXP-202606-FINANCE-MGR",
employee_id=applicant.id,
employee_name=applicant.name,
department_id=None,
department_name="交付部",
project_code=None,
expense_type="travel",
reason="差旅报销",
location="上海",
amount=Decimal("880.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 6, 3, tzinfo=UTC),
submitted_at=datetime(2026, 6, 3, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="财务审批",
risk_flags_json=[],
)
db.add_all([finance_role, applicant, finance_user, claim])
db.commit()
headers = {"x-auth-username": "qianqi@example.com"}
response = client.get("/api/v1/reimbursements/claims/claim-finance-stage-reader", headers=headers)
assert response.status_code == 200
payload = response.json()
assert payload["finance_owner_name"] == "Wang Finance Group"
assert payload["finance_approver_name"] == "Wang Finance"
def test_claim_standard_adjustment_endpoint_recalculates_and_marks_reviewer_notice() -> None: def test_claim_standard_adjustment_endpoint_recalculates_and_marks_reviewer_notice() -> None:
client, session_factory = build_client() client, session_factory = build_client()
with session_factory() as db: with session_factory() as db:

View File

@@ -147,3 +147,52 @@ def test_system_dashboard_service_aggregates_real_runtime_metrics() -> None:
assert dashboard.accuracy_comparison["wrong"][ assert dashboard.accuracy_comparison["wrong"][
dashboard.accuracy_comparison["categories"].index("异常诊断") dashboard.accuracy_comparison["categories"].index("异常诊断")
] == 1 ] == 1
def test_system_dashboard_counts_online_users_from_active_sessions_across_window() -> None:
now = datetime.now(UTC)
with build_session() as db:
db.add_all(
[
UserSessionMetric(
session_id="session-online-old-001",
username="active.user@example.com",
display_name="在线用户",
email="active.user@example.com",
login_at=now - timedelta(days=2),
last_activity_at=now - timedelta(days=2),
activity_event_count=3,
status="active",
),
UserSessionMetric(
session_id="session-online-old-002",
username="active.user@example.com",
display_name="在线用户",
email="active.user@example.com",
login_at=now - timedelta(minutes=15),
last_activity_at=now - timedelta(minutes=15),
activity_event_count=5,
status="active",
),
UserSessionMetric(
session_id="session-closed-outside-window",
username="offline.user@example.com",
display_name="离线用户",
email="offline.user@example.com",
login_at=now - timedelta(days=3),
logout_at=now - timedelta(days=2, hours=23),
duration_ms=60 * 60 * 1000,
status="closed",
),
]
)
db.commit()
dashboard = SystemDashboardService(db).build_dashboard(days=1)
login_users = dashboard.login_wave["loginUsers"]
assert dashboard.totals["onlineUsers"] == 1
assert max(login_users) == 1
assert "00:00" in dashboard.login_wave["labels"]
assert "23:00" in dashboard.login_wave["labels"]

View File

@@ -39,6 +39,7 @@
width var(--sidebar-motion), width var(--sidebar-motion),
flex-basis var(--sidebar-motion), flex-basis var(--sidebar-motion),
box-shadow 160ms var(--ease); box-shadow 160ms var(--ease);
animation: loginEntrySidebarIn 520ms cubic-bezier(0.16, 1, 0.3, 1) backwards;
} }
.app.sidebar-collapsed .app-sidebar { .app.sidebar-collapsed .app-sidebar {
@@ -151,7 +152,13 @@
font-weight: 700; font-weight: 700;
} }
.main { min-width: 0; min-height: 0; display: grid; grid-template-rows: auto auto minmax(0, 1fr); } .main {
min-width: 0;
min-height: 0;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
animation: loginEntryMainIn 620ms 90ms cubic-bezier(0.16, 1, 0.3, 1) backwards;
}
.main.overview-main { .main.overview-main {
height: var(--desktop-stage-height, 100dvh); height: var(--desktop-stage-height, 100dvh);
grid-template-rows: auto minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
@@ -201,6 +208,14 @@
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
padding: 20px 24px; padding: 20px 24px;
background-color: var(--workbench-surface-soft, #f9fbff);
background-image:
radial-gradient(ellipse at 0% 0%, rgba(58, 124, 165, 0.05) 0%, transparent 40%),
radial-gradient(ellipse at 100% 0%, rgba(110, 127, 166, 0.05) 0%, transparent 40%),
linear-gradient(rgba(58, 124, 165, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(58, 124, 165, 0.03) 1px, transparent 1px);
background-size: 100% 100%, 100% 100%, 32px 32px, 32px 32px;
background-attachment: local;
} }
.workarea.settings-workarea { .workarea.settings-workarea {
padding: 0; padding: 0;

View File

@@ -0,0 +1,574 @@
.progress-panel {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
min-height: 0;
}
.progress-panel :where(button) {
border: 0;
background: transparent;
cursor: pointer;
font: inherit;
}
.progress-panel :where(button:disabled) {
cursor: not-allowed;
opacity: 0.7;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 24px;
margin-bottom: 5px;
}
.section-head h2 {
margin: 0;
color: var(--workbench-ink);
font-size: 16px;
line-height: 1.25;
font-weight: 850;
}
.progress-section-head {
align-items: center;
}
.progress-range-select {
width: 124px;
flex: 0 0 124px;
}
.progress-range-control {
position: relative;
z-index: 6;
flex: 0 0 124px;
}
.progress-range-select:deep(.el-select__wrapper) {
min-height: 32px;
border-radius: 4px;
box-shadow: 0 0 0 1px var(--workbench-line) inset;
background: rgba(255, 255, 255, 0.86);
}
.progress-range-select:deep(.el-select__wrapper.is-focused),
.progress-range-select:deep(.el-select__wrapper:hover) {
box-shadow: 0 0 0 1px rgba(var(--theme-primary-rgb, 58, 124, 165), 0.38) inset;
}
.progress-table-shell {
position: relative;
display: grid;
--progress-table-columns: minmax(92px, 0.4fr) minmax(76px, 0.3fr) minmax(138px, 0.72fr) minmax(84px, 0.42fr) minmax(238px, 1.16fr) minmax(96px, 0.44fr);
grid-template-rows: 36px minmax(0, 1fr);
min-height: 0;
overflow: hidden;
}
.progress-table-header {
position: relative;
z-index: 3;
display: grid;
grid-template-columns: var(--progress-table-columns);
gap: 8px;
align-items: center;
height: 36px;
min-height: 36px;
max-height: 36px;
padding: 0 4px 0 0;
overflow: hidden;
border-radius: 4px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(255, 255, 255, 0.94)),
rgba(var(--theme-primary-rgb, 58, 124, 165), 0.04);
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08);
box-shadow: 0 1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08);
box-sizing: border-box;
}
.header-cell {
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
height: 100%;
overflow: hidden;
color: var(--workbench-muted);
font-size: 12px;
font-weight: 850;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-time {
padding-left: 0;
}
.header-applicant,
.header-type {
text-align: center;
}
.header-steps {
justify-content: center;
}
.header-result {
text-align: center;
padding-right: 0;
}
.progress-list {
position: relative;
z-index: 1;
display: grid;
min-height: 0;
height: 100%;
align-content: start;
grid-auto-rows: minmax(76px, auto);
overflow-x: hidden;
overflow-y: auto;
padding: 4px 4px 0 0;
scrollbar-width: thin;
scrollbar-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.28) transparent;
}
.progress-list::-webkit-scrollbar {
width: 6px;
}
.progress-list::-webkit-scrollbar-thumb {
border-radius: 999px;
background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.24);
}
.progress-empty-state {
min-height: 220px;
height: 100%;
display: grid;
place-items: center;
align-content: center;
gap: 8px;
padding: 28px 18px;
border: 1px dashed rgba(var(--theme-primary-rgb, 58, 124, 165), 0.22);
border-radius: 4px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.68), rgba(255, 255, 255, 0.42)),
rgba(var(--theme-primary-rgb, 58, 124, 165), 0.025);
color: var(--workbench-muted);
text-align: center;
}
.progress-empty-icon {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.18);
border-radius: 4px;
background: var(--workbench-primary-soft);
color: var(--workbench-primary-active);
font-size: 22px;
}
.progress-empty-state strong {
color: var(--workbench-ink);
font-size: 14px;
font-weight: 850;
}
.progress-empty-state p {
max-width: 260px;
margin: 0;
color: var(--workbench-muted);
font-size: 12px;
line-height: 1.55;
}
.progress-row {
position: relative;
display: grid;
grid-template-columns: var(--progress-table-columns);
align-items: center;
gap: 8px;
width: 100%;
min-height: 76px;
padding: 10px 0;
border-top: 0;
background: transparent;
box-shadow: inset 0 1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.1);
color: inherit;
text-align: center;
animation: workbenchItemIn 480ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
animation-delay: calc(300ms + var(--item-index, 0) * 80ms);
}
.progress-row:first-child {
padding-top: 2px;
border-top: 0;
box-shadow: none;
}
.progress-row:hover {
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.24);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.32), rgba(255, 255, 255, 0.18)),
rgba(var(--theme-primary-rgb, 58, 124, 165), 0.035);
box-shadow: inset 0 1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.14);
color: var(--workbench-primary-active);
}
.progress-time-wrapper {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
min-width: 0;
}
.expense-type-icon {
position: relative;
flex-shrink: 0;
width: 42px;
height: 42px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 12px;
font-size: 22px;
box-shadow:
0 4px 10px rgba(0, 0, 0, 0.04),
inset 0 1px 0 rgba(255, 255, 255, 0.9),
inset 0 -1px 0 rgba(0, 0, 0, 0.03);
}
.expense-type-icon::before {
content: "";
position: absolute;
inset: 0;
z-index: 0;
border-radius: inherit;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0) 100%);
}
.expense-type-icon i {
position: relative;
z-index: 1;
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.12));
}
.expense-type-icon--blue {
border: 1px solid color-mix(in srgb, var(--workbench-primary, #3a7ca5) 20%, #ffffff);
background: linear-gradient(135deg, color-mix(in srgb, var(--workbench-primary, #3a7ca5) 12%, #ffffff) 0%, color-mix(in srgb, var(--workbench-primary, #3a7ca5) 3%, #ffffff) 100%);
color: var(--workbench-primary, #3a7ca5);
}
.expense-type-icon--amber {
border: 1px solid color-mix(in srgb, var(--workbench-chart-amber, #b58b4c) 20%, #ffffff);
background: linear-gradient(135deg, color-mix(in srgb, var(--workbench-chart-amber, #b58b4c) 12%, #ffffff) 0%, color-mix(in srgb, var(--workbench-chart-amber, #b58b4c) 3%, #ffffff) 100%);
color: var(--workbench-chart-amber, #b58b4c);
}
.expense-type-icon--emerald {
border: 1px solid color-mix(in srgb, #0f8f68 20%, #ffffff);
background: linear-gradient(135deg, color-mix(in srgb, #0f8f68 12%, #ffffff) 0%, color-mix(in srgb, #0f8f68 3%, #ffffff) 100%);
color: #0f8f68;
}
.expense-type-icon--violet {
border: 1px solid color-mix(in srgb, #6d5bd0 20%, #ffffff);
background: linear-gradient(135deg, color-mix(in srgb, #6d5bd0 12%, #ffffff) 0%, color-mix(in srgb, #6d5bd0 3%, #ffffff) 100%);
color: #6d5bd0;
}
.expense-type-icon--cyan {
border: 1px solid color-mix(in srgb, #0788a2 20%, #ffffff);
background: linear-gradient(135deg, color-mix(in srgb, #0788a2 12%, #ffffff) 0%, color-mix(in srgb, #0788a2 3%, #ffffff) 100%);
color: #0788a2;
}
.expense-type-icon--muted {
border: 1px solid var(--workbench-line);
background: linear-gradient(135deg, var(--info-soft, #f1f5f9) 0%, #ffffff 100%);
color: var(--workbench-muted, #64748b);
}
.progress-time,
.progress-applicant,
.progress-identity,
.progress-type,
.progress-result {
min-width: 0;
display: grid;
gap: 2px;
}
.progress-time {
justify-items: center;
color: var(--workbench-muted);
}
.progress-applicant {
justify-items: center;
text-align: center;
}
.progress-applicant strong {
max-width: 100%;
overflow: hidden;
color: var(--workbench-ink);
font-size: 12.5px;
font-weight: 850;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.progress-time time {
min-width: 0;
overflow: hidden;
color: var(--workbench-ink);
font-size: 12px;
font-weight: 850;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.progress-identity {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
text-align: center;
}
.progress-identity strong {
margin-bottom: 2px;
}
.progress-identity strong,
.progress-result strong {
max-width: 100%;
overflow: hidden;
color: var(--workbench-ink);
font-size: 13px;
font-weight: bold;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.progress-identity small {
max-width: 100%;
overflow: hidden;
color: var(--workbench-muted);
font-size: 11.5px;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.progress-type {
justify-self: stretch;
justify-items: center;
text-align: center;
}
.progress-type strong {
max-width: 100%;
min-height: 22px;
overflow: hidden;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 8px;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.18);
border-radius: 4px;
background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08);
color: var(--workbench-primary-active);
font-size: 11.5px;
font-weight: 850;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.progress-result {
justify-self: stretch;
align-items: center;
justify-content: center;
justify-items: center;
padding-right: 0;
text-align: center;
}
.progress-result strong {
width: 100%;
text-align: center;
}
.progress-steps {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
align-items: start;
justify-items: stretch;
}
.progress-step {
position: relative;
display: grid;
justify-items: center;
gap: 1px;
color: color-mix(in srgb, var(--workbench-muted) 64%, #ffffff);
}
.progress-step::before {
content: "";
position: absolute;
top: 8px;
left: calc(-50% + 12px);
right: calc(50% + 12px);
height: 2px;
background: var(--workbench-line);
}
.progress-step:first-child::before {
display: none;
}
.progress-step.is-done::before,
.progress-step.is-current::before {
background: var(--workbench-primary);
}
.progress-step i {
position: relative;
z-index: 1;
width: 16px;
height: 16px;
display: grid;
place-items: center;
border: 2px solid var(--workbench-line);
border-radius: 999px;
background: var(--workbench-surface);
color: var(--workbench-line-strong, #cbd5e1);
font-size: 12px;
line-height: 1;
}
.progress-step.is-done i {
border-color: var(--workbench-primary);
background: var(--workbench-primary-soft);
color: var(--workbench-primary-active);
}
.progress-step.is-current i {
border-color: var(--workbench-primary-active);
background: var(--theme-primary-light-9);
color: var(--workbench-primary-active);
}
.progress-step small {
color: currentColor;
font-size: 10px;
font-weight: 750;
line-height: 1.2;
white-space: nowrap;
}
.progress-step.is-done,
.progress-step.is-current {
color: var(--workbench-ink);
}
@media (max-width: 760px) {
.progress-table-shell {
grid-template-rows: minmax(0, 1fr);
}
.progress-table-header {
display: none;
}
.progress-row {
grid-template-columns: minmax(70px, auto) minmax(62px, auto) 1fr minmax(74px, auto);
grid-template-areas:
"time applicant identity result"
"type type type type"
"steps steps steps steps";
justify-items: center;
}
.progress-section-head {
align-items: center;
}
.progress-range-select {
width: 120px;
flex-basis: 120px;
}
.progress-range-control {
flex-basis: 120px;
}
.progress-empty-state {
min-height: 180px;
}
.progress-time-wrapper {
grid-area: time;
}
.progress-applicant {
grid-area: applicant;
}
.progress-identity {
grid-area: identity;
}
.progress-type {
grid-area: type;
width: 100%;
}
.progress-result {
grid-area: result;
width: 100%;
justify-items: center;
gap: 2px;
}
.progress-steps {
grid-area: steps;
width: 100%;
margin-top: 4px;
}
.progress-step i {
width: 14px;
height: 14px;
font-size: 10px;
}
.progress-step small {
font-size: 9px;
}
.progress-step::before {
top: 7px;
}
}
@media (prefers-reduced-motion: reduce) {
.progress-row {
animation: none !important;
}
}

View File

@@ -334,6 +334,19 @@
justify-items: start; justify-items: start;
} }
.progress-section-head {
align-items: center;
}
.progress-range-select {
width: 120px;
flex-basis: 120px;
}
.progress-empty-state {
min-height: 180px;
}
.progress-result { .progress-result {
justify-items: start; justify-items: start;
} }

View File

@@ -26,6 +26,8 @@
--workbench-chart-amber: var(--chart-amber, #b58b4c); --workbench-chart-amber: var(--chart-amber, #b58b4c);
width: 100%; width: 100%;
max-width: 1680px;
margin: 0 auto;
height: 100%; height: 100%;
min-width: 0; min-width: 0;
display: grid; display: grid;
@@ -581,6 +583,27 @@
font-weight: 850; font-weight: 850;
} }
.progress-section-head {
align-items: center;
}
.progress-range-select {
width: 124px;
flex: 0 0 124px;
}
.progress-range-select .el-select__wrapper {
min-height: 32px;
border-radius: 4px;
box-shadow: 0 0 0 1px var(--workbench-line) inset;
background: rgba(255, 255, 255, 0.86);
}
.progress-range-select .el-select__wrapper.is-focused,
.progress-range-select .el-select__wrapper:hover {
box-shadow: 0 0 0 1px rgba(var(--theme-primary-rgb, 58, 124, 165), 0.38) inset;
}
.title-with-badge { .title-with-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -632,7 +655,65 @@
display: grid; display: grid;
min-height: 0; min-height: 0;
height: 100%; height: 100%;
grid-auto-rows: minmax(0, 1fr); align-content: start;
grid-auto-rows: minmax(56px, auto);
overflow-x: hidden;
overflow-y: auto;
padding-right: 4px;
scrollbar-width: thin;
scrollbar-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.28) transparent;
}
.progress-list::-webkit-scrollbar {
width: 6px;
}
.progress-list::-webkit-scrollbar-thumb {
border-radius: 999px;
background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.24);
}
.progress-empty-state {
min-height: 220px;
height: 100%;
display: grid;
place-items: center;
align-content: center;
gap: 8px;
padding: 28px 18px;
border: 1px dashed rgba(var(--theme-primary-rgb, 58, 124, 165), 0.22);
border-radius: 4px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.68), rgba(255, 255, 255, 0.42)),
rgba(var(--theme-primary-rgb, 58, 124, 165), 0.025);
color: var(--workbench-muted);
text-align: center;
}
.progress-empty-icon {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.18);
border-radius: 4px;
background: var(--workbench-primary-soft);
color: var(--workbench-primary-active);
font-size: 22px;
}
.progress-empty-state strong {
color: var(--workbench-ink);
font-size: 14px;
font-weight: 850;
}
.progress-empty-state p {
max-width: 260px;
margin: 0;
color: var(--workbench-muted);
font-size: 12px;
line-height: 1.55;
} }
.progress-row:first-child { .progress-row:first-child {
@@ -645,15 +726,25 @@
animation-delay: calc(300ms + var(--item-index, 0) * 80ms); animation-delay: calc(300ms + var(--item-index, 0) * 80ms);
} }
.progress-identity, .progress-identity {
display: flex;
flex-direction: column;
gap: 2px;
}
.progress-result { .progress-result {
gap: 12px; display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 12px;
}
.progress-identity strong {
margin-bottom: 2px;
} }
.progress-identity strong, .progress-identity strong,
.progress-result strong { .progress-result strong {
margin-bottom: 2px;
overflow: hidden; overflow: hidden;
color: var(--workbench-ink); color: var(--workbench-ink);
font-size: 13px; font-size: 13px;
@@ -751,45 +842,6 @@
text-align: left; text-align: left;
} }
.progress-row.has-long-duration-divider {
margin-top: 13px;
padding-top: 13px;
border-top: 0;
}
.progress-row.has-long-duration-divider::before {
content: "10日以上";
position: absolute;
top: -9px;
left: 50%;
z-index: 1;
display: inline-flex;
align-items: center;
height: 18px;
padding: 0 10px;
transform: translateX(-50%);
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.28);
border-radius: 4px;
background: color-mix(in srgb, var(--theme-primary) 11%, #ffffff);
box-shadow: 0 4px 10px rgba(var(--theme-primary-rgb, 58, 124, 165), 0.12);
color: var(--theme-primary-active);
font-size: 11px;
font-weight: 850;
line-height: 1;
white-space: nowrap;
}
.progress-row.has-long-duration-divider::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.26);
pointer-events: none;
}
.progress-time-wrapper { .progress-time-wrapper {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -173,8 +173,32 @@
border-color 180ms var(--ease), border-color 180ms var(--ease),
color 180ms var(--ease); color 180ms var(--ease);
will-change: gap; will-change: gap;
animation: navItemEntrance 500ms cubic-bezier(0.16, 1, 0.3, 1) backwards;
} }
@keyframes navItemEntrance {
from {
opacity: 0;
transform: translateX(-16px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.nav-btn:nth-child(1) { animation-delay: 60ms; }
.nav-btn:nth-child(2) { animation-delay: 110ms; }
.nav-btn:nth-child(3) { animation-delay: 160ms; }
.nav-btn:nth-child(4) { animation-delay: 210ms; }
.nav-btn:nth-child(5) { animation-delay: 260ms; }
.nav-btn:nth-child(6) { animation-delay: 310ms; }
.nav-btn:nth-child(7) { animation-delay: 360ms; }
.nav-btn:nth-child(8) { animation-delay: 410ms; }
.nav-btn:nth-child(9) { animation-delay: 460ms; }
.nav-btn:nth-child(10) { animation-delay: 510ms; }
.nav-btn:nth-child(11) { animation-delay: 560ms; }
.nav-btn:hover { .nav-btn:hover {
background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08); background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08);
color: var(--theme-primary-active); color: var(--theme-primary-active);

View File

@@ -3,7 +3,7 @@
:model-value="visible" :model-value="visible"
append-to-body append-to-body
align-center align-center
width="min(1040px, calc(100vw - 48px))" width="min(960px, calc(100vw - 64px))"
:show-close="false" :show-close="false"
:lock-scroll="true" :lock-scroll="true"
destroy-on-close destroy-on-close
@@ -277,6 +277,8 @@ watch(
} }
:global(.expense-profile-dialog.el-dialog) { :global(.expense-profile-dialog.el-dialog) {
max-height: calc(100vh - 56px);
max-height: calc(100dvh - 56px);
overflow: hidden; overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.34); border: 1px solid rgba(148, 163, 184, 0.34);
border-radius: 4px; border-radius: 4px;
@@ -380,7 +382,8 @@ watch(
} }
.profile-dialog-content { .profile-dialog-content {
max-height: min(660px, calc(100vh - 190px)); max-height: min(580px, calc(100vh - 176px));
max-height: min(580px, calc(100dvh - 176px));
min-height: 0; min-height: 0;
display: grid; display: grid;
gap: 12px; gap: 12px;
@@ -469,7 +472,7 @@ watch(
.profile-analysis-grid { .profile-analysis-grid {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(360px, 0.85fr); grid-template-columns: minmax(0, 1fr) minmax(320px, 0.85fr);
gap: 12px; gap: 12px;
} }
@@ -483,13 +486,13 @@ watch(
.profile-tags-panel { .profile-tags-panel {
grid-template-rows: auto minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
align-content: stretch; align-content: stretch;
min-height: 352px; min-height: 312px;
} }
.profile-radar-panel { .profile-radar-panel {
grid-template-rows: auto minmax(0, 1fr) auto; grid-template-rows: auto minmax(0, 1fr) auto;
align-content: stretch; align-content: stretch;
min-height: 352px; min-height: 312px;
} }
.profile-section-title { .profile-section-title {
@@ -554,11 +557,11 @@ watch(
} }
.profile-tags-panel > .profile-panel-empty { .profile-tags-panel > .profile-panel-empty {
min-height: 284px; min-height: 244px;
} }
.profile-radar-empty { .profile-radar-empty {
min-height: 308px; min-height: 268px;
} }
.profile-operation-copy strong { .profile-operation-copy strong {
@@ -580,13 +583,13 @@ watch(
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
align-items: center; align-items: center;
justify-items: stretch; justify-items: stretch;
min-height: 360px; min-height: 300px;
animation: profileRadarEnter 360ms cubic-bezier(0.2, 0, 0, 1) both; animation: profileRadarEnter 360ms cubic-bezier(0.2, 0, 0, 1) both;
} }
.profile-radar-chart { .profile-radar-chart {
width: 100%; width: 100%;
height: 360px; height: 300px;
} }
.profile-behavior-tags { .profile-behavior-tags {
@@ -703,6 +706,97 @@ watch(
justify-self: end; justify-self: end;
} }
@media (min-width: 861px) and (max-width: 1440px),
(min-width: 861px) and (max-height: 820px) {
:global(.expense-profile-dialog.el-dialog) {
width: min(900px, calc(100vw - 96px)) !important;
max-height: calc(100vh - 64px);
max-height: calc(100dvh - 64px);
}
.profile-dialog-header,
.profile-dialog-footer {
gap: 12px;
padding: 12px 16px;
}
.profile-dialog-header h2 {
margin: 2px 0 3px;
font-size: 17px;
}
.profile-dialog-header p,
.profile-dialog-footer span {
font-size: 11.5px;
}
.profile-dialog-content {
max-height: min(520px, calc(100vh - 152px));
max-height: min(520px, calc(100dvh - 152px));
gap: 10px;
padding: 12px;
}
.profile-summary-grid,
.profile-analysis-grid {
gap: 8px;
}
.profile-summary-item {
gap: 3px;
padding: 8px 10px;
}
.profile-summary-item strong {
font-size: 16px;
}
.profile-panel {
gap: 8px;
padding: 10px;
}
.profile-analysis-grid {
grid-template-columns: minmax(0, 1fr) minmax(300px, 0.82fr);
}
.profile-tags-panel,
.profile-radar-panel {
min-height: 272px;
}
.profile-tags-panel > .profile-panel-empty {
min-height: 210px;
}
.profile-radar-empty {
min-height: 220px;
}
.profile-radar-layout {
min-height: 248px;
}
.profile-radar-chart {
height: 248px;
}
.profile-behavior-tags {
gap: 6px;
min-height: 50px;
padding-top: 8px;
}
.profile-operation-list {
gap: 6px;
}
.profile-operation-row {
gap: 8px;
padding: 7px 0;
}
}
@keyframes expenseProfileDialogIn { @keyframes expenseProfileDialogIn {
0% { 0% {
opacity: 0; opacity: 0;

View File

@@ -3,7 +3,7 @@
:model-value="visible" :model-value="visible"
append-to-body append-to-body
align-center align-center
width="min(980px, calc(100vw - 48px))" width="min(900px, calc(100vw - 64px))"
:show-close="false" :show-close="false"
:lock-scroll="true" :lock-scroll="true"
destroy-on-close destroy-on-close
@@ -256,6 +256,8 @@ function resolveTagType(tone) {
} }
:global(.expense-stats-detail-dialog.el-dialog) { :global(.expense-stats-detail-dialog.el-dialog) {
max-height: calc(100vh - 56px);
max-height: calc(100dvh - 56px);
overflow: hidden; overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.34); border: 1px solid rgba(148, 163, 184, 0.34);
border-radius: 4px; border-radius: 4px;
@@ -353,7 +355,9 @@ function resolveTagType(tone) {
} }
.expense-stats-detail-content { .expense-stats-detail-content {
max-height: min(660px, calc(100vh - 190px)); max-height: min(580px, calc(100vh - 176px));
max-height: min(580px, calc(100dvh - 176px));
min-height: 0;
display: grid; display: grid;
gap: 12px; gap: 12px;
padding: 14px; padding: 14px;
@@ -372,7 +376,7 @@ function resolveTagType(tone) {
} }
.expense-stats-analysis-grid { .expense-stats-analysis-grid {
grid-template-columns: minmax(0, 0.9fr) minmax(360px, 1.1fr); grid-template-columns: minmax(0, 0.92fr) minmax(320px, 1.08fr);
} }
.expense-stats-summary-item, .expense-stats-summary-item,
@@ -432,7 +436,7 @@ function resolveTagType(tone) {
.expense-stats-distribution-panel, .expense-stats-distribution-panel,
.expense-stats-processing-panel { .expense-stats-processing-panel {
min-height: 336px; min-height: 300px;
} }
.expense-stats-section-title { .expense-stats-section-title {
@@ -483,17 +487,17 @@ function resolveTagType(tone) {
} }
.expense-distribution-chart { .expense-distribution-chart {
min-height: 286px; min-height: 250px;
display: grid; display: grid;
align-items: stretch; align-items: stretch;
} }
.expense-distribution-chart-layout { .expense-distribution-chart-layout {
display: grid; display: grid;
grid-template-columns: minmax(170px, 0.86fr) minmax(0, 1.14fr); grid-template-columns: minmax(160px, 0.86fr) minmax(0, 1.14fr);
align-items: center; align-items: center;
gap: 12px; gap: 12px;
min-height: 286px; min-height: 250px;
} }
.expense-distribution-donut { .expense-distribution-donut {
@@ -501,7 +505,7 @@ function resolveTagType(tone) {
} }
.expense-distribution-donut :deep(.donut-body) { .expense-distribution-donut :deep(.donut-body) {
height: 220px; height: 192px;
margin-top: 0; margin-top: 0;
} }
@@ -619,7 +623,7 @@ function resolveTagType(tone) {
.expense-stats-empty { .expense-stats-empty {
margin: 0; margin: 0;
min-height: 180px; min-height: 150px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -632,6 +636,97 @@ function resolveTagType(tone) {
text-align: center; text-align: center;
} }
@media (min-width: 861px) and (max-width: 1440px),
(min-width: 861px) and (max-height: 820px) {
:global(.expense-stats-detail-dialog.el-dialog) {
width: min(860px, calc(100vw - 96px)) !important;
max-height: calc(100vh - 64px);
max-height: calc(100dvh - 64px);
}
.expense-stats-detail-header,
.expense-stats-detail-footer {
gap: 12px;
padding: 12px 16px;
}
.expense-stats-detail-header h2 {
margin: 2px 0 3px;
font-size: 17px;
}
.expense-stats-detail-header p,
.expense-stats-detail-footer span {
font-size: 11.5px;
}
.expense-stats-detail-content {
max-height: min(520px, calc(100vh - 152px));
max-height: min(520px, calc(100dvh - 152px));
gap: 10px;
padding: 12px;
}
.expense-stats-summary-grid,
.expense-stats-analysis-grid {
gap: 8px;
}
.expense-stats-summary-item {
gap: 3px;
padding: 8px 10px;
}
.expense-stats-summary-item strong {
font-size: 16px;
}
.expense-stats-panel {
gap: 8px;
padding: 10px;
}
.expense-stats-analysis-grid {
grid-template-columns: minmax(0, 0.86fr) minmax(300px, 1.14fr);
}
.expense-stats-distribution-panel,
.expense-stats-processing-panel {
min-height: 272px;
}
.expense-distribution-chart,
.expense-distribution-chart-layout {
min-height: 222px;
}
.expense-distribution-chart-layout {
grid-template-columns: minmax(140px, 0.8fr) minmax(0, 1.2fr);
gap: 10px;
}
.expense-distribution-donut :deep(.donut-body) {
height: 170px;
}
.expense-distribution-summary-list,
.expense-processing-list,
.expense-operation-list {
gap: 6px;
}
.expense-distribution-summary-row,
.expense-processing-row,
.expense-operation-row {
gap: 8px;
padding: 7px 0;
}
.expense-processing-row {
grid-template-columns: minmax(112px, 0.78fr) minmax(148px, 1fr) auto 54px;
}
}
@keyframes expenseStatsDialogIn { @keyframes expenseStatsDialogIn {
0% { 0% {
opacity: 0; opacity: 0;

View File

@@ -199,65 +199,10 @@
</div> </div>
<div class="workbench-content-grid"> <div class="workbench-content-grid">
<article class="panel workbench-card progress-panel" style="--delay: 200ms;"> <PersonalWorkbenchProgressPanel
<div class="section-head"> :progress-items="workbenchSummary.progressItems || []"
<h2>费用进度</h2> @open-target="openWorkbenchTarget"
</div> />
<div class="progress-list">
<button
v-for="(item, index) in visibleProgressItems"
:key="item.id"
type="button"
class="progress-row"
:class="{ 'has-long-duration-divider': item.hasLongDurationDivider }"
:style="{ '--item-index': index }"
@click="openWorkbenchTarget(item)"
>
<span class="progress-time-wrapper">
<span class="expense-type-icon" :class="`expense-type-icon--${item.expenseTypeTone}`">
<i :class="item.expenseTypeIcon"></i>
</span>
<span class="progress-time">
<time :datetime="item.updatedAt || ''">{{ item.displayTime }}</time>
<small v-if="item.showTimeCapsule" class="time-capsule">更新时间</small>
<small v-if="item.showUpdateText">更新</small>
</span>
</span>
<span class="progress-identity">
<strong>{{ item.title }}</strong>
<small>{{ item.id }}</small>
</span>
<span class="progress-type" :title="`${item.documentTypeLabel} · ${item.expenseTypeLabel || '其他费用'}`">
<strong>{{ item.documentTypeLabel }} · {{ item.expenseTypeLabel || '其他费用' }}</strong>
</span>
<span class="progress-steps" aria-hidden="true">
<span
v-for="step in item.steps"
:key="step.label"
class="progress-step"
:class="{
'is-done': step.done,
'is-current': step.current,
'is-future': !step.done && !step.current
}"
>
<i :class="step.done || step.current ? 'mdi mdi-check' : 'mdi mdi-minus'"></i>
<small>{{ step.label }}</small>
</span>
</span>
<span class="progress-result">
<strong>{{ item.amount }}</strong>
<span class="progress-status" :class="`progress-status--${item.statusTone}`">{{ item.status }}</span>
</span>
</button>
</div>
</article>
<aside class="side-column"> <aside class="side-column">
<article class="panel workbench-card side-panel expense-stats-panel" style="--delay: 300ms;"> <article class="panel workbench-card side-panel expense-stats-panel" style="--delay: 300ms;">
@@ -363,6 +308,7 @@ import PanelHead from '../shared/PanelHead.vue'
import WorkbenchListIcon from '../shared/WorkbenchListIcon.vue' import WorkbenchListIcon from '../shared/WorkbenchListIcon.vue'
import ExpenseStatsDetailModal from './ExpenseStatsDetailModal.vue' import ExpenseStatsDetailModal from './ExpenseStatsDetailModal.vue'
import ExpenseProfileDetailModal from './ExpenseProfileDetailModal.vue' import ExpenseProfileDetailModal from './ExpenseProfileDetailModal.vue'
import PersonalWorkbenchProgressPanel from './PersonalWorkbenchProgressPanel.vue'
import workbenchHeroBackground from '../../assets/images/hero-3d-banner.png' import workbenchHeroBackground from '../../assets/images/hero-3d-banner.png'
import { useSystemState } from '../../composables/useSystemState.js' import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js' import { useToast } from '../../composables/useToast.js'
@@ -520,92 +466,8 @@ const expenseProfileEmptyReason = computed(() => String(employeeProfile.value?.e
const expenseStatsDetail = computed(() => props.workbenchSummary.expenseStatsDetail || {}) const expenseStatsDetail = computed(() => props.workbenchSummary.expenseStatsDetail || {})
const currentUserProfileKey = computed(() => { const currentUserProfileKey = computed(() => {
const user = currentUser.value || {} const user = currentUser.value || {}
return [ return [user.username, user.email, user.name, user.employeeNo, user.employee_no].map((item) => String(item || '').trim()).filter(Boolean).join('|')
user.username,
user.email,
user.name,
user.employeeNo,
user.employee_no
].map((item) => String(item || '').trim()).filter(Boolean).join('|')
}) })
function resolveExpenseTypeStyle(label) {
if (label === '差旅交通') return { icon: 'mdi mdi-airplane', tone: 'blue' }
if (label === '业务招待') return { icon: 'mdi mdi-silverware-fork-knife', tone: 'amber' }
if (label === '办公采购') return { icon: 'mdi mdi-cart-outline', tone: 'emerald' }
if (label === '培训会议') return { icon: 'mdi mdi-projector', tone: 'violet' }
if (label === '市场活动') return { icon: 'mdi mdi-bullhorn-outline', tone: 'cyan' }
return { icon: 'mdi mdi-receipt-text-outline', tone: 'muted' }
}
const visibleProgressItems = computed(() => {
const rows = Array.isArray(props.workbenchSummary.progressItems)
? props.workbenchSummary.progressItems
: []
const progressRows = rows.slice(0, 5).map((item) => ({
...item,
displayTime: formatProgressTime(item?.updatedAt),
isLongDuration: isLongDurationProgress(item?.updatedAt)
}))
return progressRows.map((item, index) => {
const isCompleted = item.statusTone === 'muted';
const expenseStyle = resolveExpenseTypeStyle(item.expenseTypeLabel);
return {
...item,
expenseTypeIcon: expenseStyle.icon,
expenseTypeTone: expenseStyle.tone,
showTimeCapsule: !item.isLongDuration,
showUpdateText: item.isLongDuration && !isCompleted,
hasLongDurationDivider: item.isLongDuration && !progressRows[index - 1]?.isLongDuration
}
})
})
const LONG_DURATION_DAYS = 10
const DAY_MS = 24 * 60 * 60 * 1000
function formatProgressTime(value) {
const text = String(value || '').trim()
if (!text) {
return '最近更新'
}
const match = /^(\d{4})-(\d{2})-(\d{2})(?:[T\s](\d{2}):(\d{2}))?/.exec(text)
if (match) {
return match[4] ? `${match[2]}-${match[3]} ${match[4]}:${match[5]}` : `${match[2]}-${match[3]}`
}
return text
}
function parseProgressDate(value) {
const text = String(value || '').trim()
if (!text) {
return null
}
const localDateMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text)
if (localDateMatch) {
return new Date(
Number(localDateMatch[1]),
Number(localDateMatch[2]) - 1,
Number(localDateMatch[3])
)
}
const date = new Date(text)
return Number.isNaN(date.getTime()) ? null : date
}
function isLongDurationProgress(value) {
const date = parseProgressDate(value)
if (!date) {
return false
}
return (Date.now() - date.getTime()) / DAY_MS >= LONG_DURATION_DAYS
}
function buildSelectedFileKey(file) { function buildSelectedFileKey(file) {
return [file?.name, file?.size, file?.lastModified, file?.type].join('__') return [file?.name, file?.size, file?.lastModified, file?.type].join('__')
} }
@@ -748,7 +610,7 @@ function openWorkbenchTarget(item) {
return return
} }
openPromptAssistant(item?.prompt || `查询 ${item?.id || ''}费用进度`, SESSION_TYPE_EXPENSE) openPromptAssistant(item?.prompt || `查询 ${item?.id || ''}单据进度`, SESSION_TYPE_EXPENSE)
} }
function openCapabilityAssistant(item) { function openCapabilityAssistant(item) {

View File

@@ -0,0 +1,211 @@
<template>
<article class="panel workbench-card progress-panel" style="--delay: 200ms;">
<div class="section-head progress-section-head">
<h2>单据进度</h2>
<div
class="progress-range-control"
@click.stop
@mousedown.stop
@pointerdown.stop
>
<EnterpriseSelect
v-model="selectedProgressRange"
class="progress-range-select"
:options="PROGRESS_RANGE_OPTIONS"
size="small"
:teleported="true"
aria-label="单据进度时间范围"
/>
</div>
</div>
<div v-if="visibleProgressItems.length" class="progress-table-shell">
<div class="progress-table-header" aria-hidden="true">
<span class="header-cell header-time">更新时间</span>
<span class="header-cell header-applicant">提单人</span>
<span class="header-cell header-identity">单据信息</span>
<span class="header-cell header-type">类型归属</span>
<span class="header-cell header-steps">办理进度</span>
<span class="header-cell header-result">涉及金额</span>
</div>
<div class="progress-list">
<button
v-for="(item, index) in visibleProgressItems"
:key="`${item.id}-${index}`"
type="button"
class="progress-row"
:style="{ '--item-index': index }"
@click="handleProgressItemClick($event, item)"
>
<span class="progress-time-wrapper">
<span class="expense-type-icon" :class="`expense-type-icon--${item.expenseTypeTone}`">
<i :class="item.expenseTypeIcon"></i>
</span>
<span class="progress-time">
<time :datetime="item.updatedAt || ''">{{ item.displayTime }}</time>
</span>
</span>
<span class="progress-applicant" title="申请人">
<strong>{{ item.applicantLabel || '待补充' }}</strong>
</span>
<span class="progress-identity">
<strong>{{ item.title }}</strong>
<small>{{ item.id }}</small>
</span>
<span class="progress-type" :title="`${item.documentTypeLabel} · ${item.expenseTypeLabel || '其他费用'}`">
<strong>{{ item.documentTypeLabel }} · {{ item.expenseTypeLabel || '其他费用' }}</strong>
</span>
<span class="progress-steps" aria-hidden="true">
<span
v-for="step in item.steps"
:key="step.label"
class="progress-step"
:class="{
'is-done': step.done,
'is-current': step.current,
'is-future': !step.done && !step.current
}"
>
<i :class="step.done || step.current ? 'mdi mdi-check' : 'mdi mdi-minus'"></i>
<small>{{ step.label }}</small>
</span>
</span>
<span class="progress-result">
<strong>{{ item.amount }}</strong>
</span>
</button>
</div>
</div>
<div v-else class="progress-empty-state" role="status">
<span class="progress-empty-icon" aria-hidden="true">
<i class="mdi mdi-file-document-search-outline"></i>
</span>
<strong>当前范围暂无单据</strong>
<p>{{ progressRangeLabel }}内没有申请单或报销单进度</p>
</div>
</article>
</template>
<script setup>
import { computed, ref } from 'vue'
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
const PROGRESS_RANGE_OPTIONS = Object.freeze([
{ value: '10d', label: '近10日' },
{ value: '30d', label: '近30日' },
{ value: '3m', label: '近3个月' }
])
const DAY_MS = 24 * 60 * 60 * 1000
const props = defineProps({
progressItems: { type: Array, default: () => [] }
})
const emit = defineEmits(['open-target'])
const selectedProgressRange = ref('30d')
const progressRangeLabel = computed(() =>
PROGRESS_RANGE_OPTIONS.find((item) => item.value === selectedProgressRange.value)?.label || '近30日'
)
const visibleProgressItems = computed(() => {
const rows = Array.isArray(props.progressItems) ? props.progressItems : []
return rows
.filter((item) => isInSelectedProgressRange(item?.updatedAt))
.map((item) => {
const expenseStyle = resolveExpenseTypeStyle(item.expenseTypeLabel)
return {
...item,
displayTime: formatProgressTime(item?.updatedAt),
expenseTypeIcon: expenseStyle.icon,
expenseTypeTone: expenseStyle.tone
}
})
})
function resolveExpenseTypeStyle(label) {
if (label === '差旅交通') return { icon: 'mdi mdi-airplane', tone: 'blue' }
if (label === '业务招待') return { icon: 'mdi mdi-silverware-fork-knife', tone: 'amber' }
if (label === '办公采购') return { icon: 'mdi mdi-cart-outline', tone: 'emerald' }
if (label === '培训会议') return { icon: 'mdi mdi-projector', tone: 'violet' }
if (label === '市场活动') return { icon: 'mdi mdi-bullhorn-outline', tone: 'cyan' }
return { icon: 'mdi mdi-receipt-text-outline', tone: 'muted' }
}
function formatProgressTime(value) {
const text = String(value || '').trim()
if (!text) {
return '最近更新'
}
const match = /^(\d{4})-(\d{2})-(\d{2})(?:[T\s](\d{2}):(\d{2}))?/.exec(text)
if (match) {
return match[4] ? `${match[2]}-${match[3]} ${match[4]}:${match[5]}` : `${match[2]}-${match[3]}`
}
return text
}
function parseProgressDate(value) {
const text = String(value || '').trim()
if (!text) {
return null
}
const localDateMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text)
if (localDateMatch) {
return new Date(
Number(localDateMatch[1]),
Number(localDateMatch[2]) - 1,
Number(localDateMatch[3])
)
}
const date = new Date(text)
return Number.isNaN(date.getTime()) ? null : date
}
function resolveProgressRangeStart() {
const start = new Date()
start.setHours(0, 0, 0, 0)
if (selectedProgressRange.value === '10d') {
start.setDate(start.getDate() - 10)
return start
}
if (selectedProgressRange.value === '3m') {
start.setMonth(start.getMonth() - 3)
return start
}
start.setDate(start.getDate() - 30)
return start
}
function isInSelectedProgressRange(value) {
const date = parseProgressDate(value)
if (!date) {
return true
}
return date.getTime() >= resolveProgressRangeStart().getTime()
}
function handleProgressItemClick(event, item) {
const target = event?.target
if (target?.closest?.('.progress-range-control, .enterprise-select-popper, .el-select-dropdown, .el-popper')) {
return
}
emit('open-target', item)
}
</script>
<style scoped src="../../assets/styles/components/personal-workbench-progress.css"></style>

View File

@@ -32,7 +32,7 @@ const chartColors = computed(() => ({
const ariaLabel = computed(() => const ariaLabel = computed(() =>
props.labels.map((label, index) => ( props.labels.map((label, index) => (
`${label}登录${props.loginUsers[index] || 0}人,互动${props.interactions[index] || 0}` `${label}在线${props.loginUsers[index] || 0}人,互动${props.interactions[index] || 0}`
)).join('') )).join('')
) )
@@ -84,13 +84,15 @@ const chartOptions = computed(() => ({
axisLabel: { axisLabel: {
color: '#64748b', color: '#64748b',
fontSize: 11, fontSize: 11,
fontWeight: 700 fontWeight: 700,
interval: props.compact ? 2 : 1,
hideOverlap: true
} }
}, },
yAxis: [ yAxis: [
{ {
type: 'value', type: 'value',
name: '登录', name: '在线',
min: 0, min: 0,
axisLabel: { axisLabel: {
color: '#64748b', color: '#64748b',
@@ -115,7 +117,7 @@ const chartOptions = computed(() => ({
], ],
series: [ series: [
{ {
name: '登录人数', name: '在线人数',
type: 'line', type: 'line',
smooth: 0.42, smooth: 0.42,
symbol: 'circle', symbol: 'circle',

View File

@@ -5,7 +5,7 @@ import { useNavigation, navItems } from './useNavigation.js'
import { mapExpenseClaimToRequest, useRequests } from './useRequests.js' import { mapExpenseClaimToRequest, useRequests } from './useRequests.js'
import { useSystemState } from './useSystemState.js' import { useSystemState } from './useSystemState.js'
import { useToast } from './useToast.js' import { useToast } from './useToast.js'
import { fetchExpenseClaimDetail } from '../services/reimbursements.js' import { fetchAllApprovalExpenseClaims, fetchExpenseClaimDetail } from '../services/reimbursements.js'
import { fetchOntologyParse } from '../services/ontology.js' import { fetchOntologyParse } from '../services/ontology.js'
import { fetchLatestConversation } from '../services/orchestrator.js' import { fetchLatestConversation } from '../services/orchestrator.js'
import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.js' import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.js'
@@ -18,20 +18,20 @@ import {
} from '../utils/workbenchAssistantIntent.js' } from '../utils/workbenchAssistantIntent.js'
import { buildWorkbenchSummary } from '../utils/workbenchSummary.js' import { buildWorkbenchSummary } from '../utils/workbenchSummary.js'
import { createCurrentYearDateRange } from '../utils/dateRangeDefaults.js' import { createCurrentYearDateRange } from '../utils/dateRangeDefaults.js'
const SESSION_TYPE_EXPENSE = 'expense' const SESSION_TYPE_EXPENSE = 'expense'
const SMART_ENTRY_SOURCE_APPLICATION = 'application' const SMART_ENTRY_SOURCE_APPLICATION = 'application'
const SMART_ENTRY_SOURCE_REIMBURSEMENT = 'topbar' const SMART_ENTRY_SOURCE_REIMBURSEMENT = 'topbar'
export function useAppShell() { export function useAppShell() {
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const smartEntryOpen = ref(false) const smartEntryOpen = ref(false)
const smartEntryContext = ref({ const smartEntryContext = ref({
prompt: '', prompt: '',
source: 'documents', source: 'documents',
request: null, request: null,
files: [], files: [],
conversation: null, conversation: null,
scope: null, scope: null,
@@ -45,16 +45,17 @@ export function useAppShell() {
const smartEntryInvalidatedDraftClaimId = ref('') const smartEntryInvalidatedDraftClaimId = ref('')
const selectedRequestSnapshot = ref(null) const selectedRequestSnapshot = ref(null)
const documentCenterRefreshToken = ref(0) const documentCenterRefreshToken = ref(0)
const workbenchApprovalRequests = ref([])
const { activeView, currentView, setView } = useNavigation()
const { const { activeView, currentView, setView } = useNavigation()
requests, const {
loading: requestsLoading, requests,
error: requestsError, loading: requestsLoading,
search, error: requestsError,
filters, search,
ranges, filters,
activeRange, ranges,
activeRange,
filteredRequests, filteredRequests,
approveRequest, approveRequest,
rejectRequest, rejectRequest,
@@ -65,7 +66,7 @@ export function useAppShell() {
const { toast } = useToast() const { toast } = useToast()
const customRange = ref(createCurrentYearDateRange()) const customRange = ref(createCurrentYearDateRange())
const selectedRequest = computed(() => { const selectedRequest = computed(() => {
const requestId = String(route.params.requestId || '') const requestId = String(route.params.requestId || '')
@@ -105,6 +106,40 @@ export function useAppShell() {
return reloadRequests() return reloadRequests()
} }
async function reloadWorkbenchApprovalRequests() {
try {
const payload = await fetchAllApprovalExpenseClaims()
workbenchApprovalRequests.value = Array.isArray(payload)
? payload.map((item) => mapExpenseClaimToRequest(item))
: []
} catch {
workbenchApprovalRequests.value = []
}
}
async function reloadWorkbenchRequests() {
const [payload] = await Promise.all([
reloadRequests({ silent: true }),
reloadWorkbenchApprovalRequests()
])
return payload
}
function resolveWorkbenchRequestKey(request) {
return String(request?.claimId || request?.id || request?.claimNo || '').trim()
}
function mergeWorkbenchRequests(primaryRequests = [], approvalRequests = []) {
const merged = new Map()
for (const request of [...primaryRequests, ...approvalRequests]) {
const key = resolveWorkbenchRequestKey(request)
if (key) {
merged.set(key, request)
}
}
return Array.from(merged.values())
}
function isSameRequestIdentity(request, requestId) { function isSameRequestIdentity(request, requestId) {
const normalizedId = String(requestId || '').trim() const normalizedId = String(requestId || '').trim()
if (!request || !normalizedId) { if (!request || !normalizedId) {
@@ -185,16 +220,20 @@ export function useAppShell() {
return return
} }
if (view === 'workbench') { if (view === 'workbench') {
void ensureRequestsLoaded() void reloadWorkbenchRequests()
} }
}, },
{ immediate: true } { immediate: true }
) )
const workbenchSummary = computed(() => const workbenchRequests = computed(() =>
buildWorkbenchSummary(requests.value, currentUser.value) mergeWorkbenchRequests(requests.value, workbenchApprovalRequests.value)
) )
const workbenchSummary = computed(() =>
buildWorkbenchSummary(workbenchRequests.value, currentUser.value)
)
const topBarView = computed(() => { const topBarView = computed(() => {
if (detailMode.value) { if (detailMode.value) {
const request = selectedRequest.value || {} const request = selectedRequest.value || {}
@@ -207,46 +246,46 @@ export function useAppShell() {
: '查看报销明细、票据材料、审批进度与风险提示。' : '查看报销明细、票据材料、审批进度与风险提示。'
} }
} }
return currentView.value return currentView.value
}) })
const requestSummary = computed(() => const requestSummary = computed(() =>
filteredRequests.value.reduce( filteredRequests.value.reduce(
(summary, item) => { (summary, item) => {
const request = normalizeRequestForUi(item) const request = normalizeRequestForUi(item)
if (!request) { if (!request) {
return summary return summary
} }
summary.total += 1 summary.total += 1
if (request.approvalKey === 'draft') { if (request.approvalKey === 'draft') {
summary.draft += 1 summary.draft += 1
} else if (request.approvalKey === 'in_progress') { } else if (request.approvalKey === 'in_progress') {
summary.inProgress += 1 summary.inProgress += 1
} else if (request.approvalKey === 'supplement') { } else if (request.approvalKey === 'supplement') {
summary.supplement += 1 summary.supplement += 1
} else if (request.approvalKey === 'completed') { } else if (request.approvalKey === 'completed') {
summary.completed += 1 summary.completed += 1
} }
return summary return summary
}, },
{ total: 0, draft: 0, inProgress: 0, supplement: 0, completed: 0 } { total: 0, draft: 0, inProgress: 0, supplement: 0, completed: 0 }
) )
) )
function handleApprove(request) { function handleApprove(request) {
const message = approveRequest(request) const message = approveRequest(request)
toast(message) toast(message)
} }
function handleReject(request) { function handleReject(request) {
const message = rejectRequest(request) const message = rejectRequest(request)
toast(message) toast(message)
} }
function handleNavigate(view) { function handleNavigate(view) {
smartEntryOpen.value = false smartEntryOpen.value = false
const shouldRefreshCurrentDocumentCenter = const shouldRefreshCurrentDocumentCenter =
@@ -258,7 +297,7 @@ export function useAppShell() {
void reloadDocumentCenterRequests() void reloadDocumentCenterRequests()
} }
} }
function openFinancialAssistantCreate(source) { function openFinancialAssistantCreate(source) {
if (smartEntryOpen.value) { if (smartEntryOpen.value) {
smartEntryRevealToken.value += 1 smartEntryRevealToken.value += 1
@@ -287,28 +326,28 @@ export function useAppShell() {
function openExpenseApplicationCreate() { function openExpenseApplicationCreate() {
openFinancialAssistantCreate(SMART_ENTRY_SOURCE_APPLICATION) 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'
} }
function resolveSmartEntryClaimScope(payload = {}) { function resolveSmartEntryClaimScope(payload = {}) {
const request = payload.request && typeof payload.request === 'object' ? payload.request : null const request = payload.request && typeof payload.request === 'object' ? payload.request : null
const payloadScope = payload.scope && typeof payload.scope === 'object' ? payload.scope : null const payloadScope = payload.scope && typeof payload.scope === 'object' ? payload.scope : null
const claimId = String( const claimId = String(
payloadScope?.claimId || payloadScope?.claimId ||
payloadScope?.claim_id || payloadScope?.claim_id ||
request?.claimId || request?.claimId ||
request?.claim_id || request?.claim_id ||
'' ''
).trim() ).trim()
if (!claimId) { if (!claimId) {
return null return null
} }
return { type: 'claim', claimId } return { type: 'claim', claimId }
} }
function isDetailClaimScopedPayload(payload = {}) { function isDetailClaimScopedPayload(payload = {}) {
return String(payload.source || '').trim() === 'detail' && Boolean(resolveSmartEntryClaimScope(payload)) return String(payload.source || '').trim() === 'detail' && Boolean(resolveSmartEntryClaimScope(payload))
} }
@@ -451,7 +490,7 @@ export function useAppShell() {
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) const isApplicationDocument = isApplicationDocumentPayload(payload, claimNo)
await reloadRequests() await reloadWorkbenchRequests()
if (status === 'submitted') { if (status === 'submitted') {
if (isApplicationDocument) { if (isApplicationDocument) {
toast(`${claimNo || '该'}申请单已提交${approvalStage ? `,当前节点:${approvalStage}` : ',等待直属领导审批'}`) toast(`${claimNo || '该'}申请单已提交${approvalStage ? `,当前节点:${approvalStage}` : ',等待直属领导审批'}`)
@@ -505,7 +544,7 @@ export function useAppShell() {
} }
async function handleRequestUpdated() { async function handleRequestUpdated() {
await reloadRequests() await reloadWorkbenchRequests()
await refreshSelectedRequestDetail(String(route.params.requestId || '')) await refreshSelectedRequestDetail(String(route.params.requestId || ''))
} }

View File

@@ -18,9 +18,7 @@ import {
import { import {
metricBlueprints, metricBlueprints,
systemMetricBlueprints, systemMetricBlueprints,
systemDashboardTotals as fallbackSystemDashboardTotals,
systemAgentDailyRatio as fallbackSystemAgentDailyRatio, systemAgentDailyRatio as fallbackSystemAgentDailyRatio,
systemLoginWave as fallbackSystemLoginWave,
systemTokenDailyWave as fallbackSystemTokenDailyWave, systemTokenDailyWave as fallbackSystemTokenDailyWave,
systemUsageDurationSummary as fallbackSystemUsageDurationSummary, systemUsageDurationSummary as fallbackSystemUsageDurationSummary,
systemUserTokenUsage as fallbackSystemUserTokenUsage, systemUserTokenUsage as fallbackSystemUserTokenUsage,
@@ -78,6 +76,25 @@ const emptyFinanceBudgetMetrics = [
{ label: '预警预算池', value: '0 个', detail: '超支 0 个', tone: 'success', icon: 'mdi mdi-alert-outline' } { label: '预警预算池', value: '0 个', detail: '超支 0 个', tone: 'success', icon: 'mdi mdi-alert-outline' }
] ]
const emptySystemDashboardTotals = {
toolCalls: 0,
modelTokens: 0,
onlineUsers: 0,
avgOnlineMinutes: 0,
executionSuccessRate: 0,
positiveFeedback: 0,
negativeFeedback: 0,
failedRuns: 0,
toolCallsChange: 0,
modelTokensChange: 0
}
const emptySystemLoginWave = {
labels: Array.from({ length: 24 }, (_, hour) => `${String(hour).padStart(2, '0')}:00`),
loginUsers: Array.from({ length: 24 }, () => 0),
interactions: Array.from({ length: 24 }, () => 0)
}
function parseLocalDate(value) { function parseLocalDate(value) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value || '').trim()) const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value || '').trim())
if (!match) { if (!match) {
@@ -439,13 +456,13 @@ export function useOverviewView(options = {}) {
}) })
const systemDashboardTotals = computed(() => ( const systemDashboardTotals = computed(() => (
systemDashboardPayload.value?.totals || fallbackSystemDashboardTotals systemDashboardPayload.value?.totals || emptySystemDashboardTotals
)) ))
const systemAgentDailyRatio = computed(() => ( const systemAgentDailyRatio = computed(() => (
systemDashboardPayload.value?.agentDailyRatio || fallbackSystemAgentDailyRatio systemDashboardPayload.value?.agentDailyRatio || fallbackSystemAgentDailyRatio
)) ))
const systemLoginWave = computed(() => ( const systemLoginWave = computed(() => (
systemDashboardPayload.value?.loginWave || fallbackSystemLoginWave systemDashboardPayload.value?.loginWave || emptySystemLoginWave
)) ))
const systemTokenDailyWave = computed(() => ( const systemTokenDailyWave = computed(() => (
systemDashboardPayload.value?.tokenDailyWave || fallbackSystemTokenDailyWave systemDashboardPayload.value?.tokenDailyWave || fallbackSystemTokenDailyWave

View File

@@ -1,6 +1,6 @@
import { computed, reactive, ref } from 'vue' import { computed, reactive, ref } from 'vue'
import { fetchExpenseClaims } from '../services/reimbursements.js' import { fetchAllExpenseClaims } from '../services/reimbursements.js'
import { filterActionableRiskFlags, normalizeRiskFlagTone } from '../utils/riskFlags.js' import { filterActionableRiskFlags, normalizeRiskFlagTone } from '../utils/riskFlags.js'
const EXPENSE_TYPE_LABELS = { const EXPENSE_TYPE_LABELS = {
@@ -72,7 +72,6 @@ const APPLICATION_PROGRESS_LABELS = [
'创建申请', '创建申请',
'直属领导审批', '直属领导审批',
'预算管理者审批', '预算管理者审批',
'审批完成',
APPLICATION_LINK_STATUS_STEP_LABEL, APPLICATION_LINK_STATUS_STEP_LABEL,
ARCHIVED_STEP_LABEL ARCHIVED_STEP_LABEL
] ]
@@ -80,7 +79,6 @@ const APPLICATION_PROGRESS_LABELS = [
const APPLICATION_PROGRESS_LABELS_WITHOUT_BUDGET = [ const APPLICATION_PROGRESS_LABELS_WITHOUT_BUDGET = [
'创建申请', '创建申请',
'直属领导审批', '直属领导审批',
'审批完成',
APPLICATION_LINK_STATUS_STEP_LABEL, APPLICATION_LINK_STATUS_STEP_LABEL,
ARCHIVED_STEP_LABEL ARCHIVED_STEP_LABEL
] ]
@@ -595,17 +593,17 @@ function resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode) {
const normalizedNode = String(workflowNode || '').trim() const normalizedNode = String(workflowNode || '').trim()
if (approvalMeta.key === 'completed') { if (approvalMeta.key === 'completed') {
return normalizedNode.includes(APPLICATION_ARCHIVE_STAGE_LABEL) ? 4 : 3 return normalizedNode.includes(APPLICATION_ARCHIVE_STAGE_LABEL) ? 3 : 2
} }
if (normalizedNode.includes(APPLICATION_ARCHIVE_STAGE_LABEL) || normalizedNode.includes(ARCHIVED_STEP_LABEL)) { if (normalizedNode.includes(APPLICATION_ARCHIVE_STAGE_LABEL) || normalizedNode.includes(ARCHIVED_STEP_LABEL)) {
return 4 return 3
} }
if (normalizedNode.includes(APPLICATION_LINK_STATUS_STEP_LABEL)) { if (normalizedNode.includes(APPLICATION_LINK_STATUS_STEP_LABEL)) {
return 3 return 2
} }
if (normalizedNode.includes('审批完成') || normalizedNode.includes('申请完成')) { if (normalizedNode.includes('审批完成') || normalizedNode.includes('申请完成')) {
return 3 return 2
} }
if (normalizedNode.includes('预算')) { if (normalizedNode.includes('预算')) {
return 2 return 2
@@ -693,6 +691,36 @@ function resolveApplicationApproverName(claim) {
) || '直属领导' ) || '直属领导'
} }
function resolveReimbursementApproverName(claim, label) {
const stepLabel = normalizeText(label)
if (stepLabel === '直属领导审批') {
return resolveDisplayName(
claim?.manager_name,
claim?.managerName,
claim?.profile_manager,
claim?.profileManager,
claim?.direct_manager_name,
claim?.directManagerName
) || '直属领导'
}
if (stepLabel === '财务审批') {
const routeEvent = findReimbursementFinanceRouteEvent(claim)
return resolveDisplayName(
claim?.finance_approver_name,
claim?.financeApproverName,
routeEvent?.next_approver_name,
routeEvent?.nextApproverName,
routeEvent?.finance_approver_name,
routeEvent?.financeApproverName,
claim?.finance_owner_name,
claim?.financeOwnerName
) || '财务'
}
return stepLabel.replace(/审批$/, '') || '审批人'
}
function resolveApplicationBudgetApproverName(claim) { function resolveApplicationBudgetApproverName(claim) {
const routeEvent = findApprovalEventForStep(claim, '直属领导审批') const routeEvent = findApprovalEventForStep(claim, '直属领导审批')
return resolveDisplayName( return resolveDisplayName(
@@ -708,6 +736,15 @@ function resolveApplicationBudgetApproverName(claim) {
function resolveProgressDisplayLabel(label, documentTypeCode, claim, approvalMeta) { function resolveProgressDisplayLabel(label, documentTypeCode, claim, approvalMeta) {
const normalizedLabel = normalizeText(label) const normalizedLabel = normalizeText(label)
const workflowNode = normalizeText(claim?.approval_stage || claim?.workflowNode) const workflowNode = normalizeText(claim?.approval_stage || claim?.workflowNode)
if (
documentTypeCode !== DOCUMENT_TYPE_APPLICATION
&& approvalMeta.key !== 'completed'
&& (normalizedLabel === '直属领导审批' || normalizedLabel === '财务审批')
&& workflowNode.includes(normalizedLabel.replace(/审批$/, ''))
) {
return `等待 ${resolveReimbursementApproverName(claim, normalizedLabel)} 批复`
}
if ( if (
documentTypeCode === DOCUMENT_TYPE_APPLICATION documentTypeCode === DOCUMENT_TYPE_APPLICATION
&& approvalMeta.key !== 'completed' && approvalMeta.key !== 'completed'
@@ -796,6 +833,24 @@ function findApprovalEventForStep(claim, label) {
return getLatestEvent(events) return getLatestEvent(events)
} }
function findReimbursementFinanceRouteEvent(claim) {
return getLatestEvent(
getRiskFlags(claim).filter((flag) => {
if (!flag || typeof flag !== 'object') {
return false
}
const source = normalizeText(flag.source)
if (!['manual_approval', 'budget_approval'].includes(source)) {
return false
}
const nextStage = normalizeText(flag.next_approval_stage || flag.nextApprovalStage)
return nextStage.includes('财务')
})
)
}
function findLatestReturnEvent(claim) { function findLatestReturnEvent(claim) {
return getLatestEvent( return getLatestEvent(
getRiskFlags(claim).filter((flag) => ( getRiskFlags(claim).filter((flag) => (
@@ -1066,13 +1121,39 @@ function findMergedApplicationBudgetApprovalEvent(claim) {
source === 'manual_approval' source === 'manual_approval'
&& eventType === 'expense_application_approval' && eventType === 'expense_application_approval'
&& previousStage.includes('直属领导') && previousStage.includes('直属领导')
&& nextStage.includes('审批完成') && (
nextStage.includes('审批完成')
|| nextStage.includes(APPLICATION_LINK_STATUS_STEP_LABEL)
|| nextStage.includes('申请完成')
)
&& mergedFlag && mergedFlag
) )
}) })
) )
} }
function resolveBudgetRouteResult(flag, routeDecision = {}) {
if (routeDecision && typeof routeDecision === 'object') {
const routeBudgetResult = routeDecision.budget_result || routeDecision.budgetResult
if (routeBudgetResult && typeof routeBudgetResult === 'object') {
return routeBudgetResult
}
}
const flagBudgetResult = flag?.budget_result || flag?.budgetResult
return flagBudgetResult && typeof flagBudgetResult === 'object' ? flagBudgetResult : {}
}
function applicationBudgetRouteMeetsThreshold(flag, routeDecision = {}) {
const budgetResult = resolveBudgetRouteResult(flag, routeDecision)
const metrics = budgetResult.metrics && typeof budgetResult.metrics === 'object' ? budgetResult.metrics : {}
const overBudgetAmount = parseNumber(metrics.over_budget_amount ?? metrics.overBudgetAmount)
const afterUsageRate = parseNumber(metrics.after_usage_rate ?? metrics.afterUsageRate)
const claimAmountRatio = parseNumber(metrics.claim_amount_ratio ?? metrics.claimAmountRatio)
return overBudgetAmount > 0 || Math.max(afterUsageRate, claimAmountRatio) >= 90
}
function applicationRequiresBudgetReviewStep(claim, workflowNode) { function applicationRequiresBudgetReviewStep(claim, workflowNode) {
const node = normalizeText(workflowNode || claim?.approval_stage || claim?.workflowNode) const node = normalizeText(workflowNode || claim?.approval_stage || claim?.workflowNode)
if (node.includes('预算')) { if (node.includes('预算')) {
@@ -1087,24 +1168,22 @@ function applicationRequiresBudgetReviewStep(claim, workflowNode) {
const source = normalizeText(flag.source) const source = normalizeText(flag.source)
const eventType = normalizeText(flag.event_type || flag.eventType) const eventType = normalizeText(flag.event_type || flag.eventType)
const previousStage = normalizeText(flag.previous_approval_stage || flag.previousApprovalStage) const previousStage = normalizeText(flag.previous_approval_stage || flag.previousApprovalStage)
const nextStage = normalizeText(flag.next_approval_stage || flag.nextApprovalStage)
const routeDecision = flag.route_decision || flag.routeDecision || {} const routeDecision = flag.route_decision || flag.routeDecision || {}
if (source === 'approval_routing' && flag.requires_budget_review === true) { if (source === 'approval_routing' && flag.requires_budget_review === true) {
return true return applicationBudgetRouteMeetsThreshold(flag, flag)
} }
if ( if (
routeDecision routeDecision
&& typeof routeDecision === 'object' && typeof routeDecision === 'object'
&& routeDecision.requires_budget_review === true && routeDecision.requires_budget_review === true
) { ) {
return true return applicationBudgetRouteMeetsThreshold(flag, routeDecision)
} }
return ( return (
source === 'budget_approval' source === 'budget_approval'
|| eventType === 'expense_application_budget_approval' || eventType === 'expense_application_budget_approval'
|| previousStage.includes('预算') || previousStage.includes('预算')
|| nextStage.includes('预算')
) )
}) })
} }
@@ -1273,7 +1352,7 @@ function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {}
? hasApplicationReturnStep ? hasApplicationReturnStep
? ['创建申请', '直属领导审批', '退回', '待提交'] ? ['创建申请', '直属领导审批', '退回', '待提交']
: hasMergedApplicationBudgetApproval : hasMergedApplicationBudgetApproval
? ['创建申请', '直属领导审批', '审批完成', APPLICATION_LINK_STATUS_STEP_LABEL, ARCHIVED_STEP_LABEL] ? ['创建申请', '直属领导审批', APPLICATION_LINK_STATUS_STEP_LABEL, ARCHIVED_STEP_LABEL]
: shouldShowApplicationBudgetStep : shouldShowApplicationBudgetStep
? APPLICATION_PROGRESS_LABELS ? APPLICATION_PROGRESS_LABELS
: APPLICATION_PROGRESS_LABELS_WITHOUT_BUDGET : APPLICATION_PROGRESS_LABELS_WITHOUT_BUDGET
@@ -1512,6 +1591,8 @@ export function mapExpenseClaimToRequest(claim) {
employeePosition: String(claim?.employee_position || '').trim(), employeePosition: String(claim?.employee_position || '').trim(),
employeeGrade: String(claim?.employee_grade || '').trim(), employeeGrade: String(claim?.employee_grade || '').trim(),
managerName: resolveDisplayName(claim?.manager_name), managerName: resolveDisplayName(claim?.manager_name),
financeApproverName: resolveDisplayName(claim?.finance_approver_name, claim?.financeApproverName),
financeOwnerName: resolveDisplayName(claim?.finance_owner_name, claim?.financeOwnerName),
budgetApproverName: resolveDisplayName(claim?.budget_approver_name, claim?.budgetApproverName), budgetApproverName: resolveDisplayName(claim?.budget_approver_name, claim?.budgetApproverName),
budgetApproverGrade: String(claim?.budget_approver_grade || claim?.budgetApproverGrade || '').trim(), budgetApproverGrade: String(claim?.budget_approver_grade || claim?.budgetApproverGrade || '').trim(),
budgetApproverRoleCode: String(claim?.budget_approver_role_code || claim?.budgetApproverRoleCode || '').trim(), budgetApproverRoleCode: String(claim?.budget_approver_role_code || claim?.budgetApproverRoleCode || '').trim(),
@@ -1665,19 +1746,26 @@ export function useRequests() {
}) })
}) })
async function reload() { async function reload(options = {}) {
loading.value = true const silent = Boolean(options?.silent)
error.value = '' if (!silent) {
loading.value = true
error.value = ''
}
try { try {
const payload = await fetchExpenseClaims() const payload = await fetchAllExpenseClaims()
requests.value = Array.isArray(payload) ? payload.map((item) => mapExpenseClaimToRequest(item)) : [] requests.value = Array.isArray(payload) ? payload.map((item) => mapExpenseClaimToRequest(item)) : []
loaded.value = true loaded.value = true
} catch (nextError) { } catch (nextError) {
requests.value = [] if (!silent) {
requests.value = []
}
error.value = nextError instanceof Error ? nextError.message : '个人报销列表加载失败。' error.value = nextError instanceof Error ? nextError.message : '个人报销列表加载失败。'
} finally { } finally {
loading.value = false if (!silent) {
loading.value = false
}
} }
} }

View File

@@ -49,7 +49,10 @@ const router = createRouter({
}, },
{ {
path: '/app', path: '/app',
redirect: { name: 'app-overview' } redirect: () => {
const { resolveEntryRoute } = useSystemState()
return resolveEntryRoute()
}
}, },
{ {
path: '/app/documents', path: '/app/documents',

View File

@@ -1,6 +1,8 @@
import { apiRequest } from './api.js' import { apiRequest } from './api.js'
export const REIMBURSEMENT_LIST_PREVIEW_PARAMS = Object.freeze({ page: 1, pageSize: 100 }) export const REIMBURSEMENT_LIST_PREVIEW_PARAMS = Object.freeze({ page: 1, pageSize: 100 })
export const REIMBURSEMENT_LIST_FULL_PAGE_SIZE = 100
const REIMBURSEMENT_LIST_MAX_PAGES = 200
function buildListQuery(params = {}) { function buildListQuery(params = {}) {
const search = new URLSearchParams() const search = new URLSearchParams()
@@ -27,18 +29,73 @@ export function extractExpenseClaimItems(payload) {
return Array.isArray(payload?.items) ? payload.items : [] return Array.isArray(payload?.items) ? payload.items : []
} }
function isPaginatedExpenseClaimPayload(payload) {
return Boolean(
payload
&& typeof payload === 'object'
&& Array.isArray(payload.items)
&& Number.isFinite(Number(payload.page))
&& Number.isFinite(Number(payload.page_size))
)
}
async function fetchAllExpenseClaimPages(fetchPage, params = {}) {
const pageSize = Number(params.pageSize || params.page_size || REIMBURSEMENT_LIST_FULL_PAGE_SIZE)
const normalizedPageSize = Number.isFinite(pageSize) && pageSize > 0
? Math.min(REIMBURSEMENT_LIST_FULL_PAGE_SIZE, Math.floor(pageSize))
: REIMBURSEMENT_LIST_FULL_PAGE_SIZE
let page = Math.max(1, Math.floor(Number(params.page || 1) || 1))
const items = []
for (let index = 0; index < REIMBURSEMENT_LIST_MAX_PAGES; index += 1) {
const payload = await fetchPage({
...params,
page,
pageSize: normalizedPageSize
})
if (!isPaginatedExpenseClaimPayload(payload)) {
return extractExpenseClaimItems(payload)
}
items.push(...extractExpenseClaimItems(payload))
const total = Number(payload.total || 0)
const totalPages = Number(payload.total_pages || 0)
const hasNext = Boolean(payload.has_next)
if (!hasNext || (totalPages && page >= totalPages) || (total && items.length >= total)) {
break
}
page += 1
}
return items
}
export function fetchExpenseClaims(params = {}) { export function fetchExpenseClaims(params = {}) {
return apiRequest(`/reimbursements/claims${buildListQuery(params)}`) return apiRequest(`/reimbursements/claims${buildListQuery(params)}`)
} }
export function fetchAllExpenseClaims(params = {}) {
return fetchAllExpenseClaimPages(fetchExpenseClaims, params)
}
export function fetchApprovalExpenseClaims(params = {}) { export function fetchApprovalExpenseClaims(params = {}) {
return apiRequest(`/reimbursements/claims/approvals${buildListQuery(params)}`) return apiRequest(`/reimbursements/claims/approvals${buildListQuery(params)}`)
} }
export function fetchAllApprovalExpenseClaims(params = {}) {
return fetchAllExpenseClaimPages(fetchApprovalExpenseClaims, params)
}
export function fetchArchivedExpenseClaims(params = {}) { export function fetchArchivedExpenseClaims(params = {}) {
return apiRequest(`/reimbursements/claims/archives${buildListQuery(params)}`) return apiRequest(`/reimbursements/claims/archives${buildListQuery(params)}`)
} }
export function fetchAllArchivedExpenseClaims(params = {}) {
return fetchAllExpenseClaimPages(fetchArchivedExpenseClaims, params)
}
export function fetchExpenseClaimDetail(claimId) { export function fetchExpenseClaimDetail(claimId) {
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}`) return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}`)
} }

View File

@@ -1,156 +1,156 @@
export const DEFAULT_APP_VIEW_ORDER = [ export const DEFAULT_APP_VIEW_ORDER = [
'workbench', 'workbench',
'documents', 'documents',
'receiptFolder', 'receiptFolder',
'budget', 'budget',
'audit', 'audit',
'overview', 'overview',
'policies', 'policies',
'digitalEmployees', 'digitalEmployees',
'employees', 'employees',
'settings' 'settings'
] ]
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'documents', 'receiptFolder', 'policies']) const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'documents', 'receiptFolder', 'policies'])
const VIEW_ROLE_RULES = { const VIEW_ROLE_RULES = {
overview: ['finance', 'executive'], overview: ['finance', 'executive'],
budget: ['budget_monitor', 'executive'], budget: ['budget_monitor', 'executive'],
audit: ['finance'], audit: ['finance'],
digitalEmployees: ['finance'], digitalEmployees: ['finance'],
employees: ['manager'], employees: ['manager'],
settings: ['manager'] settings: ['manager']
} }
const CLAIM_MANAGER_ROLE_CODES = new Set(['executive']) const CLAIM_MANAGER_ROLE_CODES = new Set(['executive'])
const CLAIM_RETURN_ROLE_CODES = new Set(['finance', 'executive', 'manager', 'approver', 'budget_monitor']) const CLAIM_RETURN_ROLE_CODES = new Set(['finance', 'executive', 'manager', 'approver', 'budget_monitor'])
const CLAIM_LEADER_APPROVAL_ROLE_CODES = new Set(['manager', 'approver']) const CLAIM_LEADER_APPROVAL_ROLE_CODES = new Set(['manager', 'approver'])
const CLAIM_BUDGET_APPROVAL_GRADE = 'P8' const CLAIM_BUDGET_APPROVAL_GRADE = 'P8'
function normalizedRoleCodes(user) { function normalizedRoleCodes(user) {
if (!user) { if (!user) {
return [] return []
} }
return Array.isArray(user.roleCodes) return Array.isArray(user.roleCodes)
? user.roleCodes ? user.roleCodes
.map((item) => normalizeRoleCode(item)) .map((item) => normalizeRoleCode(item))
.filter(Boolean) .filter(Boolean)
: [] : []
} }
function normalizeRoleCode(value) { function normalizeRoleCode(value) {
const roleCode = String(value || '').trim().toLowerCase() const roleCode = String(value || '').trim().toLowerCase()
return roleCode === 'auditor' ? 'budget_monitor' : roleCode return roleCode === 'auditor' ? 'budget_monitor' : roleCode
} }
function normalizeComparableText(value) { function normalizeComparableText(value) {
return String(value || '').trim() return String(value || '').trim()
} }
function collectIdentityNames(...values) { function collectIdentityNames(...values) {
return values return values
.map((value) => normalizeComparableText(value)) .map((value) => normalizeComparableText(value))
.filter(Boolean) .filter(Boolean)
} }
function identityIntersects(leftValues, rightValues) { function identityIntersects(leftValues, rightValues) {
const rightSet = new Set(rightValues) const rightSet = new Set(rightValues)
return leftValues.some((item) => rightSet.has(item)) return leftValues.some((item) => rightSet.has(item))
} }
function normalizedGrade(user) { function normalizedGrade(user) {
return String(user?.grade || user?.employeeGrade || '').trim().toUpperCase() return String(user?.grade || user?.employeeGrade || '').trim().toUpperCase()
} }
function departmentIntersects(request, user) { function departmentIntersects(request, user) {
const requestDepartments = collectIdentityNames( const requestDepartments = collectIdentityNames(
request?.dept, request?.dept,
request?.departmentName, request?.departmentName,
request?.department_name request?.department_name
) )
const currentDepartments = collectIdentityNames( const currentDepartments = collectIdentityNames(
user?.department, user?.department,
user?.departmentName, user?.departmentName,
user?.department_name user?.department_name
) )
return requestDepartments.length > 0 && identityIntersects(requestDepartments, currentDepartments) return requestDepartments.length > 0 && identityIntersects(requestDepartments, currentDepartments)
} }
function hasPlatformAdminIdentity(user) { function hasPlatformAdminIdentity(user) {
if (!user) { if (!user) {
return false return false
} }
const username = String(user.username || user.account || '').trim().toLowerCase() const username = String(user.username || user.account || '').trim().toLowerCase()
const role = String(user.role || '').trim().toLowerCase() const role = String(user.role || '').trim().toLowerCase()
const roleCodes = normalizedRoleCodes(user) const roleCodes = normalizedRoleCodes(user)
return ( return (
Boolean(user.isAdmin) Boolean(user.isAdmin)
|| username === 'admin' || username === 'admin'
|| role === 'admin' || role === 'admin'
|| role === '管理员' || role === '管理员'
|| role === '系统管理员' || role === '系统管理员'
|| roleCodes.includes('admin') || roleCodes.includes('admin')
) )
} }
export function isManagerUser(user) { export function isManagerUser(user) {
return hasPlatformAdminIdentity(user) || normalizedRoleCodes(user).includes('manager') return hasPlatformAdminIdentity(user) || normalizedRoleCodes(user).includes('manager')
} }
export function isPlatformAdminUser(user) { export function isPlatformAdminUser(user) {
return hasPlatformAdminIdentity(user) return hasPlatformAdminIdentity(user)
} }
export function isFinanceUser(user) { export function isFinanceUser(user) {
return normalizedRoleCodes(user).includes('finance') return normalizedRoleCodes(user).includes('finance')
} }
export function isExecutiveUser(user) { export function isExecutiveUser(user) {
return normalizedRoleCodes(user).includes('executive') return normalizedRoleCodes(user).includes('executive')
} }
export function isBudgetMonitorUser(user) { export function isBudgetMonitorUser(user) {
return normalizedRoleCodes(user).includes('budget_monitor') return normalizedRoleCodes(user).includes('budget_monitor')
} }
export function canEditBudgetCenter(user) { export function canEditBudgetCenter(user) {
return isPlatformAdminUser(user) || isExecutiveUser(user) return isPlatformAdminUser(user) || isExecutiveUser(user)
} }
export function canSwitchBudgetDepartments(user) { export function canSwitchBudgetDepartments(user) {
return isPlatformAdminUser(user) || isExecutiveUser(user) return isPlatformAdminUser(user) || isExecutiveUser(user)
} }
export function canManageExpenseClaims(user) { export function canManageExpenseClaims(user) {
if (isPlatformAdminUser(user)) { if (isPlatformAdminUser(user)) {
return true return true
} }
return normalizedRoleCodes(user).some((roleCode) => CLAIM_MANAGER_ROLE_CODES.has(roleCode)) return normalizedRoleCodes(user).some((roleCode) => CLAIM_MANAGER_ROLE_CODES.has(roleCode))
} }
export function canDeleteArchivedExpenseClaims(user) { export function canDeleteArchivedExpenseClaims(user) {
return isPlatformAdminUser(user) return isPlatformAdminUser(user)
} }
export function canReturnExpenseClaims(user) { export function canReturnExpenseClaims(user) {
if (isPlatformAdminUser(user)) { if (isPlatformAdminUser(user)) {
return true return true
} }
return normalizedRoleCodes(user).some((roleCode) => CLAIM_RETURN_ROLE_CODES.has(roleCode)) return normalizedRoleCodes(user).some((roleCode) => CLAIM_RETURN_ROLE_CODES.has(roleCode))
} }
export function canApproveLeaderExpenseClaims(user) { export function canApproveLeaderExpenseClaims(user) {
if (isPlatformAdminUser(user)) { if (isPlatformAdminUser(user)) {
return true return true
} }
return normalizedRoleCodes(user).some((roleCode) => CLAIM_LEADER_APPROVAL_ROLE_CODES.has(roleCode)) return normalizedRoleCodes(user).some((roleCode) => CLAIM_LEADER_APPROVAL_ROLE_CODES.has(roleCode))
} }
export function canApproveBudgetExpenseApplications(user, request = null) { export function canApproveBudgetExpenseApplications(user, request = null) {
if (isPlatformAdminUser(user)) { if (isPlatformAdminUser(user)) {
return true return true
@@ -163,10 +163,10 @@ export function canApproveBudgetExpenseApplications(user, request = null) {
if (normalizedGrade(user) !== CLAIM_BUDGET_APPROVAL_GRADE) { if (normalizedGrade(user) !== CLAIM_BUDGET_APPROVAL_GRADE) {
return false return false
} }
return request ? departmentIntersects(request, user) : true return request ? departmentIntersects(request, user) : true
} }
export function isCurrentRequestApplicant(request, user) { export function isCurrentRequestApplicant(request, user) {
const applicantIds = collectIdentityNames( const applicantIds = collectIdentityNames(
request?.employeeId, request?.employeeId,
@@ -191,45 +191,45 @@ export function isCurrentRequestApplicant(request, user) {
const applicantNames = collectIdentityNames( const applicantNames = collectIdentityNames(
request?.person, request?.person,
request?.employeeName, request?.employeeName,
request?.employee_name, request?.employee_name,
request?.profileName, request?.profileName,
request?.applicant request?.applicant
) )
const currentNames = collectIdentityNames( const currentNames = collectIdentityNames(
user?.name, user?.name,
user?.username, user?.username,
user?.email, user?.email,
user?.employeeNo, user?.employeeNo,
user?.employee_no user?.employee_no
) )
return applicantNames.length > 0 && identityIntersects(applicantNames, currentNames) return applicantNames.length > 0 && identityIntersects(applicantNames, currentNames)
} }
export function isCurrentDirectManagerForRequest(request, user) { export function isCurrentDirectManagerForRequest(request, user) {
if (isCurrentRequestApplicant(request, user)) { if (isCurrentRequestApplicant(request, user)) {
return false return false
} }
const managerNames = collectIdentityNames( const managerNames = collectIdentityNames(
request?.profileManager, request?.profileManager,
request?.managerName, request?.managerName,
request?.manager_name, request?.manager_name,
request?.directManagerName, request?.directManagerName,
request?.direct_manager_name, request?.direct_manager_name,
request?.manager request?.manager
) )
const currentNames = collectIdentityNames( const currentNames = collectIdentityNames(
user?.name, user?.name,
user?.username, user?.username,
user?.email, user?.email,
user?.employeeNo, user?.employeeNo,
user?.employee_no user?.employee_no
) )
return managerNames.length > 0 && identityIntersects(managerNames, currentNames) return managerNames.length > 0 && identityIntersects(managerNames, currentNames)
} }
export function canAccessAppView(user, viewId) { export function canAccessAppView(user, viewId) {
if (!viewId || !user) { if (!viewId || !user) {
return false return false
@@ -246,35 +246,35 @@ export function canAccessAppView(user, viewId) {
if (viewId === 'budget') { if (viewId === 'budget') {
if (isPlatformAdminUser(user)) { if (isPlatformAdminUser(user)) {
return true return true
} }
const roleCodes = normalizedRoleCodes(user) const roleCodes = normalizedRoleCodes(user)
return VIEW_ROLE_RULES.budget.some((roleCode) => roleCodes.includes(roleCode)) return VIEW_ROLE_RULES.budget.some((roleCode) => roleCodes.includes(roleCode))
} }
if (isManagerUser(user)) { if (isManagerUser(user)) {
return true return true
} }
if (ALWAYS_VISIBLE_VIEWS.has(viewId)) { if (ALWAYS_VISIBLE_VIEWS.has(viewId)) {
return true return true
} }
const requiredRoles = VIEW_ROLE_RULES[viewId] || [] const requiredRoles = VIEW_ROLE_RULES[viewId] || []
const roleCodes = normalizedRoleCodes(user) const roleCodes = normalizedRoleCodes(user)
return requiredRoles.some((roleCode) => roleCodes.includes(roleCode)) return requiredRoles.some((roleCode) => roleCodes.includes(roleCode))
} }
export function getAccessibleViewIds(user) { export function getAccessibleViewIds(user) {
return DEFAULT_APP_VIEW_ORDER.filter((viewId) => canAccessAppView(user, viewId)) return DEFAULT_APP_VIEW_ORDER.filter((viewId) => canAccessAppView(user, viewId))
} }
export function filterNavItemsByAccess(navItems, user) { export function filterNavItemsByAccess(navItems, user) {
return navItems.filter((item) => canAccessAppView(user, item.id)) return navItems.filter((item) => canAccessAppView(user, item.id))
} }
export function resolveDefaultAuthorizedRoute(user) { export function resolveDefaultAuthorizedRoute(user) {
if (isPlatformAdminUser(user) && canAccessAppView(user, 'overview')) { if (isPlatformAdminUser(user) && canAccessAppView(user, 'documents')) {
return { name: 'app-overview' } return { name: 'app-documents' }
} }
const firstVisibleView = getAccessibleViewIds(user)[0] const firstVisibleView = getAccessibleViewIds(user)[0]

View File

@@ -1,4 +1,5 @@
import { isApplicationRequestLike } from './documentClassification.js' import { isApplicationRequestLike } from './documentClassification.js'
import { canProcessApprovalRequest } from './approvalInbox.js'
function parseNumber(value) { function parseNumber(value) {
const nextValue = Number(value) const nextValue = Number(value)
@@ -46,6 +47,15 @@ export function belongsToCurrentUser(request, currentUser) {
return names.some((name) => name === person) return names.some((name) => name === person)
} }
function belongsToWorkbenchProgressScope(request, currentUser) {
if (belongsToCurrentUser(request, currentUser)) {
return true
}
return normalizeText(request?.approvalKey) === 'in_progress'
&& canProcessApprovalRequest(request, currentUser)
}
export function hasHighRiskFlag(request) { export function hasHighRiskFlag(request) {
const riskFlags = Array.isArray(request?.riskFlags) ? request.riskFlags : [] const riskFlags = Array.isArray(request?.riskFlags) ? request.riskFlags : []
@@ -133,6 +143,16 @@ function resolveRequestTarget(request) {
} }
} }
function resolveApplicantLabel(request) {
return normalizeText(
request?.person
|| request?.employeeName
|| request?.employee_name
|| request?.applicant
|| request?.profileName
) || '待补充'
}
function resolveStatusTone(approvalKey) { function resolveStatusTone(approvalKey) {
if (approvalKey === 'supplement' || approvalKey === 'rejected') return 'danger' if (approvalKey === 'supplement' || approvalKey === 'rejected') return 'danger'
if (approvalKey === 'draft') return 'success' if (approvalKey === 'draft') return 'success'
@@ -254,31 +274,84 @@ export function buildAdjacentProgressSteps(steps = [], windowSize = 4) {
})) }))
} }
const FALLBACK_APPLICATION_PROGRESS_LABELS = Object.freeze([
'创建申请',
'直属领导审批',
'关联单据状态',
'已归档'
])
const FALLBACK_REIMBURSEMENT_PROGRESS_LABELS = Object.freeze([
'关联单据',
'待提交',
'直属领导审批',
'财务审批',
'待付款',
'已付款',
'已归档'
])
function resolveFallbackProgressCurrentIndex(labels, request, documentTypeLabel) {
const approvalKey = normalizeText(request?.approvalKey)
const status = normalizeText(request?.approvalStatus || request?.status || request?.workflowNode)
if (documentTypeLabel === '申请单') {
if (/归档/.test(status)) return labels.indexOf('已归档')
if (approvalKey === 'completed' || /完成|通过/.test(status)) return labels.indexOf('关联单据状态')
if (/直属领导|领导审批|负责人/.test(status)) return labels.indexOf('直属领导审批')
return 0
}
if (approvalKey === 'completed' || /归档|已付款|完成/.test(status)) return labels.indexOf('已归档')
if (approvalKey === 'pending_payment' || /待付款|待支付/.test(status)) return labels.indexOf('待付款')
if (/财务/.test(status)) return labels.indexOf('财务审批')
if (/直属领导|领导审批|负责人/.test(status)) return labels.indexOf('直属领导审批')
return labels.indexOf('待提交')
}
function buildFallbackProgressSteps(request, documentTypeLabel) {
const labels = documentTypeLabel === '申请单'
? FALLBACK_APPLICATION_PROGRESS_LABELS
: FALLBACK_REIMBURSEMENT_PROGRESS_LABELS
const currentIndex = Math.max(0, resolveFallbackProgressCurrentIndex(labels, request, documentTypeLabel))
return labels.map((label, index) => ({
label,
rawLabel: label,
done: index < currentIndex,
active: index <= currentIndex,
current: index === currentIndex,
title: index === currentIndex ? '进行中' : index < currentIndex ? '已完成' : '待处理'
}))
}
function buildProgressItems(ownedRequests) { function buildProgressItems(ownedRequests) {
return ownedRequests return ownedRequests
.filter((request) => Array.isArray(request?.progressSteps) && request.progressSteps.length)
.map((request) => { .map((request) => {
const requestId = resolveRequestIdentity(request) const requestId = resolveRequestIdentity(request)
const steps = buildAdjacentProgressSteps(request.progressSteps, 4)
const currentStep = steps.find((step) => step.current)
const title = normalizeText(request?.title || request?.note || request?.sceneLabel) || '费用单据' const title = normalizeText(request?.title || request?.note || request?.sceneLabel) || '费用单据'
const documentTypeLabel = resolveDocumentTypeLabel(request, requestId, title)
const sourceSteps = Array.isArray(request?.progressSteps) && request.progressSteps.length
? request.progressSteps
: buildFallbackProgressSteps(request, documentTypeLabel)
const steps = buildAdjacentProgressSteps(sourceSteps, 4)
const currentStep = steps.find((step) => step.current)
const status = normalizeText(request?.approvalStatus || currentStep?.label) || '处理中' const status = normalizeText(request?.approvalStatus || currentStep?.label) || '处理中'
const documentTypeLabel = resolveDocumentTypeLabel(request, requestId, title)
return { return {
id: requestId, id: requestId,
requestId, requestId,
title, title,
documentTypeLabel, documentTypeLabel,
applicantLabel: resolveApplicantLabel(request),
expenseTypeLabel: resolveExpenseCategory(request), expenseTypeLabel: resolveExpenseCategory(request),
amount: formatCurrency(request?.amount), amount: formatCurrency(request?.amount),
status, status,
statusTone: resolveProgressStatusTone(normalizeText(request?.approvalKey), status), statusTone: resolveProgressStatusTone(normalizeText(request?.approvalKey), status),
updatedAt: normalizeText(request?.updatedAt || request?.submittedAt || request?.createdAt), updatedAt: normalizeText(request?.updatedAt || request?.submittedAt || request?.createdAt || request?.applyTime),
steps, steps,
target: resolveRequestTarget(request), target: resolveRequestTarget(request),
prompt: `查询 ${requestId || title}费用进度` prompt: `查询 ${requestId || title}单据进度`
} }
}) })
.sort((left, right) => normalizeText(right.updatedAt).localeCompare(normalizeText(left.updatedAt))) .sort((left, right) => normalizeText(right.updatedAt).localeCompare(normalizeText(left.updatedAt)))
@@ -486,9 +559,11 @@ function buildExpenseOperationRows(todoItems, notifications, progressItems) {
} }
export function buildWorkbenchSummary(requests, currentUser) { export function buildWorkbenchSummary(requests, currentUser) {
const ownedRequests = Array.isArray(requests) const allRequests = Array.isArray(requests)
? requests.filter((item) => belongsToCurrentUser(item, currentUser)) ? requests
: [] : []
const ownedRequests = allRequests.filter((item) => belongsToCurrentUser(item, currentUser))
const progressRequests = allRequests.filter((item) => belongsToWorkbenchProgressScope(item, currentUser))
const monthlyClaims = ownedRequests.filter((item) => isCurrentMonth(resolveClaimDate(item))) const monthlyClaims = ownedRequests.filter((item) => isCurrentMonth(resolveClaimDate(item)))
@@ -505,7 +580,7 @@ export function buildWorkbenchSummary(requests, currentUser) {
const returnCount = ownedRequests.filter((item) => item.approvalKey === 'rejected').length const returnCount = ownedRequests.filter((item) => item.approvalKey === 'rejected').length
const highRiskCount = monthlyClaims.filter((item) => hasHighRiskFlag(item)).length const highRiskCount = monthlyClaims.filter((item) => hasHighRiskFlag(item)).length
const todoItems = buildTodoItems(ownedRequests) const todoItems = buildTodoItems(ownedRequests)
const progressItems = buildProgressItems(ownedRequests) const progressItems = buildProgressItems(progressRequests)
const notifications = buildNotifications(todoItems, progressItems) const notifications = buildNotifications(todoItems, progressItems)
const expenseStatsDetail = { const expenseStatsDetail = {
distributionRows: buildExpenseDistributionRows(ownedRequests), distributionRows: buildExpenseDistributionRows(ownedRequests),

View File

@@ -142,7 +142,7 @@
<DocumentsCenterView <DocumentsCenterView
v-else-if="activeView === 'documents'" v-else-if="activeView === 'documents'"
:filtered-requests="filteredRequests" :filtered-requests="requests"
:has-data="requests.length > 0" :has-data="requests.length > 0"
:loading="requestsLoading" :loading="requestsLoading"
:error="requestsError" :error="requestsError"

View File

@@ -249,10 +249,9 @@ import TableLoadingState from '../components/shared/TableLoadingState.vue'
import { useMinimumVisibleState } from '../composables/useMinimumVisibleState.js' import { useMinimumVisibleState } from '../composables/useMinimumVisibleState.js'
import { mapExpenseClaimToRequest } from '../composables/useRequests.js' import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
import { import {
REIMBURSEMENT_LIST_PREVIEW_PARAMS,
extractExpenseClaimItems, extractExpenseClaimItems,
fetchApprovalExpenseClaims, fetchAllApprovalExpenseClaims,
fetchArchivedExpenseClaims fetchAllArchivedExpenseClaims
} from '../services/reimbursements.js' } from '../services/reimbursements.js'
import { fetchNotificationStates, patchNotificationStates } from '../services/notificationStates.js' import { fetchNotificationStates, patchNotificationStates } from '../services/notificationStates.js'
import { import {
@@ -925,8 +924,8 @@ async function loadSupportingRows() {
supportingError.value = '' supportingError.value = ''
const [approvalResult, archiveResult] = await Promise.allSettled([ const [approvalResult, archiveResult] = await Promise.allSettled([
fetchApprovalExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS), fetchAllApprovalExpenseClaims(),
fetchArchivedExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS) fetchAllArchivedExpenseClaims()
]) ])
if (approvalResult.status === 'fulfilled') { if (approvalResult.status === 'fulfilled') {

View File

@@ -257,7 +257,7 @@
<div class="card-head"> <div class="card-head">
<h3>用户在线波动 <i class="mdi mdi-information-outline"></i></h3> <h3>用户在线波动 <i class="mdi mdi-information-outline"></i></h3>
</div> </div>
<p class="card-subtitle">登录人数与互动次数的时段波动</p> <p class="card-subtitle">在线人数与互动次数的时段波动</p>
<SystemLoginWaveChart <SystemLoginWaveChart
compact compact

View File

@@ -755,10 +755,7 @@ export default {
&& canApproveBudgetExpenseApplications(currentUser.value, request.value) && canApproveBudgetExpenseApplications(currentUser.value, request.value)
&& !isCurrentApplicant.value && !isCurrentApplicant.value
)) ))
const canReturnRequest = computed(() => { const canProcessCurrentApprovalStage = computed(() => {
if (request.value.approvalKey !== 'in_progress' || !request.value.claimId || !canReturnExpenseClaims(currentUser.value)) {
return false
}
if (isDirectManagerApprovalStage.value) { if (isDirectManagerApprovalStage.value) {
return isCurrentDirectManagerApprover.value return isCurrentDirectManagerApprover.value
} }
@@ -767,18 +764,16 @@ export default {
} }
return canProcessFinanceApprovalStage.value return canProcessFinanceApprovalStage.value
}) })
const canReturnRequest = computed(() => {
if (request.value.approvalKey !== 'in_progress' || !request.value.claimId || !canReturnExpenseClaims(currentUser.value)) {
return false
}
return canProcessCurrentApprovalStage.value
})
const canApproveRequest = computed(() => const canApproveRequest = computed(() =>
(Boolean(props.approvalMode) || isApplicationDocument.value) request.value.approvalKey === 'in_progress'
&& request.value.approvalKey === 'in_progress'
&& Boolean(request.value.claimId) && Boolean(request.value.claimId)
&& ( && canProcessCurrentApprovalStage.value
(
isDirectManagerApprovalStage.value
&& isCurrentDirectManagerApprover.value
)
|| canProcessFinanceApprovalStage.value
|| canProcessBudgetApprovalStage.value
)
) )
const canViewApprovalRiskAdvice = computed(() => ( const canViewApprovalRiskAdvice = computed(() => (
Boolean(request.value.claimId) Boolean(request.value.claimId)

View File

@@ -183,6 +183,7 @@ export function buildFallbackProgressSteps(requestModel = {}) {
const pendingPayment = approvalKey === 'pending_payment' || /待付款/.test(node) const pendingPayment = approvalKey === 'pending_payment' || /待付款/.test(node)
const paid = /已付款/.test(node) const paid = /已付款/.test(node)
const completed = approvalKey === 'completed' || paid || /审批完成|申请完成|已完成/.test(node) const completed = approvalKey === 'completed' || paid || /审批完成|申请完成|已完成/.test(node)
const archived = /申请归档|已归档/.test(node)
const hasRelatedApplication = Boolean(requestModel?.relatedApplication?.claimNo) const hasRelatedApplication = Boolean(requestModel?.relatedApplication?.claimNo)
if (isApplicationDocumentRequest(requestModel)) { if (isApplicationDocumentRequest(requestModel)) {
@@ -207,11 +208,19 @@ export function buildFallbackProgressSteps(requestModel = {}) {
}, },
{ {
index: 3, index: 3,
label: '审批完成', label: '关联单据状态',
time: completed ? '已完成' : '待处理', time: hasRelatedApplication ? '已关联' : completed ? '未关联' : '待处理',
done: completed, done: archived,
active: completed, active: completed || archived,
current: false current: completed && !archived
},
{
index: 4,
label: '已归档',
time: archived ? '已完成' : '待处理',
done: archived,
active: archived,
current: archived
} }
] ]
} }
@@ -597,6 +606,7 @@ export function buildExpenseDraftIssues(item) {
export function buildDraftBlockingIssues(request, expenseItems) { export function buildDraftBlockingIssues(request, expenseItems) {
const issues = [] const issues = []
const isApplication = isApplicationDocumentRequest(request)
const locationRequired = isLocationRequiredExpenseType(request.typeCode) const locationRequired = isLocationRequiredExpenseType(request.typeCode)
const normalizedItems = Array.isArray(expenseItems) ? expenseItems : [] const normalizedItems = Array.isArray(expenseItems) ? expenseItems : []
const itemAmountTotal = normalizedItems.reduce((sum, item) => { const itemAmountTotal = normalizedItems.reduce((sum, item) => {
@@ -611,6 +621,25 @@ export function buildDraftBlockingIssues(request, expenseItems) {
if (isPlaceholderValue(request.profileName)) { if (isPlaceholderValue(request.profileName)) {
issues.push('申请人未完善') issues.push('申请人未完善')
} }
if (isApplication) {
if (isPlaceholderValue(request.typeLabel)) {
issues.push('申请类型未完善')
}
if (isPlaceholderValue(request.reason)) {
issues.push('申请事由未完善')
}
if (isPlaceholderValue(request.location)) {
issues.push('业务地点未完善')
}
if (isPlaceholderValue(request.occurredDisplay)) {
issues.push('申请时间未完善')
}
if (!Number.isFinite(Number(request.amountValue)) || Number(request.amountValue) <= 0) {
issues.push('预计总费用未完善')
}
return [...new Set(issues)]
}
if (isPlaceholderValue(request.typeLabel) && !hasValidItemType) { if (isPlaceholderValue(request.typeLabel) && !hasValidItemType) {
issues.push('报销类型未完善') issues.push('报销类型未完善')
} }
@@ -657,18 +686,30 @@ export function mapIssueToAdvice(issue) {
if (text === '报销类型未完善') { if (text === '报销类型未完善') {
return '选择报销类型,明确本次费用归类。' return '选择报销类型,明确本次费用归类。'
} }
if (text === '申请类型未完善') {
return '补充申请类型,明确本次申请的费用或业务场景。'
}
if (text === '报销事由未完善') { if (text === '报销事由未完善') {
return '补充报销事由,说明本次费用用途。' return '补充报销事由,说明本次费用用途。'
} }
if (text === '申请事由未完善') {
return '补充申请事由,说明本次申请的业务背景。'
}
if (text === '业务地点未完善') { if (text === '业务地点未完善') {
return '补充业务地点,方便审核业务发生场景。' return '补充业务地点,方便审核业务发生场景。'
} }
if (text === '发生时间未完善') { if (text === '发生时间未完善') {
return '补充费用发生时间,确保单据时间完整。' return '补充费用发生时间,确保单据时间完整。'
} }
if (text === '申请时间未完善') {
return '补充申请时间或行程时间,确保申请周期完整。'
}
if (text === '报销金额未完善') { if (text === '报销金额未完善') {
return '补充报销金额,并与费用明细金额保持一致。' return '补充报销金额,并与费用明细金额保持一致。'
} }
if (text === '预计总费用未完善') {
return '补充预计总费用,供审批人判断预算占用和申请额度。'
}
const itemMatch = text.match(/^费用明细第\s*(\d+)\s*条(.+)$/) const itemMatch = text.match(/^费用明细第\s*(\d+)\s*条(.+)$/)
if (!itemMatch) { if (!itemMatch) {

View File

@@ -87,7 +87,7 @@ test('platform admin users do not enter the personal workbench', () => {
assert.equal(canAccessAppView(adminUser, 'workbench'), false) assert.equal(canAccessAppView(adminUser, 'workbench'), false)
assert.equal(canAccessAppView(employeeUser, 'workbench'), true) assert.equal(canAccessAppView(employeeUser, 'workbench'), true)
assert.equal(getAccessibleViewIds(adminUser).includes('workbench'), false) assert.equal(getAccessibleViewIds(adminUser).includes('workbench'), false)
assert.deepEqual(resolveDefaultAuthorizedRoute(adminUser), { name: 'app-overview' }) assert.deepEqual(resolveDefaultAuthorizedRoute(adminUser), { name: 'app-documents' })
assert.deepEqual( assert.deepEqual(
filterNavItemsByAccess(navItems, adminUser).map((item) => item.id), filterNavItemsByAccess(navItems, adminUser).map((item) => item.id),
['documents', 'overview', 'settings'] ['documents', 'overview', 'settings']
@@ -201,6 +201,30 @@ test('direct-manager approval helpers only match claims pushed to the current us
assert.equal(isCurrentDirectManagerForRequest({ person: '张三', managerName: '王总' }, managerUser), false) assert.equal(isCurrentDirectManagerForRequest({ person: '张三', managerName: '王总' }, managerUser), false)
}) })
test('approver executive users can process claims routed to their direct-manager identity', () => {
const leaderUser = {
roleCodes: ['approver', 'executive'],
name: 'Xiang Wanhong',
username: 'xiangwanhong@xf.com'
}
assert.equal(canApproveLeaderExpenseClaims(leaderUser), true)
assert.equal(
isCurrentDirectManagerForRequest(
{ person: 'Shen Zhiyuan', managerName: 'Xiang Wanhong' },
leaderUser
),
true
)
assert.equal(
isCurrentDirectManagerForRequest(
{ person: 'Xiang Wanhong', managerName: 'Li Wenjing' },
leaderUser
),
false
)
})
test('applicant helper matches generated draft owner by employee identifiers', () => { test('applicant helper matches generated draft owner by employee identifiers', () => {
const currentUser = { const currentUser = {
username: 'caoxiaozhu@xf.com', username: 'caoxiaozhu@xf.com',

View File

@@ -27,6 +27,10 @@ const appShellComposable = readFileSync(
fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)), fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)),
'utf8' 'utf8'
) )
const requestsComposable = readFileSync(
fileURLToPath(new URL('../src/composables/useRequests.js', import.meta.url)),
'utf8'
)
const assistantScript = readFileSync( const assistantScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)), fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
'utf8' 'utf8'
@@ -70,6 +74,47 @@ test('documents center reloads immediately when entered or clicked again', () =>
assert.match(appShellComposable, /reloadDocumentCenterRequests,/) assert.match(appShellComposable, /reloadDocumentCenterRequests,/)
}) })
test('documents center uses the full request list instead of the global date-filtered list', () => {
assert.match(
appShellRouteView,
/<DocumentsCenterView[\s\S]*:filtered-requests="requests"[\s\S]*:has-data="requests\.length > 0"/
)
assert.doesNotMatch(
appShellRouteView,
/<DocumentsCenterView[\s\S]*:filtered-requests="filteredRequests"/
)
})
test('workbench summary merges approval inbox requests without polluting document center rows', () => {
assert.match(appShellComposable, /import \{ fetchAllApprovalExpenseClaims, fetchExpenseClaimDetail \} from '\.\.\/services\/reimbursements\.js'/)
assert.match(appShellComposable, /const workbenchApprovalRequests = ref\(\[\]\)/)
assert.match(appShellComposable, /async function reloadWorkbenchApprovalRequests\(\)/)
assert.match(appShellComposable, /async function reloadWorkbenchRequests\(\)/)
assert.match(appShellComposable, /fetchAllApprovalExpenseClaims\(\)/)
assert.match(appShellComposable, /payload\.map\(\(item\) => mapExpenseClaimToRequest\(item\)\)/)
assert.match(appShellComposable, /Promise\.all\(\[[\s\S]*reloadRequests\(\{ silent: true \}\),[\s\S]*reloadWorkbenchApprovalRequests\(\)[\s\S]*\]\)/)
assert.match(appShellComposable, /if \(view === 'workbench'\) \{[\s\S]*void reloadWorkbenchRequests\(\)/)
assert.match(appShellComposable, /const workbenchRequests = computed\(\(\) =>[\s\S]*mergeWorkbenchRequests\(requests\.value, workbenchApprovalRequests\.value\)/)
assert.match(appShellComposable, /buildWorkbenchSummary\(workbenchRequests\.value, currentUser\.value\)/)
assert.match(appShellRouteView, /<DocumentsCenterView[\s\S]*:filtered-requests="requests"/)
assert.doesNotMatch(appShellRouteView, /<DocumentsCenterView[\s\S]*workbenchRequests/)
})
test('workbench progress refreshes after homepage create or detail updates', () => {
assert.match(appShellComposable, /async function handleDraftSaved\(payload = \{\}\) \{[\s\S]*await reloadWorkbenchRequests\(\)/)
assert.match(appShellComposable, /async function handleRequestUpdated\(\) \{[\s\S]*await reloadWorkbenchRequests\(\)[\s\S]*await refreshSelectedRequestDetail\(String\(route\.params\.requestId \|\| ''\)\)/)
assert.doesNotMatch(appShellComposable, /async function handleRequestUpdated\(\) \{[\s\S]*await reloadRequests\(\)[\s\S]*await refreshSelectedRequestDetail/)
})
test('workbench progress refresh is silent to avoid homepage flashing', () => {
assert.match(requestsComposable, /async function reload\(options = \{\}\) \{[\s\S]*const silent = Boolean\(options\?\.silent\)/)
assert.match(requestsComposable, /if \(!silent\) \{[\s\S]*loading\.value = true[\s\S]*error\.value = ''[\s\S]*\}/)
assert.match(requestsComposable, /catch \(nextError\) \{[\s\S]*if \(!silent\) \{[\s\S]*requests\.value = \[\][\s\S]*\}/)
assert.match(requestsComposable, /finally \{[\s\S]*if \(!silent\) \{[\s\S]*loading\.value = false[\s\S]*\}/)
assert.match(appShellComposable, /async function reloadWorkbenchRequests\(\) \{[\s\S]*reloadRequests\(\{ silent: true \}\)/)
assert.match(appShellComposable, /async function reloadDocumentCenterRequests\(\) \{[\s\S]*return reloadRequests\(\)/)
})
test('document detail navigation preserves document center list query', () => { test('document detail navigation preserves document center list query', () => {
assert.match( assert.match(
appShellComposable, appShellComposable,
@@ -86,12 +131,12 @@ test('document detail navigation preserves document center list query', () => {
}) })
test('document detail refreshes claim detail instead of relying on stale list cache', () => { test('document detail refreshes claim detail instead of relying on stale list cache', () => {
assert.match(appShellComposable, /import \{ fetchExpenseClaimDetail \} from '\.\.\/services\/reimbursements\.js'/) assert.match(appShellComposable, /import \{ fetchAllApprovalExpenseClaims, fetchExpenseClaimDetail \} from '\.\.\/services\/reimbursements\.js'/)
assert.match(appShellComposable, /import \{ mapExpenseClaimToRequest, useRequests \} from '\.\/useRequests\.js'/) assert.match(appShellComposable, /import \{ mapExpenseClaimToRequest, useRequests \} from '\.\/useRequests\.js'/)
assert.match(appShellComposable, /const snapshot = normalizeRequestForUi\(selectedRequestSnapshot\.value\)[\s\S]*if \(isSameRequestIdentity\(snapshot, requestId\)\) \{[\s\S]*return snapshot/) assert.match(appShellComposable, /const snapshot = normalizeRequestForUi\(selectedRequestSnapshot\.value\)[\s\S]*if \(isSameRequestIdentity\(snapshot, requestId\)\) \{[\s\S]*return snapshot/)
assert.match(appShellComposable, /async function refreshSelectedRequestDetail\(requestOrId = selectedRequestSnapshot\.value\) \{[\s\S]*fetchExpenseClaimDetail\(lookupId\)[\s\S]*mapExpenseClaimToRequest\(payload\)[\s\S]*upsertRequestSnapshot\(mappedRequest\)/) assert.match(appShellComposable, /async function refreshSelectedRequestDetail\(requestOrId = selectedRequestSnapshot\.value\) \{[\s\S]*fetchExpenseClaimDetail\(lookupId\)[\s\S]*mapExpenseClaimToRequest\(payload\)[\s\S]*upsertRequestSnapshot\(mappedRequest\)/)
assert.match(appShellComposable, /function openRequestDetail\(request, options = \{\}\) \{[\s\S]*void refreshSelectedRequestDetail\(request\)/) assert.match(appShellComposable, /function openRequestDetail\(request, options = \{\}\) \{[\s\S]*void refreshSelectedRequestDetail\(request\)/)
assert.match(appShellComposable, /async function handleRequestUpdated\(\) \{[\s\S]*await reloadRequests\(\)[\s\S]*await refreshSelectedRequestDetail\(String\(route\.params\.requestId \|\| ''\)\)/) assert.match(appShellComposable, /async function handleRequestUpdated\(\) \{[\s\S]*await reloadWorkbenchRequests\(\)[\s\S]*await refreshSelectedRequestDetail\(String\(route\.params\.requestId \|\| ''\)\)/)
assert.match(appShellComposable, /route\.name === 'app-document-detail'[\s\S]*void refreshSelectedRequestDetail\(String\(route\.params\.requestId \|\| ''\)\)/) assert.match(appShellComposable, /route\.name === 'app-document-detail'[\s\S]*void refreshSelectedRequestDetail\(String\(route\.params\.requestId \|\| ''\)\)/)
}) })

View File

@@ -16,6 +16,14 @@ const documentListSharedStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/components/document-list-shared.css', import.meta.url)), fileURLToPath(new URL('../src/assets/styles/components/document-list-shared.css', import.meta.url)),
'utf8' 'utf8'
) )
const reimbursementService = readFileSync(
fileURLToPath(new URL('../src/services/reimbursements.js', import.meta.url)),
'utf8'
)
const requestsComposable = readFileSync(
fileURLToPath(new URL('../src/composables/useRequests.js', import.meta.url)),
'utf8'
)
test('documents center keeps only the top scope tabs and renders status as a dropdown filter', () => { test('documents center keeps only the top scope tabs and renders status as a dropdown filter', () => {
assert.match(documentsCenterView, /<nav class="status-tabs document-scope-tabs"/) assert.match(documentsCenterView, /<nav class="status-tabs document-scope-tabs"/)
@@ -134,6 +142,17 @@ test('documents center refresh token reloads supporting approval and archive row
assert.match(documentsCenterView, /function reloadAll\(\) \{[\s\S]*emit\('reload'\)[\s\S]*void loadSupportingRows\(\)/) assert.match(documentsCenterView, /function reloadAll\(\) \{[\s\S]*emit\('reload'\)[\s\S]*void loadSupportingRows\(\)/)
}) })
test('documents center fetches every paginated claim page for admin-scale lists', () => {
assert.match(reimbursementService, /export function fetchAllExpenseClaims/)
assert.match(reimbursementService, /async function fetchAllExpenseClaimPages/)
assert.match(reimbursementService, /payload\.has_next/)
assert.match(requestsComposable, /import \{ fetchAllExpenseClaims \} from '\.\.\/services\/reimbursements\.js'/)
assert.match(requestsComposable, /const payload = await fetchAllExpenseClaims\(\)/)
assert.match(documentsCenterView, /fetchAllApprovalExpenseClaims/)
assert.match(documentsCenterView, /fetchAllArchivedExpenseClaims/)
assert.doesNotMatch(documentsCenterView, /REIMBURSEMENT_LIST_PREVIEW_PARAMS/)
})
test('documents center list shows created time and conditional stay time columns', () => { test('documents center list shows created time and conditional stay time columns', () => {
assert.match(documentsCenterView, /import \{[\s\S]*formatDocumentListTime[\s\S]*resolveDocumentStayTimeDisplay[\s\S]*\} from '..\/utils\/documentCenterTime\.js'/) assert.match(documentsCenterView, /import \{[\s\S]*formatDocumentListTime[\s\S]*resolveDocumentStayTimeDisplay[\s\S]*\} from '..\/utils\/documentCenterTime\.js'/)
assert.match(documentsCenterView, /<col class="col-created">/) assert.match(documentsCenterView, /<col class="col-created">/)
@@ -326,10 +345,9 @@ test('documents center status dropdown uses compact filter styling', () => {
assert.match(documentsCenterStyles, /\.col-created\s*\{\s*width:\s*10%;\s*\}/) assert.match(documentsCenterStyles, /\.col-created\s*\{\s*width:\s*10%;\s*\}/)
assert.match(documentsCenterStyles, /\.col-stay\s*\{\s*width:\s*9%;\s*\}/) assert.match(documentsCenterStyles, /\.col-stay\s*\{\s*width:\s*9%;\s*\}/)
assert.match(documentsCenterStyles, /\.col-initiator\s*\{\s*width:\s*8%;\s*\}/) assert.match(documentsCenterStyles, /\.col-initiator\s*\{\s*width:\s*8%;\s*\}/)
assert.match(documentsCenterStyles, /\.document-status-filter\s*\{[\s\S]*display:\s*inline-flex;/) assert.match(documentListSharedStyles, /\.document-status-filter\s*\{[\s\S]*display:\s*inline-flex;/)
assert.match(documentsCenterStyles, /\.document-status-filter\s*\{[\s\S]*min-height:\s*38px;/) assert.match(documentListSharedStyles, /\.document-status-filter\s*\{[\s\S]*min-height:\s*38px;/)
assert.match(documentsCenterStyles, /\.status-filter-trigger\s*\{[\s\S]*min-width:\s*154px;/) assert.match(documentListSharedStyles, /\.status-dropdown-filter,\s*\.status-filter-trigger,\s*\.status-filter-menu\s*\{[\s\S]*min-width:\s*154px;/)
assert.match(documentsCenterStyles, /\.status-filter-menu\s*\{[\s\S]*min-width:\s*154px;/)
assert.doesNotMatch(documentsCenterStyles, /\.document-state-tabs\s*\{/) assert.doesNotMatch(documentsCenterStyles, /\.document-state-tabs\s*\{/)
assert.doesNotMatch(documentsCenterStyles, /\.document-status-filter\s*\{[^}]*margin-top:/) assert.doesNotMatch(documentsCenterStyles, /\.document-status-filter\s*\{[^}]*margin-top:/)
}) })

View File

@@ -22,6 +22,18 @@ test('expense profile modal remounts the behavior radar when opened', () => {
assert.match(modal, /scheduleRadarFrame/) assert.match(modal, /scheduleRadarFrame/)
}) })
test('expense profile modal uses compact laptop dialog sizing', () => {
assert.match(modal, /width="min\(960px, calc\(100vw - 64px\)\)"/)
assert.match(modal, /max-height:\s*min\(580px, calc\(100dvh - 176px\)\)/)
assert.match(
modal,
/@media \(min-width: 861px\) and \(max-width: 1440px\),\s*\n\s*\(min-width: 861px\) and \(max-height: 820px\)/
)
assert.match(modal, /width:\s*min\(900px, calc\(100vw - 96px\)\) !important;/)
assert.match(modal, /max-height:\s*min\(520px, calc\(100dvh - 152px\)\)/)
assert.match(modal, /\.profile-radar-chart \{[\s\S]*height:\s*248px;/)
})
test('radar chart uses the shared echarts lifecycle and enables entrance animation', () => { test('radar chart uses the shared echarts lifecycle and enables entrance animation', () => {
assert.match(radarChart, /import \{ useEcharts \} from '\.\.\/\.\.\/composables\/useEcharts\.js'/) assert.match(radarChart, /import \{ useEcharts \} from '\.\.\/\.\.\/composables\/useEcharts\.js'/)
assert.match(radarChart, /useEcharts\(chartElement, chartOptions\)/) assert.match(radarChart, /useEcharts\(chartElement, chartOptions\)/)

View File

@@ -30,6 +30,15 @@ test('expense stats detail modal exposes distribution, processing time and opera
assert.match(modal, /resolveDistributionColor/) assert.match(modal, /resolveDistributionColor/)
assert.match(modal, /processingRows/) assert.match(modal, /processingRows/)
assert.match(modal, /operationRows/) assert.match(modal, /operationRows/)
assert.match(modal, /width="min\(900px, calc\(100vw - 64px\)\)"/)
assert.match(modal, /max-height:\s*min\(580px, calc\(100dvh - 176px\)\)/)
assert.match(
modal,
/@media \(min-width: 861px\) and \(max-width: 1440px\),\s*\n\s*\(min-width: 861px\) and \(max-height: 820px\)/
)
assert.match(modal, /width:\s*min\(860px, calc\(100vw - 96px\)\) !important;/)
assert.match(modal, /max-height:\s*min\(520px, calc\(100dvh - 152px\)\)/)
assert.match(modal, /\.expense-distribution-donut :deep\(\.donut-body\) \{[\s\S]*height:\s*170px;/)
assert.doesNotMatch(modal, /--expense-detail-percent/) assert.doesNotMatch(modal, /--expense-detail-percent/)
assert.doesNotMatch(modal, /expense-distribution-track/) assert.doesNotMatch(modal, /expense-distribution-track/)
assert.match(modal, /统计口径来自当前工作台已加载的个人单据/) assert.match(modal, /统计口径来自当前工作台已加载的个人单据/)

View File

@@ -13,6 +13,14 @@ const workbench = readFileSync(
fileURLToPath(new URL('../src/components/business/PersonalWorkbench.vue', import.meta.url)), fileURLToPath(new URL('../src/components/business/PersonalWorkbench.vue', import.meta.url)),
'utf8' 'utf8'
) )
const workbenchProgressPanel = readFileSync(
fileURLToPath(new URL('../src/components/business/PersonalWorkbenchProgressPanel.vue', import.meta.url)),
'utf8'
)
const workbenchProgressStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/components/personal-workbench-progress.css', import.meta.url)),
'utf8'
)
const workbenchStyles = readFileSync( const workbenchStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/components/personal-workbench.css', import.meta.url)), fileURLToPath(new URL('../src/assets/styles/components/personal-workbench.css', import.meta.url)),
'utf8' 'utf8'
@@ -31,7 +39,7 @@ const workbenchInsightStyles = readFileSync(
'utf8' 'utf8'
) )
const heroBackgroundAsset = fileURLToPath( const heroBackgroundAsset = fileURLToPath(
new URL('../src/assets/personal-workbench-hero-bg-theme-base.webp', import.meta.url) new URL('../src/assets/images/hero-3d-banner.png', import.meta.url)
) )
const capabilityGlassAsset = fileURLToPath( const capabilityGlassAsset = fileURLToPath(
new URL('../src/assets/personal-workbench-card-glass-capability.webp', import.meta.url) new URL('../src/assets/personal-workbench-card-glass-capability.webp', import.meta.url)
@@ -43,8 +51,9 @@ const panelGlassAsset = fileURLToPath(
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, /嗨,\{\{ displayUserName \}\},我是您的 <span>AI 费用助手<\/span>/) assert.match(workbench, /\{\{ typedTitlePrefix \}\}<span v-if="titleTypingDone">小财管家<\/span>/)
assert.match(workbench, /placeholder="请输入费用申请、报销问题、预算查询或制度问答\.\.\."/) assert.match(workbench, /const heroTitleText = computed\(\(\) => `嗨,\$\{displayUserName\.value\},我是您的 `\)/)
assert.match(workbench, /placeholder="一次性描述申请、报销和附件处理事项,小财管家会先拆解再执行\.\.\."/)
assert.match(workbench, /const displayUserName = computed/) assert.match(workbench, /const displayUserName = computed/)
assert.match(workbench, /user\.name/) assert.match(workbench, /user\.name/)
}) })
@@ -83,16 +92,16 @@ test('workbench capability cards keep user-entered context only', () => {
}) })
test('workbench hero uses theme-tintable background image', () => { test('workbench hero uses theme-tintable background image', () => {
assert.match(workbench, /personal-workbench-hero-bg-theme-base\.webp/) assert.match(workbench, /hero-3d-banner\.png/)
assert.doesNotMatch(workbench, /personal-workbench-hero-bg-theme-base\.png/) assert.doesNotMatch(workbench, /personal-workbench-hero-bg-theme-base\.(webp|png)/)
assert.match(workbench, /--assistant-bg-image.*workbenchHeroBackground/) assert.match(workbench, /--assistant-bg-image.*workbenchHeroBackground/)
assert.match(workbenchStyles, /--assistant-theme-tint:[\s\S]*--theme-primary-rgb/) assert.match(workbenchStyles, /--assistant-theme-tint:[\s\S]*--theme-primary-rgb/)
assert.match(workbenchStyles, /var\(--assistant-bg-image\) var\(--assistant-bg-position\) \/ var\(--assistant-bg-size\) no-repeat/) assert.match(workbenchStyles, /url\("\.\.\/\.\.\/images\/workbench-hero-right-bg\.png"\) var\(--assistant-bg-position\) \/ var\(--assistant-decor-width\) auto no-repeat/)
assert.match(workbenchStyles, /background-blend-mode:\s*normal,\s*color,\s*luminosity;/) assert.match(workbenchStyles, /\.assistant-hero::after\s*\{[\s\S]*content:\s*"";/)
assert.match(workbenchStyles, /\.assistant-hero::after\s*\{[\s\S]*content:\s*none;/) assert.match(workbenchResponsiveStyles, /--assistant-bg-position:\s*right center;/)
assert.match(workbenchResponsiveStyles, /--assistant-bg-position:\s*68% center;/)
assert.doesNotMatch(workbenchResponsiveStyles, /homepage_backgraound/) assert.doesNotMatch(workbenchResponsiveStyles, /homepage_backgraound/)
assert.ok(statSync(heroBackgroundAsset).size < 120 * 1024) assert.ok(statSync(heroBackgroundAsset).size > 1024)
assert.ok(statSync(heroBackgroundAsset).size < 600 * 1024)
}) })
test('workbench cards use layered glass material instead of texture-led cards', () => { test('workbench cards use layered glass material instead of texture-led cards', () => {
@@ -103,13 +112,11 @@ test('workbench cards use layered glass material instead of texture-led cards',
assert.match(workbenchGlassStyles, /--workbench-glass-highlight:/) assert.match(workbenchGlassStyles, /--workbench-glass-highlight:/)
assert.match(workbenchGlassStyles, /--workbench-glass-noise-opacity:\s*0\.012;/) assert.match(workbenchGlassStyles, /--workbench-glass-noise-opacity:\s*0\.012;/)
assert.match(workbenchGlassStyles, /--workbench-glass-blur:\s*blur\(18px\) saturate\(1\.28\);/) assert.match(workbenchGlassStyles, /--workbench-glass-blur:\s*blur\(18px\) saturate\(1\.28\);/)
assert.match(workbenchGlassStyles, /\.capability-card\s*\{[\s\S]*background-color:\s*rgba\(255,\s*255,\s*255,\s*0\.64\);[\s\S]*backdrop-filter:\s*var\(--workbench-glass-blur\)/) assert.match(workbenchGlassStyles, /\.capability-card\s*\{[\s\S]*background:\s*rgba\(255,\s*255,\s*255,\s*0\.96\);[\s\S]*backdrop-filter:\s*blur\(12px\) saturate\(150%\)/)
assert.match(workbenchGlassStyles, /\.workbench-card\s*\{[\s\S]*background-color:\s*rgba\(255,\s*255,\s*255,\s*0\.66\);[\s\S]*backdrop-filter:\s*var\(--workbench-glass-blur\)/) assert.match(workbenchGlassStyles, /\.workbench-card\s*\{[\s\S]*background:\s*rgba\(255,\s*255,\s*255,\s*0\.96\);[\s\S]*backdrop-filter:\s*blur\(12px\) saturate\(150%\)/)
assert.match(workbenchGlassStyles, /\.capability-card::before,[\s\S]*\.capability-card::after/) assert.match(workbenchGlassStyles, /\.capability-card::before,[\s\S]*\.capability-card::after/)
assert.match(workbenchGlassStyles, /\.capability-card::before\s*\{[\s\S]*var\(--workbench-capability-bg-image\) 0 0 \/ var\(--workbench-capability-tile-size\) repeat;[\s\S]*opacity:\s*var\(--workbench-glass-noise-opacity\);/)
assert.match(workbenchGlassStyles, /\.workbench-card::before\s*\{[\s\S]*var\(--workbench-panel-bg-image\) 0 0 \/ var\(--workbench-panel-tile-size\) repeat;[\s\S]*opacity:\s*calc\(var\(--workbench-glass-noise-opacity\) \* 0\.8\);/)
assert.match(workbenchGlassStyles, /\.capability-card::after\s*\{[\s\S]*var\(--workbench-glass-highlight\)/) assert.match(workbenchGlassStyles, /\.capability-card::after\s*\{[\s\S]*var\(--workbench-glass-highlight\)/)
assert.match(workbenchGlassStyles, /\.workbench-card::after\s*\{[\s\S]*var\(--workbench-glass-highlight\)/) assert.match(workbenchGlassStyles, /\.workbench-card::before,[\s\S]*\.workbench-card::after\s*\{[\s\S]*display:\s*none !important;/)
assert.doesNotMatch(workbenchGlassStyles, /\.capability-card::after\s*\{[^}]*radial-gradient/) assert.doesNotMatch(workbenchGlassStyles, /\.capability-card::after\s*\{[^}]*radial-gradient/)
assert.doesNotMatch(workbenchGlassStyles, /\.workbench-card::after\s*\{[^}]*radial-gradient/) assert.doesNotMatch(workbenchGlassStyles, /\.workbench-card::after\s*\{[^}]*radial-gradient/)
assert.match(workbenchGlassStyles, /\.workbench-card > \*\s*\{[\s\S]*z-index:\s*1;/) assert.match(workbenchGlassStyles, /\.workbench-card > \*\s*\{[\s\S]*z-index:\s*1;/)
@@ -143,29 +150,67 @@ test('workbench submit shows intent recognition feedback before assistant opens'
assert.match(workbench, /:readonly="isComposerPending"/) assert.match(workbench, /:readonly="isComposerPending"/)
}) })
test('workbench progress rows show update time first', () => { test('workbench document progress has range filter, document types and empty state', () => {
assert.match(workbench, /class="progress-time"/) assert.match(workbench, /import PersonalWorkbenchProgressPanel from '\.\/PersonalWorkbenchProgressPanel\.vue'/)
assert.match(workbench, /class="progress-type"/) assert.match(workbench, /<PersonalWorkbenchProgressPanel[\s\S]*:progress-items="workbenchSummary\.progressItems \|\| \[\]"[\s\S]*@open-target="openWorkbenchTarget"/)
assert.match(workbench, /费用类型/) assert.match(workbenchProgressPanel, /<h2>单据进度<\/h2>/)
assert.match(workbench, /\{\{ item\.expenseTypeLabel \|\| '其他费用' \}\}/) assert.match(workbenchProgressPanel, /personal-workbench-progress\.css/)
assert.match(workbenchProgressPanel, /EnterpriseSelect/)
assert.match(workbenchProgressPanel, /近10日/)
assert.match(workbenchProgressPanel, /近30日/)
assert.match(workbenchProgressPanel, /近3个月/)
assert.match(workbenchProgressPanel, /class="progress-range-control"[\s\S]*@click\.stop[\s\S]*@mousedown\.stop[\s\S]*@pointerdown\.stop/)
assert.match(workbenchProgressPanel, /:teleported="true"/)
assert.match(workbenchProgressPanel, /@click="handleProgressItemClick\(\$event, item\)"/)
assert.match(workbenchProgressPanel, /function handleProgressItemClick\(event, item\)/)
assert.match(workbenchProgressPanel, /return true/)
assert.match(workbenchProgressPanel, /class="progress-time"/)
assert.match(workbenchProgressPanel, /class="progress-applicant"/)
assert.match(workbenchProgressPanel, /class="progress-table-shell"/)
assert.match(workbenchProgressPanel, />\u66f4\u65b0\u65f6\u95f4<\/span>/)
assert.doesNotMatch(workbenchProgressPanel, /\u5355\u636e\u52a8\u6001/)
assert.doesNotMatch(workbenchProgressPanel, /time-capsule/)
assert.doesNotMatch(workbenchProgressPanel, /<small>\u7533\u8bf7\u4eba<\/small>/)
assert.match(workbenchProgressPanel, /\{\{ item\.applicantLabel \|\| '待补充' \}\}/)
assert.match(workbenchProgressPanel, /class="progress-type"/)
assert.match(workbenchProgressPanel, /\{\{ item\.documentTypeLabel \}\} · \{\{ item\.expenseTypeLabel \|\| '其他费用' \}\}/)
assert.match(workbenchProgressPanel, /当前范围暂无单据/)
assert.match(workbenchProgressPanel, /没有申请单或报销单进度/)
assert.match(workbench, /source:\s*'workbench'/) assert.match(workbench, /source:\s*'workbench'/)
assert.match(workbench, /returnTo:\s*'workbench'/) assert.match(workbench, /returnTo:\s*'workbench'/)
assert.match(workbench, /has-long-duration-divider/) assert.doesNotMatch(workbenchProgressPanel, /has-long-duration-divider/)
assert.match(workbench, /hasLongDurationDivider/) assert.doesNotMatch(workbenchProgressPanel, /LONG_DURATION_DAYS/)
assert.match(workbench, /const LONG_DURATION_DAYS = 10/) assert.doesNotMatch(workbenchProgressPanel, /10日以上/)
assert.match(workbench, /isLongDurationProgress\(item\?\.updatedAt\)/) assert.doesNotMatch(workbenchStyles, /10日以上/)
assert.match(workbench, /<time :datetime="item\.updatedAt \|\| ''">\{\{ item\.displayTime \}\}<\/time>/) assert.doesNotMatch(workbenchStyles, /has-long-duration-divider/)
assert.match(workbench, /displayTime: formatProgressTime\(item\?\.updatedAt\)/) assert.doesNotMatch(workbench, />费用进度</)
assert.match(workbench, /function formatProgressTime\(value\)/) assert.match(workbenchProgressPanel, /<time :datetime="item\.updatedAt \|\| ''">\{\{ item\.displayTime \}\}<\/time>/)
assert.doesNotMatch(workbench, />全部进度/) assert.match(workbenchProgressPanel, /function formatProgressTime\(value\)/)
assert.match(workbenchStyles, /\.progress-row\s*\{[\s\S]*grid-template-columns:\s*minmax\(78px,\s*0\.42fr\)[\s\S]*minmax\(84px,\s*0\.42fr\)/) assert.match(workbenchStyles, /\.progress-row\s*\{[\s\S]*grid-template-columns:\s*minmax\(118px,\s*0\.58fr\)[\s\S]*minmax\(84px,\s*0\.42fr\)/)
assert.match(workbenchStyles, /\.progress-type\s*\{[\s\S]*justify-self:\s*stretch;[\s\S]*justify-items:\s*center;[\s\S]*text-align:\s*center;/) assert.match(workbenchStyles, /\.progress-type\s*\{[\s\S]*justify-self:\s*stretch;[\s\S]*justify-items:\s*center;[\s\S]*text-align:\s*center;/)
assert.match(workbenchStyles, /\.progress-type strong\s*\{[\s\S]*justify-content:\s*center;/) assert.match(workbenchStyles, /\.progress-type strong\s*\{[\s\S]*justify-content:\s*center;/)
assert.match(workbenchStyles, /\.progress-type strong\s*\{[\s\S]*var\(--workbench-primary-active\)/) assert.match(workbenchStyles, /\.progress-type strong\s*\{[\s\S]*var\(--workbench-primary-active\)/)
assert.match(workbenchStyles, /\.progress-row\.has-long-duration-divider::before\s*\{[\s\S]*content:\s*"10日以上"/) assert.match(workbenchStyles, /\.progress-list\s*\{[\s\S]*overflow-y:\s*auto;/)
assert.match(workbenchStyles, /\.progress-row\.has-long-duration-divider::before\s*\{[\s\S]*left:\s*50%;[\s\S]*transform:\s*translateX\(-50%\);/) assert.match(workbenchStyles, /\.progress-empty-state\s*\{[\s\S]*border:\s*1px dashed/)
assert.match(workbenchStyles, /\.progress-row\.has-long-duration-divider::before\s*\{[\s\S]*color:\s*var\(--theme-primary-active\);/) assert.match(workbenchStyles, /\.progress-range-select\s*\{[\s\S]*width:\s*124px;/)
assert.match(workbenchStyles, /\.progress-row\.has-long-duration-divider::after\s*\{[\s\S]*rgba\(var\(--theme-primary-rgb/) assert.match(workbenchProgressStyles, /\.progress-panel\s*\{[\s\S]*grid-template-rows:\s*auto minmax\(0,\s*1fr\);/)
assert.match(workbenchProgressStyles, /--progress-table-columns:[\s\S]*minmax\(96px,\s*0\.44fr\);/)
assert.match(workbenchProgressStyles, /\.progress-table-shell\s*\{[\s\S]*grid-template-rows:\s*36px minmax\(0,\s*1fr\);[\s\S]*overflow:\s*hidden;/)
assert.match(workbenchProgressStyles, /\.progress-table-header\s*\{[\s\S]*grid-template-columns:\s*var\(--progress-table-columns\);/)
assert.match(workbenchProgressStyles, /\.progress-table-header\s*\{[\s\S]*height:\s*36px;[\s\S]*max-height:\s*36px;[\s\S]*overflow:\s*hidden;/)
assert.match(workbenchProgressStyles, /\.header-cell\s*\{[\s\S]*justify-content:\s*center;[\s\S]*white-space:\s*nowrap;/)
assert.match(workbenchProgressStyles, /\.progress-list\s*\{[\s\S]*grid-auto-rows:\s*minmax\(76px,\s*auto\)/)
assert.match(workbenchProgressStyles, /\.progress-row\s*\{[\s\S]*grid-template-columns:\s*var\(--progress-table-columns\);[\s\S]*gap:\s*8px;[\s\S]*min-height:\s*76px;[\s\S]*text-align:\s*center;/)
assert.match(workbenchProgressStyles, /\.progress-time-wrapper\s*\{[\s\S]*justify-content:\s*center;/)
assert.match(workbenchProgressStyles, /\.progress-identity\s*\{[\s\S]*align-items:\s*center;[\s\S]*text-align:\s*center;/)
assert.match(workbenchProgressStyles, /\.progress-applicant\s*\{[\s\S]*justify-items:\s*center;/)
assert.match(workbenchProgressStyles, /\.progress-result\s*\{[\s\S]*justify-self:\s*stretch;[\s\S]*justify-content:\s*center;[\s\S]*justify-items:\s*center;/)
assert.match(workbenchProgressStyles, /\.progress-result strong\s*\{[\s\S]*width:\s*100%;[\s\S]*text-align:\s*center;/)
assert.match(workbenchProgressStyles, /\.progress-steps\s*\{[\s\S]*justify-items:\s*stretch;/)
assert.match(workbenchProgressStyles, /\.progress-step\s*\{[\s\S]*justify-items:\s*center;/)
assert.match(workbenchProgressStyles, /\.progress-range-control\s*\{[\s\S]*z-index:\s*6;/)
assert.match(workbenchProgressStyles, /\.progress-range-select:deep\(\.el-select__wrapper\)/)
assert.match(workbenchProgressStyles, /\.progress-empty-state\s*\{[\s\S]*border:\s*1px dashed/)
assert.match(workbenchStyles, /\.progress-time\s*\{[\s\S]*color:\s*var\(--workbench-muted\);/) assert.match(workbenchStyles, /\.progress-time\s*\{[\s\S]*color:\s*var\(--workbench-muted\);/)
assert.match(workbenchResponsiveStyles, /grid-template-areas:[\s\S]*"time identity result"[\s\S]*"type type type"[\s\S]*"steps steps steps"/) assert.match(workbenchResponsiveStyles, /grid-template-areas:[\s\S]*"time identity result"[\s\S]*"type type type"[\s\S]*"steps steps steps"/)
assert.match(workbenchResponsiveStyles, /\.progress-type\s*\{[\s\S]*grid-area:\s*type;/) assert.match(workbenchResponsiveStyles, /\.progress-type\s*\{[\s\S]*grid-area:\s*type;/)

View File

@@ -10,6 +10,7 @@ import {
const CREATE_APPLICATION = '\u521b\u5efa\u7533\u8bf7' const CREATE_APPLICATION = '\u521b\u5efa\u7533\u8bf7'
const DIRECT_MANAGER_APPROVAL = '\u76f4\u5c5e\u9886\u5bfc\u5ba1\u6279' const DIRECT_MANAGER_APPROVAL = '\u76f4\u5c5e\u9886\u5bfc\u5ba1\u6279'
const BUDGET_MANAGER_APPROVAL = '\u9884\u7b97\u7ba1\u7406\u8005\u5ba1\u6279' const BUDGET_MANAGER_APPROVAL = '\u9884\u7b97\u7ba1\u7406\u8005\u5ba1\u6279'
const FINANCE_APPROVAL = '\u8d22\u52a1\u5ba1\u6279'
const APPROVAL_COMPLETED = '\u5ba1\u6279\u5b8c\u6210' const APPROVAL_COMPLETED = '\u5ba1\u6279\u5b8c\u6210'
const APPLICATION_LINK_STATUS = '\u5173\u8054\u5355\u636e\u72b6\u6001' const APPLICATION_LINK_STATUS = '\u5173\u8054\u5355\u636e\u72b6\u6001'
const APPLICATION_ARCHIVE = '\u7533\u8bf7\u5f52\u6863' const APPLICATION_ARCHIVE = '\u7533\u8bf7\u5f52\u6863'
@@ -21,6 +22,7 @@ const ARCHIVED = '\u5df2\u5f52\u6863'
const WAIT_LEADER_LI_APPROVAL = '\u7b49\u5f85 Leader Li \u6279\u590d' const WAIT_LEADER_LI_APPROVAL = '\u7b49\u5f85 Leader Li \u6279\u590d'
const WAIT_BUDGET_ZHAO_APPROVAL = '\u7b49\u5f85 \u8d75\u9884\u7b97 \u6279\u590d' const WAIT_BUDGET_ZHAO_APPROVAL = '\u7b49\u5f85 \u8d75\u9884\u7b97 \u6279\u590d'
const WAIT_BUDGET_P8_EXECUTIVE_APPROVAL = '\u7b49\u5f85 P8 Executive \u6279\u590d' const WAIT_BUDGET_P8_EXECUTIVE_APPROVAL = '\u7b49\u5f85 P8 Executive \u6279\u590d'
const WAIT_FINANCE_FIONA_APPROVAL = '\u7b49\u5f85 Fiona Finance \u6279\u590d'
const LEADER_RETURNED_STATUS = '\u9886\u5bfc\u5df2\u9000\u56de\uff0c\u5f85\u91cd\u65b0\u63d0\u4ea4' const LEADER_RETURNED_STATUS = '\u9886\u5bfc\u5df2\u9000\u56de\uff0c\u5f85\u91cd\u65b0\u63d0\u4ea4'
test('claim mapper exposes employee identifier for reviewer risk profile lookup', () => { test('claim mapper exposes employee identifier for reviewer risk profile lookup', () => {
@@ -158,7 +160,7 @@ test('application claims are mapped as application documents', () => {
assert.equal(request.expenseTableSummary, '预计金额已随申请提交') assert.equal(request.expenseTableSummary, '预计金额已随申请提交')
assert.deepEqual( assert.deepEqual(
request.progressSteps.map((step) => step.label), request.progressSteps.map((step) => step.label),
[CREATE_APPLICATION, WAIT_LEADER_LI_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED] [CREATE_APPLICATION, WAIT_LEADER_LI_APPROVAL, APPLICATION_LINK_STATUS, ARCHIVED]
) )
assert.equal(request.progressSteps.some((step) => step.label === 'AI预审'), false) assert.equal(request.progressSteps.some((step) => step.label === 'AI预审'), false)
assert.equal(request.progressSteps.some((step) => step.label === '财务审批'), false) assert.equal(request.progressSteps.some((step) => step.label === '财务审批'), false)
@@ -252,7 +254,7 @@ test('application claims wait for department P8 budget monitor after leader appr
assert.deepEqual( assert.deepEqual(
request.progressSteps.map((step) => step.label), request.progressSteps.map((step) => step.label),
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, WAIT_BUDGET_ZHAO_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED] [CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, WAIT_BUDGET_ZHAO_APPROVAL, APPLICATION_LINK_STATUS, ARCHIVED]
) )
assert.equal(request.progressSteps.find((step) => step.label === WAIT_BUDGET_ZHAO_APPROVAL)?.current, true) assert.equal(request.progressSteps.find((step) => step.label === WAIT_BUDGET_ZHAO_APPROVAL)?.current, true)
assert.equal(request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)?.time, 'Leader Li通过') assert.equal(request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)?.time, 'Leader Li通过')
@@ -295,7 +297,7 @@ test('application budget wait label uses claim-level budget approver snapshot',
assert.equal(request.budgetApproverName, 'P8 Executive') assert.equal(request.budgetApproverName, 'P8 Executive')
assert.deepEqual( assert.deepEqual(
request.progressSteps.map((step) => step.label), request.progressSteps.map((step) => step.label),
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, WAIT_BUDGET_P8_EXECUTIVE_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED] [CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, WAIT_BUDGET_P8_EXECUTIVE_APPROVAL, APPLICATION_LINK_STATUS, ARCHIVED]
) )
assert.equal(request.progressSteps.find((step) => step.label === WAIT_BUDGET_P8_EXECUTIVE_APPROVAL)?.current, true) assert.equal(request.progressSteps.find((step) => step.label === WAIT_BUDGET_P8_EXECUTIVE_APPROVAL)?.current, true)
}) })
@@ -391,7 +393,7 @@ test('approved application claims complete after budget approval', () => {
assert.equal(request.workflowNode, APPLICATION_LINK_STATUS) assert.equal(request.workflowNode, APPLICATION_LINK_STATUS)
assert.deepEqual( assert.deepEqual(
request.progressSteps.map((step) => step.label), request.progressSteps.map((step) => step.label),
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, BUDGET_MANAGER_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED] [CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, BUDGET_MANAGER_APPROVAL, APPLICATION_LINK_STATUS, ARCHIVED]
) )
assert.equal(request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)?.current, true) assert.equal(request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)?.current, true)
assert.equal(request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)?.time, '未关联') assert.equal(request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)?.time, '未关联')
@@ -417,15 +419,40 @@ test('application claims hide budget step when leader approval also covers budge
created_at: '2026-05-25T01:30:00.000Z', created_at: '2026-05-25T01:30:00.000Z',
updated_at: '2026-05-25T03:00:00.000Z', updated_at: '2026-05-25T03:00:00.000Z',
status: 'approved', status: 'approved',
approval_stage: APPROVAL_COMPLETED, approval_stage: APPLICATION_LINK_STATUS,
risk_flags_json: [ risk_flags_json: [
{
source: 'approval_routing',
event_type: 'expense_application_route_decision',
requires_budget_review: true,
route: 'budget_manager',
budget_result: {
metrics: {
after_usage_rate: '99.27',
claim_amount_ratio: '1.27',
over_budget_amount: '0.00'
}
},
created_at: '2026-05-25T03:00:00.000Z'
},
{ {
source: 'manual_approval', source: 'manual_approval',
event_type: 'expense_application_approval', event_type: 'expense_application_approval',
operator: '李预算经理', operator: '李预算经理',
previous_approval_stage: DIRECT_MANAGER_APPROVAL, previous_approval_stage: DIRECT_MANAGER_APPROVAL,
next_approval_stage: APPROVAL_COMPLETED, next_approval_stage: APPLICATION_LINK_STATUS,
budget_approval_merged: true, budget_approval_merged: true,
route_decision: {
requires_budget_review: true,
route: 'budget_manager',
budget_result: {
metrics: {
after_usage_rate: '99.27',
claim_amount_ratio: '1.27',
over_budget_amount: '0.00'
}
}
},
created_at: '2026-05-25T03:00:00.000Z' created_at: '2026-05-25T03:00:00.000Z'
} }
], ],
@@ -434,7 +461,7 @@ test('application claims hide budget step when leader approval also covers budge
assert.deepEqual( assert.deepEqual(
request.progressSteps.map((step) => step.label), request.progressSteps.map((step) => step.label),
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED] [CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPLICATION_LINK_STATUS, ARCHIVED]
) )
assert.equal(request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)?.current, true) assert.equal(request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)?.current, true)
assert.equal(request.progressSteps.find((step) => step.label === ARCHIVED)?.done, false) assert.equal(request.progressSteps.find((step) => step.label === ARCHIVED)?.done, false)
@@ -486,7 +513,7 @@ test('approved application claims hide budget step when dynamic route skipped bu
assert.deepEqual( assert.deepEqual(
request.progressSteps.map((step) => step.label), request.progressSteps.map((step) => step.label),
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED] [CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPLICATION_LINK_STATUS, ARCHIVED]
) )
assert.equal(request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)?.current, true) assert.equal(request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)?.current, true)
assert.equal(request.progressSteps.find((step) => step.label === ARCHIVED)?.done, false) assert.equal(request.progressSteps.find((step) => step.label === ARCHIVED)?.done, false)
@@ -494,6 +521,69 @@ test('approved application claims hide budget step when dynamic route skipped bu
assert.equal(request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)?.time, 'Leader Li通过') assert.equal(request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)?.time, 'Leader Li通过')
}) })
test('approved application claims hide stale budget route below 90 percent threshold', () => {
const request = mapExpenseClaimToRequest({
id: 'claim-application-stale-budget-route',
claim_no: 'APP-20260525-STALE-BUDGET',
employee_name: '张三',
department_name: '交付部',
manager_name: 'Leader Li',
expense_type: 'travel_application',
reason: 'Project onsite support',
location: 'Shanghai',
amount: 8500,
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: APPROVAL_COMPLETED,
risk_flags_json: [
{
source: 'approval_routing',
event_type: 'expense_application_route_decision',
requires_budget_review: true,
route: 'budget_manager',
budget_result: {
metrics: {
after_usage_rate: '85.00',
claim_amount_ratio: '85.00',
over_budget_amount: '0.00'
}
},
created_at: '2026-05-25T03:00:00.000Z'
},
{
source: 'manual_approval',
event_type: 'expense_application_approval',
operator: 'Leader Li',
previous_approval_stage: DIRECT_MANAGER_APPROVAL,
next_approval_stage: BUDGET_MANAGER_APPROVAL,
route_decision: {
requires_budget_review: true,
route: 'budget_manager',
budget_result: {
metrics: {
after_usage_rate: '85.00',
claim_amount_ratio: '85.00',
over_budget_amount: '0.00'
}
}
},
created_at: '2026-05-25T03:00:00.000Z'
}
],
items: []
})
assert.deepEqual(
request.progressSteps.map((step) => step.label),
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPLICATION_LINK_STATUS, ARCHIVED]
)
assert.equal(request.progressSteps.some((step) => step.label === BUDGET_MANAGER_APPROVAL), false)
})
test('approved application claims show linked reimbursement status before archive', () => { test('approved application claims show linked reimbursement status before archive', () => {
const request = mapExpenseClaimToRequest({ const request = mapExpenseClaimToRequest({
id: 'claim-application-linked-draft', id: 'claim-application-linked-draft',
@@ -566,7 +656,7 @@ test('application claims are archived only after linked reimbursement is paid',
assert.equal(request.workflowNode, APPLICATION_ARCHIVE) assert.equal(request.workflowNode, APPLICATION_ARCHIVE)
assert.deepEqual( assert.deepEqual(
request.progressSteps.map((step) => step.label), request.progressSteps.map((step) => step.label),
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED] [CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPLICATION_LINK_STATUS, ARCHIVED]
) )
assert.equal(request.progressSteps.every((step) => step.done), true) assert.equal(request.progressSteps.every((step) => step.done), true)
assert.equal(request.secondaryStatusValue, '已归档') assert.equal(request.secondaryStatusValue, '已归档')
@@ -582,6 +672,8 @@ test('progress steps show approval operator time and current stay duration', ()
claim_no: 'EXP-202605-001', claim_no: 'EXP-202605-001',
employee_name: '张三', employee_name: '张三',
department_name: '市场部', department_name: '市场部',
finance_owner_name: 'Wang Finance Group',
finance_approver_name: 'Fiona Finance',
expense_type: 'transport', expense_type: 'transport',
reason: '交通报销', reason: '交通报销',
location: '上海', location: '上海',
@@ -592,21 +684,21 @@ test('progress steps show approval operator time and current stay duration', ()
created_at: '2026-05-20T01:30:00.000Z', created_at: '2026-05-20T01:30:00.000Z',
updated_at: '2026-05-20T03:30:00.000Z', updated_at: '2026-05-20T03:30:00.000Z',
status: 'submitted', status: 'submitted',
approval_stage: '财务审批', approval_stage: FINANCE_APPROVAL,
risk_flags_json: [ risk_flags_json: [
{ {
source: 'manual_approval', source: 'manual_approval',
operator: '李经理', operator: '李经理',
previous_approval_stage: '直属领导审批', previous_approval_stage: DIRECT_MANAGER_APPROVAL,
next_approval_stage: '财务审批', next_approval_stage: FINANCE_APPROVAL,
created_at: '2026-05-20T03:30:00.000Z' created_at: '2026-05-20T03:30:00.000Z'
} }
], ],
items: [] items: []
}) })
const leaderStep = request.progressSteps.find((step) => step.label === '直属领导审批') const leaderStep = request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)
const financeStep = request.progressSteps.find((step) => step.label === '财务审批') const financeStep = request.progressSteps.find((step) => step.rawLabel === FINANCE_APPROVAL)
const firstStep = request.progressSteps[0] const firstStep = request.progressSteps[0]
assert.equal(request.riskSummary, '无') assert.equal(request.riskSummary, '无')
@@ -615,6 +707,10 @@ test('progress steps show approval operator time and current stay duration', ()
assert.match(leaderStep.detail, /2026-05-20/) assert.match(leaderStep.detail, /2026-05-20/)
assert.match(leaderStep.title, /李经理审批通过/) assert.match(leaderStep.title, /李经理审批通过/)
assert.equal(request.progressSteps.some((step) => step.label === 'AI预审'), false) assert.equal(request.progressSteps.some((step) => step.label === 'AI预审'), false)
assert.equal(request.financeOwnerName, 'Wang Finance Group')
assert.equal(request.financeApproverName, 'Fiona Finance')
assert.equal(financeStep.label, WAIT_FINANCE_FIONA_APPROVAL)
assert.equal(financeStep.rawLabel, FINANCE_APPROVAL)
assert.equal(financeStep.current, true) assert.equal(financeStep.current, true)
assert.equal(financeStep.time, '停留 1小时30分钟') assert.equal(financeStep.time, '停留 1小时30分钟')
} finally { } finally {
@@ -1124,6 +1220,7 @@ test('current direct manager step shows how long the claim has stayed there', ()
claim_no: 'EXP-202605-002', claim_no: 'EXP-202605-002',
employee_name: '王五', employee_name: '王五',
department_name: '市场部', department_name: '市场部',
manager_name: '李经理',
expense_type: 'office', expense_type: 'office',
reason: '办公用品', reason: '办公用品',
location: '上海', location: '上海',
@@ -1139,11 +1236,13 @@ test('current direct manager step shows how long the claim has stayed there', ()
items: [] items: []
}) })
const leaderStep = request.progressSteps.find((step) => step.label === '直属领导审批') const leaderStep = request.progressSteps.find((step) => step.rawLabel === '直属领导审批')
const submitStep = request.progressSteps.find((step) => step.label === '待提交') const submitStep = request.progressSteps.find((step) => step.label === '待提交')
assert.equal(submitStep.time, '王五提交') assert.equal(submitStep.time, '王五提交')
assert.match(submitStep.detail, /2026-05-20/) assert.match(submitStep.detail, /2026-05-20/)
assert.equal(leaderStep.label, '等待 李经理 批复')
assert.equal(leaderStep.rawLabel, '直属领导审批')
assert.equal(leaderStep.current, true) assert.equal(leaderStep.current, true)
assert.equal(leaderStep.time, '停留 3小时15分钟') assert.equal(leaderStep.time, '停留 3小时15分钟')
} finally { } finally {

View File

@@ -67,6 +67,7 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
assert.match(detailScript, /const isCurrentDirectManagerApprover = computed/) assert.match(detailScript, /const isCurrentDirectManagerApprover = computed/)
assert.match(detailScript, /const canProcessFinanceApprovalStage = computed/) assert.match(detailScript, /const canProcessFinanceApprovalStage = computed/)
assert.match(detailScript, /const canProcessBudgetApprovalStage = computed/) assert.match(detailScript, /const canProcessBudgetApprovalStage = computed/)
assert.match(detailScript, /const canProcessCurrentApprovalStage = computed/)
assert.match(detailScript, /approvalOpinionTitle/) assert.match(detailScript, /approvalOpinionTitle/)
assert.match(detailScript, /approvalConfirmDescription/) assert.match(detailScript, /approvalConfirmDescription/)
assert.doesNotMatch(detailScript, /approvalNextStage/) assert.doesNotMatch(detailScript, /approvalNextStage/)
@@ -84,9 +85,14 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
/const showApplicationLeaderOpinion = computed\(\(\) => \(\s*isApplicationDocument\.value\s*&& hasLeaderApprovalEvents\.value\s*\)\)/ /const showApplicationLeaderOpinion = computed\(\(\) => \(\s*isApplicationDocument\.value\s*&& hasLeaderApprovalEvents\.value\s*\)\)/
) )
assert.match(detailScript, /isDirectManagerApprovalStage\.value\)[\s\S]*return isCurrentDirectManagerApprover\.value/) assert.match(detailScript, /isDirectManagerApprovalStage\.value\)[\s\S]*return isCurrentDirectManagerApprover\.value/)
assert.match(detailScript, /isDirectManagerApprovalStage\.value[\s\S]*&& isCurrentDirectManagerApprover\.value/) assert.match(detailScript, /if \(isDirectManagerApprovalStage\.value\) \{[\s\S]*return isCurrentDirectManagerApprover\.value/)
assert.match(detailScript, /canProcessFinanceApprovalStage\.value/) assert.match(detailScript, /canProcessFinanceApprovalStage\.value/)
assert.match(detailScript, /canProcessBudgetApprovalStage\.value/) assert.match(detailScript, /canProcessBudgetApprovalStage\.value/)
assert.match(detailScript, /const canApproveRequest = computed\(\(\) =>\s*request\.value\.approvalKey === 'in_progress'[\s\S]*&& canProcessCurrentApprovalStage\.value\s*\)/)
assert.doesNotMatch(
detailScript,
/const canApproveRequest = computed\(\(\) =>\s*\(Boolean\(props\.approvalMode\) \|\| isApplicationDocument\.value\)/
)
assert.doesNotMatch(detailScript, /leaderApprovalReadonlyText/) assert.doesNotMatch(detailScript, /leaderApprovalReadonlyText/)
assert.match(detailScript, /resolveGeneratedDraftClaimNo/) assert.match(detailScript, /resolveGeneratedDraftClaimNo/)
assert.match(detailScript, /resolveApproveErrorMessage/) assert.match(detailScript, /resolveApproveErrorMessage/)

View File

@@ -1169,6 +1169,28 @@ test('draft submit validation uses expense detail date and amount when claim sum
assert.ok(!issues.some((issue) => issue.includes('报销事由未完善'))) assert.ok(!issues.some((issue) => issue.includes('报销事由未完善')))
}) })
test('returned application submit validation does not require expense detail rows', () => {
const issues = buildDraftBlockingIssues(
{
id: 'AP-202606060001-RETURNED',
claimNo: 'AP-202606060001-RETURNED',
documentTypeCode: 'application',
approvalKey: 'rejected',
profileName: '曹笑竹',
typeLabel: '差旅费用申请',
typeCode: 'travel_application',
reason: '支撑国网仿生产环境部署',
location: '上海',
occurredDisplay: '2026-02-20 至 2026-02-23',
amountValue: 1880
},
[]
)
assert.deepEqual(issues, [])
assert.equal(issues.includes('费用明细不能为空'), false)
})
test('transport ticket descriptions use route format and invalid format becomes risk advice', () => { test('transport ticket descriptions use route format and invalid format becomes risk advice', () => {
const routeItem = buildExpenseItemViewModel( const routeItem = buildExpenseItemViewModel(
{ {

View File

@@ -71,6 +71,9 @@ test('workbench summary builds real user notifications and progress from request
assert.equal(summary.todoItems.length, 1) assert.equal(summary.todoItems.length, 1)
assert.equal(summary.todoItems[0].target.id, 'claim-1') assert.equal(summary.todoItems[0].target.id, 'claim-1')
assert.equal(summary.progressItems.length, 1) assert.equal(summary.progressItems.length, 1)
assert.match(summary.progressItems[0].prompt, /单据进度/)
assert.doesNotMatch(summary.progressItems[0].prompt, /费用进度/)
assert.equal(summary.progressItems[0].applicantLabel, '张三')
assert.deepEqual( assert.deepEqual(
summary.progressItems[0].steps.map((step) => step.label), summary.progressItems[0].steps.map((step) => step.label),
['创建单据', '待提交', '直属领导审批', '财务审批'] ['创建单据', '待提交', '直属领导审批', '财务审批']
@@ -136,3 +139,65 @@ test('workbench progress keeps application document type for AP claims', () => {
['申请单', '申请单'] ['申请单', '申请单']
) )
}) })
test('workbench progress includes application rows without backend progress steps', () => {
const summary = buildWorkbenchSummary(
[
{
id: 'AP-202606060001-NOSTEPS',
claimId: 'application-without-steps',
claimNo: 'AP-202606060001-NOSTEPS',
documentTypeCode: 'application',
person: currentUser.name,
title: '上海出差申请',
approvalKey: 'in_progress',
approvalStatus: '直属领导审批',
amount: 1880,
updatedAt: '2026-06-06T09:00:00+08:00'
}
],
currentUser
)
assert.equal(summary.progressItems.length, 1)
assert.equal(summary.progressItems[0].documentTypeLabel, '申请单')
assert.equal(summary.progressItems[0].applicantLabel, currentUser.name)
assert.deepEqual(
summary.progressItems[0].steps.map((step) => step.label),
['创建申请', '直属领导审批', '关联单据状态', '已归档']
)
assert.equal(summary.progressItems[0].steps[1].current, true)
})
test('workbench progress includes application documents assigned to current approver', () => {
const approverUser = {
name: '李经理',
username: 'manager@example.com',
roleCodes: ['approver']
}
const summary = buildWorkbenchSummary(
[
{
id: 'AP-202606060002-APPROVAL',
claimId: 'application-for-approver',
claimNo: 'AP-202606060002-APPROVAL',
documentTypeCode: 'application',
person: '张三',
managerName: '李经理',
title: '北京出差申请',
approvalKey: 'in_progress',
approvalStatus: '直属领导审批',
workflowNode: '直属领导审批',
amount: 2600,
updatedAt: '2026-06-06T11:00:00+08:00'
}
],
approverUser
)
assert.equal(summary.totalCount, 0)
assert.equal(summary.progressItems.length, 1)
assert.equal(summary.progressItems[0].documentTypeLabel, '申请单')
assert.equal(summary.progressItems[0].applicantLabel, '张三')
assert.equal(summary.progressItems[0].requestId, 'AP-202606060002-APPROVAL')
})