diff --git a/.env.example b/.env.example index a2bb8ac..df3588f 100644 --- a/.env.example +++ b/.env.example @@ -48,4 +48,8 @@ SQLALCHEMY_ECHO=false 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"]' diff --git a/docker-compose.gpu.yml b/docker-compose.gpu.yml new file mode 100644 index 0000000..d833ff2 --- /dev/null +++ b/docker-compose.gpu.yml @@ -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}" diff --git a/docker-compose.postgres.yml b/docker-compose.postgres.yml new file mode 100644 index 0000000..cfdca96 --- /dev/null +++ b/docker-compose.postgres.yml @@ -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: diff --git a/mobile/design/android-expense-assistant-screens.html b/mobile/design/android-expense-assistant-screens.html new file mode 100644 index 0000000..a8876d1 --- /dev/null +++ b/mobile/design/android-expense-assistant-screens.html @@ -0,0 +1,1177 @@ + + + + + + 员工报销助手 Android 界面设计稿 + + + + + + + + + + + + + + +
+
+
+ Android-first UI proposal +

员工报销助手:面向普通员工的简洁移动报销应用设计稿

+

+ 本稿舍弃旧版浅绿设计,改为低干扰的企业工具界面。核心路径是: + 申请出差、记一笔、拍票据、提交报销、查看进度、移动审批。界面优先服务员工快速完成动作。 +

+
+
+ 设计原则 +
+
单屏单目标每个页面只突出一个主操作,减少移动端误触。
+
先记账后报销员工可以随手记费用,后续合并提交申请。
+
拍照即暂存拍票据先识别和预览,不默认创建正式报销单。
+
+
+
+ +
+
+
+

01 登录与身份确认

+

进入 App 后确认当前员工身份,后续请求带用户上下文。

+
+
+
9:41
+
+
+
XF
X-Financial
员工报销助手
+

用手机完成报销,不再攒票据。

+

申请出差、记账、拍票据、查看审批进度,都在一个入口完成。

+
+
+
zhangsan
+
销售部
+
员工 / 可发起报销
+
+
+
进入报销助手
+
+
切换账号
+
+
+
+ +
+
+

02 首页工作台

+

首页只放员工最高频动作:申请出差、记账、拍票据、看进度。

+
+
+
9:41
+
+
上午好,张三
报销助手
消息
+
+ 今天先处理哪件事? +

可先记账和拍票据,月底再合并提交。

+
+
+
申请出差目的地与预算
+
记一笔先记录费用
+
拍票据识别发票小票
+
看进度3 单审批中
+
+
+
最近单据
+
上海客户拜访
REQ-2026-0422 · 主管审批中
¥3,280
+
客户招待费
待补充同行人员
待补
+
+
+
+
+ +
+
+

03 出差申请

+

员工先提交出差计划,行程和预算进入后续报销上下文。

+
+
+
9:41
+
+
返回
申请出差
草稿
+
+
上海客户拜访
+
上海市 更改
+
06-12 至 06-14
+
Northstar 项目
+
李四、王五
+
+
+
3 天预计行程
+
¥2,600预算合计
+
+
+
预算明细
+
交通¥900
+
住宿¥1,200
+
餐补¥500
+
+
提交出差申请
+
+
+ +
+
+

04 记一笔费用

+

随手记录费用,允许暂不上传票据,后续可补拍。

+
+
+
9:41
+
+
返回
记一笔
保存
+
+
¥ 320.00
+
业务招待费 选择
+
今天 18:30
+
海悦餐厅
+
请输入招待对象与事由
+
+
+
票据
+
拍照或上传票据
+
+
保存这笔费用
+
+
+ +
+
+

05 拍照上传票据

+

移动端核心能力。取景框明确提示票据位置,底部保留相册入口。

+
+
+
9:41
+
+
关闭
拍票据
闪光
+
+
+
将发票或小票放入框内,保持文字清晰
+
+
+
+
+
从相册选择
+
批量上传
+
+
+
+
上传规则
+

支持发票、小票、行程单。单张不超过 20MB。

+
+
+
+
+ +
+
+

06 票据识别确认

+

识别结果先展示给员工确认,不直接写入正式报销单。

+
+
+
9:41
+
+
返回
识别结果
重识别
+
+
出租车票
+
餐饮小票
+
酒店发票
+
+
+
+
AI 建议分类
+
市内交通费
出租车票 · 置信度 92%
¥86
+
业务招待费
餐饮小票 · 待补客户名称
¥320
+
住宿费
酒店发票 · 已识别
¥900
+
+
+
需补充
+ 业务招待对象 + 同行人员 +
+
确认并加入记账
+
+
+ +
+
+

07 提交报销申请

+

把记账项合并成报销申请,提交前展示缺失项和风险提示。

+
+
+
9:41
+
+
返回
提交报销
草稿
+
+
报销类型差旅报销
+
关联出差上海客户拜访
+
费用条目5 项
+
附件票据5 张
+
合计金额¥3,280
+
+
+
提交前检查
+
发票金额与明细一致
已通过
正常
+
!
餐费需补充同行人员
提交后可能被退回
需补
+
+
保存草稿
提交审批
+
+
+ +
+
+

08 单据进度列表

+

员工查看全部单据,按草稿、审批中、待补充、已完成筛选。

+
+
+
9:41
+
+
单据进度
筛选
+
全部草稿审批中待补
+
+
上海客户拜访
REQ-2026-0422 · 财务复核
审批中
+
客户招待费
REQ-2026-0458 · 部门经理退回
待补
+
办公采购
REQ-2026-0431 · 已打款
完成
+
市内交通
草稿 · 2 张票据
草稿
+
+
+
+
+ +
+
+

09 单据进度详情

+

突出当前节点和下一步动作,员工知道为什么卡住、该补什么。

+
+
+
9:41
+
+
返回
报销详情
更多
+
+ 财务复核中 +
+ 上海客户拜访 +

REQ-2026-0422 · 合计 ¥3,280

+
+
+
审批进度
+
+
已提交张三 · 06-02 10:20
+
主管审批通过李四 · 06-02 15:08
+
财务复核中预计今天 18:00 前处理
+
待打款复核通过后自动进入
+
+
+
+
费用明细
+
交通¥860
+
住宿¥900
+
餐饮¥520
+
+
+
+
+ +
+
+

10 移动审批详情

+

审批人直接看到金额、风险、票据和流程,底部固定审批动作。

+
+
+
9:41
+
+
返回
审批详情
转交
+
+ 待我审批 +
+ 差旅报销 · 李四 +

销售部 · 上海客户拜访 · ¥3,280

+
+
+
AI 风控提示
+
!
酒店费用接近标准上限
建议关注住宿天数和城市标准
关注
+
未发现重复报销风险
票据号码未重复
正常
+
+
+
审批意见
+
选填,驳回时建议填写原因
+
+
驳回
转交
同意
+
+
+
+
+ + diff --git a/mobile/design/android-expense-assistant-screens.png b/mobile/design/android-expense-assistant-screens.png new file mode 100644 index 0000000..adff9a1 Binary files /dev/null and b/mobile/design/android-expense-assistant-screens.png differ diff --git a/mobile/design/android-expense-assistant-v2.html b/mobile/design/android-expense-assistant-v2.html new file mode 100644 index 0000000..221d14a --- /dev/null +++ b/mobile/design/android-expense-assistant-v2.html @@ -0,0 +1,1338 @@ + + + + + + X-Financial 员工报销助手 Android V2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ V2 Android visual direction +

员工报销助手:加入启动素材页、登录页、统一返回图标和完整图标系统

+

这版按真实 App 启动链路重新设计:启动素材页、品牌开机页、登录页、首页工作台、出差申请、记账、拍票据、识别确认、提交、进度、审批。开机页从独立素材系统生成,避免直接把业务卡片堆到首屏。

+
+
+ 修正点 +
+
补启动素材新增独立素材页,统一启动图形、背景、动效和落地规格。
+
统一返回所有二级页使用小于号形态 chevron,而不是文字返回。
+
图标系统首页、导航、表单、审批、上传、风险全部补齐图标。
+
+
+
+ +
+
+

00 开机页

由启动素材页统一输出品牌图形、票据场景和启动状态。

+
+
9:41
+
+
+
SECURE EXPENSEANDROID
+
+
+
+
X-Financial
Expense
+
出差申请、票据采集、报销提交、移动审批
+
+
+
+
+
+
本月待处理¥ 3,280
+
+
+ AI 识别 +

票据已归类

+

5 张票据 · 2 项待补

+
+
+
+
+
审批流已同步
财务复核中
+
+
+
拍照上传
+
+ +
+
+
+
+ +
+

01 登录页

明确员工身份、部门和安全登录状态。登录页不放多余入口。

+
+
9:41
+
+
员工登录
X-Financial Expense
+
报销从手机开始。
+

登录后可申请出差、记账、拍票据并查看审批进度。

+ +
+
登录
+
+
使用企业 SSO
+
+
+
+ +
+

02 首页工作台

首页突出四个高频动作,并增加图标和统计摘要。

+
+
9:41
+
+
上午好,张三
报销工作台
+
本月待处理

3 张单据需要你处理

建议先补齐票据,再提交审批。

+
+
申请出差目的地与预算
+
记一笔随手记费用
+
拍票据上传并识别
+
移动审批2 单待处理
+
+
+
上海客户拜访
财务复核中 · REQ-0422
¥3,280
+
客户招待费
待补同行人员
待补
+
+
+ +
+
+ +
+

03 出差申请

二级页面使用 chevron 返回,表单项配图标。

+
+
9:41
+
+
<
申请出差
+
+
上海客户拜访
+
上海市变更
+
06-12 至 06-14
+
Northstar 项目
+
李四、王五
+
+
3 天预计行程
¥2,600预算合计
+
交通预算¥900
住宿预算¥1,200
餐补预算¥500
+
+
提交出差申请
+ +
+
+ +
+

04 记一笔

金额为视觉中心,费用类型和票据作为下一步。

+
+
9:41
+
+
<
记一笔
+
+
¥ 320.00
+
业务招待费选择
+
今天 18:30
+
客户名称、招待事由
+
+
票据
可现在拍,也可提交前补
拍照
+
+
保存费用
+ +
+
+ +
+

05 拍票据

取景框和底部按钮更像真实相机页,保留相册和批量上传。

+
+
9:41
+
+
<
拍票据
+
对齐票据边缘,保持文字清晰
+
相册
批量
+
+

支持发票、小票、行程单,单张不超过 20MB。

+
+
+
+ +
+

06 识别确认

识别结果以费用卡片呈现,员工先确认再入账。

+
+
9:41
+
+
<
识别结果
+
出租车票
餐饮小票
酒店发票
+
+
+
市内交通费
出租车票 · 置信度 94%
¥86
+
业务招待费
需补客户名称
¥320
+
住宿费
酒店发票 · 已识别
¥900
+
+
待补:客户名称、同行人员
+
+
确认并加入记账
+ +
+
+ +
+

07 提交报销

提交前强调合计、附件数量和缺失检查。

+
+
9:41
+
+
<
提交报销
+
报销类型差旅报销
关联出差上海客户拜访
费用条目5 项
附件票据5 张
合计金额¥3,280
+
+
+
发票金额与明细一致
已通过
正常
+
餐费需补同行人员
否则可能被退回
需补
+
+
+
保存草稿
提交审批
+ +
+
+ +
+

08 单据进度

列表加搜索、筛选和图标状态,减少“数据表”味道。

+
+
9:41
+
+
单据进度
3 单审批中 · 1 单待补
+
全部草稿审批中待补
+
+
上海客户拜访
REQ-0422 · 财务复核
审批中
+
客户招待费
REQ-0458 · 待补同行
待补
+
办公采购
REQ-0431 · 已打款
完成
+
市内交通
草稿 · 2 张票据
草稿
+
+
+ +
+
+ +
+

09 报销详情

详情页突出当前节点,用时间线展示审批真实进度。

+
+
9:41
+
+
<
报销详情
+
财务复核中

上海客户拜访

REQ-2026-0422 · 合计 ¥3,280

+
+
审批进度
已提交张三 · 06-02 10:20
主管审批通过李四 · 06-02 15:08
财务复核中预计今天 18:00 前处理
待打款复核通过后自动进入
+
+
交通¥860
住宿¥900
餐饮¥520
+
+ +
+
+ +
+

10 移动审批

审批页展示风险、票据、意见,底部固定驳回/转交/同意。

+
+
9:41
+
+
<
审批详情
+
待我审批

差旅报销 · 李四

销售部 · 上海客户拜访 · ¥3,280

+
+
+
酒店费用接近标准上限
建议关注城市和住宿天数
关注
+
未发现重复报销风险
票据号码未重复
正常
+
+
+
选填,驳回时建议填写原因
+
+
驳回
转交
同意
+ +
+
+
+
+ + diff --git a/mobile/design/android-expense-assistant-v2.png b/mobile/design/android-expense-assistant-v2.png new file mode 100644 index 0000000..0aea6a6 Binary files /dev/null and b/mobile/design/android-expense-assistant-v2.png differ diff --git a/mobile/design/android-expense-splash-assets.html b/mobile/design/android-expense-splash-assets.html new file mode 100644 index 0000000..f6598da --- /dev/null +++ b/mobile/design/android-expense-splash-assets.html @@ -0,0 +1,724 @@ + + + + + + X-Financial Expense 启动页素材系统 + + + +
+
+
+ Splash asset system +

X-Financial Expense 启动页素材系统

+

这页专门给 Android 开机页使用,不直接复用业务页面卡片。素材分成品牌锁定、深色背景、票据场景、启动动效和 Android 落地规格,后续开发 Compose 或原生启动页时按这里取值。

+
+ +
+ +
+
+

启动页预览

+

用于 9:16 Android 手机首屏,登录态检查完成后跳转登录页或首页。

+
+
+
+
9:41
+
+
SECURE EXPENSEANDROID
+
+
+
+
X-Financial
Expense
+
出差申请、票据采集、报销提交、移动审批
+
+
+
+
+ +
本月待处理¥ 3,280
+
+
+ AI 识别 +

票据已归类

+

5 张票据 · 2 项待补

+
+
+

审批流已同步

+

财务复核中 · 待提醒

+
+
拍照上传
+
+ +
+
+
+
+ +
+
+

品牌与 App 图标

+

App 图标保留双斜票据形态,避免直接使用文字。启动页 Logo 使用 74dp 等比缩放,图标安全区不低于 12dp。

+
+
+
+
+
+
+
启动 Logo
74dp / 58dp / 44dp 三档,深色背景使用。
+
Launcher Icon
前景图形居中,保留 Android 自适应图标裁切空间。
+
AI 识别
业务状态 Chip
仅作为视觉线索,不做主操作入口。
+
+
+ +
+

颜色与背景

+

启动页走深色金融安全感。绿色做品牌,金色只用于识别和进度强调,蓝色用于信息状态。

+
+
#061416
启动底色
+
#006B5E
品牌主色
+
#0F766E
场景渐变
+
#F5A524
识别强调
+
#F8FAFC
票据纸面
+
+
+ +
+

素材拆分

+

开发落地时建议拆成 5 个可复用素材层,Compose 中可分别写成 Composable。

+
+
背景层
深色底、细网格、顶部柔光,静态即可。
+
AI 识别

票据已归类

5 张票据 · 2 项待补

票据层
表达拍照上传和 OCR,不显示真实敏感票据信息。
+
拍照上传
状态层
启动时轻提示,900ms 后淡出。
+
+
+ +
+

启动动效节奏

+

控制在 900ms 左右,不阻塞用户进入登录或首页。系统检测慢时只延长进度条,不重复播放动画。

+
+
0ms深色底和网格先出现,避免白屏。
+
160msLogo scale 0.92 到 1,透明度进入。
+
320ms产品名和定位文案淡入。
+
520ms票据场景上移 8dp,进度条启动。
+
+
+ +
+

Android 落地规格

+

先走 Android 原生启动,再进入 Compose 首页。冷启动用系统 SplashScreen,业务素材用于第一个 Activity 的过渡页。

+
+
windowSplashScreenBackground#061416。系统启动页避免白屏,和业务过渡页底色一致。
+
windowSplashScreenAnimatedIcon只放 App 图标前景,不放产品文字,防止小屏裁切。
+
过渡页时长登录态检查完成即跳转;正常 600-900ms,异常最长 1800ms 后进入登录页。
+
Compose 拆分SplashBackground、BrandLockup、ExpenseScene、LoadingRail 四个组件。
+
无障碍启动页只设置应用名称 contentDescription,业务装饰图不参与朗读。
+
+
+
+
+
+ + diff --git a/mobile/design/android-expense-splash-assets.png b/mobile/design/android-expense-splash-assets.png new file mode 100644 index 0000000..ecbd7f8 Binary files /dev/null and b/mobile/design/android-expense-splash-assets.png differ diff --git a/mobile/design/yicai-splash-screen.png b/mobile/design/yicai-splash-screen.png new file mode 100644 index 0000000..557e0c9 Binary files /dev/null and b/mobile/design/yicai-splash-screen.png differ diff --git a/remove_bg.py b/remove_bg.py new file mode 100644 index 0000000..407c3e1 --- /dev/null +++ b/remove_bg.py @@ -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]) diff --git a/remove_bg_fast.ps1 b/remove_bg_fast.ps1 new file mode 100644 index 0000000..64e5446 --- /dev/null +++ b/remove_bg_fast.ps1 @@ -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" diff --git a/server/scripts/bootstrap_paddleocr_gpu.sh b/server/scripts/bootstrap_paddleocr_gpu.sh new file mode 100644 index 0000000..affcc69 --- /dev/null +++ b/server/scripts/bootstrap_paddleocr_gpu.sh @@ -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}" diff --git a/server/scripts/paddle_ocr_worker.py b/server/scripts/paddle_ocr_worker.py index 0e045ad..3fe762c 100644 --- a/server/scripts/paddle_ocr_worker.py +++ b/server/scripts/paddle_ocr_worker.py @@ -21,6 +21,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--lang", default="ch") 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("--device", default=os.environ.get("OCR_DEVICE", "")) parser.add_argument("--enable-mkldnn", action="store_true") return parser.parse_args() @@ -100,16 +101,20 @@ def build_document(input_path: str, results: list[Any]) -> dict[str, Any]: def main() -> int: args = parse_args() - ocr = PaddleOCR( - text_detection_model_name=args.text_detection_model, - text_recognition_model_name=args.text_recognition_model, - use_doc_orientation_classify=False, - use_doc_unwarping=False, - use_textline_orientation=False, - lang=args.lang, + ocr_options = { + "text_detection_model_name": args.text_detection_model, + "text_recognition_model_name": args.text_recognition_model, + "use_doc_orientation_classify": False, + "use_doc_unwarping": False, + "use_textline_orientation": False, + "lang": args.lang, # 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 = [] for input_path in args.inputs: diff --git a/server/src/app/core/config.py b/server/src/app/core/config.py index 6f5a08c..98672f8 100644 --- a/server/src/app/core/config.py +++ b/server/src/app/core/config.py @@ -19,25 +19,25 @@ ONLYOFFICE_FIELD_NAMES = { _settings_cache: Settings | None = None _settings_cache_signature: tuple[tuple[str, bool, int | None, int | None], ...] | None = None - - + + class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=DEFAULT_ENV_FILES, env_file_encoding="utf-8", extra="ignore", ) - - app_name: str = Field(default="X-Financial Server", alias="APP_NAME") - app_env: str = Field(default="local", alias="APP_ENV") - app_debug: bool = Field(default=True, alias="APP_DEBUG") - setup_completed: bool = Field(default=False, alias="SETUP_COMPLETED") - - company_name: str = Field(default="", alias="COMPANY_NAME") - company_code: str = Field(default="", alias="COMPANY_CODE") - admin_email: str = Field(default="", alias="ADMIN_EMAIL") - - web_host: str = Field(default="0.0.0.0", alias="WEB_HOST") + + app_name: str = Field(default="X-Financial Server", alias="APP_NAME") + app_env: str = Field(default="local", alias="APP_ENV") + app_debug: bool = Field(default=True, alias="APP_DEBUG") + setup_completed: bool = Field(default=False, alias="SETUP_COMPLETED") + + company_name: str = Field(default="", alias="COMPANY_NAME") + company_code: str = Field(default="", alias="COMPANY_CODE") + admin_email: str = Field(default="", alias="ADMIN_EMAIL") + + web_host: str = Field(default="0.0.0.0", alias="WEB_HOST") web_port: int = Field(default=5173, alias="WEB_PORT") app_host: str = Field(default="0.0.0.0", alias="SERVER_HOST") app_port: int = Field(default=8000, alias="SERVER_PORT") @@ -48,21 +48,21 @@ class Settings(BaseSettings): alias="BACKGROUND_SCHEDULERS_ENABLED", ) 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_port: int = Field(default=5432, alias="POSTGRES_PORT") - postgres_db: str = Field(default="x_financial", alias="POSTGRES_DB") - postgres_user: str = Field(default="postgres", alias="POSTGRES_USER") - postgres_password: str = Field(default="postgres", alias="POSTGRES_PASSWORD") - + + postgres_host: str = Field(default="127.0.0.1", alias="POSTGRES_HOST") + postgres_port: int = Field(default=5432, alias="POSTGRES_PORT") + postgres_db: str = Field(default="x_financial", alias="POSTGRES_DB") + postgres_user: str = Field(default="postgres", alias="POSTGRES_USER") + postgres_password: str = Field(default="postgres", alias="POSTGRES_PASSWORD") + database_url: str | None = Field(default=None, alias="DATABASE_URL") sqlalchemy_echo: bool = Field(default=False, alias="SQLALCHEMY_ECHO") sqlalchemy_pool_size: int = Field(default=10, alias="SQLALCHEMY_POOL_SIZE") sqlalchemy_max_overflow: int = Field(default=20, alias="SQLALCHEMY_MAX_OVERFLOW") sqlalchemy_pool_timeout: int = Field(default=30, alias="SQLALCHEMY_POOL_TIMEOUT") - - redis_url: str | None = Field(default=None, alias="REDIS_URL") - cors_origins: list[str] = Field(default_factory=list, alias="CORS_ORIGINS") + + redis_url: str | None = Field(default=None, alias="REDIS_URL") + cors_origins: list[str] = Field(default_factory=list, alias="CORS_ORIGINS") vite_api_base_url: str = Field( 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") storage_root_dir: str = Field(default="storage", alias="STORAGE_ROOT_DIR") 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_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") @@ -98,7 +99,7 @@ class Settings(BaseSettings): def resolved_database_url(self) -> str: if self.database_url: return self.database_url - + return ( f"postgresql+psycopg://{self.postgres_user}:{self.postgres_password}" f"@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}" diff --git a/server/src/app/models/financial_record.py b/server/src/app/models/financial_record.py index 6c2a182..b4c51ba 100644 --- a/server/src/app/models/financial_record.py +++ b/server/src/app/models/financial_record.py @@ -68,8 +68,16 @@ class ExpenseClaim(Base): return None if self.employee.manager is not None and self.employee.manager.name: 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 + @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 def role_labels(self) -> list[str]: if self.employee is None or not self.employee.roles: diff --git a/server/src/app/schemas/reimbursement.py b/server/src/app/schemas/reimbursement.py index 46af134..39211d5 100644 --- a/server/src/app/schemas/reimbursement.py +++ b/server/src/app/schemas/reimbursement.py @@ -4,7 +4,9 @@ from datetime import date, datetime from decimal import Decimal 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): @@ -147,6 +149,8 @@ class ExpenseClaimRead(BaseModel): employee_position: str | None = None employee_grade: 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_grade: str | None = None budget_approver_role_code: str | None = None @@ -167,6 +171,13 @@ class ExpenseClaimRead(BaseModel): updated_at: datetime 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): message: str diff --git a/server/src/app/services/expense_claim_access_policy.py b/server/src/app/services/expense_claim_access_policy.py index 4d82832..647030d 100644 --- a/server/src/app/services/expense_claim_access_policy.py +++ b/server/src/app/services/expense_claim_access_policy.py @@ -250,6 +250,45 @@ class ExpenseClaimAccessPolicy: return 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: if claim is None: return None @@ -269,9 +308,25 @@ class ExpenseClaimAccessPolicy: ) 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]: for claim in claims: - self.attach_budget_approval_snapshot(claim) + self.attach_approval_snapshot(claim) return claims @staticmethod @@ -647,6 +702,11 @@ class ExpenseClaimAccessPolicy: *, include_approval_scope: bool = False, ) -> 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) role_codes = self.normalize_role_codes(current_user) diff --git a/server/src/app/services/expense_claim_approval_routing.py b/server/src/app/services/expense_claim_approval_routing.py index 9f0f3c0..3fbfa11 100644 --- a/server/src/app/services/expense_claim_approval_routing.py +++ b/server/src/app/services/expense_claim_approval_routing.py @@ -17,6 +17,7 @@ from app.services.expense_claim_risk_stage import ( class ExpenseClaimApprovalRoutingMixin: + _APPLICATION_BUDGET_REVIEW_USAGE_THRESHOLD = Decimal("90.00") _BUDGET_REVIEW_RATINGS = {"block"} _BUDGET_REVIEW_RISK_LEVELS = {"high", "critical"} _ROUTE_RISK_SEVERITIES = {"medium", "high", "critical", "danger"} @@ -63,7 +64,11 @@ class ExpenseClaimApprovalRoutingMixin: ) -> dict[str, Any]: business_stage = risk_business_stage_for_claim(is_application_claim=is_application_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( claim.risk_flags_json, business_stage=business_stage, @@ -75,7 +80,9 @@ class ExpenseClaimApprovalRoutingMixin: else [] ) 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) route = ( @@ -86,11 +93,18 @@ class ExpenseClaimApprovalRoutingMixin: else "finance" ) label = "需要预算管理者复核" if requires_budget_review else "跳过预算管理者复核" - message = ( - "系统根据预算、当前风险和历史风险判断,该单据需要预算管理者二次确认。" - if requires_budget_review - else "系统根据预算、当前风险和历史风险判断,该单据可跳过预算管理者复核。" - ) + if is_application_claim: + message = ( + "系统根据预算占用阈值判断,该申请单达到 90% 预算复核线,需要预算管理者二次确认。" + if requires_budget_review + else "系统根据预算占用阈值判断,该申请单未达到 90% 预算复核线,可跳过预算管理者复核。" + ) + else: + message = ( + "系统根据预算、当前风险和历史风险判断,该单据需要预算管理者二次确认。" + if requires_budget_review + else "系统根据预算、当前风险和历史风险判断,该单据可跳过预算管理者复核。" + ) return with_risk_business_stage( { @@ -136,6 +150,20 @@ class ExpenseClaimApprovalRoutingMixin: reasons.append(f"预计超预算 {over_budget_amount} 元") 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( self, risk_flags: list[Any] | None, diff --git a/server/src/app/services/expense_claim_attachment_operations.py b/server/src/app/services/expense_claim_attachment_operations.py index 7ca46bc..3107d35 100644 --- a/server/src/app/services/expense_claim_attachment_operations.py +++ b/server/src/app/services/expense_claim_attachment_operations.py @@ -352,9 +352,15 @@ class ExpenseClaimAttachmentOperationsMixin: self._ensure_draft_claim(claim) self._ensure_mutable_claim_item(item) 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) self._attachment_storage.delete_item_files(item) 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._refresh_claim_pre_review_flags(claim, is_application_claim=False) @@ -379,6 +385,36 @@ class ExpenseClaimAttachmentOperationsMixin: "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( self, *, diff --git a/server/src/app/services/expense_claim_budget_flow.py b/server/src/app/services/expense_claim_budget_flow.py index e84eb1d..540119d 100644 --- a/server/src/app/services/expense_claim_budget_flow.py +++ b/server/src/app/services/expense_claim_budget_flow.py @@ -5,6 +5,7 @@ from typing import Any from app.api.deps import CurrentUserContext from app.models.financial_record import ExpenseClaim 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 @@ -104,7 +105,7 @@ class ExpenseClaimBudgetFlowMixin: else flag for flag in next_flags ] - return [*list(risk_flags or []), *enriched_flags] + return dedupe_budget_risk_flags([*list(risk_flags or []), *enriched_flags]) @staticmethod def _resolve_budget_operator(current_user: CurrentUserContext) -> str: diff --git a/server/src/app/services/expense_claim_budget_risk_flags.py b/server/src/app/services/expense_claim_budget_risk_flags.py new file mode 100644 index 0000000..3474b1f --- /dev/null +++ b/server/src/app/services/expense_claim_budget_risk_flags.py @@ -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) diff --git a/server/src/app/services/expense_claim_risk_review.py b/server/src/app/services/expense_claim_risk_review.py index 6904b44..e7dde2f 100644 --- a/server/src/app/services/expense_claim_risk_review.py +++ b/server/src/app/services/expense_claim_risk_review.py @@ -130,7 +130,10 @@ class ExpenseClaimRiskReviewMixin( attention_reasons.extend(scene_policy_review["blocking_reasons"]) 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"]) platform_risk_flags = list(platform_risk_review["flags"]) review_flags.extend(platform_risk_flags) diff --git a/server/src/app/services/expense_claims.py b/server/src/app/services/expense_claims.py index 84803c4..eaf2986 100644 --- a/server/src/app/services/expense_claims.py +++ b/server/src/app/services/expense_claims.py @@ -204,6 +204,7 @@ class ExpenseClaimService( .options( selectinload(ExpenseClaim.items), selectinload(ExpenseClaim.employee).selectinload(Employee.manager), + selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit), selectinload(ExpenseClaim.employee).selectinload(Employee.roles), ) .order_by(ExpenseClaim.created_at.desc(), ExpenseClaim.occurred_at.desc()) @@ -217,6 +218,7 @@ class ExpenseClaimService( .options( selectinload(ExpenseClaim.items), selectinload(ExpenseClaim.employee).selectinload(Employee.manager), + selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit), selectinload(ExpenseClaim.employee).selectinload(Employee.roles), ) .order_by(ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc()) @@ -230,6 +232,7 @@ class ExpenseClaimService( .options( selectinload(ExpenseClaim.items), selectinload(ExpenseClaim.employee).selectinload(Employee.manager), + selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit), selectinload(ExpenseClaim.employee).selectinload(Employee.roles), ) .order_by(ExpenseClaim.updated_at.desc(), ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc()) @@ -243,12 +246,13 @@ class ExpenseClaimService( .options( selectinload(ExpenseClaim.items), selectinload(ExpenseClaim.employee).selectinload(Employee.manager), + selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit), selectinload(ExpenseClaim.employee).selectinload(Employee.roles), ) .where(ExpenseClaim.id == claim_id) ) 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: if claim is None: @@ -1019,5 +1023,3 @@ class ExpenseClaimService( - - diff --git a/server/src/app/services/ocr.py b/server/src/app/services/ocr.py index df20acf..54b040f 100644 --- a/server/src/app/services/ocr.py +++ b/server/src/app/services/ocr.py @@ -248,6 +248,7 @@ class OcrService: return "|".join( [ self.settings.ocr_language, + self.settings.ocr_device, self.settings.ocr_text_detection_model, self.settings.ocr_text_recognition_model, digest, @@ -333,6 +334,9 @@ class OcrService: "--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: command.extend(["--input", str(path)]) diff --git a/server/src/app/services/ontology_field_registry.py b/server/src/app/services/ontology_field_registry.py index dd355be..a34748e 100644 --- a/server/src/app/services/ontology_field_registry.py +++ b/server/src/app/services/ontology_field_registry.py @@ -52,6 +52,8 @@ ONTOLOGY_FIELD_ALIASES: dict[str, tuple[str, ...]] = { "employee_no": ("employeeNo",), "employee_position": ("position", "employeePosition"), "manager_name": ("managerName", "direct_manager_name", "directManagerName"), + "finance_owner_name": ("financeOwnerName",), + "finance_approver_name": ("financeApproverName",), } CANONICAL_ONTOLOGY_FIELDS = frozenset(ONTOLOGY_FIELD_ALIASES) | frozenset( @@ -66,7 +68,6 @@ CANONICAL_ONTOLOGY_FIELDS = frozenset(ONTOLOGY_FIELD_ALIASES) | frozenset( "control_action", "employee_location", "employee_risk_profile", - "finance_owner_name", "document_id", "application_claim_id", "application_claim_no", diff --git a/server/src/app/services/system_dashboard.py b/server/src/app/services/system_dashboard.py index 44a7c4d..83e9e70 100644 --- a/server/src/app/services/system_dashboard.py +++ b/server/src/app/services/system_dashboard.py @@ -5,7 +5,7 @@ from dataclasses import dataclass, field from datetime import UTC, date, datetime, timedelta from typing import Any -from sqlalchemy import select +from sqlalchemy import or_, select from sqlalchemy.orm import Session 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)) 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"] + online_user_count = len(self._unique_session_identities(active_sessions)) return SystemDashboardRead( window_days=window_days, @@ -122,7 +123,7 @@ class SystemDashboardService: totals={ "toolCalls": len(tool_calls), "modelTokens": total_tokens, - "onlineUsers": len(active_sessions), + "onlineUsers": online_user_count, "avgOnlineMinutes": self._average_session_minutes(sessions, now), "executionSuccessRate": self._percent(succeeded_runs, len(runs)), "positiveFeedback": positive_feedback, @@ -132,7 +133,7 @@ class SystemDashboardService: "modelTokensChange": self._change_percent(total_tokens, previous_tokens), }, 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), user_token_usage=self._user_token_usage(token_records, user_names), accuracy_comparison=self._accuracy_comparison(tool_calls), @@ -215,7 +216,14 @@ class SystemDashboardService: def _fetch_sessions(self, start: datetime) -> list[UserSessionMetric]: stmt = ( 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()) ) return list(self.db.scalars(stmt).all()) @@ -258,19 +266,43 @@ class SystemDashboardService: "series": ratio_series, } - def _login_wave(self, sessions: list[UserSessionMetric]) -> dict[str, Any]: - labels = [f"{hour:02d}:00" for hour in range(8, 21)] - login_users = [0 for _ in labels] + def _login_wave( + self, + 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] 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: - hour = self._as_utc(session.login_at).hour - label = f"{hour:02d}:00" - if label not in index: + login_at = self._as_utc(session.login_at) + end_at = self._session_end_at(session, now) + if end_at < window_start or login_at > window_end: continue - login_users[index[label]] += 1 - interactions[index[label]] += max(0, int(session.activity_event_count or 0)) - return {"labels": labels, "loginUsers": login_users, "interactions": interactions} + + identity = self._session_identity(session) + 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]: input_tokens = [0 for _ in labels] @@ -547,12 +579,28 @@ class SystemDashboardService: if int(session.duration_ms or 0) > 0: return max(0, int(session.duration_ms or 0)) 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: return max(0, min(int((end_at - login_at).total_seconds() * 1000), 24 * 60 * 60 * 1000)) except TypeError: 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 def _date_labels(start_date: date, days: int) -> list[str]: return [(start_date + timedelta(days=index)).strftime("%m-%d") for index in range(days)] diff --git a/server/tests/test_expense_claim_approval_routing.py b/server/tests/test_expense_claim_approval_routing.py index 912daa6..7cbf8ba 100644 --- a/server/tests/test_expense_claim_approval_routing.py +++ b/server/tests/test_expense_claim_approval_routing.py @@ -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: with build_session() as db: department, manager, _budget_manager, employee = _seed_people(db, suffix="MIXED-STAGE") diff --git a/server/tests/test_expense_claim_service.py b/server/tests/test_expense_claim_service.py index 017dd1e..1d59ba6 100644 --- a/server/tests/test_expense_claim_service.py +++ b/server/tests/test_expense_claim_service.py @@ -28,6 +28,7 @@ from app.schemas.reimbursement import ( ) from app.services.agent_conversations import AgentConversationService 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_claims import ExpenseClaimService from app.services.expense_claim_workflow_constants import ( @@ -119,6 +120,42 @@ def build_session() -> Session: 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: 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: with build_session() as db: 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 +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: current_user = CurrentUserContext( username="executive@example.com", diff --git a/server/tests/test_ocr_service.py b/server/tests/test_ocr_service.py index e783e08..de158a5 100644 --- a/server/tests/test_ocr_service.py +++ b/server/tests/test_ocr_service.py @@ -1,6 +1,7 @@ from __future__ import annotations import stat +import subprocess from pathlib import Path 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。"] +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( monkeypatch, tmp_path: Path, diff --git a/server/tests/test_reimbursement_endpoints.py b/server/tests/test_reimbursement_endpoints.py index bcd4310..05e3ad2 100644 --- a/server/tests/test_reimbursement_endpoints.py +++ b/server/tests/test_reimbursement_endpoints.py @@ -113,6 +113,135 @@ def seed_claim(db: Session) -> tuple[ExpenseClaim, ExpenseClaimItem]: 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: client, session_factory = build_client() with session_factory() as db: diff --git a/server/tests/test_system_dashboard_service.py b/server/tests/test_system_dashboard_service.py index ad4d4c1..fed5235 100644 --- a/server/tests/test_system_dashboard_service.py +++ b/server/tests/test_system_dashboard_service.py @@ -147,3 +147,52 @@ def test_system_dashboard_service_aggregates_real_runtime_metrics() -> None: assert dashboard.accuracy_comparison["wrong"][ dashboard.accuracy_comparison["categories"].index("异常诊断") ] == 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"] diff --git a/web/src/assets/styles/app.css b/web/src/assets/styles/app.css index 66aaaef..8739a72 100644 --- a/web/src/assets/styles/app.css +++ b/web/src/assets/styles/app.css @@ -39,6 +39,7 @@ width var(--sidebar-motion), flex-basis var(--sidebar-motion), box-shadow 160ms var(--ease); + animation: loginEntrySidebarIn 520ms cubic-bezier(0.16, 1, 0.3, 1) backwards; } .app.sidebar-collapsed .app-sidebar { @@ -151,7 +152,13 @@ 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 { height: var(--desktop-stage-height, 100dvh); grid-template-rows: auto minmax(0, 1fr); @@ -201,6 +208,14 @@ overflow-x: hidden; overflow-y: auto; 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 { padding: 0; diff --git a/web/src/assets/styles/components/personal-workbench-progress.css b/web/src/assets/styles/components/personal-workbench-progress.css new file mode 100644 index 0000000..cce2f23 --- /dev/null +++ b/web/src/assets/styles/components/personal-workbench-progress.css @@ -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; + } +} diff --git a/web/src/assets/styles/components/personal-workbench-responsive.css b/web/src/assets/styles/components/personal-workbench-responsive.css index 4f50055..0d3f17a 100644 --- a/web/src/assets/styles/components/personal-workbench-responsive.css +++ b/web/src/assets/styles/components/personal-workbench-responsive.css @@ -334,6 +334,19 @@ 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 { justify-items: start; } diff --git a/web/src/assets/styles/components/personal-workbench.css b/web/src/assets/styles/components/personal-workbench.css index b6459cf..66e6de8 100644 --- a/web/src/assets/styles/components/personal-workbench.css +++ b/web/src/assets/styles/components/personal-workbench.css @@ -26,6 +26,8 @@ --workbench-chart-amber: var(--chart-amber, #b58b4c); width: 100%; + max-width: 1680px; + margin: 0 auto; height: 100%; min-width: 0; display: grid; @@ -581,6 +583,27 @@ 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 { display: inline-flex; align-items: center; @@ -632,7 +655,65 @@ display: grid; min-height: 0; 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 { @@ -645,15 +726,25 @@ animation-delay: calc(300ms + var(--item-index, 0) * 80ms); } -.progress-identity, +.progress-identity { + display: flex; + flex-direction: column; + gap: 2px; +} + .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-result strong { - margin-bottom: 2px; - overflow: hidden; color: var(--workbench-ink); font-size: 13px; @@ -751,45 +842,6 @@ 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 { display: flex; align-items: center; diff --git a/web/src/assets/styles/components/sidebar-rail.css b/web/src/assets/styles/components/sidebar-rail.css index 65c4042..21c38ce 100644 --- a/web/src/assets/styles/components/sidebar-rail.css +++ b/web/src/assets/styles/components/sidebar-rail.css @@ -173,8 +173,32 @@ border-color 180ms var(--ease), color 180ms var(--ease); 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 { background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08); color: var(--theme-primary-active); diff --git a/web/src/components/business/ExpenseProfileDetailModal.vue b/web/src/components/business/ExpenseProfileDetailModal.vue index 12fca65..8bc742c 100644 --- a/web/src/components/business/ExpenseProfileDetailModal.vue +++ b/web/src/components/business/ExpenseProfileDetailModal.vue @@ -3,7 +3,7 @@ :model-value="visible" append-to-body align-center - width="min(1040px, calc(100vw - 48px))" + width="min(960px, calc(100vw - 64px))" :show-close="false" :lock-scroll="true" destroy-on-close @@ -277,6 +277,8 @@ watch( } :global(.expense-profile-dialog.el-dialog) { + max-height: calc(100vh - 56px); + max-height: calc(100dvh - 56px); overflow: hidden; border: 1px solid rgba(148, 163, 184, 0.34); border-radius: 4px; @@ -380,7 +382,8 @@ watch( } .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; display: grid; gap: 12px; @@ -469,7 +472,7 @@ watch( .profile-analysis-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; } @@ -483,13 +486,13 @@ watch( .profile-tags-panel { grid-template-rows: auto minmax(0, 1fr); align-content: stretch; - min-height: 352px; + min-height: 312px; } .profile-radar-panel { grid-template-rows: auto minmax(0, 1fr) auto; align-content: stretch; - min-height: 352px; + min-height: 312px; } .profile-section-title { @@ -554,11 +557,11 @@ watch( } .profile-tags-panel > .profile-panel-empty { - min-height: 284px; + min-height: 244px; } .profile-radar-empty { - min-height: 308px; + min-height: 268px; } .profile-operation-copy strong { @@ -580,13 +583,13 @@ watch( grid-template-columns: minmax(0, 1fr); align-items: center; justify-items: stretch; - min-height: 360px; + min-height: 300px; animation: profileRadarEnter 360ms cubic-bezier(0.2, 0, 0, 1) both; } .profile-radar-chart { width: 100%; - height: 360px; + height: 300px; } .profile-behavior-tags { @@ -703,6 +706,97 @@ watch( 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 { 0% { opacity: 0; diff --git a/web/src/components/business/ExpenseStatsDetailModal.vue b/web/src/components/business/ExpenseStatsDetailModal.vue index 033a04a..7ec2a1a 100644 --- a/web/src/components/business/ExpenseStatsDetailModal.vue +++ b/web/src/components/business/ExpenseStatsDetailModal.vue @@ -3,7 +3,7 @@ :model-value="visible" append-to-body align-center - width="min(980px, calc(100vw - 48px))" + width="min(900px, calc(100vw - 64px))" :show-close="false" :lock-scroll="true" destroy-on-close @@ -256,6 +256,8 @@ function resolveTagType(tone) { } :global(.expense-stats-detail-dialog.el-dialog) { + max-height: calc(100vh - 56px); + max-height: calc(100dvh - 56px); overflow: hidden; border: 1px solid rgba(148, 163, 184, 0.34); border-radius: 4px; @@ -353,7 +355,9 @@ function resolveTagType(tone) { } .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; gap: 12px; padding: 14px; @@ -372,7 +376,7 @@ function resolveTagType(tone) { } .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, @@ -432,7 +436,7 @@ function resolveTagType(tone) { .expense-stats-distribution-panel, .expense-stats-processing-panel { - min-height: 336px; + min-height: 300px; } .expense-stats-section-title { @@ -483,17 +487,17 @@ function resolveTagType(tone) { } .expense-distribution-chart { - min-height: 286px; + min-height: 250px; display: grid; align-items: stretch; } .expense-distribution-chart-layout { 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; gap: 12px; - min-height: 286px; + min-height: 250px; } .expense-distribution-donut { @@ -501,7 +505,7 @@ function resolveTagType(tone) { } .expense-distribution-donut :deep(.donut-body) { - height: 220px; + height: 192px; margin-top: 0; } @@ -619,7 +623,7 @@ function resolveTagType(tone) { .expense-stats-empty { margin: 0; - min-height: 180px; + min-height: 150px; display: flex; align-items: center; justify-content: center; @@ -632,6 +636,97 @@ function resolveTagType(tone) { 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 { 0% { opacity: 0; diff --git a/web/src/components/business/PersonalWorkbench.vue b/web/src/components/business/PersonalWorkbench.vue index d71028e..597c20b 100644 --- a/web/src/components/business/PersonalWorkbench.vue +++ b/web/src/components/business/PersonalWorkbench.vue @@ -199,65 +199,10 @@
-
-
-

费用进度

-
- -
- -
- -
+
-

登录人数与互动次数的时段波动。

+

在线人数与互动次数的时段波动。

{ - if (request.value.approvalKey !== 'in_progress' || !request.value.claimId || !canReturnExpenseClaims(currentUser.value)) { - return false - } + const canProcessCurrentApprovalStage = computed(() => { if (isDirectManagerApprovalStage.value) { return isCurrentDirectManagerApprover.value } @@ -767,18 +764,16 @@ export default { } 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(() => - (Boolean(props.approvalMode) || isApplicationDocument.value) - && request.value.approvalKey === 'in_progress' + request.value.approvalKey === 'in_progress' && Boolean(request.value.claimId) - && ( - ( - isDirectManagerApprovalStage.value - && isCurrentDirectManagerApprover.value - ) - || canProcessFinanceApprovalStage.value - || canProcessBudgetApprovalStage.value - ) + && canProcessCurrentApprovalStage.value ) const canViewApprovalRiskAdvice = computed(() => ( Boolean(request.value.claimId) diff --git a/web/src/views/scripts/travelRequestDetailExpenseModel.js b/web/src/views/scripts/travelRequestDetailExpenseModel.js index 45d3f63..7662bf7 100644 --- a/web/src/views/scripts/travelRequestDetailExpenseModel.js +++ b/web/src/views/scripts/travelRequestDetailExpenseModel.js @@ -183,6 +183,7 @@ export function buildFallbackProgressSteps(requestModel = {}) { const pendingPayment = approvalKey === 'pending_payment' || /待付款/.test(node) const paid = /已付款/.test(node) const completed = approvalKey === 'completed' || paid || /审批完成|申请完成|已完成/.test(node) + const archived = /申请归档|已归档/.test(node) const hasRelatedApplication = Boolean(requestModel?.relatedApplication?.claimNo) if (isApplicationDocumentRequest(requestModel)) { @@ -207,11 +208,19 @@ export function buildFallbackProgressSteps(requestModel = {}) { }, { index: 3, - label: '审批完成', - time: completed ? '已完成' : '待处理', - done: completed, - active: completed, - current: false + label: '关联单据状态', + time: hasRelatedApplication ? '已关联' : completed ? '未关联' : '待处理', + done: archived, + active: completed || archived, + 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) { const issues = [] + const isApplication = isApplicationDocumentRequest(request) const locationRequired = isLocationRequiredExpenseType(request.typeCode) const normalizedItems = Array.isArray(expenseItems) ? expenseItems : [] const itemAmountTotal = normalizedItems.reduce((sum, item) => { @@ -611,6 +621,25 @@ export function buildDraftBlockingIssues(request, expenseItems) { if (isPlaceholderValue(request.profileName)) { 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) { issues.push('报销类型未完善') } @@ -657,18 +686,30 @@ export function mapIssueToAdvice(issue) { if (text === '报销类型未完善') { return '选择报销类型,明确本次费用归类。' } + if (text === '申请类型未完善') { + return '补充申请类型,明确本次申请的费用或业务场景。' + } if (text === '报销事由未完善') { return '补充报销事由,说明本次费用用途。' } + if (text === '申请事由未完善') { + return '补充申请事由,说明本次申请的业务背景。' + } if (text === '业务地点未完善') { return '补充业务地点,方便审核业务发生场景。' } if (text === '发生时间未完善') { return '补充费用发生时间,确保单据时间完整。' } + if (text === '申请时间未完善') { + return '补充申请时间或行程时间,确保申请周期完整。' + } if (text === '报销金额未完善') { return '补充报销金额,并与费用明细金额保持一致。' } + if (text === '预计总费用未完善') { + return '补充预计总费用,供审批人判断预算占用和申请额度。' + } const itemMatch = text.match(/^费用明细第\s*(\d+)\s*条(.+)$/) if (!itemMatch) { diff --git a/web/tests/accessControl.test.mjs b/web/tests/accessControl.test.mjs index 38ff662..0960171 100644 --- a/web/tests/accessControl.test.mjs +++ b/web/tests/accessControl.test.mjs @@ -87,7 +87,7 @@ test('platform admin users do not enter the personal workbench', () => { assert.equal(canAccessAppView(adminUser, 'workbench'), false) assert.equal(canAccessAppView(employeeUser, 'workbench'), true) assert.equal(getAccessibleViewIds(adminUser).includes('workbench'), false) - assert.deepEqual(resolveDefaultAuthorizedRoute(adminUser), { name: 'app-overview' }) + assert.deepEqual(resolveDefaultAuthorizedRoute(adminUser), { name: 'app-documents' }) assert.deepEqual( filterNavItemsByAccess(navItems, adminUser).map((item) => item.id), ['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) }) +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', () => { const currentUser = { username: 'caoxiaozhu@xf.com', diff --git a/web/tests/app-shell-financial-assistant-entry.test.mjs b/web/tests/app-shell-financial-assistant-entry.test.mjs index ccb951f..b2d5153 100644 --- a/web/tests/app-shell-financial-assistant-entry.test.mjs +++ b/web/tests/app-shell-financial-assistant-entry.test.mjs @@ -27,6 +27,10 @@ const appShellComposable = readFileSync( fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)), 'utf8' ) +const requestsComposable = readFileSync( + fileURLToPath(new URL('../src/composables/useRequests.js', import.meta.url)), + 'utf8' +) const assistantScript = readFileSync( fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)), 'utf8' @@ -70,6 +74,47 @@ test('documents center reloads immediately when entered or clicked again', () => assert.match(appShellComposable, /reloadDocumentCenterRequests,/) }) +test('documents center uses the full request list instead of the global date-filtered list', () => { + assert.match( + appShellRouteView, + / 0"/ + ) + assert.doesNotMatch( + appShellRouteView, + / { + 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, / { + 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', () => { assert.match( 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', () => { - 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, /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, /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 \|\| ''\)\)/) }) diff --git a/web/tests/documents-center-status-filter.test.mjs b/web/tests/documents-center-status-filter.test.mjs index fad3bea..7261104 100644 --- a/web/tests/documents-center-status-filter.test.mjs +++ b/web/tests/documents-center-status-filter.test.mjs @@ -16,6 +16,14 @@ const documentListSharedStyles = readFileSync( fileURLToPath(new URL('../src/assets/styles/components/document-list-shared.css', import.meta.url)), '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', () => { assert.match(documentsCenterView, /